feat(ql3): scope deployment-family release candidates

This commit is contained in:
whyour
2026-08-16 08:54:51 +08:00
parent d991deb7ee
commit 7130d77a76
27 changed files with 1332 additions and 303 deletions
@@ -10,7 +10,9 @@ const verifier = path.join(
ROOT,
'deploy/console/ql3-cluster-copilot/verify-release.sh',
);
const image = `ghcr.io/example/qinglong3-cluster-admin@sha256:${'b'.repeat(64)}`;
const image = `ghcr.io/example/qinglong3-cluster-admin@sha256:${'b'.repeat(
64,
)}`;
const revision = 'c'.repeat(40);
function fixture(t) {
@@ -46,7 +48,7 @@ function invoke(args, env) {
});
}
test('verifies one signature and three digest-bound GitHub attestations', (t) => {
test('verifies one signature and four digest-bound GitHub attestations', (t) => {
assert.equal(fs.statSync(verifier).mode & 0o777, 0o755);
const value = fixture(t);
const result = invoke(
@@ -61,11 +63,12 @@ test('verifies one signature and three digest-bound GitHub attestations', (t) =>
provenance: true,
sbom: true,
osVulnerabilityEvidence: true,
releaseCandidateContract: true,
compatible: true,
});
const calls = fs.readFileSync(value.capture, 'utf8');
assert.equal((calls.match(/^cosign$/gmu) ?? []).length, 1);
assert.equal((calls.match(/^gh$/gmu) ?? []).length, 3);
assert.equal((calls.match(/^gh$/gmu) ?? []).length, 4);
for (const required of [
'arg=--certificate-identity',
'arg=https://github.com/example/qinglong/.github/workflows/ql3-image-release.yml@refs/tags/v3.0.0-alpha.1',
@@ -83,17 +86,26 @@ test('verifies one signature and three digest-bound GitHub attestations', (t) =>
'arg=refs/tags/v3.0.0-alpha.1',
'arg=https://cyclonedx.org/bom',
'arg=https://qinglong.dev/attestations/image-os-vulnerability/v1',
'arg=https://qinglong.dev/attestations/release-candidate-contract/v1',
'arg=--deny-self-hosted-runners',
'arg=--bundle-from-oci',
]) {
assert.match(calls, new RegExp(`^${required.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`, 'mu'));
assert.match(
calls,
new RegExp(`^${required.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`, 'mu'),
);
}
});
test('rejects mutable or source-unbound inputs before invoking trust tools', (t) => {
const value = fixture(t);
for (const args of [
['ghcr.io/example/qinglong3-cluster-admin:latest', 'example/qinglong', revision, 'refs/tags/v3.0.0'],
[
'ghcr.io/example/qinglong3-cluster-admin:latest',
'example/qinglong',
revision,
'refs/tags/v3.0.0',
],
[image, 'other/qinglong', revision, 'refs/tags/v3.0.0'],
[image, 'example/qinglong', 'short', 'refs/tags/v3.0.0'],
[image, 'example/qinglong', revision, 'refs/heads/next'],
@@ -118,7 +118,8 @@ test('runs the immutable release ceremony and writes only digest-level evidence'
assert.equal(report.release.sourceRevision, revision);
assert.equal(report.verification.embeddedEvidenceVerifier, true);
assert.equal(report.evidenceVector.classification, 'synthetic_non_sensitive');
assert.equal(report.steps.length, 7);
assert.equal(report.steps.length, 8);
assert.equal(report.verification.releaseCandidateContract, true);
assert.equal(report.claims.reportAttestation, 'none');
assert.equal(reportText.includes(token), false);
assert.equal(fs.statSync(value.output).mode & 0o777, 0o600);
@@ -136,11 +137,11 @@ test('runs the immutable release ceremony and writes only digest-level evidence'
.map((line) => JSON.parse(line));
assert.deepEqual(
calls.map(({ name }) => name),
['cosign', 'gh', 'gh', 'gh', 'docker', 'docker', 'docker'],
['cosign', 'gh', 'gh', 'gh', 'gh', 'docker', 'docker', 'docker'],
);
assert.deepEqual(
calls.map(({ tokenPresent }) => tokenPresent),
[false, true, true, true, false, false, false],
[false, true, true, true, true, false, false, false],
);
assert.deepEqual(calls[0].args, [
'verify',
@@ -150,7 +151,7 @@ test('runs the immutable release ceremony and writes only digest-level evidence'
'https://token.actions.githubusercontent.com',
image,
]);
for (const call of calls.slice(1, 4)) {
for (const call of calls.slice(1, 5)) {
for (const required of [
'attestation',
'verify',
@@ -176,7 +177,12 @@ test('runs the immutable release ceremony and writes only digest-level evidence'
'https://qinglong.dev/attestations/image-os-vulnerability/v1',
),
);
const dockerRun = calls[6].args;
assert.ok(
calls[4].args.includes(
'https://qinglong.dev/attestations/release-candidate-contract/v1',
),
);
const dockerRun = calls[7].args;
for (const required of [
'--read-only',
'none',
@@ -64,6 +64,7 @@ function validReport() {
['provenance_attestation', 'gh'],
['cyclonedx_sbom_attestation', 'gh'],
['os_vulnerability_attestation', 'gh'],
['release_candidate_attestation', 'gh'],
['immutable_image_pull', 'docker'],
['local_digest_inspection', 'docker'],
['embedded_evidence_verifier', 'docker'],
@@ -89,6 +90,7 @@ function validReport() {
provenance: true,
cyclonedxSbom: true,
osVulnerabilityEvidence: true,
releaseCandidateContract: true,
imagePulled: true,
localRepoDigestBound: true,
embeddedEvidenceVerifier: true,
@@ -116,9 +118,9 @@ function validReport() {
name,
tool,
executableSha256: toolDigest[tool],
argvSha256: `sha256:${'56789ab'[index].repeat(64)}`,
stdoutBytes: index === 6 ? 400 : 0,
stdoutSha256: index === 6 ? `sha256:${'d'.repeat(64)}` : emptyDigest,
argvSha256: `sha256:${'56789abc'[index].repeat(64)}`,
stdoutBytes: index === 7 ? 400 : 0,
stdoutSha256: index === 7 ? `sha256:${'d'.repeat(64)}` : emptyDigest,
stderrBytes: 0,
stderrSha256: emptyDigest,
exitCode: 0,
@@ -166,7 +168,7 @@ test('accepts a canonical digest-bound ceremony report without replay claims', (
compatible: true,
reportContentDigest: report.contentDigest,
releaseImage: image,
verificationSteps: 7,
verificationSteps: 8,
externalResults: 'not_replayed',
actionAuthority: 'none',
});
@@ -187,7 +189,7 @@ test('accepts a canonical digest-bound ceremony report without replay claims', (
compatible: true,
reportContentDigest: report.contentDigest,
releaseImage: image,
verificationSteps: 7,
verificationSteps: 8,
externalResults: 'not_replayed',
actionAuthority: 'none',
});
@@ -205,7 +207,7 @@ test('rejects structural claim widening even after the report is re-digested', (
report.isolation.network = 'default';
},
(report) => {
report.steps[6].stderrBytes = 1;
report.steps[7].stderrBytes = 1;
},
(report) => {
report.secret = 'must-not-be-accepted';
+77 -19
View File
@@ -23,7 +23,7 @@ const releaseSource = fs.readFileSync(
test('accepts the reviewed native CI and digest release contracts', () => {
assert.deepEqual(auditClusterImageRelease(root), {
ci: {
images: ['control', 'control-ai', 'admin', 'local'],
images: ['control', 'control-ai', 'admin', 'local', 'worker'],
nativeArchitectures: ['amd64', 'arm64'],
runtimeInventory: true,
clusterAdminProductFacade: true,
@@ -40,6 +40,14 @@ test('accepts the reviewed native CI and digest release contracts', () => {
},
release: {
trigger: 'explicit protected v3 tag dispatch',
releaseCandidateContract: {
scopes: ['local', 'cluster', 'all'],
workspacePackages: 18,
sourceDerived: true,
digestAttested: true,
localClusterEvidenceRequired: false,
clusterPrivateEvidenceRequired: true,
},
workerManagementEvidence: {
sourceAware: true,
privateEphemeralRunner: true,
@@ -65,11 +73,16 @@ test('accepts the reviewed native CI and digest release contracts', () => {
immutableArtifactRetentionDays: 1,
attestedToPublishedDigest: true,
},
images: ['control', 'control-ai', 'admin', 'local'],
images: ['control', 'control-ai', 'admin', 'worker', 'local'],
platforms: ['linux/amd64', 'linux/arm64'],
keylessSignature: true,
buildkitAttestations: ['sbom', 'provenance'],
githubAttestations: ['provenance', 'sbom', 'os-vulnerability'],
githubAttestations: [
'provenance',
'sbom',
'os-vulnerability',
'release-candidate',
],
publication: {
copier: 'regctl@0.11.5',
copierSha256:
@@ -85,6 +98,7 @@ test('accepts the reviewed native CI and digest release contracts', () => {
'provenance',
'cyclonedx',
'os-vulnerability',
'release-candidate',
'release-tags',
],
},
@@ -175,6 +189,17 @@ test('rejects removal of the native cluster-admin image gate', () => {
);
});
test('rejects removal of the native Worker image gate', () => {
const mutated = ciSource.replace(
' image: worker\n repository: qinglong3-worker\n runtime_user: 65532:65532',
' image: worker-disabled\n repository: qinglong3-worker\n runtime_user: 65532:65532',
);
assert.throws(
() => auditClusterImageCiWorkflow(mutated),
/matrices must contain only exact/,
);
});
test('rejects an additional unreviewed CI image authority', () => {
const mutated = ciSource.replace(
' target: runtime\n steps:',
@@ -245,7 +270,7 @@ test('rejects release publication without the private evidence dependency', () =
);
assert.throws(
() => auditReleaseWorkflow(mutated),
/depend on both protected ephemeral private evidence jobs/,
/always require candidate and OS gates/,
);
});
@@ -256,7 +281,7 @@ test('rejects release publication without the OS vulnerability dependency', () =
);
assert.throws(
() => auditReleaseWorkflow(mutated),
/depend on both protected ephemeral private evidence jobs/,
/always require candidate and OS gates/,
);
});
@@ -267,7 +292,7 @@ test('rejects release publication without current disaster-recovery evidence', (
);
assert.throws(
() => auditReleaseWorkflow(mutated),
/depend on both protected ephemeral private evidence jobs/,
/always require candidate and OS gates/,
);
});
@@ -289,12 +314,12 @@ test('rejects disaster-recovery evidence detached from the release source', () =
test('rejects an incomplete native OS vulnerability architecture matrix', () => {
const mutated = releaseSource.replace(
' - image: local\n runner: ubuntu-24.04-arm\n node_arch: arm64\n image_arch: arm64\n dockerfile: deploy/containers/ql3-local-application/Dockerfile',
' - image: local\n runner: ubuntu-24.04-arm\n node_arch: arm64\n image_arch: disabled\n dockerfile: deploy/containers/ql3-local-application/Dockerfile',
'include: ${{ fromJSON(needs.release-candidate.outputs.os-matrix) }}',
'include: []',
);
assert.throws(
() => auditReleaseWorkflow(mutated),
/OS vulnerability matrix must scan exact/,
/OS vulnerability matrix must come from/,
);
});
@@ -349,7 +374,7 @@ test('rejects a reusable private evidence runner', () => {
);
assert.throws(
() => auditReleaseWorkflow(mutated),
/protected ephemeral private evidence job/,
/always require candidate and OS gates/,
);
});
@@ -404,34 +429,34 @@ test('rejects a release missing application SBOM attestation', () => {
test('rejects a release missing the independent admin image', () => {
const mutated = releaseSource.replace(
'- image: admin\n repository: qinglong3-cluster-admin\n runtime_root: deploy/containers/ql3-cluster-admin/runtime-dependencies',
'- image: admin-disabled\n repository: qinglong3-cluster-admin\n runtime_root: deploy/containers/ql3-cluster-admin/runtime-dependencies',
'include: ${{ fromJSON(needs.release-candidate.outputs.publish-matrix) }}',
'include: []',
);
assert.throws(
() => auditReleaseWorkflow(mutated),
/matrix must contain only exact/,
/matrix must come only from/,
);
});
test('rejects a release missing the AI-excluded local image', () => {
const mutated = releaseSource.replace(
'- image: local\n repository: qinglong3-local-application\n runtime_root: deploy/containers/ql3-local-application/runtime-dependencies',
'- image: local-disabled\n repository: qinglong3-local-application\n runtime_root: deploy/containers/ql3-local-application/runtime-dependencies',
' - local\n - cluster\n - all',
' - cluster\n - all',
);
assert.throws(
() => auditReleaseWorkflow(mutated),
/matrix must contain only exact/,
/closed local, cluster or all/,
);
});
test('rejects an additional repository in the privileged release matrix', () => {
const mutated = releaseSource.replace(
' runtime_root: deploy/containers/ql3-local-application/runtime-dependencies\n steps:',
' runtime_root: deploy/containers/ql3-local-application/runtime-dependencies\n - image: unreviewed\n repository: unreviewed\n dockerfile: Dockerfile\n runtime_root: unreviewed\n steps:',
'include: ${{ fromJSON(needs.release-candidate.outputs.publish-matrix) }}',
'include:\n - image: unreviewed\n repository: unreviewed\n runtime_root: unreviewed',
);
assert.throws(
() => auditReleaseWorkflow(mutated),
/matrix must contain only exact/,
/matrix must come only from/,
);
});
@@ -639,3 +664,36 @@ test('rejects removal of the digest-bound OS vulnerability attestation', () => {
/digest-bound OS vulnerability evidence/,
);
});
test('rejects removal of the digest-bound release candidate attestation', () => {
const mutated = releaseSource.replace(
'predicate-type: https://qinglong.dev/attestations/release-candidate-contract/v1',
'predicate-type: https://example.invalid/not-a-candidate-contract',
);
assert.throws(
() => auditReleaseWorkflow(mutated),
/source-derived release candidate contract/,
);
});
test('rejects treating skipped private evidence as cluster success', () => {
const mutated = releaseSource.replace(
"needs.release-candidate.outputs.cluster-evidence-required != 'true' ||",
"needs.release-candidate.outputs.cluster-evidence-required == 'true' ||",
);
assert.throws(
() => auditReleaseWorkflow(mutated),
/requiring private HA evidence only for a cluster family/,
);
});
test('rejects a publisher matrix detached from the source-derived contract', () => {
const mutated = releaseSource.replace(
'include: ${{ fromJSON(needs.release-candidate.outputs.publish-matrix) }}',
'include: ${{ fromJSON(inputs.publish_matrix) }}',
);
assert.throws(
() => auditReleaseWorkflow(mutated),
/matrix must come only from/,
);
});
+28
View File
@@ -124,6 +124,34 @@ test('generates the AI-excluded local application image closure', () => {
);
});
test('generates the headless Worker image runtime closure', () => {
const document = createClusterImageSbom({ root, image: 'worker' });
const report = auditClusterImageSbom(document, {
root,
image: 'worker',
});
assert.deepEqual(report, {
image: 'worker',
root: 'pkg:npm/%40qinglong/worker-image-dependencies@3.0.0-alpha.0',
components: 27,
externalComponents: 24,
internalComponents: 3,
dependencyNodes: 28,
inventoryVerified: false,
});
assert.equal(
document.components.some(
(component) => component.name === '@qinglong/worker-runtime',
),
true,
);
assert.equal(
document.components.some((component) => component.name === '@qinglong/ai'),
false,
);
});
test('rejects a control SBOM presented as cluster-admin evidence', () => {
const document = createClusterImageSbom({ root });
assert.throws(
+31 -2
View File
@@ -25,6 +25,7 @@ function createFixture(t, options = {}) {
const isControl = image === 'control' || image === 'control-ai';
const isControlAi = image === 'control-ai';
const isLocal = image === 'local';
const isWorker = image === 'worker';
const layoutRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-oci-layout-'));
t.after(() => fs.rmSync(layoutRoot, { recursive: true, force: true }));
const blobDirectory = path.join(layoutRoot, 'blobs', 'sha256');
@@ -96,6 +97,8 @@ function createFixture(t, options = {}) {
User:
options.rootArm64 && architecture === 'arm64'
? '0:0'
: isWorker
? '65532:65532'
: isLocal
? '65532:65532'
: '10001:10001',
@@ -108,7 +111,9 @@ function createFixture(t, options = {}) {
],
Entrypoint: [
'node',
isLocal
isWorker
? '/opt/qinglong/node_modules/@qinglong/worker-runtime/dist/process/workerProcessCli.js'
: isLocal
? '/opt/qinglong/node_modules/@qinglong/local-application/dist/cli.js'
: isControl
? isControlAi
@@ -118,6 +123,12 @@ function createFixture(t, options = {}) {
],
WorkingDir: '/opt/qinglong',
Labels: {
...(isWorker
? {
'io.qinglong.profile': 'worker',
'io.qinglong.worker.capacity-profiles': 'edge,node',
}
: {}),
...(isLocal
? {
'io.qinglong.ai': 'excluded',
@@ -131,6 +142,8 @@ function createFixture(t, options = {}) {
: {}),
'org.opencontainers.image.description': isLocal
? 'QingLong 3.0 AI-excluded Edge and Standalone runtime'
: isWorker
? 'QingLong 3.0 headless Remote Worker runtime'
: isControl
? isControlAi
? 'Optional QingLong 3.0 AI-enabled cluster control plane'
@@ -142,12 +155,14 @@ function createFixture(t, options = {}) {
'https://github.com/whyour/qinglong',
'org.opencontainers.image.title': isLocal
? 'QingLong 3.0 Local Application'
: isWorker
? 'QingLong 3.0 Worker'
: isControl
? isControlAi
? 'QingLong 3.0 Cluster Control AI'
: 'QingLong 3.0 Cluster Control'
: 'QingLong 3.0 Cluster Admin',
...(isLocal
...(isLocal || isWorker || isControl || image === 'admin'
? {
'org.opencontainers.image.version': '3.0.0-alpha.0',
}
@@ -349,6 +364,20 @@ test('accepts the AI-excluded local image and attestation closure', (t) => {
);
});
test('accepts the headless Worker image and attestation closure', (t) => {
const report = auditClusterOciLayout({
root,
layoutRoot: createFixture(t, { image: 'worker' }),
expectedRevision: revision,
image: 'worker',
});
assert.equal(report.image, 'worker');
assert.deepEqual(
report.platforms.map((platform) => platform.platform),
['linux/amd64', 'linux/arm64'],
);
});
test('rejects cluster-control config presented as cluster-admin evidence', (t) => {
assert.throws(
() =>
@@ -25,7 +25,8 @@ function exception(overrides = {}) {
owner: 'security/platform',
ticket: 'QLSEC-123',
expiresOn: '2026-08-15',
rationale: 'Temporary exposure accepted while the fixed base image is qualified.',
rationale:
'Temporary exposure accepted while the fixed base image is qualified.',
...overrides,
};
}
@@ -55,6 +56,7 @@ test('accepts the empty fail-closed production exception policy', () => {
control: 0,
'control-ai': 0,
local: 0,
worker: 0,
},
});
assert.equal(
@@ -132,10 +134,7 @@ test('rejects unscoped images and non-OS package purls', () => {
test('rejects duplicate, unsorted and extensible exception identities', () => {
for (const exceptions of [
[exception(), exception()],
[
exception({ id: 'CVE-2026-99999' }),
exception({ id: 'CVE-2026-12345' }),
],
[exception({ id: 'CVE-2026-99999' }), exception({ id: 'CVE-2026-12345' })],
[{ ...exception(), extra: true }],
]) {
const audit = auditImageOsVulnerabilityPolicy(policy(exceptions), {
@@ -144,8 +143,7 @@ test('rejects duplicate, unsorted and extensible exception identities', () => {
assert.equal(audit.compatible, false);
assert.equal(
audit.findings.some(
(finding) =>
finding.code === 'QL3_IMAGE_OS_VULNERABILITY_EXCEPTION_ID',
(finding) => finding.code === 'QL3_IMAGE_OS_VULNERABILITY_EXCEPTION_ID',
),
true,
);
@@ -0,0 +1,202 @@
'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 {
auditReleaseCandidateContract,
createReleaseCandidateContract,
parseArguments,
runCli,
} = require('../../scripts/ql3-release-candidate-contract.cjs');
const root = path.resolve(__dirname, '../..');
const identity = Object.freeze({
version: '3.0.0-alpha.0',
sourceRevision: 'a'.repeat(40),
sourceRef: 'refs/tags/v3.0.0-alpha.0',
});
test('freezes an independent low-resource local release family', () => {
const contract = createReleaseCandidateContract({
root,
...identity,
releaseScope: 'local',
});
assert.deepEqual(
contract.images.map((entry) => entry.image),
['local'],
);
assert.equal(contract.releasePlan.clusterEvidenceRequired, false);
assert.deepEqual(contract.deploymentFamilies.local.profiles, [
'edge',
'standalone',
]);
assert.equal(contract.workspace.packageCount, 18);
assert.match(contract.contractDigest, /^sha256:[a-f0-9]{64}$/u);
assert.deepEqual(
auditReleaseCandidateContract(contract, {
root,
...identity,
releaseScope: 'local',
}),
{
compatible: true,
contractDigest: contract.contractDigest,
releaseScope: 'local',
workspacePackageCount: 18,
images: ['local'],
clusterEvidenceRequired: false,
},
);
});
test('closes the cluster release family with the Worker image', () => {
const contract = createReleaseCandidateContract({
root,
...identity,
releaseScope: 'cluster',
});
assert.deepEqual(
contract.images.map((entry) => entry.image),
['control', 'control-ai', 'admin', 'worker'],
);
assert.equal(contract.releasePlan.clusterEvidenceRequired, true);
assert.equal(contract.releasePlan.osMatrix.length, 8);
assert.equal(
contract.requiredGates.includes('edge-and-standalone-rollout'),
false,
);
assert.equal(
contract.requiredGates.includes('cloudnativepg-disaster-recovery-evidence'),
true,
);
});
test('combines local and cluster families without weakening either gate', () => {
const contract = createReleaseCandidateContract({
root,
...identity,
releaseScope: 'all',
});
assert.deepEqual(
contract.images.map((entry) => entry.image),
['control', 'control-ai', 'admin', 'worker', 'local'],
);
assert.equal(
contract.requiredGates.includes('edge-and-standalone-rollout'),
true,
);
assert.equal(
contract.requiredGates.includes('worker-management-production-evidence'),
true,
);
});
test('rejects tag, version and source identity drift', () => {
assert.throws(
() =>
createReleaseCandidateContract({
root,
...identity,
sourceRef: 'refs/heads/next',
releaseScope: 'local',
}),
/exact version tag/,
);
assert.throws(
() =>
createReleaseCandidateContract({
root,
...identity,
version: '2.21.0',
releaseScope: 'local',
}),
/QingLong 3 SemVer/,
);
assert.throws(
() =>
createReleaseCandidateContract({
root,
...identity,
sourceRevision: 'movable',
releaseScope: 'local',
}),
/Git SHA-1/,
);
});
test('rejects a source-derived report mutated after creation', () => {
const contract = createReleaseCandidateContract({
root,
...identity,
releaseScope: 'local',
});
contract.releasePlan.publishMatrix[0].repository = 'unreviewed';
assert.throws(
() =>
auditReleaseCandidateContract(contract, {
root,
...identity,
releaseScope: 'local',
}),
/differs from the source-derived contract/,
);
});
test('writes once and independently audits the exact report through the CLI', (t) => {
const directory = fs.realpathSync(
fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-candidate-')),
);
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
const report = path.join(directory, 'contract.json');
const common = [
'--version=3.0.0-alpha.0',
`--source-revision=${identity.sourceRevision}`,
'--source-ref=refs/tags/v3.0.0-alpha.0',
'--release-scope=local',
];
const output = { write() {} };
runCli(['--mode=create', ...common, `--output=${report}`], root, output);
assert.equal(fs.statSync(report).mode & 0o777, 0o600);
assert.equal(
runCli(['--mode=audit', ...common, `--report=${report}`], root, output)
.compatible,
true,
);
assert.throws(
() =>
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',
`--source-revision=${identity.sourceRevision}`,
'--source-ref=refs/tags/v3.0.0-alpha.0',
'--release-scope=local',
];
assert.equal(
parseArguments(['--mode=create', ...common, '--output=/tmp/report.json'])
.mode,
'create',
);
assert.throws(
() =>
parseArguments([
'--mode=create',
...common,
'--output=/tmp/report.json',
'--extra=true',
]),
/arguments are invalid/,
);
});