Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
183 changes: 157 additions & 26 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,14 @@ on:
type: choice
options: [latest, next, beta, alpha]
dry_run:
description: 'Dry run (no publish, no version commit, no tag)'
description: 'Dry run (no publish, no version PR, no tag)'
required: true
default: false
type: boolean

permissions:
contents: write
pull-requests: write
id-token: write # required for npm OIDC provenance (trusted publisher)

concurrency:
Expand All @@ -41,6 +42,21 @@ jobs:
with:
fetch-depth: 0

- name: Require current main for a live release
if: ${{ github.event.inputs.dry_run != 'true' }}
shell: bash
run: |
set -euo pipefail
if [ "$GITHUB_REF" != "refs/heads/main" ]; then
echo "::error::Live releases must be dispatched from main, not $GITHUB_REF"
exit 1
fi
REMOTE_MAIN=$(git ls-remote origin refs/heads/main | awk '{print $1}')

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: A merge to main during the workflow can make this check stale, while the later tag and npm publish still use $GITHUB_SHA; the job can therefore release a commit that is no longer current origin/main. Revalidation immediately before both side effects, together with serialization or an atomic head check, would preserve the canonical-main guarantee.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At .github/workflows/publish.yml, line 54:

<comment>A merge to main during the workflow can make this check stale, while the later tag and npm publish still use `$GITHUB_SHA`; the job can therefore release a commit that is no longer current `origin/main`. Revalidation immediately before both side effects, together with serialization or an atomic head check, would preserve the canonical-main guarantee.</comment>

<file context>
@@ -42,6 +42,21 @@ jobs:
+            echo "::error::Live releases must be dispatched from main, not $GITHUB_REF"
+            exit 1
+          fi
+          REMOTE_MAIN=$(git ls-remote origin refs/heads/main | awk '{print $1}')
+          if [ -z "$REMOTE_MAIN" ] || [ "$REMOTE_MAIN" != "$GITHUB_SHA" ]; then
+            echo "::error::Checked-out SHA $GITHUB_SHA is not current origin/main ($REMOTE_MAIN)"
</file context>

if [ -z "$REMOTE_MAIN" ] || [ "$REMOTE_MAIN" != "$GITHUB_SHA" ]; then
echo "::error::Checked-out SHA $GITHUB_SHA is not current origin/main ($REMOTE_MAIN)"
exit 1
fi

- name: Setup Node
uses: actions/setup-node@v6
with:
Expand All @@ -56,63 +72,178 @@ jobs:
- name: Test
run: npm run test --if-present

- name: Bump version
- name: Resolve version
id: bump
shell: bash
run: |
ORIGINAL=$(node -p "require('./package.json').version")
CUSTOM='${{ github.event.inputs.custom_version }}'
BUMP='${{ github.event.inputs.version }}'
if [ -n "$CUSTOM" ]; then
npm version "$CUSTOM" --no-git-tag-version --allow-same-version
elif [ "$BUMP" != "none" ]; then
npm version "$BUMP" --no-git-tag-version
fi
echo "version=$(node -p "require('./package.json').version")" >> "$GITHUB_OUTPUT"
V=$(node -p "require('./package.json').version")
echo "version=$V" >> "$GITHUB_OUTPUT"
if [ "$ORIGINAL" = "$V" ]; then
echo "needs_version_pr=false" >> "$GITHUB_OUTPUT"
else
echo "needs_version_pr=true" >> "$GITHUB_OUTPUT"
fi

# Protected main requires a PR. A real release never publishes until its
# exact version is already canonical on main. Re-run with version=none
# after this PR merges to perform the release.
- name: Open version PR
if: ${{ github.event.inputs.dry_run != 'true' && steps.bump.outputs.needs_version_pr == 'true' }}
env:
GH_TOKEN: ${{ github.token }}
shell: bash
run: |
set -euo pipefail
V='${{ steps.bump.outputs.version }}'
BRANCH="release/v$V"
if git ls-remote --exit-code --heads origin "$BRANCH" >/dev/null 2>&1; then
git fetch origin "$BRANCH"
REMOTE_VERSION=$(git show FETCH_HEAD:package.json | node -e \
"let s='';process.stdin.on('data',d=>s+=d).on('end',()=>console.log(JSON.parse(s).version))")
if [ "$REMOTE_VERSION" != "$V" ]; then
echo "::error::Existing $BRANCH contains version $REMOTE_VERSION, expected $V"
exit 1
fi
else
git switch -c "$BRANCH"
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add package.json package-lock.json
git commit -m "chore(release): @agent-relay/factory@$V"
git push origin "HEAD:refs/heads/$BRANCH"
fi
PR_URL=$(gh pr list --repo "$GITHUB_REPOSITORY" --state open \
--head "$BRANCH" --json url --jq '.[0].url // empty')
if [ -z "$PR_URL" ]; then
PR_URL=$(gh pr create --repo "$GITHUB_REPOSITORY" --base main \
--head "$BRANCH" --title "chore(release): @agent-relay/factory@$V" \
--body "Canonical version metadata for @agent-relay/factory@$V. After merge, re-run Publish with version=none.")
fi
echo "Prepared $PR_URL; nothing was published or tagged." >> "$GITHUB_STEP_SUMMARY"

- name: Verify packed end-to-end lifecycle
if: ${{ github.event.inputs.dry_run == 'true' || steps.bump.outputs.needs_version_pr != 'true' }}
run: npm run verify:e2e
env:
FACTORY_E2E_HEAD_SHA: ${{ github.sha }}

- name: Upload release E2E attestation
if: ${{ github.event.inputs.dry_run == 'true' || steps.bump.outputs.needs_version_pr != 'true' }}
uses: actions/upload-artifact@v6
with:
name: factory-release-e2e-${{ github.sha }}
path: artifacts/factory-e2e-attestation.json
if-no-files-found: error

- name: Verify version is unpublished
- name: Dry-run package
if: ${{ github.event.inputs.dry_run == 'true' }}
run: npm publish --dry-run --access public --tag '${{ github.event.inputs.tag }}'

- name: Inspect release state
id: release_state
if: ${{ github.event.inputs.dry_run != 'true' && steps.bump.outputs.needs_version_pr != 'true' }}
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
shell: bash
run: |
set -euo pipefail
V='${{ steps.bump.outputs.version }}'
if [ -n "$(npm view @agent-relay/factory@"$V" version 2>/dev/null || true)" ]; then
echo "::error::@agent-relay/factory@$V is already on npm. Pick a higher version."; exit 1
PUBLISHED=false
if [ "$(npm view @agent-relay/factory@"$V" version 2>/dev/null || true)" = "$V" ]; then
PUBLISHED=true
DIST_TAG=$(npm view @agent-relay/factory \
'dist-tags.${{ github.event.inputs.tag }}' 2>/dev/null || true)
if [ "$DIST_TAG" != "$V" ]; then
echo "::error::npm dist-tag ${{ github.event.inputs.tag }} points to $DIST_TAG, expected $V"
exit 1
fi
fi

DIRECT=$(git ls-remote origin "refs/tags/v$V" | awk '{print $1}')
PEELED=$(git ls-remote origin "refs/tags/v$V^{}" | awk '{print $1}')
TAG_TARGET=${PEELED:-$DIRECT}
TAG_PAYLOAD_MATCHES=false
if [ "$PUBLISHED" = true ] && [ -n "$TAG_TARGET" ] && [ "$TAG_TARGET" != "$GITHUB_SHA" ]; then
# The 0.1.58 incident left a release tag on an equivalent orphaned
# commit. Rebuild that exact tag and compare its packed payload to
# npm; source-path approximations are not sufficient here.
TAG_ROOT=$(mktemp -d)
TAG_DIR="$TAG_ROOT/checkout"
if git worktree add --detach "$TAG_DIR" "$TAG_TARGET" && \
(cd "$TAG_DIR" && npm ci && npm run build) && \
scripts/verify-release-payload.sh "$V" "$TAG_DIR"; then
TAG_PAYLOAD_MATCHES=true
fi
git worktree remove --force "$TAG_DIR" 2>/dev/null || true
rm -rf "$TAG_ROOT"
fi
echo "@agent-relay/factory@$V: unpublished — OK"

REGISTRY_PAYLOAD_MATCHES=false
if [ "$PUBLISHED" = true ]; then
if scripts/verify-release-payload.sh "$V" "$GITHUB_WORKSPACE"; then
REGISTRY_PAYLOAD_MATCHES=true
fi
fi

PLAN=$(node scripts/release-state.mjs \
--published "$PUBLISHED" \
--tag-target "$TAG_TARGET" \
--head "$GITHUB_SHA" \
--tag-payload-matches "$TAG_PAYLOAD_MATCHES" \
--registry-payload-matches "$REGISTRY_PAYLOAD_MATCHES")
echo "$PLAN"
echo "create_tag=$(node -e "const p=JSON.parse(process.argv[1]);console.log(p.createTag)" "$PLAN")" >> "$GITHUB_OUTPUT"
echo "publish=$(node -e "const p=JSON.parse(process.argv[1]);console.log(p.publish)" "$PLAN")" >> "$GITHUB_OUTPUT"
echo "state=$(node -e "const p=JSON.parse(process.argv[1]);console.log(p.state)" "$PLAN")" >> "$GITHUB_OUTPUT"

# Tagging a commit is allowed on protected main. Doing it before npm makes
# either partial failure safely resumable: a rerun publishes an existing
# matching tag, or restores a missing tag for an identical npm payload.
- name: Create release tag
if: ${{ steps.release_state.outputs.create_tag == 'true' }}
shell: bash
run: |
V='${{ steps.bump.outputs.version }}'
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git tag -a "v$V" "$GITHUB_SHA" -m "@agent-relay/factory@$V"
git push origin "refs/tags/v$V"

# npm >= 11.5.1 for the OIDC trusted-publisher (provenance) flow.
- name: Update npm for OIDC support
# npm 12 requires Node >=22.22.2, while this workflow uses Node 22.14.0.
# npm 11 supports trusted publishing and remains compatible with Node 22.14.
if: ${{ steps.release_state.outputs.publish == 'true' }}
run: npm install -g npm@11.18.0

# No NPM_TOKEN: relies on npm OIDC trusted-publisher. Register this
# repo/workflow as a trusted publisher for @agent-relay/factory on
# npmjs.com before the first CI publish.
# repo/workflow as a trusted publisher for @agent-relay/factory on npmjs.com.
- name: Publish
run: |
FLAGS=(--access public --tag "${{ github.event.inputs.tag }}")
if [ "${{ github.event.inputs.dry_run }}" = "true" ]; then
npm publish --dry-run "${FLAGS[@]}"
else
npm publish --provenance "${FLAGS[@]}"
fi
if: ${{ steps.release_state.outputs.publish == 'true' }}
run: npm publish --provenance --access public --tag '${{ github.event.inputs.tag }}'

- name: Commit + tag + push
if: ${{ github.event.inputs.dry_run != 'true' && (github.event.inputs.version != 'none' || github.event.inputs.custom_version != '') }}
- name: Verify final release state
if: ${{ github.event.inputs.dry_run != 'true' && steps.bump.outputs.needs_version_pr != 'true' }}
shell: bash
run: |
set -euo pipefail
V='${{ steps.bump.outputs.version }}'
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add package.json
git commit -m "chore(release): @agent-relay/factory@$V" || echo "nothing to commit"
git tag -a "v$V" -m "@agent-relay/factory@$V"
git push origin HEAD --follow-tags
for attempt in 1 2 3 4 5; do
[ "$(npm view @agent-relay/factory@"$V" version 2>/dev/null || true)" = "$V" ] && break
sleep $((attempt * 2))
done
[ "$(npm view @agent-relay/factory@"$V" version)" = "$V" ]
[ "$(npm view @agent-relay/factory 'dist-tags.${{ github.event.inputs.tag }}')" = "$V" ]
TAG_TARGET=$(git ls-remote origin "refs/tags/v$V^{}" | awk '{print $1}')
if [ -z "$TAG_TARGET" ]; then
TAG_TARGET=$(git ls-remote origin "refs/tags/v$V" | awk '{print $1}')
fi
[ -n "$TAG_TARGET" ]

scripts/verify-release-payload.sh "$V" "$GITHUB_WORKSPACE"
echo "Release state: ${{ steps.release_state.outputs.state }}" >> "$GITHUB_STEP_SUMMARY"
echo "npm payload/provenance/dist-tag and v$V are consistent; package metadata was canonical before publish." >> "$GITHUB_STEP_SUMMARY"
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

80 changes: 80 additions & 0 deletions scripts/release-state.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
#!/usr/bin/env node

import { pathToFileURL } from 'node:url'

function parseBoolean(value, name) {
if (value === 'true') return true
if (value === 'false') return false
throw new Error(`${name} must be true or false`)
}

export function planReleaseState({
published,
tagTarget,
head,
tagPayloadMatches,
registryPayloadMatches,
}) {
if (!head) throw new Error('head is required')

if (published && !registryPayloadMatches) {
throw new Error('published package payload or provenance does not match this checkout')
}

if (!tagTarget) {
return {
state: published ? 'recover-missing-tag' : 'new-release',
createTag: true,
publish: !published,
}
}

if (tagTarget === head) {
return {
state: published ? 'complete' : 'resume-after-tag',
createTag: false,
publish: !published,
}
}

if (published && tagPayloadMatches) {
return {
state: 'complete-equivalent-legacy-tag',
createTag: false,
publish: false,
}
}

throw new Error(`release tag points to ${tagTarget}, not ${head}`)
}

function readArgs(argv) {
const values = new Map()
for (let index = 0; index < argv.length; index += 2) {
const key = argv[index]
const value = argv[index + 1]
if (!key?.startsWith('--') || value === undefined) {
throw new Error(`invalid argument near ${key ?? '<end>'}`)
}
values.set(key.slice(2), value)
}
return {
published: parseBoolean(values.get('published'), 'published'),
tagTarget: values.get('tag-target') ?? '',
head: values.get('head') ?? '',
tagPayloadMatches: parseBoolean(values.get('tag-payload-matches'), 'tag-payload-matches'),
registryPayloadMatches: parseBoolean(
values.get('registry-payload-matches'),
'registry-payload-matches',
),
}
}

if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
try {
console.log(JSON.stringify(planReleaseState(readArgs(process.argv.slice(2)))))
} catch (error) {
console.error(error instanceof Error ? error.message : String(error))
process.exitCode = 1
}
}
22 changes: 22 additions & 0 deletions scripts/verify-release-payload.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
#!/usr/bin/env bash
set -euo pipefail

VERSION=${1:?usage: verify-release-payload.sh VERSION [LOCAL_PACKAGE_DIR]}
LOCAL_PACKAGE_DIR=${2:-$PWD}
PACKAGE_NAME=@agent-relay/factory
TMP_DIR=$(mktemp -d)
trap 'rm -rf "$TMP_DIR"' EXIT

mkdir "$TMP_DIR/local" "$TMP_DIR/registry" "$TMP_DIR/local-x" "$TMP_DIR/registry-x"
(cd "$LOCAL_PACKAGE_DIR" && npm pack --pack-destination "$TMP_DIR/local" --silent >/dev/null)
npm pack "$PACKAGE_NAME@$VERSION" --pack-destination "$TMP_DIR/registry" --silent >/dev/null
tar -xzf "$TMP_DIR/local"/*.tgz -C "$TMP_DIR/local-x"
tar -xzf "$TMP_DIR/registry"/*.tgz -C "$TMP_DIR/registry-x"
diff -qr "$TMP_DIR/local-x/package" "$TMP_DIR/registry-x/package"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Release recovery can accept an npm payload whose executable bits differ from this checkout because diff -qr does not compare permissions. Comparing file modes as well would prevent a legacy tag or existing release with different executable behavior from being treated as equivalent.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At scripts/verify-release-payload.sh, line 15:

<comment>Release recovery can accept an npm payload whose executable bits differ from this checkout because `diff -qr` does not compare permissions. Comparing file modes as well would prevent a legacy tag or existing release with different executable behavior from being treated as equivalent.</comment>

<file context>
@@ -0,0 +1,22 @@
+npm pack "$PACKAGE_NAME@$VERSION" --pack-destination "$TMP_DIR/registry" --silent >/dev/null
+tar -xzf "$TMP_DIR/local"/*.tgz -C "$TMP_DIR/local-x"
+tar -xzf "$TMP_DIR/registry"/*.tgz -C "$TMP_DIR/registry-x"
+diff -qr "$TMP_DIR/local-x/package" "$TMP_DIR/registry-x/package"
+
+PROVENANCE=$(npm view "$PACKAGE_NAME@$VERSION" \
</file context>


PROVENANCE=$(npm view "$PACKAGE_NAME@$VERSION" \
dist.attestations.provenance.predicateType 2>/dev/null || true)
if [ "$PROVENANCE" != "https://slsa.dev/provenance/v1" ]; then
echo "Expected SLSA provenance for $PACKAGE_NAME@$VERSION, got: ${PROVENANCE:-<missing>}" >&2
exit 1
fi
Loading