mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-20 16:07:11 +08:00
feat(ql3): publish complete release sets
This commit is contained in:
@@ -252,7 +252,8 @@ function auditClusterCopilotConsoleDistribution(options = {}) {
|
||||
'--predicate-type "https://qinglong.dev/attestations/release-candidate-contract/v1"',
|
||||
'--deny-self-hosted-runners',
|
||||
'--bundle-from-oci',
|
||||
'Promote only the verified digest to immutable release tags',
|
||||
'Promote tags only after the complete set is verified',
|
||||
'Attest the complete release-set file provenance',
|
||||
],
|
||||
'QL3_CLUSTER_ADMIN_RELEASE_WORKFLOW_DRIFT',
|
||||
);
|
||||
|
||||
@@ -274,8 +274,8 @@ function auditClusterImageCiWorkflow(
|
||||
);
|
||||
requirePattern(
|
||||
source,
|
||||
/node --test test\/back\/ql3ClusterImageSbom\.test\.cjs test\/back\/ql3ClusterImageReleaseAudit\.test\.cjs/,
|
||||
'cluster image CI must run SBOM and release-contract 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/,
|
||||
'cluster image CI must run SBOM, candidate, release-set and workflow negative tests',
|
||||
);
|
||||
requirePattern(
|
||||
source,
|
||||
@@ -403,6 +403,7 @@ function auditReleaseWorkflow(source) {
|
||||
const drEvidenceJob = workflow?.jobs?.['cluster-dr-release-evidence'];
|
||||
const osVulnerabilityJob = workflow?.jobs?.['os-vulnerability'];
|
||||
const publishJob = workflow?.jobs?.publish;
|
||||
const releaseSetJob = workflow?.jobs?.['release-set'];
|
||||
if (
|
||||
publishJob?.strategy?.matrix?.include !==
|
||||
'${{ fromJSON(needs.release-candidate.outputs.publish-matrix) }}'
|
||||
@@ -450,6 +451,14 @@ function auditReleaseWorkflow(source) {
|
||||
JSON.stringify(drEvidenceJob?.permissions) !==
|
||||
JSON.stringify({ contents: 'read' }) ||
|
||||
JSON.stringify(publishJob?.permissions) !==
|
||||
JSON.stringify({
|
||||
contents: 'read',
|
||||
packages: 'write',
|
||||
'id-token': 'write',
|
||||
attestations: 'write',
|
||||
'artifact-metadata': 'write',
|
||||
}) ||
|
||||
JSON.stringify(releaseSetJob?.permissions) !==
|
||||
JSON.stringify({
|
||||
contents: 'read',
|
||||
packages: 'write',
|
||||
@@ -459,7 +468,7 @@ function auditReleaseWorkflow(source) {
|
||||
})
|
||||
) {
|
||||
throw new Error(
|
||||
'release permissions must keep evidence read-only and grant writes only to the gated publisher',
|
||||
'release permissions must keep evidence read-only and grant writes only to gated image and release-set publishers',
|
||||
);
|
||||
}
|
||||
if (
|
||||
@@ -504,10 +513,18 @@ function auditReleaseWorkflow(source) {
|
||||
typeof publishJob?.if !== 'string' ||
|
||||
!/always\(\)[\s\S]*release-candidate\.result == 'success'[\s\S]*os-vulnerability\.result == 'success'[\s\S]*cluster-evidence-required != 'true'[\s\S]*worker-management-release-evidence\.result == 'success'[\s\S]*cluster-dr-release-evidence\.result == 'success'/.test(
|
||||
publishJob.if,
|
||||
) ||
|
||||
JSON.stringify(releaseSetJob?.needs) !==
|
||||
JSON.stringify(['release-candidate', 'publish']) ||
|
||||
releaseSetJob?.['runs-on'] !== 'ubuntu-24.04' ||
|
||||
releaseSetJob?.['timeout-minutes'] !== 15 ||
|
||||
typeof releaseSetJob?.if !== 'string' ||
|
||||
!/always\(\)[\s\S]*release-candidate\.result == 'success'[\s\S]*publish\.result == 'success'/.test(
|
||||
releaseSetJob.if,
|
||||
)
|
||||
) {
|
||||
throw new Error(
|
||||
'release publisher must always require candidate and OS gates while requiring private HA evidence only for a cluster family',
|
||||
'release publisher must always require candidate and OS gates while requiring private HA evidence only for a cluster family; release-set closure must additionally require the complete publish matrix',
|
||||
);
|
||||
}
|
||||
const candidateSteps = candidateJob?.steps;
|
||||
@@ -616,8 +633,14 @@ function auditReleaseWorkflow(source) {
|
||||
const importIndex = publishSteps?.findIndex(
|
||||
(step) => step.id === 'push' && /image import/.test(step.run || ''),
|
||||
);
|
||||
const promotionIndex = publishSteps?.findIndex((step) =>
|
||||
/Promote only the verified digest/.test(step.name || ''),
|
||||
const recordIndex = publishSteps?.findIndex((step) =>
|
||||
/Record the fully verified image/.test(step.name || ''),
|
||||
);
|
||||
const recordUploadIndex = publishSteps?.findIndex(
|
||||
(step) =>
|
||||
step.uses ===
|
||||
'actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a' &&
|
||||
/same-run verified image record/.test(step.name || ''),
|
||||
);
|
||||
if (
|
||||
!Array.isArray(publishSteps) ||
|
||||
@@ -627,9 +650,9 @@ function auditReleaseWorkflow(source) {
|
||||
mergeIndex < 0 ||
|
||||
loginIndex <= mergeIndex ||
|
||||
importIndex <= loginIndex ||
|
||||
promotionIndex !== publishSteps.length - 1 ||
|
||||
publishSteps.filter((step) => /\bimage copy\b/.test(step.run || ''))
|
||||
.length !== 1 ||
|
||||
recordIndex !== publishSteps.length - 2 ||
|
||||
recordUploadIndex !== publishSteps.length - 1 ||
|
||||
publishSteps.some((step) => /\bimage copy\b/.test(step.run || '')) ||
|
||||
publishSteps.some(
|
||||
(step) =>
|
||||
step.uses?.startsWith('docker/build-push-action@') ||
|
||||
@@ -637,7 +660,80 @@ function auditReleaseWorkflow(source) {
|
||||
)
|
||||
) {
|
||||
throw new Error(
|
||||
'privileged publisher must re-audit, import and verify the scanned bundle without any rebuild before final tag promotion',
|
||||
'privileged image publisher must re-audit, import and verify the scanned bundle without any rebuild or tag promotion before release-set closure',
|
||||
);
|
||||
}
|
||||
if (
|
||||
!/ql3-release-set-contract\.cjs[\s\S]*--mode=record-image[\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]*--repository-owner="\$\{owner\}"[\s\S]*--candidate="\$\{RUNNER_TEMP\}\/\$\{\{ matrix\.repository \}\}-release-candidate-contract\.json"[\s\S]*--image="\$\{\{ matrix\.image \}\}"[\s\S]*--digest="\$\{DIGEST\}"/.test(
|
||||
publishSteps[recordIndex]?.run ?? '',
|
||||
) ||
|
||||
JSON.stringify(publishSteps[recordUploadIndex]?.with) !==
|
||||
JSON.stringify({
|
||||
name: 'ql3-release-record-${{ github.run_id }}-${{ github.run_attempt }}-${{ matrix.image }}',
|
||||
path: '${{ runner.temp }}/release-record/${{ matrix.image }}.json',
|
||||
'if-no-files-found': 'error',
|
||||
'retention-days': 1,
|
||||
'compression-level': 0,
|
||||
overwrite: false,
|
||||
'include-hidden-files': false,
|
||||
})
|
||||
) {
|
||||
throw new Error(
|
||||
'each image publisher must upload one exact same-run digest record only after all image verification',
|
||||
);
|
||||
}
|
||||
const releaseSetSteps = releaseSetJob?.steps;
|
||||
if (
|
||||
!Array.isArray(releaseSetSteps) ||
|
||||
releaseSetSteps.length !== 9 ||
|
||||
releaseSetSteps[0]?.uses !==
|
||||
'actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803' ||
|
||||
releaseSetSteps[0]?.with?.['persist-credentials'] !== false ||
|
||||
releaseSetSteps[1]?.uses !==
|
||||
'actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38' ||
|
||||
releaseSetSteps[1]?.with?.['node-version'] !== '24.18.0' ||
|
||||
releaseSetSteps[2]?.uses !==
|
||||
'actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c' ||
|
||||
JSON.stringify(releaseSetSteps[2]?.with) !==
|
||||
JSON.stringify({
|
||||
pattern:
|
||||
'ql3-release-record-${{ github.run_id }}-${{ github.run_attempt }}-*',
|
||||
path: '${{ runner.temp }}/release-records',
|
||||
'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(
|
||||
releaseSetSteps[3]?.run ?? '',
|
||||
) ||
|
||||
!/v0\.11\.5\/regctl-linux-amd64[\s\S]*c93aa7638749f5aaac1a8e01787321889c78f0101809bb2880343478d0ba0467[\s\S]*sha256sum --check --strict/.test(
|
||||
releaseSetSteps[4]?.run ?? '',
|
||||
) ||
|
||||
releaseSetSteps[5]?.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]?.uses !==
|
||||
'actions/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6' ||
|
||||
JSON.stringify(releaseSetSteps[7]?.with) !==
|
||||
JSON.stringify({
|
||||
'subject-path': '${{ steps.release-set.outputs.report }}',
|
||||
}) ||
|
||||
releaseSetSteps[8]?.uses !==
|
||||
'actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a' ||
|
||||
JSON.stringify(releaseSetSteps[8]?.with) !==
|
||||
JSON.stringify({
|
||||
name: 'ql3-release-set-${{ inputs.version }}-${{ inputs.release_scope }}',
|
||||
path: '${{ steps.release-set.outputs.report }}',
|
||||
'if-no-files-found': 'error',
|
||||
'retention-days': 90,
|
||||
'compression-level': 0,
|
||||
overwrite: false,
|
||||
'include-hidden-files': false,
|
||||
})
|
||||
) {
|
||||
throw new Error(
|
||||
'release-set job must download only same-run records, independently attest and publish one no-overwrite deployment lock',
|
||||
);
|
||||
}
|
||||
if (
|
||||
@@ -727,13 +823,13 @@ function auditReleaseWorkflow(source) {
|
||||
requireOccurrences(
|
||||
source,
|
||||
/uses: actions\/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6/g,
|
||||
5,
|
||||
6,
|
||||
'all release jobs must pin the reviewed immutable checkout action',
|
||||
);
|
||||
requireOccurrences(
|
||||
source,
|
||||
/uses: actions\/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6/g,
|
||||
5,
|
||||
6,
|
||||
'all release jobs must pin the reviewed immutable Node setup action',
|
||||
);
|
||||
requirePattern(
|
||||
@@ -771,6 +867,12 @@ function auditReleaseWorkflow(source) {
|
||||
/v0\.11\.5\/regctl-linux-amd64[\s\S]*c93aa7638749f5aaac1a8e01787321889c78f0101809bb2880343478d0ba0467[\s\S]*sha256sum --check --strict/,
|
||||
'publisher must checksum-pin the exact regctl OCI copier',
|
||||
);
|
||||
requireExactOccurrences(
|
||||
source,
|
||||
/c93aa7638749f5aaac1a8e01787321889c78f0101809bb2880343478d0ba0467/g,
|
||||
2,
|
||||
'both image and release-set publishers must checksum-pin the exact regctl OCI copier',
|
||||
);
|
||||
requirePattern(
|
||||
source,
|
||||
/regctl[\s\S]*image import "\$\{IMAGE\}@\$\{DIGEST\}" "\$\{ARCHIVE\}"[\s\S]*image digest "\$\{IMAGE\}@\$\{DIGEST\}"/,
|
||||
@@ -789,8 +891,8 @@ function auditReleaseWorkflow(source) {
|
||||
requireOccurrences(
|
||||
source,
|
||||
/uses: actions\/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6 # v4/g,
|
||||
4,
|
||||
'release workflow must create provenance, SBOM, OS vulnerability and release candidate attestations',
|
||||
5,
|
||||
'release workflow must create four image attestations and one complete release-set provenance attestation',
|
||||
);
|
||||
requireOccurrences(
|
||||
source,
|
||||
@@ -884,8 +986,8 @@ function auditReleaseWorkflow(source) {
|
||||
);
|
||||
requirePattern(
|
||||
source,
|
||||
/name: Promote only the verified digest to immutable release tags[\s\S]*image copy "\$\{IMAGE\}@\$\{DIGEST\}" "\$\{IMAGE\}:\$\{VERSION\}"[\s\S]*image copy "\$\{IMAGE\}@\$\{DIGEST\}" "\$\{IMAGE\}:sha-\$\{GITHUB_SHA\}"[\s\S]*image digest "\$\{IMAGE\}:\$\{VERSION\}"[\s\S]*image digest "\$\{IMAGE\}:sha-\$\{GITHUB_SHA\}"/,
|
||||
'release tags must be promoted only after all digest verification succeeds',
|
||||
/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',
|
||||
);
|
||||
return {
|
||||
trigger: 'explicit protected v3 tag dispatch',
|
||||
@@ -938,6 +1040,16 @@ function auditReleaseWorkflow(source) {
|
||||
'c93aa7638749f5aaac1a8e01787321889c78f0101809bb2880343478d0ba0467',
|
||||
rebuildAfterScan: false,
|
||||
tagAfterVerification: true,
|
||||
tagAfterCompleteReleaseSet: true,
|
||||
},
|
||||
releaseSet: {
|
||||
sourceDerived: true,
|
||||
sameRunRecords: true,
|
||||
exactScopeClosure: true,
|
||||
tagPromotionAuthority: 'complete_verified_release_set',
|
||||
fileProvenanceAttested: true,
|
||||
artifactRetentionDays: 90,
|
||||
crossRepositoryAtomicity: false,
|
||||
},
|
||||
localRolloutPreflight: true,
|
||||
localRolloutApply: true,
|
||||
@@ -948,6 +1060,7 @@ function auditReleaseWorkflow(source) {
|
||||
'cyclonedx',
|
||||
'os-vulnerability',
|
||||
'release-candidate',
|
||||
'release-set',
|
||||
'release-tags',
|
||||
],
|
||||
};
|
||||
|
||||
@@ -252,6 +252,7 @@ function createReleaseCandidateContract(options) {
|
||||
'multiarch-oci-layout',
|
||||
'production-dependency-audit',
|
||||
'digest-signature-and-attestations',
|
||||
'cross-image-release-set',
|
||||
...(options.releaseScope !== 'cluster'
|
||||
? ['edge-and-standalone-rollout']
|
||||
: []),
|
||||
|
||||
@@ -0,0 +1,440 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
'use strict';
|
||||
|
||||
const crypto = require('node:crypto');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const {
|
||||
auditReleaseCandidateContract,
|
||||
} = require('./ql3-release-candidate-contract.cjs');
|
||||
|
||||
const DEFAULT_ROOT = path.resolve(__dirname, '..');
|
||||
const IMAGE_RECORD_SCHEMA = 'qinglong/release-set-image-record@v1';
|
||||
const RELEASE_SET_SCHEMA = 'qinglong/release-set@v1';
|
||||
const MAX_JSON_BYTES = 1024 * 1024;
|
||||
const DIGEST_PATTERN = /^sha256:[a-f0-9]{64}$/u;
|
||||
const OWNER_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,37}[a-z0-9])?$/u;
|
||||
const REQUIRED_IMAGE_ATTESTATIONS = Object.freeze([
|
||||
'github-provenance',
|
||||
'cyclonedx-sbom',
|
||||
'os-vulnerability',
|
||||
'release-candidate-contract',
|
||||
]);
|
||||
|
||||
class QingLong3ReleaseSetError extends Error {
|
||||
constructor(message) {
|
||||
super(`QingLong 3 release set failed: ${message}`);
|
||||
this.name = 'QingLong3ReleaseSetError';
|
||||
}
|
||||
}
|
||||
|
||||
function fail(message) {
|
||||
throw new QingLong3ReleaseSetError(message);
|
||||
}
|
||||
|
||||
function sha256(value) {
|
||||
return `sha256:${crypto.createHash('sha256').update(value).digest('hex')}`;
|
||||
}
|
||||
|
||||
function exactKeys(value, expected) {
|
||||
return (
|
||||
value !== null &&
|
||||
typeof value === 'object' &&
|
||||
!Array.isArray(value) &&
|
||||
JSON.stringify(Object.keys(value)) === JSON.stringify(expected)
|
||||
);
|
||||
}
|
||||
|
||||
function canonicalJson(value) {
|
||||
return `${JSON.stringify(value)}\n`;
|
||||
}
|
||||
|
||||
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 readCanonicalJson(filePath, label) {
|
||||
const resolved = resolveCanonicalAbsolute(filePath, label);
|
||||
const stat = fs.lstatSync(resolved);
|
||||
if (
|
||||
!stat.isFile() ||
|
||||
stat.isSymbolicLink() ||
|
||||
stat.size < 2 ||
|
||||
stat.size > MAX_JSON_BYTES ||
|
||||
fs.realpathSync(resolved) !== resolved ||
|
||||
fs.realpathSync(path.dirname(resolved)) !== path.dirname(resolved)
|
||||
) {
|
||||
fail(`${label} must be one bounded canonical regular file`);
|
||||
}
|
||||
const contents = fs.readFileSync(resolved, 'utf8');
|
||||
let value;
|
||||
try {
|
||||
value = JSON.parse(contents);
|
||||
} catch {
|
||||
fail(`${label} must contain valid JSON`);
|
||||
}
|
||||
if (canonicalJson(value) !== contents) {
|
||||
fail(`${label} must use exact canonical JSON encoding`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
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 normalizeRepositoryOwner(value) {
|
||||
if (typeof value !== 'string' || !OWNER_PATTERN.test(value)) {
|
||||
fail('repository owner must be one lowercase GitHub owner');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function verifyCandidate(candidate, options) {
|
||||
auditReleaseCandidateContract(candidate, {
|
||||
root: options.root || DEFAULT_ROOT,
|
||||
version: options.version,
|
||||
sourceRevision: options.sourceRevision,
|
||||
sourceRef: options.sourceRef,
|
||||
releaseScope: options.releaseScope,
|
||||
});
|
||||
return candidate;
|
||||
}
|
||||
|
||||
function selectedImage(candidate, imageName) {
|
||||
const matches = candidate.images.filter((entry) => entry.image === imageName);
|
||||
if (matches.length !== 1) {
|
||||
fail('image must be selected exactly once by the release candidate');
|
||||
}
|
||||
return matches[0];
|
||||
}
|
||||
|
||||
function deriveVerifiedImageRecord(
|
||||
candidate,
|
||||
repositoryOwner,
|
||||
imageName,
|
||||
digest,
|
||||
) {
|
||||
if (!DIGEST_PATTERN.test(digest || '')) {
|
||||
fail('image digest must be an exact SHA-256 digest');
|
||||
}
|
||||
const selected = selectedImage(candidate, imageName);
|
||||
const repository = `ghcr.io/${repositoryOwner}/${selected.repository}`;
|
||||
const image = {
|
||||
name: selected.image,
|
||||
repository: selected.repository,
|
||||
digest,
|
||||
reference: `${repository}@${digest}`,
|
||||
versionTag: `${repository}:${candidate.release.version}`,
|
||||
sourceTag: `${repository}:sha-${candidate.release.sourceRevision}`,
|
||||
platforms: [...candidate.compatibility.platforms],
|
||||
};
|
||||
const unsigned = {
|
||||
schemaVersion: 1,
|
||||
schema: IMAGE_RECORD_SCHEMA,
|
||||
release: { ...candidate.release },
|
||||
candidateContractDigest: candidate.contractDigest,
|
||||
repositoryOwner,
|
||||
image,
|
||||
verification: {
|
||||
remoteDigestVerified: true,
|
||||
keylessSignatureVerified: true,
|
||||
githubAttestations: [...REQUIRED_IMAGE_ATTESTATIONS],
|
||||
localProfileRolloutVerified: selected.image === 'local',
|
||||
tagPromotion: 'deferred_to_complete_release_set',
|
||||
},
|
||||
};
|
||||
return Object.freeze({
|
||||
...unsigned,
|
||||
recordDigest: sha256(JSON.stringify(unsigned)),
|
||||
});
|
||||
}
|
||||
|
||||
function createVerifiedImageRecord(options) {
|
||||
const candidate = verifyCandidate(options.candidate, options);
|
||||
const owner = normalizeRepositoryOwner(options.repositoryOwner);
|
||||
return deriveVerifiedImageRecord(
|
||||
candidate,
|
||||
owner,
|
||||
options.image,
|
||||
options.digest,
|
||||
);
|
||||
}
|
||||
|
||||
function validateImageRecord(record, candidate, repositoryOwner) {
|
||||
if (
|
||||
!exactKeys(record, [
|
||||
'schemaVersion',
|
||||
'schema',
|
||||
'release',
|
||||
'candidateContractDigest',
|
||||
'repositoryOwner',
|
||||
'image',
|
||||
'verification',
|
||||
'recordDigest',
|
||||
]) ||
|
||||
record.schemaVersion !== 1 ||
|
||||
record.schema !== IMAGE_RECORD_SCHEMA ||
|
||||
!exactKeys(record.image, [
|
||||
'name',
|
||||
'repository',
|
||||
'digest',
|
||||
'reference',
|
||||
'versionTag',
|
||||
'sourceTag',
|
||||
'platforms',
|
||||
]) ||
|
||||
!exactKeys(record.verification, [
|
||||
'remoteDigestVerified',
|
||||
'keylessSignatureVerified',
|
||||
'githubAttestations',
|
||||
'localProfileRolloutVerified',
|
||||
'tagPromotion',
|
||||
])
|
||||
) {
|
||||
fail('image record shape is invalid');
|
||||
}
|
||||
const expected = deriveVerifiedImageRecord(
|
||||
candidate,
|
||||
repositoryOwner,
|
||||
record.image.name,
|
||||
record.image.digest,
|
||||
);
|
||||
if (JSON.stringify(record) !== JSON.stringify(expected)) {
|
||||
fail(`image record drifted: ${record.image.name || 'unknown'}`);
|
||||
}
|
||||
return record;
|
||||
}
|
||||
|
||||
function deploymentFamily(candidate, family, imageNames) {
|
||||
const source = candidate.deploymentFamilies[family];
|
||||
return Object.freeze({
|
||||
selected: source.selected,
|
||||
profiles: [...source.profiles],
|
||||
images: imageNames,
|
||||
});
|
||||
}
|
||||
|
||||
function createReleaseSet(options) {
|
||||
const candidate = verifyCandidate(options.candidate, options);
|
||||
const repositoryOwner = normalizeRepositoryOwner(options.repositoryOwner);
|
||||
if (!Array.isArray(options.records)) fail('image records must be an array');
|
||||
if (options.records.length !== candidate.images.length) {
|
||||
fail('image record count differs from the release candidate');
|
||||
}
|
||||
const recordsByName = new Map();
|
||||
for (const record of options.records) {
|
||||
validateImageRecord(record, candidate, repositoryOwner);
|
||||
if (recordsByName.has(record.image.name)) {
|
||||
fail('image records must be unique');
|
||||
}
|
||||
recordsByName.set(record.image.name, record);
|
||||
}
|
||||
const orderedRecords = candidate.images.map((entry) => {
|
||||
const record = recordsByName.get(entry.image);
|
||||
if (!record) fail(`missing image record: ${entry.image}`);
|
||||
return record;
|
||||
});
|
||||
const localImages = orderedRecords
|
||||
.filter((record) => record.image.name === 'local')
|
||||
.map((record) => record.image.name);
|
||||
const clusterImages = orderedRecords
|
||||
.filter((record) => record.image.name !== 'local')
|
||||
.map((record) => record.image.name);
|
||||
const images = orderedRecords.map((record) => ({
|
||||
...record.image,
|
||||
imageRecordDigest: record.recordDigest,
|
||||
}));
|
||||
const unsigned = {
|
||||
schemaVersion: 1,
|
||||
schema: RELEASE_SET_SCHEMA,
|
||||
release: { ...candidate.release },
|
||||
candidate: {
|
||||
schema: candidate.schema,
|
||||
contractDigest: candidate.contractDigest,
|
||||
},
|
||||
repositoryOwner,
|
||||
platforms: [...candidate.compatibility.platforms],
|
||||
deploymentFamilies: {
|
||||
local: deploymentFamily(candidate, 'local', localImages),
|
||||
cluster: deploymentFamily(candidate, 'cluster', clusterImages),
|
||||
},
|
||||
images,
|
||||
promotion: {
|
||||
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',
|
||||
},
|
||||
requiredVerification: {
|
||||
imageKeylessSignature: true,
|
||||
imageAttestations: [...REQUIRED_IMAGE_ATTESTATIONS],
|
||||
releaseSetBuildProvenance: true,
|
||||
},
|
||||
};
|
||||
return Object.freeze({
|
||||
...unsigned,
|
||||
releaseSetDigest: sha256(JSON.stringify(unsigned)),
|
||||
});
|
||||
}
|
||||
|
||||
function auditReleaseSet(actual, options) {
|
||||
const expected = createReleaseSet(options);
|
||||
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
|
||||
fail('release set differs from the verified image records');
|
||||
}
|
||||
return Object.freeze({
|
||||
compatible: true,
|
||||
releaseSetDigest: actual.releaseSetDigest,
|
||||
releaseScope: actual.release.scope,
|
||||
imageCount: actual.images.length,
|
||||
images: Object.freeze(actual.images.map((entry) => entry.name)),
|
||||
references: Object.freeze(actual.images.map((entry) => entry.reference)),
|
||||
tagPromotionAuthority: actual.promotion.authority,
|
||||
});
|
||||
}
|
||||
|
||||
function readRecordDirectory(directoryPath, candidate) {
|
||||
const resolved = resolveCanonicalAbsolute(directoryPath, 'records');
|
||||
const stat = fs.lstatSync(resolved);
|
||||
if (
|
||||
!stat.isDirectory() ||
|
||||
stat.isSymbolicLink() ||
|
||||
fs.realpathSync(resolved) !== resolved
|
||||
) {
|
||||
fail('records must be one canonical directory');
|
||||
}
|
||||
const expectedNames = candidate.images
|
||||
.map((entry) => `${entry.image}.json`)
|
||||
.sort();
|
||||
const actualNames = fs.readdirSync(resolved).sort();
|
||||
if (JSON.stringify(actualNames) !== JSON.stringify(expectedNames)) {
|
||||
fail('records directory differs from the exact selected image set');
|
||||
}
|
||||
return candidate.images.map((entry) =>
|
||||
readCanonicalJson(
|
||||
path.join(resolved, `${entry.image}.json`),
|
||||
'image record',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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 common = [
|
||||
'candidate',
|
||||
'mode',
|
||||
'release-scope',
|
||||
'repository-owner',
|
||||
'source-ref',
|
||||
'source-revision',
|
||||
'version',
|
||||
];
|
||||
const expected =
|
||||
values.mode === 'record-image'
|
||||
? [...common, 'digest', 'image', 'output']
|
||||
: values.mode === 'aggregate'
|
||||
? [...common, 'output', 'records']
|
||||
: values.mode === 'audit'
|
||||
? [...common, 'records', 'report']
|
||||
: [];
|
||||
if (
|
||||
expected.length === 0 ||
|
||||
JSON.stringify(Object.keys(values).sort()) !==
|
||||
JSON.stringify(expected.sort())
|
||||
) {
|
||||
fail('arguments are invalid');
|
||||
}
|
||||
return Object.freeze({
|
||||
mode: values.mode,
|
||||
candidate: values.candidate,
|
||||
version: values.version,
|
||||
sourceRevision: values['source-revision'],
|
||||
sourceRef: values['source-ref'],
|
||||
releaseScope: values['release-scope'],
|
||||
repositoryOwner: values['repository-owner'],
|
||||
...(values.image ? { image: values.image } : {}),
|
||||
...(values.digest ? { digest: values.digest } : {}),
|
||||
...(values.records ? { records: values.records } : {}),
|
||||
...(values.output ? { output: values.output } : {}),
|
||||
...(values.report ? { report: values.report } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
function runCli(argv, root = DEFAULT_ROOT, output = process.stdout) {
|
||||
const options = parseArguments(argv);
|
||||
const candidate = readCanonicalJson(options.candidate, 'release candidate');
|
||||
if (options.mode === 'record-image') {
|
||||
const record = createVerifiedImageRecord({ ...options, candidate, root });
|
||||
writeNoReplace(options.output, record);
|
||||
output.write(canonicalJson(record));
|
||||
return record;
|
||||
}
|
||||
const records = readRecordDirectory(options.records, candidate);
|
||||
if (options.mode === 'aggregate') {
|
||||
const releaseSet = createReleaseSet({
|
||||
...options,
|
||||
candidate,
|
||||
records,
|
||||
root,
|
||||
});
|
||||
writeNoReplace(options.output, releaseSet);
|
||||
output.write(canonicalJson(releaseSet));
|
||||
return releaseSet;
|
||||
}
|
||||
const report = readCanonicalJson(options.report, 'release set');
|
||||
const audit = auditReleaseSet(report, {
|
||||
...options,
|
||||
candidate,
|
||||
records,
|
||||
root,
|
||||
});
|
||||
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 set failed'}\n`,
|
||||
);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Object.freeze({
|
||||
IMAGE_RECORD_SCHEMA,
|
||||
RELEASE_SET_SCHEMA,
|
||||
QingLong3ReleaseSetError,
|
||||
auditReleaseSet,
|
||||
createReleaseSet,
|
||||
createVerifiedImageRecord,
|
||||
parseArguments,
|
||||
runCli,
|
||||
});
|
||||
Reference in New Issue
Block a user