Skip to content
Merged
51 changes: 41 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,31 @@ function checkReleaseWorkflow(workflows) {
fail('electric-release.yml package job must depend on exact-head ci');
}

const proofJob = workflow?.jobs?.package_proof;
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 +338,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 +435,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 +454,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
52 changes: 47 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,38 @@ 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:
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 +194,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 +226,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 +471,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 +490,28 @@ 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 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
153 changes: 153 additions & 0 deletions .github/scripts/verify-electric-package.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
#!/usr/bin/env node

import { createHash } from 'node:crypto';
import { spawnSync } from 'node:child_process';
import { createRequire } from 'node:module';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';

const EXPECTED_LADYBUG_VERSION = '0.18.1';
Comment thread
100yenadmin marked this conversation as resolved.
Outdated
const ARGUMENT_KEYS = new Set(['asset', 'checksums', 'prefix', 'expected-version']);
const USAGE =
'usage: verify-electric-package.mjs --asset <tarball> --checksums <file> --prefix <dir> --expected-version <version>';

function parseArgs(argv) {
const values = {};
for (let index = 0; index < argv.length; index += 2) {
Comment thread
100yenadmin marked this conversation as resolved.
const key = argv[index];
const value = argv[index + 1];
if (!key?.startsWith('--'))
throw new Error(`unexpected argument: ${key ?? '<missing>'}\n${USAGE}`);
const name = key.slice(2);
if (!ARGUMENT_KEYS.has(name)) throw new Error(`unexpected argument: ${key}\n${USAGE}`);
if (Object.hasOwn(values, name)) throw new Error(`duplicate argument: ${key}`);
if (!value || value.startsWith('--')) throw new Error(`missing value for ${key}\n${USAGE}`);
values[name] = value;
}
for (const key of ARGUMENT_KEYS) {
if (!values[key]) throw new Error(`missing --${key}`);
}
return values;
}

function requireRegularFile(file) {
const stat = fs.lstatSync(file);
if (!stat.isFile() || stat.size <= 0)
throw new Error(`expected a non-empty regular file: ${file}`);
}

function verifyChecksum(assetPath, checksumPath) {
requireRegularFile(assetPath);
requireRegularFile(checksumPath);
const filename = path.basename(assetPath);
const matches = fs
.readFileSync(checksumPath, 'utf8')
.split(/\r?\n/u)
.filter(Boolean)
.map((line) => line.match(/^([0-9a-f]{64})\s+\*?(.+)$/u))
Comment thread
100yenadmin marked this conversation as resolved.
.filter((match) => match?.[2] === filename);
if (matches.length !== 1) {
throw new Error(`SHA256SUMS must contain exactly one lowercase SHA-256 entry for ${filename}`);
}
const actual = createHash('sha256').update(fs.readFileSync(assetPath)).digest('hex');
if (actual !== matches[0][1]) throw new Error(`SHA-256 mismatch for ${filename}`);
return actual;
}

function locateInstalledPackage(prefix) {
const candidates = [
path.join(prefix, 'lib', 'node_modules', 'gitnexus'),
path.join(prefix, 'node_modules', 'gitnexus'),
];
const installed = candidates.find((candidate) =>
fs.existsSync(path.join(candidate, 'package.json')),
);
if (!installed) throw new Error(`installed gitnexus package not found under ${prefix}`);
return installed;
}

function runCli(prefix, args) {
Comment thread
100yenadmin marked this conversation as resolved.
Outdated
const executable =
process.platform === 'win32'
? path.join(prefix, 'gitnexus.cmd')
: path.join(prefix, 'bin', 'gitnexus');
const result = spawnSync(executable, args, {
encoding: 'utf8',
env: { ...process.env, NO_COLOR: '1' },
shell: process.platform === 'win32',
});
if (result.error || result.status !== 0) {
throw new Error(
`gitnexus ${args.join(' ')} failed: ${result.error?.message ?? result.stderr.trim()}`,
);
}
return result.stdout.trim();
}

export function verifyElectricPackage({ asset, checksums, prefix, expectedVersion }) {
const digest = verifyChecksum(asset, checksums);
const installed = locateInstalledPackage(prefix);
const packageJson = JSON.parse(fs.readFileSync(path.join(installed, 'package.json'), 'utf8'));
if (packageJson.name !== 'gitnexus' || packageJson.version !== expectedVersion) {
throw new Error(
`installed package identity is ${packageJson.name}@${packageJson.version}, expected gitnexus@${expectedVersion}`,
);
}

const ladybugPackagePath = path.join(
installed,
'node_modules',
'@ladybugdb',
'core',
'package.json',
);
const ladybugPackage = JSON.parse(fs.readFileSync(ladybugPackagePath, 'utf8'));
if (ladybugPackage.version !== EXPECTED_LADYBUG_VERSION) {
throw new Error(
`installed @ladybugdb/core is ${ladybugPackage.version}, expected ${EXPECTED_LADYBUG_VERSION}`,
);
}

const requireFromPackage = createRequire(path.join(installed, 'package.json'));
Comment thread
100yenadmin marked this conversation as resolved.
const loaded = requireFromPackage('@ladybugdb/core');
const api = loaded?.default ?? loaded;
if (typeof api?.Database !== 'function' || typeof api?.Connection !== 'function') {
throw new Error('@ladybugdb/core loaded without Database and Connection constructors');
}

const versionOutput = runCli(prefix, ['--version']);
Comment thread
100yenadmin marked this conversation as resolved.
if (versionOutput !== expectedVersion) {
throw new Error(`packaged CLI version is ${versionOutput}, expected ${expectedVersion}`);
}
runCli(prefix, ['--help']);
runCli(prefix, ['mcp', '--help']);

return {
package: `gitnexus@${expectedVersion}`,
ladybug: `@ladybugdb/core@${EXPECTED_LADYBUG_VERSION}`,
sha256: digest,
nativeImport: 'ok',
cli: 'ok',
mcpHelp: 'ok',
};
}

if (
process.argv[1] &&
path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url))
) {
try {
const args = parseArgs(process.argv.slice(2));
const result = verifyElectricPackage({
asset: path.resolve(args.asset),
checksums: path.resolve(args.checksums),
prefix: path.resolve(args.prefix),
expectedVersion: args['expected-version'],
});
process.stdout.write(`${JSON.stringify(result)}\n`);
} catch (error) {
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
process.exitCode = 1;
}
}
Loading
Loading