Dependency audit / audit (push) Canceled after 0s
CI / plan (push) Canceled after 0s
Gitoxide helper admission / macos-latest (push) Canceled after 0s
Gitoxide helper admission / ubuntu-latest (push) Canceled after 0s
Gitoxide helper admission / windows-latest (push) Canceled after 0s
Runtime Host peer admission / quality (push) Canceled after 0s
Windows recovery / windows_recovery (push) Canceled after 0s
CI / heavy (push) Canceled after 0s
CI / test (push) Canceled after 0s
408 lines
13 KiB
JavaScript
408 lines
13 KiB
JavaScript
/*
|
|
* Licensed to the Apache Software Foundation (ASF) under one
|
|
* or more contributor license agreements. See the NOTICE file
|
|
* distributed with this work for additional information
|
|
* regarding copyright ownership. The ASF licenses this file
|
|
* to you under the Apache License, Version 2.0 (the
|
|
* "License"); you may not use this file except in compliance
|
|
* with the License. You may obtain a copy of the License at
|
|
*
|
|
* http://www.apache.org/licenses/LICENSE-2.0
|
|
*
|
|
* Unless required by applicable law or agreed to in writing,
|
|
* software distributed under the License is distributed on an
|
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
* KIND, either express or implied. See the License for the
|
|
* specific language governing permissions and limitations
|
|
* under the License.
|
|
*/
|
|
|
|
import { execFile } from 'node:child_process';
|
|
import { createHash } from 'node:crypto';
|
|
import { createReadStream } from 'node:fs';
|
|
import { readFile, stat } from 'node:fs/promises';
|
|
import { basename } from 'node:path';
|
|
import { pathToFileURL } from 'node:url';
|
|
import { promisify } from 'node:util';
|
|
import {
|
|
compareProductReleaseNames,
|
|
readProductReleasePublicationRecord,
|
|
verifyProductReleasePublicationRecord,
|
|
} from './product-release-artifacts.mjs';
|
|
import { parseAsfSourceReferenceTag } from './product-release-identity.mjs';
|
|
import { parseProductTag, remoteProductTagCommit } from './product-release-tag.mjs';
|
|
|
|
const execFileAsync = promisify(execFile);
|
|
|
|
function expectedReleaseIdentity(tag) {
|
|
const { prerelease, version } = parseProductTag(tag);
|
|
if (prerelease.length > 0) {
|
|
throw new Error('Formal product releases require a stable product tag');
|
|
}
|
|
return {
|
|
tag,
|
|
version,
|
|
attestationName: `Maka-${version}-attestation.sigstore.json`,
|
|
};
|
|
}
|
|
|
|
export function assertDraftProductRelease(release, tag) {
|
|
const expected = expectedReleaseIdentity(tag);
|
|
if (
|
|
!release ||
|
|
!Number.isSafeInteger(release.id) ||
|
|
release.id < 1 ||
|
|
release.tag !== expected.tag
|
|
) {
|
|
throw new Error(`GitHub Release does not identify product tag ${tag}`);
|
|
}
|
|
if (release.draft !== true) {
|
|
throw new Error(`GitHub Release ${tag} must remain a Draft`);
|
|
}
|
|
if (release.prerelease !== false) {
|
|
throw new Error(`GitHub Release ${tag} must not be a prerelease`);
|
|
}
|
|
return release;
|
|
}
|
|
|
|
export function assertPublishedProductRelease(release, tag, releaseId, expectedAssets) {
|
|
expectedReleaseIdentity(tag);
|
|
if (!release || release.id !== releaseId || release.tag !== tag || release.draft !== false) {
|
|
throw new Error(`GitHub Release ${tag} was not published`);
|
|
}
|
|
if (release.prerelease !== false) {
|
|
throw new Error(`GitHub Release ${tag} must not be a prerelease`);
|
|
}
|
|
if (JSON.stringify(release.assets) !== JSON.stringify(expectedAssets)) {
|
|
throw new Error(`GitHub Release ${tag} assets changed during publication`);
|
|
}
|
|
return release;
|
|
}
|
|
|
|
function releaseSnapshotFromGhView(value) {
|
|
return {
|
|
id: value?.databaseId,
|
|
tag: value?.tagName,
|
|
draft: value?.isDraft,
|
|
prerelease: value?.isPrerelease,
|
|
assets: (value?.assets ?? [])
|
|
.map(({ name, size, digest }) => ({ name, size, digest }))
|
|
.sort((left, right) => compareProductReleaseNames(left.name, right.name)),
|
|
};
|
|
}
|
|
|
|
function releaseSnapshotFromRest(value) {
|
|
return {
|
|
id: value?.id,
|
|
tag: value?.tag_name,
|
|
draft: value?.draft,
|
|
prerelease: value?.prerelease,
|
|
assets: (value?.assets ?? [])
|
|
.map(({ name, size, digest }) => ({ name, size, digest }))
|
|
.sort((left, right) => compareProductReleaseNames(left.name, right.name)),
|
|
};
|
|
}
|
|
|
|
async function localAssetRecord(path) {
|
|
const details = await stat(path);
|
|
if (!details.isFile() || details.size === 0) {
|
|
throw new Error('Product release attestation bundle must be a non-empty regular file');
|
|
}
|
|
const hash = createHash('sha256');
|
|
for await (const chunk of createReadStream(path)) hash.update(chunk);
|
|
return { name: basename(path), size: details.size, digest: `sha256:${hash.digest('hex')}` };
|
|
}
|
|
|
|
export function assertProductReleaseWorkflowRun({
|
|
run,
|
|
tag,
|
|
sourceCommit,
|
|
repository,
|
|
runId,
|
|
runAttempt,
|
|
}) {
|
|
if (!/^[1-9]\d*$/u.test(String(runId)) || !/^[1-9]\d*$/u.test(String(runAttempt))) {
|
|
throw new Error('Release workflow run ID and attempt must be positive integers');
|
|
}
|
|
const product = parseProductTag(tag);
|
|
const source = parseAsfSourceReferenceTag(run?.head_branch);
|
|
const exact =
|
|
String(run?.id) === String(runId) &&
|
|
String(run?.run_attempt) === String(runAttempt) &&
|
|
run?.path === '.github/workflows/release.yml' &&
|
|
run?.event === 'workflow_dispatch' &&
|
|
run?.status === 'completed' &&
|
|
run?.conclusion === 'success' &&
|
|
run?.head_sha === sourceCommit &&
|
|
run?.head_repository?.full_name === repository &&
|
|
source.version === product.version;
|
|
if (!exact) {
|
|
throw new Error('Release workflow run does not match the approved product source');
|
|
}
|
|
return run;
|
|
}
|
|
|
|
export async function verifyDraftProductRelease({
|
|
tag,
|
|
sourceCommit,
|
|
repository,
|
|
cwd = process.cwd(),
|
|
run = execFileAsync,
|
|
}) {
|
|
expectedReleaseIdentity(tag);
|
|
if (!/^[0-9a-f]{40}$/u.test(sourceCommit)) {
|
|
throw new Error(`Product source must be an exact commit SHA; found ${sourceCommit}`);
|
|
}
|
|
if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/u.test(repository)) {
|
|
throw new Error(`Product repository must be an exact owner/name; found ${repository}`);
|
|
}
|
|
|
|
const remoteCommit = await remoteProductTagCommit({ cwd, remote: 'origin', tag, run });
|
|
if (!remoteCommit) throw new Error(`Product tag ${tag} does not exist on origin`);
|
|
if (remoteCommit !== sourceCommit) {
|
|
throw new Error(`Product tag ${tag} points to ${remoteCommit} instead of ${sourceCommit}`);
|
|
}
|
|
|
|
await run('git', ['fetch', '--force', '--no-tags', 'origin', 'main:refs/remotes/origin/main'], {
|
|
cwd,
|
|
});
|
|
await run('git', ['merge-base', '--is-ancestor', sourceCommit, 'refs/remotes/origin/main'], {
|
|
cwd,
|
|
});
|
|
|
|
const release = await run(
|
|
'gh',
|
|
[
|
|
'release',
|
|
'view',
|
|
tag,
|
|
'--repo',
|
|
repository,
|
|
'--json',
|
|
'databaseId,tagName,isDraft,isPrerelease,assets',
|
|
],
|
|
{ cwd },
|
|
);
|
|
let parsedRelease;
|
|
try {
|
|
parsedRelease = JSON.parse(release.stdout);
|
|
} catch (error) {
|
|
throw new Error(`GitHub returned an invalid Release record for ${tag}`, { cause: error });
|
|
}
|
|
return assertDraftProductRelease(releaseSnapshotFromGhView(parsedRelease), tag);
|
|
}
|
|
|
|
export async function publishDraftProductRelease({
|
|
tag,
|
|
sourceCommit,
|
|
repository,
|
|
artifactDirectory,
|
|
publicationRecordPath,
|
|
sourceReferenceTag,
|
|
releaseRunId,
|
|
releaseRunAttempt,
|
|
attestationBundlePath,
|
|
cwd = process.cwd(),
|
|
run = execFileAsync,
|
|
pause = (milliseconds) =>
|
|
new Promise((resolvePromise) => setTimeout(resolvePromise, milliseconds)),
|
|
}) {
|
|
const releaseIdentity = expectedReleaseIdentity(tag);
|
|
const attestation = await localAssetRecord(attestationBundlePath);
|
|
if (attestation.name !== releaseIdentity.attestationName) {
|
|
throw new Error(`Product release attestation must be named ${releaseIdentity.attestationName}`);
|
|
}
|
|
const { draft, evidence } = await verifyDraftProductReleasePublication({
|
|
tag,
|
|
sourceCommit,
|
|
repository,
|
|
artifactDirectory,
|
|
publicationRecordPath,
|
|
sourceReferenceTag,
|
|
releaseRunId,
|
|
releaseRunAttempt,
|
|
cwd,
|
|
run,
|
|
});
|
|
|
|
await run(
|
|
'gh',
|
|
['release', 'upload', tag, attestationBundlePath, '--repo', repository, '--clobber'],
|
|
{ cwd },
|
|
);
|
|
const attestedDraft = await verifyDraftProductRelease({
|
|
tag,
|
|
sourceCommit,
|
|
repository,
|
|
cwd,
|
|
run,
|
|
});
|
|
const expectedAssets = [...evidence.assets, attestation].sort((left, right) =>
|
|
compareProductReleaseNames(left.name, right.name),
|
|
);
|
|
if (JSON.stringify(attestedDraft.assets) !== JSON.stringify(expectedAssets)) {
|
|
throw new Error('Draft GitHub Release does not contain the exact attestation bundle');
|
|
}
|
|
|
|
const published = await run(
|
|
'gh',
|
|
[
|
|
'api',
|
|
'--header',
|
|
'X-GitHub-Api-Version: 2026-03-10',
|
|
'--method',
|
|
'PATCH',
|
|
`repos/${repository}/releases/${draft.id}`,
|
|
'-F',
|
|
'draft=false',
|
|
'-F',
|
|
'prerelease=false',
|
|
'-f',
|
|
'make_latest=true',
|
|
],
|
|
{ cwd },
|
|
);
|
|
let record;
|
|
try {
|
|
record = releaseSnapshotFromRest(JSON.parse(published.stdout));
|
|
} catch (error) {
|
|
throw new Error(`GitHub returned an invalid publication result for ${tag}`, { cause: error });
|
|
}
|
|
assertPublishedProductRelease(record, tag, draft.id, expectedAssets);
|
|
|
|
let latestTag;
|
|
for (let attempt = 0; attempt < 5; attempt += 1) {
|
|
try {
|
|
const latest = await run('gh', ['api', `repos/${repository}/releases/latest`], { cwd });
|
|
latestTag = JSON.parse(latest.stdout).tag_name;
|
|
} catch (error) {
|
|
if (attempt === 4) {
|
|
throw new Error('GitHub returned an invalid Latest release record', { cause: error });
|
|
}
|
|
}
|
|
if (latestTag === tag) break;
|
|
if (attempt < 4) await pause(1_000);
|
|
}
|
|
if (latestTag !== tag) {
|
|
throw new Error(`Stable release ${tag} was published but Latest points to ${latestTag}`);
|
|
}
|
|
return record;
|
|
}
|
|
|
|
export async function verifyDraftProductReleasePublication({
|
|
tag,
|
|
sourceCommit,
|
|
repository,
|
|
artifactDirectory,
|
|
publicationRecordPath,
|
|
sourceReferenceTag,
|
|
releaseRunId,
|
|
releaseRunAttempt,
|
|
cwd = process.cwd(),
|
|
run = execFileAsync,
|
|
}) {
|
|
const releaseIdentity = expectedReleaseIdentity(tag);
|
|
const evidence = await readProductReleasePublicationRecord(publicationRecordPath, {
|
|
repository,
|
|
tag,
|
|
sourceCommit,
|
|
sourceReferenceTag,
|
|
runId: releaseRunId,
|
|
runAttempt: releaseRunAttempt,
|
|
});
|
|
await verifyProductReleasePublicationRecord({
|
|
artifactDirectory,
|
|
record: evidence,
|
|
expected: {
|
|
repository,
|
|
tag,
|
|
sourceCommit,
|
|
sourceReferenceTag,
|
|
runId: releaseRunId,
|
|
runAttempt: releaseRunAttempt,
|
|
},
|
|
});
|
|
const draft = await verifyDraftProductRelease({ tag, sourceCommit, repository, cwd, run });
|
|
const remoteAssets = (draft.assets ?? [])
|
|
.map(({ name, size, digest }) => ({ name, size, digest }))
|
|
.sort((left, right) => compareProductReleaseNames(left.name, right.name));
|
|
const allowedDraftAssets = remoteAssets.filter(
|
|
({ name }) => name !== releaseIdentity.attestationName,
|
|
);
|
|
const unexpectedAttestations = remoteAssets.filter(
|
|
({ name }) =>
|
|
name.endsWith('-attestation.sigstore.json') && name !== releaseIdentity.attestationName,
|
|
);
|
|
if (
|
|
unexpectedAttestations.length > 0 ||
|
|
JSON.stringify(allowedDraftAssets) !== JSON.stringify(evidence.assets)
|
|
) {
|
|
throw new Error('Draft GitHub Release assets do not match the verified Release run artifacts');
|
|
}
|
|
return { draft, evidence };
|
|
}
|
|
|
|
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
const [command, ...args] = process.argv.slice(2);
|
|
const usage =
|
|
'usage: product-release-authority.mjs verify-build-run <run-json> <tag> <source-commit> <owner/repository> <run-id> <run-attempt> | verify-draft <tag> <source-commit> <owner/repository> | verify-publication <tag> <source-commit> <owner/repository> <artifact-directory> <publication-record> <source-reference-tag> <run-id> <run-attempt> | publish-draft <tag> <source-commit> <owner/repository> <artifact-directory> <publication-record> <source-reference-tag> <run-id> <run-attempt> <attestation-bundle>';
|
|
if (command === 'verify-build-run' && args.length === 6) {
|
|
const [runPath, tag, sourceCommit, repository, runId, runAttempt] = args;
|
|
const run = JSON.parse(await readFile(runPath, 'utf8'));
|
|
assertProductReleaseWorkflowRun({ run, tag, sourceCommit, repository, runId, runAttempt });
|
|
console.log(`Verified Release workflow run ${runId}/${runAttempt} for ${tag}`);
|
|
} else if (command === 'verify-draft' && args.length === 3) {
|
|
const [tag, sourceCommit, repository] = args;
|
|
await verifyDraftProductRelease({ tag, sourceCommit, repository });
|
|
console.log(`Verified Draft product Release ${tag} at ${sourceCommit}`);
|
|
} else if (command === 'verify-publication' && args.length === 8) {
|
|
const [
|
|
tag,
|
|
sourceCommit,
|
|
repository,
|
|
artifactDirectory,
|
|
publicationRecordPath,
|
|
sourceReferenceTag,
|
|
releaseRunId,
|
|
releaseRunAttempt,
|
|
] = args;
|
|
await verifyDraftProductReleasePublication({
|
|
tag,
|
|
sourceCommit,
|
|
repository,
|
|
artifactDirectory,
|
|
publicationRecordPath,
|
|
sourceReferenceTag,
|
|
releaseRunId,
|
|
releaseRunAttempt,
|
|
});
|
|
console.log(`Verified exact publication input for ${tag}`);
|
|
} else if (command === 'publish-draft' && args.length === 9) {
|
|
const [
|
|
tag,
|
|
sourceCommit,
|
|
repository,
|
|
artifactDirectory,
|
|
publicationRecordPath,
|
|
sourceReferenceTag,
|
|
releaseRunId,
|
|
releaseRunAttempt,
|
|
attestationBundlePath,
|
|
] = args;
|
|
await publishDraftProductRelease({
|
|
tag,
|
|
sourceCommit,
|
|
repository,
|
|
artifactDirectory,
|
|
publicationRecordPath,
|
|
sourceReferenceTag,
|
|
releaseRunId,
|
|
releaseRunAttempt,
|
|
attestationBundlePath,
|
|
});
|
|
console.log(`Published product Release ${tag} from ${sourceCommit}`);
|
|
} else {
|
|
throw new Error(usage);
|
|
}
|
|
}
|