Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/create-package-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ permissions:
contents: write
packages: read
checks: read
security-events: read
concurrency:
group: action-kit-package-release
cancel-in-progress: false
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/publish-package.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ permissions:
contents: read
packages: write
checks: read
security-events: read
concurrency:
group: action-kit-package-publication
cancel-in-progress: false
Expand Down
2 changes: 1 addition & 1 deletion RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<version>` 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.
45 changes: 43 additions & 2 deletions compatibility/tests/release-checks.test.mjs
Original file line number Diff line number Diff line change
@@ -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' },
Expand Down Expand Up @@ -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();
});
8 changes: 6 additions & 2 deletions compatibility/tests/workflows.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
Expand Down
2 changes: 2 additions & 0 deletions docs/package-release-policy.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <package> --output-directory <absolute-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.
50 changes: 47 additions & 3 deletions scripts/release-checks.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand All @@ -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];
Expand All @@ -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.',
);
}
Loading