* fix: recurse test discovery so subdir test suites actually run
scripts/run-tests.cjs discovered tests with a flat readdirSync(testDir),
silently excluding tests/observability/ (4 files), tests/dispatch/ (1) and
tests/installer-migrations/ (1) — 94 passing tests — from `npm test` and all
CI lanes. Walk the tree recursively (relative subpaths preserved), classify
suites by basename, and add a fail-on-zero-executed guard for suite/default
runs (escape hatch GSD_ALLOW_EMPTY_SUITE=1) while preserving the empty
--files/--files-from path the CI inert lane relies on.
Unit suite 735 -> 741 files; surfaces ADR-227's observability/dispatch seam.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* test: retire 5 verified-worthless tests
Adversarial verification confirmed these 5 prove nothing — their coverage is
provided more strictly elsewhere:
- enh-2790 'has a name: field' spot-checks (command-contract enforces /^gsd[:-]/)
- command-routing-hub duplicate construct + duplicate ERROR_KINDS assertions
- no-cjs-sdk-handsync-tooling (guarded files that never existed on main; bug-190
covers the real retired SDK artifacts)
- runtime-artifact-layout cline edge case (subsumed by the explicit-global test
and bug-782-cline-skills-emission)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* test: add ADR-218 release version-validation coverage
ADR-218 (reject leading-zero versions like 1.01.0; npm duplicate pre-check) had
zero tests — the logic lived only in release.yml bash. Add a test that extracts
the actual rejection regexes from the workflow and exercises them against a
boundary table (leading-zero/malformed rejected, valid accepted) plus structural
wiring assertions. Goes red if the regex is reverted to [0-9]+.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* test: redesign weak tests into behavioral, deterministic assertions
Per the ADR test audit, rewrite 27 weak test files (test-only, no source
changes) so each can go red for the defect it guards:
- kill pass-always assert.ok(true) placeholders (research-cli, worktree-baseref,
bug-260 security guard, eslint-rules x24, clusters '|| true')
- replace source-text grep with behavioral calls (install Kilo, sh-hook-paths,
plan-review-convergence) and add a repo-layout governance test
- de-flake real-clock/Math.random coupling (phase last_updated, bug-3707 mtime,
context-utilization property, feat-3594)
- fix independence/shared-state violations (bug-492 singleton, issue-844 tmpRoot,
core reapStaleTempFiles, active-workstream TTY, feat-488 GSD_HOME)
- strengthen property/shape-only tests (research-provider/store classification +
collision) and unconditional plugin.json schema validation (issue-766)
Verified: all 28 files run together 1220 pass / 0 fail / 1 skip.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* chore: add no-tautological-assert lint rule, error in test suite
New custom ESLint rule (eslint-rules/no-tautological-assert.cjs) bans asserts
that can never fail: assert(true)/assert.ok(<always-truthy literal>),
'cond || true' inside an assert, and equality asserts comparing two identical
literals. Wired as error on tests/**; full sweep confirmed zero existing
violations so the suite stays green. Prevents the placeholder-assert regressions
the audit redesigns just removed. RuleTester coverage added (6 valid, 8 invalid).
Note: no-only-tests was already enforced via eslint-plugin-no-only-tests, so no
duplicate rule was added.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* chore: gate new allow-test-rule exemptions to require an issue ref
ADR-456 requires any allow-test-rule exemption added after the ADR to carry a
tracking issue number, but nothing enforced it. New ratchet gate
(scripts/lint-allow-test-rule-refs.cjs, wired into lint:ci) fails when a NEW
allow-test-rule comment lacks a #NNN/URL reference; the 323 existing untracked
exemptions are grandfathered in an allowlist that ratchets down as they gain
refs. Red-green verified (novel untracked offender fails; compliant passes).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* docs: add ADR test-audit evidence report (#1192)
Full risk-first qa-test-architect audit of the ADR portfolio (37 ADRs + 4
platform lenses, adversarial verification of retire verdicts) that drove the
P0 discovery fix, ADR-218 coverage, 5 retires, 27 redesigns, and the two new
lint gates. Filed as point-in-time evidence under docs/issueevidence/, named
for tracking issue #1192.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* test: replace pre-existing raw NUL byte with escape in feat-3594 fixture
feat-3594's null-byte parser fixture contained a literal NUL byte (pre-existing
on next at b10e5681 — confirmed: base blob has 1 NUL, this fix has 0), which
made git treat the file as binary and would break grep/editors. Switch to the
\x00 escape; the runtime string value (a real NUL in the parser input) is
unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* test: address adversarial-review findings
Codex adversarial pass over the branch:
- capability-registry drift test no longer mutates the committed generated
capability-registry.cjs in place (concurrency hazard) — uses in-memory
checkPipeline comparison instead.
- allow-test-rule ratchet now detects exemptions in ALL comment forms (block
/* */ too, matching no-source-grep) so a block comment can't bypass it;
one newly-surfaced pre-existing offender grandfathered (323->324).
- install.test Kilo case asserts on what install(false,'kilo') actually writes
rather than manually calling configureKiloPermissions (masked the call site).
- issue-766 drops the undeclared transitive ajv dep for explicit structural
assertions from the schema fixture.
- adr-218 test notes the hotfix leading-zero gap is tracked in #1186.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix: address code-review findings (subdir discovery, rule + test gaps)
xhigh code review surfaced 15 confirmed issues, all fixed:
- run-tests.cjs --files now resolves subdir tests by bare basename + handles
Windows backslash paths (ambiguous basenames error clearly).
- affected-tests-lib.cjs listTestFiles made recursive — the targeted CI lane was
silently dropping changed subdir tests (same false-green class the audit fixed).
- no-tautological-assert now catches 'true || cond' and empty []/{} equality.
- verify-test-quality: restore provenance-classification coverage, tighten the
writeFile circular-detection check, guard the module-level file read.
- sh-hook-paths: cover the global-install .sh delegation branch (#2045 guard).
- active-workstream null-guard runs deterministically (no longer skipped on TTY).
- adr-218 structural guards tightened (major/minor leading-zero; needs: membership).
- repo-layout AGENTS.md guard no longer false-alarms on equivalent refactors.
- cross-ai ordering guard fails red when the step is missing.
- issue-766 parses required fields from the schema fixture (auto-enforced).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* test: stub USERPROFILE alongside HOME in feat-488 (Windows parity)
The feat-488 redesign stubbed process.env.HOME but not USERPROFILE; os.homedir()
resolves from USERPROFILE on Windows, so the home stub was not hermetic there —
caught by windows-test-parity-guard (stubsHomeNoUserProfile). Save/set/restore
USERPROFILE symmetrically with HOME (delete-if-originally-undefined).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* chore: reconcile allow-test-rule allowlist after rebase onto next
Rebasing onto current next pulled in merged PR #1170, which added
inventory-headings-countfree.test.cjs (a baseline allow-test-rule exemption) and
deleted inventory-counts.test.cjs. Grandfather the former and prune the latter so
the ratchet matches the merged tree. No new debt from this PR.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
468 lines
18 KiB
JavaScript
468 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)');
|
|
|
|
// NOTE: The hotfix regex in release.yml currently permits leading zeros on
|
|
// the major and minor segments (e.g. `1.01.3` and `01.2.3` both pass).
|
|
// This is a known gap tracked in issue #1186. The assertions below are
|
|
// intentionally absent for those cases: this test documents CURRENT
|
|
// behavior, not ideal behavior. Fix #1186 will harden the pattern and
|
|
// add leading-zero rejection assertions here.
|
|
});
|
|
|
|
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'
|
|
);
|
|
}
|
|
});
|
|
});
|