dtu-github-actions 0.5.0 → 0.7.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.
@@ -1,6 +1,8 @@
1
1
  export interface EphemeralDtu {
2
- /** Full URL including port, e.g. "http://127.0.0.1:49823" */
2
+ /** Full URL including port for CLI access (127.0.0.1), e.g. "http://127.0.0.1:49823" */
3
3
  url: string;
4
+ /** Full URL including port for container access (host IP), e.g. "http://172.17.0.1:49823" */
5
+ containerUrl: string;
4
6
  port: number;
5
7
  /** Shut down the ephemeral DTU server. */
6
8
  close(): Promise<void>;
package/dist/ephemeral.js CHANGED
@@ -1,6 +1,10 @@
1
1
  import http from "node:http";
2
2
  import { setCacheDir } from "./server/store.js";
3
3
  import { bootstrapAndReturnApp } from "./server/index.js";
4
+ function resolveContainerHost() {
5
+ const configuredHost = process.env.AGENT_CI_DTU_HOST?.trim();
6
+ return configuredHost || "host.docker.internal";
7
+ }
4
8
  /**
5
9
  * Start an ephemeral in-process DTU server on a random OS-assigned port.
6
10
  *
@@ -31,9 +35,13 @@ export async function startEphemeralDtu(cacheDir) {
31
35
  });
32
36
  server.on("error", reject);
33
37
  });
34
- const url = `http://127.0.0.1:${port}`;
38
+ // Use 127.0.0.1 for CLI access (same host) and host IP for container access
39
+ const containerHost = resolveContainerHost();
40
+ const cliUrl = `http://127.0.0.1:${port}`;
41
+ const containerUrl = `http://${containerHost}:${port}`;
35
42
  return {
36
- url,
43
+ url: cliUrl,
44
+ containerUrl,
37
45
  port,
38
46
  close() {
39
47
  return new Promise((resolve) => {
@@ -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
+ });
@@ -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(),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dtu-github-actions",
3
- "version": "0.5.0",
3
+ "version": "0.7.0",
4
4
  "description": "Digital Twin Universe - GitHub Actions Mock and Simulation",
5
5
  "keywords": [
6
6
  "ci",