dtu-github-actions 0.6.0 → 0.7.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.
@@ -0,0 +1,333 @@
1
+ import { describe, it, expect, beforeEach, beforeAll, afterAll } from "vitest";
2
+ import { state, getActionTarballsDir } from "../../store.js";
3
+ import { bootstrapAndReturnApp } from "../../index.js";
4
+ import fs from "node:fs";
5
+ import path from "node:path";
6
+ let PORT;
7
+ describe("Action Tarball Cache", () => {
8
+ let server;
9
+ beforeAll(async () => {
10
+ state.reset();
11
+ const app = await bootstrapAndReturnApp();
12
+ return new Promise((resolve) => {
13
+ server = app.listen(0, () => {
14
+ const address = server.server?.address();
15
+ PORT = address.port;
16
+ resolve();
17
+ });
18
+ });
19
+ });
20
+ beforeEach(() => {
21
+ state.reset();
22
+ // Clean up any tarball cache files from prior tests
23
+ const dir = getActionTarballsDir();
24
+ if (fs.existsSync(dir)) {
25
+ for (const file of fs.readdirSync(dir)) {
26
+ try {
27
+ fs.unlinkSync(path.join(dir, file));
28
+ }
29
+ catch { }
30
+ }
31
+ }
32
+ });
33
+ afterAll(async () => {
34
+ // Clean up tarball cache dir
35
+ const dir = getActionTarballsDir();
36
+ if (fs.existsSync(dir)) {
37
+ for (const file of fs.readdirSync(dir)) {
38
+ try {
39
+ fs.unlinkSync(path.join(dir, file));
40
+ }
41
+ catch { }
42
+ }
43
+ }
44
+ await new Promise((resolve) => {
45
+ if (server?.server) {
46
+ server.server.close(() => resolve());
47
+ }
48
+ else {
49
+ resolve();
50
+ }
51
+ });
52
+ });
53
+ // ── Action tarball proxy route ───────────────────────────────────────────────
54
+ it("should serve a cached tarball from disk (cache hit)", async () => {
55
+ const baseUrl = `http://localhost:${PORT}`;
56
+ // Pre-seed a tarball file on disk
57
+ const dir = getActionTarballsDir();
58
+ fs.mkdirSync(dir, { recursive: true });
59
+ const tarballPath = path.join(dir, "actions__checkout@v4.tar.gz");
60
+ const content = Buffer.from("fake-tarball-content");
61
+ fs.writeFileSync(tarballPath, content);
62
+ // Request should serve from disk
63
+ const res = await fetch(`${baseUrl}/_dtu/action-tarball/actions/checkout/v4`);
64
+ expect(res.status).toBe(200);
65
+ expect(res.headers.get("content-type")).toBe("application/x-tar");
66
+ expect(res.headers.get("content-length")).toBe(String(content.length));
67
+ const body = Buffer.from(await res.arrayBuffer());
68
+ expect(body).toEqual(content);
69
+ });
70
+ it("should return error for cache miss when GitHub is unreachable", async () => {
71
+ const baseUrl = `http://localhost:${PORT}`;
72
+ // No cached file exists, and the proxy will try to fetch from GitHub.
73
+ // Since the test env can't reach GitHub reliably, we just verify the route
74
+ // doesn't crash and returns a response (either 200 if GitHub responds, or
75
+ // 502/error if it can't reach GitHub). The key is no server crash.
76
+ const res = await fetch(`${baseUrl}/_dtu/action-tarball/nonexistent/repo/v999`);
77
+ // Should get some response (not a connection error)
78
+ expect(res.status).toBeGreaterThanOrEqual(200);
79
+ });
80
+ it("should not match slash-containing refs as a single route param", async () => {
81
+ const baseUrl = `http://localhost:${PORT}`;
82
+ const dir = getActionTarballsDir();
83
+ fs.mkdirSync(dir, { recursive: true });
84
+ // This is the cache file that would be used if "refs/heads/main" were accepted
85
+ // as a single ref value and sanitized to "refs-heads-main".
86
+ const tarballPath = path.join(dir, "my-org__my-repo@refs-heads-main.tar.gz");
87
+ fs.writeFileSync(tarballPath, "test-content");
88
+ const res = await fetch(`${baseUrl}/_dtu/action-tarball/my-org/my-repo/refs/heads/main`);
89
+ // Polka does not bind :ref across slashes, so this URL does not hit the route
90
+ // as a single ref value and therefore must not serve the cached tarball above.
91
+ expect(res.status).toBe(404);
92
+ await expect(res.text()).resolves.not.toBe("test-content");
93
+ });
94
+ it("should serve different tarballs for different repos", async () => {
95
+ const baseUrl = `http://localhost:${PORT}`;
96
+ const dir = getActionTarballsDir();
97
+ fs.mkdirSync(dir, { recursive: true });
98
+ const content1 = Buffer.from("tarball-for-checkout");
99
+ const content2 = Buffer.from("tarball-for-setup-node");
100
+ fs.writeFileSync(path.join(dir, "actions__checkout@v4.tar.gz"), content1);
101
+ fs.writeFileSync(path.join(dir, "actions__setup-node@v4.tar.gz"), content2);
102
+ const res1 = await fetch(`${baseUrl}/_dtu/action-tarball/actions/checkout/v4`);
103
+ const res2 = await fetch(`${baseUrl}/_dtu/action-tarball/actions/setup-node/v4`);
104
+ expect(res1.status).toBe(200);
105
+ expect(res2.status).toBe(200);
106
+ const body1 = Buffer.from(await res1.arrayBuffer());
107
+ const body2 = Buffer.from(await res2.arrayBuffer());
108
+ expect(body1).toEqual(content1);
109
+ expect(body2).toEqual(content2);
110
+ });
111
+ // ── Action download info URL rewriting ───────────────────────────────────────
112
+ it("should rewrite tarball URLs to local proxy", async () => {
113
+ const baseUrl = `http://localhost:${PORT}`;
114
+ const res = await fetch(`${baseUrl}/_apis/distributedtask/hubs/Hub/plans/Plan/actiondownloadinfo`, {
115
+ method: "POST",
116
+ headers: { "Content-Type": "application/json" },
117
+ body: JSON.stringify({
118
+ actions: [
119
+ { nameWithOwner: "actions/checkout", ref: "v4" },
120
+ { nameWithOwner: "actions/setup-node", ref: "v4" },
121
+ ],
122
+ }),
123
+ });
124
+ expect(res.status).toBe(200);
125
+ const data = await res.json();
126
+ // Both actions should have tarballUrls pointing at the local proxy
127
+ const checkoutInfo = data.actions["actions/checkout@v4"];
128
+ expect(checkoutInfo).toBeDefined();
129
+ expect(checkoutInfo.tarballUrl).toBe(`${baseUrl}/_dtu/action-tarball/actions/checkout/v4`);
130
+ expect(checkoutInfo.zipballUrl).toBe(`${baseUrl}/_dtu/action-tarball/actions/checkout/v4`);
131
+ const setupNodeInfo = data.actions["actions/setup-node@v4"];
132
+ expect(setupNodeInfo).toBeDefined();
133
+ expect(setupNodeInfo.tarballUrl).toBe(`${baseUrl}/_dtu/action-tarball/actions/setup-node/v4`);
134
+ });
135
+ it("should strip sub-paths from action names for tarball URL", async () => {
136
+ const baseUrl = `http://localhost:${PORT}`;
137
+ const res = await fetch(`${baseUrl}/_apis/distributedtask/hubs/Hub/plans/Plan/actiondownloadinfo`, {
138
+ method: "POST",
139
+ headers: { "Content-Type": "application/json" },
140
+ body: JSON.stringify({
141
+ actions: [
142
+ { nameWithOwner: "actions/cache/save", ref: "v3" },
143
+ { nameWithOwner: "actions/cache/restore", ref: "v3" },
144
+ ],
145
+ }),
146
+ });
147
+ expect(res.status).toBe(200);
148
+ const data = await res.json();
149
+ // "actions/cache/save" should be rewritten to use "actions/cache" repo
150
+ const saveInfo = data.actions["actions/cache/save@v3"];
151
+ expect(saveInfo.tarballUrl).toBe(`${baseUrl}/_dtu/action-tarball/actions/cache/v3`);
152
+ // "actions/cache/restore" should also use "actions/cache" repo
153
+ const restoreInfo = data.actions["actions/cache/restore@v3"];
154
+ expect(restoreInfo.tarballUrl).toBe(`${baseUrl}/_dtu/action-tarball/actions/cache/v3`);
155
+ });
156
+ it("should include resolvedSha as a deterministic hash", async () => {
157
+ const baseUrl = `http://localhost:${PORT}`;
158
+ const res = await fetch(`${baseUrl}/_apis/distributedtask/hubs/Hub/plans/Plan/actiondownloadinfo`, {
159
+ method: "POST",
160
+ headers: { "Content-Type": "application/json" },
161
+ body: JSON.stringify({
162
+ actions: [{ nameWithOwner: "actions/checkout", ref: "v4" }],
163
+ }),
164
+ });
165
+ const data = await res.json();
166
+ const info = data.actions["actions/checkout@v4"];
167
+ // resolvedSha should be a 40-char hex string (SHA-1)
168
+ expect(info.resolvedSha).toMatch(/^[0-9a-f]{40}$/);
169
+ // Same input should produce the same hash (deterministic)
170
+ const res2 = await fetch(`${baseUrl}/_apis/distributedtask/hubs/Hub/plans/Plan/actiondownloadinfo`, {
171
+ method: "POST",
172
+ headers: { "Content-Type": "application/json" },
173
+ body: JSON.stringify({
174
+ actions: [{ nameWithOwner: "actions/checkout", ref: "v4" }],
175
+ }),
176
+ });
177
+ const data2 = await res2.json();
178
+ expect(data2.actions["actions/checkout@v4"].resolvedSha).toBe(info.resolvedSha);
179
+ });
180
+ it("should handle empty actions array", async () => {
181
+ const baseUrl = `http://localhost:${PORT}`;
182
+ const res = await fetch(`${baseUrl}/_apis/distributedtask/hubs/Hub/plans/Plan/actiondownloadinfo`, {
183
+ method: "POST",
184
+ headers: { "Content-Type": "application/json" },
185
+ body: JSON.stringify({ actions: [] }),
186
+ });
187
+ expect(res.status).toBe(200);
188
+ const data = await res.json();
189
+ expect(data.actions).toEqual({});
190
+ });
191
+ });
192
+ // ── writeStepOutputLines group filtering ─────────────────────────────────────
193
+ // The writeStepOutputLines function is internal to registerActionRoutes, so we
194
+ // test it via the timeline record feed endpoint which calls it.
195
+ describe("Step output group filtering", () => {
196
+ let server;
197
+ let logDir;
198
+ const planId = "test-plan-group";
199
+ const timelineId = "test-timeline-group";
200
+ const recordId = "test-record-group";
201
+ beforeAll(async () => {
202
+ state.reset();
203
+ const app = await bootstrapAndReturnApp();
204
+ return new Promise((resolve) => {
205
+ server = app.listen(0, () => {
206
+ const address = server.server?.address();
207
+ PORT = address.port;
208
+ resolve();
209
+ });
210
+ });
211
+ });
212
+ beforeEach(() => {
213
+ state.reset();
214
+ // Set up log dir for step output writing
215
+ logDir = fs.mkdtempSync("/tmp/dtu-group-test-");
216
+ state.planToLogDir.set(planId, logDir);
217
+ state.recordToStepName.set(recordId, "test-step");
218
+ });
219
+ afterAll(async () => {
220
+ await new Promise((resolve) => {
221
+ if (server?.server) {
222
+ server.server.close(() => resolve());
223
+ }
224
+ else {
225
+ resolve();
226
+ }
227
+ });
228
+ });
229
+ function postFeed(lines) {
230
+ return fetch(`http://localhost:${PORT}/_apis/distributedtask/hubs/Hub/plans/${planId}/timelines/${timelineId}/records/${recordId}/feed`, {
231
+ method: "POST",
232
+ headers: { "Content-Type": "application/json" },
233
+ body: JSON.stringify({ value: lines }),
234
+ });
235
+ }
236
+ function readStepLog() {
237
+ const logFile = path.join(logDir, "steps", "test-step.log");
238
+ return fs.existsSync(logFile) ? fs.readFileSync(logFile, "utf-8") : "";
239
+ }
240
+ it("should strip ##[group]/##[endgroup] markers and their contents", async () => {
241
+ await postFeed([
242
+ "visible line 1",
243
+ "##[group]Downloading action",
244
+ "hidden inside group",
245
+ "also hidden",
246
+ "##[endgroup]",
247
+ "visible line 2",
248
+ ]);
249
+ const log = readStepLog();
250
+ expect(log).toContain("visible line 1");
251
+ expect(log).toContain("visible line 2");
252
+ expect(log).not.toContain("hidden inside group");
253
+ expect(log).not.toContain("also hidden");
254
+ expect(log).not.toContain("##[group]");
255
+ expect(log).not.toContain("##[endgroup]");
256
+ });
257
+ it("should handle nested groups (flat — no true nesting)", async () => {
258
+ await postFeed([
259
+ "before",
260
+ "##[group]outer",
261
+ "inside outer",
262
+ "##[endgroup]",
263
+ "between",
264
+ "##[group]inner",
265
+ "inside inner",
266
+ "##[endgroup]",
267
+ "after",
268
+ ]);
269
+ const log = readStepLog();
270
+ expect(log).toContain("before");
271
+ expect(log).toContain("between");
272
+ expect(log).toContain("after");
273
+ expect(log).not.toContain("inside outer");
274
+ expect(log).not.toContain("inside inner");
275
+ });
276
+ it("should suppress empty lines inside groups", async () => {
277
+ await postFeed([
278
+ "visible",
279
+ "##[group]Group start",
280
+ "",
281
+ "hidden in group",
282
+ "",
283
+ "##[endgroup]",
284
+ "also visible",
285
+ ]);
286
+ const log = readStepLog();
287
+ expect(log).toContain("visible");
288
+ expect(log).toContain("also visible");
289
+ expect(log).not.toContain("hidden in group");
290
+ });
291
+ it("should still filter ##[command] and runner internal lines", async () => {
292
+ await postFeed([
293
+ "real output",
294
+ "[command]/usr/bin/npm test",
295
+ "##[debug]some debug info",
296
+ "[RUNNER 2025-01-01 00:00:00Z INFO Something internal",
297
+ "more real output",
298
+ ]);
299
+ const log = readStepLog();
300
+ expect(log).toContain("real output");
301
+ expect(log).toContain("more real output");
302
+ expect(log).not.toContain("[command]");
303
+ expect(log).not.toContain("##[debug]");
304
+ expect(log).not.toContain("[RUNNER");
305
+ });
306
+ it("should strip BOM and timestamp prefixes", async () => {
307
+ await postFeed([
308
+ "\uFEFF2025-01-01T00:00:00.000Z actual content",
309
+ "2025-06-15T12:30:45.123Z another line",
310
+ ]);
311
+ const log = readStepLog();
312
+ expect(log).toContain("actual content");
313
+ expect(log).toContain("another line");
314
+ expect(log).not.toContain("2025-01-01T");
315
+ expect(log).not.toContain("\uFEFF");
316
+ });
317
+ it("should parse and persist agent-ci-output lines", async () => {
318
+ await postFeed([
319
+ "normal output",
320
+ "::agent-ci-output::result=success",
321
+ "::agent-ci-output::version=1.2.3",
322
+ ]);
323
+ const log = readStepLog();
324
+ expect(log).toContain("normal output");
325
+ expect(log).not.toContain("agent-ci-output");
326
+ // Check outputs.json was written
327
+ const outputsPath = path.join(logDir, "outputs.json");
328
+ expect(fs.existsSync(outputsPath)).toBe(true);
329
+ const outputs = JSON.parse(fs.readFileSync(outputsPath, "utf-8"));
330
+ expect(outputs.result).toBe("success");
331
+ expect(outputs.version).toBe("1.2.3");
332
+ });
333
+ });
@@ -173,11 +173,15 @@ export function createJobResponse(jobId, payload, baseUrl, planId) {
173
173
  "build.repository.name": { Value: repoFullName, IsSecret: false },
174
174
  "build.repository.uri": { Value: `https://github.com/${repoFullName}`, IsSecret: false },
175
175
  };
176
- // Merge all step-level env: vars into the job Variables.
176
+ // Merge job-level env: into Variables first, then step-level env: (step wins on conflict).
177
177
  // The runner exports every Variable as a process env var for all steps, so this is the
178
- // reliable mechanism to get DB_HOST=mysql, DB_PORT=3306 etc. into the step subprocess.
179
- // (Step-scoped env would require full template compilation; job-level Variables are sufficient
180
- // for DB credentials / CI flags that are consistent across steps.)
178
+ // reliable mechanism to get AGENT_CI_LOCAL, DB_HOST, DB_PORT etc. into the step subprocess
179
+ // and into the runner's expression engine (${{ env.AGENT_CI_LOCAL }}).
180
+ if (payload.env && typeof payload.env === "object") {
181
+ for (const [key, val] of Object.entries(payload.env)) {
182
+ Variables[key] = { Value: String(val), IsSecret: false };
183
+ }
184
+ }
181
185
  for (const step of payload.steps || []) {
182
186
  if (step.Env && typeof step.Env === "object") {
183
187
  for (const [key, val] of Object.entries(step.Env)) {
@@ -214,14 +218,15 @@ export function createJobResponse(jobId, payload, baseUrl, planId) {
214
218
  },
215
219
  };
216
220
  }
217
- // Collect env vars from all steps (job-level env context seen by the runner's expression engine).
218
- // Step-level `env:` blocks in the workflow YAML need to be exposed via ContextData.env so the
219
- // runner can evaluate them. We merge all step envs — slightly broader than per-step scoping but
220
- // correct for typical use (DB_HOST, DB_PORT, CI flags etc.).
221
- const mergedStepEnv = {};
221
+ // Collect env vars from job-level and all steps (seen by the runner's expression engine).
222
+ // Job-level env is applied first, then step-level env wins on conflict.
223
+ const mergedEnv = {};
224
+ if (payload.env && typeof payload.env === "object") {
225
+ Object.assign(mergedEnv, payload.env);
226
+ }
222
227
  for (const step of payload.steps || []) {
223
228
  if (step.Env) {
224
- Object.assign(mergedStepEnv, step.Env);
229
+ Object.assign(mergedEnv, step.Env);
225
230
  }
226
231
  }
227
232
  const ContextData = {
@@ -230,9 +235,9 @@ export function createJobResponse(jobId, payload, baseUrl, planId) {
230
235
  needs: { t: 2, d: [] }, // Empty needs context
231
236
  strategy: { t: 2, d: [] }, // Empty strategy context
232
237
  matrix: { t: 2, d: [] }, // Empty matrix context
233
- // env context: merged from all step-level env: blocks so the runner's expression engine
234
- // can substitute ${{ env.DB_HOST }} etc. during step execution.
235
- ...(Object.keys(mergedStepEnv).length > 0 ? { env: toContextData(mergedStepEnv) } : {}),
238
+ // env context: merged from job-level + step-level env: blocks so the runner's expression
239
+ // engine can substitute ${{ env.AGENT_CI_LOCAL }}, ${{ env.DB_HOST }} etc.
240
+ ...(Object.keys(mergedEnv).length > 0 ? { env: toContextData(mergedEnv) } : {}),
236
241
  };
237
242
  const generatedJobId = crypto.randomUUID();
238
243
  const mockToken = createMockJwt(planId, generatedJobId);
@@ -303,7 +308,7 @@ export function createJobResponse(jobId, payload, baseUrl, planId) {
303
308
  // EnvironmentVariables is IList<TemplateToken> in the runner — each element is a MappingToken.
304
309
  // The runner evaluates each MappingToken and merges into Global.EnvironmentVariables (last wins),
305
310
  // which then populates ExpressionValues["env"] → subprocess env vars.
306
- EnvironmentVariables: Object.keys(mergedStepEnv).length > 0 ? [toTemplateTokenMapping(mergedStepEnv)] : [],
311
+ EnvironmentVariables: Object.keys(mergedEnv).length > 0 ? [toTemplateTokenMapping(mergedEnv)] : [],
307
312
  };
308
313
  return {
309
314
  MessageId: 1,
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,71 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { createJobResponse } from "./generators.js";
3
+ describe("createJobResponse", () => {
4
+ const basePayload = {
5
+ id: "1",
6
+ name: "test-job",
7
+ githubRepo: "owner/repo",
8
+ steps: [],
9
+ };
10
+ it("propagates job-level env into Variables", () => {
11
+ const payload = {
12
+ ...basePayload,
13
+ env: { AGENT_CI_LOCAL: "true", MY_VAR: "hello" },
14
+ };
15
+ const response = createJobResponse("1", payload, "http://localhost:3000", "plan-1");
16
+ const body = JSON.parse(response.Body);
17
+ const vars = body.Variables;
18
+ expect(vars.AGENT_CI_LOCAL).toEqual({ Value: "true", IsSecret: false });
19
+ expect(vars.MY_VAR).toEqual({ Value: "hello", IsSecret: false });
20
+ });
21
+ it("propagates job-level env into ContextData.env", () => {
22
+ const payload = {
23
+ ...basePayload,
24
+ env: { AGENT_CI_LOCAL: "true" },
25
+ };
26
+ const response = createJobResponse("1", payload, "http://localhost:3000", "plan-1");
27
+ const body = JSON.parse(response.Body);
28
+ // ContextData.env should be a ContextData object with type 2 (mapping)
29
+ expect(body.ContextData.env).toBeDefined();
30
+ expect(body.ContextData.env.t).toBe(2);
31
+ const entries = body.ContextData.env.d;
32
+ const localEntry = entries.find((e) => e.k === "AGENT_CI_LOCAL");
33
+ expect(localEntry).toBeDefined();
34
+ expect(localEntry.v).toEqual({ t: 0, s: "true" });
35
+ });
36
+ it("propagates job-level env into EnvironmentVariables", () => {
37
+ const payload = {
38
+ ...basePayload,
39
+ env: { AGENT_CI_LOCAL: "true" },
40
+ };
41
+ const response = createJobResponse("1", payload, "http://localhost:3000", "plan-1");
42
+ const body = JSON.parse(response.Body);
43
+ expect(body.EnvironmentVariables).toHaveLength(1);
44
+ const mapping = body.EnvironmentVariables[0];
45
+ expect(mapping.type).toBe(2);
46
+ const entry = mapping.map.find((e) => e.Key === "AGENT_CI_LOCAL");
47
+ expect(entry).toBeDefined();
48
+ expect(entry.Value).toBe("true");
49
+ });
50
+ it("step-level env overrides job-level env on conflict", () => {
51
+ const payload = {
52
+ ...basePayload,
53
+ env: { SHARED: "from-job" },
54
+ steps: [{ name: "step1", run: "echo hi", Env: { SHARED: "from-step" } }],
55
+ };
56
+ const response = createJobResponse("1", payload, "http://localhost:3000", "plan-1");
57
+ const body = JSON.parse(response.Body);
58
+ // Variables should have the step-level value (last-write wins)
59
+ expect(body.Variables.SHARED).toEqual({ Value: "from-step", IsSecret: false });
60
+ // ContextData.env should also have the step-level value
61
+ const entries = body.ContextData.env.d;
62
+ const entry = entries.find((e) => e.k === "SHARED");
63
+ expect(entry.v).toEqual({ t: 0, s: "from-step" });
64
+ });
65
+ it("omits env from ContextData when no env is provided", () => {
66
+ const response = createJobResponse("1", basePayload, "http://localhost:3000", "plan-1");
67
+ const body = JSON.parse(response.Body);
68
+ expect(body.ContextData.env).toBeUndefined();
69
+ expect(body.EnvironmentVariables).toEqual([]);
70
+ });
71
+ });
@@ -1,11 +1,111 @@
1
1
  import crypto from "node:crypto";
2
2
  import fs from "node:fs";
3
+ import https from "node:https";
4
+ import http from "node:http";
3
5
  import path from "node:path";
4
- import { state } from "../../store.js";
6
+ import { state, getActionTarballsDir } from "../../store.js";
5
7
  import { getBaseUrl } from "../dtu.js";
6
8
  import { createJobResponse } from "./generators.js";
9
+ // ─── Action tarball cache ──────────────────────────────────────────────────────
10
+ // Downloads action tarballs from GitHub on first use and serves them from disk
11
+ // on subsequent runs, eliminating ~30s GitHub CDN download delays.
12
+ /** Tracks in-flight downloads so concurrent cache misses for the same tarball
13
+ * coalesce into a single GitHub fetch instead of racing on the same tmp file. */
14
+ const inflightDownloads = new Map();
15
+ function actionTarballPath(repoPath, ref) {
16
+ const key = `${repoPath.replace("/", "__")}@${ref.replace(/[^a-zA-Z0-9._-]/g, "-")}`;
17
+ return path.join(getActionTarballsDir(), `${key}.tar.gz`);
18
+ }
19
+ /** Follow redirects and invoke callback with the final response. */
20
+ function fetchWithRedirects(url, callback, redirects = 0) {
21
+ if (redirects > 5) {
22
+ return;
23
+ }
24
+ const mod = url.startsWith("https") ? https : http;
25
+ mod.get(url, { headers: { "User-Agent": "agent-ci/1.0" } }, (res) => {
26
+ if ((res.statusCode === 301 || res.statusCode === 302) && res.headers.location) {
27
+ res.resume();
28
+ return fetchWithRedirects(res.headers.location, callback, redirects + 1);
29
+ }
30
+ callback(res);
31
+ });
32
+ }
7
33
  // Helper to reliably find log Id from URLs like /_apis/distributedtask/hubs/Hub/plans/Plan/logs/123
8
34
  export function registerActionRoutes(app) {
35
+ // ── Action tarball proxy: serves cached tarballs to the runner ──────────────
36
+ // First run: proxies from GitHub while saving to disk (same speed as direct download).
37
+ // Subsequent runs: serves from disk cache instantly (~0ms).
38
+ app.get("/_dtu/action-tarball/:owner/:repo/:ref", (req, res) => {
39
+ const { owner, repo, ref } = req.params;
40
+ const repoPath = `${owner}/${repo}`;
41
+ const dest = actionTarballPath(repoPath, ref);
42
+ /** Serve a completed cache file from disk. */
43
+ const serveFromDisk = () => {
44
+ const stat = fs.statSync(dest);
45
+ res.writeHead(200, {
46
+ "Content-Type": "application/x-tar",
47
+ "Content-Length": String(stat.size),
48
+ });
49
+ fs.createReadStream(dest).pipe(res);
50
+ };
51
+ // Cache hit: serve from disk
52
+ if (fs.existsSync(dest)) {
53
+ serveFromDisk();
54
+ return;
55
+ }
56
+ // Another request is already downloading this tarball — wait for it,
57
+ // then serve from the completed cache file.
58
+ const inflight = inflightDownloads.get(dest);
59
+ if (inflight) {
60
+ inflight.then(() => serveFromDisk(), () => {
61
+ res.writeHead(502);
62
+ res.end();
63
+ });
64
+ return;
65
+ }
66
+ // Cache miss: proxy from GitHub, write to disk simultaneously.
67
+ // Register a promise so concurrent requests can coalesce.
68
+ let resolveDownload;
69
+ let rejectDownload;
70
+ const downloadPromise = new Promise((resolve, reject) => {
71
+ resolveDownload = resolve;
72
+ rejectDownload = reject;
73
+ });
74
+ // Prevent unhandled-rejection when no concurrent waiter is attached.
75
+ downloadPromise.catch(() => { });
76
+ inflightDownloads.set(dest, downloadPromise);
77
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
78
+ const githubUrl = `https://api.github.com/repos/${repoPath}/tarball/${ref}`;
79
+ fetchWithRedirects(githubUrl, (upstream) => {
80
+ if (upstream.statusCode !== 200) {
81
+ inflightDownloads.delete(dest);
82
+ rejectDownload(new Error(`upstream ${upstream.statusCode}`));
83
+ res.writeHead(upstream.statusCode ?? 502);
84
+ res.end();
85
+ return;
86
+ }
87
+ res.writeHead(200, { "Content-Type": "application/x-tar" });
88
+ const tmp = dest + ".tmp." + process.pid;
89
+ const file = fs.createWriteStream(tmp);
90
+ upstream.pipe(res);
91
+ upstream.pipe(file);
92
+ file.on("finish", () => file.close(() => {
93
+ try {
94
+ fs.renameSync(tmp, dest);
95
+ }
96
+ catch {
97
+ /* best-effort */
98
+ }
99
+ inflightDownloads.delete(dest);
100
+ resolveDownload();
101
+ }));
102
+ file.on("error", () => {
103
+ fs.rmSync(tmp, { force: true });
104
+ inflightDownloads.delete(dest);
105
+ rejectDownload(new Error("write failed"));
106
+ });
107
+ });
108
+ });
9
109
  // 7. Pipeline Service Discovery Mock
10
110
  const serviceDiscoveryHandler = (req, res) => {
11
111
  console.log(`[DTU] Handling service discovery: ${req.url}`);
@@ -467,13 +567,20 @@ export function registerActionRoutes(app) {
467
567
  res.end(JSON.stringify({ value: {} }));
468
568
  });
469
569
  // 18. Resolve Action Download Info Mock
470
- app.post("/_apis/distributedtask/hubs/:hub/plans/:planId/actiondownloadinfo", (req, res) => {
570
+ app.post("/_apis/distributedtask/hubs/:hub/plans/:planId/actiondownloadinfo", async (req, res) => {
471
571
  const payload = req.body || {};
472
572
  const actions = payload.actions || [];
473
573
  const result = { actions: {} };
574
+ const baseUrl = getBaseUrl(req);
474
575
  for (const action of actions) {
475
576
  const key = `${action.nameWithOwner}@${action.ref}`;
476
- const downloadUrl = `https://api.github.com/repos/${action.nameWithOwner}/tarball/${action.ref}`;
577
+ // Strip sub-path from nameWithOwner (e.g. "actions/cache/save" → "actions/cache")
578
+ // Sub-path actions share the same repo tarball as the parent action.
579
+ const repoPath = action.nameWithOwner.split("/").slice(0, 2).join("/");
580
+ const [owner, repo] = repoPath.split("/");
581
+ // Point the runner at our local proxy; on cache miss the proxy streams from GitHub
582
+ // while saving to disk — subsequent runs are served instantly from the local cache.
583
+ const localUrl = `${baseUrl}/_dtu/action-tarball/${owner}/${repo}/${action.ref}`;
477
584
  result.actions[key] = {
478
585
  nameWithOwner: action.nameWithOwner,
479
586
  resolvedNameWithOwner: action.nameWithOwner,
@@ -482,8 +589,8 @@ export function registerActionRoutes(app) {
482
589
  .createHash("sha1")
483
590
  .update(`${action.nameWithOwner}@${action.ref}`)
484
591
  .digest("hex"),
485
- tarballUrl: downloadUrl,
486
- zipballUrl: downloadUrl.replace("tarball", "zipball"),
592
+ tarballUrl: localUrl,
593
+ zipballUrl: localUrl,
487
594
  authentication: null,
488
595
  };
489
596
  }
@@ -526,12 +633,15 @@ export function registerActionRoutes(app) {
526
633
  }
527
634
  const RUNNER_INTERNAL_RE = /^\[(?:RUNNER|WORKER) \d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}Z (?:INFO|WARN|ERR)\s/;
528
635
  let content = "";
636
+ let inGroup = false;
529
637
  // Collect agent-ci-output lines for cross-job output passing
530
638
  const outputEntries = [];
531
639
  for (const rawLine of lines) {
532
640
  const line = rawLine.trimEnd();
533
641
  if (!line) {
534
- content += "\n";
642
+ if (!inGroup) {
643
+ content += "\n";
644
+ }
535
645
  continue;
536
646
  }
537
647
  // Strip BOM + timestamp prefix before filtering
@@ -547,7 +657,16 @@ export function registerActionRoutes(app) {
547
657
  }
548
658
  continue; // Don't include in regular step logs
549
659
  }
550
- if (!stripped ||
660
+ if (stripped.startsWith("##[group]")) {
661
+ inGroup = true;
662
+ continue;
663
+ }
664
+ if (stripped.startsWith("##[endgroup]")) {
665
+ inGroup = false;
666
+ continue;
667
+ }
668
+ if (inGroup ||
669
+ !stripped ||
551
670
  stripped.startsWith("##[") ||
552
671
  stripped.startsWith("[command]") ||
553
672
  RUNNER_INTERNAL_RE.test(stripped)) {
@@ -1,6 +1,7 @@
1
1
  import http from "node:http";
2
2
  /** Override the cache directory at runtime (e.g. for ephemeral per-repo DTU instances). */
3
3
  export declare function setCacheDir(dir: string): void;
4
+ export declare function getActionTarballsDir(): string;
4
5
  export declare const state: {
5
6
  jobs: Map<string, any>;
6
7
  runnerJobs: Map<string, any>;
@@ -3,10 +3,15 @@ import path from "node:path";
3
3
  import { config } from "../config.js";
4
4
  let CACHE_DIR = config.DTU_CACHE_DIR;
5
5
  let CACHES_FILE = path.join(CACHE_DIR, "caches.json");
6
+ let ACTION_TARBALLS_DIR = path.join(CACHE_DIR, "action-tarballs");
6
7
  /** Override the cache directory at runtime (e.g. for ephemeral per-repo DTU instances). */
7
8
  export function setCacheDir(dir) {
8
9
  CACHE_DIR = dir;
9
10
  CACHES_FILE = path.join(dir, "caches.json");
11
+ ACTION_TARBALLS_DIR = path.join(dir, "action-tarballs");
12
+ }
13
+ export function getActionTarballsDir() {
14
+ return ACTION_TARBALLS_DIR;
10
15
  }
11
16
  export const state = {
12
17
  jobs: new Map(),
@@ -439,6 +439,51 @@ describe("Artifact v4 upload/download", () => {
439
439
  expect(res.body.lockedUntil).toBeDefined();
440
440
  expect(new Date(res.body.lockedUntil).getTime()).toBeGreaterThan(Date.now());
441
441
  });
442
+ it("should let runner B steal runner A's job when seeded WITHOUT runnerName (generic pool bug)", async () => {
443
+ // REPRODUCTION for issue #103:
444
+ // When local-job.ts seeds a job without setting runnerName, the job lands
445
+ // in the generic state.jobs pool. If another runner (from a different
446
+ // concurrent workflow) polls before runner A, it steals the job — causing
447
+ // runner A to hang forever waiting for a job that will never arrive.
448
+ const runnerA = "agent-ci-repro-A";
449
+ const runnerB = "agent-ci-repro-B";
450
+ // Register both runners
451
+ await request("POST", "/_dtu/start-runner", {
452
+ runnerName: runnerA,
453
+ logDir: "/tmp/agent-ci-repro-A-logs",
454
+ timelineDir: "/tmp/agent-ci-repro-A-logs",
455
+ });
456
+ await request("POST", "/_dtu/start-runner", {
457
+ runnerName: runnerB,
458
+ logDir: "/tmp/agent-ci-repro-B-logs",
459
+ timelineDir: "/tmp/agent-ci-repro-B-logs",
460
+ });
461
+ // Seed a job intended for runner A, but WITHOUT runnerName (the bug).
462
+ // This goes into the generic state.jobs pool.
463
+ await request("POST", "/_dtu/seed", {
464
+ id: 4001,
465
+ name: "job-intended-for-A",
466
+ // BUG: no runnerName — job lands in generic pool
467
+ });
468
+ // Runner B creates a session and polls — it shouldn't get A's job,
469
+ // but because the job is in the generic pool, B steals it.
470
+ const sessionB = await request("POST", "/_apis/distributedtask/pools/1/sessions", {
471
+ agent: { name: runnerB },
472
+ });
473
+ const pollB = await request("GET", `/_apis/distributedtask/pools/1/messages?sessionId=${sessionB.body.sessionId}`);
474
+ // BUG CONFIRMED: runner B stole the job from the generic pool
475
+ expect(pollB.status).toBe(200);
476
+ const bodyB = JSON.parse(pollB.body.Body);
477
+ expect(bodyB.JobDisplayName).toBe("job-intended-for-A");
478
+ // Now runner A creates a session and polls — the job is gone
479
+ await request("POST", "/_apis/distributedtask/pools/1/sessions", {
480
+ agent: { name: runnerA },
481
+ });
482
+ // Runner A's poll will hang (long-poll timeout) because its job was stolen.
483
+ // We verify by checking state: no jobs remain for A.
484
+ const aHasJob = state.runnerJobs.has(runnerA) || state.jobs.size > 0;
485
+ expect(aHasJob).toBe(false); // A's job is gone — it will hang forever
486
+ }, 10_000);
442
487
  it("should handle job request finish (PATCH with result + finishTime)", async () => {
443
488
  const finishTime = new Date().toISOString();
444
489
  const res = await request("PATCH", "/_apis/distributedtask/jobrequests", {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dtu-github-actions",
3
- "version": "0.6.0",
3
+ "version": "0.7.1",
4
4
  "description": "Digital Twin Universe - GitHub Actions Mock and Simulation",
5
5
  "keywords": [
6
6
  "ci",