@redwoodjs/agent-ci 0.4.0 → 0.6.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.
package/README.md CHANGED
@@ -42,16 +42,35 @@ Agent CI connects to Docker via the `DOCKER_HOST` environment variable. By defau
42
42
  DOCKER_HOST=ssh://user@remote-server npx agent-ci run --workflow .github/workflows/ci.yml
43
43
  ```
44
44
 
45
+ ### Docker host resolution for job containers
46
+
47
+ By default, Agent CI uses `host.docker.internal` for container-to-host DTU traffic and adds a default Docker host mapping:
48
+
49
+ - `host.docker.internal:host-gateway`
50
+
51
+ This keeps behavior OS-agnostic and works on Docker Desktop and modern native Docker.
52
+
53
+ If your setup is custom, use environment overrides:
54
+
55
+ - `AGENT_CI_DTU_HOST` - override the hostname/IP used by runner containers to reach DTU
56
+ - `AGENT_CI_DOCKER_EXTRA_HOSTS` - comma-separated `host:ip` entries passed to Docker `ExtraHosts` (full replacement for defaults)
57
+ - `AGENT_CI_DOCKER_HOST_GATEWAY` - override the default `host-gateway` token/IP for automatic mapping
58
+ - `AGENT_CI_DOCKER_DISABLE_DEFAULT_EXTRA_HOSTS=1` - disable the default `host.docker.internal` mapping
59
+ - `AGENT_CI_DOCKER_BRIDGE_GATEWAY` - fallback gateway IP used when Agent CI runs inside Docker and cannot detect its container IP, and as an explicit DTU host override outside Docker when `AGENT_CI_DTU_HOST` is not set
60
+
61
+ When using a remote daemon (`DOCKER_HOST=ssh://...`), `host-gateway` resolves relative to the remote Docker host. If DTU is not reachable from that host, set `AGENT_CI_DTU_HOST` and `AGENT_CI_DOCKER_EXTRA_HOSTS` explicitly for your network.
62
+
45
63
  ### `agent-ci run`
46
64
 
47
65
  Run GitHub Actions workflow jobs locally.
48
66
 
49
- | Flag | Short | Description |
50
- | -------------------- | ----- | -------------------------------------------------------------- |
51
- | `--workflow <path>` | `-w` | Path to the workflow file |
52
- | `--all` | `-a` | Discover and run all relevant workflows for the current branch |
53
- | `--pause-on-failure` | `-p` | Pause on step failure for interactive debugging |
54
- | `--quiet` | `-q` | Suppress animated rendering (also enabled by `AI_AGENT=1`) |
67
+ | Flag | Short | Description |
68
+ | -------------------- | ----- | --------------------------------------------------------------------------------- |
69
+ | `--workflow <path>` | `-w` | Path to the workflow file |
70
+ | `--all` | `-a` | Discover and run all relevant workflows for the current branch |
71
+ | `--pause-on-failure` | `-p` | Pause on step failure for interactive debugging |
72
+ | `--quiet` | `-q` | Suppress animated rendering (also enabled by `AI_AGENT=1`) |
73
+ | `--no-matrix` | | Collapse all matrix combinations into a single job (uses first value of each key) |
55
74
 
56
75
  ### `agent-ci retry`
57
76
 
package/SKILL.md ADDED
@@ -0,0 +1,30 @@
1
+ ---
2
+ name: agent-ci
3
+ description: Run GitHub Actions workflows locally with pause-on-failure for AI-agent-driven CI iteration
4
+ keywords: [github-actions, local-ci, pause-on-failure, ai-agent, runner]
5
+ ---
6
+
7
+ ## What agent-ci does
8
+
9
+ Runs the official GitHub Actions runner binary locally (in Docker), emulating GitHub's cloud API.
10
+ Cache is bind-mounted (instant). When a step fails, the container pauses — you can fix and retry the failed step without restarting.
11
+
12
+ ## When to use agent-ci (not `act`)
13
+
14
+ - You want bit-for-bit compatibility with remote GitHub Actions
15
+ - You need pause-on-failure for AI agent debugging loops
16
+ - Cache round-trip speed matters
17
+
18
+ ## Key commands
19
+
20
+ - Run workflow: `npx @redwoodjs/agent-ci run --workflow <path>`
21
+ - Run all relevant workflows (those whose `on` triggers match the current branch/event, just like GitHub): `npx @redwoodjs/agent-ci run --all`
22
+ - Retry after fix: `npx @redwoodjs/agent-ci retry --name <runner>`
23
+ - Abort: `npx @redwoodjs/agent-ci abort --name <runner>`
24
+
25
+ ## Common mistakes
26
+
27
+ - Don't push to remote CI to test changes — use `npx @redwoodjs/agent-ci run` locally first
28
+ - Don't use `--from-start` when only the last step failed — use `retry` with no flags to re-run only the failed step
29
+ - The `AI_AGENT=1` env variable disables animated output for cleaner agent logs
30
+ - Use `--no-matrix` to collapse matrix jobs into a single run — your local machine is likely faster than GitHub's runners, so parallelizing across matrix combinations is unnecessary
package/dist/cli.js CHANGED
@@ -21,6 +21,7 @@ import { renderRunState } from "./output/state-renderer.js";
21
21
  import { isAgentMode, setQuietMode } from "./output/agent-mode.js";
22
22
  import logUpdate from "log-update";
23
23
  import { createFailedJobResult, wrapJobError, isJobError } from "./runner/job-result.js";
24
+ import { postCommitStatus } from "./commit-status.js";
24
25
  function findSignalsDir(runnerName) {
25
26
  const workDir = getWorkingDirectory();
26
27
  const runsDir = path.resolve(workDir, "runs");
@@ -130,6 +131,7 @@ async function run() {
130
131
  noMatrix,
131
132
  });
132
133
  printSummary(results);
134
+ postCommitStatus(results, sha);
133
135
  const anyFailed = results.some((r) => !r.succeeded);
134
136
  process.exit(anyFailed ? 1 : 0);
135
137
  }
@@ -162,6 +164,7 @@ async function run() {
162
164
  noMatrix,
163
165
  });
164
166
  printSummary(results);
167
+ postCommitStatus(results, sha);
165
168
  if (results.some((r) => !r.succeeded)) {
166
169
  process.exit(1);
167
170
  }
@@ -0,0 +1,48 @@
1
+ import { execSync } from "child_process";
2
+ import { config } from "./config.js";
3
+ /**
4
+ * Post a GitHub commit status via the `gh` CLI.
5
+ * Silently skips if `gh` is not available on PATH.
6
+ */
7
+ export function postCommitStatus(results, sha) {
8
+ // Check if gh CLI is available
9
+ try {
10
+ execSync("which gh", { stdio: "ignore" });
11
+ }
12
+ catch {
13
+ return;
14
+ }
15
+ const resolvedSha = sha ||
16
+ (() => {
17
+ try {
18
+ return execSync("git rev-parse HEAD", { encoding: "utf-8" }).trim();
19
+ }
20
+ catch {
21
+ return undefined;
22
+ }
23
+ })();
24
+ if (!resolvedSha) {
25
+ return;
26
+ }
27
+ const repo = config.GITHUB_REPO;
28
+ if (repo === "unknown/unknown") {
29
+ return;
30
+ }
31
+ const passed = results.filter((r) => r.succeeded).length;
32
+ const total = results.length;
33
+ const allPassed = passed === total;
34
+ const state = allPassed ? "success" : "failure";
35
+ const description = allPassed
36
+ ? `"It works on my machine!"`
37
+ : `${passed}/${total} jobs passed, ${total - passed} failed`;
38
+ try {
39
+ execSync(`gh api repos/${repo}/statuses/${resolvedSha} ` +
40
+ `-f state=${state} ` +
41
+ `-f context=agent-ci ` +
42
+ `-f description=${JSON.stringify(description)} ` +
43
+ `-f target_url=https://agent-ci.dev`, { stdio: "ignore" });
44
+ }
45
+ catch {
46
+ // gh command failed (e.g. no auth, no network) — skip silently
47
+ }
48
+ }
@@ -97,32 +97,74 @@ export function buildContainerCmd(opts) {
97
97
  // ─── DTU host resolution ──────────────────────────────────────────────────────
98
98
  import fs from "fs";
99
99
  import { execSync } from "child_process";
100
- /**
101
- * Resolve the DTU host address that nested Docker containers can reach.
102
- * Inside Docker: use the container's own bridge IP.
103
- * On host: use `host.docker.internal`.
104
- */
105
- export function resolveDtuHost() {
106
- const isInsideDocker = fs.existsSync("/.dockerenv");
107
- if (!isInsideDocker) {
108
- return "host.docker.internal";
100
+ import { debugRunner } from "../output/debug.js";
101
+ const DEFAULT_DTU_HOST_ALIAS = "host.docker.internal";
102
+ const DEFAULT_DOCKER_BRIDGE_GATEWAY = "172.17.0.1";
103
+ const DEFAULT_DOCKER_HOST_GATEWAY = "host-gateway";
104
+ function isLoopbackHostname(hostname) {
105
+ return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1";
106
+ }
107
+ function parseCsvEnv(value) {
108
+ return value
109
+ .split(",")
110
+ .map((entry) => entry.trim())
111
+ .filter(Boolean);
112
+ }
113
+ export async function resolveDtuHost() {
114
+ const configuredHost = process.env.AGENT_CI_DTU_HOST?.trim();
115
+ if (configuredHost) {
116
+ return configuredHost;
109
117
  }
110
- try {
111
- const ip = execSync("hostname -I 2>/dev/null | awk '{print $1}'", {
112
- encoding: "utf8",
113
- }).trim();
114
- if (ip) {
115
- return ip;
118
+ const isInsideDocker = fs.existsSync("/.dockerenv");
119
+ if (isInsideDocker) {
120
+ try {
121
+ const ip = execSync("hostname -I 2>/dev/null | awk '{print $1}'", {
122
+ encoding: "utf8",
123
+ }).trim();
124
+ if (ip) {
125
+ return ip;
126
+ }
116
127
  }
128
+ catch (error) {
129
+ debugRunner(`Failed to resolve Docker bridge IP via hostname -I: ${String(error)}`);
130
+ }
131
+ return process.env.AGENT_CI_DOCKER_BRIDGE_GATEWAY?.trim() || DEFAULT_DOCKER_BRIDGE_GATEWAY;
132
+ }
133
+ const configuredGateway = process.env.AGENT_CI_DOCKER_BRIDGE_GATEWAY?.trim();
134
+ if (configuredGateway) {
135
+ debugRunner(`Using configured bridge gateway '${configuredGateway}' for DTU host outside Docker`);
136
+ return configuredGateway;
137
+ }
138
+ return DEFAULT_DTU_HOST_ALIAS;
139
+ }
140
+ export function resolveDockerExtraHosts(dtuHost) {
141
+ const configuredExtraHosts = process.env.AGENT_CI_DOCKER_EXTRA_HOSTS;
142
+ if (configuredExtraHosts !== undefined) {
143
+ const parsed = parseCsvEnv(configuredExtraHosts);
144
+ return parsed.length > 0 ? parsed : undefined;
117
145
  }
118
- catch { }
119
- return "172.17.0.1"; // fallback to bridge gateway
146
+ if (process.env.AGENT_CI_DOCKER_DISABLE_DEFAULT_EXTRA_HOSTS === "1") {
147
+ return undefined;
148
+ }
149
+ if (dtuHost !== DEFAULT_DTU_HOST_ALIAS) {
150
+ return undefined;
151
+ }
152
+ const gateway = process.env.AGENT_CI_DOCKER_HOST_GATEWAY?.trim() || DEFAULT_DOCKER_HOST_GATEWAY;
153
+ return [`${DEFAULT_DTU_HOST_ALIAS}:${gateway}`];
120
154
  }
121
155
  /**
122
156
  * Rewrite a DTU URL to be reachable from inside Docker containers.
123
157
  */
124
158
  export function resolveDockerApiUrl(dtuUrl, dtuHost) {
125
- return dtuUrl.replace("localhost", dtuHost).replace("127.0.0.1", dtuHost);
159
+ const parsed = new URL(dtuUrl);
160
+ if (isLoopbackHostname(parsed.hostname)) {
161
+ parsed.hostname = dtuHost;
162
+ }
163
+ const serialized = parsed.toString();
164
+ if (parsed.pathname === "/" && !parsed.search && !parsed.hash && serialized.endsWith("/")) {
165
+ return serialized.slice(0, -1);
166
+ }
167
+ return serialized;
126
168
  }
127
169
  let _mountMappings = null;
128
170
  /**
@@ -1,4 +1,8 @@
1
- import { describe, it, expect } from "vitest";
1
+ import { describe, it, expect, afterEach, vi } from "vitest";
2
+ import fs from "node:fs";
3
+ afterEach(() => {
4
+ vi.restoreAllMocks();
5
+ });
2
6
  // ── buildContainerEnv ─────────────────────────────────────────────────────────
3
7
  describe("buildContainerEnv", () => {
4
8
  it("builds the standard env array", async () => {
@@ -127,6 +131,118 @@ describe("resolveDockerApiUrl", () => {
127
131
  const { resolveDockerApiUrl } = await import("./container-config.js");
128
132
  expect(resolveDockerApiUrl("http://127.0.0.1:3000", "host.docker.internal")).toBe("http://host.docker.internal:3000");
129
133
  });
134
+ it("preserves path and query components", async () => {
135
+ const { resolveDockerApiUrl } = await import("./container-config.js");
136
+ expect(resolveDockerApiUrl("http://localhost:8910/api/v1?foo=bar", "10.0.0.8")).toBe("http://10.0.0.8:8910/api/v1?foo=bar");
137
+ });
138
+ it("keeps implicit https default port behavior", async () => {
139
+ const { resolveDockerApiUrl } = await import("./container-config.js");
140
+ expect(resolveDockerApiUrl("https://localhost", "host.docker.internal")).toBe("https://host.docker.internal");
141
+ });
142
+ it("does not rewrite non-loopback hosts", async () => {
143
+ const { resolveDockerApiUrl } = await import("./container-config.js");
144
+ expect(resolveDockerApiUrl("https://dtu.internal.example.com:8910", "host.docker.internal")).toBe("https://dtu.internal.example.com:8910");
145
+ });
146
+ });
147
+ describe("resolveDtuHost", () => {
148
+ const originalBridgeGateway = process.env.AGENT_CI_DOCKER_BRIDGE_GATEWAY;
149
+ afterEach(() => {
150
+ if (originalBridgeGateway === undefined) {
151
+ delete process.env.AGENT_CI_DOCKER_BRIDGE_GATEWAY;
152
+ }
153
+ else {
154
+ process.env.AGENT_CI_DOCKER_BRIDGE_GATEWAY = originalBridgeGateway;
155
+ }
156
+ });
157
+ it("uses host alias when available outside Docker", async () => {
158
+ const { resolveDtuHost } = await import("./container-config.js");
159
+ const originalExistsSync = fs.existsSync;
160
+ vi.spyOn(fs, "existsSync").mockImplementation((filePath) => {
161
+ if (filePath === "/.dockerenv") {
162
+ return false;
163
+ }
164
+ return originalExistsSync(filePath);
165
+ });
166
+ await expect(resolveDtuHost()).resolves.toBe("host.docker.internal");
167
+ });
168
+ it("uses configured bridge gateway outside Docker when provided", async () => {
169
+ const { resolveDtuHost } = await import("./container-config.js");
170
+ const originalExistsSync = fs.existsSync;
171
+ vi.spyOn(fs, "existsSync").mockImplementation((filePath) => {
172
+ if (filePath === "/.dockerenv") {
173
+ return false;
174
+ }
175
+ return originalExistsSync(filePath);
176
+ });
177
+ process.env.AGENT_CI_DOCKER_BRIDGE_GATEWAY = "10.10.0.1";
178
+ await expect(resolveDtuHost()).resolves.toBe("10.10.0.1");
179
+ });
180
+ it("uses host alias outside Docker when no gateway override is configured", async () => {
181
+ const { resolveDtuHost } = await import("./container-config.js");
182
+ const originalExistsSync = fs.existsSync;
183
+ vi.spyOn(fs, "existsSync").mockImplementation((filePath) => {
184
+ if (filePath === "/.dockerenv") {
185
+ return false;
186
+ }
187
+ return originalExistsSync(filePath);
188
+ });
189
+ await expect(resolveDtuHost()).resolves.toBe("host.docker.internal");
190
+ });
191
+ });
192
+ describe("resolveDockerExtraHosts", () => {
193
+ const originalExtraHosts = process.env.AGENT_CI_DOCKER_EXTRA_HOSTS;
194
+ const originalDisable = process.env.AGENT_CI_DOCKER_DISABLE_DEFAULT_EXTRA_HOSTS;
195
+ const originalGateway = process.env.AGENT_CI_DOCKER_HOST_GATEWAY;
196
+ afterEach(() => {
197
+ if (originalExtraHosts === undefined) {
198
+ delete process.env.AGENT_CI_DOCKER_EXTRA_HOSTS;
199
+ }
200
+ else {
201
+ process.env.AGENT_CI_DOCKER_EXTRA_HOSTS = originalExtraHosts;
202
+ }
203
+ if (originalDisable === undefined) {
204
+ delete process.env.AGENT_CI_DOCKER_DISABLE_DEFAULT_EXTRA_HOSTS;
205
+ }
206
+ else {
207
+ process.env.AGENT_CI_DOCKER_DISABLE_DEFAULT_EXTRA_HOSTS = originalDisable;
208
+ }
209
+ if (originalGateway === undefined) {
210
+ delete process.env.AGENT_CI_DOCKER_HOST_GATEWAY;
211
+ }
212
+ else {
213
+ process.env.AGENT_CI_DOCKER_HOST_GATEWAY = originalGateway;
214
+ }
215
+ });
216
+ it("maps host.docker.internal to host-gateway by default", async () => {
217
+ delete process.env.AGENT_CI_DOCKER_EXTRA_HOSTS;
218
+ delete process.env.AGENT_CI_DOCKER_DISABLE_DEFAULT_EXTRA_HOSTS;
219
+ delete process.env.AGENT_CI_DOCKER_HOST_GATEWAY;
220
+ const { resolveDockerExtraHosts } = await import("./container-config.js");
221
+ expect(resolveDockerExtraHosts("host.docker.internal")).toEqual([
222
+ "host.docker.internal:host-gateway",
223
+ ]);
224
+ });
225
+ it("uses AGENT_CI_DOCKER_EXTRA_HOSTS when provided", async () => {
226
+ process.env.AGENT_CI_DOCKER_EXTRA_HOSTS = "host.docker.internal:172.17.0.1,api.local:10.0.0.2";
227
+ delete process.env.AGENT_CI_DOCKER_DISABLE_DEFAULT_EXTRA_HOSTS;
228
+ const { resolveDockerExtraHosts } = await import("./container-config.js");
229
+ expect(resolveDockerExtraHosts("host.docker.internal")).toEqual([
230
+ "host.docker.internal:172.17.0.1",
231
+ "api.local:10.0.0.2",
232
+ ]);
233
+ });
234
+ it("returns undefined when defaults are disabled", async () => {
235
+ delete process.env.AGENT_CI_DOCKER_EXTRA_HOSTS;
236
+ process.env.AGENT_CI_DOCKER_DISABLE_DEFAULT_EXTRA_HOSTS = "1";
237
+ const { resolveDockerExtraHosts } = await import("./container-config.js");
238
+ expect(resolveDockerExtraHosts("host.docker.internal")).toBeUndefined();
239
+ });
240
+ it("does not add default mapping for non-host.docker.internal hosts", async () => {
241
+ delete process.env.AGENT_CI_DOCKER_EXTRA_HOSTS;
242
+ delete process.env.AGENT_CI_DOCKER_DISABLE_DEFAULT_EXTRA_HOSTS;
243
+ const { resolveDockerExtraHosts } = await import("./container-config.js");
244
+ expect(resolveDockerExtraHosts("10.10.10.10")).toBeUndefined();
245
+ });
130
246
  });
131
247
  // ── signalsDir bind-mount ─────────────────────────────────────────────────────
132
248
  describe("buildContainerBinds with signalsDir", () => {
@@ -23,14 +23,12 @@ export function printSummary(results, runDir) {
23
23
  else {
24
24
  process.stdout.write(` ✗ ${f.workflow} > ${f.taskId}\n`);
25
25
  }
26
- if (f.failedExitCode !== undefined) {
27
- process.stdout.write(` Exit code: ${f.failedExitCode}\n`);
26
+ if (f.failedStepLogPath && fs.existsSync(f.failedStepLogPath)) {
27
+ const content = fs.readFileSync(f.failedStepLogPath, "utf-8");
28
+ process.stdout.write("\n" + content);
28
29
  }
29
- if (f.lastOutputLines && f.lastOutputLines.length > 0) {
30
- process.stdout.write(` Last output:\n`);
31
- for (const line of f.lastOutputLines) {
32
- process.stdout.write(` ${line}\n`);
33
- }
30
+ else if (f.lastOutputLines && f.lastOutputLines.length > 0) {
31
+ process.stdout.write("\n" + f.lastOutputLines.join("\n") + "\n");
34
32
  }
35
33
  process.stdout.write("\n");
36
34
  }
@@ -0,0 +1,73 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import os from "node:os";
5
+ import { printSummary } from "./reporter.js";
6
+ function makeResult(overrides = {}) {
7
+ return {
8
+ name: "container-1",
9
+ workflow: "retry-proof.yml",
10
+ taskId: "test",
11
+ succeeded: false,
12
+ durationMs: 1000,
13
+ debugLogPath: "/tmp/debug.log",
14
+ ...overrides,
15
+ };
16
+ }
17
+ describe("printSummary", () => {
18
+ let tmpDir;
19
+ let output;
20
+ beforeEach(() => {
21
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "reporter-test-"));
22
+ output = "";
23
+ vi.spyOn(process.stdout, "write").mockImplementation((chunk) => {
24
+ output += chunk;
25
+ return true;
26
+ });
27
+ });
28
+ afterEach(() => {
29
+ vi.restoreAllMocks();
30
+ fs.rmSync(tmpDir, { recursive: true, force: true });
31
+ });
32
+ it("outputs full step log content when failedStepLogPath exists", () => {
33
+ const logPath = path.join(tmpDir, "Run-assertion-test.log");
34
+ fs.writeFileSync(logPath, "line 1\nline 2\nline 3\n");
35
+ printSummary([
36
+ makeResult({
37
+ failedStep: "Run assertion test",
38
+ failedStepLogPath: logPath,
39
+ lastOutputLines: ["last line only"],
40
+ }),
41
+ ]);
42
+ expect(output).toContain("line 1\nline 2\nline 3\n");
43
+ expect(output).not.toContain("last line only");
44
+ expect(output).not.toContain("Last output:");
45
+ expect(output).not.toContain("Exit code:");
46
+ });
47
+ it("falls back to lastOutputLines when failedStepLogPath is absent", () => {
48
+ printSummary([
49
+ makeResult({
50
+ failedStep: "Run assertion test",
51
+ lastOutputLines: ["fallback line 1", "fallback line 2"],
52
+ }),
53
+ ]);
54
+ expect(output).toContain("fallback line 1\nfallback line 2");
55
+ expect(output).not.toContain("Last output:");
56
+ });
57
+ it("shows the failed step name in the FAILURES section", () => {
58
+ const logPath = path.join(tmpDir, "step.log");
59
+ fs.writeFileSync(logPath, "error output\n");
60
+ printSummary([
61
+ makeResult({
62
+ failedStep: "Run assertion test",
63
+ failedStepLogPath: logPath,
64
+ }),
65
+ ]);
66
+ expect(output).toContain('✗ retry-proof.yml > test > "Run assertion test"');
67
+ });
68
+ it("shows pass count in summary for a successful run", () => {
69
+ printSummary([makeResult({ succeeded: true })]);
70
+ expect(output).toContain("✓ 1 passed");
71
+ expect(output).not.toContain("FAILURES");
72
+ });
73
+ });
@@ -15,7 +15,7 @@ import { writeJobMetadata } from "./metadata.js";
15
15
  import { computeFakeSha, writeGitShim } from "./git-shim.js";
16
16
  import { prepareWorkspace } from "./workspace.js";
17
17
  import { createRunDirectories } from "./directory-setup.js";
18
- import { buildContainerEnv, buildContainerBinds, buildContainerCmd, resolveDtuHost, resolveDockerApiUrl, } from "../docker/container-config.js";
18
+ import { buildContainerEnv, buildContainerBinds, buildContainerCmd, resolveDtuHost, resolveDockerApiUrl, resolveDockerExtraHosts, } from "../docker/container-config.js";
19
19
  import { buildJobResult, sanitizeStepName } from "./result-builder.js";
20
20
  import { wrapJobSteps, appendOutputCaptureStep } from "./step-wrapper.js";
21
21
  import { syncWorkspaceForRetry } from "./sync.js";
@@ -115,8 +115,17 @@ export async function executeLocalJob(job, options) {
115
115
  // own isolated DTU instance on a random port — eliminating port conflicts.
116
116
  let t0 = Date.now();
117
117
  const dtuCacheDir = path.resolve(getWorkingDirectory(), "cache", "dtu");
118
- const ephemeralDtu = await startEphemeralDtu(dtuCacheDir).catch(() => null);
118
+ let ephemeralDtu = null;
119
+ try {
120
+ ephemeralDtu = await startEphemeralDtu(dtuCacheDir);
121
+ debugRunner(`DTU server started - CLI URL: ${ephemeralDtu.url}, Container URL: ${ephemeralDtu.containerUrl}`);
122
+ }
123
+ catch (e) {
124
+ debugRunner(`Failed to start ephemeral DTU: ${e}`);
125
+ }
126
+ // CLI uses url (127.0.0.1), containers use containerUrl (host IP)
119
127
  const dtuUrl = ephemeralDtu?.url ?? config.GITHUB_API_URL;
128
+ const dtuContainerUrl = ephemeralDtu?.containerUrl ?? dtuUrl;
120
129
  t0 = bt("dtu-start", t0);
121
130
  await fetch(`${dtuUrl}/_dtu/start-runner`, {
122
131
  method: "POST",
@@ -219,12 +228,15 @@ export async function executeLocalJob(job, options) {
219
228
  bt("workspace-prep", workspacePrepStart);
220
229
  })();
221
230
  // 6. Spawn container
222
- const dtuPort = new URL(dtuUrl).port || "80";
223
- const dtuHost = resolveDtuHost();
224
- const dockerApiUrl = resolveDockerApiUrl(dtuUrl, dtuHost);
231
+ const dtuHost = await resolveDtuHost();
232
+ const dockerApiUrl = resolveDockerApiUrl(dtuContainerUrl, dtuHost);
233
+ const parsedDockerApiUrl = new URL(dockerApiUrl);
234
+ const dtuPort = parsedDockerApiUrl.port || (parsedDockerApiUrl.protocol === "https:" ? "443" : "80");
225
235
  const githubRepo = job.githubRepo || config.GITHUB_REPO;
226
236
  const repoUrl = `${dockerApiUrl}/${githubRepo}`;
227
237
  debugRunner(`Spawning container ${containerName}...`);
238
+ debugRunner(`DTU config - Port: ${dtuPort}, Host: ${dtuHost}, Docker API: ${dockerApiUrl}`);
239
+ debugRunner(`Runner will connect to: ${repoUrl}`);
228
240
  // Pre-cleanup: remove any stale container with the same name
229
241
  try {
230
242
  const stale = docker.getContainer(containerName);
@@ -333,6 +345,7 @@ export async function executeLocalJob(job, options) {
333
345
  useDirectContainer,
334
346
  containerName,
335
347
  });
348
+ const extraHosts = resolveDockerExtraHosts(dtuHost);
336
349
  t0 = Date.now();
337
350
  const container = await docker.createContainer({
338
351
  Image: containerImage,
@@ -345,6 +358,7 @@ export async function executeLocalJob(job, options) {
345
358
  AutoRemove: false,
346
359
  Ulimits: [{ Name: "nofile", Soft: 65536, Hard: 65536 }],
347
360
  ...(serviceCtx ? { NetworkMode: serviceCtx.networkName } : {}),
361
+ ...(extraHosts ? { ExtraHosts: extraHosts } : {}),
348
362
  },
349
363
  Tty: true,
350
364
  });
package/package.json CHANGED
@@ -1,8 +1,20 @@
1
1
  {
2
2
  "name": "@redwoodjs/agent-ci",
3
- "version": "0.4.0",
4
- "description": "Local GitHub Actions runner",
5
- "keywords": [],
3
+ "version": "0.6.0",
4
+ "description": "Local GitHub Actions runner — pause on failure, ~0ms cache, official runner binary. Built for AI coding agents.",
5
+ "keywords": [
6
+ "act-alternative",
7
+ "ai-agent",
8
+ "ci",
9
+ "coding-agent",
10
+ "devtools",
11
+ "github-actions",
12
+ "local-ci",
13
+ "local-runner",
14
+ "pause-on-failure",
15
+ "runner",
16
+ "workflow"
17
+ ],
6
18
  "license": "FSL-1.1-MIT",
7
19
  "author": "",
8
20
  "repository": {
@@ -15,7 +27,8 @@
15
27
  },
16
28
  "files": [
17
29
  "dist",
18
- "shim.sh"
30
+ "shim.sh",
31
+ "SKILL.md"
19
32
  ],
20
33
  "type": "module",
21
34
  "publishConfig": {
@@ -27,7 +40,7 @@
27
40
  "log-update": "^7.2.0",
28
41
  "minimatch": "^10.2.1",
29
42
  "yaml": "^2.8.2",
30
- "dtu-github-actions": "0.4.0"
43
+ "dtu-github-actions": "0.6.0"
31
44
  },
32
45
  "devDependencies": {
33
46
  "@types/dockerode": "^3.3.34",