From 52eb83f01f5c6adc247552632ab2e317b8d3b89b Mon Sep 17 00:00:00 2001 From: Andrew Jon Przybilla Date: Wed, 29 Apr 2026 17:03:37 -0700 Subject: [PATCH] Harden the release pipeline: provenance, dry-run, pack-contents check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three guard rails added to `.github/workflows/release.yml` so the next `git tag v… && git push` ships with stronger supply-chain guarantees and fewer surprises. Provenance - Added `id-token: write` permission to the release job and `--provenance` to the `pnpm publish` invocation. The npm registry now attaches a signed attestation linking each published tarball to this exact GitHub commit; consumers see a "Provenance" badge on the package page. Manual rehearsal - Added `workflow_dispatch` trigger with an optional `version` input (default `0.0.0-rehearsal`). Running the workflow manually executes the full pipeline (install / version-stamp / lint / build / typecheck / test / pack-verify) but skips both the `publish` and the GitHub Release steps. Use it to sanity-check a release would succeed before committing the tag. - Version-stamp step branches on `github.event_name`: tag drives the version on real pushes, the input drives it on manual runs. - Publish step + GH Release step are now gated `if: github.event_name == 'push'`. A dispatch run cannot accidentally publish. Pack-contents verification - New `scripts/verify-pack-contents.mjs` (~160 lines, no deps). Discovers every non-private workspace package, runs `pnpm pack`, walks the resulting tarball, and verifies every relative path declared by `exports` (incl. nested condition objects), `main`, `module`, `types`, and `bin` actually ships in the tarball. - Wired into the release workflow as a step before `publish`, so any future change that drops a file from the published artifact (e.g. a removed entry in `files`, a moved source path) fails the release loud — never silently in npm. - Verified clean against the current monorepo: @agentc7/ac7: OK (2 paths) @agentc7/cli: OK (1 path) @agentc7/core: OK (2 paths) @agentc7/sdk: OK (10 paths) @agentc7/web-shell: OK (2 paths) @agentc7/server: OK (4 paths) Signed-off-by: Andrew Jon Przybilla --- .github/workflows/release.yml | 41 +++++++- scripts/verify-pack-contents.mjs | 160 +++++++++++++++++++++++++++++++ 2 files changed, 196 insertions(+), 5 deletions(-) create mode 100644 scripts/verify-pack-contents.mjs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f28b881..ff649a2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,6 +4,15 @@ on: push: tags: - 'v*' + # Manual rehearsal — runs the entire pipeline without publishing or + # creating a GitHub Release. Use it to verify a release would + # succeed before actually cutting the tag. + workflow_dispatch: + inputs: + version: + description: 'Version to stamp during rehearsal (no effect on real tag pushes)' + type: string + default: '0.0.0-rehearsal' concurrency: group: release-${{ github.ref }} @@ -15,6 +24,10 @@ jobs: runs-on: ubuntu-latest permissions: contents: write + # Required for npm publish --provenance: the `id-token` is what + # the npm registry consumes to attach a signed attestation + # linking the published tarball to this exact GitHub commit. + id-token: write steps: - uses: actions/checkout@v6 with: @@ -30,13 +43,16 @@ jobs: - run: pnpm install --frozen-lockfile - - name: Stamp version from tag + - name: Stamp version run: | - TAG_VERSION="${GITHUB_REF_NAME#v}" - echo "Stamping version $TAG_VERSION from tag $GITHUB_REF_NAME" + if [ "${{ github.event_name }}" = "push" ]; then + TAG_VERSION="${GITHUB_REF_NAME#v}" + else + TAG_VERSION="${{ inputs.version }}" + fi + echo "Stamping version $TAG_VERSION (event: ${{ github.event_name }})" node -e " const fs = require('fs'); - const glob = require('path'); const files = [ 'package.json', ...fs.readdirSync('packages', {withFileTypes:true}).filter(d=>d.isDirectory()).map(d=>'packages/'+d.name+'/package.json'), @@ -58,12 +74,27 @@ jobs: - run: pnpm test + # Pack every publishable workspace package and verify each one + # ships the files its `exports` / `main` / `types` / `bin` map + # promises. Catches the class of bug where someone removes a + # path from `files` (or moves a file out of `src`) and only + # finds out post-publish. + - name: Verify pack contents + run: node scripts/verify-pack-contents.mjs + - name: Publish to npm - run: pnpm -r publish --access public --no-git-checks + if: github.event_name == 'push' + run: pnpm -r publish --access public --no-git-checks --provenance env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + - name: Rehearsal summary + if: github.event_name == 'workflow_dispatch' + run: | + echo "::notice::Dry run complete. Pipeline passed; no packages were published and no GitHub Release was created." + - name: Create GitHub Release with auto-generated notes + if: github.event_name == 'push' uses: softprops/action-gh-release@v3 with: generate_release_notes: true diff --git a/scripts/verify-pack-contents.mjs b/scripts/verify-pack-contents.mjs new file mode 100644 index 0000000..6032b2a --- /dev/null +++ b/scripts/verify-pack-contents.mjs @@ -0,0 +1,160 @@ +#!/usr/bin/env node +/** + * verify-pack-contents — sanity-check that every publishable package + * actually ships the files its `package.json` promises. + * + * What we check, per publishable workspace package: + * - Run `pnpm pack` to produce a tarball. + * - Read `exports` (handles nested condition objects), plus the + * top-level `main`, `module`, `types`, `bin` fields. Collect + * every relative path each one points at. + * - List the tarball contents (`tar -tzf`) and verify that every + * declared path is present. + * + * What we deliberately don't check: + * - Pattern subpath imports (`./*.ts`) — can't statically resolve + * without expanding glob; trust the publish-side to surface + * missing files when an actual import fails. + * - Non-relative export targets (e.g. external module redirects). + * + * Run from the repo root: node scripts/verify-pack-contents.mjs + * + * Exit code 0 if every declared file ships; 1 otherwise (with a + * per-package list of missing paths). Designed to be the last gate + * before `pnpm publish` in CI so a broken release fails loud, not + * silently in the npm registry. + */ + +import { execSync } from 'node:child_process'; +import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const REPO_ROOT = resolve(fileURLToPath(import.meta.url), '..', '..'); +const PACK_DIR = '/tmp/ac7-pack-verify'; + +function discoverPublishable() { + const candidates = []; + for (const parent of ['packages', 'apps']) { + const parentDir = resolve(REPO_ROOT, parent); + if (!existsSync(parentDir)) continue; + for (const entry of readdirSync(parentDir, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + const pkgPath = resolve(parentDir, entry.name, 'package.json'); + if (!existsSync(pkgPath)) continue; + const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')); + if (pkg.private === true) continue; + candidates.push({ + name: pkg.name, + dir: resolve(parentDir, entry.name), + pkg, + }); + } + } + return candidates; +} + +/** + * Walk a `package.json` and collect every relative path the package + * declares as a file consumers should be able to import. Handles: + * + * - `main`, `module`, `types` (string) + * - `bin` (string OR { name: path }) + * - `exports` (string OR { ".": string } OR + * { ".": { import: string, types: string, ... } } OR + * { "./foo": string | object, ... }) + * + * Skips entries whose target isn't a relative path beginning with + * "./" — those are either external module redirects or pattern + * imports we can't statically resolve. + */ +function declaredPaths(pkg) { + const paths = new Set(); + const collect = (val) => { + if (typeof val === 'string') { + if (val.startsWith('./')) paths.add(val.slice(2)); + return; + } + if (val && typeof val === 'object' && !Array.isArray(val)) { + for (const v of Object.values(val)) collect(v); + } + }; + + for (const field of ['main', 'module', 'types']) { + if (typeof pkg[field] === 'string') collect(pkg[field]); + } + if (typeof pkg.bin === 'string') { + collect(pkg.bin); + } else if (pkg.bin && typeof pkg.bin === 'object') { + for (const v of Object.values(pkg.bin)) collect(v); + } + if (pkg.exports !== undefined) collect(pkg.exports); + + return [...paths]; +} + +function listTarballEntries(tgzPath) { + const out = execSync(`tar -tzf "${tgzPath}"`, { encoding: 'utf8' }); + return out + .split('\n') + .filter(Boolean) + .map((line) => (line.startsWith('package/') ? line.slice('package/'.length) : line)); +} + +function main() { + rmSync(PACK_DIR, { recursive: true, force: true }); + mkdirSync(PACK_DIR, { recursive: true }); + + const packages = discoverPublishable(); + if (packages.length === 0) { + console.error('no publishable packages discovered — is this the repo root?'); + process.exit(1); + } + + console.log(`Verifying pack contents for ${packages.length} packages…\n`); + + let failed = false; + for (const { name, dir, pkg } of packages) { + process.stdout.write(` ${name}: `); + let tgzName; + try { + execSync('pnpm pack --pack-destination ' + PACK_DIR, { + cwd: dir, + stdio: 'pipe', + }); + tgzName = readdirSync(PACK_DIR).find( + (f) => f.startsWith(name.replace('@', '').replace('/', '-')) && f.endsWith('.tgz'), + ); + } catch (err) { + console.log( + `PACK FAILED — ${(err instanceof Error ? err.message : String(err)).split('\n')[0]}`, + ); + failed = true; + continue; + } + if (!tgzName) { + console.log('PACK FAILED — no .tgz produced'); + failed = true; + continue; + } + + const entries = new Set(listTarballEntries(resolve(PACK_DIR, tgzName))); + const expected = declaredPaths(pkg); + const missing = expected.filter((p) => !entries.has(p)); + if (missing.length === 0) { + console.log(`OK (${expected.length} paths)`); + } else { + console.log(`MISSING ${missing.length}/${expected.length}`); + for (const m of missing) console.log(` - ${m}`); + failed = true; + } + } + + if (failed) { + console.error('\nPack-content verification failed.'); + process.exit(1); + } + console.log('\nAll publishable packages ship the files their package.json declares.'); +} + +main();