@magland/mochi 0.3.1 → 0.4.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.
Files changed (83) hide show
  1. package/README.md +7 -6
  2. package/dist/agefile.js +47 -0
  3. package/dist/agescript.js +343 -0
  4. package/dist/api/admin.js +66 -28
  5. package/dist/api/auth.js +3 -2
  6. package/dist/api/backup.js +227 -192
  7. package/dist/api/contents.js +46 -16
  8. package/dist/api/repos.js +60 -4
  9. package/dist/api/write.js +138 -8
  10. package/dist/api.js +34 -1
  11. package/dist/browse.js +171 -33
  12. package/dist/build-info.json +2 -2
  13. package/dist/ci/api.js +138 -4
  14. package/dist/ci/artifacts.js +82 -2
  15. package/dist/ci/engine.js +30 -17
  16. package/dist/ci/expr.js +14 -0
  17. package/dist/ci/runners.js +60 -1
  18. package/dist/ci/runs.js +21 -3
  19. package/dist/ci/trigger.js +6 -4
  20. package/dist/ci/views.js +24 -3
  21. package/dist/ci/web.js +48 -0
  22. package/dist/cli/admin-cmd.js +40 -3
  23. package/dist/cli/backup-cmd.js +138 -120
  24. package/dist/cli/gitrun.js +50 -0
  25. package/dist/cli/pr-cmd.js +155 -0
  26. package/dist/cli/repo-cmd.js +134 -6
  27. package/dist/cli/sync-cmd.js +128 -0
  28. package/dist/cli/target.js +7 -3
  29. package/dist/config.js +10 -0
  30. package/dist/credentials.js +9 -8
  31. package/dist/deploy-cli.js +214 -109
  32. package/dist/deploy-runner-cli.js +125 -49
  33. package/dist/domains.js +259 -0
  34. package/dist/egress.js +23 -6
  35. package/dist/forms.js +703 -32
  36. package/dist/git.js +124 -13
  37. package/dist/githttp.js +80 -23
  38. package/dist/githubauth.js +185 -0
  39. package/dist/icons.js +14 -0
  40. package/dist/import-cli.js +40 -25
  41. package/dist/index.js +110 -4
  42. package/dist/issueweb.js +10 -6
  43. package/dist/lfs.js +9 -2
  44. package/dist/limit.js +2 -2
  45. package/dist/logincodes.js +31 -0
  46. package/dist/maintenance.js +297 -0
  47. package/dist/markdown.js +192 -3
  48. package/dist/mdedit.js +29 -0
  49. package/dist/migrate.js +48 -0
  50. package/dist/naming.js +25 -0
  51. package/dist/onetime.js +112 -0
  52. package/dist/ops.js +293 -16
  53. package/dist/pagescript.js +355 -24
  54. package/dist/perms.js +1 -0
  55. package/dist/profile.js +2 -0
  56. package/dist/pulls.js +21 -1
  57. package/dist/pullweb.js +25 -6
  58. package/dist/releases.js +10 -1
  59. package/dist/runner/actions.js +12 -1
  60. package/dist/runner/client.js +40 -2
  61. package/dist/runner/docker.js +40 -28
  62. package/dist/runner/download.js +77 -0
  63. package/dist/runner/externals.js +12 -1
  64. package/dist/runner/job.js +26 -11
  65. package/dist/runner/steps.js +42 -7
  66. package/dist/runner-cli.js +89 -0
  67. package/dist/scan.js +58 -2
  68. package/dist/server.js +107 -26
  69. package/dist/session.js +82 -40
  70. package/dist/site.js +110 -27
  71. package/dist/sitepublish.js +177 -0
  72. package/dist/sitesettings.js +420 -0
  73. package/dist/siteshost.js +116 -28
  74. package/dist/source.js +20 -0
  75. package/dist/style.js +162 -3
  76. package/dist/topics.js +165 -0
  77. package/dist/vault.js +399 -6
  78. package/dist/vendor-age.js +15 -0
  79. package/dist/views.js +242 -57
  80. package/dist/web.js +11 -1
  81. package/dist/webauthn.js +347 -0
  82. package/dist/webops.js +1263 -33
  83. package/package.json +4 -4
@@ -45,12 +45,17 @@ const os = __importStar(require("os"));
45
45
  const path = __importStar(require("path"));
46
46
  const YAML = __importStar(require("yaml"));
47
47
  const actionref_1 = require("../ci/actionref");
48
+ const download_1 = require("./download");
48
49
  // Fetching actions and reading their definitions. Actions come from a forge
49
50
  // over HTTPS (github.com by default) as a source tarball, and are cached on
50
51
  // the runner's disk so a repeated job does not re-download them.
51
52
  //
52
53
  // Nothing here executes anything: the store hands back a directory and a
53
54
  // parsed action.yml, and steps.ts decides what to do with them.
55
+ // The most an action's source archive may be. The largest actions in common
56
+ // use are a few megabytes of bundled JavaScript; this is a ceiling on what a
57
+ // `uses:` line can make the runner hold, not a budget anyone should approach.
58
+ const MAX_ACTION_BYTES = 256 * 1024 * 1024;
54
59
  class ActionError extends Error {
55
60
  }
56
61
  exports.ActionError = ActionError;
@@ -400,7 +405,13 @@ class ActionStore {
400
405
  fs.rmSync(tmp, { recursive: true, force: true });
401
406
  fs.mkdirSync(tmp, { recursive: true });
402
407
  const tarball = path.join(tmp, 'source.tar.gz');
403
- fs.writeFileSync(tarball, Buffer.from(await res.arrayBuffer()));
408
+ try {
409
+ await (0, download_1.downloadTo)(res, tarball, MAX_ACTION_BYTES);
410
+ }
411
+ catch (e) {
412
+ fs.rmSync(tmp, { recursive: true, force: true });
413
+ throw new ActionError(`could not fetch ${ref.raw}: ${e instanceof Error ? e.message : e}`);
414
+ }
404
415
  const extracted = path.join(tmp, 'src');
405
416
  fs.mkdirSync(extracted);
406
417
  try {
@@ -84,6 +84,7 @@ class JobClient {
84
84
  buffer = [];
85
85
  flushing = false;
86
86
  cancelSeen = false;
87
+ cancelListeners = [];
87
88
  timer = null;
88
89
  constructor(runner, spec) {
89
90
  this.runner = runner;
@@ -139,17 +140,34 @@ class JobClient {
139
140
  cancelled() {
140
141
  return this.cancelSeen;
141
142
  }
143
+ // Called once, the first time a heartbeat learns the job should stop. The
144
+ // flag above is polled between steps; this is for the step that is running
145
+ // now, which nothing polls and which only removing the container can end.
146
+ onCancel(listener) {
147
+ if (this.cancelSeen)
148
+ listener();
149
+ else
150
+ this.cancelListeners.push(listener);
151
+ }
152
+ markCancelled() {
153
+ if (this.cancelSeen)
154
+ return;
155
+ this.cancelSeen = true;
156
+ const listeners = this.cancelListeners.splice(0, this.cancelListeners.length);
157
+ for (const l of listeners)
158
+ l();
159
+ }
142
160
  async heartbeat() {
143
161
  try {
144
162
  const res = await fetch(`${this.base()}/heartbeat`, { method: 'POST', headers: this.headers() });
145
163
  if (res.status === 409) {
146
164
  // The server took the job back (lease expired, run cancelled). Stop.
147
- this.cancelSeen = true;
165
+ this.markCancelled();
148
166
  return;
149
167
  }
150
168
  const body = (await res.json());
151
169
  if (body.cancel)
152
- this.cancelSeen = true;
170
+ this.markCancelled();
153
171
  }
154
172
  catch {
155
173
  // A transient failure is not a cancellation; keep working.
@@ -198,6 +216,7 @@ class Runner {
198
216
  images;
199
217
  workDir;
200
218
  network;
219
+ limits;
201
220
  idleSeconds;
202
221
  wake;
203
222
  // When this runner last had something to do. A wake request counts, so that
@@ -223,6 +242,7 @@ class Runner {
223
242
  this.images = { ...exports.DEFAULT_IMAGES, ...(config.images ?? {}) };
224
243
  this.workDir = config.workDir ?? (0, job_1.defaultWorkDir)();
225
244
  this.network = config.network;
245
+ this.limits = config.limits;
226
246
  this.idleSeconds = config.idleSeconds && config.idleSeconds > 0 ? config.idleSeconds : null;
227
247
  this.wake = config.wakePort && config.wakeSecret ? { port: config.wakePort, secret: config.wakeSecret } : null;
228
248
  const actionCache = config.cacheDir ? path.join(config.cacheDir, 'actions') : (0, actions_1.defaultActionCacheDir)();
@@ -294,6 +314,17 @@ class Runner {
294
314
  throw new Error(`none of the requested labels (${this.labels.join(', ')}) are registered for this runner (${identity.labels.join(', ')})`);
295
315
  }
296
316
  this.name = identity.name;
317
+ // Whatever a previous life of this runner left running. A container is
318
+ // removed when its job ends, so any found here belong to a job that was
319
+ // under way when the runner last died, and nothing else will ever remove them.
320
+ try {
321
+ const stale = await (0, docker_1.removeStaleJobContainers)();
322
+ if (stale > 0)
323
+ console.log(`Removed ${stale} job container(s) left behind by an earlier run.`);
324
+ }
325
+ catch (e) {
326
+ console.log(`Could not look for leftover job containers: ${e instanceof Error ? e.message : String(e)}`);
327
+ }
297
328
  // Listening before the banner, because a runner that cannot be woken
298
329
  // should say so at the top rather than after claiming to be ready. A
299
330
  // failure to bind throws out of here: a runner meant to stop when idle
@@ -313,6 +344,12 @@ class Runner {
313
344
  console.log(` workdir: ${this.workDir}`);
314
345
  console.log(` labels: ${labels.join(', ')}`);
315
346
  console.log(` serving: ${identity.allow.join(', ')}`);
347
+ const limits = [
348
+ `pids ${this.limits?.pids ?? docker_1.DEFAULT_PIDS_LIMIT}`,
349
+ this.limits?.memory ? `memory ${this.limits.memory}` : null,
350
+ this.limits?.cpus ? `cpus ${this.limits.cpus}` : null,
351
+ ].filter((l) => l !== null);
352
+ console.log(` limits: ${limits.join(', ')} per job`);
316
353
  if (this.idleSeconds !== null)
317
354
  console.log(` idle: stopping after ${this.idleSeconds}s with no job`);
318
355
  if (listener)
@@ -398,6 +435,7 @@ async function executeSpec(spec, deps) {
398
435
  log: (i, line) => client.log(i, line),
399
436
  progress: (steps) => void client.progress(steps),
400
437
  cancelled: () => client.cancelled(),
438
+ onCancel: (listener) => client.onCancel(listener),
401
439
  });
402
440
  }
403
441
  catch (e) {
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.DockerError = void 0;
3
+ exports.JOB_CONTAINER_LABEL = exports.DEFAULT_PIDS_LIMIT = exports.DockerError = void 0;
4
4
  exports.setContainerEngine = setContainerEngine;
5
5
  exports.containerEngine = containerEngine;
6
6
  exports.dockerAvailable = dockerAvailable;
@@ -8,10 +8,9 @@ exports.probeEngine = probeEngine;
8
8
  exports.imagePresent = imagePresent;
9
9
  exports.pullImage = pullImage;
10
10
  exports.startContainer = startContainer;
11
+ exports.removeStaleJobContainers = removeStaleJobContainers;
11
12
  exports.removeContainer = removeContainer;
12
13
  exports.execInContainer = execInContainer;
13
- exports.writeFileInContainer = writeFileInContainer;
14
- exports.readFileInContainer = readFileInContainer;
15
14
  const child_process_1 = require("child_process");
16
15
  // The Docker surface the runner needs, as thin wrappers over the CLI rather
17
16
  // than the Engine API socket. Shelling out keeps the runner dependency-free
@@ -94,12 +93,24 @@ function pullImage(image, onLine) {
94
93
  child.on('close', (code) => code === 0 ? resolve() : reject(new DockerError(`docker pull ${image} exited ${code}`)));
95
94
  });
96
95
  }
96
+ /**
97
+ * Processes without a bound are the cheapest way to take a machine down and
98
+ * the one no job legitimately needs, so the ceiling is on unless the operator
99
+ * says otherwise; a build that wants more than this many at once is rare
100
+ * enough to be asked for by name. Memory and CPU have no default: what a job
101
+ * may reasonably want depends on the machine, and a cap that is wrong for it
102
+ * fails honest builds rather than protecting anything.
103
+ */
104
+ exports.DEFAULT_PIDS_LIMIT = 4096;
105
+ /** The label every job container carries, so a runner can find the ones a crash left behind. */
106
+ exports.JOB_CONTAINER_LABEL = 'mochi.job';
97
107
  // One container per job, kept alive by a sleep so each step can exec into
98
108
  // it. This is how a container job behaves on GitHub: steps share a
99
109
  // filesystem and a process namespace, and anything a step installs is there
100
110
  // for the next one.
101
111
  async function startContainer(opts) {
102
112
  const args = ['run', '--detach', '--name', opts.name, '--workdir', opts.workdir, '--entrypoint', ''];
113
+ args.push('--label', `${exports.JOB_CONTAINER_LABEL}=1`);
103
114
  for (const b of opts.binds) {
104
115
  args.push('--volume', `${b.host}:${b.container}${b.readonly ? ':ro' : ''}`);
105
116
  }
@@ -108,10 +119,36 @@ async function startContainer(opts) {
108
119
  }
109
120
  if (opts.network)
110
121
  args.push('--network', opts.network);
122
+ const limits = opts.limits ?? {};
123
+ const pids = limits.pids ?? exports.DEFAULT_PIDS_LIMIT;
124
+ if (pids > 0)
125
+ args.push('--pids-limit', String(pids));
126
+ if (limits.memory)
127
+ args.push('--memory', limits.memory);
128
+ if (limits.cpus)
129
+ args.push('--cpus', limits.cpus);
111
130
  args.push(opts.image, 'sh', '-c', 'while true; do sleep 3600; done');
112
131
  const { stdout } = await run(args);
113
132
  return stdout.trim();
114
133
  }
134
+ /**
135
+ * Remove every job container on this daemon, and say how many there were.
136
+ *
137
+ * A job's container is removed when the job finishes or times out, and by
138
+ * nothing else: a runner killed mid-job, or a machine that lost power, leaves
139
+ * a container running its idle sleep for good, and on a machine whose docker
140
+ * directory is a persistent volume, they accumulate across restarts. This
141
+ * runs when a runner starts, when by construction no job of its own is in
142
+ * flight. A daemon shared with a second runner would lose that runner's
143
+ * running job here, which is one more reason not to share one.
144
+ */
145
+ async function removeStaleJobContainers() {
146
+ const { stdout } = await run(['ps', '--all', '--quiet', '--filter', `label=${exports.JOB_CONTAINER_LABEL}`]);
147
+ const ids = stdout.split('\n').map((l) => l.trim()).filter((l) => l !== '');
148
+ for (const id of ids)
149
+ await removeContainer(id);
150
+ return ids.length;
151
+ }
115
152
  async function removeContainer(id) {
116
153
  try {
117
154
  await run(['rm', '--force', '--volumes', id]);
@@ -170,28 +207,3 @@ function execInContainer(id, argv, onLine, opts = {}) {
170
207
  });
171
208
  return { child, done };
172
209
  }
173
- // Copy a file into a container. `docker cp` from stdin needs a tar stream, so
174
- // the simpler route for the small files the runner writes (step scripts, the
175
- // environment files) is to pipe the bytes through a shell redirect.
176
- function writeFileInContainer(id, containerPath, content) {
177
- return new Promise((resolve, reject) => {
178
- const child = (0, child_process_1.spawn)(engineBin, ['exec', '--interactive', id, 'sh', '-c', `cat > "$0"`, containerPath], { stdio: ['pipe', 'ignore', 'pipe'] });
179
- let stderr = '';
180
- child.stderr?.on('data', (b) => {
181
- stderr += b.toString('utf8');
182
- });
183
- child.on('error', (e) => reject(new DockerError(e.message)));
184
- child.on('close', (code) => code === 0 ? resolve() : reject(new DockerError(`writing ${containerPath} failed: ${stderr.trim()}`)));
185
- child.stdin?.write(content);
186
- child.stdin?.end();
187
- });
188
- }
189
- async function readFileInContainer(id, containerPath) {
190
- try {
191
- const { stdout } = await run(['exec', id, 'sh', '-c', `cat "$0" 2>/dev/null || true`, containerPath]);
192
- return stdout;
193
- }
194
- catch {
195
- return '';
196
- }
197
- }
@@ -0,0 +1,77 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.DownloadTooLarge = void 0;
37
+ exports.downloadTo = downloadTo;
38
+ const fs = __importStar(require("fs"));
39
+ const stream_1 = require("stream");
40
+ const promises_1 = require("stream/promises");
41
+ // A response body written to a file, with a ceiling.
42
+ //
43
+ // The two callers fetch archives -- an action's source from a forge, a node
44
+ // build from nodejs.org -- and used to read each whole into one Buffer before
45
+ // writing it out. The size of that Buffer was whatever the far end chose to
46
+ // serve, and a `uses:` line names the far end, so a workflow could point the
47
+ // runner at a tarball the size of its memory. Streaming through a counter
48
+ // bounds the transfer at the cap and costs one chunk of memory at a time.
49
+ class DownloadTooLarge extends Error {
50
+ }
51
+ exports.DownloadTooLarge = DownloadTooLarge;
52
+ async function downloadTo(res, file, maxBytes) {
53
+ if (!res.body)
54
+ throw new Error('the response had no body');
55
+ // A declared length over the cap is refused before a byte is read; a body
56
+ // that declares less than it sends, or nothing, is caught as it arrives.
57
+ const declared = Number(res.headers.get('content-length') ?? '0');
58
+ if (declared > maxBytes)
59
+ throw new DownloadTooLarge(`${declared} bytes, over the limit of ${maxBytes}`);
60
+ let seen = 0;
61
+ const cap = new stream_1.Transform({
62
+ transform(chunk, _encoding, callback) {
63
+ seen += chunk.length;
64
+ if (seen > maxBytes)
65
+ callback(new DownloadTooLarge(`more than ${maxBytes} bytes`));
66
+ else
67
+ callback(null, chunk);
68
+ },
69
+ });
70
+ try {
71
+ await (0, promises_1.pipeline)(stream_1.Readable.fromWeb(res.body), cap, fs.createWriteStream(file));
72
+ }
73
+ catch (e) {
74
+ fs.rmSync(file, { force: true });
75
+ throw e;
76
+ }
77
+ }
@@ -40,6 +40,7 @@ const fs = __importStar(require("fs"));
40
40
  const os = __importStar(require("os"));
41
41
  const path = __importStar(require("path"));
42
42
  const docker_1 = require("./docker");
43
+ const download_1 = require("./download");
43
44
  // JavaScript actions need a node interpreter inside the job's container.
44
45
  // GitHub's runner ships its own and mounts it in, which is why an image
45
46
  // without node still runs `actions/checkout`. We do the same, but only when
@@ -49,6 +50,10 @@ const docker_1 = require("./docker");
49
50
  // The downloaded builds are the official linux-x64/arm64 ones, which are
50
51
  // linked against glibc. An Alpine or other musl image therefore cannot use
51
52
  // them, and gets a clear message rather than a confusing loader error.
53
+ // An official node build is around fifty megabytes compressed; a download
54
+ // several times that is not one, whatever nodejs.org or the proxy in front
55
+ // of it is serving.
56
+ const MAX_NODE_BYTES = 256 * 1024 * 1024;
52
57
  class ExternalsError extends Error {
53
58
  }
54
59
  exports.ExternalsError = ExternalsError;
@@ -160,7 +165,13 @@ class Externals {
160
165
  fs.rmSync(tmp, { recursive: true, force: true });
161
166
  fs.mkdirSync(tmp);
162
167
  const tarball = path.join(tmp, 'node.tar.gz');
163
- fs.writeFileSync(tarball, Buffer.from(await res.arrayBuffer()));
168
+ try {
169
+ await (0, download_1.downloadTo)(res, tarball, MAX_NODE_BYTES);
170
+ }
171
+ catch (e) {
172
+ fs.rmSync(tmp, { recursive: true, force: true });
173
+ throw new ExternalsError(`could not download node ${version}: ${e instanceof Error ? e.message : e}`);
174
+ }
164
175
  const extracted = path.join(tmp, 'out');
165
176
  fs.mkdirSync(extracted);
166
177
  await run('tar', ['-xzf', tarball, '-C', extracted, '--strip-components=1']);
@@ -89,13 +89,23 @@ async function runJob(spec, ctx, hooks) {
89
89
  let containerId = null;
90
90
  // `timeout-minutes` is enforced here rather than by the server: the lease
91
91
  // sweep only notices a runner that has stopped heartbeating, and a job stuck
92
- // inside a step heartbeats perfectly well. Cancellation is cooperative and
93
- // checked between steps, so a deadline that only set the flag would never
94
- // interrupt the step that is actually hanging; removing the container is what
95
- // makes the in-flight exec fail and the loop unwind.
92
+ // inside a step heartbeats perfectly well. The cancelled flag is checked
93
+ // between steps, so neither a deadline nor a cancel that only set it would
94
+ // ever interrupt the step that is actually hanging; removing the container is
95
+ // what makes the in-flight exec fail and the loop unwind. Both do that, and
96
+ // `stopped` records that they did, so nothing tries to use the container
97
+ // afterwards.
96
98
  let timedOut = false;
99
+ let stopped = false;
97
100
  let deadline = null;
98
101
  const cancelled = () => hooks.cancelled() || timedOut;
102
+ const stopContainer = (why) => {
103
+ if (stopped || !containerId)
104
+ return;
105
+ stopped = true;
106
+ log(why);
107
+ void (0, docker_1.removeContainer)(containerId);
108
+ };
99
109
  const finish = async (conclusion) => {
100
110
  currentStep = RUNNER_STEP;
101
111
  if (deadline)
@@ -158,10 +168,10 @@ async function runJob(spec, ctx, hooks) {
158
168
  env,
159
169
  workdir: context_1.WORKSPACE,
160
170
  network: ctx.network,
171
+ limits: ctx.limits,
161
172
  });
162
173
  await (0, docker_1.execInContainer)(containerId, ['mkdir', '-p', context_1.RUNNER_TOOL_CACHE], () => { }).done;
163
174
  log(`Container started from ${image}`);
164
- const id = containerId;
165
175
  // A runner and its server are separate long-running processes and need not
166
176
  // be the same version, so a spec that carries no usable timeout falls back
167
177
  // to GitHub's six hours rather than to NaN, which setTimeout would treat as
@@ -174,9 +184,13 @@ async function runJob(spec, ctx, hooks) {
174
184
  const delay = Math.min(limit * 60_000, 2 ** 31 - 1);
175
185
  deadline = setTimeout(() => {
176
186
  timedOut = true;
177
- log(`Job exceeded its timeout of ${limit} minute${limit === 1 ? '' : 's'}`);
178
- void (0, docker_1.removeContainer)(id);
187
+ stopContainer(`Job exceeded its timeout of ${limit} minute${limit === 1 ? '' : 's'}`);
179
188
  }, delay);
189
+ // A cancel that arrives while a step is running is served the same way.
190
+ // One that arrived earlier is caught by the step loop before it runs
191
+ // anything, and the listener fires at once, so the container goes either
192
+ // way.
193
+ hooks.onCancel?.(() => stopContainer('The run was cancelled; stopping the job container'));
180
194
  const exec = {
181
195
  containerId,
182
196
  spec,
@@ -231,10 +245,11 @@ async function runJob(spec, ctx, hooks) {
231
245
  });
232
246
  failed = failed || result.failed;
233
247
  // Post steps run even when the job failed or was cancelled, which is what
234
- // makes them useful for cleanup. A timeout is the exception: the container
235
- // they would run in is the thing that was torn down to stop the job, so
236
- // attempting them would only add a failure for each one to the log.
237
- if (rt.postHooks.length && !timedOut) {
248
+ // makes them useful for cleanup. A job stopped mid-step, by its timeout or
249
+ // by a cancel, is the exception: the container they would run in is the
250
+ // thing that was torn down to stop the job, so attempting them would only
251
+ // add a failure for each one to the log.
252
+ if (rt.postHooks.length && !stopped) {
238
253
  currentStep = RUNNER_STEP;
239
254
  await (0, steps_1.runPostHooks)(exec, failed);
240
255
  }
@@ -77,9 +77,11 @@ async function runSteps(steps, scope, ctx, hooks = {}) {
77
77
  }),
78
78
  functions: (0, context_1.statusFunctions)(failed || ctx.status().failed, ctx.status().cancelled),
79
79
  });
80
+ // A step that failed under continue-on-error never set `failed`, so it
81
+ // does not close the gate for the steps after it.
80
82
  let shouldRun;
81
83
  try {
82
- shouldRun = step.if === undefined ? !failed : (0, expr_1.evalCondition)(step.if, evalEnv());
84
+ shouldRun = (0, expr_1.evalGatedCondition)(step.if, !failed, evalEnv());
83
85
  }
84
86
  catch (e) {
85
87
  ctx.log(`Error evaluating if: ${e instanceof Error ? e.message : String(e)}`);
@@ -213,9 +215,42 @@ async function makeFileCommands(ctx, tag) {
213
215
  state: `${context_1.FILE_COMMAND_DIR}/state-${tag}`,
214
216
  };
215
217
  for (const f of Object.values(files))
216
- await (0, docker_1.writeFileInContainer)(ctx.containerId, f, '');
218
+ writeJobFile(ctx, f, '');
217
219
  return files;
218
220
  }
221
+ // The file-command directory is a bind mount of a directory the runner owns,
222
+ // so the files a step appends to are created and read back on the host. This
223
+ // used to go through `docker exec --interactive ... cat`, one exec per file,
224
+ // which has a race: when stdin is closed before the exec has attached, the
225
+ // EOF is lost and cat waits forever, and so did the job, until its timeout.
226
+ // An empty file is the widest case of that window, since there is no data
227
+ // write to sequence against. The files are world-writable because the step
228
+ // appends to them as whatever user the image runs, which need not be the
229
+ // runner's.
230
+ function writeJobFile(ctx, containerPath, content) {
231
+ const host = ctx.hostPath(containerPath);
232
+ if (host === null)
233
+ throw new Error(`${containerPath} is not on a mount the runner can write`);
234
+ fs.writeFileSync(host, content);
235
+ try {
236
+ fs.chmodSync(host, 0o666);
237
+ }
238
+ catch {
239
+ // the runner and the container being the same user, which is the common
240
+ // case, needs no wider mode
241
+ }
242
+ }
243
+ function readJobFile(ctx, containerPath) {
244
+ const host = ctx.hostPath(containerPath);
245
+ if (host === null)
246
+ return '';
247
+ try {
248
+ return fs.readFileSync(host, 'utf8');
249
+ }
250
+ catch {
251
+ return '';
252
+ }
253
+ }
219
254
  function fileCommandEnv(files) {
220
255
  return {
221
256
  GITHUB_OUTPUT: files.output,
@@ -228,20 +263,20 @@ function fileCommandEnv(files) {
228
263
  // Read back everything a step wrote through the file commands, and fold it
229
264
  // into the scope and the job runtime.
230
265
  async function collectFileCommands(ctx, scope, files, stepId) {
231
- const outputText = await (0, docker_1.readFileInContainer)(ctx.containerId, files.output);
266
+ const outputText = readJobFile(ctx, files.output);
232
267
  const parsed = (0, context_1.parseKeyValueFile)(outputText);
233
268
  if (stepId && Object.keys(parsed).length) {
234
269
  scope.outputs[stepId] = { ...(scope.outputs[stepId] ?? {}), ...parsed };
235
270
  }
236
- const envText = await (0, docker_1.readFileInContainer)(ctx.containerId, files.envFile);
271
+ const envText = readJobFile(ctx, files.envFile);
237
272
  Object.assign(ctx.rt.env, (0, context_1.parseKeyValueFile)(envText));
238
- const pathText = await (0, docker_1.readFileInContainer)(ctx.containerId, files.pathFile);
273
+ const pathText = readJobFile(ctx, files.pathFile);
239
274
  for (const line of pathText.split('\n')) {
240
275
  const p = line.trim();
241
276
  if (p !== '' && !ctx.rt.extraPath.includes(p))
242
277
  ctx.rt.extraPath.unshift(p);
243
278
  }
244
- const summaryText = await (0, docker_1.readFileInContainer)(ctx.containerId, files.summary);
279
+ const summaryText = readJobFile(ctx, files.summary);
245
280
  if (summaryText.trim() !== '')
246
281
  ctx.rt.summaries.push(summaryText);
247
282
  }
@@ -332,7 +367,7 @@ async function runShellStep(step, scope, ctx, stepEnv, evalEnv, index) {
332
367
  const tag = envFileTag(index);
333
368
  const files = await makeFileCommands(ctx, tag);
334
369
  const scriptPath = `${context_1.FILE_COMMAND_DIR}/step-${tag}`;
335
- await (0, docker_1.writeFileInContainer)(ctx.containerId, scriptPath, script.endsWith('\n') ? script : script + '\n');
370
+ writeJobFile(ctx, scriptPath, script.endsWith('\n') ? script : script + '\n');
336
371
  const shell = step.shell ?? (scope.depth === 0 ? ctx.spec.defaults?.shell : undefined);
337
372
  const env = {
338
373
  ...stepEnv,