mandrel-platform 0.24.0 → 0.26.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/scripts/check-pin-drift.mjs +66 -1
- package/scripts/check-pin-drift.test.mjs +125 -0
- package/scripts/check-workflow-gh-flags.mjs +172 -0
- package/scripts/check-workflow-gh-flags.test.mjs +86 -0
- package/scripts/deploy-boot-smoke.mjs +116 -36
- package/scripts/deploy-boot-smoke.test.mjs +138 -17
- package/scripts/platform-repair.mjs +38 -2
- package/scripts/platform-repair.test.mjs +113 -0
- package/scripts/semgrep-requirements.txt +169 -0
- package/templates/workflows/deploy-staging-run.yml +44 -4
- package/templates/workflows/deploy-staging.yml +68 -9
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mandrel-platform",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.26.0",
|
|
4
4
|
"description": "Shared CI/deploy workflows, composite toolchain action, npm config package, Renovate preset, and operator runbook templates.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -44,7 +44,7 @@
|
|
|
44
44
|
"provenance": true
|
|
45
45
|
},
|
|
46
46
|
"dependencies": {
|
|
47
|
-
"mandrel": "^1.
|
|
47
|
+
"mandrel": "^1.83.0"
|
|
48
48
|
},
|
|
49
49
|
"scripts": {
|
|
50
50
|
"typecheck": "node --input-type=module --eval 'process.exit(0)'",
|
|
@@ -979,18 +979,61 @@ export function hasDrift(report) {
|
|
|
979
979
|
return report.results.some((r) => r.error || r.drift);
|
|
980
980
|
}
|
|
981
981
|
|
|
982
|
+
/**
|
|
983
|
+
* Is EVERY configured consumer an `error` row (M11)? This is the signature of a
|
|
984
|
+
* dead cross-repo credential: a PIN_DRIFT_TOKEN that was *provided* but has
|
|
985
|
+
* expired (fine-grained PATs always expire) can no longer read ANY consumer, so
|
|
986
|
+
* every `fetchConsumerWorkflows` call fails closed to an `error` row. Contrast
|
|
987
|
+
* the not-yet-provisioned bootstrap case: there the token is *absent*, the run
|
|
988
|
+
* legitimately can't read the private consumers, and that benign state must keep
|
|
989
|
+
* its current exit-0 behavior. The caller distinguishes the two by whether the
|
|
990
|
+
* token was provided (see `runCli`'s `tokenProvided`); this predicate only
|
|
991
|
+
* answers "did every row error", which — given a provided token — means the
|
|
992
|
+
* credential died rather than "no drift".
|
|
993
|
+
*
|
|
994
|
+
* Requires at least one consumer (an empty registry is vacuously not a
|
|
995
|
+
* dead-credential signal).
|
|
996
|
+
*
|
|
997
|
+
* @param {{ results: Array<{ error?: string }> }} report
|
|
998
|
+
* @returns {boolean}
|
|
999
|
+
*/
|
|
1000
|
+
export function allConsumersErrored(report) {
|
|
1001
|
+
const results = Array.isArray(report.results) ? report.results : [];
|
|
1002
|
+
return results.length > 0 && results.every((r) => Boolean(r.error));
|
|
1003
|
+
}
|
|
1004
|
+
|
|
982
1005
|
// ---------------------------------------------------------------------------
|
|
983
1006
|
// CLI entry
|
|
984
1007
|
// ---------------------------------------------------------------------------
|
|
985
1008
|
|
|
1009
|
+
/**
|
|
1010
|
+
* Whether the cross-repo `PIN_DRIFT_TOKEN` was PROVIDED (non-empty) to the run
|
|
1011
|
+
* (M11). The scheduled/dispatch workflow reads consumers over the `gh` CLI with
|
|
1012
|
+
* `GH_TOKEN: ${{ secrets.PIN_DRIFT_TOKEN || github.token }}` — the built-in
|
|
1013
|
+
* `github.token` fallback can only read THIS repo, so cross-repo reads fail
|
|
1014
|
+
* closed to `error` rows both when the token is absent (bootstrap) AND when it
|
|
1015
|
+
* is provided-but-dead (expired PAT). This env var is the only signal that
|
|
1016
|
+
* distinguishes the two: the workflow sets it to the raw secret so an empty
|
|
1017
|
+
* value ⇒ absent (bootstrap, benign) and a non-empty value ⇒ provided (a
|
|
1018
|
+
* total error sweep then means the credential died).
|
|
1019
|
+
*
|
|
1020
|
+
* @param {Record<string, string | undefined>} env
|
|
1021
|
+
* @returns {boolean}
|
|
1022
|
+
*/
|
|
1023
|
+
export function pinDriftTokenProvided(env) {
|
|
1024
|
+
return typeof env.PIN_DRIFT_TOKEN === "string" && env.PIN_DRIFT_TOKEN.length > 0;
|
|
1025
|
+
}
|
|
1026
|
+
|
|
986
1027
|
/**
|
|
987
1028
|
* @param {{
|
|
988
1029
|
* argv?: string[],
|
|
989
1030
|
* cwd?: string,
|
|
990
1031
|
* stdout?: { write: (s: string) => void },
|
|
991
1032
|
* stderr?: { write: (s: string) => void },
|
|
1033
|
+
* env?: Record<string, string | undefined>,
|
|
992
1034
|
* runGh?: (args: string[]) => string,
|
|
993
1035
|
* summaryPath?: string | undefined,
|
|
1036
|
+
* tokenProvided?: boolean,
|
|
994
1037
|
* nowMs?: number,
|
|
995
1038
|
* }} [opts]
|
|
996
1039
|
* @returns {number} exit code
|
|
@@ -1000,8 +1043,10 @@ export function runCli({
|
|
|
1000
1043
|
cwd = process.cwd(),
|
|
1001
1044
|
stdout = process.stdout,
|
|
1002
1045
|
stderr = process.stderr,
|
|
1046
|
+
env = process.env,
|
|
1003
1047
|
runGh = defaultGhRunner,
|
|
1004
1048
|
summaryPath = process.env.GITHUB_STEP_SUMMARY,
|
|
1049
|
+
tokenProvided = pinDriftTokenProvided(env),
|
|
1005
1050
|
nowMs = Date.now(),
|
|
1006
1051
|
} = {}) {
|
|
1007
1052
|
const { config: configRel, json, strict } = parseArgv(argv);
|
|
@@ -1025,9 +1070,19 @@ export function runCli({
|
|
|
1025
1070
|
|
|
1026
1071
|
const report = buildReport(config, runGh, nowMs);
|
|
1027
1072
|
const drift = hasDrift(report);
|
|
1073
|
+
// M11: a PROVIDED-but-dead PIN_DRIFT_TOKEN (expired PAT) can no longer read
|
|
1074
|
+
// ANY consumer, so every row fails closed to `error`. That is a credential
|
|
1075
|
+
// failure, NOT the benign not-yet-provisioned bootstrap (token absent) — fail
|
|
1076
|
+
// the run unconditionally (even without --strict, which the scheduled path
|
|
1077
|
+
// can never pass) so the death is loud instead of a green no-op. The absent-
|
|
1078
|
+
// token bootstrap keeps its current behavior: tokenProvided is false, so this
|
|
1079
|
+
// branch never fires and the run exits per the drift/--strict rules below.
|
|
1080
|
+
const deadCredential = tokenProvided && allConsumersErrored(report);
|
|
1028
1081
|
|
|
1029
1082
|
if (json) {
|
|
1030
|
-
stdout.write(
|
|
1083
|
+
stdout.write(
|
|
1084
|
+
`${JSON.stringify({ kind: "pin-drift-report", drift, deadCredential, ...report }, null, 2)}\n`,
|
|
1085
|
+
);
|
|
1031
1086
|
} else {
|
|
1032
1087
|
const text = renderReport(report);
|
|
1033
1088
|
stdout.write(`${text}\n`);
|
|
@@ -1042,6 +1097,16 @@ export function runCli({
|
|
|
1042
1097
|
}
|
|
1043
1098
|
}
|
|
1044
1099
|
|
|
1100
|
+
if (deadCredential) {
|
|
1101
|
+
stderr.write(
|
|
1102
|
+
"::error::[pin-drift] PIN_DRIFT_TOKEN was provided but every cross-repo " +
|
|
1103
|
+
"consumer read errored — the credential is dead (likely an expired " +
|
|
1104
|
+
"fine-grained PAT), not a not-yet-provisioned bootstrap. Rotate the " +
|
|
1105
|
+
"token. See docs/runbooks/pin-drift-dashboard.md.\n",
|
|
1106
|
+
);
|
|
1107
|
+
return 1;
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1045
1110
|
if (strict && drift) {
|
|
1046
1111
|
stderr.write(`[pin-drift] ❌ drift detected (--strict)\n`);
|
|
1047
1112
|
return 1;
|
|
@@ -20,6 +20,7 @@ import { join } from "node:path";
|
|
|
20
20
|
import { test } from "node:test";
|
|
21
21
|
|
|
22
22
|
import {
|
|
23
|
+
allConsumersErrored,
|
|
23
24
|
buildReport,
|
|
24
25
|
classifyNpmPin,
|
|
25
26
|
classifyStaleLiterals,
|
|
@@ -34,6 +35,7 @@ import {
|
|
|
34
35
|
isWithinReleaseAgeWindow,
|
|
35
36
|
parseDurationMs,
|
|
36
37
|
parseSemver,
|
|
38
|
+
pinDriftTokenProvided,
|
|
37
39
|
renderReport,
|
|
38
40
|
resolveLatestRelease,
|
|
39
41
|
runCli,
|
|
@@ -883,3 +885,126 @@ test("runCli --strict DOES exit 1 once the held release ages out", () => {
|
|
|
883
885
|
rmSync(cfgDir, { recursive: true, force: true });
|
|
884
886
|
}
|
|
885
887
|
});
|
|
888
|
+
|
|
889
|
+
// ---------------------------------------------------------------------------
|
|
890
|
+
// M11 — provided-but-dead PIN_DRIFT_TOKEN (expired PAT) vs. not-yet-provisioned
|
|
891
|
+
// bootstrap. A dead credential can read NO consumer, so every row fails closed
|
|
892
|
+
// to an `error` row; that must hard-fail EVEN without --strict (the scheduled
|
|
893
|
+
// path can never pass --strict). The absent-token bootstrap — identical row
|
|
894
|
+
// shape but token unset — must keep its benign exit-0.
|
|
895
|
+
// (temp/audits/workflow-robustness-review-2026-07-05.md M11)
|
|
896
|
+
// ---------------------------------------------------------------------------
|
|
897
|
+
|
|
898
|
+
const DEAD_CRED_CONFIG = {
|
|
899
|
+
platformRepo: PLATFORM,
|
|
900
|
+
consumers: [
|
|
901
|
+
{ name: "c1", repo: "o/c1", branch: "main" },
|
|
902
|
+
{ name: "c2", repo: "o/c2", branch: "main" },
|
|
903
|
+
],
|
|
904
|
+
};
|
|
905
|
+
|
|
906
|
+
/**
|
|
907
|
+
* A gh runner where the platform's own release resolution succeeds (that read
|
|
908
|
+
* uses the built-in token, which CAN read this repo) but EVERY cross-repo
|
|
909
|
+
* consumer workflow-listing call fails with a non-404 (auth/transport) — the
|
|
910
|
+
* exact shape of a dead cross-repo PAT. Every consumer therefore becomes an
|
|
911
|
+
* `error` row.
|
|
912
|
+
*/
|
|
913
|
+
function makeAllConsumersFailRunGh() {
|
|
914
|
+
return (args) => {
|
|
915
|
+
const path = args[1];
|
|
916
|
+
if (path === `repos/${PLATFORM}/releases/latest`) {
|
|
917
|
+
return JSON.stringify({ tag_name: TAG });
|
|
918
|
+
}
|
|
919
|
+
if (path === `repos/${PLATFORM}/git/ref/tags/${TAG}`) {
|
|
920
|
+
return JSON.stringify({ object: { sha: LATEST_SHA, type: "commit" } });
|
|
921
|
+
}
|
|
922
|
+
if (/\/contents\/\.github\/workflows/.test(path)) {
|
|
923
|
+
// 403/5xx (not 404) → fail-closed to an `error` row, mirroring an expired
|
|
924
|
+
// PAT that can no longer read the private consumer repo.
|
|
925
|
+
throw ghHttpError(403, "Forbidden");
|
|
926
|
+
}
|
|
927
|
+
throw new Error(`unexpected gh api path: ${path}`);
|
|
928
|
+
};
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
test("pinDriftTokenProvided: non-empty ⇒ true, empty/absent ⇒ false", () => {
|
|
932
|
+
assert.equal(pinDriftTokenProvided({ PIN_DRIFT_TOKEN: "ghp_live" }), true);
|
|
933
|
+
assert.equal(pinDriftTokenProvided({ PIN_DRIFT_TOKEN: "" }), false);
|
|
934
|
+
assert.equal(pinDriftTokenProvided({}), false);
|
|
935
|
+
});
|
|
936
|
+
|
|
937
|
+
test("allConsumersErrored: true only when every row errored and ≥1 consumer", () => {
|
|
938
|
+
const report = buildReport(DEAD_CRED_CONFIG, makeAllConsumersFailRunGh());
|
|
939
|
+
assert.equal(allConsumersErrored(report), true);
|
|
940
|
+
// A single clean row flips it back to false — that is drift/partial, not a
|
|
941
|
+
// dead credential.
|
|
942
|
+
assert.equal(
|
|
943
|
+
allConsumersErrored({ results: [{ error: "x" }, { drift: true }] }),
|
|
944
|
+
false,
|
|
945
|
+
);
|
|
946
|
+
assert.equal(allConsumersErrored({ results: [] }), false);
|
|
947
|
+
});
|
|
948
|
+
|
|
949
|
+
test("runCli: PROVIDED-but-dead PIN_DRIFT_TOKEN (all rows error) exits 1 with ::error:: even without --strict", () => {
|
|
950
|
+
cfgDir = mkdtempSync(join(tmpdir(), "pin-drift-dead-"));
|
|
951
|
+
const p = join(cfgDir, "consumers.json");
|
|
952
|
+
writeFileSync(p, JSON.stringify(DEAD_CRED_CONFIG));
|
|
953
|
+
const stderr = capture();
|
|
954
|
+
try {
|
|
955
|
+
const code = runCli({
|
|
956
|
+
argv: ["--config", p], // NOTE: no --strict.
|
|
957
|
+
env: { PIN_DRIFT_TOKEN: "ghp_expired" },
|
|
958
|
+
runGh: makeAllConsumersFailRunGh(),
|
|
959
|
+
stdout: capture(),
|
|
960
|
+
stderr,
|
|
961
|
+
summaryPath: undefined,
|
|
962
|
+
});
|
|
963
|
+
assert.equal(code, 1);
|
|
964
|
+
assert.match(stderr.text(), /::error::/);
|
|
965
|
+
assert.match(stderr.text(), /credential is dead/);
|
|
966
|
+
} finally {
|
|
967
|
+
rmSync(cfgDir, { recursive: true, force: true });
|
|
968
|
+
}
|
|
969
|
+
});
|
|
970
|
+
|
|
971
|
+
test("runCli: dead-credential also surfaces in the --json envelope (deadCredential:true)", () => {
|
|
972
|
+
cfgDir = mkdtempSync(join(tmpdir(), "pin-drift-dead-json-"));
|
|
973
|
+
const p = join(cfgDir, "consumers.json");
|
|
974
|
+
writeFileSync(p, JSON.stringify(DEAD_CRED_CONFIG));
|
|
975
|
+
const stdout = capture();
|
|
976
|
+
try {
|
|
977
|
+
const code = runCli({
|
|
978
|
+
argv: ["--config", p, "--json"],
|
|
979
|
+
env: { PIN_DRIFT_TOKEN: "ghp_expired" },
|
|
980
|
+
runGh: makeAllConsumersFailRunGh(),
|
|
981
|
+
stdout,
|
|
982
|
+
stderr: capture(),
|
|
983
|
+
summaryPath: undefined,
|
|
984
|
+
});
|
|
985
|
+
assert.equal(code, 1);
|
|
986
|
+
const envelope = JSON.parse(stdout.text());
|
|
987
|
+
assert.equal(envelope.deadCredential, true);
|
|
988
|
+
} finally {
|
|
989
|
+
rmSync(cfgDir, { recursive: true, force: true });
|
|
990
|
+
}
|
|
991
|
+
});
|
|
992
|
+
|
|
993
|
+
test("runCli: ABSENT PIN_DRIFT_TOKEN bootstrap (all rows error, token unset) stays exit 0 — benign", () => {
|
|
994
|
+
cfgDir = mkdtempSync(join(tmpdir(), "pin-drift-bootstrap-"));
|
|
995
|
+
const p = join(cfgDir, "consumers.json");
|
|
996
|
+
writeFileSync(p, JSON.stringify(DEAD_CRED_CONFIG));
|
|
997
|
+
try {
|
|
998
|
+
const code = runCli({
|
|
999
|
+
argv: ["--config", p], // no --strict, no token.
|
|
1000
|
+
env: {}, // PIN_DRIFT_TOKEN absent → not-yet-provisioned bootstrap.
|
|
1001
|
+
runGh: makeAllConsumersFailRunGh(),
|
|
1002
|
+
stdout: capture(),
|
|
1003
|
+
stderr: capture(),
|
|
1004
|
+
summaryPath: undefined,
|
|
1005
|
+
});
|
|
1006
|
+
assert.equal(code, 0);
|
|
1007
|
+
} finally {
|
|
1008
|
+
rmSync(cfgDir, { recursive: true, force: true });
|
|
1009
|
+
}
|
|
1010
|
+
});
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* check-workflow-gh-flags.mjs — static lint for known-invalid `gh` CLI flag
|
|
4
|
+
* combinations inside GitHub Actions workflows.
|
|
5
|
+
*
|
|
6
|
+
* WHY THIS EXISTS
|
|
7
|
+
* ---------------
|
|
8
|
+
* A `gh api` invocation is valid shell and passes `actionlint` /
|
|
9
|
+
* `shellcheck`, yet can still be rejected by the `gh` CLI at RUNTIME because a
|
|
10
|
+
* flag combination is unsupported. That failure surfaces only when the step
|
|
11
|
+
* runs — for the release pipeline, that means "at release time", the worst
|
|
12
|
+
* possible moment. Release 0.25.0 wedged its `await-smoke` gate for exactly
|
|
13
|
+
* this reason:
|
|
14
|
+
*
|
|
15
|
+
* gh api --paginate --slurp "…/status" --jq '…'
|
|
16
|
+
* → the `--slurp` option is not supported with `--jq` or `--template`
|
|
17
|
+
*
|
|
18
|
+
* Every poll attempt failed instantly, `|| echo none` swallowed it, and the
|
|
19
|
+
* gate timed out on EVERY release even though smoke was green. No unit test,
|
|
20
|
+
* acceptance critic, epic-audit, or code-review caught it because none
|
|
21
|
+
* exercised the real `gh` CLI. This lint shifts that class of failure LEFT
|
|
22
|
+
* into `ci-required` so an invalid `gh` invocation fails a PR, not a release.
|
|
23
|
+
*
|
|
24
|
+
* RULES (extensible — add more as new `gh` incompatibilities are discovered):
|
|
25
|
+
* 1. slurp-with-jq — `gh` rejects `--slurp` together with `--jq` or
|
|
26
|
+
* `--template`. The supported pattern is `gh api --slurp … | jq …`
|
|
27
|
+
* (pipe to a STANDALONE jq), so this lint splits on shell pipes and only
|
|
28
|
+
* flags a single `gh` command segment that carries BOTH flags.
|
|
29
|
+
*
|
|
30
|
+
* SCOPE: `.github/workflows/*.yml` + `templates/workflows/*.yml`.
|
|
31
|
+
* Exit 0 when clean, 1 when any violation is found (prints file:line).
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import { readFileSync, readdirSync, existsSync } from 'node:fs';
|
|
35
|
+
import { join } from 'node:path';
|
|
36
|
+
|
|
37
|
+
const WORKFLOW_DIRS = ['.github/workflows', 'templates/workflows'];
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Blank out full-line comments (YAML `#` lines and shell `#` comment lines
|
|
41
|
+
* inside `run:` blocks) while preserving line count, so the lint never
|
|
42
|
+
* analyzes PROSE — a workflow comment that merely *documents* an invalid flag
|
|
43
|
+
* combo (like this file's own header, or a step comment describing the rule)
|
|
44
|
+
* is not a `gh` command and must not be flagged. Only whole-line comments are
|
|
45
|
+
* stripped; an inline `#` inside real shell is left alone (it is rarely a
|
|
46
|
+
* comment there and never carries the flag pattern this lint targets).
|
|
47
|
+
*/
|
|
48
|
+
export function stripComments(source) {
|
|
49
|
+
return source
|
|
50
|
+
.split('\n')
|
|
51
|
+
.map((line) => (/^\s*#/.test(line) ? '' : line))
|
|
52
|
+
.join('\n');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Collapse shell line-continuations (`\` + newline) so a multi-line `gh`
|
|
57
|
+
* invocation becomes one logical line, WITHOUT losing the original line number
|
|
58
|
+
* of where the command started. Returns an array of
|
|
59
|
+
* `{ line, text }` logical commands (1-indexed `line`).
|
|
60
|
+
*/
|
|
61
|
+
export function collapseContinuations(source) {
|
|
62
|
+
const rawLines = source.split('\n');
|
|
63
|
+
const logical = [];
|
|
64
|
+
let buf = null;
|
|
65
|
+
let startLine = 0;
|
|
66
|
+
for (let i = 0; i < rawLines.length; i++) {
|
|
67
|
+
const line = rawLines[i];
|
|
68
|
+
const continues = /\\\s*$/.test(line);
|
|
69
|
+
const stripped = line.replace(/\\\s*$/, '');
|
|
70
|
+
if (buf === null) {
|
|
71
|
+
startLine = i + 1;
|
|
72
|
+
buf = stripped;
|
|
73
|
+
} else {
|
|
74
|
+
buf += ' ' + stripped.trim();
|
|
75
|
+
}
|
|
76
|
+
if (!continues) {
|
|
77
|
+
logical.push({ line: startLine, text: buf });
|
|
78
|
+
buf = null;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
if (buf !== null) logical.push({ line: startLine, text: buf });
|
|
82
|
+
return logical;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Split a logical shell line into command segments on the separators that
|
|
87
|
+
* terminate one simple command and start another: pipe, `;`, `&&`, `||`,
|
|
88
|
+
* and command-substitution boundaries. A `gh api --slurp … | jq …` therefore
|
|
89
|
+
* becomes two segments — the `gh` part (no `--jq`) and the `jq` part — so the
|
|
90
|
+
* SUPPORTED pattern is never flagged.
|
|
91
|
+
*/
|
|
92
|
+
export function splitSegments(text) {
|
|
93
|
+
// Split on |, ||, ;, &&, and the `$(` / `)` / backtick substitution edges.
|
|
94
|
+
return text.split(/\|\||&&|[|;`]|\$\(|\)/);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Return an array of rule-violation strings for one segment (may be empty). */
|
|
98
|
+
export function lintSegment(segment) {
|
|
99
|
+
const violations = [];
|
|
100
|
+
const isGh = /(^|\s)gh(\s|$)/.test(segment);
|
|
101
|
+
if (!isGh) return violations;
|
|
102
|
+
|
|
103
|
+
// Rule 1 — slurp-with-jq/template.
|
|
104
|
+
const hasSlurp = /(^|\s)--slurp(\s|=|$)/.test(segment);
|
|
105
|
+
const hasJq = /(^|\s)--jq(\s|=|$)/.test(segment);
|
|
106
|
+
const hasTemplate = /(^|\s)(--template|-t)(\s|=|$)/.test(segment);
|
|
107
|
+
if (hasSlurp && (hasJq || hasTemplate)) {
|
|
108
|
+
violations.push(
|
|
109
|
+
`slurp-with-jq: \`gh\` rejects --slurp together with ${
|
|
110
|
+
hasJq ? '--jq' : '--template'
|
|
111
|
+
}. Pipe --slurp's output to a STANDALONE jq instead: \`gh api --slurp … | jq …\`.`,
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
return violations;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Lint a single workflow file. Returns an array of finding objects. */
|
|
118
|
+
export function lintFile(path, source) {
|
|
119
|
+
const findings = [];
|
|
120
|
+
for (const { line, text } of collapseContinuations(stripComments(source))) {
|
|
121
|
+
for (const segment of splitSegments(text)) {
|
|
122
|
+
for (const rule of lintSegment(segment)) {
|
|
123
|
+
findings.push({ path, line, rule });
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return findings;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function collectWorkflowFiles() {
|
|
131
|
+
const files = [];
|
|
132
|
+
for (const dir of WORKFLOW_DIRS) {
|
|
133
|
+
if (!existsSync(dir)) continue;
|
|
134
|
+
for (const name of readdirSync(dir)) {
|
|
135
|
+
if (name.endsWith('.yml') || name.endsWith('.yaml')) {
|
|
136
|
+
files.push(join(dir, name));
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return files.sort();
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function main() {
|
|
144
|
+
const files = collectWorkflowFiles();
|
|
145
|
+
const findings = [];
|
|
146
|
+
for (const f of files) {
|
|
147
|
+
findings.push(...lintFile(f, readFileSync(f, 'utf8')));
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (findings.length === 0) {
|
|
151
|
+
console.log(
|
|
152
|
+
`[check-workflow-gh-flags] ✓ ${files.length} workflow file(s) — no invalid gh flag combinations.`,
|
|
153
|
+
);
|
|
154
|
+
return 0;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
console.error(
|
|
158
|
+
`[check-workflow-gh-flags] ✗ ${findings.length} invalid gh flag combination(s):\n`,
|
|
159
|
+
);
|
|
160
|
+
for (const { path, line, rule } of findings) {
|
|
161
|
+
console.error(` ${path}:${line} — ${rule}`);
|
|
162
|
+
}
|
|
163
|
+
console.error(
|
|
164
|
+
'\nThese pass actionlint/shellcheck but fail the `gh` CLI at RUNTIME. Fix before merge.',
|
|
165
|
+
);
|
|
166
|
+
return 1;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Run only as a CLI, not when imported by the test suite.
|
|
170
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
171
|
+
process.exit(main());
|
|
172
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import {
|
|
4
|
+
collapseContinuations,
|
|
5
|
+
splitSegments,
|
|
6
|
+
lintSegment,
|
|
7
|
+
lintFile,
|
|
8
|
+
} from './check-workflow-gh-flags.mjs';
|
|
9
|
+
|
|
10
|
+
test('collapseContinuations joins backslash-continued lines and keeps start line', () => {
|
|
11
|
+
const src = ['a=1', 'state="$(gh api --slurp x \\', ' --jq y \\', ' || echo none)"', 'b=2'].join(
|
|
12
|
+
'\n',
|
|
13
|
+
);
|
|
14
|
+
const logical = collapseContinuations(src);
|
|
15
|
+
const joined = logical.find((l) => l.text.includes('gh api'));
|
|
16
|
+
assert.equal(joined.line, 2, 'start line is the first line of the command');
|
|
17
|
+
assert.match(joined.text, /gh api --slurp x\s+--jq y\s+\|\| echo none/);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test('lintSegment flags gh --slurp with --jq (the 0.25.0 regression)', () => {
|
|
21
|
+
const v = lintSegment('gh api --paginate --slurp "repos/x/commits/y/status" --jq \'.a\'');
|
|
22
|
+
assert.equal(v.length, 1);
|
|
23
|
+
assert.match(v[0], /slurp-with-jq/);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test('lintSegment flags gh --slurp with --template / -t', () => {
|
|
27
|
+
assert.equal(lintSegment('gh api --slurp x --template "{{.a}}"').length, 1);
|
|
28
|
+
assert.equal(lintSegment('gh api --slurp x -t "{{.a}}"').length, 1);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test('lintSegment does NOT flag the SUPPORTED pattern (slurp piped to standalone jq)', () => {
|
|
32
|
+
// The pipe splits this into two segments upstream; each segment alone is clean.
|
|
33
|
+
assert.equal(lintSegment('gh api --paginate --slurp "…/status"').length, 0);
|
|
34
|
+
assert.equal(lintSegment(" jq -r '[.[].statuses[]] | first.state'").length, 0);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('lintSegment ignores non-gh commands and plain gh usage', () => {
|
|
38
|
+
assert.equal(lintSegment('jq --slurp --jq nonsense').length, 0, 'not a gh command');
|
|
39
|
+
assert.equal(lintSegment('gh api "repos/x" --jq .a').length, 0, 'jq without slurp is fine');
|
|
40
|
+
assert.equal(lintSegment('gh api --slurp x').length, 0, 'slurp without jq is fine');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('splitSegments separates a gh|jq pipe so the supported pattern is not flagged end-to-end', () => {
|
|
44
|
+
const cmd =
|
|
45
|
+
'state="$(gh api --paginate --slurp "repos/x/commits/y/status" | jq -r \'.a\' || echo none)"';
|
|
46
|
+
const segs = splitSegments(cmd);
|
|
47
|
+
const flagged = segs.flatMap((s) => lintSegment(s));
|
|
48
|
+
assert.equal(flagged.length, 0, 'gh segment has slurp-no-jq; jq segment is not gh');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('lintFile flags the invalid combo across continuation lines', () => {
|
|
52
|
+
const src = [
|
|
53
|
+
'jobs:',
|
|
54
|
+
' x:',
|
|
55
|
+
' steps:',
|
|
56
|
+
' - run: |',
|
|
57
|
+
' state="$(gh api --slurp "u" \\',
|
|
58
|
+
" --jq '.a' \\",
|
|
59
|
+
' || echo none)"',
|
|
60
|
+
].join('\n');
|
|
61
|
+
const findings = lintFile('.github/workflows/fake.yml', src);
|
|
62
|
+
assert.equal(findings.length, 1);
|
|
63
|
+
assert.equal(findings[0].line, 5, 'points at the line the gh command starts on');
|
|
64
|
+
assert.match(findings[0].rule, /slurp-with-jq/);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('lintFile ignores COMMENTS that merely document the invalid combo (false-positive guard)', () => {
|
|
68
|
+
// A step comment describing the rule — must NOT be flagged (regression: this
|
|
69
|
+
// exact false positive failed CI on the guard's own PR).
|
|
70
|
+
const src = [
|
|
71
|
+
' # Catches gh flag combos like --slurp with --jq that fail at runtime.',
|
|
72
|
+
' - name: Lint gh CLI flag combinations',
|
|
73
|
+
' run: node scripts/check-workflow-gh-flags.mjs',
|
|
74
|
+
].join('\n');
|
|
75
|
+
assert.deepEqual(lintFile('.github/workflows/ci.yml', src), []);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('lintFile is clean for the corrected release-please pattern', () => {
|
|
79
|
+
const src = [
|
|
80
|
+
' - run: |',
|
|
81
|
+
' state="$(gh api --paginate --slurp "u" 2>/dev/null \\',
|
|
82
|
+
" | jq -r '[.[].statuses[]] | first.state' \\",
|
|
83
|
+
' || echo none)"',
|
|
84
|
+
].join('\n');
|
|
85
|
+
assert.deepEqual(lintFile('.github/workflows/ok.yml', src), []);
|
|
86
|
+
});
|