mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-22 02:27:44 +08:00
feat(ql3): close release tags after catalog verification
This commit is contained in:
@@ -73,7 +73,7 @@ test('rejects verifier, embedded artifact and release workflow drift', () => {
|
||||
'.github/workflows/ql3-image-release.yml',
|
||||
(source) =>
|
||||
source.replace(
|
||||
'Promote tags only after the complete set is verified',
|
||||
'Promote final tags only after the catalog receipt is attested',
|
||||
'Promote mutable release tags',
|
||||
),
|
||||
'QL3_CLUSTER_ADMIN_RELEASE_WORKFLOW_DRIFT',
|
||||
|
||||
@@ -94,6 +94,10 @@ test('accepts the reviewed native CI and digest release contracts', () => {
|
||||
rebuildAfterScan: false,
|
||||
tagAfterVerification: true,
|
||||
tagAfterCompleteReleaseSet: true,
|
||||
tagAfterVerifiedCatalog: true,
|
||||
boundedRepositoryTagInventory: true,
|
||||
allTagConflictsCheckedBeforeMutation: true,
|
||||
responseLossRecovery: 'reuse_exact_digest_only',
|
||||
},
|
||||
releaseSet: {
|
||||
sourceDerived: true,
|
||||
@@ -103,7 +107,7 @@ test('accepts the reviewed native CI and digest release contracts', () => {
|
||||
privateEvidenceFreshnessRevalidatedAtClosure: true,
|
||||
exactScopeClosure: true,
|
||||
standaloneInspection: true,
|
||||
tagPromotionAuthority: 'complete_verified_release_set',
|
||||
tagPromotionAuthority: 'verified_immutable_catalog',
|
||||
fileProvenanceAttested: true,
|
||||
artifactRetentionDays: 90,
|
||||
crossRepositoryAtomicity: false,
|
||||
@@ -130,6 +134,19 @@ test('accepts the reviewed native CI and digest release contracts', () => {
|
||||
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',
|
||||
@@ -163,6 +180,7 @@ test('accepts the reviewed native CI and digest release contracts', () => {
|
||||
'catalog-bound-local-compose-deployment',
|
||||
'catalog-bound-k3s-deployment',
|
||||
'release-tags',
|
||||
'release-publication-closure',
|
||||
],
|
||||
},
|
||||
});
|
||||
@@ -190,6 +208,17 @@ test('rejects removal of the durable release-catalog contract tests', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects removal of the final publication closure contract tests', () => {
|
||||
const mutated = ciSource.replace(
|
||||
'test/back/ql3ReleasePublicationClosureContract.test.cjs',
|
||||
'test/back/publication-closure-tests-removed.test.cjs',
|
||||
);
|
||||
assert.throws(
|
||||
() => auditClusterImageCiWorkflow(mutated),
|
||||
/durable catalog, deployment-lock and workflow negative tests/,
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects removal of the private release evidence receipt contract tests', () => {
|
||||
const mutated = ciSource.replace(
|
||||
'test/back/ql3PrivateReleaseEvidenceReceiptContract.test.cjs',
|
||||
@@ -1069,6 +1098,61 @@ test('rejects a release-catalog receipt without file provenance', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects final tag publication before catalog receipt attestation', () => {
|
||||
const mutated = releaseSource.replace(
|
||||
'Promote final tags only after the catalog receipt is attested',
|
||||
'Promote tags after release-set audit only',
|
||||
);
|
||||
assert.throws(
|
||||
() => auditReleaseWorkflow(mutated),
|
||||
/release tags and the final closure receipt|release-set job must download/,
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects final tag promotion without bounded repository inventory', () => {
|
||||
const mutated = releaseSource.replace(
|
||||
" 'tag',\n 'ls',\n image.registryRepository,",
|
||||
" 'image',\n 'digest',\n image.registryRepository,",
|
||||
);
|
||||
assert.throws(
|
||||
() => auditReleaseWorkflow(mutated),
|
||||
/release-set job must download only same-run records/,
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects omission of the final publication closure audit', () => {
|
||||
const mutated = releaseSource.replace(
|
||||
' --mode=close \\',
|
||||
' --mode=audit \\',
|
||||
);
|
||||
assert.throws(
|
||||
() => auditReleaseWorkflow(mutated),
|
||||
/release-set job must download only same-run records/,
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects omitting tag observations from the durable closure bundle', () => {
|
||||
const mutated = releaseSource.replace(
|
||||
'observations="${BUNDLE}/qinglong3-release-publication-tag-observation-',
|
||||
'observations="${RUNNER_TEMP}/qinglong3-release-publication-tag-observation-',
|
||||
);
|
||||
assert.throws(
|
||||
() => auditReleaseWorkflow(mutated),
|
||||
/release-set job must download only same-run records/,
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects a final closure receipt without its own attestation', () => {
|
||||
const mutated = releaseSource.replace(
|
||||
' subject-path: ${{ steps.final-publication.outputs.receipt }}',
|
||||
' subject-path: ${{ steps.catalog-receipt.outputs.receipt }}',
|
||||
);
|
||||
assert.throws(
|
||||
() => auditReleaseWorkflow(mutated),
|
||||
/release-set job must download only same-run records/,
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects a short-lived deployment digest lock', () => {
|
||||
const marker = ' retention-days: 90';
|
||||
assert.equal(releaseSource.includes(marker), true);
|
||||
|
||||
@@ -0,0 +1,359 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const crypto = require('node:crypto');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const test = require('node:test');
|
||||
const {
|
||||
CLOSURE_RECEIPT_SCHEMA,
|
||||
PUBLICATION_PLAN_SCHEMA,
|
||||
TAG_OBSERVATION_SCHEMA,
|
||||
auditClosureReceipt,
|
||||
createClosureReceipt,
|
||||
createPublicationPlan,
|
||||
createPublicationTagObservation,
|
||||
parseArguments,
|
||||
runCli,
|
||||
} = require('../../scripts/ql3-release-publication-closure-contract.cjs');
|
||||
const {
|
||||
ARTIFACT_TYPE,
|
||||
OCI_EMPTY_CONFIG_DIGEST,
|
||||
OCI_EMPTY_CONFIG_MEDIA_TYPE,
|
||||
OCI_MANIFEST_MEDIA_TYPE,
|
||||
createCatalogPlan,
|
||||
createCatalogReceipt,
|
||||
} = require('../../scripts/ql3-release-catalog-contract.cjs');
|
||||
const {
|
||||
createReleaseSet,
|
||||
createVerifiedImageRecord,
|
||||
} = require('../../scripts/ql3-release-set-contract.cjs');
|
||||
const {
|
||||
createReleaseCandidateContract,
|
||||
} = require('../../scripts/ql3-release-candidate-contract.cjs');
|
||||
const {
|
||||
readReleaseIdentity,
|
||||
} = require('../../scripts/lib/ql3-release-identity.cjs');
|
||||
const {
|
||||
privateReleaseEvidenceReceipts,
|
||||
} = require('./ql3ReleaseEvidenceFixture.cjs');
|
||||
|
||||
const root = path.resolve(__dirname, '../..');
|
||||
const version = readReleaseIdentity(root).version;
|
||||
const identity = Object.freeze({
|
||||
version,
|
||||
sourceRevision: 'e'.repeat(40),
|
||||
sourceRef: `refs/tags/v${version}`,
|
||||
repositoryOwner: 'qinglong-release',
|
||||
sourceRepository: 'qinglong-release/qinglong',
|
||||
});
|
||||
|
||||
function sha256(value) {
|
||||
return `sha256:${crypto.createHash('sha256').update(value).digest('hex')}`;
|
||||
}
|
||||
|
||||
function releaseSet(scope) {
|
||||
const candidate = createReleaseCandidateContract({
|
||||
root,
|
||||
version,
|
||||
sourceRevision: identity.sourceRevision,
|
||||
sourceRef: identity.sourceRef,
|
||||
releaseScope: scope,
|
||||
});
|
||||
const records = candidate.images.map((entry, index) =>
|
||||
createVerifiedImageRecord({
|
||||
root,
|
||||
candidate,
|
||||
...identity,
|
||||
releaseScope: scope,
|
||||
image: entry.image,
|
||||
digest: `sha256:${String(index + 1).repeat(64)}`,
|
||||
}),
|
||||
);
|
||||
return createReleaseSet({
|
||||
root,
|
||||
candidate,
|
||||
records,
|
||||
evidenceReceipts: privateReleaseEvidenceReceipts(candidate.release),
|
||||
...identity,
|
||||
validationClockMs: Date.parse('2026-08-18T00:05:00.000Z'),
|
||||
releaseScope: scope,
|
||||
});
|
||||
}
|
||||
|
||||
function manifestFor(plan) {
|
||||
return JSON.stringify({
|
||||
schemaVersion: 2,
|
||||
mediaType: OCI_MANIFEST_MEDIA_TYPE,
|
||||
artifactType: ARTIFACT_TYPE,
|
||||
config: {
|
||||
mediaType: OCI_EMPTY_CONFIG_MEDIA_TYPE,
|
||||
digest: OCI_EMPTY_CONFIG_DIGEST,
|
||||
size: 2,
|
||||
},
|
||||
layers: [
|
||||
{
|
||||
mediaType: ARTIFACT_TYPE,
|
||||
digest: plan.releaseSet.contentDigest,
|
||||
size: plan.releaseSet.bytes,
|
||||
annotations: {
|
||||
'org.opencontainers.image.title': plan.releaseSet.fileName,
|
||||
},
|
||||
},
|
||||
],
|
||||
annotations: { ...plan.catalog.annotations },
|
||||
});
|
||||
}
|
||||
|
||||
function fixture(scope = 'all') {
|
||||
const set = releaseSet(scope);
|
||||
const options = { ...identity, releaseScope: scope };
|
||||
const catalogPlan = createCatalogPlan(set, options);
|
||||
const manifest = manifestFor(catalogPlan);
|
||||
const manifestDigest = sha256(manifest);
|
||||
const catalogReceipt = createCatalogReceipt(
|
||||
catalogPlan,
|
||||
manifest,
|
||||
manifestDigest,
|
||||
);
|
||||
const publicationPlan = createPublicationPlan(
|
||||
set,
|
||||
catalogPlan,
|
||||
catalogReceipt,
|
||||
manifest,
|
||||
manifestDigest,
|
||||
options,
|
||||
);
|
||||
const tags = publicationPlan.images.flatMap((image) =>
|
||||
image.tags.map((tag) => ({
|
||||
image: image.name,
|
||||
kind: tag.kind,
|
||||
reference: tag.reference,
|
||||
digest: image.digest,
|
||||
})),
|
||||
);
|
||||
const observation = createPublicationTagObservation(publicationPlan, tags);
|
||||
return {
|
||||
set,
|
||||
options,
|
||||
catalogPlan,
|
||||
manifest,
|
||||
manifestDigest,
|
||||
catalogReceipt,
|
||||
publicationPlan,
|
||||
observation,
|
||||
};
|
||||
}
|
||||
|
||||
function temporaryDirectory(t) {
|
||||
const directory = fs.realpathSync(
|
||||
fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-release-closure-')),
|
||||
);
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
|
||||
return directory;
|
||||
}
|
||||
|
||||
function writeCanonical(filePath, value) {
|
||||
fs.writeFileSync(filePath, `${JSON.stringify(value)}\n`, { mode: 0o600 });
|
||||
}
|
||||
|
||||
test('plans tag publication only from a verified immutable catalog', () => {
|
||||
for (const scope of ['local', 'cluster', 'all']) {
|
||||
const { publicationPlan, set } = fixture(scope);
|
||||
assert.equal(publicationPlan.schema, PUBLICATION_PLAN_SCHEMA);
|
||||
assert.equal(
|
||||
publicationPlan.promotionPolicy.authority,
|
||||
'verified_immutable_catalog',
|
||||
);
|
||||
assert.equal(
|
||||
publicationPlan.requiredPrerequisites.catalogReceipt,
|
||||
'attested_before_tag_promotion',
|
||||
);
|
||||
assert.equal(publicationPlan.promotionPolicy.registryTagCas, false);
|
||||
assert.equal(publicationPlan.images.length, set.images.length);
|
||||
assert.equal(
|
||||
publicationPlan.images.every((image) => image.tags.length === 2),
|
||||
true,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('creates a deterministic final closure receipt for every exact tag', () => {
|
||||
const { publicationPlan, observation } = fixture();
|
||||
const first = createClosureReceipt(publicationPlan, observation);
|
||||
const replay = createClosureReceipt(publicationPlan, observation);
|
||||
assert.equal(first.schema, CLOSURE_RECEIPT_SCHEMA);
|
||||
assert.equal(observation.schema, TAG_OBSERVATION_SCHEMA);
|
||||
assert.deepEqual(first, replay);
|
||||
assert.equal(first.publishedTags.length, publicationPlan.images.length * 2);
|
||||
assert.equal(first.verification.catalogReadyBeforeTagMutation, true);
|
||||
assert.equal(first.verification.allTagsExactDigest, true);
|
||||
assert.equal(first.verification.registryTagCas, false);
|
||||
assert.equal(
|
||||
auditClosureReceipt(first, publicationPlan, observation).compatible,
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects missing, reordered, extra or digest-drifted tag observations', () => {
|
||||
const { publicationPlan, observation } = fixture('cluster');
|
||||
const variants = [
|
||||
observation.tags.slice(1),
|
||||
[...observation.tags].reverse(),
|
||||
[...observation.tags, observation.tags[0]],
|
||||
observation.tags.map((entry, index) =>
|
||||
index === 0 ? { ...entry, digest: `sha256:${'f'.repeat(64)}` } : entry,
|
||||
),
|
||||
];
|
||||
for (const tags of variants) {
|
||||
assert.throws(
|
||||
() => createPublicationTagObservation(publicationPlan, tags),
|
||||
/observations differ from the exact plan/,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects a publication plan detached from release-set or catalog evidence', () => {
|
||||
const current = fixture('local');
|
||||
const other = fixture('cluster');
|
||||
assert.throws(
|
||||
() =>
|
||||
createPublicationPlan(
|
||||
current.set,
|
||||
other.catalogPlan,
|
||||
other.catalogReceipt,
|
||||
other.manifest,
|
||||
other.manifestDigest,
|
||||
current.options,
|
||||
),
|
||||
/catalog plan differs from the standalone release set/,
|
||||
);
|
||||
const weakenedReceipt = structuredClone(current.catalogReceipt);
|
||||
weakenedReceipt.verification.keylessSignature = 'not_verified';
|
||||
assert.throws(
|
||||
() =>
|
||||
createPublicationPlan(
|
||||
current.set,
|
||||
current.catalogPlan,
|
||||
weakenedReceipt,
|
||||
current.manifest,
|
||||
current.manifestDigest,
|
||||
current.options,
|
||||
),
|
||||
/catalog receipt differs from the verified OCI manifest/,
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects tampered plan, observation and closure self-digests', () => {
|
||||
const { publicationPlan, observation } = fixture('local');
|
||||
const badPlan = structuredClone(publicationPlan);
|
||||
badPlan.promotionPolicy.registryTagCas = true;
|
||||
assert.throws(
|
||||
() => createPublicationTagObservation(badPlan, observation.tags),
|
||||
/publication plan (?:shape|digest) is invalid/,
|
||||
);
|
||||
const recomputedWeakenedPlan = structuredClone(publicationPlan);
|
||||
recomputedWeakenedPlan.promotionPolicy.registryTagCas = true;
|
||||
delete recomputedWeakenedPlan.planDigest;
|
||||
recomputedWeakenedPlan.planDigest = sha256(
|
||||
JSON.stringify(recomputedWeakenedPlan),
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
createPublicationTagObservation(recomputedWeakenedPlan, observation.tags),
|
||||
/publication plan shape is invalid/,
|
||||
);
|
||||
const badObservation = structuredClone(observation);
|
||||
badObservation.observationDigest = `sha256:${'a'.repeat(64)}`;
|
||||
assert.throws(
|
||||
() => createClosureReceipt(publicationPlan, badObservation),
|
||||
/observation digest is invalid/,
|
||||
);
|
||||
const receipt = createClosureReceipt(publicationPlan, observation);
|
||||
const badReceipt = structuredClone(receipt);
|
||||
badReceipt.verification.registryTagCas = true;
|
||||
assert.throws(
|
||||
() => auditClosureReceipt(badReceipt, publicationPlan, observation),
|
||||
/closure receipt differs/,
|
||||
);
|
||||
});
|
||||
|
||||
test('runs plan, close and audit as canonical no-replace CLI stages', (t) => {
|
||||
const value = fixture('cluster');
|
||||
const directory = temporaryDirectory(t);
|
||||
const files = {
|
||||
releaseSet: path.join(directory, 'release-set.json'),
|
||||
catalogPlan: path.join(directory, 'catalog-plan.json'),
|
||||
catalogReceipt: path.join(directory, 'catalog-receipt.json'),
|
||||
manifest: path.join(directory, 'manifest.json'),
|
||||
publicationPlan: path.join(directory, 'publication-plan.json'),
|
||||
observation: path.join(directory, 'observation.json'),
|
||||
receipt: path.join(directory, 'receipt.json'),
|
||||
};
|
||||
writeCanonical(files.releaseSet, value.set);
|
||||
writeCanonical(files.catalogPlan, value.catalogPlan);
|
||||
writeCanonical(files.catalogReceipt, value.catalogReceipt);
|
||||
fs.writeFileSync(files.manifest, value.manifest, { mode: 0o600 });
|
||||
writeCanonical(files.observation, value.observation);
|
||||
const planArgs = [
|
||||
'--mode=plan',
|
||||
`--version=${version}`,
|
||||
`--source-revision=${identity.sourceRevision}`,
|
||||
`--source-ref=${identity.sourceRef}`,
|
||||
'--release-scope=cluster',
|
||||
`--repository-owner=${identity.repositoryOwner}`,
|
||||
`--source-repository=${identity.sourceRepository}`,
|
||||
`--release-set=${files.releaseSet}`,
|
||||
`--catalog-plan=${files.catalogPlan}`,
|
||||
`--catalog-receipt=${files.catalogReceipt}`,
|
||||
`--catalog-manifest=${files.manifest}`,
|
||||
`--catalog-manifest-digest=${value.manifestDigest}`,
|
||||
`--output=${files.publicationPlan}`,
|
||||
];
|
||||
const output = { write() {} };
|
||||
assert.equal(runCli(planArgs, output).schema, PUBLICATION_PLAN_SCHEMA);
|
||||
assert.throws(() => runCli(planArgs, output), /EEXIST/);
|
||||
assert.equal(
|
||||
runCli(
|
||||
[
|
||||
'--mode=close',
|
||||
`--plan=${files.publicationPlan}`,
|
||||
`--observations=${files.observation}`,
|
||||
`--output=${files.receipt}`,
|
||||
],
|
||||
output,
|
||||
).schema,
|
||||
CLOSURE_RECEIPT_SCHEMA,
|
||||
);
|
||||
assert.equal(
|
||||
runCli(
|
||||
[
|
||||
'--mode=audit',
|
||||
`--plan=${files.publicationPlan}`,
|
||||
`--observations=${files.observation}`,
|
||||
`--receipt=${files.receipt}`,
|
||||
],
|
||||
output,
|
||||
).compatible,
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects ambiguous CLI modes and extra arguments', () => {
|
||||
assert.throws(
|
||||
() => parseArguments(['--mode=close']),
|
||||
/arguments are invalid/,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
parseArguments([
|
||||
'--mode=audit',
|
||||
'--plan=/tmp/plan',
|
||||
'--observations=/tmp/observations',
|
||||
'--receipt=/tmp/receipt',
|
||||
'--extra=true',
|
||||
]),
|
||||
/arguments are invalid/,
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user