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
@@ -254,6 +254,8 @@ function auditClusterCopilotConsoleDistribution(options = {}) {
'--bundle-from-oci',
'Promote tags only after the complete set is verified',
'Attest the complete release-set file provenance',
'Publish and round-trip the durable OCI release catalog',
'Attest durable release-catalog provenance',
],
'QL3_CLUSTER_ADMIN_RELEASE_WORKFLOW_DRIFT',
);
+66 -19
View File
@@ -274,8 +274,8 @@ function auditClusterImageCiWorkflow(
);
requirePattern(
source,
/node --test[\s\S]*test\/back\/ql3ClusterImageSbom\.test\.cjs[\s\S]*test\/back\/ql3ClusterImageReleaseAudit\.test\.cjs[\s\S]*test\/back\/ql3ReleaseCandidateContract\.test\.cjs[\s\S]*test\/back\/ql3ReleaseSetContract\.test\.cjs/,
'cluster image CI must run SBOM, candidate, release-set and workflow negative tests',
/node --test[\s\S]*test\/back\/ql3ClusterImageSbom\.test\.cjs[\s\S]*test\/back\/ql3ClusterImageReleaseAudit\.test\.cjs[\s\S]*test\/back\/ql3ReleaseCandidateContract\.test\.cjs[\s\S]*test\/back\/ql3ReleaseSetContract\.test\.cjs[\s\S]*test\/back\/ql3ReleaseCatalogContract\.test\.cjs/,
'cluster image CI must run SBOM, candidate, release-set, durable catalog and workflow negative tests',
);
requirePattern(
source,
@@ -517,7 +517,7 @@ function auditReleaseWorkflow(source) {
JSON.stringify(releaseSetJob?.needs) !==
JSON.stringify(['release-candidate', 'publish']) ||
releaseSetJob?.['runs-on'] !== 'ubuntu-24.04' ||
releaseSetJob?.['timeout-minutes'] !== 15 ||
releaseSetJob?.['timeout-minutes'] !== 20 ||
typeof releaseSetJob?.if !== 'string' ||
!/always\(\)[\s\S]*release-candidate\.result == 'success'[\s\S]*publish\.result == 'success'/.test(
releaseSetJob.if,
@@ -685,7 +685,7 @@ function auditReleaseWorkflow(source) {
const releaseSetSteps = releaseSetJob?.steps;
if (
!Array.isArray(releaseSetSteps) ||
releaseSetSteps.length !== 9 ||
releaseSetSteps.length !== 15 ||
releaseSetSteps[0]?.uses !==
'actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803' ||
releaseSetSteps[0]?.with?.['persist-credentials'] !== false ||
@@ -702,29 +702,56 @@ function auditReleaseWorkflow(source) {
'merge-multiple': true,
}) ||
releaseSetSteps[3]?.id !== 'release-set' ||
!/ql3-release-candidate-contract\.cjs[\s\S]*--mode=create[\s\S]*ql3-release-set-contract\.cjs[\s\S]*--mode=aggregate[\s\S]*--records="\$\{RUNNER_TEMP\}\/release-records"[\s\S]*ql3-release-set-contract\.cjs[\s\S]*--mode=audit[\s\S]*--report="\$\{report\}"[\s\S]*GITHUB_OUTPUT/.test(
!/ql3-release-candidate-contract\.cjs[\s\S]*--mode=create[\s\S]*ql3-release-set-contract\.cjs[\s\S]*--mode=aggregate[\s\S]*--records="\$\{RUNNER_TEMP\}\/release-records"[\s\S]*ql3-release-set-contract\.cjs[\s\S]*--mode=audit[\s\S]*--report="\$\{report\}"[\s\S]*ql3-release-set-contract\.cjs[\s\S]*--mode=inspect[\s\S]*ql3-release-catalog-contract\.cjs[\s\S]*--mode=plan[\s\S]*--source-repository="\$\{source_repository\}"[\s\S]*--release-set="\$\{report\}"[\s\S]*GITHUB_OUTPUT/.test(
releaseSetSteps[3]?.run ?? '',
) ||
!/v0\.11\.5\/regctl-linux-amd64[\s\S]*c93aa7638749f5aaac1a8e01787321889c78f0101809bb2880343478d0ba0467[\s\S]*sha256sum --check --strict/.test(
releaseSetSteps[4]?.run ?? '',
) ||
releaseSetSteps[5]?.uses !==
'sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6' ||
releaseSetSteps[6]?.uses !==
'docker/login-action@06fb636fac595d6fb4b28a5dfcb21a6f5091859c' ||
!/for \(const image of report\.images\)[\s\S]*image\.reference[\s\S]*image\.versionTag, image\.sourceTag[\s\S]*release tag already points at another digest[\s\S]*\['image', 'copy', state\.image\.reference, state\.tag\][\s\S]*promoted tag does not resolve to the release-set digest/.test(
releaseSetSteps[6]?.run ?? '',
releaseSetSteps[7]?.run ?? '',
) ||
releaseSetSteps[7]?.uses !==
releaseSetSteps[8]?.uses !==
'actions/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6' ||
JSON.stringify(releaseSetSteps[7]?.with) !==
JSON.stringify(releaseSetSteps[8]?.with) !==
JSON.stringify({
'subject-path': '${{ steps.release-set.outputs.report }}',
}) ||
releaseSetSteps[8]?.uses !==
releaseSetSteps[9]?.id !== 'catalog' ||
!/artifact put[\s\S]*--artifact-type "\$\{artifact_type\}"[\s\S]*--file-media-type "\$\{file_media_type\}"[\s\S]*--file "\$\{RELEASE_SET\}"[\s\S]*--file-title[\s\S]*--strip-dirs[\s\S]*dev\.qinglong\.release\.scope[\s\S]*org\.opencontainers\.image\.revision[\s\S]*org\.opencontainers\.image\.source[\s\S]*org\.opencontainers\.image\.version[\s\S]*image digest "\$\{discovery_tag\}"[\s\S]*artifact get --file "\$\{file_name\}" "\$\{immutable_reference\}"[\s\S]*cmp --silent "\$\{RELEASE_SET\}" "\$\{roundtrip\}"[\s\S]*manifest get "\$\{immutable_reference\}" --format raw-body[\s\S]*GITHUB_OUTPUT/.test(
releaseSetSteps[9]?.run ?? '',
) ||
!/cosign sign --yes "\$\{CATALOG\}@\$\{DIGEST\}"/.test(
releaseSetSteps[10]?.run ?? '',
) ||
releaseSetSteps[11]?.uses !==
'actions/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6' ||
JSON.stringify(releaseSetSteps[11]?.with) !==
JSON.stringify({
'subject-name': '${{ steps.catalog.outputs.repository }}',
'subject-digest': '${{ steps.catalog.outputs.digest }}',
'push-to-registry': true,
}) ||
releaseSetSteps[12]?.id !== 'catalog-receipt' ||
!/cosign verify[\s\S]*--certificate-identity "\$\{certificate_identity\}"[\s\S]*--certificate-oidc-issuer "https:\/\/token\.actions\.githubusercontent\.com"[\s\S]*"\$\{CATALOG\}@\$\{DIGEST\}"[\s\S]*gh attestation verify "oci:\/\/\$\{CATALOG\}@\$\{DIGEST\}"[\s\S]*--source-digest "\$\{GITHUB_SHA\}"[\s\S]*--source-ref "\$\{GITHUB_REF\}"[\s\S]*--deny-self-hosted-runners[\s\S]*--bundle-from-oci[\s\S]*ql3-release-catalog-contract\.cjs[\s\S]*--mode=receipt[\s\S]*--manifest-digest="\$\{DIGEST\}"[\s\S]*ql3-release-catalog-contract\.cjs[\s\S]*--mode=audit[\s\S]*GITHUB_OUTPUT/.test(
releaseSetSteps[12]?.run ?? '',
) ||
releaseSetSteps[13]?.uses !==
'actions/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6' ||
JSON.stringify(releaseSetSteps[13]?.with) !==
JSON.stringify({
'subject-path': '${{ steps.catalog-receipt.outputs.receipt }}',
}) ||
releaseSetSteps[14]?.uses !==
'actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a' ||
JSON.stringify(releaseSetSteps[8]?.with) !==
JSON.stringify(releaseSetSteps[14]?.with) !==
JSON.stringify({
name: 'ql3-release-set-${{ inputs.version }}-${{ inputs.release_scope }}',
path: '${{ steps.release-set.outputs.report }}',
path: '${{ steps.release-set.outputs.bundle }}',
'if-no-files-found': 'error',
'retention-days': 90,
'compression-level': 0,
@@ -733,7 +760,7 @@ function auditReleaseWorkflow(source) {
})
) {
throw new Error(
'release-set job must download only same-run records, independently attest and publish one no-overwrite deployment lock',
'release-set job must download only same-run records, independently inspect, durably publish and attest one no-overwrite deployment lock bundle',
);
}
if (
@@ -832,6 +859,12 @@ function auditReleaseWorkflow(source) {
6,
'all release jobs must pin the reviewed immutable Node setup action',
);
requireOccurrences(
source,
/uses: sigstore\/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4\.1\.2/g,
2,
'image and release-catalog publishers must both pin the reviewed Cosign installer',
);
requirePattern(
source,
/name: Recreate and audit the source-derived release candidate contract[\s\S]*ql3-release-candidate-contract\.cjs[\s\S]*--mode=create[\s\S]*--version="\$\{RELEASE_VERSION\}"[\s\S]*--source-revision="\$\{GITHUB_SHA\}"[\s\S]*--source-ref="\$\{GITHUB_REF\}"[\s\S]*--release-scope="\$\{RELEASE_SCOPE\}"[\s\S]*ql3-release-candidate-contract\.cjs[\s\S]*--mode=audit[\s\S]*--report="\$\{contract\}"/,
@@ -891,14 +924,14 @@ function auditReleaseWorkflow(source) {
requireOccurrences(
source,
/uses: actions\/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6 # v4/g,
5,
'release workflow must create four image attestations and one complete release-set provenance attestation',
7,
'release workflow must create four image attestations plus release-set, durable catalog and receipt provenance',
);
requireOccurrences(
source,
/push-to-registry: true/g,
4,
'all GitHub attestations must be pushed beside the OCI image',
5,
'all image and durable-catalog OCI attestations must be pushed beside their subject',
);
requirePattern(
source,
@@ -967,7 +1000,7 @@ function auditReleaseWorkflow(source) {
'GitHub attestation verification must read the published OCI bundle',
],
]) {
requireOccurrences(source, pattern, 4, finding);
requireOccurrences(source, pattern, 5, finding);
}
requirePattern(
source,
@@ -986,8 +1019,8 @@ function auditReleaseWorkflow(source) {
);
requirePattern(
source,
/release-set:\s+name: Close and publish the complete deployment release set[\s\S]*needs:[\s\S]*- publish[\s\S]*name: Promote tags only after the complete set is verified[\s\S]*for \(const image of report\.images\)[\s\S]*image\.versionTag, image\.sourceTag[\s\S]*image', 'copy'[\s\S]*name: Attest the complete release-set file provenance[\s\S]*name: Publish the deployment digest lock/,
'release tags and the deployment lock must be published only after every selected digest record is complete',
/release-set:\s+name: Close and publish the complete deployment release set[\s\S]*needs:[\s\S]*- publish[\s\S]*name: Promote tags only after the complete set is verified[\s\S]*for \(const image of report\.images\)[\s\S]*image\.versionTag, image\.sourceTag[\s\S]*image', 'copy'[\s\S]*name: Attest the complete release-set file provenance[\s\S]*name: Publish and round-trip the durable OCI release catalog[\s\S]*name: Keylessly sign the immutable release-catalog digest[\s\S]*name: Attest durable release-catalog provenance[\s\S]*name: Verify the durable catalog and create its immutable receipt[\s\S]*name: Attest the immutable release-catalog receipt[\s\S]*name: Publish the deployment digest lock/,
'release tags, durable OCI catalog and deployment bundle must be published only after every selected digest record is complete',
);
return {
trigger: 'explicit protected v3 tag dispatch',
@@ -1046,11 +1079,24 @@ function auditReleaseWorkflow(source) {
sourceDerived: true,
sameRunRecords: true,
exactScopeClosure: true,
standaloneInspection: true,
tagPromotionAuthority: 'complete_verified_release_set',
fileProvenanceAttested: true,
artifactRetentionDays: 90,
crossRepositoryAtomicity: false,
},
durableCatalog: {
repository: 'qinglong3-release-catalog',
artifactType: 'application/vnd.qinglong.release-set.v1+json',
basenameOnly: true,
crossRunnerDeterministic: true,
byteExactRoundTrip: true,
keylessSignatureVerified: true,
githubProvenanceVerified: true,
discoveryTagAuthority: 'none',
immutableDigestAuthority: 'verified',
receiptAttested: true,
},
localRolloutPreflight: true,
localRolloutApply: true,
postPublishVerification: [
@@ -1061,6 +1107,7 @@ function auditReleaseWorkflow(source) {
'os-vulnerability',
'release-candidate',
'release-set',
'durable-catalog',
'release-tags',
],
};
@@ -253,6 +253,7 @@ function createReleaseCandidateContract(options) {
'production-dependency-audit',
'digest-signature-and-attestations',
'cross-image-release-set',
'durable-oci-release-catalog',
...(options.releaseScope !== 'cluster'
? ['edge-and-standalone-rollout']
: []),
+434
View File
@@ -0,0 +1,434 @@
#!/usr/bin/env node
'use strict';
const crypto = require('node:crypto');
const fs = require('node:fs');
const path = require('node:path');
const {
RELEASE_SET_SCHEMA,
inspectReleaseSet,
} = require('./ql3-release-set-contract.cjs');
const CATALOG_PLAN_SCHEMA = 'qinglong/release-catalog-plan@v1';
const CATALOG_RECEIPT_SCHEMA = 'qinglong/release-catalog-receipt@v1';
const ARTIFACT_TYPE = 'application/vnd.qinglong.release-set.v1+json';
const FILE_MEDIA_TYPE = ARTIFACT_TYPE;
const OCI_MANIFEST_MEDIA_TYPE = 'application/vnd.oci.image.manifest.v1+json';
const OCI_EMPTY_CONFIG_MEDIA_TYPE = 'application/vnd.oci.empty.v1+json';
const OCI_EMPTY_CONFIG_DIGEST =
'sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a';
const DIGEST_PATTERN = /^sha256:[a-f0-9]{64}$/u;
const SOURCE_REPOSITORY_PATTERN =
/^[a-z0-9](?:[a-z0-9-]{0,37}[a-z0-9])?\/[a-z0-9._-]{1,100}$/u;
const MAX_FILE_BYTES = 1024 * 1024;
class QingLong3ReleaseCatalogError extends Error {
constructor(message) {
super(`QingLong 3 release catalog failed: ${message}`);
this.name = 'QingLong3ReleaseCatalogError';
}
}
function fail(message) {
throw new QingLong3ReleaseCatalogError(message);
}
function sha256(value) {
return `sha256:${crypto.createHash('sha256').update(value).digest('hex')}`;
}
function canonicalJson(value) {
return `${JSON.stringify(value)}\n`;
}
function exactKeys(value, expected) {
return (
value !== null &&
typeof value === 'object' &&
!Array.isArray(value) &&
JSON.stringify(Object.keys(value)) === JSON.stringify(expected)
);
}
function resolveCanonicalAbsolute(input, label) {
if (typeof input !== 'string' || !path.isAbsolute(input)) {
fail(`${label} path must be absolute`);
}
const resolved = path.resolve(input);
if (resolved !== input) fail(`${label} path must be normalized`);
return resolved;
}
function readBoundedFile(filePath, label) {
const resolved = resolveCanonicalAbsolute(filePath, label);
const stat = fs.lstatSync(resolved);
if (
!stat.isFile() ||
stat.isSymbolicLink() ||
stat.size < 2 ||
stat.size > MAX_FILE_BYTES ||
fs.realpathSync(resolved) !== resolved ||
fs.realpathSync(path.dirname(resolved)) !== path.dirname(resolved)
) {
fail(`${label} must be one bounded canonical regular file`);
}
return Object.freeze({
path: resolved,
contents: fs.readFileSync(resolved, 'utf8'),
bytes: stat.size,
});
}
function parseJson(contents, label, requireCanonical) {
let value;
try {
value = JSON.parse(contents);
} catch {
fail(`${label} must contain valid JSON`);
}
if (requireCanonical && canonicalJson(value) !== contents) {
fail(`${label} must use exact canonical JSON encoding`);
}
return value;
}
function readCanonicalJson(filePath, label) {
const file = readBoundedFile(filePath, label);
return Object.freeze({
...file,
value: parseJson(file.contents, label, true),
});
}
function writeNoReplace(filePath, value) {
const resolved = resolveCanonicalAbsolute(filePath, 'output');
if (
fs.existsSync(resolved) ||
fs.realpathSync(path.dirname(resolved)) !== path.dirname(resolved)
) {
fail('output must be unused in one canonical directory');
}
fs.writeFileSync(resolved, canonicalJson(value), {
encoding: 'utf8',
mode: 0o600,
flag: 'wx',
});
}
function normalizeSourceRepository(value, repositoryOwner) {
if (
typeof value !== 'string' ||
!SOURCE_REPOSITORY_PATTERN.test(value) ||
!value.startsWith(`${repositoryOwner}/`)
) {
fail(
'source repository must be one lowercase repository owned by the publisher',
);
}
return value;
}
function expectedFileName(version, releaseScope) {
return `qinglong3-release-set-${version}-${releaseScope}.json`;
}
function createCatalogPlan(releaseSet, options) {
inspectReleaseSet(releaseSet, options);
const sourceRepository = normalizeSourceRepository(
options.sourceRepository,
releaseSet.repositoryOwner,
);
const contents = canonicalJson(releaseSet);
const fileName = expectedFileName(options.version, options.releaseScope);
const registryRepository = `ghcr.io/${releaseSet.repositoryOwner}/qinglong3-release-catalog`;
const unsigned = {
schemaVersion: 1,
schema: CATALOG_PLAN_SCHEMA,
release: { ...releaseSet.release },
sourceRepository,
releaseSet: {
schema: RELEASE_SET_SCHEMA,
releaseSetDigest: releaseSet.releaseSetDigest,
contentDigest: sha256(contents),
bytes: Buffer.byteLength(contents),
fileName,
},
catalog: {
repository: 'qinglong3-release-catalog',
registryRepository,
discoveryTag: `${registryRepository}:v${options.version}-${options.releaseScope}`,
artifactType: ARTIFACT_TYPE,
fileMediaType: FILE_MEDIA_TYPE,
annotations: {
'dev.qinglong.release.scope': options.releaseScope,
'org.opencontainers.image.revision': options.sourceRevision,
'org.opencontainers.image.source': `https://github.com/${sourceRepository}`,
'org.opencontainers.image.version': options.version,
},
},
publicationPolicy: {
title: 'basename_only',
crossRunnerDeterministic: true,
discoveryTagAuthority: 'none',
immutableDigestAuthority: 'required',
roundTrip: 'byte_exact',
recovery: 'republish_same_content_then_verify_digest',
},
};
return Object.freeze({
...unsigned,
planDigest: sha256(JSON.stringify(unsigned)),
});
}
function auditCatalogPlan(actual, releaseSet, options) {
const expected = createCatalogPlan(releaseSet, options);
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
fail('catalog plan differs from the standalone release set');
}
return expected;
}
function validatePlanShape(plan) {
if (
!exactKeys(plan, [
'schemaVersion',
'schema',
'release',
'sourceRepository',
'releaseSet',
'catalog',
'publicationPolicy',
'planDigest',
]) ||
plan.schemaVersion !== 1 ||
plan.schema !== CATALOG_PLAN_SCHEMA ||
!DIGEST_PATTERN.test(plan.planDigest || '')
) {
fail('catalog plan shape is invalid');
}
const { planDigest, ...unsigned } = plan;
if (planDigest !== sha256(JSON.stringify(unsigned))) {
fail('catalog plan digest is invalid');
}
return plan;
}
function validateManifest(plan, manifestContents, manifestDigest) {
if (
!DIGEST_PATTERN.test(manifestDigest || '') ||
sha256(manifestContents) !== manifestDigest
) {
fail('catalog manifest digest is invalid');
}
const manifest = parseJson(manifestContents, 'catalog manifest', false);
if (
!exactKeys(manifest, [
'schemaVersion',
'mediaType',
'artifactType',
'config',
'layers',
'annotations',
]) ||
manifest.schemaVersion !== 2 ||
manifest.mediaType !== OCI_MANIFEST_MEDIA_TYPE ||
manifest.artifactType !== plan.catalog.artifactType ||
!exactKeys(manifest.config, ['mediaType', 'digest', 'size']) ||
manifest.config.mediaType !== OCI_EMPTY_CONFIG_MEDIA_TYPE ||
manifest.config.digest !== OCI_EMPTY_CONFIG_DIGEST ||
manifest.config.size !== 2 ||
!Array.isArray(manifest.layers) ||
manifest.layers.length !== 1 ||
!exactKeys(manifest.layers[0], [
'mediaType',
'digest',
'size',
'annotations',
]) ||
manifest.layers[0].mediaType !== plan.catalog.fileMediaType ||
manifest.layers[0].digest !== plan.releaseSet.contentDigest ||
manifest.layers[0].size !== plan.releaseSet.bytes ||
JSON.stringify(manifest.layers[0].annotations) !==
JSON.stringify({
'org.opencontainers.image.title': plan.releaseSet.fileName,
}) ||
JSON.stringify(manifest.annotations) !==
JSON.stringify(plan.catalog.annotations)
) {
fail('catalog manifest differs from the exact publication plan');
}
return manifest;
}
function createCatalogReceipt(plan, manifestContents, manifestDigest) {
validatePlanShape(plan);
validateManifest(plan, manifestContents, manifestDigest);
const unsigned = {
schemaVersion: 1,
schema: CATALOG_RECEIPT_SCHEMA,
release: { ...plan.release },
planDigest: plan.planDigest,
releaseSet: { ...plan.releaseSet },
catalog: {
repository: plan.catalog.repository,
discoveryTag: plan.catalog.discoveryTag,
manifestDigest,
immutableReference: `${plan.catalog.registryRepository}@${manifestDigest}`,
artifactType: plan.catalog.artifactType,
},
verification: {
remoteManifestStructure: 'exact',
releaseSetRoundTrip: 'byte_exact',
keylessSignature: 'exact_workflow_identity',
githubProvenance: 'source_tag_and_revision_bound',
discoveryTagAuthority: 'none',
immutableDigestAuthority: 'verified',
},
};
return Object.freeze({
...unsigned,
receiptDigest: sha256(JSON.stringify(unsigned)),
});
}
function auditCatalogReceipt(actual, plan, manifestContents, manifestDigest) {
const expected = createCatalogReceipt(plan, manifestContents, manifestDigest);
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
fail('catalog receipt differs from the verified OCI manifest');
}
return Object.freeze({
compatible: true,
releaseSetDigest: actual.releaseSet.releaseSetDigest,
releaseScope: actual.release.scope,
catalogManifestDigest: actual.catalog.manifestDigest,
immutableReference: actual.catalog.immutableReference,
discoveryTagAuthority: actual.verification.discoveryTagAuthority,
});
}
function parseArguments(argv) {
const values = {};
for (const argument of argv) {
const match = /^--([a-z-]+)=(.+)$/u.exec(argument);
if (!match || Object.hasOwn(values, match[1]))
fail('arguments are invalid');
values[match[1]] = match[2];
}
const identity = [
'mode',
'release-scope',
'repository-owner',
'source-ref',
'source-repository',
'source-revision',
'version',
];
const expected =
values.mode === 'plan'
? [...identity, 'output', 'release-set']
: values.mode === 'receipt'
? ['manifest', 'manifest-digest', 'mode', 'output', 'plan']
: values.mode === 'audit'
? ['manifest', 'manifest-digest', 'mode', 'plan', 'receipt']
: [];
if (
expected.length === 0 ||
JSON.stringify(Object.keys(values).sort()) !==
JSON.stringify(expected.sort())
) {
fail('arguments are invalid');
}
return Object.freeze({
mode: values.mode,
...(values.version ? { version: values.version } : {}),
...(values['source-revision']
? { sourceRevision: values['source-revision'] }
: {}),
...(values['source-ref'] ? { sourceRef: values['source-ref'] } : {}),
...(values['release-scope']
? { releaseScope: values['release-scope'] }
: {}),
...(values['repository-owner']
? { repositoryOwner: values['repository-owner'] }
: {}),
...(values['source-repository']
? { sourceRepository: values['source-repository'] }
: {}),
...(values['release-set'] ? { releaseSet: values['release-set'] } : {}),
...(values.plan ? { plan: values.plan } : {}),
...(values.manifest ? { manifest: values.manifest } : {}),
...(values['manifest-digest']
? { manifestDigest: values['manifest-digest'] }
: {}),
...(values.receipt ? { receipt: values.receipt } : {}),
...(values.output ? { output: values.output } : {}),
});
}
function runCli(argv, output = process.stdout) {
const options = parseArguments(argv);
if (options.mode === 'plan') {
const releaseSetFile = readCanonicalJson(options.releaseSet, 'release set');
if (
path.basename(releaseSetFile.path) !==
expectedFileName(options.version, options.releaseScope)
) {
fail('release set filename must be deterministic');
}
const plan = createCatalogPlan(releaseSetFile.value, options);
writeNoReplace(options.output, plan);
output.write(canonicalJson(plan));
return plan;
}
const plan = readCanonicalJson(options.plan, 'catalog plan').value;
const manifest = readBoundedFile(options.manifest, 'catalog manifest');
if (options.mode === 'receipt') {
const receipt = createCatalogReceipt(
plan,
manifest.contents,
options.manifestDigest,
);
writeNoReplace(options.output, receipt);
output.write(canonicalJson(receipt));
return receipt;
}
const receipt = readCanonicalJson(options.receipt, 'catalog receipt').value;
const audit = auditCatalogReceipt(
receipt,
plan,
manifest.contents,
options.manifestDigest,
);
output.write(canonicalJson(audit));
return audit;
}
if (require.main === module) {
try {
runCli(process.argv.slice(2));
} catch (error) {
process.stderr.write(
`${error instanceof Error ? error.message : 'release catalog failed'}\n`,
);
process.exitCode = 1;
}
}
module.exports = Object.freeze({
ARTIFACT_TYPE,
CATALOG_PLAN_SCHEMA,
CATALOG_RECEIPT_SCHEMA,
FILE_MEDIA_TYPE,
OCI_EMPTY_CONFIG_DIGEST,
OCI_EMPTY_CONFIG_MEDIA_TYPE,
OCI_MANIFEST_MEDIA_TYPE,
QingLong3ReleaseCatalogError,
auditCatalogPlan,
auditCatalogReceipt,
createCatalogPlan,
createCatalogReceipt,
parseArguments,
runCli,
});
+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,
});