mandrel-platform 0.25.0 → 0.26.1

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mandrel-platform",
3
- "version": "0.25.0",
3
+ "version": "0.26.1",
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": {
@@ -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
+ });