diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index cccf8c4..f7cf9c4 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -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: @@ -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}') + 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: @@ -56,9 +72,11 @@ 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 @@ -66,53 +84,166 @@ jobs: 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' }} + 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" diff --git a/package-lock.json b/package-lock.json index 820cdbd..d987256 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@agent-relay/factory", - "version": "0.1.57", + "version": "0.1.58", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@agent-relay/factory", - "version": "0.1.57", + "version": "0.1.58", "license": "Apache-2.0", "dependencies": { "@agent-relay/cloud": "^10.6.4", diff --git a/scripts/release-state.mjs b/scripts/release-state.mjs new file mode 100644 index 0000000..bd88e95 --- /dev/null +++ b/scripts/release-state.mjs @@ -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 ?? ''}`) + } + 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 + } +} diff --git a/scripts/verify-release-payload.sh b/scripts/verify-release-payload.sh new file mode 100755 index 0000000..73d899e --- /dev/null +++ b/scripts/verify-release-payload.sh @@ -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" + +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:-}" >&2 + exit 1 +fi diff --git a/src/release-state.test.ts b/src/release-state.test.ts new file mode 100644 index 0000000..4dd7bc6 --- /dev/null +++ b/src/release-state.test.ts @@ -0,0 +1,109 @@ +import { readFileSync } from 'node:fs' +import { execFileSync } from 'node:child_process' +import { describe, expect, it } from 'vitest' + +import { planReleaseState } from '../scripts/release-state.mjs' + +const base = { + head: 'head-sha', + tagTarget: '', + published: false, + tagPayloadMatches: false, + registryPayloadMatches: false, +} + +describe('release state recovery', () => { + it('is importable when the host process has no script path', () => { + expect(() => execFileSync(process.execPath, [ + '--input-type=module', + '--eval', + "process.argv.splice(1); await import('./scripts/release-state.mjs')", + ])).not.toThrow() + }) + + it('starts a new release by creating its tag before publishing', () => { + expect(planReleaseState(base)).toEqual({ + state: 'new-release', + createTag: true, + publish: true, + }) + }) + + it('resumes publishing when a matching tag already exists', () => { + expect(planReleaseState({ ...base, tagTarget: 'head-sha' })).toEqual({ + state: 'resume-after-tag', + createTag: false, + publish: true, + }) + }) + + it('recovers a missing tag only after verifying the published payload', () => { + expect(planReleaseState({ + ...base, + published: true, + registryPayloadMatches: true, + })).toEqual({ + state: 'recover-missing-tag', + createTag: true, + publish: false, + }) + }) + + it('treats a fully matching published release as an idempotent no-op', () => { + expect(planReleaseState({ + ...base, + published: true, + registryPayloadMatches: true, + tagTarget: 'head-sha', + })).toEqual({ state: 'complete', createTag: false, publish: false }) + }) + + it('accepts the historical 0.1.58 tag only when its payload is equivalent', () => { + expect(planReleaseState({ + ...base, + published: true, + registryPayloadMatches: true, + tagTarget: 'release-commit', + tagPayloadMatches: true, + })).toEqual({ + state: 'complete-equivalent-legacy-tag', + createTag: false, + publish: false, + }) + }) + + it('fails closed for mismatched registry payloads and tag targets', () => { + expect(() => planReleaseState({ + ...base, + published: true, + registryPayloadMatches: false, + })).toThrow(/payload or provenance/) + expect(() => planReleaseState({ ...base, tagTarget: 'other-sha' })).toThrow(/other-sha/) + }) +}) + +describe('publish workflow policy', () => { + const workflow = readFileSync('.github/workflows/publish.yml', 'utf8') + + it('uses a protected-branch-safe version PR and never pushes HEAD to main', () => { + expect(workflow).toContain('pull-requests: write') + expect(workflow).toContain('name: Require current main for a live release') + expect(workflow).toContain('REMOTE_MAIN') + expect(workflow).toContain('name: Open version PR') + expect(workflow).toContain('gh pr create') + expect(workflow).toContain('git add package.json package-lock.json') + expect(workflow).not.toContain('git push origin HEAD --follow-tags') + expect(workflow).toContain('git push origin "refs/tags/v$V"') + }) + + it('gates publish on canonical version metadata and the recovery plan', () => { + expect(workflow).toContain("steps.bump.outputs.needs_version_pr != 'true'") + expect(workflow).toContain('node scripts/release-state.mjs') + expect(workflow.match(/scripts\/verify-release-payload\.sh/g)).toHaveLength(3) + expect(workflow).toContain('git worktree add --detach "$TAG_DIR" "$TAG_TARGET"') + expect(workflow).toContain("steps.release_state.outputs.publish == 'true'") + expect(workflow.indexOf('- name: Create release tag')).toBeLessThan( + workflow.indexOf('- name: Publish\n'), + ) + }) +})