@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 +25 -6
- package/SKILL.md +30 -0
- package/dist/cli.js +3 -0
- package/dist/commit-status.js +48 -0
- package/dist/docker/container-config.js +60 -18
- package/dist/docker/container-config.test.js +117 -1
- package/dist/output/reporter.js +5 -7
- package/dist/output/reporter.test.js +73 -0
- package/dist/runner/local-job.js +19 -5
- package/package.json +18 -5
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
|
|
119
|
-
|
|
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
|
-
|
|
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", () => {
|
package/dist/output/reporter.js
CHANGED
|
@@ -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.
|
|
27
|
-
|
|
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(
|
|
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
|
+
});
|
package/dist/runner/local-job.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
223
|
-
const
|
|
224
|
-
const
|
|
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
|
-
"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.
|
|
43
|
+
"dtu-github-actions": "0.6.0"
|
|
31
44
|
},
|
|
32
45
|
"devDependencies": {
|
|
33
46
|
"@types/dockerode": "^3.3.34",
|