diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2cabb6da..e1d41ef6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -22,9 +22,18 @@ jobs: runs-on: ubuntu-24.04 steps: - name: Check out the tagged source - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + ref: ${{ github.sha }} fetch-depth: 0 + persist-credentials: false + - name: Verify exact checkout + env: + INKSPAN_EXPECTED_HEAD_SHA: ${{ github.sha }} + run: | + set -euo pipefail + actual_head="$(git rev-parse HEAD)" + test "$actual_head" = "$INKSPAN_EXPECTED_HEAD_SHA" - name: Set up pnpm uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 - name: Set up Node.js @@ -468,7 +477,6 @@ jobs: gh release edit "$GITHUB_REF_NAME" \ --repo "$GITHUB_REPOSITORY" \ --draft=false - release_immutable="$(gh release view "$GITHUB_REF_NAME" \ --repo "$GITHUB_REPOSITORY" \ --json isImmutable \ diff --git a/src/releaseExactCheckout.test.ts b/src/releaseExactCheckout.test.ts new file mode 100644 index 00000000..bb7fd53b --- /dev/null +++ b/src/releaseExactCheckout.test.ts @@ -0,0 +1,95 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +const RELEASE_CHECKOUT_ACTION = + 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1'; + +/** Read one authoritative repository file as UTF-8 text. */ +function repositoryFile(path: string): string { + return readFileSync(resolve(process.cwd(), path), 'utf8'); +} + +/** Extract one top-level workflow job without allowing another job to satisfy it. */ +function workflowJob(source: string, jobName: string, nextJobName: string): string { + const start = source.indexOf(` ${jobName}:`); + const end = source.indexOf(` ${nextJobName}:`, start + 1); + expect(start).toBeGreaterThan(-1); + expect(end).toBeGreaterThan(start); + return source.slice(start, end); +} + +/** Extract one named workflow step so unrelated steps cannot satisfy its contract. */ +function workflowStep(source: string, stepName: string, nextStepName: string): string { + const start = source.indexOf(` - name: ${stepName}`); + const end = source.indexOf(` - name: ${nextStepName}`, start + 1); + expect(start).toBeGreaterThan(-1); + expect(end).toBeGreaterThan(start); + return source.slice(start, end); +} + +describe('release artifact checkout authority', () => { + it('binds the artifact build to the repository checkout baseline and exact tag SHA before setup', () => { + const workflow = repositoryFile('.github/workflows/release.yml'); + const buildJob = workflowJob( + workflow, + 'build-release-artifacts', + 'browser-release-evidence', + ); + const checkoutStep = workflowStep( + buildJob, + 'Check out the tagged source', + 'Verify exact checkout', + ); + const verifyStep = workflowStep( + buildJob, + 'Verify exact checkout', + 'Set up pnpm', + ); + + expect(checkoutStep).toContain(`uses: ${RELEASE_CHECKOUT_ACTION}`); + expect(checkoutStep).toContain('with:'); + expect(checkoutStep).toContain('ref: ${{ github.sha }}'); + expect(checkoutStep).toContain('fetch-depth: 0'); + expect(checkoutStep).toContain('persist-credentials: false'); + expect(verifyStep).toContain('INKSPAN_EXPECTED_HEAD_SHA: ${{ github.sha }}'); + expect(verifyStep).toContain('actual_head="$(git rev-parse HEAD)"'); + expect(verifyStep).toContain( + 'test "$actual_head" = "$INKSPAN_EXPECTED_HEAD_SHA"', + ); + + expect(buildJob.indexOf('name: Verify exact checkout')).toBeLessThan( + buildJob.indexOf('name: Set up pnpm'), + ); + expect(buildJob.indexOf('name: Verify exact checkout')).toBeLessThan( + buildJob.indexOf('name: Set up Node.js'), + ); + expect(buildJob.indexOf('name: Verify exact checkout')).toBeLessThan( + buildJob.indexOf('name: Set up Python'), + ); + }); + + it('preserves the established prerelease tag semantics while npm publication remains gated', () => { + const workflow = repositoryFile('.github/workflows/release.yml'); + const buildJob = workflowJob( + workflow, + 'build-release-artifacts', + 'browser-release-evidence', + ); + const identityStep = workflowStep( + buildJob, + 'Verify release identity and current main tip', + 'Install JavaScript dependencies', + ); + + expect(identityStep).toContain( + "if (!/^v(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)(?:-[0-9A-Za-z.-]+)?$/.test(releaseTag))", + ); + expect(identityStep).toContain('Release tag is not valid semantic version syntax'); + expect(identityStep).not.toContain('valid stable semantic version syntax'); + expect(workflow).toContain( + "github.repository == 'ContextualWisdomLab/inkspan' && !contains(github.ref_name, '-')", + ); + }); +});