diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index f7cf9c4..b898cf1 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -42,20 +42,12 @@ jobs: with: fetch-depth: 0 - - name: Require current main for a live release + # A rerun keeps its original GITHUB_SHA. Require the main ref here, then + # require the current main SHA only for fresh side effects below. Verified + # recovery states remain resumable if unrelated commits advance main. + - name: Require main branch 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 + run: scripts/require-current-main.sh --ref-only - name: Setup Node uses: actions/setup-node@v6 @@ -207,9 +199,14 @@ jobs: # matching tag, or restores a missing tag for an identical npm payload. - name: Create release tag if: ${{ steps.release_state.outputs.create_tag == 'true' }} + env: + RELEASE_STATE: ${{ steps.release_state.outputs.state }} shell: bash run: | V='${{ steps.bump.outputs.version }}' + if [ "$RELEASE_STATE" = "new-release" ]; then + scripts/require-current-main.sh + fi 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" @@ -224,7 +221,14 @@ jobs: # repo/workflow as a trusted publisher for @agent-relay/factory on npmjs.com. - name: Publish if: ${{ steps.release_state.outputs.publish == 'true' }} - run: npm publish --provenance --access public --tag '${{ github.event.inputs.tag }}' + env: + NPM_DIST_TAG: ${{ github.event.inputs.tag }} + RELEASE_STATE: ${{ steps.release_state.outputs.state }} + run: | + if [ "$RELEASE_STATE" = "new-release" ]; then + scripts/require-current-main.sh + fi + npm publish --provenance --access public --tag "$NPM_DIST_TAG" - name: Verify final release state if: ${{ github.event.inputs.dry_run != 'true' && steps.bump.outputs.needs_version_pr != 'true' }} diff --git a/scripts/compare-package-trees.mjs b/scripts/compare-package-trees.mjs new file mode 100755 index 0000000..913e33a --- /dev/null +++ b/scripts/compare-package-trees.mjs @@ -0,0 +1,82 @@ +#!/usr/bin/env node + +import { lstat, readdir, readFile, readlink } from 'node:fs/promises' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' + +export async function comparePackageTrees(leftRoot, rightRoot) { + const differences = [] + await compareEntry(leftRoot, rightRoot, '.', differences) + return differences +} + +async function compareEntry(leftPath, rightPath, relativePath, differences) { + const [left, right] = await Promise.all([ + lstat(leftPath).catch(() => undefined), + lstat(rightPath).catch(() => undefined), + ]) + if (!left || !right) { + differences.push(`${relativePath}: missing from ${left ? 'right' : 'left'} tree`) + return + } + + const leftType = fileType(left) + const rightType = fileType(right) + if (leftType !== rightType) { + differences.push(`${relativePath}: type ${leftType} != ${rightType}`) + return + } + + // npm tarballs preserve the special permission bits as well as rwx bits. + // Include setuid, setgid, and sticky so recovery cannot accept a payload + // whose effective package metadata differs from the registry artifact. + const leftMode = left.mode & 0o7777 + const rightMode = right.mode & 0o7777 + if (leftMode !== rightMode) { + differences.push( + `${relativePath}: mode ${leftMode.toString(8)} != ${rightMode.toString(8)}`, + ) + } + + if (left.isDirectory()) { + const [leftNames, rightNames] = await Promise.all([readdir(leftPath), readdir(rightPath)]) + const names = [...new Set([...leftNames, ...rightNames])].sort() + for (const name of names) { + await compareEntry( + join(leftPath, name), + join(rightPath, name), + relativePath === '.' ? name : `${relativePath}/${name}`, + differences, + ) + } + } else if (left.isSymbolicLink()) { + const [leftTarget, rightTarget] = await Promise.all([readlink(leftPath), readlink(rightPath)]) + if (leftTarget !== rightTarget) { + differences.push(`${relativePath}: symlink ${leftTarget} != ${rightTarget}`) + } + } else if (left.isFile()) { + const [leftBytes, rightBytes] = await Promise.all([readFile(leftPath), readFile(rightPath)]) + if (!leftBytes.equals(rightBytes)) differences.push(`${relativePath}: content differs`) + } +} + +function fileType(stat) { + if (stat.isDirectory()) return 'directory' + if (stat.isFile()) return 'file' + if (stat.isSymbolicLink()) return 'symlink' + return 'other' +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + const [, , leftRoot, rightRoot] = process.argv + if (!leftRoot || !rightRoot) { + console.error('usage: compare-package-trees.mjs LEFT_TREE RIGHT_TREE') + process.exitCode = 2 + } else { + const differences = await comparePackageTrees(leftRoot, rightRoot) + if (differences.length > 0) { + for (const difference of differences) console.error(difference) + process.exitCode = 1 + } + } +} diff --git a/scripts/require-current-main.sh b/scripts/require-current-main.sh new file mode 100755 index 0000000..368dcfc --- /dev/null +++ b/scripts/require-current-main.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [ "${GITHUB_REF:-}" != "refs/heads/main" ]; then + echo "::error::Live releases must be dispatched from main, not ${GITHUB_REF:-}" >&2 + exit 1 +fi + +if [ "$#" -gt 1 ]; then + echo "usage: $0 [--ref-only]" >&2 + exit 2 +fi + +if [ "${1:-}" = "--ref-only" ]; then + exit 0 +fi + +if [ "$#" -ne 0 ]; then + echo "usage: $0 [--ref-only]" >&2 + exit 2 +fi + +EXPECTED_SHA=${GITHUB_SHA:?GITHUB_SHA is required} +REMOTE_MAIN=$(git ls-remote origin refs/heads/main | awk '{print $1}') +if [ -z "$REMOTE_MAIN" ] || [ "$REMOTE_MAIN" != "$EXPECTED_SHA" ]; then + echo "::error::Checked-out SHA $EXPECTED_SHA is not current origin/main ($REMOTE_MAIN)" >&2 + exit 1 +fi diff --git a/scripts/verify-release-payload.sh b/scripts/verify-release-payload.sh index 73d899e..3641361 100755 --- a/scripts/verify-release-payload.sh +++ b/scripts/verify-release-payload.sh @@ -12,7 +12,8 @@ mkdir "$TMP_DIR/local" "$TMP_DIR/registry" "$TMP_DIR/local-x" "$TMP_DIR/registry 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" +node "$(dirname "$0")/compare-package-trees.mjs" \ + "$TMP_DIR/local-x/package" "$TMP_DIR/registry-x/package" PROVENANCE=$(npm view "$PACKAGE_NAME@$VERSION" \ dist.attestations.provenance.predicateType 2>/dev/null || true) diff --git a/src/release-state.test.ts b/src/release-state.test.ts index 4dd7bc6..7b6c73f 100644 --- a/src/release-state.test.ts +++ b/src/release-state.test.ts @@ -1,7 +1,11 @@ +import { chmod, mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises' import { readFileSync } from 'node:fs' import { execFileSync } from 'node:child_process' +import { join } from 'node:path' +import { tmpdir } from 'node:os' import { describe, expect, it } from 'vitest' +import { comparePackageTrees } from '../scripts/compare-package-trees.mjs' import { planReleaseState } from '../scripts/release-state.mjs' const base = { @@ -82,13 +86,73 @@ describe('release state recovery', () => { }) }) +describe('packed payload comparison', () => { + it('compares file contents and all permission modes', async () => { + const root = await mkdtemp(join(tmpdir(), 'factory-release-payload-')) + const left = join(root, 'left') + const right = join(root, 'right') + try { + await Promise.all([mkdir(left), mkdir(right)]) + await Promise.all([ + writeFile(join(left, 'cli'), '#!/bin/sh\n'), + writeFile(join(right, 'cli'), '#!/bin/sh\n'), + ]) + await Promise.all([chmod(join(left, 'cli'), 0o755), chmod(join(right, 'cli'), 0o644)]) + expect(await comparePackageTrees(left, right)).toContain('cli: mode 755 != 644') + await chmod(join(right, 'cli'), 0o755) + expect(await comparePackageTrees(left, right)).toEqual([]) + await chmod(join(left, 'cli'), 0o4755) + expect(await comparePackageTrees(left, right)).toContain('cli: mode 4755 != 755') + await chmod(join(right, 'cli'), 0o4755) + expect(await comparePackageTrees(left, right)).toEqual([]) + await writeFile(join(right, 'cli'), '#!/bin/false\n') + expect(await comparePackageTrees(left, right)).toContain('cli: content differs') + } finally { + await rm(root, { recursive: true, force: true }) + } + }) +}) + +describe('require-current-main.sh argument validation', () => { + const run = (args) => + execFileSync('bash', ['scripts/require-current-main.sh', ...args], { + env: { ...process.env, GITHUB_REF: 'refs/heads/main' }, + encoding: 'utf8', + }) + + it('accepts --ref-only alone without contacting the remote', () => { + expect(run(['--ref-only'])).toBe('') + }) + + it('rejects --ref-only with trailing arguments instead of ignoring them', () => { + try { + run(['--ref-only', 'extra']) + expect.unreachable('expected require-current-main.sh to exit non-zero') + } catch (error) { + expect(error.status).toBe(2) + expect(error.stderr.toString()).toContain('usage:') + } + }) + + it('rejects unrecognized single arguments', () => { + try { + run(['--bogus']) + expect.unreachable('expected require-current-main.sh to exit non-zero') + } catch (error) { + expect(error.status).toBe(2) + expect(error.stderr.toString()).toContain('usage:') + } + }) +}) + 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: Require main branch for a live release') + expect(workflow.match(/scripts\/require-current-main\.sh/g)).toHaveLength(3) + expect(workflow).toContain('scripts/require-current-main.sh --ref-only') expect(workflow).toContain('name: Open version PR') expect(workflow).toContain('gh pr create') expect(workflow).toContain('git add package.json package-lock.json') @@ -102,6 +166,11 @@ describe('publish workflow policy', () => { 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.match(/RELEASE_STATE: \$\{\{ steps\.release_state\.outputs\.state \}\}/g)) + .toHaveLength(2) + expect(workflow.match(/\[ "\$RELEASE_STATE" = "new-release" \]/g)).toHaveLength(2) + expect(workflow).toContain('NPM_DIST_TAG: ${{ github.event.inputs.tag }}') + expect(workflow).toContain('npm publish --provenance --access public --tag "$NPM_DIST_TAG"') expect(workflow.indexOf('- name: Create release tag')).toBeLessThan( workflow.indexOf('- name: Publish\n'), )