feat(ql3): govern release version transitions

This commit is contained in:
whyour
2026-08-16 09:32:27 +08:00
parent 7130d77a76
commit bad8399cc3
24 changed files with 1462 additions and 128 deletions
+41 -28
View File
@@ -12,9 +12,13 @@ const {
reviewedOperatorManifest,
verifyImageIds,
} = require('../../scripts/ql3-cloudnativepg-live-contract.cjs');
const {
readReleaseIdentity,
} = require('../../scripts/lib/ql3-release-identity.cjs');
const INDEX = `sha256:${'a'.repeat(64)}`;
const PLATFORM = `sha256:${'b'.repeat(64)}`;
const VERSION = readReleaseIdentity(path.resolve(__dirname, '../..')).version;
function pods(...imageIds) {
return imageIds.map((imageID) => ({
@@ -23,10 +27,7 @@ function pods(...imageIds) {
}
test('extracts only one exact digest-pinned image reference', () => {
assert.equal(
imageDigest(`registry.example/operand:18.4@${INDEX}`),
INDEX,
);
assert.equal(imageDigest(`registry.example/operand:18.4@${INDEX}`), INDEX);
assert.throws(() => imageDigest('registry.example/operand:18.4'));
assert.throws(() => imageDigest(`registry.example/operand@${INDEX}:tag`));
});
@@ -41,16 +42,16 @@ test('derives a normal tagged preload reference from a reviewed image', () => {
});
test('replaces exactly one fail-closed application image only in live rendering', () => {
const placeholder = `registry.example.com/qinglong/qinglong3-cluster-control@sha256:${'0'.repeat(64)}`;
const placeholder = `registry.example.com/qinglong/qinglong3-cluster-control@sha256:${'0'.repeat(
64,
)}`;
const rendered = `kind: Deployment\nspec:\n image: ${placeholder}\n`;
assert.equal(
localApplicationManifest(rendered),
'kind: Deployment\nspec:\n image: registry.example.com/qinglong/qinglong3-cluster-control:3.0.0-alpha.0\n',
`kind: Deployment\nspec:\n image: registry.example.com/qinglong/qinglong3-cluster-control:${VERSION}\n`,
);
assert.throws(() => localApplicationManifest('kind: Deployment\n'));
assert.throws(() =>
localApplicationManifest(`${rendered}---\n${rendered}`),
);
assert.throws(() => localApplicationManifest(`${rendered}---\n${rendered}`));
});
test('accepts uniform runtime reporting of the reviewed index or platform digest', () => {
@@ -74,7 +75,8 @@ test('accepts uniform runtime reporting of the reviewed index or platform digest
test('rejects tags, unknown digests, missing status and widened reviewed sets', () => {
for (const invoke of [
() => verifyImageIds(pods('registry.example/operand:18.4'), [INDEX], 'operand'),
() =>
verifyImageIds(pods('registry.example/operand:18.4'), [INDEX], 'operand'),
() =>
verifyImageIds(
pods(`registry.example/operand@sha256:${'c'.repeat(64)}`),
@@ -82,7 +84,12 @@ test('rejects tags, unknown digests, missing status and widened reviewed sets',
'operand',
),
() => verifyImageIds([{ status: {} }], [INDEX], 'operand'),
() => verifyImageIds(pods(`registry.example/operand@${INDEX}`), ['*'], 'operand'),
() =>
verifyImageIds(
pods(`registry.example/operand@${INDEX}`),
['*'],
'operand',
),
]) {
assert.throws(invoke);
}
@@ -90,7 +97,10 @@ test('rejects tags, unknown digests, missing status and widened reviewed sets',
test('creates the namespaced control identity before the migration Job', () => {
const source = fs.readFileSync(
path.resolve(__dirname, '../../scripts/ql3-cloudnativepg-live-contract.cjs'),
path.resolve(
__dirname,
'../../scripts/ql3-cloudnativepg-live-contract.cjs',
),
'utf8',
);
const namespace = source.indexOf(
@@ -105,10 +115,7 @@ test('creates the namespaced control identity before the migration Job', () => {
assert.ok(namespace >= 0);
assert.ok(serviceAccount > namespace);
assert.ok(migration > serviceAccount);
assert.match(
source.slice(namespace, serviceAccount),
/kubectl\(\[/,
);
assert.match(source.slice(namespace, serviceAccount), /kubectl\(\[/);
assert.match(
source.slice(namespace, migration),
/'-n',\s*NAMESPACE,\s*'apply',\s*'-f',\s*'deploy\/kubernetes\/ql3-cluster\/base\/service-account\.yaml'/,
@@ -117,7 +124,10 @@ test('creates the namespaced control identity before the migration Job', () => {
test('provisions the fail-closed worker ingress identity and derives all role evidence from one set', () => {
const source = fs.readFileSync(
path.resolve(__dirname, '../../scripts/ql3-cloudnativepg-live-contract.cjs'),
path.resolve(
__dirname,
'../../scripts/ql3-cloudnativepg-live-contract.cjs',
),
'utf8',
);
assert.match(source, /const roleList = ROLE_NAMES\.map/);
@@ -144,7 +154,10 @@ test('provisions the fail-closed worker ingress identity and derives all role ev
test('preloads both lock-owned images before applying the operator manifest', () => {
const source = fs.readFileSync(
path.resolve(__dirname, '../../scripts/ql3-cloudnativepg-live-contract.cjs'),
path.resolve(
__dirname,
'../../scripts/ql3-cloudnativepg-live-contract.cjs',
),
'utf8',
);
const preload = source.indexOf(
@@ -158,14 +171,8 @@ test('preloads both lock-owned images before applying the operator manifest', ()
const contract = source.slice(preload, manifest);
assert.match(contract, /docker\(\['pull', reviewedImage\]\)/);
assert.match(contract, /imageDigest\(reviewedImage\)/);
assert.match(
contract,
/const preloadTag = imageTag\(reviewedImage\)/,
);
assert.match(
contract,
/docker\(\['tag', reviewedImage, preloadTag\]\)/,
);
assert.match(contract, /const preloadTag = imageTag\(reviewedImage\)/);
assert.match(contract, /docker\(\['tag', reviewedImage, preloadTag\]\)/);
assert.match(
contract,
/kind\(\['load', 'docker-image', preloadTag, '--name', clusterName\]\)/,
@@ -195,12 +202,18 @@ test('rejects a canonical but checksum-unreviewed operator manifest', () => {
test('bounds remote manifest retries and removes disposable temporary state', () => {
const source = fs.readFileSync(
path.resolve(__dirname, '../../scripts/ql3-cloudnativepg-live-contract.cjs'),
path.resolve(
__dirname,
'../../scripts/ql3-cloudnativepg-live-contract.cjs',
),
'utf8',
);
assert.match(source, /'--http1\.1'/);
assert.match(source, /'--retry-max-time',\s*'300'/);
assert.match(source, /reviewedOperatorManifest\(downloadedOperatorManifest\)/);
assert.match(
source,
/reviewedOperatorManifest\(downloadedOperatorManifest\)/,
);
assert.match(
source,
/fs\.rmSync\(tempDirectory, \{ recursive: true, force: true \}\)/,
+14 -17
View File
@@ -5,8 +5,12 @@ const { test } = require('node:test');
const {
auditClusterDeployment,
} = require('../../scripts/ql3-cluster-deployment-audit.cjs');
const {
readReleaseIdentity,
} = require('../../scripts/lib/ql3-release-identity.cjs');
const ROOT = path.resolve(__dirname, '../..');
const VERSION = readReleaseIdentity(ROOT).version;
function intercept(relativePath, transform) {
const target = path.join(ROOT, relativePath);
@@ -233,8 +237,8 @@ test('keeps Cluster AI optional with projected authority and an independent dige
'deploy/kubernetes/ql3-cluster/base/deployment.yaml',
(source) =>
source.replace(
'image: qinglong3-cluster-control:3.0.0-alpha.0',
'image: qinglong3-cluster-control-ai:3.0.0-alpha.0',
`image: qinglong3-cluster-control:${VERSION}`,
`image: qinglong3-cluster-control-ai:${VERSION}`,
),
),
});
@@ -263,8 +267,7 @@ test('keeps Cluster AI optional with projected authority and an independent dige
root: ROOT,
readFile: intercept(
'deploy/kubernetes/ql3-cluster/overlays/cluster-ai-example/kustomization.yaml',
(source) =>
source.replace(/digest: sha256:0{64}/, 'newTag: 3.0.0-alpha.0'),
(source) => source.replace(/digest: sha256:0{64}/, `newTag: ${VERSION}`),
),
});
assert.equal(tagBased.compatible, false);
@@ -600,8 +603,7 @@ test('rejects widened lifecycle, authority or public inputs in the Approval clie
],
[
'deploy/kubernetes/ql3-cluster/operations/approval-management-client/kustomization.yaml',
(source) =>
source.replace(/digest: sha256:0{64}/, 'newTag: 3.0.0-alpha.0'),
(source) => source.replace(/digest: sha256:0{64}/, `newTag: ${VERSION}`),
'QL3_CLUSTER_APPROVAL_MANAGEMENT_CLIENT_OPT_IN',
],
[
@@ -654,8 +656,7 @@ test('rejects widened lifecycle, authority or public inputs in the automation cl
],
[
'deploy/kubernetes/ql3-cluster/operations/automation-management-client/kustomization.yaml',
(source) =>
source.replace(/digest: sha256:0{64}/, 'newTag: 3.0.0-alpha.0'),
(source) => source.replace(/digest: sha256:0{64}/, `newTag: ${VERSION}`),
'QL3_CLUSTER_AUTOMATION_MANAGEMENT_CLIENT_RELEASE_DIGEST_PIN',
],
[
@@ -726,8 +727,7 @@ test('rejects widened authority or lifecycle in the Worker management client', (
],
[
'deploy/kubernetes/ql3-cluster/operations/worker-credential-management-client/kustomization.yaml',
(source) =>
source.replace(/digest: sha256:0{64}/, 'newTag: 3.0.0-alpha.0'),
(source) => source.replace(/digest: sha256:0{64}/, `newTag: ${VERSION}`),
'QL3_CLUSTER_WORKER_CREDENTIAL_MANAGEMENT_CLIENT_RELEASE_DIGEST_PIN',
],
[
@@ -840,8 +840,7 @@ test('rejects recurring, default-enabled or public-egress Worker execution', ()
],
[
'deploy/kubernetes/ql3-cluster/operations/worker-credential-executor/cloudnative-pg/kustomization.yaml',
(source) =>
source.replace(/digest: sha256:0{64}/, 'newTag: 3.0.0-alpha.0'),
(source) => source.replace(/digest: sha256:0{64}/, `newTag: ${VERSION}`),
'QL3_CLUSTER_WORKER_CREDENTIAL_EXECUTOR_CLOUDNATIVE_PG_AUTHORITY',
],
]) {
@@ -917,8 +916,7 @@ test('rejects default or tag-based Worker management rollout', () => {
root: ROOT,
readFile: intercept(
'deploy/kubernetes/ql3-cluster/operations/worker-credential-management/cloudnative-pg/kustomization.yaml',
(source) =>
source.replace(/digest: sha256:0{64}/, 'newTag: 3.0.0-alpha.0'),
(source) => source.replace(/digest: sha256:0{64}/, `newTag: ${VERSION}`),
),
});
assert.equal(tagBased.compatible, false);
@@ -1114,8 +1112,7 @@ test('rejects default enablement or tag-based Package management rollout', () =>
root: ROOT,
readFile: intercept(
'deploy/kubernetes/ql3-cluster/operations/plugin-package-management/cloudnative-pg/kustomization.yaml',
(source) =>
source.replace(/digest: sha256:0{64}/, 'newTag: 3.0.0-alpha.0'),
(source) => source.replace(/digest: sha256:0{64}/, `newTag: ${VERSION}`),
),
});
assert.equal(tagBased.compatible, false);
@@ -1424,7 +1421,7 @@ test('rejects tag-based production overlays for either cluster image', () => {
const report = auditClusterDeployment({
root: ROOT,
readFile: intercept(relativePath, (source) =>
source.replace(/digest: sha256:0{64}/, 'newTag: 3.0.0-alpha.0'),
source.replace(/digest: sha256:0{64}/, `newTag: ${VERSION}`),
),
});
assert.equal(report.compatible, false);
@@ -30,6 +30,7 @@ test('accepts the reviewed native CI and digest release contracts', () => {
clusterAdminOperatorContext: true,
clusterAdminContextPreflight: true,
clusterAdminContextReadiness: true,
releaseVersionAudit: true,
ociAttestations: true,
osVulnerabilityScan: {
scanner: 'trivy@0.70.0',
@@ -105,6 +106,17 @@ test('accepts the reviewed native CI and digest release contracts', () => {
});
});
test('rejects removal of the source-derived release version audit', () => {
const mutated = ciSource.replace(
'pnpm audit:release-version:ql3',
'echo release-version-audit-removed',
);
assert.throws(
() => auditClusterImageCiWorkflow(mutated),
/source-derived release version identity/,
);
});
test('rejects removal of the native arm64 image gate', () => {
const mutated = ciSource.replace(
'runner: ubuntu-24.04-arm\n node_arch: arm64\n image_arch: arm64\n image: control',
+10 -6
View File
@@ -10,8 +10,12 @@ const {
componentRef,
createClusterImageSbom,
} = require('../../scripts/ql3-cluster-image-sbom.cjs');
const {
readReleaseIdentity,
} = require('../../scripts/lib/ql3-release-identity.cjs');
const root = path.resolve(__dirname, '../..');
const version = readReleaseIdentity(root).version;
function clone(value) {
return JSON.parse(JSON.stringify(value));
@@ -23,7 +27,7 @@ test('generates the exact reviewed cluster image runtime closure', () => {
assert.deepEqual(report, {
image: 'control',
root: 'pkg:npm/%40qinglong/cluster-control-image-dependencies@3.0.0-alpha.0',
root: `pkg:npm/%40qinglong/cluster-control-image-dependencies@${version}`,
components: 46,
externalComponents: 43,
internalComponents: 3,
@@ -49,7 +53,7 @@ test('generates the optional Cluster AI image runtime closure', () => {
assert.deepEqual(report, {
image: 'control-ai',
root: 'pkg:npm/%40qinglong/cluster-control-image-dependencies@3.0.0-alpha.0',
root: `pkg:npm/%40qinglong/cluster-control-image-dependencies@${version}`,
components: 47,
externalComponents: 43,
internalComponents: 4,
@@ -71,7 +75,7 @@ test('generates the independent reviewed cluster-admin image closure', () => {
assert.deepEqual(report, {
image: 'admin',
root: 'pkg:npm/%40qinglong/cluster-admin-image-dependencies@3.0.0-alpha.0',
root: `pkg:npm/%40qinglong/cluster-admin-image-dependencies@${version}`,
components: 91,
externalComponents: 87,
internalComponents: 4,
@@ -107,7 +111,7 @@ test('generates the AI-excluded local application image closure', () => {
assert.deepEqual(report, {
image: 'local',
root: 'pkg:npm/%40qinglong/local-application-image@3.0.0-alpha.0',
root: `pkg:npm/%40qinglong/local-application-image@${version}`,
components: 10,
externalComponents: 2,
internalComponents: 8,
@@ -133,7 +137,7 @@ test('generates the headless Worker image runtime closure', () => {
assert.deepEqual(report, {
image: 'worker',
root: 'pkg:npm/%40qinglong/worker-image-dependencies@3.0.0-alpha.0',
root: `pkg:npm/%40qinglong/worker-image-dependencies@${version}`,
components: 27,
externalComponents: 24,
internalComponents: 3,
@@ -182,7 +186,7 @@ test('rejects widened metadata and root component drift', () => {
test('rejects a missing internal dependency edge', () => {
const document = createClusterImageSbom({ root });
const controlRef = componentRef('@qinglong/cluster-control', '3.0.0-alpha.0');
const controlRef = componentRef('@qinglong/cluster-control', version);
const edge = document.dependencies.find((entry) => entry.ref === controlRef);
edge.dependsOn = edge.dependsOn.slice(1);
+5 -1
View File
@@ -16,8 +16,12 @@ const {
const {
createClusterImageSbom,
} = require('../../scripts/ql3-cluster-image-sbom.cjs');
const {
readReleaseIdentity,
} = require('../../scripts/lib/ql3-release-identity.cjs');
const root = path.resolve(__dirname, '../..');
const version = readReleaseIdentity(root).version;
const revision = 'fixture-revision';
function createFixture(t, options = {}) {
@@ -164,7 +168,7 @@ function createFixture(t, options = {}) {
: 'QingLong 3.0 Cluster Admin',
...(isLocal || isWorker || isControl || image === 'admin'
? {
'org.opencontainers.image.version': '3.0.0-alpha.0',
'org.opencontainers.image.version': version,
}
: {}),
},
@@ -20,6 +20,11 @@ const {
const {
canonicalDigest,
} = require('../../scripts/ql3-physical-edge-evidence.cjs');
const {
readReleaseIdentity,
} = require('../../scripts/lib/ql3-release-identity.cjs');
const version = readReleaseIdentity(path.resolve(__dirname, '../..')).version;
const packages = [
'@qinglong/local-admin',
@@ -55,7 +60,7 @@ function artifactFixture(t) {
packageName === '@qinglong/local-application'
? {
name: packageName,
version: '3.0.0-alpha.0',
version,
engines: { node: '>=24.18.0 <25' },
bin: { 'ql3-local-application': 'dist/cli.js' },
}
+31 -11
View File
@@ -11,12 +11,16 @@ const {
parseArguments,
runCli,
} = 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: '3.0.0-alpha.0',
version,
sourceRevision: 'a'.repeat(40),
sourceRef: 'refs/tags/v3.0.0-alpha.0',
sourceRef: `refs/tags/v${version}`,
});
test('freezes an independent low-resource local release family', () => {
@@ -35,6 +39,14 @@ test('freezes an independent low-resource local release family', () => {
'standalone',
]);
assert.equal(contract.workspace.packageCount, 18);
assert.equal(
contract.compatibility.releaseIdentitySchema,
'qinglong/release-identity@v1',
);
assert.match(
contract.compatibility.releaseIdentityDigest,
/^sha256:[a-f0-9]{64}$/u,
);
assert.match(contract.contractDigest, /^sha256:[a-f0-9]{64}$/u);
assert.deepEqual(
auditReleaseCandidateContract(contract, {
@@ -126,6 +138,18 @@ test('rejects tag, version and source identity drift', () => {
}),
/Git SHA-1/,
);
const nextVersion = `${version.slice(0, version.lastIndexOf('.') + 1)}1`;
assert.throws(
() =>
createReleaseCandidateContract({
root,
...identity,
version: nextVersion,
sourceRef: `refs/tags/v${nextVersion}`,
releaseScope: 'local',
}),
/repository release identity/,
);
});
test('rejects a source-derived report mutated after creation', () => {
@@ -153,9 +177,9 @@ test('writes once and independently audits the exact report through the CLI', (t
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
const report = path.join(directory, 'contract.json');
const common = [
'--version=3.0.0-alpha.0',
`--version=${version}`,
`--source-revision=${identity.sourceRevision}`,
'--source-ref=refs/tags/v3.0.0-alpha.0',
`--source-ref=refs/tags/v${version}`,
'--release-scope=local',
];
const output = { write() {} };
@@ -168,20 +192,16 @@ test('writes once and independently audits the exact report through the CLI', (t
);
assert.throws(
() =>
runCli(
['--mode=create', ...common, `--output=${report}`],
root,
output,
),
runCli(['--mode=create', ...common, `--output=${report}`], root, output),
/output must be unused/,
);
});
test('parses only exact closed create and audit modes', () => {
const common = [
'--version=3.0.0-alpha.0',
`--version=${version}`,
`--source-revision=${identity.sourceRevision}`,
'--source-ref=refs/tags/v3.0.0-alpha.0',
`--source-ref=refs/tags/v${version}`,
'--release-scope=local',
];
assert.equal(
+280
View File
@@ -0,0 +1,280 @@
'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 semver = require('semver');
const {
CONTAINER_ROOTS,
DEPLOYMENT_FILES,
DEPLOYMENT_ROOTS,
applyVersionTransitionPlan,
auditReleaseVersionContract,
createVersionTransitionPlan,
parseArguments,
runCli,
validatePlan,
} = require('../../scripts/ql3-version-transition.cjs');
const {
readReleaseIdentity,
} = require('../../scripts/lib/ql3-release-identity.cjs');
const root = path.resolve(__dirname, '../..');
const SOURCE_VERSION = readReleaseIdentity(root).version;
const TARGET_VERSION = semver.inc(SOURCE_VERSION, 'prerelease');
const LEGACY_VERSION = JSON.parse(
fs.readFileSync(path.join(root, 'package.json')),
).version;
function copyFile(sourceRoot, targetRoot, relativePath) {
const source = path.join(sourceRoot, relativePath);
const target = path.join(targetRoot, relativePath);
fs.mkdirSync(path.dirname(target), { recursive: true });
fs.copyFileSync(source, target);
fs.chmodSync(target, fs.statSync(source).mode & 0o777);
}
function createFixture(t) {
const fixture = fs.realpathSync(
fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-version-')),
);
t.after(() => fs.rmSync(fixture, { recursive: true, force: true }));
copyFile(root, fixture, 'package.json');
copyFile(root, fixture, 'ql3-release.json');
for (const entry of fs.readdirSync(path.join(root, 'packages'), {
withFileTypes: true,
})) {
if (entry.isDirectory() && entry.name.startsWith('ql3-')) {
copyFile(root, fixture, `packages/${entry.name}/package.json`);
}
}
for (const containerRoot of CONTAINER_ROOTS) {
for (const relativePath of [
'Dockerfile',
'package.json',
'package-lock.json',
'runtime-dependencies/package.json',
'runtime-dependencies/package-lock.json',
]) {
copyFile(root, fixture, `${containerRoot}/${relativePath}`);
}
}
for (const deploymentRoot of DEPLOYMENT_ROOTS) {
fs.cpSync(
path.join(root, deploymentRoot),
path.join(fixture, deploymentRoot),
{ recursive: true, dereference: false },
);
}
for (const relativePath of DEPLOYMENT_FILES) {
copyFile(root, fixture, relativePath);
}
return fixture;
}
function replaceVersion(
filePath,
source = SOURCE_VERSION,
target = TARGET_VERSION,
) {
const contents = fs.readFileSync(filePath, 'utf8');
assert.equal(contents.includes(source), true);
fs.writeFileSync(filePath, contents.split(source).join(target));
}
test('audits one source-derived QingLong 3 release identity', () => {
assert.deepEqual(auditReleaseVersionContract(root), {
schemaVersion: 1,
schema: 'qinglong/release-identity@v1',
version: SOURCE_VERSION,
nodeVersion: '24.18.0',
nodeEngine: '>=24.18.0 <25',
legacyRootPackageVersion: LEGACY_VERSION,
legacyRootExcluded: true,
workspacePackageCount: 18,
containerRootCount: 4,
deploymentFileCount: 242,
deploymentImageReferences: 32,
deploymentVersionOccurrences: 36,
compatible: true,
});
});
test('plans the exact governed version surface without touching legacy 2.x', () => {
const plan = createVersionTransitionPlan({
root,
sourceVersion: SOURCE_VERSION,
targetVersion: TARGET_VERSION,
});
assert.equal(plan.fileCount, 65);
assert.equal(plan.replacementCount, 83);
assert.equal(plan.legacyRootPackageVersion, LEGACY_VERSION);
assert.equal(plan.legacyRootExcluded, true);
assert.equal(
plan.entries.some((entry) => entry.path === 'package.json'),
false,
);
assert.equal(
plan.entries.filter((entry) => entry.path.startsWith('packages/')).length,
18,
);
assert.match(plan.planDigest, /^sha256:[a-f0-9]{64}$/u);
assert.equal(validatePlan(plan), plan);
});
test('applies and exactly replays a complete version transition', (t) => {
const fixture = createFixture(t);
const plan = createVersionTransitionPlan({
root: fixture,
sourceVersion: SOURCE_VERSION,
targetVersion: TARGET_VERSION,
});
const report = applyVersionTransitionPlan(plan, {
root: fixture,
report: path.join(fixture, 'first-report.json'),
});
assert.equal(report.changedFiles, plan.fileCount);
assert.equal(report.alreadyCurrentFiles, 0);
assert.equal(report.exactReplay, false);
assert.equal(auditReleaseVersionContract(fixture).version, TARGET_VERSION);
assert.equal(
JSON.parse(fs.readFileSync(path.join(fixture, 'package.json'))).version,
LEGACY_VERSION,
);
const replay = applyVersionTransitionPlan(plan, {
root: fixture,
report: path.join(fixture, 'replay-report.json'),
});
assert.equal(replay.changedFiles, 0);
assert.equal(replay.alreadyCurrentFiles, plan.fileCount);
assert.equal(replay.exactReplay, true);
});
test('resumes a partial transition using before and after digests', (t) => {
const fixture = createFixture(t);
const plan = createVersionTransitionPlan({
root: fixture,
sourceVersion: SOURCE_VERSION,
targetVersion: TARGET_VERSION,
});
replaceVersion(path.join(fixture, 'ql3-release.json'));
const report = applyVersionTransitionPlan(plan, {
root: fixture,
report: path.join(fixture, 'recovered-report.json'),
});
assert.equal(report.changedFiles, plan.fileCount - 1);
assert.equal(report.alreadyCurrentFiles, 1);
assert.equal(auditReleaseVersionContract(fixture).version, TARGET_VERSION);
});
test('preflights every governed file before making a partial mutation', (t) => {
const fixture = createFixture(t);
const plan = createVersionTransitionPlan({
root: fixture,
sourceVersion: SOURCE_VERSION,
targetVersion: TARGET_VERSION,
});
const drifted = path.join(fixture, plan.entries.at(-1).path);
fs.appendFileSync(drifted, '\n');
assert.throws(
() =>
applyVersionTransitionPlan(plan, {
root: fixture,
report: path.join(fixture, 'must-not-exist.json'),
}),
/drifted after plan creation/,
);
assert.equal(
JSON.parse(fs.readFileSync(path.join(fixture, 'ql3-release.json'))).version,
SOURCE_VERSION,
);
assert.equal(fs.existsSync(path.join(fixture, 'must-not-exist.json')), false);
});
test('rejects invalid SemVer, downgrade, plan mutation and a symbolic-link identity', (t) => {
assert.throws(
() =>
createVersionTransitionPlan({
root,
sourceVersion: SOURCE_VERSION,
targetVersion: '3.0.0-alpha',
}),
/monotonically newer/,
);
const plan = createVersionTransitionPlan({
root,
sourceVersion: SOURCE_VERSION,
targetVersion: TARGET_VERSION,
});
const mutatedPlan = JSON.parse(JSON.stringify(plan));
mutatedPlan.entries[0].path = 'package.json';
assert.throws(() => validatePlan(mutatedPlan), /plan entry is invalid/);
const fixture = createFixture(t);
const invalidIdentityPath = path.join(fixture, 'ql3-release.json');
const invalidIdentity = JSON.parse(fs.readFileSync(invalidIdentityPath));
invalidIdentity.version = '3.0.0-alpha.01';
fs.writeFileSync(
invalidIdentityPath,
`${JSON.stringify(invalidIdentity, null, 2)}\n`,
);
assert.throws(
() => auditReleaseVersionContract(fixture),
/identity shape or value is incompatible/,
);
copyFile(root, fixture, 'ql3-release.json');
fs.renameSync(
path.join(fixture, 'ql3-release.json'),
path.join(fixture, 'identity-target.json'),
);
fs.symlinkSync(
'identity-target.json',
path.join(fixture, 'ql3-release.json'),
);
assert.throws(
() => auditReleaseVersionContract(fixture),
/canonical regular file/,
);
});
test('CLI writes no-replace plans and accepts only closed modes', (t) => {
const directory = fs.realpathSync(
fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-version-cli-')),
);
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
const planPath = path.join(directory, 'plan.json');
const output = { write() {} };
runCli(
[
'--mode=plan',
`--from=${SOURCE_VERSION}`,
`--to=${TARGET_VERSION}`,
`--output=${planPath}`,
],
root,
output,
);
assert.equal(fs.statSync(planPath).mode & 0o777, 0o600);
assert.throws(
() =>
runCli(
[
'--mode=plan',
`--from=${SOURCE_VERSION}`,
`--to=${TARGET_VERSION}`,
`--output=${planPath}`,
],
root,
output,
),
/output must be unused/,
);
assert.deepEqual(parseArguments(['--mode=audit']), { mode: 'audit' });
assert.throws(
() => parseArguments(['--mode=audit', '--extra=true']),
/arguments are invalid/,
);
});