Replace `^[0-9]+\.[0-9]+\.[1-9][0-9]*$` with `^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.[1-9][0-9]*$` in the hotfix branch of the validate-version step, consistent with the two sibling patterns already using the strict (0|[1-9][0-9]*) guard. Adds regression assertions to tests/adr-218-release-version-validation.test.cjs that prove `01.2.3` and `1.02.3` are rejected. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
477 lines
18 KiB
JavaScript
477 lines
18 KiB
JavaScript
'use strict';
|
|
|
|
/**
|
|
* ADR-218 regression guard: release-workflow version validation.
|
|
*
|
|
* (A) Behavioral regex coverage — extracts the actual leading-zero-rejection
|
|
* regex strings from .github/workflows/release.yml at test time, compiles
|
|
* them as RegExp, and asserts boundary behavior. If someone weakens the
|
|
* regex (e.g. back to [0-9]+), these assertions go RED.
|
|
*
|
|
* (B) Structural wiring assertions — confirms the validate-version job exists,
|
|
* the npm duplicate-version pre-check step is present, and that all
|
|
* downstream publish/create jobs declare `needs: validate-version`.
|
|
*/
|
|
|
|
const { test, describe } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
|
|
const WORKFLOW_PATH = process.env.ADR218_WORKFLOW_PATH
|
|
|| path.join(__dirname, '..', '.github', 'workflows', 'release.yml');
|
|
|
|
function loadWorkflow() {
|
|
assert.ok(
|
|
fs.existsSync(WORKFLOW_PATH),
|
|
`release.yml not found at ${WORKFLOW_PATH} — file moved or deleted?`
|
|
);
|
|
return fs.readFileSync(WORKFLOW_PATH, 'utf8');
|
|
}
|
|
|
|
/**
|
|
* Extract all grep -qE '...' or grep -qE "..." patterns from a bash block.
|
|
* Returns an array of raw regex strings (the content inside the quotes).
|
|
*/
|
|
function extractGrepPatterns(text) {
|
|
// Match: grep -qE '...' or grep -qE "..."
|
|
const re = /grep\s+-qE\s+(?:'([^']+)'|"([^"]+)")/g;
|
|
const found = [];
|
|
let m;
|
|
while ((m = re.exec(text)) !== null) {
|
|
found.push(m[1] !== undefined ? m[1] : m[2]);
|
|
}
|
|
return found;
|
|
}
|
|
|
|
/**
|
|
* Parse the `needs:` list of a GitHub Actions job.
|
|
*
|
|
* @param {string} src - Full YAML source text.
|
|
* @param {number} jobIdx - Index of the '\n <job-name>:\n' match in src.
|
|
* @returns {string[] | null} Array of job-name strings listed under `needs:`,
|
|
* or null if no `needs:` key was found for the job.
|
|
*
|
|
* Strategy: slice from jobIdx to the next top-level (two-space-indented) job
|
|
* header, then scan that segment for the `needs:` key. The key accepts two
|
|
* YAML forms:
|
|
* needs: validate-version (scalar)
|
|
* needs: [validate-version, install-smoke] (flow sequence)
|
|
* needs: (block sequence)
|
|
* - validate-version
|
|
* - install-smoke
|
|
*
|
|
* This deliberately only inspects the `needs:` line and its immediate list
|
|
* items — it will NOT match "validate-version" appearing later in a step
|
|
* expression such as `${{ needs.validate-version.outputs.branch }}`.
|
|
*/
|
|
function parseJobNeeds(src, jobIdx) {
|
|
// Isolate the job's YAML block: from the job header to the next top-level
|
|
// job (same two-space indentation) or end of file.
|
|
const rest = src.slice(jobIdx + 1); // skip the leading newline of the match
|
|
// Next top-level job starts with "\n <word>:\n" at column 2
|
|
const nextJobMatch = rest.match(/\n {2}[a-z][a-z0-9_-]*:\n/);
|
|
const segment = nextJobMatch
|
|
? rest.slice(0, rest.indexOf(nextJobMatch[0]) + 1)
|
|
: rest;
|
|
|
|
// Match the `needs:` line within the segment
|
|
const needsLineMatch = segment.match(/^ {4}needs:\s*(.*)$/m);
|
|
if (!needsLineMatch) return null;
|
|
|
|
const inline = needsLineMatch[1].trim();
|
|
|
|
if (inline === '') {
|
|
// Block sequence form: lines following `needs:` at deeper indent
|
|
// needs:
|
|
// - job-one
|
|
// - job-two
|
|
const blockItems = [];
|
|
const blockRe = /^ {6}- ([a-z][a-z0-9_-]*)$/gm;
|
|
// Only scan text after the `needs:` line
|
|
const afterNeeds = segment.slice(needsLineMatch.index + needsLineMatch[0].length);
|
|
let bm;
|
|
while ((bm = blockRe.exec(afterNeeds)) !== null) {
|
|
blockItems.push(bm[1]);
|
|
}
|
|
return blockItems.length > 0 ? blockItems : null;
|
|
}
|
|
|
|
if (inline.startsWith('[')) {
|
|
// Flow sequence form: needs: [job-one, job-two]
|
|
const inner = inline.replace(/^\[|\]$/g, '');
|
|
return inner.split(',').map(s => s.trim()).filter(Boolean);
|
|
}
|
|
|
|
// Scalar form: needs: single-job
|
|
return [inline];
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// (A) Behavioral regex tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('ADR-218 — leading-zero rejection regex (behavioral)', () => {
|
|
|
|
test('release.yml contains at least two leading-zero-aware grep patterns', () => {
|
|
const src = loadWorkflow();
|
|
const patterns = extractGrepPatterns(src);
|
|
assert.ok(
|
|
patterns.length >= 2,
|
|
`Expected at least 2 grep -qE patterns in release.yml, found ${patterns.length}: ${JSON.stringify(patterns)}`
|
|
);
|
|
});
|
|
|
|
test('minor/major pattern (X.Y.0) rejects leading zeros and accepts valid versions', () => {
|
|
const src = loadWorkflow();
|
|
const patterns = extractGrepPatterns(src);
|
|
|
|
// The minor/major pattern must match X.Y.0 (not hotfix) and must be the
|
|
// one that guards the release branch decision. We identify it as the first
|
|
// pattern that matches `1.0.0` AND `0.1.0` AND `10.20.0`.
|
|
const minorMajorPatterns = patterns.filter(p => {
|
|
const re = new RegExp(p);
|
|
return re.test('1.0.0') && re.test('0.1.0') && re.test('10.20.0');
|
|
});
|
|
|
|
assert.ok(
|
|
minorMajorPatterns.length >= 1,
|
|
`Could not locate the minor/major (X.Y.0) grep pattern in release.yml.\n` +
|
|
`Extracted patterns: ${JSON.stringify(patterns)}\n` +
|
|
`If the pattern was relocated or renamed, update this test to match.`
|
|
);
|
|
|
|
const re = new RegExp(minorMajorPatterns[0]);
|
|
|
|
// Boundary table: REJECTED (leading zeros or malformed)
|
|
const shouldReject = [
|
|
'1.01.0', // leading zero in minor
|
|
'01.0.0', // leading zero in major
|
|
'1.1.01', // leading zero in patch (also not X.Y.0 form)
|
|
'00.0.0', // double leading zero in major
|
|
'1.00.0', // double leading zero in minor
|
|
];
|
|
for (const v of shouldReject) {
|
|
assert.equal(
|
|
re.test(v), false,
|
|
`Version "${v}" should be REJECTED by the minor/major pattern but was accepted.\n` +
|
|
`Pattern: ${minorMajorPatterns[0]}\n` +
|
|
`This is the ADR-218 leading-zero regression. Restore (0|[1-9][0-9]*) grouping.`
|
|
);
|
|
}
|
|
|
|
// Boundary table: ACCEPTED (valid semver, no leading zeros)
|
|
const shouldAccept = [
|
|
'1.0.0',
|
|
'0.1.0',
|
|
'10.20.0',
|
|
'1.2.0',
|
|
'0.0.0',
|
|
];
|
|
for (const v of shouldAccept) {
|
|
assert.equal(
|
|
re.test(v), true,
|
|
`Version "${v}" should be ACCEPTED by the minor/major pattern but was rejected.\n` +
|
|
`Pattern: ${minorMajorPatterns[0]}`
|
|
);
|
|
}
|
|
});
|
|
|
|
test('major-only sub-check (X.0.0) correctly classifies major releases', () => {
|
|
const src = loadWorkflow();
|
|
const patterns = extractGrepPatterns(src);
|
|
|
|
// The major-only pattern matches X.0.0 exactly (not X.Y.0 with Y>0).
|
|
// We identify it as the pattern that matches `1.0.0` but NOT `1.1.0`.
|
|
const majorOnlyPatterns = patterns.filter(p => {
|
|
const re = new RegExp(p);
|
|
return re.test('1.0.0') && !re.test('1.1.0');
|
|
});
|
|
|
|
assert.ok(
|
|
majorOnlyPatterns.length >= 1,
|
|
`Could not locate the major-only (X.0.0) grep pattern in release.yml.\n` +
|
|
`Extracted patterns: ${JSON.stringify(patterns)}\n` +
|
|
`ADR-218 requires IS_MAJOR detection to also forbid leading zeros.`
|
|
);
|
|
|
|
const re = new RegExp(majorOnlyPatterns[0]);
|
|
|
|
// REJECTED: leading zeros in the major segment
|
|
const shouldReject = [
|
|
'01.0.0', // leading zero in major
|
|
'00.0.0', // double leading zero
|
|
];
|
|
for (const v of shouldReject) {
|
|
assert.equal(
|
|
re.test(v), false,
|
|
`Version "${v}" should be REJECTED by the major-only pattern but was accepted.\n` +
|
|
`Pattern: ${majorOnlyPatterns[0]}\n` +
|
|
`ADR-218: IS_MAJOR check must also use (0|[1-9][0-9]*) grouping.`
|
|
);
|
|
}
|
|
|
|
// ACCEPTED: valid major versions
|
|
const shouldAccept = [
|
|
'1.0.0',
|
|
'10.0.0',
|
|
'0.0.0',
|
|
];
|
|
for (const v of shouldAccept) {
|
|
assert.equal(
|
|
re.test(v), true,
|
|
`Version "${v}" should be ACCEPTED by the major-only pattern but was rejected.\n` +
|
|
`Pattern: ${majorOnlyPatterns[0]}`
|
|
);
|
|
}
|
|
});
|
|
|
|
test('hotfix pattern (X.Y.Z, Z>0) is present and uses digit anchors', () => {
|
|
const src = loadWorkflow();
|
|
const patterns = extractGrepPatterns(src);
|
|
|
|
// The hotfix pattern matches X.Y.Z where Z > 0, e.g. `1.2.3`.
|
|
// Identify it as the pattern matching `1.2.3` but NOT `1.2.0`.
|
|
const hotfixPatterns = patterns.filter(p => {
|
|
const re = new RegExp(p);
|
|
return re.test('1.2.3') && !re.test('1.2.0');
|
|
});
|
|
|
|
assert.ok(
|
|
hotfixPatterns.length >= 1,
|
|
`Could not locate the hotfix (X.Y.Z, Z>0) grep pattern in release.yml.\n` +
|
|
`Extracted patterns: ${JSON.stringify(patterns)}\n` +
|
|
`Expected a pattern matching 1.2.3 but not 1.2.0.`
|
|
);
|
|
|
|
const re = new RegExp(hotfixPatterns[0]);
|
|
|
|
// Sanity: valid hotfix versions accepted
|
|
assert.equal(re.test('1.2.3'), true, 'Hotfix pattern must accept 1.2.3');
|
|
assert.equal(re.test('1.0.1'), true, 'Hotfix pattern must accept 1.0.1');
|
|
assert.equal(re.test('10.20.30'), true, 'Hotfix pattern must accept 10.20.30');
|
|
|
|
// Patch = 0 must be rejected (that is the minor/major form)
|
|
assert.equal(re.test('1.2.0'), false, 'Hotfix pattern must not match X.Y.0 (Z must be >0)');
|
|
|
|
// ADR-218 / #1186: hotfix pattern must also reject leading zeros on major
|
|
// and minor segments. The old `[0-9]+` form allowed e.g. `01.2.3` and
|
|
// `1.02.3`. The corrected pattern uses `(0|[1-9][0-9]*)` for both.
|
|
const shouldRejectLeadingZero = [
|
|
'01.2.3', // leading zero in major
|
|
'1.02.3', // leading zero in minor
|
|
];
|
|
for (const v of shouldRejectLeadingZero) {
|
|
assert.equal(
|
|
re.test(v), false,
|
|
`Hotfix version "${v}" should be REJECTED (leading zero) but was accepted.\n` +
|
|
`Pattern: ${hotfixPatterns[0]}\n` +
|
|
`ADR-218 / #1186: restore (0|[1-9][0-9]*) on major and minor segments.`
|
|
);
|
|
}
|
|
});
|
|
|
|
test('extracted patterns use strict leading-zero guard, not the old [0-9]+ form', () => {
|
|
const src = loadWorkflow();
|
|
const patterns = extractGrepPatterns(src);
|
|
|
|
// ADR-218 Decision #1: the X.Y.0 (minor/major) pattern and the X.0.0
|
|
// (major-only) pattern MUST guard their major and minor segments with
|
|
// (0|[1-9][0-9]*), not the old bare [0-9]+ form.
|
|
//
|
|
// The hotfix pattern contains "[1-9][0-9]*" for its PATCH segment, so
|
|
// testing .some(p => p.includes('[1-9][0-9]*')) would still pass even if
|
|
// the major/minor patterns were weakened back to [0-9]+. We therefore
|
|
// assert specifically against the patterns that guard major/minor segments.
|
|
|
|
// Identify the minor/major pattern: matches X.Y.0 (both 1.0.0 and 1.1.0)
|
|
const minorMajorPatterns = patterns.filter(p => {
|
|
const re = new RegExp(p);
|
|
return re.test('1.0.0') && re.test('1.1.0') && re.test('10.20.0');
|
|
});
|
|
|
|
assert.ok(
|
|
minorMajorPatterns.length >= 1,
|
|
`Could not locate the minor/major (X.Y.0) grep pattern in release.yml.\n` +
|
|
`Extracted patterns: ${JSON.stringify(patterns)}`
|
|
);
|
|
|
|
// The minor/major pattern must contain (0|[1-9][0-9]*) to guard BOTH the
|
|
// major AND minor segments. A pattern that uses [0-9]+ on those segments
|
|
// would allow "01.0.0" or "1.01.0" — the ADR-218 regression.
|
|
assert.ok(
|
|
minorMajorPatterns[0].includes('(0|[1-9][0-9]*)'),
|
|
`The minor/major (X.Y.0) grep pattern does NOT contain the strict ` +
|
|
`"(0|[1-9][0-9]*)" guard required by ADR-218 Decision #1.\n` +
|
|
`Pattern found: ${minorMajorPatterns[0]}\n` +
|
|
`This is the leading-zero regression. Restore (0|[1-9][0-9]*) grouping ` +
|
|
`on every major/minor segment.`
|
|
);
|
|
|
|
// Identify the major-only pattern: matches X.0.0 but NOT X.Y.0 with Y>0
|
|
const majorOnlyPatterns = patterns.filter(p => {
|
|
const re = new RegExp(p);
|
|
return re.test('1.0.0') && !re.test('1.1.0');
|
|
});
|
|
|
|
assert.ok(
|
|
majorOnlyPatterns.length >= 1,
|
|
`Could not locate the major-only (X.0.0) grep pattern in release.yml.\n` +
|
|
`Extracted patterns: ${JSON.stringify(patterns)}`
|
|
);
|
|
|
|
// The major-only pattern must also contain (0|[1-9][0-9]*) to guard the
|
|
// major segment.
|
|
assert.ok(
|
|
majorOnlyPatterns[0].includes('(0|[1-9][0-9]*)'),
|
|
`The major-only (X.0.0) grep pattern does NOT contain the strict ` +
|
|
`"(0|[1-9][0-9]*)" guard required by ADR-218 Decision #1.\n` +
|
|
`Pattern found: ${majorOnlyPatterns[0]}\n` +
|
|
`ADR-218: IS_MAJOR check must also use (0|[1-9][0-9]*) grouping.`
|
|
);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// (B) Structural / wiring assertions
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('ADR-218 — structural wiring of release.yml', () => {
|
|
|
|
test('validate-version job exists in release.yml', () => {
|
|
const src = loadWorkflow();
|
|
assert.ok(
|
|
src.includes('validate-version:'),
|
|
'release.yml must define a `validate-version:` job (ADR-218 requires it as the gate)'
|
|
);
|
|
});
|
|
|
|
test('npm duplicate-version pre-check step is present', () => {
|
|
const src = loadWorkflow();
|
|
// Assert both the "Reject already-published versions" step name and
|
|
// the npm view command exist in the file.
|
|
assert.ok(
|
|
src.includes('Reject already-published versions'),
|
|
'release.yml must contain a step named "Reject already-published versions" (ADR-218 Decision #2)'
|
|
);
|
|
assert.ok(
|
|
src.includes('npm view'),
|
|
'release.yml must contain an `npm view` call for duplicate-version pre-check (ADR-218 Decision #2)'
|
|
);
|
|
});
|
|
|
|
test('npm duplicate-check step appears AFTER format validation step within validate-version job', () => {
|
|
const src = loadWorkflow();
|
|
|
|
const formatIdx = src.indexOf('Validate version format');
|
|
const dupCheckIdx = src.indexOf('Reject already-published versions');
|
|
|
|
assert.ok(
|
|
formatIdx !== -1,
|
|
'Could not find "Validate version format" step in release.yml'
|
|
);
|
|
assert.ok(
|
|
dupCheckIdx !== -1,
|
|
'Could not find "Reject already-published versions" step in release.yml'
|
|
);
|
|
assert.ok(
|
|
dupCheckIdx > formatIdx,
|
|
`"Reject already-published versions" (offset ${dupCheckIdx}) must appear AFTER ` +
|
|
`"Validate version format" (offset ${formatIdx}) in release.yml.\n` +
|
|
`Format validation must gate before the npm pre-check.`
|
|
);
|
|
});
|
|
|
|
test('create job declares needs: validate-version', () => {
|
|
const src = loadWorkflow();
|
|
// Check that between "create:" and the next top-level job, "needs: validate-version" appears.
|
|
const createJobIdx = src.indexOf('\n create:\n');
|
|
assert.ok(createJobIdx !== -1, 'release.yml must have a `create:` job');
|
|
|
|
// Find the segment from create: to the next job header
|
|
const afterCreate = src.slice(createJobIdx);
|
|
const nextJobMatch = afterCreate.match(/\n {2}[a-z][a-z-]+:\n/g);
|
|
const createSegment = nextJobMatch && nextJobMatch.length > 1
|
|
? afterCreate.slice(0, afterCreate.indexOf(nextJobMatch[1]))
|
|
: afterCreate;
|
|
|
|
assert.ok(
|
|
createSegment.includes('needs: validate-version') || createSegment.includes('needs: [validate-version'),
|
|
'The `create` job must declare `needs: validate-version` to ensure validation runs first (ADR-218)'
|
|
);
|
|
});
|
|
|
|
test('rc job declares needs including validate-version', () => {
|
|
const src = loadWorkflow();
|
|
const rcJobIdx = src.indexOf('\n rc:\n');
|
|
assert.ok(rcJobIdx !== -1, 'release.yml must have an `rc:` job');
|
|
|
|
// Parse the `needs:` list from the rc job header region. We look for the
|
|
// `needs:` key in the lines immediately following the job header, stopping
|
|
// at the first non-indented (top-level) keyword. This avoids false passes
|
|
// where "validate-version" only appears inside a step expression such as
|
|
// `${{ needs.validate-version.outputs.branch }}` but is absent from the
|
|
// actual `needs:` dependency declaration.
|
|
const needsList = parseJobNeeds(src, rcJobIdx);
|
|
assert.ok(
|
|
needsList !== null,
|
|
'Could not locate a `needs:` declaration in the `rc` job of release.yml'
|
|
);
|
|
assert.ok(
|
|
needsList.includes('validate-version'),
|
|
`The \`rc\` job must list validate-version as a member of its \`needs:\` ` +
|
|
`(ADR-218 gate must run before rc).\n` +
|
|
`Parsed needs list: ${JSON.stringify(needsList)}`
|
|
);
|
|
});
|
|
|
|
test('finalize job declares needs including validate-version', () => {
|
|
const src = loadWorkflow();
|
|
const finalizeIdx = src.indexOf('\n finalize:\n');
|
|
assert.ok(finalizeIdx !== -1, 'release.yml must have a `finalize:` job');
|
|
|
|
// Same targeted parse as the rc test above.
|
|
const needsList = parseJobNeeds(src, finalizeIdx);
|
|
assert.ok(
|
|
needsList !== null,
|
|
'Could not locate a `needs:` declaration in the `finalize` job of release.yml'
|
|
);
|
|
assert.ok(
|
|
needsList.includes('validate-version'),
|
|
`The \`finalize\` job must list validate-version as a member of its \`needs:\` ` +
|
|
`(ADR-218 gate must run before finalize).\n` +
|
|
`Parsed needs list: ${JSON.stringify(needsList)}`
|
|
);
|
|
});
|
|
|
|
test('validate-version job appears before create/rc/finalize jobs in file', () => {
|
|
const src = loadWorkflow();
|
|
|
|
const validateIdx = src.indexOf('\n validate-version:\n');
|
|
const createIdx = src.indexOf('\n create:\n');
|
|
const rcIdx = src.indexOf('\n rc:\n');
|
|
const finalizeIdx = src.indexOf('\n finalize:\n');
|
|
|
|
assert.ok(validateIdx !== -1, 'validate-version job must be defined');
|
|
|
|
if (createIdx !== -1) {
|
|
assert.ok(
|
|
validateIdx < createIdx,
|
|
'validate-version must be declared before the create job in release.yml'
|
|
);
|
|
}
|
|
if (rcIdx !== -1) {
|
|
assert.ok(
|
|
validateIdx < rcIdx,
|
|
'validate-version must be declared before the rc job in release.yml'
|
|
);
|
|
}
|
|
if (finalizeIdx !== -1) {
|
|
assert.ok(
|
|
validateIdx < finalizeIdx,
|
|
'validate-version must be declared before the finalize job in release.yml'
|
|
);
|
|
}
|
|
});
|
|
});
|