@norskvideo/ctl-dev-kit 0.1.84 → 0.1.86

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.
@@ -25,3 +25,139 @@ export function bashBlockLines(yaml: string): Set<number> {
25
25
  });
26
26
  return inside;
27
27
  }
28
+
29
+ /** The `run: |` steps that pipe into `tee` without `pipefail` set on the
30
+ * block's first non-comment line — each reported at its `run: |` line. The
31
+ * default `bash -e {0}` has no pipefail, so such a step exits with TEE's
32
+ * status and a dead suite reads green; `set -euo pipefail` inside a nested
33
+ * `bash -c '...'` body cannot reach the pipeline one shell up. Read from the
34
+ * raw text, not a YAML parse: what matters is the ORDER of lines in the script
35
+ * the runner executes. */
36
+ export function teeWithoutPipefail(yaml: string): { line: number; text: string }[] {
37
+ const out: { line: number; text: string }[] = [];
38
+ const lines = yaml.split("\n");
39
+ for (let i = 0; i < lines.length; i++) {
40
+ const head = lines[i] as string;
41
+ if (!/^\s*run: \|\s*$/.test(head)) continue;
42
+ const indent = (lines[i + 1] ?? "").match(/^\s*/)?.[0].length ?? 0;
43
+ const body: string[] = [];
44
+ for (let j = i + 1; j < lines.length; j++) {
45
+ const line = lines[j] as string;
46
+ if (line.trim() === "") continue;
47
+ if ((line.match(/^\s*/)?.[0].length ?? 0) < indent) break;
48
+ body.push(line.trim());
49
+ }
50
+ if (!body.some((l) => /\|\s*tee\b/.test(l))) continue;
51
+ const first = body.find((l) => !l.startsWith("#"));
52
+ const pipefailFirst =
53
+ first !== undefined && /^set\s+(-[a-zA-Z]*o\s+pipefail|-euo\s+pipefail|-o\s+pipefail)/.test(first);
54
+ if (!pipefailFirst) out.push({ line: i + 1, text: head });
55
+ }
56
+ return out;
57
+ }
58
+
59
+ /** The step every convention workflow runs before `actions/checkout`, named so
60
+ * the guard below can find it. */
61
+ export const TEST_TEMP_STEP = "Route test-temp out of the workspace";
62
+
63
+ // What that step must do, and what breaks when it doesn't. All four were learned
64
+ // the hard way over three months on the shared DooD runners:
65
+ //
66
+ // route -- THE fix. These runners are docker-outside-of-docker, so the daemon
67
+ // a launch talks to is the HOST's, and a bind-mount source it has not
68
+ // seen is created by dockerd as ROOT on the host. --container-user
69
+ // does not help: it governs what the container WRITES, not what the
70
+ // daemon CREATES. Leave the harness on its repo-local default and
71
+ // every run seeds the checkout with root-owned dirs. The override
72
+ // must therefore point outside $GITHUB_WORKSPACE.
73
+ // reap -- rm cannot unlink a live mountpoint even as ROOT (EBUSY), and a
74
+ // RESTARTING leaked container re-creates the path as root seconds
75
+ // after any clean. The holders are untracked by construction (a
76
+ // killed run labels nothing), so they are found by mount SOURCE.
77
+ // base -- clearing only the CONTENTS leaves the base dir itself, and an
78
+ // empty-but-root-owned base still EACCESes a later mkdtemp.
79
+ // verify -- the old form swallowed every failure into `2>/dev/null || true`,
80
+ // so a clean that removed nothing looked identical to one that
81
+ // worked. The checkout then died with a bare "EACCES rmdir" and no
82
+ // clue what held the path. It stayed that way for a week.
83
+ // The exported base must TRACE to $RUNNER_TEMP. Checking only that the export
84
+ // exists would pass a step that dutifully exports the workspace path it was
85
+ // already defaulting to, which is the whole bug.
86
+ function routesOutOfWorkspace(body: string): boolean {
87
+ const exported = body.match(/NORSK_CTL_TEST_TMP=(\$\{?\w+\}?|[^\s"]+)[^\n]*>> "\$GITHUB_ENV"/);
88
+ if (!exported) return false;
89
+ const value = exported[1] ?? "";
90
+ if (value.startsWith("$RUNNER_TEMP")) return true;
91
+ const name = value.replace(/^\$\{?/, "").replace(/\}$/, "");
92
+ return name.length > 0 && new RegExp(`\\b${name}="\\$RUNNER_TEMP/`).test(body);
93
+ }
94
+
95
+ const TEST_TEMP_REQUIREMENTS: { name: string; ok: (body: string) => boolean }[] = [
96
+ { name: "route", ok: (b) => routesOutOfWorkspace(b) },
97
+ { name: "reap", ok: (b) => /\.Mounts\b/.test(b) && /docker rm -f/.test(b) },
98
+ { name: "base", ok: (b) => /rm -rf [^\n]*\/test-temp(?![\w/*])/.test(b) },
99
+ { name: "verify", ok: (b) => /\[ -e "\$tt" \]/.test(b) && /\bexit 1\b/.test(b) },
100
+ ];
101
+
102
+ /** The `run: |` body of the step starting at `nameLine`, de-indented. */
103
+ function stepBody(lines: string[], nameLine: number): string {
104
+ const runAt = lines.findIndex((l, i) => i > nameLine && /^\s*run: \|\s*$/.test(l));
105
+ if (runAt < 0) return "";
106
+ const indent = (lines[runAt + 1] ?? "").match(/^\s*/)?.[0].length ?? 0;
107
+ const body: string[] = [];
108
+ for (let j = runAt + 1; j < lines.length; j++) {
109
+ const line = lines[j] as string;
110
+ if (line.trim() === "") continue;
111
+ if ((line.match(/^\s*/)?.[0].length ?? 0) < indent) break;
112
+ body.push(line.trim());
113
+ }
114
+ return body.join("\n");
115
+ }
116
+
117
+ /** Pre-checkout test-temp steps that would leave the checkout exposed, each
118
+ * reported at its `- name:` line with the requirements it does not meet. */
119
+ export function testTempStepProblems(yaml: string): { line: number; missing: string[] }[] {
120
+ const lines = yaml.split("\n");
121
+ const out: { line: number; missing: string[] }[] = [];
122
+ lines.forEach((text, i) => {
123
+ if (!text.includes(`- name: ${TEST_TEMP_STEP}`)) return;
124
+ const body = stepBody(lines, i);
125
+ const missing = TEST_TEMP_REQUIREMENTS.filter((r) => !r.ok(body)).map((r) => r.name);
126
+ if (missing.length > 0) out.push({ line: i + 1, missing });
127
+ });
128
+ return out;
129
+ }
130
+
131
+ /** Jobs that check out without a {@link TEST_TEMP_STEP} of their own.
132
+ *
133
+ * The per-step guard above can only inspect steps it finds BY NAME, so
134
+ * deleting the step -- or renaming it back -- reads as "no problems". This is
135
+ * the presence half. It is per JOB because both halves of the step are
136
+ * per-job: `$GITHUB_ENV` does not cross jobs, and neither does the runner's
137
+ * workspace state. Job ids are read off the two-space indent under `jobs:`
138
+ * rather than a YAML parse, to stay consistent with the line-oriented guards
139
+ * here and to keep reporting real line numbers. */
140
+ export function jobsMissingTestTempStep(yaml: string): string[] {
141
+ const lines = yaml.split("\n");
142
+ const bad: string[] = [];
143
+ let job: string | undefined;
144
+ let checkouts = 0;
145
+ let steps = 0;
146
+ const settle = () => {
147
+ if (job !== undefined && checkouts > steps) bad.push(job);
148
+ };
149
+ for (const line of lines) {
150
+ const head = line.match(/^ {2}([A-Za-z_][\w-]*):\s*$/);
151
+ if (head) {
152
+ settle();
153
+ job = head[1];
154
+ checkouts = 0;
155
+ steps = 0;
156
+ continue;
157
+ }
158
+ if (/^\s*-?\s*uses: actions\/checkout@/.test(line)) checkouts++;
159
+ if (line.includes(`- name: ${TEST_TEMP_STEP}`)) steps++;
160
+ }
161
+ settle();
162
+ return bad;
163
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@norskvideo/ctl-dev-kit",
3
- "version": "0.1.84",
3
+ "version": "0.1.86",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./create-product": "./create-product/create-product.ts",