diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3422939..111e277 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -85,6 +85,8 @@ jobs: verify-prepared-release: needs: [preflight, prepare-release] runs-on: ubuntu-latest + permissions: + contents: read steps: - name: Checkout prepared release commit uses: actions/checkout@v7 diff --git a/scripts/release-local-gate.mjs b/scripts/release-local-gate.mjs index 6df47cf..c80f67a 100644 --- a/scripts/release-local-gate.mjs +++ b/scripts/release-local-gate.mjs @@ -64,7 +64,7 @@ export function parseRemoteMain(text) { } export function validateReleaseWorkflowContract(text) { - const workflow = String(text); + const workflow = String(text).replace(/\r\n/gu, '\n'); const image = 'ghcr.io/shik3i/koalasync'; if (!workflow.includes(`IMAGE: ${image}`)) { throw new Error(`release workflow must define the lowercase canonical image ${image}`); @@ -95,6 +95,16 @@ export function validateReleaseWorkflowContract(text) { if (/git push origin HEAD:main\s*(?:\|\||;\s*true)/u.test(workflow)) { throw new Error('release workflow must stop when the automatic main push fails'); } + const verificationPermissions = [ + ' verify-prepared-release:', + ' needs: [preflight, prepare-release]', + ' runs-on: ubuntu-latest', + ' permissions:', + ' contents: read' + ].join('\n'); + if (!workflow.includes(verificationPermissions)) { + throw new Error('prepared release verification must explicitly limit GITHUB_TOKEN to contents: read'); + } return image; } diff --git a/scripts/release-local-gate.test.mjs b/scripts/release-local-gate.test.mjs index 99c1672..10e9f29 100644 --- a/scripts/release-local-gate.test.mjs +++ b/scripts/release-local-gate.test.mjs @@ -40,6 +40,11 @@ describe('local release gate contract', () => { 'node scripts/release-preflight.mjs --sources "$VERSION"', 'git commit -m "chore(release): update versions to v$VERSION [skip ci]"', 'git push origin HEAD:main', + ' verify-prepared-release:', + ' needs: [preflight, prepare-release]', + ' runs-on: ubuntu-latest', + ' permissions:', + ' contents: read', 'ref: ${{ needs.prepare-release.outputs.prepared-commit }}', 'ref: ${{ needs.prepare-release.outputs.prepared-commit }}', 'ref: ${{ needs.prepare-release.outputs.prepared-commit }}', @@ -66,6 +71,10 @@ describe('local release gate contract', () => { 'git push origin HEAD:main', 'git push origin HEAD:main || true' ))).toThrow('stop when the automatic main push fails'); + expect(() => validateReleaseWorkflowContract(workflow.replace( + / permissions:\r?\n contents: read\r?\n steps:/u, + ' steps:' + ))).toThrow('explicitly limit GITHUB_TOKEN'); }); it('runs the complete CI-equivalent dependency, verify, and browser sequence', () => {