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
32 changes: 18 additions & 14 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand All @@ -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' }}
Expand Down
82 changes: 82 additions & 0 deletions scripts/compare-package-trees.mjs
Original file line number Diff line number Diff line change
@@ -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
}
}
}
28 changes: 28 additions & 0 deletions scripts/require-current-main.sh
Original file line number Diff line number Diff line change
@@ -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:-<unset>}" >&2
exit 1
fi

if [ "$#" -gt 1 ]; then
echo "usage: $0 [--ref-only]" >&2
exit 2
fi

if [ "${1:-}" = "--ref-only" ]; then
Comment thread
miyaontherelay marked this conversation as resolved.
exit 0
fi

if [ "$#" -ne 0 ]; then
echo "usage: $0 [--ref-only]" >&2
exit 2
fi

EXPECTED_SHA=${GITHUB_SHA:?GITHUB_SHA is required}
Comment thread
miyaontherelay marked this conversation as resolved.
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
3 changes: 2 additions & 1 deletion scripts/verify-release-payload.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
73 changes: 71 additions & 2 deletions src/release-state.test.ts
Original file line number Diff line number Diff line change
@@ -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 = {
Expand Down Expand Up @@ -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')
Expand All @@ -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'),
)
Expand Down