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:
@@ -73,7 +73,7 @@ test('rejects verifier, embedded artifact and release workflow drift', () => {
|
||||
'.github/workflows/ql3-image-release.yml',
|
||||
(source) =>
|
||||
source.replace(
|
||||
'Promote only the verified digest to immutable release tags',
|
||||
'Promote tags only after the complete set is verified',
|
||||
'Promote mutable release tags',
|
||||
),
|
||||
'QL3_CLUSTER_ADMIN_RELEASE_WORKFLOW_DRIFT',
|
||||
|
||||
@@ -90,6 +90,16 @@ test('accepts the reviewed native CI and digest release contracts', () => {
|
||||
'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,
|
||||
@@ -100,6 +110,7 @@ test('accepts the reviewed native CI and digest release contracts', () => {
|
||||
'cyclonedx',
|
||||
'os-vulnerability',
|
||||
'release-candidate',
|
||||
'release-set',
|
||||
'release-tags',
|
||||
],
|
||||
},
|
||||
@@ -506,7 +517,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/,
|
||||
/privileged publisher|immutable checkout action|release-set job/,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -654,18 +665,62 @@ test('rejects an image tag created before digest verification completes', () =>
|
||||
);
|
||||
assert.throws(
|
||||
() => auditReleaseWorkflow(mutated),
|
||||
/before final tag promotion/,
|
||||
/without any rebuild or tag promotion before release-set closure/,
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects any publisher step after immutable tag promotion', () => {
|
||||
test('rejects any publisher step after the deployment lock is published', () => {
|
||||
const mutated = `${releaseSource}\n - name: Post-promotion mutation\n run: echo unsafe\n`;
|
||||
assert.throws(
|
||||
() => auditReleaseWorkflow(mutated),
|
||||
/before final tag promotion/,
|
||||
/release-set job must download only same-run records/,
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects release-set closure before every image publisher succeeds', () => {
|
||||
const mutated = releaseSource.replace(
|
||||
" needs.publish.result == 'success'",
|
||||
" needs.publish.result != 'success'",
|
||||
);
|
||||
assert.throws(() => auditReleaseWorkflow(mutated), /complete publish matrix/);
|
||||
});
|
||||
|
||||
test('rejects image records detached from the same workflow attempt', () => {
|
||||
const mutated = releaseSource.replace(
|
||||
'ql3-release-record-${{ github.run_id }}-${{ github.run_attempt }}-*',
|
||||
'ql3-release-record-${{ github.run_id }}-*',
|
||||
);
|
||||
assert.throws(
|
||||
() => auditReleaseWorkflow(mutated),
|
||||
/download only same-run records/,
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects release-set aggregation without independent audit', () => {
|
||||
const releaseSetOffset = releaseSource.indexOf('\n release-set:');
|
||||
assert.notEqual(releaseSetOffset, -1);
|
||||
const releaseSetSource = releaseSource.slice(releaseSetOffset);
|
||||
const mutatedReleaseSet = releaseSetSource.replace(
|
||||
' --mode=audit \\',
|
||||
' --mode=aggregate \\',
|
||||
);
|
||||
const mutated = `${releaseSource.slice(
|
||||
0,
|
||||
releaseSetOffset,
|
||||
)}${mutatedReleaseSet}`;
|
||||
assert.throws(
|
||||
() => auditReleaseWorkflow(mutated),
|
||||
/download only same-run records/,
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects a short-lived deployment digest lock', () => {
|
||||
const marker = ' retention-days: 90';
|
||||
assert.equal(releaseSource.includes(marker), true);
|
||||
const mutated = releaseSource.replace(marker, ' retention-days: 1');
|
||||
assert.throws(() => auditReleaseWorkflow(mutated), /deployment lock/);
|
||||
});
|
||||
|
||||
test('rejects removal of the digest-bound OS vulnerability attestation', () => {
|
||||
const mutated = releaseSource.replace(
|
||||
'predicate-type: https://qinglong.dev/attestations/image-os-vulnerability/v1',
|
||||
|
||||
@@ -101,6 +101,10 @@ test('combines local and cluster families without weakening either gate', () =>
|
||||
contract.requiredGates.includes('edge-and-standalone-rollout'),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
contract.requiredGates.includes('cross-image-release-set'),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
contract.requiredGates.includes('worker-management-production-evidence'),
|
||||
true,
|
||||
|
||||
@@ -0,0 +1,343 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const test = require('node:test');
|
||||
const {
|
||||
auditReleaseSet,
|
||||
createReleaseSet,
|
||||
createVerifiedImageRecord,
|
||||
parseArguments,
|
||||
runCli,
|
||||
} = 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 root = path.resolve(__dirname, '../..');
|
||||
const version = readReleaseIdentity(root).version;
|
||||
const identity = Object.freeze({
|
||||
version,
|
||||
sourceRevision: 'b'.repeat(40),
|
||||
sourceRef: `refs/tags/v${version}`,
|
||||
repositoryOwner: 'qinglong-release',
|
||||
});
|
||||
|
||||
function candidate(scope) {
|
||||
return createReleaseCandidateContract({
|
||||
root,
|
||||
version,
|
||||
sourceRevision: identity.sourceRevision,
|
||||
sourceRef: identity.sourceRef,
|
||||
releaseScope: scope,
|
||||
});
|
||||
}
|
||||
|
||||
function recordsFor(releaseCandidate) {
|
||||
return releaseCandidate.images.map((entry, index) =>
|
||||
createVerifiedImageRecord({
|
||||
root,
|
||||
candidate: releaseCandidate,
|
||||
...identity,
|
||||
releaseScope: releaseCandidate.release.scope,
|
||||
image: entry.image,
|
||||
digest: `sha256:${String(index + 1).repeat(64)}`,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function temporaryDirectory(t) {
|
||||
const directory = fs.realpathSync(
|
||||
fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-release-set-')),
|
||||
);
|
||||
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('aggregates the independent Local image into one immutable release set', () => {
|
||||
const releaseCandidate = candidate('local');
|
||||
const records = recordsFor(releaseCandidate);
|
||||
const releaseSet = createReleaseSet({
|
||||
root,
|
||||
candidate: releaseCandidate,
|
||||
records,
|
||||
...identity,
|
||||
releaseScope: 'local',
|
||||
});
|
||||
assert.deepEqual(
|
||||
releaseSet.images.map((entry) => entry.name),
|
||||
['local'],
|
||||
);
|
||||
assert.equal(releaseSet.deploymentFamilies.local.selected, true);
|
||||
assert.equal(releaseSet.deploymentFamilies.cluster.selected, false);
|
||||
assert.match(releaseSet.images[0].reference, /@sha256:1{64}$/u);
|
||||
assert.equal(releaseSet.promotion.authority, 'complete_verified_release_set');
|
||||
assert.match(releaseSet.releaseSetDigest, /^sha256:[a-f0-9]{64}$/u);
|
||||
assert.equal(
|
||||
auditReleaseSet(releaseSet, {
|
||||
root,
|
||||
candidate: releaseCandidate,
|
||||
records,
|
||||
...identity,
|
||||
releaseScope: 'local',
|
||||
}).compatible,
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('closes cluster and all scopes over the exact candidate image order', () => {
|
||||
const clusterCandidate = candidate('cluster');
|
||||
const clusterSet = createReleaseSet({
|
||||
root,
|
||||
candidate: clusterCandidate,
|
||||
records: recordsFor(clusterCandidate).reverse(),
|
||||
...identity,
|
||||
releaseScope: 'cluster',
|
||||
});
|
||||
assert.deepEqual(
|
||||
clusterSet.images.map((entry) => entry.name),
|
||||
['control', 'control-ai', 'admin', 'worker'],
|
||||
);
|
||||
assert.deepEqual(clusterSet.deploymentFamilies.cluster.images, [
|
||||
'control',
|
||||
'control-ai',
|
||||
'admin',
|
||||
'worker',
|
||||
]);
|
||||
|
||||
const allCandidate = candidate('all');
|
||||
const allSet = createReleaseSet({
|
||||
root,
|
||||
candidate: allCandidate,
|
||||
records: recordsFor(allCandidate),
|
||||
...identity,
|
||||
releaseScope: 'all',
|
||||
});
|
||||
assert.equal(allSet.images.length, 5);
|
||||
assert.deepEqual(allSet.deploymentFamilies.local.images, ['local']);
|
||||
});
|
||||
|
||||
test('rejects missing, duplicate and cross-candidate image evidence', () => {
|
||||
const releaseCandidate = candidate('cluster');
|
||||
const records = recordsFor(releaseCandidate);
|
||||
assert.throws(
|
||||
() =>
|
||||
createReleaseSet({
|
||||
root,
|
||||
candidate: releaseCandidate,
|
||||
records: records.slice(1),
|
||||
...identity,
|
||||
releaseScope: 'cluster',
|
||||
}),
|
||||
/record count differs/,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
createReleaseSet({
|
||||
root,
|
||||
candidate: releaseCandidate,
|
||||
records: [records[0], records[0], records[2], records[3]],
|
||||
...identity,
|
||||
releaseScope: 'cluster',
|
||||
}),
|
||||
/records must be unique/,
|
||||
);
|
||||
const mutated = JSON.parse(JSON.stringify(records[0]));
|
||||
mutated.release.sourceRevision = 'c'.repeat(40);
|
||||
assert.throws(
|
||||
() =>
|
||||
createReleaseSet({
|
||||
root,
|
||||
candidate: releaseCandidate,
|
||||
records: [mutated, ...records.slice(1)],
|
||||
...identity,
|
||||
releaseScope: 'cluster',
|
||||
}),
|
||||
/image record drifted/,
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects mutable identity, malformed owner and post-aggregate drift', () => {
|
||||
const releaseCandidate = candidate('local');
|
||||
assert.throws(
|
||||
() =>
|
||||
createVerifiedImageRecord({
|
||||
root,
|
||||
candidate: releaseCandidate,
|
||||
...identity,
|
||||
repositoryOwner: 'UPPERCASE',
|
||||
releaseScope: 'local',
|
||||
image: 'local',
|
||||
digest: `sha256:${'1'.repeat(64)}`,
|
||||
}),
|
||||
/lowercase GitHub owner/,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
createVerifiedImageRecord({
|
||||
root,
|
||||
candidate: releaseCandidate,
|
||||
...identity,
|
||||
releaseScope: 'local',
|
||||
image: 'local',
|
||||
digest: 'latest',
|
||||
}),
|
||||
/exact SHA-256 digest/,
|
||||
);
|
||||
const records = recordsFor(releaseCandidate);
|
||||
const releaseSet = createReleaseSet({
|
||||
root,
|
||||
candidate: releaseCandidate,
|
||||
records,
|
||||
...identity,
|
||||
releaseScope: 'local',
|
||||
});
|
||||
const mutated = JSON.parse(JSON.stringify(releaseSet));
|
||||
mutated.images[0].versionTag = 'ghcr.io/qinglong-release/other:latest';
|
||||
assert.throws(
|
||||
() =>
|
||||
auditReleaseSet(mutated, {
|
||||
root,
|
||||
candidate: releaseCandidate,
|
||||
records,
|
||||
...identity,
|
||||
releaseScope: 'local',
|
||||
}),
|
||||
/differs from the verified image records/,
|
||||
);
|
||||
});
|
||||
|
||||
test('CLI records, aggregates and audits exact no-replace files', (t) => {
|
||||
const directory = temporaryDirectory(t);
|
||||
const recordsDirectory = path.join(directory, 'records');
|
||||
fs.mkdirSync(recordsDirectory);
|
||||
const releaseCandidate = candidate('local');
|
||||
const candidatePath = path.join(directory, 'candidate.json');
|
||||
writeCanonical(candidatePath, releaseCandidate);
|
||||
const recordPath = path.join(recordsDirectory, 'local.json');
|
||||
const common = [
|
||||
`--version=${version}`,
|
||||
`--source-revision=${identity.sourceRevision}`,
|
||||
`--source-ref=${identity.sourceRef}`,
|
||||
'--release-scope=local',
|
||||
`--repository-owner=${identity.repositoryOwner}`,
|
||||
`--candidate=${candidatePath}`,
|
||||
];
|
||||
const output = { write() {} };
|
||||
runCli(
|
||||
[
|
||||
'--mode=record-image',
|
||||
...common,
|
||||
'--image=local',
|
||||
`--digest=sha256:${'1'.repeat(64)}`,
|
||||
`--output=${recordPath}`,
|
||||
],
|
||||
root,
|
||||
output,
|
||||
);
|
||||
assert.equal(fs.statSync(recordPath).mode & 0o777, 0o600);
|
||||
const setPath = path.join(directory, 'release-set.json');
|
||||
runCli(
|
||||
[
|
||||
'--mode=aggregate',
|
||||
...common,
|
||||
`--records=${recordsDirectory}`,
|
||||
`--output=${setPath}`,
|
||||
],
|
||||
root,
|
||||
output,
|
||||
);
|
||||
assert.equal(
|
||||
runCli(
|
||||
[
|
||||
'--mode=audit',
|
||||
...common,
|
||||
`--records=${recordsDirectory}`,
|
||||
`--report=${setPath}`,
|
||||
],
|
||||
root,
|
||||
output,
|
||||
).compatible,
|
||||
true,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
runCli(
|
||||
[
|
||||
'--mode=aggregate',
|
||||
...common,
|
||||
`--records=${recordsDirectory}`,
|
||||
`--output=${setPath}`,
|
||||
],
|
||||
root,
|
||||
output,
|
||||
),
|
||||
/output must be unused/,
|
||||
);
|
||||
});
|
||||
|
||||
test('CLI rejects extra records, symlinks and open argument shapes', (t) => {
|
||||
const directory = temporaryDirectory(t);
|
||||
const recordsDirectory = path.join(directory, 'records');
|
||||
fs.mkdirSync(recordsDirectory);
|
||||
const releaseCandidate = candidate('local');
|
||||
const candidatePath = path.join(directory, 'candidate.json');
|
||||
writeCanonical(candidatePath, releaseCandidate);
|
||||
writeCanonical(
|
||||
path.join(recordsDirectory, 'local.json'),
|
||||
recordsFor(releaseCandidate)[0],
|
||||
);
|
||||
writeCanonical(path.join(recordsDirectory, 'extra.json'), {});
|
||||
const common = [
|
||||
`--version=${version}`,
|
||||
`--source-revision=${identity.sourceRevision}`,
|
||||
`--source-ref=${identity.sourceRef}`,
|
||||
'--release-scope=local',
|
||||
`--repository-owner=${identity.repositoryOwner}`,
|
||||
`--candidate=${candidatePath}`,
|
||||
];
|
||||
assert.throws(
|
||||
() =>
|
||||
runCli(
|
||||
[
|
||||
'--mode=aggregate',
|
||||
...common,
|
||||
`--records=${recordsDirectory}`,
|
||||
`--output=${path.join(directory, 'set.json')}`,
|
||||
],
|
||||
root,
|
||||
{ write() {} },
|
||||
),
|
||||
/exact selected image set/,
|
||||
);
|
||||
fs.unlinkSync(path.join(recordsDirectory, 'extra.json'));
|
||||
fs.renameSync(candidatePath, path.join(directory, 'candidate-target.json'));
|
||||
fs.symlinkSync('candidate-target.json', candidatePath);
|
||||
assert.throws(
|
||||
() =>
|
||||
runCli(
|
||||
[
|
||||
'--mode=aggregate',
|
||||
...common,
|
||||
`--records=${recordsDirectory}`,
|
||||
`--output=${path.join(directory, 'set.json')}`,
|
||||
],
|
||||
root,
|
||||
{ write() {} },
|
||||
),
|
||||
/canonical regular file/,
|
||||
);
|
||||
assert.throws(
|
||||
() => parseArguments(['--mode=audit', '--extra=true']),
|
||||
/arguments are invalid/,
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user