feat(ql3): close release tags after catalog verification

This commit is contained in:
whyour
2026-08-18 03:53:25 +08:00
parent dbd57b27d0
commit a99f1508f7
12 changed files with 1336 additions and 77 deletions
@@ -252,10 +252,14 @@ function auditClusterCopilotConsoleDistribution(options = {}) {
'--predicate-type "https://qinglong.dev/attestations/release-candidate-contract/v1"',
'--deny-self-hosted-runners',
'--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',
'Verify the durable catalog and create its immutable receipt',
'Attest the immutable release-catalog receipt',
'Promote final tags only after the catalog receipt is attested',
'Close and audit the final public tag set',
'Attest the immutable release publication closure receipt',
],
'QL3_CLUSTER_ADMIN_RELEASE_WORKFLOW_DRIFT',
);
+56 -25
View File
@@ -274,7 +274,7 @@ 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\/ql3PrivateReleaseEvidenceReceiptContract\.test\.cjs[\s\S]*test\/back\/ql3ReleaseSetContract\.test\.cjs[\s\S]*test\/back\/ql3ReleaseCatalogContract\.test\.cjs[\s\S]*test\/back\/ql3ReleaseCatalogConsumptionCeremony\.test\.cjs[\s\S]*test\/back\/ql3DeploymentLockContract\.test\.cjs/,
/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\/ql3PrivateReleaseEvidenceReceiptContract\.test\.cjs[\s\S]*test\/back\/ql3ReleaseSetContract\.test\.cjs[\s\S]*test\/back\/ql3ReleaseCatalogContract\.test\.cjs[\s\S]*test\/back\/ql3ReleasePublicationClosureContract\.test\.cjs[\s\S]*test\/back\/ql3ReleaseCatalogConsumptionCeremony\.test\.cjs[\s\S]*test\/back\/ql3DeploymentLockContract\.test\.cjs/,
'cluster image CI must run SBOM, candidate, private evidence receipt, release-set, durable catalog, deployment-lock and workflow negative tests; catalog consumption ceremony is mandatory',
);
requirePattern(
@@ -712,7 +712,7 @@ function auditReleaseWorkflow(source) {
const releaseSetSteps = releaseSetJob?.steps;
if (
!Array.isArray(releaseSetSteps) ||
releaseSetSteps.length !== 16 ||
releaseSetSteps.length !== 19 ||
releaseSetSteps[0]?.uses !==
'actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803' ||
releaseSetSteps[0]?.with?.['persist-credentials'] !== false ||
@@ -750,55 +750,68 @@ function auditReleaseWorkflow(source) {
'sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6' ||
releaseSetSteps[7]?.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[8]?.run ?? '',
) ||
releaseSetSteps[9]?.uses !==
releaseSetSteps[8]?.uses !==
'actions/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6' ||
JSON.stringify(releaseSetSteps[9]?.with) !==
JSON.stringify(releaseSetSteps[8]?.with) !==
JSON.stringify({
'subject-path': '${{ steps.release-set.outputs.report }}',
}) ||
releaseSetSteps[10]?.id !== 'catalog' ||
releaseSetSteps[9]?.id !== 'catalog' ||
!/local_tag="ocidir:\/\/\$\{local_layout\}:candidate"[\s\S]*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]*"\$\{local_tag\}"[\s\S]*expected_digest=.*image digest "\$\{local_tag\}"[\s\S]*local_immutable="ocidir:\/\/\$\{local_layout\}@\$\{expected_digest\}"/.test(
releaseSetSteps[10]?.run ?? '',
releaseSetSteps[9]?.run ?? '',
) ||
!/tag ls "\$\{catalog_repository\}" --format '\{\{ range \.Tags \}\}\{\{ println \. \}\}\{\{ end \}\}'[\s\S]*staging_tag="\$\{catalog_repository\}:staging-\$\{plan_digest#sha256:\}"[\s\S]*image copy "\$\{local_immutable\}" "\$\{staging_tag\}"[\s\S]*tag ls "\$\{catalog_repository\}" --format '\{\{ range \.Tags \}\}\{\{ println \. \}\}\{\{ end \}\}'[\s\S]*--mode=tag-inventory[\s\S]*--plan="\$\{PLAN\}"[\s\S]*--tag-inventory="\$\{tags\}"[\s\S]*--output="\$\{inventory_decision\}"[\s\S]*tag_state=.*p\.observation/.test(
releaseSetSteps[10]?.run ?? '',
releaseSetSteps[9]?.run ?? '',
) ||
!/--mode=publication-decision[\s\S]*--manifest="\$\{local_manifest\}"[\s\S]*--manifest-digest="\$\{expected_digest\}"[\s\S]*--observed-discovery-digest="\$\{observed_digest\}"[\s\S]*action=.*p\.action[\s\S]*publish_if_absent[\s\S]*image copy "\$\{local_immutable\}" "\$\{discovery_tag\}"[\s\S]*reuse_exact_digest[\s\S]*digest=.*image digest "\$\{discovery_tag\}"[\s\S]*"\$\{digest\}" != "\$\{expected_digest\}"/.test(
releaseSetSteps[10]?.run ?? '',
releaseSetSteps[9]?.run ?? '',
) ||
!/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]*cmp --silent "\$\{local_manifest\}" "\$\{manifest\}"[\s\S]*GITHUB_OUTPUT/.test(
releaseSetSteps[10]?.run ?? '',
releaseSetSteps[9]?.run ?? '',
) ||
/artifact put[\s\S]{0,1200}"\$\{discovery_tag\}"/.test(
releaseSetSteps[10]?.run ?? '',
releaseSetSteps[9]?.run ?? '',
) ||
!/cosign sign --yes "\$\{CATALOG\}@\$\{DIGEST\}"/.test(
releaseSetSteps[11]?.run ?? '',
releaseSetSteps[10]?.run ?? '',
) ||
releaseSetSteps[12]?.uses !==
releaseSetSteps[11]?.uses !==
'actions/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6' ||
JSON.stringify(releaseSetSteps[12]?.with) !==
JSON.stringify(releaseSetSteps[11]?.with) !==
JSON.stringify({
'subject-name': '${{ steps.catalog.outputs.repository }}',
'subject-digest': '${{ steps.catalog.outputs.digest }}',
'push-to-registry': true,
}) ||
releaseSetSteps[13]?.id !== 'catalog-receipt' ||
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[13]?.run ?? '',
releaseSetSteps[12]?.run ?? '',
) ||
releaseSetSteps[14]?.uses !==
releaseSetSteps[13]?.uses !==
'actions/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6' ||
JSON.stringify(releaseSetSteps[14]?.with) !==
JSON.stringify(releaseSetSteps[13]?.with) !==
JSON.stringify({
'subject-path': '${{ steps.catalog-receipt.outputs.receipt }}',
}) ||
releaseSetSteps[15]?.uses !==
releaseSetSteps[14]?.id !== 'final-publication' ||
!/plan="\$\{BUNDLE\}\/qinglong3-release-publication-plan-[^\n]+[\s\S]*observations="\$\{BUNDLE\}\/qinglong3-release-publication-tag-observation-[^\n]+[\s\S]*receipt="\$\{BUNDLE\}\/qinglong3-release-publication-closure-receipt-[^\n]+[\s\S]*ql3-release-publication-closure-contract\.cjs[\s\S]*--mode=plan[\s\S]*--release-set="\$\{RELEASE_SET\}"[\s\S]*--catalog-plan="\$\{CATALOG_PLAN\}"[\s\S]*--catalog-manifest="\$\{CATALOG_MANIFEST\}"[\s\S]*--catalog-manifest-digest="\$\{CATALOG_MANIFEST_DIGEST\}"[\s\S]*--catalog-receipt="\$\{CATALOG_RECEIPT\}"[\s\S]*GITHUB_OUTPUT/.test(
releaseSetSteps[14]?.run ?? '',
) ||
!/createPublicationTagObservation[\s\S]*maxInventoryBytes = 1024 \* 1024[\s\S]*tagPattern[\s\S]*for \(const image of plan\.images\)[\s\S]*image\.immutableReference[\s\S]*'tag',[\s\S]*'ls',[\s\S]*image\.registryRepository[\s\S]*inventoryContents\.endsWith\('\\n'\)[\s\S]*new Set\(inventory\)\.size !== inventory\.length[\s\S]*release tag already points at another digest[\s\S]*for \(const state of states\)[\s\S]*'image',[\s\S]*'copy',[\s\S]*state\.image\.immutableReference[\s\S]*promoted tag does not resolve to the release-set digest[\s\S]*createPublicationTagObservation\(plan, observedTags\)[\s\S]*fs\.openSync\(process\.env\.TAG_OBSERVATIONS, 'wx', 0o600\)/.test(
releaseSetSteps[15]?.run ?? '',
) ||
!/ql3-release-publication-closure-contract\.cjs[\s\S]*--mode=close[\s\S]*--plan="\$\{PUBLICATION_PLAN\}"[\s\S]*--observations="\$\{TAG_OBSERVATIONS\}"[\s\S]*--output="\$\{CLOSURE_RECEIPT\}"[\s\S]*ql3-release-publication-closure-contract\.cjs[\s\S]*--mode=audit[\s\S]*--receipt="\$\{CLOSURE_RECEIPT\}"/.test(
releaseSetSteps[16]?.run ?? '',
) ||
releaseSetSteps[17]?.uses !==
'actions/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6' ||
JSON.stringify(releaseSetSteps[17]?.with) !==
JSON.stringify({
'subject-path': '${{ steps.final-publication.outputs.receipt }}',
}) ||
releaseSetSteps[18]?.uses !==
'actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a' ||
JSON.stringify(releaseSetSteps[15]?.with) !==
JSON.stringify(releaseSetSteps[18]?.with) !==
JSON.stringify({
name: 'ql3-release-set-${{ inputs.version }}-${{ inputs.release_scope }}',
path: '${{ steps.release-set.outputs.bundle }}',
@@ -1253,8 +1266,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 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',
/release-set:\s+name: Close and publish the complete deployment release set[\s\S]*needs:[\s\S]*- publish[\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: Materialize the catalog-authorized final tag publication plan[\s\S]*name: Promote final tags only after the catalog receipt is attested[\s\S]*name: Close and audit the final public tag set[\s\S]*name: Attest the immutable release publication closure receipt[\s\S]*name: Publish the deployment digest lock/,
'release tags and the final closure receipt must be published only after the durable catalog is verified and its receipt is attested',
);
return {
trigger: 'explicit protected v3 tag dispatch',
@@ -1310,6 +1323,10 @@ function auditReleaseWorkflow(source) {
rebuildAfterScan: false,
tagAfterVerification: true,
tagAfterCompleteReleaseSet: true,
tagAfterVerifiedCatalog: true,
boundedRepositoryTagInventory: true,
allTagConflictsCheckedBeforeMutation: true,
responseLossRecovery: 'reuse_exact_digest_only',
},
releaseSet: {
sourceDerived: true,
@@ -1319,7 +1336,7 @@ function auditReleaseWorkflow(source) {
privateEvidenceFreshnessRevalidatedAtClosure: true,
exactScopeClosure: true,
standaloneInspection: true,
tagPromotionAuthority: 'complete_verified_release_set',
tagPromotionAuthority: 'verified_immutable_catalog',
fileProvenanceAttested: true,
artifactRetentionDays: 90,
crossRepositoryAtomicity: false,
@@ -1346,6 +1363,19 @@ function auditReleaseWorkflow(source) {
immutableDigestAuthority: 'verified',
receiptAttested: true,
},
finalPublicationClosure: {
planSchema: 'qinglong/release-publication-plan@v1',
tagObservationSchema: 'qinglong/release-publication-tag-observation@v1',
receiptSchema: 'qinglong/release-publication-closure-receipt@v1',
catalogReadyBeforeTagMutation: true,
allTagsExactDigest: true,
tagsPerImage: 2,
conflictPolicy: 'fail_closed_before_any_tag_mutation',
responseLossRecovery: 'reuse_exact_digest_only',
crossRepositoryAtomicity: false,
registryTagCas: false,
receiptAttested: true,
},
catalogDeploymentGate: {
scopes: ['cluster', 'all'],
catalogAuthority: 'immutable_digest_after_public_consumption',
@@ -1379,6 +1409,7 @@ function auditReleaseWorkflow(source) {
'catalog-bound-local-compose-deployment',
'catalog-bound-k3s-deployment',
'release-tags',
'release-publication-closure',
],
};
}
@@ -0,0 +1,552 @@
#!/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 {
auditCatalogPlan,
auditCatalogReceipt,
} = require('./ql3-release-catalog-contract.cjs');
const PUBLICATION_PLAN_SCHEMA = 'qinglong/release-publication-plan@v1';
const TAG_OBSERVATION_SCHEMA =
'qinglong/release-publication-tag-observation@v1';
const CLOSURE_RECEIPT_SCHEMA =
'qinglong/release-publication-closure-receipt@v1';
const MAX_JSON_BYTES = 1024 * 1024;
const DIGEST_PATTERN = /^sha256:[a-f0-9]{64}$/u;
const SOURCE_REVISION_PATTERN = /^[a-f0-9]{40}$/u;
const REGISTRY_REPOSITORY_PATTERN =
/^ghcr\.io\/[a-z0-9](?:[a-z0-9-]{0,37}[a-z0-9])?\/[a-z0-9._-]{1,100}$/u;
const REQUIRED_PREREQUISITES = Object.freeze({
releaseSetProvenance: 'attested_before_catalog_publication',
catalogSignature: 'verified_exact_workflow_identity',
catalogProvenance: 'verified_source_tag_and_revision',
catalogReceipt: 'attested_before_tag_promotion',
});
const PROMOTION_POLICY = Object.freeze({
authority: 'verified_immutable_catalog',
inventory: 'bounded_exact_repository_tags',
conflict: 'fail_closed_before_any_tag_mutation',
recovery: 'reuse_exact_digest_only',
finalVerification: 'all_tags_exact_digest',
crossRepositoryAtomicity: false,
registryTagCas: false,
});
const CLOSURE_VERIFICATION = Object.freeze({
authority: 'verified_immutable_catalog',
catalogReadyBeforeTagMutation: true,
allTagsExactDigest: true,
inventory: 'bounded_exact_repository_tags',
conflict: 'fail_closed_before_any_tag_mutation',
recovery: 'reuse_exact_digest_only',
crossRepositoryAtomicity: false,
registryTagCas: false,
});
class QingLong3ReleasePublicationClosureError extends Error {
constructor(message) {
super(`QingLong 3 release publication closure failed: ${message}`);
this.name = 'QingLong3ReleasePublicationClosureError';
}
}
function fail(message) {
throw new QingLong3ReleasePublicationClosureError(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_JSON_BYTES ||
fs.realpathSync(resolved) !== resolved ||
fs.realpathSync(path.dirname(resolved)) !== path.dirname(resolved)
) {
fail(`${label} must be one bounded canonical regular file`);
}
return { contents: fs.readFileSync(resolved, 'utf8'), resolved };
}
function parseJson(contents, label, requireCanonical = true) {
let value;
try {
value = JSON.parse(contents);
} catch {
fail(`${label} must contain valid JSON`);
}
if (requireCanonical && canonicalJson(value) !== contents) {
fail(`${label} must use canonical JSON encoding`);
}
return value;
}
function readCanonicalJson(filePath, label) {
return parseJson(readBoundedFile(filePath, label).contents, label);
}
function writeNoReplace(filePath, value) {
const resolved = resolveCanonicalAbsolute(filePath, 'output');
const parent = path.dirname(resolved);
const parentStat = fs.lstatSync(parent);
if (
!parentStat.isDirectory() ||
parentStat.isSymbolicLink() ||
fs.realpathSync(parent) !== parent
) {
fail('output parent must be one canonical directory');
}
const descriptor = fs.openSync(resolved, 'wx', 0o600);
try {
fs.writeFileSync(descriptor, canonicalJson(value));
fs.fsyncSync(descriptor);
} finally {
fs.closeSync(descriptor);
}
}
function validateIdentityOptions(options) {
for (const key of [
'version',
'sourceRevision',
'sourceRef',
'releaseScope',
'repositoryOwner',
'sourceRepository',
]) {
if (typeof options[key] !== 'string' || options[key].length === 0) {
fail('release identity options are incomplete');
}
}
}
function createPublicationPlan(
releaseSet,
catalogPlan,
catalogReceipt,
catalogManifestContents,
catalogManifestDigest,
options,
) {
validateIdentityOptions(options);
inspectReleaseSet(releaseSet, options);
auditCatalogPlan(catalogPlan, releaseSet, options);
auditCatalogReceipt(
catalogReceipt,
catalogPlan,
catalogManifestContents,
catalogManifestDigest,
);
const images = releaseSet.images.map((image) => {
const registryRepository = image.reference.slice(
0,
image.reference.lastIndexOf('@'),
);
return {
name: image.name,
registryRepository,
immutableReference: image.reference,
digest: image.digest,
tags: [
{ kind: 'version', reference: image.versionTag },
{ kind: 'source', reference: image.sourceTag },
],
};
});
const unsigned = {
schemaVersion: 1,
schema: PUBLICATION_PLAN_SCHEMA,
release: { ...releaseSet.release },
releaseSet: {
schema: RELEASE_SET_SCHEMA,
releaseSetDigest: releaseSet.releaseSetDigest,
contentDigest: sha256(canonicalJson(releaseSet)),
},
catalog: {
planDigest: catalogPlan.planDigest,
receiptDigest: catalogReceipt.receiptDigest,
manifestDigest: catalogManifestDigest,
immutableReference: catalogReceipt.catalog.immutableReference,
},
requiredPrerequisites: { ...REQUIRED_PREREQUISITES },
promotionPolicy: { ...PROMOTION_POLICY },
images,
};
return Object.freeze({
...unsigned,
planDigest: sha256(JSON.stringify(unsigned)),
});
}
function validatePublicationPlan(plan) {
if (
!exactKeys(plan, [
'schemaVersion',
'schema',
'release',
'releaseSet',
'catalog',
'requiredPrerequisites',
'promotionPolicy',
'images',
'planDigest',
]) ||
plan.schemaVersion !== 1 ||
plan.schema !== PUBLICATION_PLAN_SCHEMA ||
!DIGEST_PATTERN.test(plan.planDigest || '') ||
!exactKeys(plan.release, [
'version',
'sourceRevision',
'sourceRef',
'scope',
]) ||
typeof plan.release.version !== 'string' ||
plan.release.version.length < 1 ||
plan.release.version.length > 64 ||
!SOURCE_REVISION_PATTERN.test(plan.release.sourceRevision || '') ||
plan.release.sourceRef !== `refs/tags/v${plan.release.version}` ||
!['local', 'cluster', 'all'].includes(plan.release.scope) ||
!exactKeys(plan.releaseSet, [
'schema',
'releaseSetDigest',
'contentDigest',
]) ||
plan.releaseSet.schema !== RELEASE_SET_SCHEMA ||
!DIGEST_PATTERN.test(plan.releaseSet.releaseSetDigest || '') ||
!DIGEST_PATTERN.test(plan.releaseSet.contentDigest || '') ||
!exactKeys(plan.catalog, [
'planDigest',
'receiptDigest',
'manifestDigest',
'immutableReference',
]) ||
!DIGEST_PATTERN.test(plan.catalog.planDigest || '') ||
!DIGEST_PATTERN.test(plan.catalog.receiptDigest || '') ||
!DIGEST_PATTERN.test(plan.catalog.manifestDigest || '') ||
typeof plan.catalog.immutableReference !== 'string' ||
!plan.catalog.immutableReference.endsWith(
`@${plan.catalog.manifestDigest}`,
) ||
JSON.stringify(plan.requiredPrerequisites) !==
JSON.stringify(REQUIRED_PREREQUISITES) ||
JSON.stringify(plan.promotionPolicy) !== JSON.stringify(PROMOTION_POLICY) ||
!Array.isArray(plan.images) ||
plan.images.length !==
(plan.release.scope === 'local'
? 1
: plan.release.scope === 'cluster'
? 4
: 5)
) {
fail('publication plan shape is invalid');
}
const { planDigest, ...unsigned } = plan;
if (planDigest !== sha256(JSON.stringify(unsigned))) {
fail('publication plan digest is invalid');
}
const references = [];
const names = [];
const repositories = [];
for (const image of plan.images) {
if (
!exactKeys(image, [
'name',
'registryRepository',
'immutableReference',
'digest',
'tags',
]) ||
!/^[a-z][a-z0-9-]{0,31}$/u.test(image.name || '') ||
!REGISTRY_REPOSITORY_PATTERN.test(image.registryRepository || '') ||
image.immutableReference !==
`${image.registryRepository}@${image.digest}` ||
!DIGEST_PATTERN.test(image.digest || '') ||
!Array.isArray(image.tags) ||
image.tags.length !== 2
) {
fail('publication plan image is invalid');
}
names.push(image.name);
repositories.push(image.registryRepository);
for (let index = 0; index < image.tags.length; index += 1) {
const tag = image.tags[index];
const kind = index === 0 ? 'version' : 'source';
const expectedReference =
kind === 'version'
? `${image.registryRepository}:${plan.release.version}`
: `${image.registryRepository}:sha-${plan.release.sourceRevision}`;
if (
!exactKeys(tag, ['kind', 'reference']) ||
tag.kind !== kind ||
tag.reference !== expectedReference
) {
fail('publication plan tag is invalid');
}
references.push(tag.reference);
}
}
if (
new Set(references).size !== references.length ||
new Set(names).size !== names.length ||
new Set(repositories).size !== repositories.length
) {
fail('publication plan contains duplicate images or tags');
}
return plan;
}
function expectedObservedTags(plan) {
return plan.images.flatMap((image) =>
image.tags.map((tag) => ({
image: image.name,
kind: tag.kind,
reference: tag.reference,
digest: image.digest,
})),
);
}
function createPublicationTagObservation(plan, observedTags) {
validatePublicationPlan(plan);
if (
!Array.isArray(observedTags) ||
JSON.stringify(observedTags) !== JSON.stringify(expectedObservedTags(plan))
) {
fail('publication tag observations differ from the exact plan');
}
const unsigned = {
schemaVersion: 1,
schema: TAG_OBSERVATION_SCHEMA,
planDigest: plan.planDigest,
tags: observedTags.map((entry) => ({ ...entry })),
};
return Object.freeze({
...unsigned,
observationDigest: sha256(JSON.stringify(unsigned)),
});
}
function validateTagObservation(plan, observation) {
if (
!exactKeys(observation, [
'schemaVersion',
'schema',
'planDigest',
'tags',
'observationDigest',
]) ||
observation.schemaVersion !== 1 ||
observation.schema !== TAG_OBSERVATION_SCHEMA ||
observation.planDigest !== plan.planDigest ||
!DIGEST_PATTERN.test(observation.observationDigest || '')
) {
fail('publication tag observation shape is invalid');
}
const expected = createPublicationTagObservation(plan, observation.tags);
if (JSON.stringify(observation) !== JSON.stringify(expected)) {
fail('publication tag observation digest is invalid');
}
return observation;
}
function createClosureReceipt(plan, observation) {
validatePublicationPlan(plan);
validateTagObservation(plan, observation);
const unsigned = {
schemaVersion: 1,
schema: CLOSURE_RECEIPT_SCHEMA,
release: { ...plan.release },
planDigest: plan.planDigest,
releaseSet: { ...plan.releaseSet },
catalog: { ...plan.catalog },
tagObservationDigest: observation.observationDigest,
publishedTags: observation.tags.map((entry) => ({ ...entry })),
verification: { ...CLOSURE_VERIFICATION },
};
return Object.freeze({
...unsigned,
receiptDigest: sha256(JSON.stringify(unsigned)),
});
}
function auditClosureReceipt(actual, plan, observation) {
const expected = createClosureReceipt(plan, observation);
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
fail('publication closure receipt differs from the verified final tags');
}
return Object.freeze({
compatible: true,
releaseSetDigest: actual.releaseSet.releaseSetDigest,
releaseScope: actual.release.scope,
catalogManifestDigest: actual.catalog.manifestDigest,
publishedTagCount: actual.publishedTags.length,
allTagsExactDigest: actual.verification.allTagsExactDigest,
registryTagCas: actual.verification.registryTagCas,
});
}
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 = [
'release-scope',
'repository-owner',
'source-ref',
'source-repository',
'source-revision',
'version',
];
const expected =
values.mode === 'plan'
? [
'catalog-manifest',
'catalog-manifest-digest',
'catalog-plan',
'catalog-receipt',
'mode',
'output',
'release-set',
...identity,
]
: values.mode === 'close'
? ['mode', 'observations', 'output', 'plan']
: values.mode === 'audit'
? ['mode', 'observations', '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,
output: values.output,
plan: values.plan,
observations: values.observations,
receipt: values.receipt,
releaseSet: values['release-set'],
catalogPlan: values['catalog-plan'],
catalogReceipt: values['catalog-receipt'],
catalogManifest: values['catalog-manifest'],
catalogManifestDigest: values['catalog-manifest-digest'],
version: values.version,
sourceRevision: values['source-revision'],
sourceRef: values['source-ref'],
releaseScope: values['release-scope'],
repositoryOwner: values['repository-owner'],
sourceRepository: values['source-repository'],
});
}
function runCli(argv, output = process.stdout) {
const options = parseArguments(argv);
if (options.mode === 'plan') {
const releaseSet = readCanonicalJson(options.releaseSet, 'release set');
const catalogPlan = readCanonicalJson(options.catalogPlan, 'catalog plan');
const catalogReceipt = readCanonicalJson(
options.catalogReceipt,
'catalog receipt',
);
const manifest = readBoundedFile(
options.catalogManifest,
'catalog manifest',
).contents;
const plan = createPublicationPlan(
releaseSet,
catalogPlan,
catalogReceipt,
manifest,
options.catalogManifestDigest,
options,
);
writeNoReplace(options.output, plan);
output.write(canonicalJson(plan));
return plan;
}
const plan = readCanonicalJson(options.plan, 'publication plan');
const observation = readCanonicalJson(
options.observations,
'tag observations',
);
if (options.mode === 'close') {
const receipt = createClosureReceipt(plan, observation);
writeNoReplace(options.output, receipt);
output.write(canonicalJson(receipt));
return receipt;
}
const receipt = readCanonicalJson(options.receipt, 'closure receipt');
const audit = auditClosureReceipt(receipt, plan, observation);
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 publication closure failed'
}\n`,
);
process.exitCode = 1;
}
}
module.exports = Object.freeze({
CLOSURE_RECEIPT_SCHEMA,
PUBLICATION_PLAN_SCHEMA,
TAG_OBSERVATION_SCHEMA,
QingLong3ReleasePublicationClosureError,
auditClosureReceipt,
createClosureReceipt,
createPublicationPlan,
createPublicationTagObservation,
parseArguments,
runCli,
});