diff --git a/.github/workflows/create-package-release.yml b/.github/workflows/create-package-release.yml index 8a5a75b..b11023c 100644 --- a/.github/workflows/create-package-release.yml +++ b/.github/workflows/create-package-release.yml @@ -20,6 +20,7 @@ permissions: contents: write packages: read checks: read + security-events: read concurrency: group: action-kit-package-release cancel-in-progress: false diff --git a/.github/workflows/publish-package.yml b/.github/workflows/publish-package.yml index 8a2f5e1..e5b1c6c 100644 --- a/.github/workflows/publish-package.yml +++ b/.github/workflows/publish-package.yml @@ -17,6 +17,7 @@ permissions: contents: read packages: write checks: read + security-events: read concurrency: group: action-kit-package-publication cancel-in-progress: false diff --git a/RELEASING.md b/RELEASING.md index fe17694..65fa65d 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -41,4 +41,4 @@ gh workflow run create-package-release.yml --ref main \ This creates `notification-contract-v0.0.1`; selecting ccv2-client creates its independent `ccv2-client-v` tag. Stable publication uses channel `stable`, a reviewed stable manifest version and confirmation `PUBLISH-COMMERCEKIT-STABLE`. -Tags are an output of the verified release phase, not a publication trigger. This keeps failed publication from creating a misleading release tag and avoids relying on a token-created tag to trigger another workflow. Both release phases require explicit dispatch. The publish and Release jobs have `checks: read` solely to verify commit check results; CI remains read-only and publication has no contents-write permission. +Tags are an output of the verified release phase, not a publication trigger. This keeps failed publication from creating a misleading release tag and avoids relying on a token-created tag to trigger another workflow. Both release phases require explicit dispatch. The publish and Release jobs have `checks: read` to verify commit check results and `security-events: read` to verify exact-commit main CodeQL analyses and the absence of open alerts; CI remains read-only and publication has no contents-write permission. diff --git a/compatibility/tests/release-checks.test.mjs b/compatibility/tests/release-checks.test.mjs index 57f62a9..0cff51b 100644 --- a/compatibility/tests/release-checks.test.mjs +++ b/compatibility/tests/release-checks.test.mjs @@ -1,7 +1,11 @@ import { it, expect } from 'vitest'; -import { assertReleaseChecks, requiredReleaseChecks } from '../../scripts/release-checks.mjs'; +import { + assertReleaseChecks, + requiredReleaseChecks, + assertMainCodeqlEvidence, +} from '../../scripts/release-checks.mjs'; const passed = () => - requiredReleaseChecks.map((name, index) => ({ + [...requiredReleaseChecks, 'CodeQL'].map((name, index) => ({ name, id: index + 1, app: { slug: name === 'CodeQL' ? 'github-advanced-security' : 'github-actions' }, @@ -52,3 +56,40 @@ it('rejects an Actions check impersonating the security app result', () => { checks.find((check) => check.name === 'CodeQL').app.slug = 'github-actions'; expect(() => assertReleaseChecks([{ check_runs: checks }])).toThrow('CodeQL'); }); + +it('accepts main checks without the PR-only CodeQL security check', () => + expect(() => + assertReleaseChecks([{ check_runs: passed().filter((check) => check.name !== 'CodeQL') }]), + ).not.toThrow()); +const sha = 'a'.repeat(40); +const analysis = () => ({ + id: 10, + commit_sha: sha, + ref: 'refs/heads/main', + tool: { name: 'CodeQL' }, + analysis_key: '.github/workflows/codeql.yml:analyze', + error: '', + warning: '', +}); +it('requires an exact main-commit analysis with no errors, warnings or open alerts', () => { + expect(() => assertMainCodeqlEvidence([[], [analysis()]], [[]], sha)).not.toThrow(); + for (const patch of [ + { commit_sha: 'b'.repeat(40) }, + { ref: 'refs/pull/1/merge' }, + { tool: { name: 'other' } }, + { analysis_key: 'other' }, + { error: 'failed' }, + { warning: 'partial scan' }, + ]) + expect(() => assertMainCodeqlEvidence([[{ ...analysis(), ...patch }]], [[]], sha)).toThrow(); + expect(() => + assertMainCodeqlEvidence([[analysis(), { ...analysis(), id: 11, error: 'failed' }]], [[]], sha), + ).toThrow(); + expect(() => + assertMainCodeqlEvidence([[analysis()]], [[{ state: 'open', tool: { name: 'CodeQL' } }]], sha), + ).toThrow('alerts'); + expect(() => + assertMainCodeqlEvidence([[analysis()]], [[{ state: 'fixed', tool: { name: 'CodeQL' } }]], sha), + ).not.toThrow(); + for (const pages of [null, {}, [{}]]) expect(() => assertMainCodeqlEvidence(pages, [[]], sha)).toThrow(); +}); diff --git a/compatibility/tests/workflows.test.ts b/compatibility/tests/workflows.test.ts index d8885bd..e8faefe 100644 --- a/compatibility/tests/workflows.test.ts +++ b/compatibility/tests/workflows.test.ts @@ -24,8 +24,12 @@ for (const name of ['reserve-package-names', 'publish-package', 'create-package- expect(w.concurrency['cancel-in-progress']).toBe(false); expect(w.permissions).toEqual( name === 'create-package-release' - ? { contents: 'write', packages: 'read', checks: 'read' } - : { contents: 'read', packages: 'write', ...(name === 'publish-package' ? { checks: 'read' } : {}) }, + ? { contents: 'write', packages: 'read', checks: 'read', 'security-events': 'read' } + : { + contents: 'read', + packages: 'write', + ...(name === 'publish-package' ? { checks: 'read', 'security-events': 'read' } : {}), + }, ); expect(JSON.stringify(w.jobs)).toContain('refs/heads/main'); }); diff --git a/docs/package-release-policy.md b/docs/package-release-policy.md index dc10449..cf6f478 100644 --- a/docs/package-release-policy.md +++ b/docs/package-release-policy.md @@ -21,3 +21,5 @@ Publication requires successful quality, three platform checks and CodeQL analys The selected tarball is built, content-scanned, installed, imported and typechecked before it is retained. The publish step verifies its SHA-256 and publishes that same tarball with lifecycle scripts disabled. Normal `pnpm pack:check` still cleans every temporary file. Optional `pnpm pack:check --output-directory ` retains one tested tarball for owner inspection or the manual publication workflow; retained files belong outside the repository and must never be committed. The owner-requested first integration release is `0.0.1` for both packages. It uses the preview publication channel and `preview` dist-tag even though the version has no SemVer prerelease suffix. Its GitHub Releases are marked prerelease. This permits exact-version consumer adoption without claiming the coordinated stable/1.0 gates have passed. The stable channel remains restricted to 1.0.0 and later. + +Main-branch releases require the exact commit's completed CodeQL analysis from `.github/workflows/codeql.yml:analyze`, with no scan errors/warnings and no open CodeQL alerts on main. The PR-only GitHub Advanced Security check is validated when present, but its absence on main does not replace this direct analysis evidence. Publication and Release jobs receive `security-events: read` for these read-only API checks. diff --git a/scripts/release-checks.mjs b/scripts/release-checks.mjs index fd6f93a..8b348dd 100644 --- a/scripts/release-checks.mjs +++ b/scripts/release-checks.mjs @@ -5,14 +5,13 @@ export const requiredReleaseChecks = [ 'cross-platform (windows-2025)', 'cross-platform (macos-15)', 'analyze', - 'CodeQL', ]; export function assertReleaseChecks(pages) { if (!Array.isArray(pages) || pages.some((page) => !Array.isArray(page?.check_runs))) throw Error('Invalid CI check evidence.'); const checks = pages.flatMap((page) => page.check_runs); for (const name of requiredReleaseChecks) { - const trustedApp = name === 'CodeQL' ? 'github-advanced-security' : 'github-actions'; + const trustedApp = 'github-actions'; const latest = checks .filter((check) => check.name === name && check.app?.slug === trustedApp) .sort((a, b) => b.id - a.id)[0]; @@ -24,6 +23,38 @@ export function assertReleaseChecks(pages) { ) throw Error(`Required CI check missing, pending or unsuccessful: ${name}`); } + // GitHub emits this security-app check for PRs, but not necessarily for main. + const securityChecks = checks.filter((check) => check.name === 'CodeQL'); + if (securityChecks.length) { + const latest = securityChecks + .filter((check) => check.app?.slug === 'github-advanced-security') + .sort((a, b) => b.id - a.id)[0]; + if (!latest || latest.status !== 'completed' || latest.conclusion !== 'success') + throw Error('CodeQL security check is not successful.'); + } +} +export function assertMainCodeqlEvidence(analysisPages, alertPages, sha) { + if ( + !Array.isArray(analysisPages) || + analysisPages.some((page) => !Array.isArray(page)) || + !Array.isArray(alertPages) || + alertPages.some((page) => !Array.isArray(page)) + ) + throw Error('Invalid CodeQL evidence.'); + const latest = analysisPages + .flat() + .filter( + (analysis) => + analysis.commit_sha === sha && + analysis.ref === 'refs/heads/main' && + analysis.tool?.name === 'CodeQL' && + analysis.analysis_key === '.github/workflows/codeql.yml:analyze', + ) + .sort((a, b) => b.id - a.id)[0]; + if (!latest || !Number.isSafeInteger(latest.id) || latest.error !== '' || latest.warning !== '') + throw Error('Successful complete CodeQL analysis missing for the selected main commit.'); + if (alertPages.flat().some((alert) => alert.tool?.name === 'CodeQL' && alert.state === 'open')) + throw Error('Open CodeQL alerts block release.'); } if (process.argv[1]?.endsWith('/release-checks.mjs') || process.argv[1]?.endsWith('\\release-checks.mjs')) { const sha = process.argv[2]; @@ -38,5 +69,18 @@ if (process.argv[1]?.endsWith('/release-checks.mjs') || process.argv[1]?.endsWit ), ); assertReleaseChecks(pages); - console.log('Required CI and CodeQL checks passed for the selected commit.'); + const query = (path) => + JSON.parse( + execFileSync('gh', ['api', '--paginate', '--slurp', `repos/${repository}/${path}`], { + encoding: 'utf8', + }), + ); + assertMainCodeqlEvidence( + query('code-scanning/analyses?ref=refs/heads/main&per_page=100'), + query('code-scanning/alerts?ref=refs/heads/main&state=open&tool_name=CodeQL&per_page=100'), + sha, + ); + console.log( + 'Required CI and complete CodeQL analysis passed for the selected main commit; no open CodeQL alerts.', + ); }