feat(ql3): publish durable release catalog

This commit is contained in:
whyour
2026-08-16 10:50:15 +08:00
parent 41549524d4
commit a44c213be1
16 changed files with 1486 additions and 37 deletions
+167 -3
View File
@@ -6,8 +6,13 @@ const crypto = require('node:crypto');
const fs = require('node:fs');
const path = require('node:path');
const {
CLUSTER_IMAGES,
LOCAL_IMAGES,
RELEASE_SCOPES,
SCHEMA: RELEASE_CANDIDATE_SCHEMA,
auditReleaseCandidateContract,
} = require('./ql3-release-candidate-contract.cjs');
const { VERSION_PATTERN } = require('./lib/ql3-release-identity.cjs');
const DEFAULT_ROOT = path.resolve(__dirname, '..');
const IMAGE_RECORD_SCHEMA = 'qinglong/release-set-image-record@v1';
@@ -312,6 +317,156 @@ function auditReleaseSet(actual, options) {
});
}
function inspectReleaseSet(actual, options) {
const repositoryOwner = normalizeRepositoryOwner(options.repositoryOwner);
if (
!RELEASE_SCOPES.includes(options.releaseScope) ||
typeof options.version !== 'string' ||
!VERSION_PATTERN.test(options.version) ||
!/^[a-f0-9]{40}$/u.test(options.sourceRevision || '') ||
options.sourceRef !== `refs/tags/v${options.version}`
) {
fail('expected release identity is invalid');
}
if (
!exactKeys(actual, [
'schemaVersion',
'schema',
'release',
'candidate',
'repositoryOwner',
'platforms',
'deploymentFamilies',
'images',
'promotion',
'requiredVerification',
'releaseSetDigest',
]) ||
actual.schemaVersion !== 1 ||
actual.schema !== RELEASE_SET_SCHEMA ||
!exactKeys(actual.release, [
'version',
'sourceRevision',
'sourceRef',
'scope',
]) ||
actual.release.version !== options.version ||
actual.release.sourceRevision !== options.sourceRevision ||
actual.release.sourceRef !== options.sourceRef ||
actual.release.scope !== options.releaseScope ||
!exactKeys(actual.candidate, ['schema', 'contractDigest']) ||
actual.candidate.schema !== RELEASE_CANDIDATE_SCHEMA ||
!DIGEST_PATTERN.test(actual.candidate.contractDigest || '') ||
actual.repositoryOwner !== repositoryOwner ||
JSON.stringify(actual.platforms) !==
JSON.stringify(['linux/amd64', 'linux/arm64']) ||
!exactKeys(actual.deploymentFamilies, ['local', 'cluster']) ||
!Array.isArray(actual.images) ||
!exactKeys(actual.promotion, [
'authority',
'versionTags',
'sourceTags',
'crossRepositoryAtomicity',
'recovery',
]) ||
JSON.stringify(actual.promotion) !==
JSON.stringify({
authority: 'complete_verified_release_set',
versionTags: 'promote_after_complete_set_audit',
sourceTags: 'promote_after_complete_set_audit',
crossRepositoryAtomicity: false,
recovery: 'verify_exact_digest_then_continue',
}) ||
!exactKeys(actual.requiredVerification, [
'imageKeylessSignature',
'imageAttestations',
'releaseSetBuildProvenance',
]) ||
JSON.stringify(actual.requiredVerification) !==
JSON.stringify({
imageKeylessSignature: true,
imageAttestations: [...REQUIRED_IMAGE_ATTESTATIONS],
releaseSetBuildProvenance: true,
})
) {
fail('standalone release set shape or identity is invalid');
}
const selected =
options.releaseScope === 'local'
? [...LOCAL_IMAGES]
: options.releaseScope === 'cluster'
? [...CLUSTER_IMAGES]
: [...CLUSTER_IMAGES, ...LOCAL_IMAGES];
const expectedNames = selected.map((entry) => entry.image);
if (actual.images.length !== selected.length) {
fail('standalone release set image count is invalid');
}
for (let index = 0; index < selected.length; index += 1) {
const image = actual.images[index];
const expected = selected[index];
const repository = `ghcr.io/${repositoryOwner}/${expected.repository}`;
if (
!exactKeys(image, [
'name',
'repository',
'digest',
'reference',
'versionTag',
'sourceTag',
'platforms',
'imageRecordDigest',
]) ||
image.name !== expected.image ||
image.repository !== expected.repository ||
!DIGEST_PATTERN.test(image.digest || '') ||
image.reference !== `${repository}@${image.digest}` ||
image.versionTag !== `${repository}:${options.version}` ||
image.sourceTag !== `${repository}:sha-${options.sourceRevision}` ||
JSON.stringify(image.platforms) !== JSON.stringify(actual.platforms) ||
!DIGEST_PATTERN.test(image.imageRecordDigest || '')
) {
fail(`standalone release set image is invalid: ${expected.image}`);
}
}
const expectedFamilies = {
local: {
selected: ['local', 'all'].includes(options.releaseScope),
profiles: ['edge', 'standalone'],
images: expectedNames.filter((name) => name === 'local'),
},
cluster: {
selected: ['cluster', 'all'].includes(options.releaseScope),
profiles: ['cluster', 'worker-edge', 'worker-node'],
images: expectedNames.filter((name) => name !== 'local'),
},
};
if (
JSON.stringify(actual.deploymentFamilies) !==
JSON.stringify(expectedFamilies)
) {
fail('standalone release set deployment families are invalid');
}
const { releaseSetDigest, ...unsigned } = actual;
if (
!DIGEST_PATTERN.test(releaseSetDigest || '') ||
releaseSetDigest !== sha256(JSON.stringify(unsigned))
) {
fail('standalone release set digest is invalid');
}
return Object.freeze({
compatible: true,
releaseSetDigest,
releaseScope: actual.release.scope,
imageCount: actual.images.length,
images: Object.freeze([...expectedNames]),
references: Object.freeze(actual.images.map((entry) => entry.reference)),
verification: 'standalone_structure_identity_and_self_digest',
sourceRecordsReplayed: false,
});
}
function readRecordDirectory(directoryPath, candidate) {
const resolved = resolveCanonicalAbsolute(directoryPath, 'records');
const stat = fs.lstatSync(resolved);
@@ -345,8 +500,7 @@ function parseArguments(argv) {
fail('arguments are invalid');
values[match[1]] = match[2];
}
const common = [
'candidate',
const identity = [
'mode',
'release-scope',
'repository-owner',
@@ -354,6 +508,7 @@ function parseArguments(argv) {
'source-revision',
'version',
];
const common = ['candidate', ...identity];
const expected =
values.mode === 'record-image'
? [...common, 'digest', 'image', 'output']
@@ -361,6 +516,8 @@ function parseArguments(argv) {
? [...common, 'output', 'records']
: values.mode === 'audit'
? [...common, 'records', 'report']
: values.mode === 'inspect'
? [...identity, 'report']
: [];
if (
expected.length === 0 ||
@@ -371,7 +528,7 @@ function parseArguments(argv) {
}
return Object.freeze({
mode: values.mode,
candidate: values.candidate,
...(values.candidate ? { candidate: values.candidate } : {}),
version: values.version,
sourceRevision: values['source-revision'],
sourceRef: values['source-ref'],
@@ -387,6 +544,12 @@ function parseArguments(argv) {
function runCli(argv, root = DEFAULT_ROOT, output = process.stdout) {
const options = parseArguments(argv);
if (options.mode === 'inspect') {
const report = readCanonicalJson(options.report, 'release set');
const inspection = inspectReleaseSet(report, options);
output.write(canonicalJson(inspection));
return inspection;
}
const candidate = readCanonicalJson(options.candidate, 'release candidate');
if (options.mode === 'record-image') {
const record = createVerifiedImageRecord({ ...options, candidate, root });
@@ -435,6 +598,7 @@ module.exports = Object.freeze({
auditReleaseSet,
createReleaseSet,
createVerifiedImageRecord,
inspectReleaseSet,
parseArguments,
runCli,
});