feat(ql3): gate cluster releases through published catalogs

This commit is contained in:
whyour
2026-08-18 00:24:13 +08:00
parent 8496b56bed
commit 9cd42ff454
9 changed files with 737 additions and 15 deletions
+53 -2
View File
@@ -115,6 +115,15 @@ test('accepts the reviewed native CI and digest release contracts', () => {
immutableDigestAuthority: 'verified',
receiptAttested: true,
},
catalogDeploymentGate: {
scopes: ['cluster', 'all'],
catalogAuthority: 'immutable_digest_after_public_consumption',
deploymentLockReconstructed: true,
k3sNodes: 3,
installReceiptAudited: true,
fencedRetirementReceiptAudited: true,
publicationAuthority: false,
},
localRolloutPreflight: true,
localRolloutApply: true,
postPublishVerification: [
@@ -126,6 +135,8 @@ test('accepts the reviewed native CI and digest release contracts', () => {
'release-candidate',
'release-set',
'durable-catalog',
'catalog-consumption',
'catalog-bound-k3s-deployment',
'release-tags',
],
},
@@ -565,7 +576,7 @@ test('rejects a movable action tag in the privileged release job', () => {
)}actions/checkout@v6${releaseSource.slice(offset + pinned.length)}`;
assert.throws(
() => auditReleaseWorkflow(mutated),
/privileged publisher|immutable checkout action|release-set job/,
/privileged publisher|immutable checkout action|release-set job|cluster release must read/,
);
});
@@ -718,13 +729,53 @@ test('rejects an image tag created before digest verification completes', () =>
});
test('rejects any publisher step after the deployment lock is published', () => {
const mutated = `${releaseSource}\n - name: Post-promotion mutation\n run: echo unsafe\n`;
const marker = '\n release-catalog-deployment-live:';
assert.equal(releaseSource.includes(marker), true);
const mutated = releaseSource.replace(
marker,
'\n - name: Post-promotion mutation\n run: echo unsafe\n' +
marker,
);
assert.throws(
() => auditReleaseWorkflow(mutated),
/release-set job must download only same-run records/,
);
});
test('rejects removal of the downstream catalog deployment release gate', function rejectsMissingCatalogDeploymentGate() {
const marker = '\n release-catalog-deployment-live:';
const offset = releaseSource.indexOf(marker);
assert.notEqual(offset, -1);
assert.throws(
() => auditReleaseWorkflow(releaseSource.slice(0, offset)),
/keep evidence read-only|cluster release must read the newly published immutable catalog/,
);
});
test('rejects running the cluster deployment gate for Local-only releases', function rejectsLocalCatalogDeploymentGate() {
const mutated = releaseSource.replace(
"inputs.release_scope != 'local'",
"inputs.release_scope == 'local'",
);
assert.throws(
() => auditReleaseWorkflow(mutated),
/cluster release must read the newly published immutable catalog/,
);
});
test('rejects publication authority in the post-publish catalog consumer', function rejectsCatalogConsumerPublicationAuthority() {
const marker = '\n release-catalog-deployment-live:';
const offset = releaseSource.indexOf(marker);
assert.notEqual(offset, -1);
const mutated = `${releaseSource.slice(0, offset)}${releaseSource
.slice(offset)
.replace('packages: read', 'packages: write')}`;
assert.throws(
() => auditReleaseWorkflow(mutated),
/keep evidence read-only|without publication authority/,
);
});
test('rejects release-set closure before every image publisher succeeds', () => {
const mutated = releaseSource.replace(
" needs.publish.result == 'success'",
@@ -38,6 +38,10 @@ const {
const {
readReleaseIdentity,
} = require('../../scripts/lib/ql3-release-identity.cjs');
const {
catalogLockedArtifacts,
deploymentArtifacts,
} = require('../../scripts/ql3-kubernetes-deployment-live-contract.cjs');
const root = path.resolve(__dirname, '../..');
const version = readReleaseIdentity(root).version;
@@ -314,6 +318,112 @@ function kubernetesOptions(set, extra = {}) {
});
}
test('materializes the Kubernetes live lock from one audited catalog consumption bundle', function materializesCatalogBoundLiveLock() {
const set = releaseSet('cluster');
const consumption = Object.freeze({
...consumptionAuthority(set),
releaseSet: set,
});
const consumptionBundle = '/private/ql3-release-catalog-consumption';
let auditedOptions;
const artifacts = catalogLockedArtifacts(
{
...identity,
releaseScope: 'cluster',
consumptionBundle,
},
{
auditCeremonyBundle(options) {
auditedOptions = options;
return consumption;
},
},
);
assert.deepEqual(auditedOptions, {
...identity,
releaseScope: 'cluster',
outputDirectory: consumptionBundle,
});
assert.equal(artifacts.report.releaseSetDigest, set.releaseSetDigest);
assert.equal(
artifacts.report.catalog.manifestDigest,
consumption.catalogManifestDigest,
);
assert.equal(artifacts.releaseAuthority.mode, 'verified_release_catalog');
assert.equal(
artifacts.releaseAuthority.catalogConsumptionDigest,
consumption.contentDigest,
);
assert.deepEqual(artifacts.imageReferences, references(set));
assert.equal(artifacts.report.manifest.resources, 7);
assert.equal(artifacts.report.manifest.changedResources, 5);
assert.equal(artifacts.report.manifest.admissionAuthorityCount, 1);
const resources = [];
yaml.loadAll(artifacts.manifest, (resource) => resources.push(resource));
assert.equal(resources.length, 7);
assert.equal(
resources.find(
(resource) => resource.metadata?.name === 'ql3-retirement-live-target',
)?.data?.purpose,
'catalog-bound-retirement-live-contract',
);
for (const resource of resources.filter(
(entry) => entry.kind === 'Deployment',
)) {
assert.equal(
resource.metadata.annotations['qinglong.io/release-set-digest'],
set.releaseSetDigest,
);
assert.match(
resource.spec.template.spec.containers[0].image,
/^ghcr\.io\/qinglong-release\/qinglong3-[a-z-]+@sha256:[a-f0-9]{64}$/u,
);
}
});
test('selects catalog-backed live artifacts only for one complete environment', function selectsCompleteCatalogLiveEnvironment() {
assert.equal(
deploymentArtifacts({}).releaseAuthority.mode,
'synthetic_live_fixture',
);
assert.throws(
() =>
deploymentArtifacts({
QL3_RELEASE_CATALOG_CONSUMPTION_BUNDLE: '/private/bundle',
}),
/configuration is incomplete/u,
);
assert.throws(
() =>
catalogLockedArtifacts({
...identity,
releaseScope: 'local',
consumptionBundle: '/private/bundle',
}),
/requires cluster or all scope/u,
);
const set = releaseSet('all');
const consumption = Object.freeze({
...consumptionAuthority(set),
releaseSet: set,
});
const environment = {
QL3_RELEASE_CATALOG_CONSUMPTION_BUNDLE: '/private/bundle',
QL3_RELEASE_SOURCE_REVISION: identity.sourceRevision,
QL3_RELEASE_SOURCE_REF: identity.sourceRef,
QL3_RELEASE_SCOPE: 'all',
QL3_RELEASE_REPOSITORY_OWNER: identity.repositoryOwner,
QL3_RELEASE_SOURCE_REPOSITORY: identity.sourceRepository,
};
const artifacts = deploymentArtifacts(environment, {
auditCeremonyBundle: () => consumption,
});
assert.equal(artifacts.releaseAuthority.mode, 'verified_release_catalog');
assert.equal(artifacts.releaseAuthority.scope, 'all');
assert.equal(artifacts.report.release.scope, 'all');
assert.equal(artifacts.report.requiredImages.length, 4);
});
test('selects one immutable Local Compose image without adding device work', () => {
for (const scope of ['local', 'all']) {
const set = releaseSet(scope);