diff --git a/.github/release-workflows/sync.py b/.github/release-workflows/sync.py index b3b4435..f3aaa7d 100644 --- a/.github/release-workflows/sync.py +++ b/.github/release-workflows/sync.py @@ -115,7 +115,23 @@ def type_gate(text, manager, offline): return ''.join(lines) +def initialize_release_state(text): + match = re.search(r'^ publish-github-packages:\n[\s\S]*?(?=^ [a-z][\w-]*:\n|\Z)', text, re.M) + if not match: + raise ValueError('Missing publication job') + job = match.group(0) + # Runner contexts are available in steps, not jobs..env. + job = job.replace(' RELEASE_STATE: ${{ runner.temp }}/release-${{ github.run_id }}-${{ github.run_attempt }}.json\n', '') + if ' - name: Initialize release state path\n' not in job: + job = once(job, ' steps:\n', ' steps:\n' + fragment('state') + '\n') + return once(text, match.group(0), job) + + def harden(text, policy): + return initialize_release_state(harden_workflow(text, policy)) + + +def harden_workflow(text, policy): manager = policy['manager'] text = text.replace('# Release safety managed by humanspeak/svelte-diff .github/release-workflows/sync.py\n', HEADER) if HEADER in text: @@ -184,7 +200,6 @@ def harden(text, policy): else: chunk = re.sub(r'^ if: (.+)$', r" if: needs.debug-check.result == 'success' && \1", chunk, count=1, flags=re.M) chunk = once(chunk, ' permissions:\n', ' env:\n' - ' RELEASE_STATE: ${{ runner.temp }}/release-${{ github.run_id }}-${{ github.run_attempt }}.json\n' ' PREPARED_SHA: ${{ needs.prepare.outputs.checkout_sha }}\n permissions:\n') chunk = re.sub(r'(uses: actions/checkout@[^\n]+\n with:\n)', r'\1 ref: ${{ needs.prepare.outputs.checkout_sha }}\n', chunk) diff --git a/.github/release-workflows/sync_test.py b/.github/release-workflows/sync_test.py index db77ec0..fd606b9 100644 --- a/.github/release-workflows/sync_test.py +++ b/.github/release-workflows/sync_test.py @@ -127,6 +127,20 @@ def test_manager_variants_are_idempotent_and_preserve_publication(self): self.assertNotIn('git push --tags', text) self.assertNotIn('gh release delete', text) + def test_existing_consumer_migrates_runner_state_out_of_job_environment(self): + policy = self.policy() + text = sync.harden(legacy(), policy) + initialization = sync.fragment('state') + '\n' + invalid = text.replace(initialization, '').replace( + ' PREPARED_SHA:', + ' RELEASE_STATE: ${{ runner.temp }}/release-${{ github.run_id }}-${{ github.run_attempt }}.json\n' + ' PREPARED_SHA:') + corrected = sync.harden(invalid, policy) + self.assertEqual(corrected, text) + self.assertNotIn('${{ runner.temp }}', corrected) + self.assertEqual(corrected.count('Initialize release state path'), 1) + self.assertEqual(sync.harden(corrected, policy), corrected) + def test_generated_ci_uses_version_tags_and_has_no_whitespace_only_lines(self): for manager in ['npm', 'pnpm']: with self.subTest(manager=manager), tempfile.TemporaryDirectory(prefix='release-sync-') as directory: diff --git a/.github/release-workflows/templates/calver.yml b/.github/release-workflows/templates/calver.yml index 7584bf4..4320972 100644 --- a/.github/release-workflows/templates/calver.yml +++ b/.github/release-workflows/templates/calver.yml @@ -58,9 +58,11 @@ jobs: permissions: contents: write env: - RELEASE_STATE: ${{ runner.temp }}/release-${{ github.run_id }}-${{ github.run_attempt }}.json PREPARED_SHA: ${{ needs.prepare.outputs.checkout_sha }} steps: + - name: Initialize release state path + run: | + printf 'RELEASE_STATE=%s/release-%s-%s.json\n' "$RUNNER_TEMP" "$GITHUB_RUN_ID" "$GITHUB_RUN_ATTEMPT" >> "$GITHUB_ENV" - uses: actions/checkout@v7 # zizmor: ignore[unpinned-uses] with: ref: ${{ needs.prepare.outputs.checkout_sha }} diff --git a/.github/release-workflows/templates/state.yml b/.github/release-workflows/templates/state.yml new file mode 100644 index 0000000..5ad84f6 --- /dev/null +++ b/.github/release-workflows/templates/state.yml @@ -0,0 +1,3 @@ +- name: Initialize release state path + run: | + printf 'RELEASE_STATE=%s/release-%s-%s.json\n' "$RUNNER_TEMP" "$GITHUB_RUN_ID" "$GITHUB_RUN_ATTEMPT" >> "$GITHUB_ENV" diff --git a/.github/scripts/release-workflow.test.mjs b/.github/scripts/release-workflow.test.mjs index c49b097..c0ae494 100644 --- a/.github/scripts/release-workflow.test.mjs +++ b/.github/scripts/release-workflow.test.mjs @@ -1,5 +1,9 @@ import assert from 'node:assert/strict' -import { readFileSync } from 'node:fs' +import { execFileSync } from 'node:child_process' +import { mkdtempSync, readFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { env } from 'node:process' import test from 'node:test' import { fileURLToPath, URL } from 'node:url' import { selectBaseline, validateMetadata } from './release-publication.mjs' @@ -9,6 +13,40 @@ const read = (path) => readFileSync(`${root}${path}`, 'utf8') const policy = JSON.parse(read('.github/release-policy.json')) const workflow = policy.event === 'calver' ? null : read('.github/workflows/npm-publish.yml') +test('release state is initialized on the runner outside checkout and isolated by attempt', () => { + const releaseWorkflow = workflow ?? read('.github/workflows/release.yml') + const job = releaseWorkflow + .split(policy.event === 'calver' ? ' release:\n' : ' publish-github-packages:\n')[1] + .split(/\n {4}[a-z][\w-]*:\n/)[0] + assert.doesNotMatch(job.split(' steps:\n')[0], /\$\{\{\s*runner\./) + const initialization = job.split(' - name: Initialize release state path\n')[1] + assert.ok(initialization, 'Release state must be initialized by a runner step') + const script = initialization.split(/\n {12}- /)[0].split(' run: |\n')[1] + assert.ok(job.indexOf('Initialize release state path') < job.indexOf('uses: actions/checkout@')) + const directory = mkdtempSync(join(tmpdir(), 'release-state-environment-')) + try { + for (const attempt of ['1', '2']) { + const environment = join(directory, `environment-${attempt}`) + execFileSync('/bin/bash', ['--noprofile', '--norc', '-eu', '-c', script], { + cwd: root, + env: { + PATH: env.PATH, + RUNNER_TEMP: directory, + GITHUB_ENV: environment, + GITHUB_RUN_ID: '12345', + GITHUB_RUN_ATTEMPT: attempt + } + }) + assert.equal( + readFileSync(environment, 'utf8'), + `RELEASE_STATE=${directory}/release-12345-${attempt}.json\n` + ) + } + } finally { + rmSync(directory, { recursive: true, force: true }) + } +}) + test('consumer policy rejects unowned manifest, dependency, lockfile, and README changes', () => { for (const config of [ { manifests: ['package.json'], lockfile: null, readme: 'unchanged' }, diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7b96f65..b6d56f7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -59,9 +59,11 @@ jobs: permissions: contents: write env: - RELEASE_STATE: ${{ runner.temp }}/release-${{ github.run_id }}-${{ github.run_attempt }}.json PREPARED_SHA: ${{ needs.prepare.outputs.checkout_sha }} steps: + - name: Initialize release state path + run: | + printf 'RELEASE_STATE=%s/release-%s-%s.json\n' "$RUNNER_TEMP" "$GITHUB_RUN_ID" "$GITHUB_RUN_ATTEMPT" >> "$GITHUB_ENV" - uses: actions/checkout@v7 # zizmor: ignore[unpinned-uses] with: ref: ${{ needs.prepare.outputs.checkout_sha }}