chore(scripts): make the browser rule kind-aware, cover container.env, pin the name (#2146)
Copilot, fifth review: - The SMOKE_BROWSER rule was a blanket text match across every region, so a script that merely PRINTS the variable — `echo "SMOKE_BROWSER: firefox"` — or a `with:` input mentioning it was reported as an override. It is kind-aware now: an `env:` entry counts only when SMOKE_BROWSER is literally its key (recognized structurally, not by regex), a `run:` script only on a shell assignment, and a `with:` value not at all — whatever it names is already covered by the script rules. A shell assignment inside an `echo` is indistinguishable without parsing the shell, and inside `run:` erring toward the finding is the safe direction; that is stated in the header. - `jobs.<id>.container.env` is job-wide too — every step in the container sees it — so an override there redirected `npm run smoke` with the guard silent. Now traversed, and a `container:` given as a bare image string is ignored rather than tripping over. - Nothing asserted the FIRST acceptance criterion: reintroducing a `ci` or `ci:*` root script left test:scripts green. Asserted directly over the manifest's keys now, and checked to actually bite by reviving `ci` and watching the suite fail. Signed-off-by: Cliff Hall <cliff@futurescale.com> Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
parent
8b02329703
commit
6d2806de75
2 changed files with 125 additions and 20 deletions
|
|
@ -33,7 +33,14 @@
|
|||
* 3. Invoking `smoke:web:engine`, whose engine comes from the environment and
|
||||
* therefore cannot be read off the workflow at all.
|
||||
* 4. Setting `SMOKE_BROWSER` to anything but a literal `chromium` — the back
|
||||
* door that would redirect an otherwise innocent `npm run smoke`.
|
||||
* door that would redirect an otherwise innocent `npm run smoke`. This one
|
||||
* is KIND-AWARE: an `env:` entry counts only when `SMOKE_BROWSER` is
|
||||
* literally its key, and a `run:` script only on a shell assignment
|
||||
* (`SMOKE_BROWSER=…`). A blanket text match rejected `echo
|
||||
* "SMOKE_BROWSER: firefox"`, which sets nothing (Copilot). A shell
|
||||
* assignment inside an `echo` is indistinguishable without parsing the
|
||||
* shell, and inside a `run:` block erring toward the finding is the safe
|
||||
* direction.
|
||||
* 5. Naming either family through a workflow **expression** —
|
||||
* `npm run smoke:web:${{ matrix.browser }}`, `npm run local:${{ … }}`, or
|
||||
* `SMOKE_BROWSER: ${{ matrix.browser }}`. This is rule 3's reason applied
|
||||
|
|
@ -74,7 +81,11 @@ import {
|
|||
isSeq,
|
||||
parseAllDocuments,
|
||||
} from "yaml";
|
||||
import { SUPPORTED_BROWSERS, DEFAULT_BROWSER } from "./headless-browser.mjs";
|
||||
import {
|
||||
BROWSER_ENV_VAR,
|
||||
SUPPORTED_BROWSERS,
|
||||
DEFAULT_BROWSER,
|
||||
} from "./headless-browser.mjs";
|
||||
|
||||
/** Scripts whose `local:` prefix declares them local-only. */
|
||||
export const LOCAL_SCRIPT_PREFIX = "local:";
|
||||
|
|
@ -118,12 +129,13 @@ const DYNAMIC_ENGINE_RE = new RegExp(
|
|||
String.raw`\bsmoke:web:${EXPRESSION}`,
|
||||
"gi",
|
||||
);
|
||||
// The value may be quoted (`SMOKE_BROWSER: "firefox"`), assigned
|
||||
// (`SMOKE_BROWSER=firefox`), an expression, or ABSENT — `SMOKE_BROWSER:` with
|
||||
// no value is valid YAML that sets the variable to the empty string, which
|
||||
// `resolveBrowserName` rejects as present-but-empty rather than treating as
|
||||
// unset (Copilot). So the value is `\S*`, and an empty capture is a finding.
|
||||
const BROWSER_ENV_RE = /\bSMOKE_BROWSER\s*[:=]\s*(\S*)/gi;
|
||||
// A shell assignment inside a `run:` script. The YAML `env:` spelling is not
|
||||
// matched by text at all — it is recognized structurally, by key, so that a
|
||||
// script merely PRINTING `SMOKE_BROWSER: firefox` is not read as setting it
|
||||
// (Copilot). `\S*` rather than `\S+` because `SMOKE_BROWSER=` sets the empty
|
||||
// string, which `resolveBrowserName` rejects as present-but-empty rather than
|
||||
// treating as unset.
|
||||
const BROWSER_SHELL_RE = new RegExp(`\\b${BROWSER_ENV_VAR}=(\\S*)`, "g");
|
||||
|
||||
// A shell assignment inside a `run:` script can still be quoted; a YAML value
|
||||
// arrives already unquoted from the parser, so this is a no-op there.
|
||||
|
|
@ -151,7 +163,8 @@ function stripQuotes(value) {
|
|||
* allowlist, so it changes nothing about the tarball.
|
||||
*
|
||||
* The walk is STRUCTURAL — it descends the schema's executable paths
|
||||
* (workflow `env`, job `env`/`with`, step `run`/`env`/`with`) rather than
|
||||
* (workflow `env`, job `env`/`container.env`/`with`, step `run`/`env`/`with`)
|
||||
* rather than
|
||||
* matching pairs by key name anywhere in the tree. Key-name matching looks
|
||||
* equivalent and is not: workflow input ids are user-defined, so an input
|
||||
* legitimately named `env` puts its own `description` and `default` in front of
|
||||
|
|
@ -221,6 +234,8 @@ export function extractExecutableRegions(text, file = "<workflow>") {
|
|||
regions.push({
|
||||
line: lineOf(entry.key),
|
||||
kind,
|
||||
name: String(entry.key.value),
|
||||
value: text,
|
||||
text: `${String(entry.key.value)}: ${text}`,
|
||||
});
|
||||
}
|
||||
|
|
@ -240,6 +255,11 @@ export function extractExecutableRegions(text, file = "<workflow>") {
|
|||
addMapping("env", at(job, "env"));
|
||||
// A job-level `with:` is the input block of a reusable-workflow call.
|
||||
addMapping("with", at(job, "with"));
|
||||
// `container.env` is job-wide too: every step running in the container
|
||||
// sees it, so an override there redirects `npm run smoke` exactly as a
|
||||
// job-level `env` would (Copilot). `container:` may also be a bare image
|
||||
// string, which `addMapping` ignores.
|
||||
addMapping("env", at(deref(at(job, "container")), "env"));
|
||||
|
||||
const steps = deref(at(job, "steps"));
|
||||
if (!isSeq(steps)) continue;
|
||||
|
|
@ -269,14 +289,12 @@ export function extractExecutableRegions(text, file = "<workflow>") {
|
|||
export function findWorkflowViolations(text, file = "<workflow>") {
|
||||
const findings = [];
|
||||
|
||||
for (const { line: lineNumber, text: region } of extractExecutableRegions(
|
||||
text,
|
||||
file,
|
||||
)) {
|
||||
for (const region of extractExecutableRegions(text, file)) {
|
||||
const { line: lineNumber, kind } = region;
|
||||
// Inside a `run:` block the parser hands back the shell comments too, so
|
||||
// the "a comment invokes nothing" rule has to be applied here now that it
|
||||
// no longer falls out of skipping YAML comment lines (Copilot).
|
||||
const line = region
|
||||
const line = region.text
|
||||
.split("\n")
|
||||
.filter((l) => !l.trimStart().startsWith("#"))
|
||||
.join("\n");
|
||||
|
|
@ -323,8 +341,23 @@ export function findWorkflowViolations(text, file = "<workflow>") {
|
|||
});
|
||||
}
|
||||
|
||||
for (const match of line.matchAll(BROWSER_ENV_RE)) {
|
||||
const value = stripQuotes(match[1]);
|
||||
// The browser rule is the one that must respect WHICH position it is in.
|
||||
const overrides =
|
||||
kind === "env"
|
||||
? region.name === BROWSER_ENV_VAR
|
||||
? [{ value: region.value, match: region.text.trim() }]
|
||||
: []
|
||||
: kind === "run"
|
||||
? [...line.matchAll(BROWSER_SHELL_RE)].map((m) => ({
|
||||
value: m[1],
|
||||
match: m[0].trim(),
|
||||
}))
|
||||
: // A `with:` value is an action input, not an environment. Whatever
|
||||
// it names is covered by the script rules above.
|
||||
[];
|
||||
|
||||
for (const override of overrides) {
|
||||
const value = stripQuotes(override.value);
|
||||
if (value === DEFAULT_BROWSER) continue;
|
||||
// Setting it to nothing is not the same as leaving it out: the variable
|
||||
// is then present and empty, which `resolveBrowserName` rejects outright
|
||||
|
|
@ -334,9 +367,9 @@ export function findWorkflowViolations(text, file = "<workflow>") {
|
|||
file,
|
||||
line: lineNumber,
|
||||
rule: VIOLATION.BROWSER_ENV,
|
||||
match: match[0].trim(),
|
||||
match: override.match,
|
||||
message:
|
||||
`SMOKE_BROWSER must not be set to ${described} in a workflow — only ` +
|
||||
`${BROWSER_ENV_VAR} must not be set to ${described} in a workflow — only ` +
|
||||
`\`${DEFAULT_BROWSER}\` runs in GitHub CI, an expression cannot be ` +
|
||||
`checked statically, and an empty value is rejected rather than ` +
|
||||
`defaulted. Leave it unset; the default is \`${DEFAULT_BROWSER}\`.`,
|
||||
|
|
|
|||
|
|
@ -188,6 +188,52 @@ describe("findWorkflowViolations", () => {
|
|||
].join("\n"),
|
||||
rules: [VIOLATION.BROWSER_ENV],
|
||||
},
|
||||
{
|
||||
// Copilot, fifth review: `container.env` is job-wide too — every step in
|
||||
// the container sees it, so it redirects `npm run smoke` exactly as a
|
||||
// job-level `env` would.
|
||||
name: "flags a SMOKE_BROWSER override under container.env",
|
||||
text: [
|
||||
"on: push",
|
||||
"jobs:",
|
||||
" build:",
|
||||
" container:",
|
||||
" image: node:22",
|
||||
" env:",
|
||||
" SMOKE_BROWSER: firefox",
|
||||
" steps:",
|
||||
" - run: npm run smoke",
|
||||
].join("\n"),
|
||||
rules: [VIOLATION.BROWSER_ENV],
|
||||
},
|
||||
{
|
||||
name: "does not trip over a container given as a bare image string",
|
||||
text: [
|
||||
"on: push",
|
||||
"jobs:",
|
||||
" build:",
|
||||
" container: node:22",
|
||||
" steps:",
|
||||
" - run: npm run smoke",
|
||||
].join("\n"),
|
||||
rules: [],
|
||||
},
|
||||
{
|
||||
// Copilot, fifth review: a blanket text match rejected a script that
|
||||
// merely PRINTS the variable name, which sets nothing.
|
||||
name: "does not read a script printing the variable as setting it",
|
||||
text: workflow(
|
||||
' - run: |\n echo "SMOKE_BROWSER: firefox"\n npm run smoke',
|
||||
),
|
||||
rules: [],
|
||||
},
|
||||
{
|
||||
name: "does not read an action input mentioning it as setting it",
|
||||
text: workflow(
|
||||
' - with:\n message: "SMOKE_BROWSER: firefox"',
|
||||
),
|
||||
rules: [],
|
||||
},
|
||||
{
|
||||
name: "allows SMOKE_BROWSER set explicitly to chromium",
|
||||
text: workflow(
|
||||
|
|
@ -334,6 +380,32 @@ describe("findWorkflowViolations", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("the gate's name", () => {
|
||||
const scripts = JSON.parse(
|
||||
readFileSync(join(repoRoot, "package.json"), "utf8"),
|
||||
).scripts;
|
||||
|
||||
it("is `local:gate`, and runs the local-only Storybook step", () => {
|
||||
assert.ok(scripts["local:gate"], "the pre-push gate must exist");
|
||||
assert.match(scripts["local:gate"], /local:storybook/);
|
||||
});
|
||||
|
||||
it("has no `ci` alias, not even a deprecated one", () => {
|
||||
// The rename is only worth anything if the old name is GONE (#2146): an
|
||||
// alias preserves exactly the association being scrubbed, and leaves
|
||||
// something copy-pasteable for a workflow author. Nothing else notices a
|
||||
// reintroduced `ci`, so assert it here (Copilot).
|
||||
const revived = Object.keys(scripts).filter(
|
||||
(name) => name === "ci" || name.startsWith("ci:"),
|
||||
);
|
||||
assert.deepEqual(
|
||||
revived,
|
||||
[],
|
||||
`no root script may be named \`ci\` or \`ci:*\` — found ${revived.join(", ")}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractExecutableRegions", () => {
|
||||
it("descends the executable paths and skips the metadata beside them", () => {
|
||||
const regions = extractExecutableRegions(
|
||||
|
|
|
|||
Loading…
Reference in a new issue