From 23d2002f4354327823877543ac42bb71582a99bb Mon Sep 17 00:00:00 2001 From: Drew Stone Date: Tue, 28 Jul 2026 20:37:33 -0600 Subject: [PATCH] fix(release): publish exact tested archives --- .github/workflows/ci.yml | 11 ++ .github/workflows/publish.yml | 205 ++++++++++++++++------- bench/scripts/verify-packed-consumer.mjs | 53 ++++-- package.json | 4 +- pnpm-lock.yaml | 3 + scripts/check-publish-workflow.mjs | 124 ++++++++++++++ scripts/verify-edge-tool-loop.mjs | 20 ++- 7 files changed, 340 insertions(+), 80 deletions(-) create mode 100644 scripts/check-publish-workflow.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a3f4fa5a..1d048fe1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,6 +14,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 @@ -33,6 +35,9 @@ jobs: - name: Install deps run: pnpm install --frozen-lockfile + - name: Check publish credential isolation + run: pnpm run check:publish-workflow + - name: Lint (biome) run: pnpm run lint @@ -59,6 +64,8 @@ jobs: timeout-minutes: 30 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -96,6 +103,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 @@ -119,6 +128,8 @@ jobs: timeout-minutes: 60 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 031c7057..f9c9c9a8 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -20,11 +20,13 @@ jobs: if: startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch' runs-on: ubuntu-latest outputs: - release-sha: ${{ steps.release.outputs.sha }} + release-version: ${{ steps.release.outputs.version }} + package-sha256: ${{ steps.package.outputs.sha256 }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ github.event_name == 'workflow_dispatch' && inputs.release_tag || github.ref }} + persist-credentials: false - name: Verify release source and version id: release @@ -50,7 +52,7 @@ jobs: echo "::error::Tag/version mismatch: tag=$TAG_VERSION package=$NPM_VERSION." exit 1 fi - echo "sha=$TAG_SHA" >> "$GITHUB_OUTPUT" + echo "version=$NPM_VERSION" >> "$GITHUB_OUTPUT" echo "Release source locked: $RELEASE_TAG at $TAG_SHA" - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 @@ -82,7 +84,8 @@ jobs: # ship a fix to a consumer pinned to an older minor). Comparing SHAs needs no # shared history, so a shallow fetch is sufficient. - name: Verify tag is the tip of its release line - if: startsWith(github.ref, 'refs/tags/v') + env: + RELEASE_TAG: ${{ github.event_name == 'workflow_dispatch' && inputs.release_tag || github.ref_name }} run: | TAG_SHA=$(git rev-parse HEAD) git fetch --no-tags --depth=100 origin +refs/heads/main:refs/remotes/origin/main @@ -91,7 +94,7 @@ jobs: echo "Tag is the tip of main: $TAG_SHA" exit 0 fi - VERSION="${GITHUB_REF#refs/tags/v}" + VERSION="${RELEASE_TAG#v}" LINE="release/${VERSION%.*}.x" if git fetch --no-tags --depth=100 origin "+refs/heads/$LINE:refs/remotes/origin/$LINE" 2>/dev/null; then LINE_SHA=$(git rev-parse "refs/remotes/origin/$LINE") @@ -168,6 +171,33 @@ jobs: - name: Verify agent-bench against this release run: pnpm run verify:bench + - name: Package and verify the exact Runtime archive + id: package + run: | + package_dir="$RUNNER_TEMP/agent-runtime-package" + mkdir -p "$package_dir" + npm_config_ignore_scripts=true pnpm pack --pack-destination "$package_dir" + mapfile -t packages < <(find "$package_dir" -maxdepth 1 -type f -name '*.tgz' -print) + if [ "${#packages[@]}" -ne 1 ]; then + echo "::error::Expected one Runtime archive, found ${#packages[@]}." + exit 1 + fi + package="${packages[0]}" + node scripts/verify-package-exports.mjs "$package" + node scripts/verify-edge-tool-loop.mjs "$package" + sha256=$(sha256sum "$package" | cut -d' ' -f1) + echo "sha256=$sha256" >> "$GITHUB_OUTPUT" + echo "Runtime archive locked: $package ($sha256)" + + - name: Upload exact Runtime archive + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: agent-runtime-package + path: ${{ runner.temp }}/agent-runtime-package/*.tgz + if-no-files-found: error + compression-level: 0 + retention-days: 1 + publish-npm: needs: verify if: startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch' @@ -175,55 +205,65 @@ jobs: permissions: contents: read id-token: write + env: + EXPECTED_NAME: '@tangle-network/agent-runtime' + EXPECTED_VERSION: ${{ needs.verify.outputs.release-version }} + EXPECTED_SHA256: ${{ needs.verify.outputs.package-sha256 }} steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: ${{ needs.verify.outputs.release-sha }} - - - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: 22 - cache: pnpm + node-version: 24.18.0 - - run: pnpm install --frozen-lockfile - - run: pnpm run build + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: agent-runtime-package + path: ${{ runner.temp }}/agent-runtime-package - # Tokenless OIDC trusted publishing requires npm >= 11.5.1. pnpm creates - # the release tarball because it resolves catalog dependencies; npm then - # publishes that exact, consumer-tested file. No setup-node registry-url: - # it writes an empty-authToken .npmrc that blocks OIDC. Idempotent. + # Node 24.18.0 bundles npm 11.16.0, which supports trusted publishing. + # This job has no source checkout and runs no dependency or package build. # Requires the npmjs Trusted Publisher: org tangle-network, repo # agent-runtime, workflow publish.yml. - - name: Publish to npm (OIDC trusted publishing) + - name: Publish exact Runtime archive run: | - # npm 12.0.0 ships a broken provenance publish (libnpmpublish requires - # sigstore without bundling it); OIDC needs npm >= 11.5.1, so pin major 11. - npm install -g npm@11 - NAME=$(node -p "require('./package.json').name") - VERSION=$(node -p "require('./package.json').version") - if npm view "$NAME@$VERSION" version >/dev/null 2>&1; then - echo "$NAME@$VERSION already on registry; skipping publish" + npm_version=$(npm --version) + if [[ "$npm_version" != 11.* ]]; then + echo "::error::Expected npm 11, received $npm_version." + exit 1 + fi + mapfile -t packages < <(find "$RUNNER_TEMP/agent-runtime-package" -maxdepth 1 -type f -name '*.tgz' -print) + if [ "${#packages[@]}" -ne 1 ]; then + echo "::error::Expected one Runtime archive, found ${#packages[@]}." + exit 1 + fi + package="${packages[0]}" + actual_sha256=$(sha256sum "$package" | cut -d' ' -f1) + if [ "$actual_sha256" != "$EXPECTED_SHA256" ]; then + echo "::error::Runtime archive digest mismatch." + exit 1 + fi + manifest=$(tar -xOzf "$package" package/package.json) + name=$(node -e 'process.stdout.write(JSON.parse(process.argv[1]).name)' "$manifest") + version=$(node -e 'process.stdout.write(JSON.parse(process.argv[1]).version)' "$manifest") + if [ "$name" != "$EXPECTED_NAME" ] || [ "$version" != "$EXPECTED_VERSION" ]; then + echo "::error::Runtime archive identity mismatch: $name@$version." + exit 1 + fi + if npm view "$name@$version" version --registry=https://registry.npmjs.org >/dev/null 2>&1; then + echo "$name@$version already on registry; skipping publish" else - mkdir -p "$RUNNER_TEMP/agent-runtime-package" - npm_config_ignore_scripts=true pnpm pack --pack-destination "$RUNNER_TEMP/agent-runtime-package" - package="$(find "$RUNNER_TEMP/agent-runtime-package" -maxdepth 1 -name '*.tgz' -print -quit)" - test -n "$package" - node scripts/verify-package-exports.mjs "$package" # npm refuses to implicitly move `latest` to a LOWER version, which is # exactly what a backport off release/..x is. When this # version is not the highest published, publish it under a line-scoped # dist-tag instead. Dist-tags do not affect semver range resolution, so # a consumer pinned to ^0.84.0 still resolves 0.84.1. - LATEST=$(npm view "$NAME" version 2>/dev/null || true) - if [ -n "$LATEST" ] && \ - [ "$(printf '%s\n%s\n' "$LATEST" "$VERSION" | sort -V | tail -1)" != "$VERSION" ]; then - LINE_TAG="release-${VERSION%.*}" - echo "$VERSION is below latest ($LATEST); publishing under dist-tag $LINE_TAG" - npm publish "$package" --provenance --access public --tag "$LINE_TAG" + latest=$(npm view "$name" version --registry=https://registry.npmjs.org 2>/dev/null || true) + if [ -n "$latest" ] && \ + [ "$(printf '%s\n%s\n' "$latest" "$version" | sort -V | tail -1)" != "$version" ]; then + line_tag="release-${version%.*}" + echo "$version is below latest ($latest); publishing under dist-tag $line_tag" + npm publish "$package" --ignore-scripts --provenance --access public --tag "$line_tag" --registry=https://registry.npmjs.org else - npm publish "$package" --provenance --access public + npm publish "$package" --ignore-scripts --provenance --access public --registry=https://registry.npmjs.org fi fi @@ -232,8 +272,13 @@ jobs: # tag/version locking stays intact and the root package is never co-published. if: startsWith(github.ref, 'refs/tags/agent-bench-v') runs-on: ubuntu-latest + outputs: + release-version: ${{ steps.release.outputs.version }} + package-sha256: ${{ steps.package.outputs.sha256 }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 @@ -254,6 +299,7 @@ jobs: run: pnpm run verify:bench:published - name: Verify tag/version lock + id: release run: | NPM_VERSION=$(node -p "require('./bench/package.json').version") TAG_VERSION="${GITHUB_REF#refs/tags/agent-bench-v}" @@ -261,8 +307,35 @@ jobs: echo "::error::Tag/version mismatch: tag=$TAG_VERSION package=$NPM_VERSION." exit 1 fi + echo "version=$NPM_VERSION" >> "$GITHUB_OUTPUT" echo "Version locked: $NPM_VERSION" + - name: Package and verify the exact agent-bench archive + id: package + run: | + package_dir="$RUNNER_TEMP/agent-bench-package" + mkdir -p "$package_dir" + npm_config_ignore_scripts=true pnpm --dir bench pack --pack-destination "$package_dir" + mapfile -t packages < <(find "$package_dir" -maxdepth 1 -type f -name '*.tgz' -print) + if [ "${#packages[@]}" -ne 1 ]; then + echo "::error::Expected one agent-bench archive, found ${#packages[@]}." + exit 1 + fi + package="${packages[0]}" + node bench/scripts/verify-packed-consumer.mjs --tarball "$package" + sha256=$(sha256sum "$package" | cut -d' ' -f1) + echo "sha256=$sha256" >> "$GITHUB_OUTPUT" + echo "agent-bench archive locked: $package ($sha256)" + + - name: Upload exact agent-bench archive + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: agent-bench-package + path: ${{ runner.temp }}/agent-bench-package/*.tgz + if-no-files-found: error + compression-level: 0 + retention-days: 1 + publish-agent-bench: needs: verify-agent-bench if: startsWith(github.ref, 'refs/tags/agent-bench-v') @@ -270,33 +343,49 @@ jobs: permissions: contents: read id-token: write + env: + EXPECTED_NAME: '@tangle-network/agent-bench' + EXPECTED_VERSION: ${{ needs.verify-agent-bench.outputs.release-version }} + EXPECTED_SHA256: ${{ needs.verify-agent-bench.outputs.package-sha256 }} steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: 22 - cache: pnpm - cache-dependency-path: pnpm-lock.yaml + node-version: 24.18.0 - - run: pnpm install --frozen-lockfile + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: agent-bench-package + path: ${{ runner.temp }}/agent-bench-package - - name: Publish to npm (OIDC trusted publishing) + - name: Publish exact agent-bench archive run: | # Requires the npmjs Trusted Publisher on @tangle-network/agent-bench: # org tangle-network, repo agent-runtime, workflow publish.yml. - npm install -g npm@11 - NAME=$(node -p "require('./bench/package.json').name") - VERSION=$(node -p "require('./bench/package.json').version") - if npm view "$NAME@$VERSION" version >/dev/null 2>&1; then - echo "$NAME@$VERSION already on registry; skipping publish" + npm_version=$(npm --version) + if [[ "$npm_version" != 11.* ]]; then + echo "::error::Expected npm 11, received $npm_version." + exit 1 + fi + mapfile -t packages < <(find "$RUNNER_TEMP/agent-bench-package" -maxdepth 1 -type f -name '*.tgz' -print) + if [ "${#packages[@]}" -ne 1 ]; then + echo "::error::Expected one agent-bench archive, found ${#packages[@]}." + exit 1 + fi + package="${packages[0]}" + actual_sha256=$(sha256sum "$package" | cut -d' ' -f1) + if [ "$actual_sha256" != "$EXPECTED_SHA256" ]; then + echo "::error::agent-bench archive digest mismatch." + exit 1 + fi + manifest=$(tar -xOzf "$package" package/package.json) + name=$(node -e 'process.stdout.write(JSON.parse(process.argv[1]).name)' "$manifest") + version=$(node -e 'process.stdout.write(JSON.parse(process.argv[1]).version)' "$manifest") + if [ "$name" != "$EXPECTED_NAME" ] || [ "$version" != "$EXPECTED_VERSION" ]; then + echo "::error::agent-bench archive identity mismatch: $name@$version." + exit 1 + fi + if npm view "$name@$version" version --registry=https://registry.npmjs.org >/dev/null 2>&1; then + echo "$name@$version already on registry; skipping publish" else - mkdir -p "$RUNNER_TEMP/agent-bench-package" - pnpm --dir bench build - npm_config_ignore_scripts=true pnpm --dir bench pack --pack-destination "$RUNNER_TEMP/agent-bench-package" - package="$(find "$RUNNER_TEMP/agent-bench-package" -maxdepth 1 -name '*.tgz' -print -quit)" - test -n "$package" - npm publish "$package" --provenance --access public + npm publish "$package" --ignore-scripts --provenance --access public --registry=https://registry.npmjs.org fi diff --git a/bench/scripts/verify-packed-consumer.mjs b/bench/scripts/verify-packed-consumer.mjs index 16ecd948..146039b1 100644 --- a/bench/scripts/verify-packed-consumer.mjs +++ b/bench/scripts/verify-packed-consumer.mjs @@ -1,5 +1,5 @@ import { execFile } from 'node:child_process' -import { mkdtemp, mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises' +import { access, mkdtemp, mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import path from 'node:path' import { fileURLToPath } from 'node:url' @@ -9,9 +9,10 @@ const execFileAsync = promisify(execFile) const benchDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') const repoRoot = path.resolve(benchDir, '..') const scratch = await mkdtemp(path.join(tmpdir(), 'agent-bench-consumer-')) -const args = new Set(process.argv.slice(2)) -const useLocalRuntime = args.delete('--local-runtime') -if (args.size > 0) throw new Error(`unknown arguments: ${[...args].join(', ')}`) +const args = process.argv.slice(2) +const useLocalRuntime = removeFlag(args, '--local-runtime') +const suppliedTarball = removeOption(args, '--tarball') +if (args.length > 0) throw new Error(`unknown arguments: ${args.join(', ')}`) const TYPESCRIPT_5 = '5.9.3' const TYPESCRIPT_6 = '6.0.3' @@ -105,18 +106,24 @@ writeFileSync( { mode: 0o755 }, ) - // Build explicitly so verification cannot inherit a machine-level - // ignore-scripts setting and accidentally pack stale or missing output. - await run('pnpm', ['build'], benchDir) - await run('pnpm', ['pack', '--pack-destination', packDir], benchDir, { - ...process.env, - npm_config_ignore_scripts: 'true', - }) - const packedFiles = (await readdir(packDir)).filter((name) => name.endsWith('.tgz')) - if (packedFiles.length !== 1) { - throw new Error(`expected one packed agent-bench tarball, found ${packedFiles.length}`) + let tarball + if (suppliedTarball) { + tarball = path.resolve(suppliedTarball) + await access(tarball) + } else { + // Build explicitly so verification cannot inherit a machine-level + // ignore-scripts setting and accidentally pack stale or missing output. + await run('pnpm', ['build'], benchDir) + await run('pnpm', ['pack', '--pack-destination', packDir], benchDir, { + ...process.env, + npm_config_ignore_scripts: 'true', + }) + const packedFiles = (await readdir(packDir)).filter((name) => name.endsWith('.tgz')) + if (packedFiles.length !== 1) { + throw new Error(`expected one packed agent-bench tarball, found ${packedFiles.length}`) + } + tarball = path.join(packDir, packedFiles[0]) } - const tarball = path.join(packDir, packedFiles[0]) await run('tar', ['-xzf', tarball, '-C', unpackDir], benchDir) const runtimePackage = await resolveRuntimePackage(runtimePackDir) const packedManifest = JSON.parse( @@ -300,3 +307,19 @@ function requiredPackedDevelopmentDependency(packageJson, name) { } return version } + +function removeFlag(args, flag) { + const index = args.indexOf(flag) + if (index === -1) return false + args.splice(index, 1) + return true +} + +function removeOption(args, option) { + const index = args.indexOf(option) + if (index === -1) return undefined + const value = args[index + 1] + if (!value || value.startsWith('--')) throw new Error(`${option} requires a value`) + args.splice(index, 2) + return value +} diff --git a/package.json b/package.json index 73c3eabc..abff23ab 100644 --- a/package.json +++ b/package.json @@ -121,6 +121,7 @@ "generate:testing-fixture": "tsx scripts/generate-agent-improvement-proposal-fixtures.ts", "check:testing-fixture": "tsx scripts/generate-agent-improvement-proposal-fixtures.ts --check", "check:skills": "node scripts/check-skills.mjs", + "check:publish-workflow": "node scripts/check-publish-workflow.mjs", "release:prepare": "node scripts/prepare-release.mjs", "verify:static-imports": "node scripts/verify-static-imports.mjs", "verify:edge-tool-loop": "node scripts/verify-edge-tool-loop.mjs", @@ -152,7 +153,8 @@ "typedoc-plugin-markdown": "4.12.0", "typescript": "catalog:", "vitest": "^4.1.10", - "wrangler": "4.114.0" + "wrangler": "4.114.0", + "yaml": "2.9.0" }, "engines": { "node": ">=22.13.0" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dc294ef2..2cd682b1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -105,6 +105,9 @@ importers: wrangler: specifier: 4.114.0 version: 4.114.0 + yaml: + specifier: 2.9.0 + version: 2.9.0 bench: dependencies: diff --git a/scripts/check-publish-workflow.mjs b/scripts/check-publish-workflow.mjs new file mode 100644 index 00000000..67d7457e --- /dev/null +++ b/scripts/check-publish-workflow.mjs @@ -0,0 +1,124 @@ +import { readFileSync } from 'node:fs' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { parseDocument } from 'yaml' + +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..') +const workflowPath = process.argv[2] + ? resolve(process.argv[2]) + : resolve(repoRoot, '.github/workflows/publish.yml') +const document = parseDocument(readFileSync(workflowPath, 'utf8')) + +if (document.errors.length > 0) { + throw new Error(`publish workflow is invalid YAML: ${document.errors.join('; ')}`) +} + +const workflow = document.toJS() +const jobs = workflow.jobs +if (!jobs || typeof jobs !== 'object') throw new Error('publish workflow has no jobs') + +const publishJobs = { + 'publish-npm': 'verify', + 'publish-agent-bench': 'verify-agent-bench', +} + +for (const [jobName, requiredJob] of Object.entries(publishJobs)) { + const job = requireJob(jobName) + assertEqual(job.permissions?.contents, 'read', `${jobName} contents permission`) + assertEqual(job.permissions?.['id-token'], 'write', `${jobName} id-token permission`) + assertNeeds(job, requiredJob) + + const steps = requireSteps(jobName, job) + const actionNames = steps.flatMap((step) => (typeof step.uses === 'string' ? [step.uses] : [])) + assertCount(actionNames, /^actions\/setup-node@[a-f0-9]{40}$/, 1, `${jobName} setup-node`) + assertCount(actionNames, /^actions\/download-artifact@[a-f0-9]{40}$/, 1, `${jobName} artifact download`) + + for (const step of steps) { + if (typeof step.uses === 'string') { + if (/^actions\/checkout@|^pnpm\/action-setup@/.test(step.uses)) { + throw new Error(`${jobName} may not check out source or install pnpm: ${step.uses}`) + } + if (!/@[a-f0-9]{40}$/.test(step.uses)) { + throw new Error(`${jobName} action is not pinned to a commit: ${step.uses}`) + } + } + + if (typeof step.run === 'string') { + const forbidden = [ + /(^|\s)pnpm(\s|$)/m, + /(^|\s)npm\s+(ci|install|pack|rebuild|run)(\s|$)/m, + /(^|\s)(npx|corepack)(\s|$)/m, + ] + for (const pattern of forbidden) { + if (pattern.test(step.run)) { + throw new Error(`${jobName} runs dependency or build tooling: ${pattern}`) + } + } + } + } + + const commands = steps.map((step) => step.run ?? '').join('\n') + for (const required of ['sha256sum', 'npm publish', '--ignore-scripts', '--provenance']) { + if (!commands.includes(required)) { + throw new Error(`${jobName} publish command is missing ${required}`) + } + } +} + +for (const [jobName, job] of Object.entries(jobs)) { + const idToken = job?.permissions?.['id-token'] + if (idToken === 'write' && !(jobName in publishJobs)) { + throw new Error(`unexpected job can mint a package identity token: ${jobName}`) + } + if (job?.permissions?.contents === 'write') { + throw new Error(`publish workflow must not write repository contents: ${jobName}`) + } +} + +for (const jobName of ['verify', 'verify-agent-bench']) { + const job = requireJob(jobName) + if (job.permissions?.['id-token'] === 'write') { + throw new Error(`${jobName} may not mint a package identity token`) + } + const steps = requireSteps(jobName, job) + assertCount( + steps.flatMap((step) => (typeof step.uses === 'string' ? [step.uses] : [])), + /^actions\/upload-artifact@[a-f0-9]{40}$/, + 1, + `${jobName} artifact upload`, + ) + for (const step of steps) { + if (typeof step.uses === 'string' && /^actions\/checkout@/.test(step.uses)) { + assertEqual(step.with?.['persist-credentials'], false, `${jobName} checkout credentials`) + } + } +} + +process.stdout.write('Publish workflow keeps package creation separate from npm authority.\n') + +function requireJob(name) { + const job = jobs[name] + if (!job || typeof job !== 'object') throw new Error(`publish workflow is missing job ${name}`) + return job +} + +function requireSteps(jobName, job) { + if (!Array.isArray(job.steps)) throw new Error(`${jobName} has no steps`) + return job.steps +} + +function assertNeeds(job, expected) { + const needs = Array.isArray(job.needs) ? job.needs : [job.needs] + if (!needs.includes(expected)) throw new Error(`publish job must depend on ${expected}`) +} + +function assertCount(values, pattern, expected, label) { + const count = values.filter((value) => pattern.test(value)).length + if (count !== expected) throw new Error(`${label}: expected ${expected}, received ${count}`) +} + +function assertEqual(actual, expected, label) { + if (actual !== expected) { + throw new Error(`${label}: expected ${JSON.stringify(expected)}, received ${JSON.stringify(actual)}`) + } +} diff --git a/scripts/verify-edge-tool-loop.mjs b/scripts/verify-edge-tool-loop.mjs index 6de570c0..20915830 100644 --- a/scripts/verify-edge-tool-loop.mjs +++ b/scripts/verify-edge-tool-loop.mjs @@ -1,5 +1,6 @@ import { execFileSync } from 'node:child_process' import { + existsSync, mkdirSync, mkdtempSync, readdirSync, @@ -12,6 +13,7 @@ import { fileURLToPath } from 'node:url' import { Miniflare } from 'miniflare' const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..') +const suppliedArchive = process.argv[2] ? resolve(process.argv[2]) : undefined const tempRoot = mkdtempSync(join(tmpdir(), 'agent-runtime-edge-tool-loop-')) const packDir = join(tempRoot, 'pack') const appDir = join(tempRoot, 'app') @@ -21,13 +23,19 @@ try { mkdirSync(packDir, { recursive: true }) mkdirSync(join(appDir, 'src'), { recursive: true }) - const pack = JSON.parse( - run('pnpm', ['pack', '--json', '--pack-destination', packDir], repoRoot), - ) - if (!pack || Array.isArray(pack) || typeof pack.filename !== 'string') { - throw new Error('expected one packed Runtime archive') + if (suppliedArchive && !existsSync(suppliedArchive)) { + throw new Error(`supplied Runtime archive does not exist: ${suppliedArchive}`) + } + let archive = suppliedArchive + if (!archive) { + const pack = JSON.parse( + run('pnpm', ['pack', '--json', '--pack-destination', packDir], repoRoot), + ) + if (!pack || Array.isArray(pack) || typeof pack.filename !== 'string') { + throw new Error('expected one packed Runtime archive') + } + archive = resolve(pack.filename) } - const archive = resolve(pack.filename) writeFileSync( join(appDir, 'package.json'),