Skip to content
Merged
54 changes: 44 additions & 10 deletions .github/scripts/check-electric-release-policy.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -188,12 +188,12 @@ function checkReleaseWorkflow(workflows) {
}
}

for (const jobName of ['inspect', 'ci', 'package', 'release']) {
for (const jobName of ['inspect', 'ci', 'package', 'package_proof', 'release']) {
if (!workflow?.jobs?.[jobName]) fail(`electric-release.yml must define ${jobName} job`);
}

const releaseJob = workflow?.jobs?.release;
for (const jobName of ['inspect', 'ci', 'package']) {
for (const jobName of ['inspect', 'ci', 'package', 'package_proof']) {
const permissions = workflow?.jobs?.[jobName]?.permissions;
const entries =
permissions && typeof permissions === 'object' ? Object.entries(permissions) : [];
Expand Down Expand Up @@ -249,25 +249,21 @@ function checkReleaseWorkflow(workflows) {
]);
checkDraftSafeReleaseLookup(inspectStep, 'manifest verification step');

const packageStep = findNamedStep(workflow?.jobs?.package, 'Pack and install isolated CLI');
checkRunRequirements(packageStep, 'package proof step', [
const packageStep = findNamedStep(workflow?.jobs?.package, 'Pack release tarball');
checkRunRequirements(packageStep, 'package build step', [
['npm pack --dry-run', 'npm pack dry-run'],
['FILENAME="gitnexus-$EXPECTED_VERSION.tgz"', 'deterministic package asset filename'],
[
'if [ ! -f "$ASSET_PATH" ] || [ ! -s "$ASSET_PATH" ]; then',
'non-empty regular package asset validation',
],
[
'if [ "$VERSION_OUTPUT" != "$EXPECTED_VERSION" ]; then',
'exact packaged-version equality check',
],
['SHA256SUMS', 'SHA256SUMS asset'],
]);
if (
typeof packageStep?.step?.run === 'string' &&
/\bJSON\.parse\s*\(/.test(packageStep.step.run)
) {
fail('electric-release.yml package proof step must not parse npm pack stdout as JSON');
fail('electric-release.yml package build step must not parse npm pack stdout as JSON');
}
const packageNeeds = Array.isArray(workflow?.jobs?.package?.needs)
? workflow.jobs.package.needs
Expand All @@ -276,6 +272,34 @@ function checkReleaseWorkflow(workflows) {
fail('electric-release.yml package job must depend on exact-head ci');
}

const proofJob = workflow?.jobs?.package_proof;
if (proofJob?.name !== 'Prove release tarball (${{ matrix.os }})') {
fail('electric-release.yml package proof job name must match recovery proof identities');
}
const proofOperatingSystems = proofJob?.strategy?.matrix?.os;
const expectedOperatingSystems = ['macos-latest', 'ubuntu-latest', 'windows-latest'];
if (
!Array.isArray(proofOperatingSystems) ||
JSON.stringify([...proofOperatingSystems].sort()) !== JSON.stringify(expectedOperatingSystems)
) {
fail('electric-release.yml package proof matrix must cover ubuntu, macOS, and Windows');
}
const proofNeeds = Array.isArray(proofJob?.needs) ? proofJob.needs : [proofJob?.needs];
if (!proofNeeds.includes('inspect') || !proofNeeds.includes('package')) {
fail(
'electric-release.yml package proof must depend on inspected release identity and tarball',
);
}
const proofStep = findNamedStep(proofJob, 'Install and verify release tarball');
checkRunRequirements(proofStep, 'cross-platform package proof step', [
['npm install --global --prefix', 'clean-prefix package installation'],
['verify-electric-package.mjs', 'shared package verifier'],
['--asset', 'tarball checksum verification'],
['--checksums', 'SHA256SUMS verification'],
['--prefix', 'installed-prefix verification'],
['--expected-version', 'exact packaged-version equality check'],
]);

const reverifyStep = findNamedStep(releaseJob, 'Reverify resumable release state');
checkRunRequirements(reverifyStep, 'reverify resumable release state step', [
['git/ref/heads/main', 'fresh current-main guard'],
Expand Down Expand Up @@ -317,7 +341,11 @@ function checkReleaseWorkflow(workflows) {
checkDraftSafeReleaseLookup(publishStep, 'publish the verified draft step');

const releaseNeeds = Array.isArray(releaseJob?.needs) ? releaseJob.needs : [releaseJob?.needs];
if (!releaseNeeds.includes('inspect') || !releaseNeeds.includes('package')) {
if (
!releaseNeeds.includes('inspect') ||
!releaseNeeds.includes('package') ||
!releaseNeeds.includes('package_proof')
) {
fail('electric-release.yml manifest verification must run before release mutation');
}
const orderedReleaseSteps = [reverifyStep, tagStep, upsertStep, publishStep];
Expand Down Expand Up @@ -410,6 +438,9 @@ function checkRecoveryWorkflow(workflows) {
['.github/workflows/electric-release.yml', 'original Electric Release workflow identity'],
['Exact-head CI / CI Gate', 'successful exact-head CI proof'],
['Build and prove release tarball', 'successful package proof'],
['Prove release tarball (ubuntu-latest)', 'successful Linux package proof'],
['Prove release tarball (macos-latest)', 'successful macOS package proof'],
['Prove release tarball (windows-latest)', 'successful Windows package proof'],
['Create protected Electric GitHub Release', 'failed protected release proof'],
['repos/$REPO/actions/runs/$SOURCE_RUN_ID/approvals', 'original environment approval proof'],
['internal-release', 'internal-release approval proof'],
Expand All @@ -426,6 +457,9 @@ function checkRecoveryWorkflow(workflows) {
['repos/$REPO/actions/runs/$SOURCE_RUN_ID', 'original Electric Release run proof'],
['Exact-head CI / CI Gate', 'successful exact-head CI proof'],
['Build and prove release tarball', 'successful package proof'],
['Prove release tarball (ubuntu-latest)', 'successful Linux package proof'],
['Prove release tarball (macos-latest)', 'successful macOS package proof'],
['Prove release tarball (windows-latest)', 'successful Windows package proof'],
['repos/$REPO/actions/runs/$SOURCE_RUN_ID/approvals', 'original environment approval proof'],
['$RELEASE_ID', 'immutable release ID binding'],
['$SOURCE_SHA', 'immutable source SHA binding'],
Expand Down
64 changes: 59 additions & 5 deletions .github/scripts/check-electric-release-policy.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -59,24 +59,39 @@ jobs:
permissions:
contents: read
steps:
- name: Pack and install isolated CLI
- name: Pack release tarball
run: |
npm pack --dry-run
npm pack
FILENAME="gitnexus-$EXPECTED_VERSION.tgz"
ASSET_PATH="$FILENAME"
if [ ! -f "$ASSET_PATH" ] || [ ! -s "$ASSET_PATH" ]; then exit 1; fi
VERSION_OUTPUT="$EXPECTED_VERSION"
if [ "$VERSION_OUTPUT" != "$EXPECTED_VERSION" ]; then exit 1; fi
shasum -a 256 gitnexus-*.tgz > SHA256SUMS
- uses: actions/upload-artifact@v4
with:
name: electric-release-assets
path: |
gitnexus-*.tgz
SHA256SUMS
release:
package_proof:
name: Prove release tarball (\${{ matrix.os }})
needs: [inspect, package]
strategy:
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
permissions:
contents: read
steps:
- name: Install and verify release tarball
run: |
npm install --global --prefix "$PREFIX" "$ASSET_PATH"
node .github/scripts/verify-electric-package.mjs \
--asset "$ASSET_PATH" \
--checksums "$ASSET_DIR/SHA256SUMS" \
--prefix "$PREFIX" \
--expected-version "$EXPECTED_VERSION"
release:
needs: [inspect, package, package_proof]
environment:
name: internal-release
permissions:
Expand Down Expand Up @@ -180,6 +195,9 @@ jobs:
echo .github/workflows/electric-release.yml
echo "Exact-head CI / CI Gate"
echo "Build and prove release tarball"
echo "Prove release tarball (ubuntu-latest)"
echo "Prove release tarball (macos-latest)"
echo "Prove release tarball (windows-latest)"
echo "Create protected Electric GitHub Release"
gh api "repos/$REPO/actions/runs/$SOURCE_RUN_ID/approvals"
echo internal-release
Expand Down Expand Up @@ -209,6 +227,9 @@ jobs:
gh api "repos/$REPO/actions/runs/$SOURCE_RUN_ID"
echo "Exact-head CI / CI Gate"
echo "Build and prove release tarball"
echo "Prove release tarball (ubuntu-latest)"
echo "Prove release tarball (macos-latest)"
echo "Prove release tarball (windows-latest)"
gh api "repos/$REPO/actions/runs/$SOURCE_RUN_ID/approvals"
ENCODED_TAG="$(jq -rn --arg value "$TAG" '$value | @uri')"
gh api "repos/$REPO/git/ref/tags/$ENCODED_TAG"
Expand Down Expand Up @@ -451,7 +472,7 @@ test('rejects missing manifest verification or release ordering', () => {
test('rejects release mutation that does not depend on manifest verification', () => {
const workflow = replaceOnce(
validWorkflow,
' release:\n needs: [inspect, package]',
' release:\n needs: [inspect, package, package_proof]',
' release:\n needs: [package]',
);
const result = runChecker(createFixture(workflow));
Expand All @@ -470,6 +491,39 @@ test('rejects packaging that does not depend on exact-head CI', () => {
assert.match(result.stderr, /package job must depend on exact-head ci/);
});

test('rejects package proof that omits a supported runner family', () => {
const workflow = replaceOnce(
validWorkflow,
' os: [ubuntu-latest, macos-latest, windows-latest]',
' os: [ubuntu-latest, windows-latest]',
);
const result = runChecker(createFixture(workflow));
assert.notEqual(result.status, 0);
assert.match(result.stderr, /package proof matrix must cover ubuntu, macOS, and Windows/);
});

test('rejects a package proof display name that would break recovery', () => {
const workflow = replaceOnce(
validWorkflow,
' name: Prove release tarball (${{ matrix.os }})',
' name: Release package proof (${{ matrix.os }})',
);
const result = runChecker(createFixture(workflow));
assert.notEqual(result.status, 0);
assert.match(result.stderr, /package proof job name must match recovery proof identities/);
});

test('rejects package proof that skips the shared verifier', () => {
const workflow = replaceOnce(
validWorkflow,
'verify-electric-package.mjs',
'unverified-package.mjs',
);
const result = runChecker(createFixture(workflow));
assert.notEqual(result.status, 0);
assert.match(result.stderr, /shared package verifier/);
});

test('rejects packaging without the deterministic asset name', () => {
const workflow = replaceOnce(
validWorkflow,
Expand Down
Loading
Loading