From 4b24b2e0a6e366b7dc93f660675a77a35166187f Mon Sep 17 00:00:00 2001 From: Tanner Linsley Date: Fri, 31 Jul 2026 11:57:51 -0600 Subject: [PATCH] Simplify release workflow --- .../workflows/chart-library-benchmarks.yml | 13 +- .github/workflows/release.yml | 228 ++------------- API-FRICTION.md | 57 ++-- CONTRIBUTING.md | 43 +-- package.json | 1 + scripts/ci-workflow.test.mjs | 19 +- scripts/publish-release.mjs | 112 ++++++-- scripts/release-security.mjs | 212 -------------- scripts/release-security.test.mjs | 265 +----------------- scripts/release-workflow.test.mjs | 264 ++++------------- scripts/verify-ci-success.mjs | 61 ---- scripts/verify-ci-success.test.mjs | 30 -- scripts/verify-published-release.mjs | 243 ---------------- scripts/verify-release-revision.mjs | 140 --------- 14 files changed, 238 insertions(+), 1450 deletions(-) delete mode 100644 scripts/verify-ci-success.mjs delete mode 100644 scripts/verify-ci-success.test.mjs delete mode 100644 scripts/verify-published-release.mjs delete mode 100644 scripts/verify-release-revision.mjs diff --git a/.github/workflows/chart-library-benchmarks.yml b/.github/workflows/chart-library-benchmarks.yml index eb4d04d..ee05a16 100644 --- a/.github/workflows/chart-library-benchmarks.yml +++ b/.github/workflows/chart-library-benchmarks.yml @@ -9,11 +9,6 @@ on: - cron: '17 7 * * 1' workflow_dispatch: inputs: - version_pr: - description: Run the pull-request validation profile - required: false - type: boolean - default: false upload_bundle_baseline_candidate: description: Upload exact comparison bundle measurements required: false @@ -153,11 +148,11 @@ jobs: playwright: 'true' - name: Run pull-request conformance shard - if: github.event_name == 'pull_request' || inputs.version_pr + if: github.event_name == 'pull_request' run: pnpm conformance:quick -- --shard=${{ matrix.shard }}/8 - name: Run production conformance shard - if: github.event_name != 'pull_request' && !inputs.version_pr + if: github.event_name != 'pull_request' run: pnpm conformance -- --shard=${{ matrix.shard }}/8 - name: Publish conformance summary @@ -209,11 +204,11 @@ jobs: run: pnpm exec vitest run benchmarks/comparison/stress - name: Run quick stress shard - if: github.event_name == 'pull_request' || github.event_name == 'push' || inputs.version_pr + if: github.event_name == 'pull_request' || github.event_name == 'push' run: pnpm benchmark:stress:quick -- --workload=${{ matrix.workloads }} - name: Run standard stress shard - if: github.event_name == 'schedule' || (github.event_name == 'workflow_dispatch' && !inputs.version_pr) + if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' run: pnpm benchmark:stress:standard -- --workload=${{ matrix.workloads }} - name: Publish stress summary diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1a94a14..1a36004 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,268 +1,80 @@ -name: Publish release +name: Release on: push: - tags: - - 'v*' - workflow_dispatch: - workflow_run: - workflows: - - Chart library benchmarks - types: - - completed + branches: + - main permissions: contents: read concurrency: - group: charts-release + group: charts-release-${{ github.ref }} cancel-in-progress: false jobs: - version: - name: Version pull request - if: >- - github.event_name == 'workflow_run' && - github.event.workflow_run.conclusion == 'success' && - github.event.workflow_run.event == 'push' && - github.event.workflow_run.head_branch == 'main' + release: + name: Release + if: github.repository_owner == 'TanStack' runs-on: ubuntu-24.04 - timeout-minutes: 10 + timeout-minutes: 20 permissions: - actions: write contents: write + id-token: write pull-requests: write steps: - - name: Checkout validated main + - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: - ref: ${{ github.event.workflow_run.head_sha }} fetch-depth: 0 persist-credentials: true - name: Setup uses: ./.github/actions/setup - - name: Create or update version pull request + - name: Create version pull request or publish id: changesets uses: changesets/action@63a615b9cd06ba9a3e6d13796c7fbcb080a60a0b # v1.8.0 with: branch: main version: pnpm changeset:version + publish: pnpm changeset:publish title: 'ci: Version Packages' commit: 'ci: changeset release' createGithubReleases: false env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RELEASE_REVISION: ${{ github.sha }} - - name: Validate version pull request - if: steps.changesets.outputs.pullRequestNumber != '' - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - VERSION_PR_NUMBER: ${{ steps.changesets.outputs.pullRequestNumber }} - run: | - version_ref=$(gh pr view "$VERSION_PR_NUMBER" \ - --repo "$GITHUB_REPOSITORY" \ - --json headRefName \ - --jq .headRefName) - test -n "$version_ref" - gh workflow run chart-library-benchmarks.yml \ - --repo "$GITHUB_REPOSITORY" \ - --ref "$version_ref" \ - -f version_pr=true - - tag: - name: Dispatch validated release - needs: version - if: >- - github.event_name == 'workflow_run' && - github.event.workflow_run.conclusion == 'success' && - github.event.workflow_run.event == 'push' && - github.event.workflow_run.head_branch == 'main' - runs-on: ubuntu-24.04 - timeout-minutes: 10 - permissions: - actions: write - contents: write - - steps: - - name: Checkout validated main - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - ref: ${{ github.event.workflow_run.head_sha }} - fetch-depth: 0 - persist-credentials: true - - - name: Setup - uses: ./.github/actions/setup - - - name: Determine release status + - name: Determine release finalization id: status + if: steps.changesets.outputs.hasChangesets == 'false' env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - RELEASE_REVISION: ${{ github.event.workflow_run.head_sha }} + RELEASE_REVISION: ${{ github.sha }} run: node scripts/release-status.mjs - name: Create annotated release tag if: steps.status.outputs.create_tag == 'true' env: RELEASE_TAG: ${{ steps.status.outputs.tag }} - RELEASE_REVISION: ${{ github.event.workflow_run.head_sha }} + RELEASE_REVISION: ${{ github.sha }} run: | git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" git tag -a "$RELEASE_TAG" "$RELEASE_REVISION" -m "TanStack Charts $RELEASE_TAG" git push origin "refs/tags/$RELEASE_TAG" - - name: Dispatch tag-scoped OIDC release + - name: Create GitHub release if: steps.status.outputs.dispatch == 'true' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} RELEASE_TAG: ${{ steps.status.outputs.tag }} - run: gh workflow run release.yml --ref "$RELEASE_TAG" - - validate: - name: Build release candidate - if: github.event_name == 'push' || github.event_name == 'workflow_dispatch' - runs-on: ubuntu-24.04 - timeout-minutes: 15 - permissions: - actions: read - contents: read - - steps: - - name: Checkout tagged revision - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - ref: ${{ github.sha }} - fetch-depth: 0 - persist-credentials: false - - - name: Setup - uses: ./.github/actions/setup - - - name: Verify tag and successful exact-SHA CI - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - node scripts/verify-release-revision.mjs - node scripts/verify-ci-success.mjs - - - name: Build fresh package artifacts - run: node scripts/build-release-artifacts.mjs - - - name: Check release artifacts - run: node scripts/publish-release.mjs --check - - - name: Upload checked package artifacts - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 - with: - name: charts-release-${{ github.ref_name }} - path: .release-artifacts - if-no-files-found: error - include-hidden-files: true - - publish: - name: Publish with npm OIDC - needs: validate - if: needs.validate.result == 'success' - runs-on: ubuntu-24.04 - timeout-minutes: 15 - permissions: - contents: read - id-token: write - - steps: - - name: Checkout tagged revision - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - ref: ${{ github.sha }} - fetch-depth: 0 - persist-credentials: false - - - name: Setup Node - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 - with: - node-version: 24.18.0 - registry-url: https://registry.npmjs.org - package-manager-cache: false - - - name: Download checked package artifacts - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 - with: - name: charts-release-${{ github.ref_name }} - path: .release-artifacts - - - name: Revalidate refs and publish with OIDC - run: | - node scripts/verify-release-revision.mjs - node scripts/publish-release.mjs - - verify: - name: Verify published packages - needs: publish - runs-on: ubuntu-24.04 - timeout-minutes: 15 - permissions: - contents: read - - steps: - - name: Checkout tagged revision - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - ref: ${{ github.sha }} - persist-credentials: false - - - name: Setup Node - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 - with: - node-version: 24.18.0 - registry-url: https://registry.npmjs.org - package-manager-cache: false - - - name: Download checked package artifacts - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 - with: - name: charts-release-${{ github.ref_name }} - path: .release-artifacts - - - name: Verify installed packages, signatures, and provenance - run: node scripts/verify-published-release.mjs - - release: - name: Create GitHub release - needs: verify - runs-on: ubuntu-24.04 - timeout-minutes: 10 - permissions: - contents: write - - steps: - - name: Checkout tagged revision - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - ref: ${{ github.sha }} - fetch-depth: 0 - persist-credentials: false - - - name: Setup Node - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 - with: - node-version: 24.18.0 - package-manager-cache: false - - - name: Revalidate refs and create GitHub release - env: - GH_TOKEN: ${{ github.token }} run: | - node scripts/verify-release-revision.mjs - if gh release view "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then - exit 0 - fi node scripts/write-release-notes.mjs /tmp/charts-release-notes.md - gh release create "$GITHUB_REF_NAME" \ + gh release create "$RELEASE_TAG" \ --repo "$GITHUB_REPOSITORY" \ --verify-tag \ - --title "TanStack Charts $GITHUB_REF_NAME" \ + --title "TanStack Charts $RELEASE_TAG" \ --notes-file /tmp/charts-release-notes.md diff --git a/API-FRICTION.md b/API-FRICTION.md index 1fe0fe6..fd9fd52 100644 --- a/API-FRICTION.md +++ b/API-FRICTION.md @@ -182,7 +182,7 @@ Each entry records: | F-144 | Action pin checks accepted invalid commit lengths | Tooling | resolved | | F-145 | Changesets included private workspaces in version plans | Tooling/Release | resolved | | F-146 | Octane hydration used a unit-test timeout | Tooling | resolved | -| F-147 | Bot-authored version PRs lacked an automatic CI trigger | Tooling/Release | resolved | +| F-147 | Release automation duplicated validated work | Tooling/Release | resolved | | F-148 | Publisher failure returned before its workers settled | Tooling/Release | resolved | | F-149 | Release checks could stall or accept an unbound tag | Tooling/Release | resolved | | F-150 | Nx worktree caches followed the common Git directory | Tooling | monitoring | @@ -3362,22 +3362,18 @@ Each entry records: request completed two-factor authentication but returned an opaque HTTP 400 instead of identifying the missing `--allow-publish` permission. - Decision: configure trust with npm `11.18.0`, give each public package an - explicit publish permission, and keep npm installation outside the - OIDC-enabled job. The release checks that Node's bundled npm meets the - trusted-publishing minimum instead of replacing the CLI while a job can mint - identity tokens. + explicit publish permission, and use the repository's standard Changesets + workflow without an npm token. The publisher checks that Node's bundled npm + meets the trusted-publishing minimum before requesting OIDC-backed + publication. - Verification: `npm trust list` reports the exact `TanStack/charts` repository, `release.yml` workflow, and `createPackage` permission for core - and all nine public framework adapters. The workflow has one OIDC-enabled - job; it checks out the exact release SHA, downloads already-checked - artifacts, installs no dependencies, revalidates the protected remote tag - and main immediately before publishing, and delegates package installation - and signature verification to a post-publish job without OIDC. That job - requires npm to verify every release package's attestation bundle and then - matches the fetched bundles before checking their exact package, digest, - repository, workflow, tag, and commit claims. Release workflow - `30592985603` completed that OIDC publication and independent verification - successfully for all ten packages. + and all nine public framework adapters. Release workflow `30592985603` + completed OIDC publication for all ten `0.0.1` packages. The current + publisher builds checked tarballs for an unpublished or unfinished release, + waits for matching registry integrity and attestations, and the workflow + contract grants `id-token: write` once without `NPM_TOKEN` or + `NODE_AUTH_TOKEN`. ### F-139 — Top-level package entries bypassed tarball validation @@ -3537,23 +3533,28 @@ Each entry records: - Verification: the focused Octane client suite covers all seven client tests; pull-request static checks remain the parallel Linux gate. -### F-147 — Bot-authored version PRs lacked an automatic CI trigger +### F-147 — Release automation duplicated validated work - Status: resolved - Severity: high - Owner: Tooling/Release -- Observed in: hardening the automated Changesets release path -- Friction: the built-in workflow token can create and update the Changesets - version pull request, but relying on that mutation to start pull-request CI - leaves validation subject to GitHub's bot-event suppression and approval - behavior. -- Decision: use the action's `pullRequestNumber` output to resolve the exact - version branch, then explicitly dispatch the full chart workflow on that - ref with the job's narrowly scoped `actions: write` permission. A typed - dispatch input selects the same quick browser profile as ordinary pull - requests. -- Verification: the release workflow contract requires the Changesets output, - exact PR-head lookup, and explicit branch-scoped workflow dispatch. +- Observed in: the `0.0.2` release and comparison with Query, Router, Virtual, + Form, Store, Pacer, and the TanStack repository template +- Friction: the built-in workflow token intentionally does not start + pull-request Actions for the generated Changesets PR. The previous workaround + explicitly dispatched 22 checks that took 2 minutes 46 seconds, even though + Charts has no required status-check or review rule. Merging that mechanical + PR then ran the complete main workflow for another 4 minutes 12 seconds + before a tag dispatch started four more release jobs. +- Decision: use the standard TanStack push-to-main Changesets job. Pending + changesets create or update the mergeable version PR immediately. With no + pending changesets, the same job preflights npm, builds the checked package + tarballs, publishes through OIDC, and finalizes the aggregate tag and release. + Main browser and catalog CI remains independent. +- Verification: the repository ruleset protects main only from deletion and + non-fast-forward updates. PR `#13` merged without a review or required check. + Workflow contracts reject the removed `version_pr` dispatch path and require + one branch-scoped Changesets release job with registry-aware finalization. ### F-148 — Publisher failure returned before its workers settled diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c8dcfc3..4128def 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -69,24 +69,31 @@ hand. The automated version pull request owns those changes. ## Release flow -After a push to `main` passes the complete CI workflow: - -1. Changesets creates or updates `ci: Version Packages` from the pending - changesets. -2. Merging that pull request updates all public package versions, consumes the - changesets, synchronizes the root changelog, and updates the lockfile. -3. CI validates the exact merge commit. The release workflow then creates an - annotated version tag when that version is not already released. -4. The tag-scoped workflow rebuilds and checks all ten package artifacts. -5. Only the publish job receives `id-token: write`. npm trusted publishing uses - that OIDC identity to publish with provenance; the repository does not use a - long-lived npm token. -6. A separate job installs the published packages and verifies their - integrity, signatures, and provenance before GitHub creates the release. - -Never move or reuse a release tag. If publishing succeeds but final -verification or GitHub release creation fails, rerun the release workflow -against the existing tag. +Every push to `main` starts the release workflow: + +1. When pending changesets exist, Changesets creates or updates + `ci: Version Packages`. The generated pull request does not repeat the + browser benchmark matrix. +2. Review and merge that pull request when the release is ready. It updates all + public package versions, consumes the changesets, synchronizes the root and + package changelogs, updates release-facing docs, and refreshes the lockfile. +3. The merge starts the same workflow again. With no pending changesets, the + publisher checks npm and builds fresh, consumer-tested tarballs only when + the coordinated version is unpublished. +4. npm trusted publishing uses the workflow's OIDC identity to publish core + before the nine adapters with provenance. The repository has no long-lived + npm token. +5. After all ten registry entries report the expected integrity and + attestations, the workflow creates one annotated `vX.Y.Z` tag and GitHub + release from the root changelog. + +The complete chart, browser, and catalog workflow still runs on `main`, but it +is independent from npm publication. User-visible package work must pass that +workflow in its feature pull request before merging. + +Never move or reuse a release tag. If publishing succeeds but tag or GitHub +release creation fails, rerun the failed `Release` workflow; its registry +preflight resumes finalization without republishing existing versions. Changing the repository name or `.github/workflows/release.yml` requires updating the trusted-publisher diff --git a/package.json b/package.json index f9ed45e..a1f5082 100644 --- a/package.json +++ b/package.json @@ -54,6 +54,7 @@ "test": "nx run charts-workspace:test-unit --parallel=5", "typecheck": "nx run charts-workspace:typecheck", "changeset": "changeset", + "changeset:publish": "node scripts/publish-release.mjs", "changeset:version": "changeset version && node scripts/sync-release-changelog.mjs && node scripts/sync-release-version.mjs && pnpm docs:sync && pnpm install --lockfile-only --ignore-scripts --no-frozen-lockfile && pnpm format" }, "devDependencies": { diff --git a/scripts/ci-workflow.test.mjs b/scripts/ci-workflow.test.mjs index 1c504a9..4dbe1fd 100644 --- a/scripts/ci-workflow.test.mjs +++ b/scripts/ci-workflow.test.mjs @@ -111,18 +111,13 @@ describe('CI workflow contract', () => { conformance, /pnpm conformance:quick -- --shard=\${{ matrix\.shard }}\/8/, ) - assert.match( - conformance, - /if:\s*github\.event_name == 'pull_request' \|\| inputs\.version_pr/, - ) + assert.match(conformance, /if:\s*github\.event_name == 'pull_request'/) assert.match( conformance, /pnpm conformance -- --shard=\${{ matrix\.shard }}\/8/, ) - assert.match( - conformance, - /if:\s*github\.event_name != 'pull_request' && !inputs\.version_pr/, - ) + assert.match(conformance, /if:\s*github\.event_name != 'pull_request'/) + assert.doesNotMatch(conformance, /version_pr/) assert.match( conformance, /name:\s*chart-library-conformance-\${{ matrix\.shard }}-\${{ github\.run_id }}/, @@ -162,11 +157,15 @@ describe('CI workflow contract', () => { stress, /benchmark:stress:standard -- --workload=\${{ matrix\.workloads }}/, ) - assert.match(stress, /github\.event_name == 'push' \|\| inputs\.version_pr/) assert.match( stress, - /github\.event_name == 'workflow_dispatch' && !inputs\.version_pr/, + /github\.event_name == 'pull_request' \|\| github\.event_name == 'push'/, + ) + assert.match( + stress, + /github\.event_name == 'schedule' \|\| github\.event_name == 'workflow_dispatch'/, ) + assert.doesNotMatch(stress, /version_pr/) assert.match( stress, /name:\s*chart-library-stress-\${{ matrix\.name }}-\${{ github\.run_id }}/, diff --git a/scripts/publish-release.mjs b/scripts/publish-release.mjs index 9d23013..76bd244 100644 --- a/scripts/publish-release.mjs +++ b/scripts/publish-release.mjs @@ -1,5 +1,5 @@ import assert from 'node:assert/strict' -import { execFile } from 'node:child_process' +import { execFile, spawn } from 'node:child_process' import { resolve } from 'node:path' import { promisify } from 'node:util' import { @@ -7,6 +7,7 @@ import { validateReleaseArtifacts, } from './release-artifacts.mjs' import { validateTrustedPublishingNpmVersion } from './release-security.mjs' +import { releaseStatus } from './release-status.mjs' import { runWithConcurrency } from './run-with-concurrency.mjs' const execFileAsync = promisify(execFile) @@ -19,38 +20,31 @@ assert.deepEqual( 'Usage: node scripts/publish-release.mjs [--check]', ) -const { artifacts, manifest, version } = - await validateReleaseArtifacts(repositoryRoot) - if (checkOnly) { + const { artifacts, version } = await validateReleaseArtifacts(repositoryRoot) console.log( `Release artifact contract passed for ${artifacts.length} packages at ${version}.`, ) process.exit(0) } -assert.equal( - process.env.GITHUB_ACTIONS, - 'true', - 'Publishing is restricted to GitHub Actions', -) -assert.equal( - process.env.GITHUB_REF_TYPE, - 'tag', - 'Publishing requires a tag event', -) -assert.equal( - process.env.GITHUB_REF_NAME, - manifest.tag, - `Expected release tag ${manifest.tag}`, -) -assert.match( - process.env.GITHUB_SHA ?? '', - /^[0-9a-f]{40}$/, - 'Publishing requires an exact GitHub revision', +validatePublishEnvironment(process.env) + +const status = await releaseStatus({ repositoryRoot }) +if (status.reason === 'released') { + console.log(`No unpublished packages for ${status.tag}.`) + process.exit(0) +} +assert.ok( + status.reason === 'publish' || status.reason === 'finalize', + `Changesets invoked publishing with release status ${status.reason}`, ) + validateTrustedPublishingNpmVersion((await runNpm(['--version'])).stdout) +await buildReleaseArtifacts() +const { artifacts, manifest, version } = + await validateReleaseArtifacts(repositoryRoot) const states = new Map() await Promise.all( artifacts.map(async (artifact) => { @@ -64,6 +58,21 @@ await Promise.all( }), ) +const unpublishedArtifacts = artifacts.filter( + (artifact) => states.get(artifact.name) === 'missing', +) +if (status.reason === 'finalize') { + assert.equal( + unpublishedArtifacts.length, + 0, + `${manifest.tag} cannot finalize with unpublished packages`, + ) + console.log( + `Verified ${manifest.tag} registry integrity and attestations before finalization.`, + ) + process.exit(0) +} + const coreArtifact = artifacts.find( (artifact) => artifact.name === '@tanstack/charts', ) @@ -76,6 +85,9 @@ await runWithConcurrency( ) console.log(`Published ${manifest.tag} with verified integrity and provenance.`) +for (const artifact of unpublishedArtifacts) { + console.log(`New tag: ${artifact.name}@${version}`) +} async function publishArtifact(artifact) { if (states.get(artifact.name) === 'published') { @@ -94,6 +106,7 @@ async function publishArtifact(artifact) { ]) const registry = await waitForRegistryPackage(artifact, version) validateRegistryPackage(artifact, registry) + states.set(artifact.name, 'published') console.log(`Published: ${artifact.name}@${version}`) } @@ -169,3 +182,56 @@ function runNpm(args) { maxBuffer: 20 * 1024 * 1024, }) } + +function validatePublishEnvironment(env) { + assert.equal(env.GITHUB_ACTIONS, 'true', 'Publishing requires GitHub Actions') + assert.equal(env.GITHUB_EVENT_NAME, 'push', 'Publishing requires a push') + assert.equal(env.GITHUB_REF_TYPE, 'branch', 'Publishing requires a branch') + assert.equal(env.GITHUB_REF_NAME, 'main', 'Publishing requires main') + assert.equal( + env.GITHUB_REF, + 'refs/heads/main', + 'Publishing requires refs/heads/main', + ) + assert.equal( + env.GITHUB_REPOSITORY, + 'TanStack/charts', + 'Publishing requires TanStack/charts', + ) + assert.match( + env.GITHUB_SHA ?? '', + /^[0-9a-f]{40}$/, + 'Publishing requires an exact GitHub revision', + ) + assert.equal( + env.RELEASE_REVISION, + env.GITHUB_SHA, + 'Release revision differs from GITHUB_SHA', + ) +} + +function buildReleaseArtifacts() { + return new Promise((resolvePromise, reject) => { + const child = spawn( + process.execPath, + [resolve(repositoryRoot, 'scripts/build-release-artifacts.mjs')], + { + cwd: repositoryRoot, + env: { ...process.env, CI: 'true' }, + stdio: 'inherit', + }, + ) + child.once('error', reject) + child.once('exit', (code, signal) => { + if (code === 0) { + resolvePromise() + return + } + reject( + new Error( + `Release artifact build exited with ${code ?? `signal ${signal ?? 'unknown'}`}`, + ), + ) + }) + }) +} diff --git a/scripts/release-security.mjs b/scripts/release-security.mjs index d704074..362472f 100644 --- a/scripts/release-security.mjs +++ b/scripts/release-security.mjs @@ -1,51 +1,5 @@ import assert from 'node:assert/strict' -export const releaseRepositorySlug = 'TanStack/charts' -export const releaseRepositoryUrl = `https://github.com/${releaseRepositorySlug}` -export const releaseWorkflowPath = '.github/workflows/release.yml' -export const provenancePredicateType = 'https://slsa.dev/provenance/v1' -export const githubWorkflowBuildType = - 'https://slsa-framework.github.io/github-actions-buildtypes/workflow/v1' - -export function validateReleaseEnvironment({ - env, - expectedTag, - expectedRevision = env.GITHUB_SHA, -}) { - assert.equal(env.GITHUB_ACTIONS, 'true', 'Release requires GitHub Actions') - assert.ok( - env.GITHUB_EVENT_NAME === 'push' || - env.GITHUB_EVENT_NAME === 'workflow_dispatch', - 'Release requires a tag push or explicit workflow dispatch', - ) - assert.equal(env.GITHUB_REF_TYPE, 'tag', 'Release requires a tag ref') - assert.equal( - env.GITHUB_REF_NAME, - expectedTag, - `Release requires tag ${expectedTag}`, - ) - assert.equal( - env.GITHUB_REF, - `refs/tags/${expectedTag}`, - `Release requires refs/tags/${expectedTag}`, - ) - assert.equal( - env.GITHUB_REPOSITORY, - releaseRepositorySlug, - `Release requires ${releaseRepositorySlug}`, - ) - assert.match( - expectedRevision ?? '', - /^[0-9a-f]{40}$/, - 'Release requires an exact GitHub revision', - ) - assert.equal( - env.GITHUB_SHA, - expectedRevision, - 'Release revision differs from GITHUB_SHA', - ) -} - export function validateTrustedPublishingNpmVersion(version) { const match = /^(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/.exec(version.trim()) assert.ok(match, `Invalid npm version: ${version}`) @@ -56,172 +10,6 @@ export function validateTrustedPublishingNpmVersion(version) { ) } -export function expectedPackagePurl(name, version) { - const scoped = /^(@[^/]+)\/([^/]+)$/.exec(name) - const encodedName = scoped - ? `${encodeURIComponent(scoped[1])}/${encodeURIComponent(scoped[2])}` - : encodeURIComponent(name) - return `pkg:npm/${encodedName}@${encodeURIComponent(version)}` -} - -export function integritySha512Hex(integrity) { - const match = /^sha512-([A-Za-z0-9+/]+={0,2})$/.exec(integrity) - assert.ok(match, `Expected sha512 integrity, received ${integrity}`) - const digest = Buffer.from(match[1], 'base64') - assert.equal(digest.byteLength, 64, 'sha512 integrity must contain 64 bytes') - assert.equal( - digest.toString('base64'), - match[1], - 'sha512 integrity is not canonical base64', - ) - return digest.toString('hex') -} - -export function decodeProvenanceStatement(attestationDocument) { - assert.ok( - Array.isArray(attestationDocument?.attestations), - 'Attestation response must include attestations', - ) - const matches = attestationDocument.attestations.filter( - (attestation) => attestation?.predicateType === provenancePredicateType, - ) - assert.equal( - matches.length, - 1, - 'Attestation response must include exactly one SLSA provenance bundle', - ) - const envelope = matches[0].bundle?.dsseEnvelope - assert.equal( - envelope?.payloadType, - 'application/vnd.in-toto+json', - 'Provenance bundle has the wrong DSSE payload type', - ) - assert.ok( - Array.isArray(envelope?.signatures) && envelope.signatures.length > 0, - 'Provenance bundle must include a DSSE signature', - ) - assert.match( - envelope?.payload ?? '', - /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/, - 'Provenance payload must be canonical base64', - ) - return JSON.parse(Buffer.from(envelope.payload, 'base64').toString('utf8')) -} - -export function validatePackageProvenance({ - artifact, - attestationDocument, - revision, - tag, -}) { - const statement = decodeProvenanceStatement(attestationDocument) - const expectedRef = `refs/tags/${tag}` - const expectedPurl = expectedPackagePurl( - artifact.name, - artifact.manifest.version, - ) - const expectedSha512 = integritySha512Hex(artifact.integrity) - - assert.equal( - statement?._type, - 'https://in-toto.io/Statement/v1', - `${artifact.name} provenance has the wrong statement type`, - ) - assert.equal( - statement?.predicateType, - provenancePredicateType, - `${artifact.name} provenance has the wrong predicate type`, - ) - assert.equal( - statement?.subject?.length, - 1, - `${artifact.name} provenance must have one subject`, - ) - assert.equal( - statement.subject[0]?.name, - expectedPurl, - `${artifact.name} provenance has the wrong package PURL`, - ) - assert.equal( - statement.subject[0]?.digest?.sha512, - expectedSha512, - `${artifact.name} provenance has the wrong tarball digest`, - ) - - const buildDefinition = statement.predicate?.buildDefinition - assert.equal( - buildDefinition?.buildType, - githubWorkflowBuildType, - `${artifact.name} provenance has the wrong build type`, - ) - assert.deepEqual( - buildDefinition?.externalParameters?.workflow, - { - ref: expectedRef, - repository: releaseRepositoryUrl, - path: releaseWorkflowPath, - }, - `${artifact.name} provenance has the wrong workflow identity`, - ) - - const expectedDependency = `git+${releaseRepositoryUrl}@${expectedRef}` - const resolvedDependencies = buildDefinition?.resolvedDependencies - assert.ok( - Array.isArray(resolvedDependencies), - `${artifact.name} provenance lacks resolved dependencies`, - ) - const sourceDependencies = resolvedDependencies.filter( - (dependency) => dependency?.uri === expectedDependency, - ) - assert.equal( - sourceDependencies.length, - 1, - `${artifact.name} provenance must resolve the tagged repository once`, - ) - assert.equal( - sourceDependencies[0]?.digest?.gitCommit, - revision, - `${artifact.name} provenance resolved the wrong Git commit`, - ) - return statement -} - -export function verifiedAttestationBundles(artifacts, audit) { - assert.deepEqual( - audit?.invalid, - [], - 'npm found invalid registry signatures or attestations', - ) - assert.deepEqual(audit?.missing, [], 'npm found missing registry signatures') - assert.ok( - Array.isArray(audit?.verified), - 'npm signature audit omitted verified packages', - ) - - const bundles = new Map() - for (const artifact of artifacts) { - const matches = audit.verified.filter( - (entry) => - entry?.name === artifact.name && - entry?.version === artifact.manifest.version, - ) - assert.equal( - matches.length, - 1, - `${artifact.name}@${artifact.manifest.version} must have one verified npm attestation`, - ) - assert.ok( - Array.isArray(matches[0].attestationBundles), - `${artifact.name}@${artifact.manifest.version} lacks verified attestation bundles`, - ) - decodeProvenanceStatement({ - attestations: matches[0].attestationBundles, - }) - bundles.set(artifact.name, matches[0].attestationBundles) - } - return bundles -} - function compareVersions(left, right) { for (let index = 0; index < 3; index += 1) { if (left[index] !== right[index]) return left[index] - right[index] diff --git a/scripts/release-security.test.mjs b/scripts/release-security.test.mjs index 238c66f..5fb1735 100644 --- a/scripts/release-security.test.mjs +++ b/scripts/release-security.test.mjs @@ -1,64 +1,8 @@ -import { Buffer } from 'node:buffer' import { describe, expect, it } from 'vitest' -import { - decodeProvenanceStatement, - expectedPackagePurl, - githubWorkflowBuildType, - integritySha512Hex, - provenancePredicateType, - releaseRepositoryUrl, - releaseWorkflowPath, - validatePackageProvenance, - validateReleaseEnvironment, - validateTrustedPublishingNpmVersion, - verifiedAttestationBundles, -} from './release-security.mjs' -import { - parseRemoteRefs, - validateReleaseRevisionEvidence, -} from './verify-release-revision.mjs' +import { validateTrustedPublishingNpmVersion } from './release-security.mjs' -const revision = 'a'.repeat(40) -const tag = 'v0.0.1' -const integrity = `sha512-${Buffer.alloc(64, 0xab).toString('base64')}` -const artifact = { - name: '@tanstack/charts', - integrity, - manifest: { version: '0.0.1' }, -} - -describe('release environment', () => { - it('requires the exact repository, tag ref, and revision', () => { - expect(() => - validateReleaseEnvironment({ - env: releaseEnvironment(), - expectedTag: tag, - expectedRevision: revision, - }), - ).not.toThrow() - expect(() => - validateReleaseEnvironment({ - env: { - ...releaseEnvironment(), - GITHUB_REF: 'refs/heads/main', - }, - expectedTag: tag, - expectedRevision: revision, - }), - ).toThrow(/refs\/tags\/v0\.0\.1/) - expect(() => - validateReleaseEnvironment({ - env: { - ...releaseEnvironment(), - GITHUB_EVENT_NAME: 'workflow_dispatch', - }, - expectedTag: tag, - expectedRevision: revision, - }), - ).not.toThrow() - }) - - it('requires an npm version with trusted publishing support', () => { +describe('npm trusted publishing', () => { + it('requires an npm version with OIDC support', () => { expect(() => validateTrustedPublishingNpmVersion('11.5.1\n')).not.toThrow() expect(() => validateTrustedPublishingNpmVersion('11.18.0')).not.toThrow() expect(() => validateTrustedPublishingNpmVersion('11.5.0')).toThrow( @@ -66,206 +10,3 @@ describe('release environment', () => { ) }) }) - -describe('release ref evidence', () => { - it('requires an annotated remote tag peeled to the checked-out main commit', () => { - const refs = parseRemoteRefs( - `${'b'.repeat(40)}\trefs/tags/${tag}\n${revision}\trefs/tags/${tag}^{}\n`, - ) - expect(() => - validateReleaseRevisionEvidence({ - headRevision: revision, - mainRevision: 'c'.repeat(40), - remoteRefs: refs, - revision, - tag, - isMainAncestor: true, - }), - ).not.toThrow() - refs.delete(`refs/tags/${tag}^{}`) - expect(() => - validateReleaseRevisionEvidence({ - headRevision: revision, - mainRevision: 'c'.repeat(40), - remoteRefs: refs, - revision, - tag, - isMainAncestor: true, - }), - ).toThrow(/must be annotated/) - }) -}) - -describe('npm provenance', () => { - it('derives scoped PURLs and sha512 digests exactly', () => { - expect(expectedPackagePurl('@tanstack/charts', '0.0.1')).toBe( - 'pkg:npm/%40tanstack/charts@0.0.1', - ) - expect(integritySha512Hex(integrity)).toBe('ab'.repeat(64)) - }) - - it('decodes and validates the exact workflow and tagged source revision', () => { - const statement = provenanceStatement() - const document = attestationDocument(statement) - expect(decodeProvenanceStatement(document)).toEqual(statement) - expect( - validatePackageProvenance({ - artifact, - attestationDocument: document, - revision, - tag, - }), - ).toEqual(statement) - }) - - it('rejects a valid-looking provenance statement for another source revision', () => { - const document = attestationDocument( - provenanceStatement({ sourceRevision: 'f'.repeat(40) }), - ) - expect(() => - validatePackageProvenance({ - artifact, - attestationDocument: document, - revision, - tag, - }), - ).toThrow(/resolved the wrong Git commit/) - }) - - it.each([ - [ - 'package PURL', - (statement) => { - statement.subject[0].name = 'pkg:npm/%40tanstack/react-charts@0.0.1' - }, - /wrong package PURL/, - ], - [ - 'tarball digest', - (statement) => { - statement.subject[0].digest.sha512 = 'ff'.repeat(64) - }, - /wrong tarball digest/, - ], - [ - 'repository', - (statement) => { - statement.predicate.buildDefinition.externalParameters.workflow.repository = - 'https://github.com/example/charts' - }, - /wrong workflow identity/, - ], - [ - 'workflow path', - (statement) => { - statement.predicate.buildDefinition.externalParameters.workflow.path = - '.github/workflows/other.yml' - }, - /wrong workflow identity/, - ], - [ - 'tag ref', - (statement) => { - statement.predicate.buildDefinition.externalParameters.workflow.ref = - 'refs/heads/main' - }, - /wrong workflow identity/, - ], - ])('rejects provenance with the wrong %s', (_, mutate, error) => { - const statement = provenanceStatement() - mutate(statement) - expect(() => - validatePackageProvenance({ - artifact, - attestationDocument: attestationDocument(statement), - revision, - tag, - }), - ).toThrow(error) - }) - - it('requires npm to cryptographically verify every release package attestation', () => { - const bundles = attestationDocument(provenanceStatement()).attestations - expect( - verifiedAttestationBundles([artifact], { - invalid: [], - missing: [], - verified: [ - { - name: artifact.name, - version: artifact.manifest.version, - attestationBundles: bundles, - }, - ], - }).get(artifact.name), - ).toEqual(bundles) - expect(() => - verifiedAttestationBundles([artifact], { - invalid: [], - missing: [], - verified: [], - }), - ).toThrow(/must have one verified npm attestation/) - }) -}) - -function releaseEnvironment() { - return { - GITHUB_ACTIONS: 'true', - GITHUB_EVENT_NAME: 'push', - GITHUB_REF_TYPE: 'tag', - GITHUB_REF_NAME: tag, - GITHUB_REF: `refs/tags/${tag}`, - GITHUB_REPOSITORY: 'TanStack/charts', - GITHUB_SHA: revision, - } -} - -function provenanceStatement({ sourceRevision = revision } = {}) { - const ref = `refs/tags/${tag}` - return { - _type: 'https://in-toto.io/Statement/v1', - subject: [ - { - name: 'pkg:npm/%40tanstack/charts@0.0.1', - digest: { sha512: 'ab'.repeat(64) }, - }, - ], - predicateType: provenancePredicateType, - predicate: { - buildDefinition: { - buildType: githubWorkflowBuildType, - externalParameters: { - workflow: { - ref, - repository: releaseRepositoryUrl, - path: releaseWorkflowPath, - }, - }, - resolvedDependencies: [ - { - uri: `git+${releaseRepositoryUrl}@${ref}`, - digest: { gitCommit: sourceRevision }, - }, - ], - }, - }, - } -} - -function attestationDocument(statement) { - return { - attestations: [ - { - predicateType: provenancePredicateType, - bundle: { - dsseEnvelope: { - payload: Buffer.from(JSON.stringify(statement)).toString('base64'), - payloadType: 'application/vnd.in-toto+json', - signatures: [{ sig: 'example' }], - }, - }, - }, - ], - } -} diff --git a/scripts/release-workflow.test.mjs b/scripts/release-workflow.test.mjs index b1b1499..0998be6 100644 --- a/scripts/release-workflow.test.mjs +++ b/scripts/release-workflow.test.mjs @@ -10,16 +10,8 @@ const workflow = await readFile( resolve(import.meta.dirname, '../.github/workflows/release.yml'), 'utf8', ) -const releaseStatusScript = await readFile( - resolve(import.meta.dirname, './release-status.mjs'), - 'utf8', -) -const verifyCiScript = await readFile( - resolve(import.meta.dirname, './verify-ci-success.mjs'), - 'utf8', -) -const verifyPublishedScript = await readFile( - resolve(import.meta.dirname, './verify-published-release.mjs'), +const publisher = await readFile( + resolve(import.meta.dirname, './publish-release.mjs'), 'utf8', ) const changesetConfig = JSON.parse( @@ -51,202 +43,87 @@ describe('release workflow contract', () => { ]) }) - test('synchronizes changelogs and release-facing docs in version pull requests', () => { + test('keeps version and publication behind explicit Changesets scripts', () => { assert.equal( packageManifest.scripts['changeset:version'], 'changeset version && node scripts/sync-release-changelog.mjs && node scripts/sync-release-version.mjs && pnpm docs:sync && pnpm install --lockfile-only --ignore-scripts --no-frozen-lockfile && pnpm format', ) + assert.equal( + packageManifest.scripts['changeset:publish'], + 'node scripts/publish-release.mjs', + ) }) - test('orchestrates versions and tags only from successful main CI', () => { - assert.match( - workflow, - /workflow_run:[\s\S]*workflows:[\s\S]*Chart library benchmarks[\s\S]*types:[\s\S]*completed/, - ) - assert.match(workflow, /push:[\s\S]*tags:[\s\S]*['"]v\*['"]/) - assert.match(workflow, /workflow_dispatch:/) - assert.match(workflow, /group:\s*charts-release\s*$/m) + test('uses the standard push-to-main Changesets flow', () => { + assert.match(workflow, /push:\s*\n\s+branches:\s*\n\s+- main/) + assert.doesNotMatch(workflow, /workflow_run:|workflow_dispatch:|tags:/) + assert.match(workflow, /group:\s*charts-release-\${{ github\.ref }}/) assert.match(workflow, /cancel-in-progress:\s*false/) - const version = job('version') - assert.match(version, /github\.event_name == 'workflow_run'/) - assert.match( - version, - /github\.event\.workflow_run\.conclusion == 'success'/, - ) - assert.match(version, /github\.event\.workflow_run\.event == 'push'/) - assert.match(version, /github\.event\.workflow_run\.head_branch == 'main'/) - assert.match( - version, - /ref:\s*\${{ github\.event\.workflow_run\.head_sha }}/, - ) - assert.match(version, /persist-credentials:\s*true/) - assert.match(version, /changesets\/action@[0-9a-f]{40}/) - assert.match(version, /id:\s*changesets/) - assert.match(version, /version:\s*pnpm changeset:version/) - assert.match(version, /createGithubReleases:\s*false/) - assert.match( - version, - /if:\s*steps\.changesets\.outputs\.pullRequestNumber != ''/, - ) - assert.match(version, /gh pr view "\$VERSION_PR_NUMBER"/) - assert.match( - version, - /gh workflow run chart-library-benchmarks\.yml[\s\S]*--ref "\$version_ref"[\s\S]*-f version_pr=true/, - ) - assert.match(version, /actions:\s*write/) - assert.match(version, /contents:\s*write/) - assert.match(version, /pull-requests:\s*write/) - assert.doesNotMatch(version, /id-token:\s*write/) + const release = job('release') + assert.match(release, /github\.repository_owner == 'TanStack'/) + assert.match(release, /contents:\s*write/) + assert.match(release, /id-token:\s*write/) + assert.match(release, /pull-requests:\s*write/) + assert.doesNotMatch(release, /actions:\s*write/) + assert.match(release, /fetch-depth:\s*0/) + assert.match(release, /persist-credentials:\s*true/) + assert.match(release, /changesets\/action@[0-9a-f]{40}/) + assert.match(release, /id:\s*changesets/) + assert.match(release, /branch:\s*main/) + assert.match(release, /version:\s*pnpm changeset:version/) + assert.match(release, /publish:\s*pnpm changeset:publish/) + assert.match(release, /createGithubReleases:\s*false/) + assert.match(release, /RELEASE_REVISION:\s*\${{ github\.sha }}/) + assert.doesNotMatch(release, /NPM_TOKEN|NODE_AUTH_TOKEN/) + assert.equal((workflow.match(/id-token:\s*write/g) ?? []).length, 1) }) - test('creates an annotated tag and explicitly dispatches its release', () => { - const tag = job('tag') - assert.deepEqual(needs(tag), ['version']) - assert.match(tag, /github\.event\.workflow_run\.conclusion == 'success'/) - assert.match(tag, /github\.event\.workflow_run\.head_branch == 'main'/) - assert.match(tag, /ref:\s*\${{ github\.event\.workflow_run\.head_sha }}/) - assert.match(tag, /node scripts\/release-status\.mjs/) + test('builds checked tarballs only when npm has a release to publish', () => { + assert.match(publisher, /validatePublishEnvironment\(process\.env\)/) + assert.match(publisher, /GITHUB_REF_TYPE, 'branch'/) + assert.match(publisher, /GITHUB_REF_NAME, 'main'/) + assert.match(publisher, /'refs\/heads\/main'/) + assert.match(publisher, /'TanStack\/charts'/) + assert.match(publisher, /const status = await releaseStatus/) assert.ok( - tag.indexOf('release-status.mjs') < tag.indexOf('git tag -a'), - 'release notes and registry state must be valid before a tag is created', + publisher.lastIndexOf("status.reason === 'finalize'") > + publisher.indexOf('validateRegistryPackage(artifact, registry)'), + 'recovery must verify registry contents before finalization', ) - assert.match( - tag, - /RELEASE_REVISION:\s*\${{ github\.event\.workflow_run\.head_sha }}/, - ) - assert.match(tag, /git tag -a "\$RELEASE_TAG" "\$RELEASE_REVISION"/) - assert.match(tag, /git push origin "refs\/tags\/\$RELEASE_TAG"/) - assert.match(tag, /gh workflow run release\.yml --ref "\$RELEASE_TAG"/) + assert.match(publisher, /await buildReleaseArtifacts\(\)/) + assert.match(publisher, /await validateReleaseArtifacts\(repositoryRoot\)/) assert.ok( - tag.indexOf('git tag -a') < tag.indexOf('gh workflow run release.yml'), - 'the annotated tag must exist before its tag-scoped release is dispatched', + publisher.indexOf('await publishArtifact(coreArtifact)') < + publisher.indexOf('await runWithConcurrency('), + 'core must publish before adapters', ) - assert.match(tag, /actions:\s*write/) - assert.match(tag, /contents:\s*write/) - assert.doesNotMatch(tag, /id-token:\s*write/) - assert.doesNotMatch(tag, /pull-requests:\s*write/) - }) - - test('builds fresh artifacts only after tag and exact-SHA CI verification', () => { - const validate = job('validate') + assert.match(publisher, /New tag: \${artifact\.name}@\${version}/) + assert.match(publisher, /validateTrustedPublishingNpmVersion/) assert.match( - validate, - /if:\s*github\.event_name == 'push' \|\| github\.event_name == 'workflow_dispatch'/, - ) - assert.match(validate, /ref:\s*\${{ github\.sha }}/) - assert.match(validate, /persist-credentials:\s*false/) - assert.match(validate, /actions:\s*read/) - assert.match(validate, /contents:\s*read/) - assert.doesNotMatch(validate, /(?:actions|contents):\s*write/) - assert.doesNotMatch(validate, /id-token:\s*write/) - assert.match(validate, /node scripts\/verify-release-revision\.mjs/) - assert.match(validate, /node scripts\/verify-ci-success\.mjs/) - assert.match(validate, /node scripts\/build-release-artifacts\.mjs/) - assert.match(validate, /node scripts\/publish-release\.mjs --check/) - assert.match(validate, /actions\/upload-artifact@[0-9a-f]{40}/) - assert.match(validate, /name:\s*charts-release-\${{ github\.ref_name }}/) - assert.match(validate, /path:\s*\.release-artifacts/) - assert.doesNotMatch(validate, /actions\/download-artifact@/) - assert.ok( - validate.indexOf('verify-ci-success.mjs') < - validate.indexOf('build-release-artifacts.mjs'), - 'the exact revision must be CI-approved before package artifacts are built', + publisher, + /Verified \${manifest\.tag} registry integrity and attestations before finalization/, ) }) - test('isolates npm OIDC to artifact-only publication', () => { - const publish = job('publish') - assert.equal((workflow.match(/id-token:\s*write/g) ?? []).length, 1) - assert.match(publish, /id-token:\s*write/) - assert.match(publish, /contents:\s*read/) - assert.doesNotMatch(publish, /(?:actions|contents):\s*write/) - assert.deepEqual(needs(publish), ['validate']) - assert.match(publish, /if:\s*needs\.validate\.result == 'success'/) - assert.match(publish, /ref:\s*\${{ github\.sha }}/) - assert.match(publish, /persist-credentials:\s*false/) - assert.match(publish, /actions\/download-artifact@[0-9a-f]{40}/) - assert.match(publish, /name:\s*charts-release-\${{ github\.ref_name }}/) - assert.match(publish, /path:\s*\.release-artifacts/) - assert.match(publish, /node scripts\/verify-release-revision\.mjs/) - assert.match(publish, /node scripts\/publish-release\.mjs/) - assert.doesNotMatch(publish, /\.\/\.github\/actions\/setup/) - assert.doesNotMatch(publish, /\b(?:npm|pnpm) install\b/) - assert.doesNotMatch(publish, /\bcorepack enable\b/) - assert.doesNotMatch(publish, /\bpnpm (?:test|typecheck|docs:check)\b/) - assert.doesNotMatch(publish, /\bNPM_TOKEN\b|\bNODE_AUTH_TOKEN\b/) - assert.match(publish, /package-manager-cache:\s*false/) - assert.ok( - publish.indexOf('verify-release-revision.mjs') < - publish.indexOf('publish-release.mjs'), - 'publication must revalidate the tag before invoking npm', - ) - }) - - test('gates the GitHub release on independent registry verification', () => { - const verify = job('verify') - assert.deepEqual(needs(verify), ['publish']) - assert.match(verify, /ref:\s*\${{ github\.sha }}/) - assert.match(verify, /persist-credentials:\s*false/) - assert.match(verify, /actions\/download-artifact@[0-9a-f]{40}/) - assert.match(verify, /node scripts\/verify-published-release\.mjs/) - assert.match(verify, /package-manager-cache:\s*false/) - assert.doesNotMatch(verify, /id-token:\s*write/) - assert.doesNotMatch(verify, /contents:\s*write/) - + test('finalizes one recoverable aggregate tag and GitHub release', () => { const release = job('release') - assert.deepEqual(needs(release), ['verify']) - assert.match(release, /ref:\s*\${{ github\.sha }}/) - assert.match(release, /persist-credentials:\s*false/) - assert.match(release, /node scripts\/verify-release-revision\.mjs/) + assert.match( + release, + /if:\s*steps\.changesets\.outputs\.hasChangesets == 'false'/, + ) + assert.match(release, /node scripts\/release-status\.mjs/) + assert.match(release, /if:\s*steps\.status\.outputs\.create_tag == 'true'/) + assert.match(release, /git tag -a "\$RELEASE_TAG" "\$RELEASE_REVISION"/) + assert.match(release, /git push origin "refs\/tags\/\$RELEASE_TAG"/) + assert.match(release, /if:\s*steps\.status\.outputs\.dispatch == 'true'/) assert.match(release, /node scripts\/write-release-notes\.mjs/) - assert.match(release, /gh release create "\$GITHUB_REF_NAME"/) + assert.match(release, /gh release create "\$RELEASE_TAG"/) assert.match(release, /--verify-tag/) - assert.match(release, /--notes-file \/tmp\/charts-release-notes\.md/) - assert.match(release, /package-manager-cache:\s*false/) - assert.doesNotMatch(release, /--notes-file CHANGELOG\.md/) - assert.doesNotMatch(release, /id-token:\s*write/) - assert.ok( - release.indexOf('verify-release-revision.mjs') < - release.indexOf('gh release create'), - 'the GitHub release must use freshly validated refs', - ) }) test('pins every external action to an immutable revision', () => { assertPinnedExternalActions(workflow) - assert.equal( - (workflow.match(/package-manager-cache:\s*false/g) ?? []).length, - 3, - ) - assert.doesNotMatch(workflow, /^\s*cache:\s*['"]{2}\s*$/m) - }) - - test('bounds every direct release network request', () => { - assert.equal( - ( - releaseStatusScript.match( - /signal:\s*AbortSignal\.timeout\(requestTimeout\)/g, - ) ?? [] - ).length, - 3, - ) - assert.equal( - ( - verifyCiScript.match( - /signal:\s*AbortSignal\.timeout\(requestTimeout\)/g, - ) ?? [] - ).length, - 1, - ) - assert.equal( - ( - verifyPublishedScript.match( - /signal:\s*AbortSignal\.timeout\(requestTimeout\)/g, - ) ?? [] - ).length, - 1, - ) }) }) @@ -281,31 +158,6 @@ function job(name) { return lines.slice(start, end).join('\n') } -function needs(block) { - const scalar = block.match(/^\s*needs:\s*([A-Za-z0-9_-]+)\s*$/m) - if (scalar) return [scalar[1]] - return scalarList(block, 'needs') -} - -function scalarList(block, key) { - const lines = block.split(/\r?\n/) - const start = lines.findIndex((line) => - new RegExp(`^\\s*${escapeRegExp(key)}:\\s*$`).test(line), - ) - assert.notEqual(start, -1, `expected ${key} list`) - const keyIndent = indentation(lines[start]) - const values = [] - for (let index = start + 1; index < lines.length; index += 1) { - if (!lines[index].trim() || lines[index].trimStart().startsWith('#')) { - continue - } - if (indentation(lines[index]) <= keyIndent) break - const item = lines[index].match(/^\s*-\s*([^#]+?)\s*$/) - if (item) values.push(item[1].replace(/^['"]|['"]$/g, '')) - } - return values -} - function assertPinnedExternalActions(source) { const uses = [...source.matchAll(/^\s*uses:\s*([^\s#]+)\s*(?:#.*)?$/gm)].map( (match) => match[1], diff --git a/scripts/verify-ci-success.mjs b/scripts/verify-ci-success.mjs deleted file mode 100644 index 0916bc3..0000000 --- a/scripts/verify-ci-success.mjs +++ /dev/null @@ -1,61 +0,0 @@ -import assert from 'node:assert/strict' -import { resolve } from 'node:path' -import { pathToFileURL } from 'node:url' -import { releaseRepositorySlug } from './release-security.mjs' - -const requestTimeout = 30_000 - -export function findSuccessfulMainRun(runs, revision) { - return runs.find( - (run) => - run?.head_sha === revision && - run?.head_branch === 'main' && - run?.event === 'push' && - run?.status === 'completed' && - run?.conclusion === 'success', - ) -} - -export async function verifyCiSuccess({ env = process.env } = {}) { - const revision = env.GITHUB_SHA - const repository = env.GITHUB_REPOSITORY - assert.match(revision ?? '', /^[0-9a-f]{40}$/, 'CI gate requires a SHA') - assert.equal( - repository, - releaseRepositorySlug, - `CI gate requires ${releaseRepositorySlug}`, - ) - assert.ok(env.GITHUB_TOKEN, 'CI gate requires GITHUB_TOKEN') - - const query = new URLSearchParams({ - head_sha: revision, - per_page: '100', - status: 'success', - }) - const response = await fetch( - `https://api.github.com/repos/${repository}/actions/workflows/chart-library-benchmarks.yml/runs?${query}`, - { - headers: { - accept: 'application/vnd.github+json', - authorization: `Bearer ${env.GITHUB_TOKEN}`, - 'x-github-api-version': '2022-11-28', - }, - signal: AbortSignal.timeout(requestTimeout), - }, - ) - assert.equal( - response.ok, - true, - `GitHub returned HTTP ${response.status} while checking CI`, - ) - const payload = await response.json() - const run = findSuccessfulMainRun(payload.workflow_runs ?? [], revision) - assert.ok(run, `No successful main CI run exists for ${revision}`) - console.log(`Verified successful main CI run ${run.id} for ${revision}.`) - return run -} - -const entrypoint = process.argv[1] -if (entrypoint && import.meta.url === pathToFileURL(resolve(entrypoint)).href) { - await verifyCiSuccess() -} diff --git a/scripts/verify-ci-success.test.mjs b/scripts/verify-ci-success.test.mjs deleted file mode 100644 index a0736ae..0000000 --- a/scripts/verify-ci-success.test.mjs +++ /dev/null @@ -1,30 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { findSuccessfulMainRun } from './verify-ci-success.mjs' - -const revision = 'a'.repeat(40) - -describe('release CI gate', () => { - it('accepts only a completed successful main push for the exact revision', () => { - const valid = { - id: 1, - conclusion: 'success', - event: 'push', - head_branch: 'main', - head_sha: revision, - status: 'completed', - } - expect( - findSuccessfulMainRun( - [ - { ...valid, id: 2, head_sha: 'b'.repeat(40) }, - { ...valid, id: 3, event: 'pull_request' }, - { ...valid, id: 4, conclusion: 'failure' }, - { ...valid, id: 5, head_branch: 'release' }, - { ...valid, id: 6, status: 'in_progress' }, - valid, - ], - revision, - ), - ).toEqual(valid) - }) -}) diff --git a/scripts/verify-published-release.mjs b/scripts/verify-published-release.mjs deleted file mode 100644 index 90e11c6..0000000 --- a/scripts/verify-published-release.mjs +++ /dev/null @@ -1,243 +0,0 @@ -import assert from 'node:assert/strict' -import { execFile } from 'node:child_process' -import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { resolve } from 'node:path' -import { promisify } from 'node:util' -import { - normalizeRegistryPackageMetadata, - validateReleaseArtifacts, -} from './release-artifacts.mjs' -import { - validatePackageProvenance, - validateReleaseEnvironment, - validateTrustedPublishingNpmVersion, - verifiedAttestationBundles, -} from './release-security.mjs' - -const execFileAsync = promisify(execFile) -const repositoryRoot = resolve(import.meta.dirname, '..') -const registry = 'https://registry.npmjs.org' -const requestTimeout = 30_000 -const { artifacts, manifest, version } = - await validateReleaseArtifacts(repositoryRoot) - -validateReleaseEnvironment({ - env: process.env, - expectedTag: manifest.tag, - expectedRevision: process.env.GITHUB_SHA, -}) -validateTrustedPublishingNpmVersion( - (await runNpm(['--version'], repositoryRoot)).stdout, -) - -const installDirectory = await mkdtemp( - resolve(tmpdir(), 'tanstack-charts-release-verification-'), -) -try { - await writeFile( - resolve(installDirectory, 'package.json'), - `${JSON.stringify( - { - name: 'tanstack-charts-release-verification', - version: '0.0.0', - private: true, - dependencies: Object.fromEntries( - artifacts.map((artifact) => [artifact.name, version]), - ), - }, - null, - 2, - )}\n`, - ) - await runNpm( - [ - 'install', - '--ignore-scripts', - '--no-audit', - '--no-fund', - '--save-exact', - `--registry=${registry}`, - ], - installDirectory, - true, - ) - const signatureAudit = JSON.parse( - ( - await runNpm( - [ - 'audit', - 'signatures', - '--json', - '--include-attestations', - `--registry=${registry}`, - ], - installDirectory, - ) - ).stdout, - ) - const auditedBundles = verifiedAttestationBundles(artifacts, signatureAudit) - console.log( - `npm verified registry signatures and provenance for ${auditedBundles.size} release packages.`, - ) - - const lockfile = JSON.parse( - await readFile(resolve(installDirectory, 'package-lock.json'), 'utf8'), - ) - for (const artifact of artifacts) { - await verifyInstalledPackage(artifact, installDirectory, lockfile) - const metadata = await waitForRegistryMetadata(artifact) - const attestationDocument = await waitForAttestations( - metadata.dist.attestations.url, - ) - assert.deepEqual( - attestationDocument.attestations, - auditedBundles.get(artifact.name), - `${artifact.name} fetched attestations differ from npm's verified bundles`, - ) - validatePackageProvenance({ - artifact, - attestationDocument, - revision: process.env.GITHUB_SHA, - tag: manifest.tag, - }) - console.log(`Verified published provenance: ${artifact.name}@${version}`) - } -} finally { - await rm(installDirectory, { recursive: true, force: true }) -} - -console.log( - `Verified ${artifacts.length} installed packages, registry signatures, and provenance bundles for ${manifest.tag}.`, -) - -async function verifyInstalledPackage(artifact, directory, lockfile) { - const packagePath = resolve( - directory, - 'node_modules', - ...artifact.name.split('/'), - 'package.json', - ) - const installedManifest = JSON.parse(await readFile(packagePath, 'utf8')) - assert.equal( - installedManifest.name, - artifact.name, - `${artifact.name} installed with the wrong name`, - ) - assert.equal( - installedManifest.version, - artifact.manifest.version, - `${artifact.name} installed at the wrong version`, - ) - assert.deepEqual( - installedManifest.repository, - artifact.packedManifest.repository, - `${artifact.name} installed with stale repository metadata`, - ) - if (artifact.name !== '@tanstack/charts') { - assert.equal( - installedManifest.dependencies?.['@tanstack/charts'], - artifact.manifest.version, - `${artifact.name} installed with the wrong core dependency`, - ) - } - - const lockEntry = lockfile.packages?.[`node_modules/${artifact.name}`] - assert.equal( - lockEntry?.version, - artifact.manifest.version, - `${artifact.name} lock entry has the wrong version`, - ) - assert.equal( - lockEntry?.integrity, - artifact.integrity, - `${artifact.name} installed tarball differs from the checked artifact`, - ) -} - -async function waitForRegistryMetadata(artifact) { - let lastMetadata = null - for (let attempt = 0; attempt < 30; attempt += 1) { - try { - const { stdout } = await runNpm( - [ - 'view', - `${artifact.name}@${artifact.manifest.version}`, - 'name', - 'version', - 'dist.integrity', - 'dist.attestations', - '--json', - `--registry=${registry}`, - ], - repositoryRoot, - ) - lastMetadata = normalizeRegistryPackageMetadata(JSON.parse(stdout)) - if ( - lastMetadata.name === artifact.name && - lastMetadata.version === artifact.manifest.version && - lastMetadata.dist?.integrity === artifact.integrity && - typeof lastMetadata.dist?.attestations?.url === 'string' - ) { - assertAttestationUrl(lastMetadata.dist.attestations.url) - return lastMetadata - } - } catch (error) { - if (!error?.stderr?.includes('E404')) throw error - } - await delay() - } - assert.fail( - `${artifact.name}@${artifact.manifest.version} registry metadata did not stabilize: ${JSON.stringify(lastMetadata)}`, - ) -} - -async function waitForAttestations(url) { - assertAttestationUrl(url) - let lastStatus = null - for (let attempt = 0; attempt < 30; attempt += 1) { - const response = await fetch(url, { - headers: { accept: 'application/json' }, - redirect: 'error', - signal: AbortSignal.timeout(requestTimeout), - }) - lastStatus = response.status - if (response.ok) return response.json() - await response.body?.cancel() - if (response.status !== 404) { - assert.fail(`Attestation endpoint returned HTTP ${response.status}`) - } - await delay() - } - assert.fail(`Attestation endpoint did not stabilize; last HTTP ${lastStatus}`) -} - -function assertAttestationUrl(value) { - const url = new URL(value) - assert.equal( - url.origin, - registry, - 'Attestation URL must use the npm registry', - ) - assert.ok( - url.pathname.startsWith('/-/npm/v1/attestations/'), - 'Attestation URL has an unexpected path', - ) - assert.equal(url.search, '', 'Attestation URL must not include a query') - assert.equal(url.hash, '', 'Attestation URL must not include a fragment') -} - -function delay() { - return new Promise((resolvePromise) => setTimeout(resolvePromise, 2_000)) -} - -async function runNpm(args, cwd, logOutput = false) { - const result = await execFileAsync('npm', args, { - cwd, - env: { ...process.env }, - maxBuffer: 50 * 1024 * 1024, - }) - if (logOutput && result.stdout) process.stdout.write(result.stdout) - if (logOutput && result.stderr) process.stderr.write(result.stderr) - return result -} diff --git a/scripts/verify-release-revision.mjs b/scripts/verify-release-revision.mjs deleted file mode 100644 index 5f14448..0000000 --- a/scripts/verify-release-revision.mjs +++ /dev/null @@ -1,140 +0,0 @@ -import assert from 'node:assert/strict' -import { execFile } from 'node:child_process' -import { resolve } from 'node:path' -import { pathToFileURL } from 'node:url' -import { promisify } from 'node:util' -import { readReleasePackages, releaseTag } from './release-package-config.mjs' -import { - releaseRepositoryUrl, - validateReleaseEnvironment, -} from './release-security.mjs' - -const execFileAsync = promisify(execFile) - -export function parseRemoteRefs(output) { - const refs = new Map() - for (const line of output.trim().split('\n')) { - if (!line) continue - const match = /^([0-9a-f]{40})\t(.+)$/.exec(line) - assert.ok(match, `Invalid git ls-remote line: ${line}`) - assert.equal(refs.has(match[2]), false, `Duplicate remote ref ${match[2]}`) - refs.set(match[2], match[1]) - } - return refs -} - -export function validateReleaseRevisionEvidence({ - headRevision, - mainRevision, - remoteRefs, - revision, - tag, - isMainAncestor, -}) { - const tagRef = `refs/tags/${tag}` - assert.equal( - headRevision, - revision, - `Checked-out revision ${headRevision} differs from ${revision}`, - ) - assert.match(mainRevision, /^[0-9a-f]{40}$/, 'Remote main is not a commit') - assert.ok(remoteRefs.has(tagRef), `Remote tag ${tagRef} does not exist`) - assert.ok( - remoteRefs.has(`${tagRef}^{}`), - `Remote tag ${tagRef} must be annotated`, - ) - assert.equal( - remoteRefs.get(`${tagRef}^{}`), - revision, - `Remote tag ${tagRef} peels to the wrong commit`, - ) - assert.equal( - isMainAncestor, - true, - `Release revision ${revision} is not on remote main ${mainRevision}`, - ) -} - -export async function verifyReleaseRevision({ - env = process.env, - repositoryRoot = resolve(import.meta.dirname, '..'), -} = {}) { - const packages = await readReleasePackages(repositoryRoot) - const expectedTag = releaseTag(packages[0].manifest.version) - const revision = env.GITHUB_SHA - validateReleaseEnvironment({ env, expectedTag, expectedRevision: revision }) - - const remoteUrl = ( - await runGit(repositoryRoot, ['remote', 'get-url', 'origin']) - ).trim() - assert.ok( - remoteUrl === releaseRepositoryUrl || - remoteUrl === `${releaseRepositoryUrl}.git`, - `origin is ${remoteUrl}, expected ${releaseRepositoryUrl}`, - ) - - await runGit(repositoryRoot, [ - 'fetch', - '--no-tags', - 'origin', - 'refs/heads/main', - ]) - const mainRevision = ( - await runGit(repositoryRoot, ['rev-parse', 'FETCH_HEAD^{commit}']) - ).trim() - const tagRef = `refs/tags/${expectedTag}` - const remoteRefs = parseRemoteRefs( - await runGit(repositoryRoot, [ - 'ls-remote', - '--exit-code', - 'origin', - tagRef, - `${tagRef}^{}`, - ]), - ) - const headRevision = ( - await runGit(repositoryRoot, ['rev-parse', 'HEAD^{commit}']) - ).trim() - const isMainAncestor = await gitSucceeds(repositoryRoot, [ - 'merge-base', - '--is-ancestor', - revision, - mainRevision, - ]) - - validateReleaseRevisionEvidence({ - headRevision, - mainRevision, - remoteRefs, - revision, - tag: expectedTag, - isMainAncestor, - }) - console.log( - `Verified ${tagRef} peels to ${revision} on remote main ${mainRevision}.`, - ) -} - -async function runGit(repositoryRoot, args) { - const { stdout } = await execFileAsync('git', args, { - cwd: repositoryRoot, - env: { ...process.env }, - maxBuffer: 5 * 1024 * 1024, - }) - return stdout -} - -async function gitSucceeds(repositoryRoot, args) { - try { - await runGit(repositoryRoot, args) - return true - } catch (error) { - if (error?.code === 1) return false - throw error - } -} - -const entrypoint = process.argv[1] -if (entrypoint && import.meta.url === pathToFileURL(resolve(entrypoint)).href) { - await verifyReleaseRevision() -}