[E00-S05-T01] Implement CI quality baseline (required PR stages) #405
@@ -15,6 +15,13 @@
|
|||||||
* compiles the whole apps group (`./apps/**` — apps/server today, the
|
* compiles the whole apps group (`./apps/**` — apps/server today, the
|
||||||
* admin app when E06-S01 lands) and verifies the compiled server
|
* admin app when E06-S01 lands) and verifies the compiled server
|
||||||
* artifact.
|
* artifact.
|
||||||
|
* - "CI workflow pins third-party actions (actions/checkout,
|
||||||
|
* actions/setup-node) to full commit SHAs, not floating tags" → every
|
||||||
|
* `uses:` ref is a 40-char commit SHA pinned to the committed
|
||||||
|
* PINNED_ACTIONS values — no `@v4`-style floating tags.
|
||||||
|
* - "CI workflow declares minimal permissions (`permissions: contents:
|
||||||
|
* read`) at the workflow level" → the workflow declares a top-level
|
||||||
|
* `permissions:` block granting exactly `contents: read`.
|
||||||
* - every committed test suite under tests/ is wired into exactly one stage
|
* - every committed test suite under tests/ is wired into exactly one stage
|
||||||
* (`pnpm lint` runs the formatting-policy suite; every other suite is
|
* (`pnpm lint` runs the formatting-policy suite; every other suite is
|
||||||
* named by a `node --test` run in the unit, architecture or
|
* named by a `node --test` run in the unit, architecture or
|
||||||
@@ -54,6 +61,16 @@ const REQUIRED_STAGES = [
|
|||||||
/** The root lint command the formatting-lint stage must run. */
|
/** The root lint command the formatting-lint stage must run. */
|
||||||
const LINT_SCRIPT = 'node --test tests/formatting-policy.test.mjs';
|
const LINT_SCRIPT = 'node --test tests/formatting-policy.test.mjs';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The third-party actions the workflow may use, pinned to the full commit
|
||||||
|
* SHA of a released version (E00-S05-T01 hardening). Updating a pin means
|
||||||
|
* updating this table and the workflow together, in the same PR.
|
||||||
|
*/
|
||||||
|
const PINNED_ACTIONS = {
|
||||||
|
'actions/checkout': '11bd71901bbe5b1630ceea73d27597364c9af683', // v4.2.2
|
||||||
|
'actions/setup-node': '1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a', // v4.2.0
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parses the workflow's `jobs:` section (the committed file is 2-space
|
* Parses the workflow's `jobs:` section (the committed file is 2-space
|
||||||
* indented YAML) into `{ order, jobs }` where `order` lists job keys in
|
* indented YAML) into `{ order, jobs }` where `order` lists job keys in
|
||||||
@@ -106,6 +123,56 @@ function suitesNamedInRuns(runs) {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Extracts the `uses:` refs from the workflow, e.g. "actions/checkout@<sha>". */
|
||||||
|
function usesRefs(yamlText) {
|
||||||
|
const refs = [];
|
||||||
|
for (const line of yamlText.split('\n')) {
|
||||||
|
// `uses:` appears either as a bare key or as a sequence item ("- uses:").
|
||||||
|
const match = /^\s*(?:-\s+)?uses:\s*(\S+)/.exec(line);
|
||||||
|
if (match) refs.push(match[1]);
|
||||||
|
}
|
||||||
|
return refs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asserts the E00-S05-T01 hardening criteria: every third-party `uses:` ref
|
||||||
|
* is pinned to a full 40-char commit SHA (exactly the committed PINNED_ACTIONS
|
||||||
|
* values — no floating tags) and the workflow declares `permissions:
|
||||||
|
* contents: read` at the top level. Throws an AssertionError describing the
|
||||||
|
* first violated invariant.
|
||||||
|
*/
|
||||||
|
function assertHardening(yamlText) {
|
||||||
|
const refs = usesRefs(yamlText);
|
||||||
|
assert.ok(refs.length > 0, 'the workflow must use at least one third-party action');
|
||||||
|
for (const ref of refs) {
|
||||||
|
const match = /^([\w.-]+\/[\w.-]+)@([0-9a-f]{40})$/.exec(ref);
|
||||||
|
assert.ok(
|
||||||
|
match,
|
||||||
|
`every third-party action must be pinned to a full 40-char commit SHA, not a floating tag (got "${ref}")`,
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
Object.hasOwn(PINNED_ACTIONS, match[1]),
|
||||||
|
`unexpected third-party action "${match[1]}" — add it to the PINNED_ACTIONS policy table if it is approved`,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
match[2],
|
||||||
|
PINNED_ACTIONS[match[1]],
|
||||||
|
`"${match[1]}" must be pinned to the committed full commit SHA ${PINNED_ACTIONS[match[1]]} (got ${match[2]})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (const action of Object.keys(PINNED_ACTIONS)) {
|
||||||
|
assert.ok(
|
||||||
|
refs.some((ref) => ref.startsWith(`${action}@`)),
|
||||||
|
`the workflow must use "${action}" pinned to a full commit SHA`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert.match(
|
||||||
|
yamlText,
|
||||||
|
/^permissions:\n[ \t]+contents: read$/m,
|
||||||
|
'the workflow must declare a top-level "permissions: contents: read" block',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Asserts the whole E00-S05-T01 baseline for a parsed workflow. Throws an
|
* Asserts the whole E00-S05-T01 baseline for a parsed workflow. Throws an
|
||||||
* AssertionError describing the first violated invariant.
|
* AssertionError describing the first violated invariant.
|
||||||
@@ -231,6 +298,10 @@ test('the root lint script runs the formatting-policy suite', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('the workflow pins third-party actions to full commit SHAs and declares minimal permissions', () => {
|
||||||
|
assertHardening(read(WORKFLOW));
|
||||||
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Mutation probes — the baseline assertions are non-vacuous
|
// Mutation probes — the baseline assertions are non-vacuous
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -270,3 +341,30 @@ test('reordering the stages fails the baseline (mutation probe)', () => {
|
|||||||
assert.notEqual(mutated, text, 'the probe must mutate the workflow');
|
assert.notEqual(mutated, text, 'the probe must mutate the workflow');
|
||||||
assert.throws(() => assertBaseline(parseWorkflowJobs(mutated)), /must run in order/);
|
assert.throws(() => assertBaseline(parseWorkflowJobs(mutated)), /must run in order/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('reverting an action pin to a floating tag fails the hardening assertion (mutation probe)', () => {
|
||||||
|
const text = read(WORKFLOW);
|
||||||
|
const mutated = text.replace(
|
||||||
|
`actions/checkout@${PINNED_ACTIONS['actions/checkout']}`,
|
||||||
|
'actions/checkout@v4',
|
||||||
|
);
|
||||||
|
assert.notEqual(mutated, text, 'the probe must mutate the workflow');
|
||||||
|
assert.throws(() => assertHardening(mutated), /full 40-char commit SHA/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('changing a pinned action SHA fails the hardening assertion (mutation probe)', () => {
|
||||||
|
const text = read(WORKFLOW);
|
||||||
|
const mutated = text.replace(
|
||||||
|
`actions/setup-node@${PINNED_ACTIONS['actions/setup-node']}`,
|
||||||
|
`actions/setup-node@${'a'.repeat(40)}`,
|
||||||
|
);
|
||||||
|
assert.notEqual(mutated, text, 'the probe must mutate the workflow');
|
||||||
|
assert.throws(() => assertHardening(mutated), /must be pinned to the committed full commit SHA/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('removing the workflow-level permissions block fails the hardening assertion (mutation probe)', () => {
|
||||||
|
const text = read(WORKFLOW);
|
||||||
|
const mutated = text.replace(/^permissions:\n contents: read\n\n/m, '');
|
||||||
|
assert.notEqual(mutated, text, 'the probe must mutate the workflow');
|
||||||
|
assert.throws(() => assertHardening(mutated), /permissions: contents: read/);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user