@redwoodjs/agent-ci 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/LICENSE +110 -0
  2. package/README.md +79 -0
  3. package/dist/cli.js +628 -0
  4. package/dist/config.js +63 -0
  5. package/dist/docker/container-config.js +178 -0
  6. package/dist/docker/container-config.test.js +156 -0
  7. package/dist/docker/service-containers.js +205 -0
  8. package/dist/docker/service-containers.test.js +236 -0
  9. package/dist/docker/shutdown.js +120 -0
  10. package/dist/docker/shutdown.test.js +148 -0
  11. package/dist/output/agent-mode.js +7 -0
  12. package/dist/output/agent-mode.test.js +36 -0
  13. package/dist/output/cleanup.js +218 -0
  14. package/dist/output/cleanup.test.js +241 -0
  15. package/dist/output/concurrency.js +57 -0
  16. package/dist/output/concurrency.test.js +88 -0
  17. package/dist/output/debug.js +36 -0
  18. package/dist/output/logger.js +57 -0
  19. package/dist/output/logger.test.js +82 -0
  20. package/dist/output/reporter.js +67 -0
  21. package/dist/output/run-state.js +126 -0
  22. package/dist/output/run-state.test.js +169 -0
  23. package/dist/output/state-renderer.js +149 -0
  24. package/dist/output/state-renderer.test.js +488 -0
  25. package/dist/output/tree-renderer.js +52 -0
  26. package/dist/output/tree-renderer.test.js +105 -0
  27. package/dist/output/working-directory.js +20 -0
  28. package/dist/runner/directory-setup.js +98 -0
  29. package/dist/runner/directory-setup.test.js +31 -0
  30. package/dist/runner/git-shim.js +92 -0
  31. package/dist/runner/git-shim.test.js +57 -0
  32. package/dist/runner/local-job.js +691 -0
  33. package/dist/runner/metadata.js +90 -0
  34. package/dist/runner/metadata.test.js +127 -0
  35. package/dist/runner/result-builder.js +119 -0
  36. package/dist/runner/result-builder.test.js +177 -0
  37. package/dist/runner/step-wrapper.js +82 -0
  38. package/dist/runner/step-wrapper.test.js +77 -0
  39. package/dist/runner/sync.js +80 -0
  40. package/dist/runner/workspace.js +66 -0
  41. package/dist/types.js +1 -0
  42. package/dist/workflow/job-scheduler.js +62 -0
  43. package/dist/workflow/job-scheduler.test.js +130 -0
  44. package/dist/workflow/workflow-parser.js +556 -0
  45. package/dist/workflow/workflow-parser.test.js +642 -0
  46. package/package.json +39 -0
  47. package/shim.sh +11 -0
@@ -0,0 +1,98 @@
1
+ import path from "path";
2
+ import fs from "fs";
3
+ import { getWorkingDirectory } from "../output/working-directory.js";
4
+ import { computeLockfileHash, repairWarmCache } from "../output/cleanup.js";
5
+ import { config } from "../config.js";
6
+ import { findRepoRoot } from "./metadata.js";
7
+ import { debugRunner } from "../output/debug.js";
8
+ // ─── Directory creation ───────────────────────────────────────────────────────
9
+ /**
10
+ * Create all per-run and shared-cache directories, returning the paths.
11
+ *
12
+ * Also verifies warm-cache integrity and ensures world-writable permissions
13
+ * for DinD scenarios.
14
+ */
15
+ export function createRunDirectories(opts) {
16
+ const { runDir, githubRepo, workflowPath } = opts;
17
+ const workDir = getWorkingDirectory();
18
+ // Per-run dirs
19
+ const containerWorkDir = path.resolve(runDir, "work");
20
+ const shimsDir = path.resolve(runDir, "shims");
21
+ const signalsDir = path.resolve(runDir, "signals");
22
+ const diagDir = path.resolve(runDir, "diag");
23
+ // Shared caches
24
+ const repoSlug = (githubRepo || config.GITHUB_REPO).replace("/", "-");
25
+ const toolCacheDir = path.resolve(workDir, "cache", "toolcache");
26
+ const pnpmStoreDir = path.resolve(workDir, "cache", "pnpm-store", repoSlug);
27
+ const npmCacheDir = path.resolve(workDir, "cache", "npm-cache", repoSlug);
28
+ const bunCacheDir = path.resolve(workDir, "cache", "bun-cache", repoSlug);
29
+ const playwrightCacheDir = path.resolve(workDir, "cache", "playwright", repoSlug);
30
+ // Warm node_modules: keyed by the lockfile hash (any supported PM)
31
+ let lockfileHash = "no-lockfile";
32
+ try {
33
+ const repoRoot = workflowPath ? findRepoRoot(workflowPath) : undefined;
34
+ if (repoRoot) {
35
+ lockfileHash = computeLockfileHash(repoRoot);
36
+ }
37
+ }
38
+ catch {
39
+ // Best-effort; fall back to "no-lockfile"
40
+ }
41
+ const warmModulesDir = path.resolve(workDir, "cache", "warm-modules", repoSlug, lockfileHash);
42
+ // Workspace path
43
+ const repoName = (githubRepo || config.GITHUB_REPO).split("/").pop() || "repo";
44
+ const workspaceDir = path.resolve(containerWorkDir, repoName, repoName);
45
+ // Create all directories
46
+ const allDirs = [
47
+ workspaceDir,
48
+ containerWorkDir,
49
+ shimsDir,
50
+ signalsDir,
51
+ diagDir,
52
+ toolCacheDir,
53
+ pnpmStoreDir,
54
+ npmCacheDir,
55
+ bunCacheDir,
56
+ playwrightCacheDir,
57
+ warmModulesDir,
58
+ ];
59
+ for (const dir of allDirs) {
60
+ fs.mkdirSync(dir, { recursive: true, mode: 0o777 });
61
+ }
62
+ // Verify warm cache integrity
63
+ const cacheStatus = repairWarmCache(warmModulesDir);
64
+ if (cacheStatus === "repaired") {
65
+ debugRunner(`Repaired corrupted warm cache: ${warmModulesDir}`);
66
+ }
67
+ // Ensure world-writable for DinD scenarios
68
+ ensureWorldWritable(allDirs);
69
+ return {
70
+ containerWorkDir,
71
+ shimsDir,
72
+ signalsDir,
73
+ diagDir,
74
+ toolCacheDir,
75
+ pnpmStoreDir,
76
+ npmCacheDir,
77
+ bunCacheDir,
78
+ playwrightCacheDir,
79
+ warmModulesDir,
80
+ workspaceDir,
81
+ repoSlug,
82
+ };
83
+ }
84
+ // ─── Permissions helper ───────────────────────────────────────────────────────
85
+ /**
86
+ * Ensure all directories are world-writable (0o777).
87
+ * Errors are ignored (non-critical).
88
+ */
89
+ export function ensureWorldWritable(dirs) {
90
+ try {
91
+ for (const dir of dirs) {
92
+ fs.chmodSync(dir, 0o777);
93
+ }
94
+ }
95
+ catch {
96
+ // Ignore chmod errors (non-critical)
97
+ }
98
+ }
@@ -0,0 +1,31 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import os from "node:os";
5
+ // ── ensureWorldWritable ───────────────────────────────────────────────────────
6
+ describe("ensureWorldWritable", () => {
7
+ let tmpDir;
8
+ beforeEach(() => {
9
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "dirsetup-test-"));
10
+ });
11
+ afterEach(() => {
12
+ fs.rmSync(tmpDir, { recursive: true, force: true });
13
+ });
14
+ it("sets all directories to 0o777", async () => {
15
+ const { ensureWorldWritable } = await import("./directory-setup.js");
16
+ const dir1 = path.join(tmpDir, "a");
17
+ const dir2 = path.join(tmpDir, "b");
18
+ fs.mkdirSync(dir1);
19
+ fs.mkdirSync(dir2);
20
+ // Start with restrictive permissions
21
+ fs.chmodSync(dir1, 0o700);
22
+ fs.chmodSync(dir2, 0o700);
23
+ ensureWorldWritable([dir1, dir2]);
24
+ expect(fs.statSync(dir1).mode & 0o777).toBe(0o777);
25
+ expect(fs.statSync(dir2).mode & 0o777).toBe(0o777);
26
+ });
27
+ it("does not throw on non-existent directories", async () => {
28
+ const { ensureWorldWritable } = await import("./directory-setup.js");
29
+ expect(() => ensureWorldWritable(["/nonexistent/path"])).not.toThrow();
30
+ });
31
+ });
@@ -0,0 +1,92 @@
1
+ import path from "path";
2
+ import fs from "fs";
3
+ // ─── Fake SHA computation ─────────────────────────────────────────────────────
4
+ /**
5
+ * Resolve which SHA the git shim should return for ls-remote / rev-parse.
6
+ * Uses the real SHA if provided, otherwise falls back to a deterministic fake.
7
+ */
8
+ export function computeFakeSha(headSha) {
9
+ return headSha && headSha !== "HEAD" ? headSha : "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
10
+ }
11
+ // ─── Git shim script ──────────────────────────────────────────────────────────
12
+ /**
13
+ * Write the bash git shim to `<shimsDir>/git`.
14
+ *
15
+ * The shim intercepts git commands inside the runner container to make
16
+ * actions/checkout work against the pre-populated local workspace instead
17
+ * of fetching from a real remote.
18
+ */
19
+ export function writeGitShim(shimsDir, fakeSha) {
20
+ const gitShimPath = path.join(shimsDir, "git");
21
+ fs.writeFileSync(gitShimPath, `#!/bin/bash
22
+
23
+ # Log every call for debugging
24
+ echo "git $*" >> /home/runner/_diag/agent-ci-git-calls.log
25
+
26
+ # actions/checkout probes the remote URL via config.
27
+ # It computes the expected URL using URL.origin, which strips the default port 80.
28
+ # So we must return the URL WITHOUT :80 to match.
29
+ if [[ "$*" == *"config --local --get remote.origin.url"* || "$*" == *"config --get remote.origin.url"* ]]; then
30
+ echo "https://github.com/\${GITHUB_REPOSITORY}"
31
+ exit 0
32
+ fi
33
+
34
+ # actions/checkout probes ls-remote to find the target SHA.
35
+ # Return the same SHA that github.sha uses in the job definition.
36
+ if [[ "$*" == *"ls-remote"* ]]; then
37
+ echo "${fakeSha}\\tHEAD"
38
+ echo "${fakeSha}\\trefs/heads/main"
39
+ exit 0
40
+ fi
41
+
42
+ # Intercept fetch - we don't have a real git server, so fetch is a no-op.
43
+ # But we must create refs/remotes/origin/main so checkout's post-fetch validation passes.
44
+ if [[ "$*" == *"fetch"* ]]; then
45
+ echo "[Agent CI Shim] Intercepted 'fetch' - workspace is pre-populated."
46
+ # If this is a fresh git init (no commits), create a seed commit
47
+ # so HEAD is valid and we can create branches from it.
48
+ if ! /usr/bin/git.real rev-parse HEAD >/dev/null 2>&1; then
49
+ /usr/bin/git.real config user.name "agent-ci" 2>/dev/null
50
+ /usr/bin/git.real config user.email "agent-ci@example.com" 2>/dev/null
51
+ /usr/bin/git.real add -A 2>/dev/null
52
+ /usr/bin/git.real commit --allow-empty -m "workspace" 2>/dev/null
53
+ fi
54
+ /usr/bin/git.real update-ref refs/remotes/origin/main HEAD 2>/dev/null || true
55
+ exit 0
56
+ fi
57
+
58
+ # Redirect: git checkout ... refs/remotes/origin/main -> create local main from HEAD.
59
+ # Note: actions/checkout deletes the local 'main' branch before fetching, so we cannot
60
+ # checkout the local branch - instead we recreate it from the current HEAD commit.
61
+ if [[ "$*" == *"checkout"* && "$*" == *"refs/remotes/origin/"* ]]; then
62
+ echo "[Agent CI Shim] Redirecting remote checkout - recreating main from HEAD."
63
+ /usr/bin/git.real checkout -B main HEAD
64
+ exit $?
65
+ fi
66
+
67
+ # Intercept clean and rm which can destroy workspace files
68
+ if [[ "$1" == "clean" || "$1" == "rm" ]]; then
69
+ echo "[Agent CI Shim] Intercepted '$1' to protect local files."
70
+ exit 0
71
+ fi
72
+
73
+ # Intercept rev-parse for HEAD/refs/heads/main so the SHA matches github.sha
74
+ # actions/checkout validates that refs/heads/main == github.sha after checkout
75
+ if [[ "$1" == "rev-parse" ]]; then
76
+ for arg in "$@"; do
77
+ if [[ "$arg" == "HEAD" || "$arg" == "refs/heads/main" || "$arg" == "refs/remotes/origin/main" ]]; then
78
+ echo "${fakeSha}"
79
+ exit 0
80
+ fi
81
+ done
82
+ # Fall through for other rev-parse calls (e.g. rev-parse --show-toplevel)
83
+ fi
84
+
85
+ # Pass through all other git commands (checkout, reset, log, init, config, etc.)
86
+ echo "git $@ (pass-through)" >> /home/runner/_diag/agent-ci-git-calls.log
87
+ /usr/bin/git.real "$@"
88
+ EXIT_CODE=$?
89
+ echo "git $@ exited with $EXIT_CODE" >> /home/runner/_diag/agent-ci-git-calls.log
90
+ exit $EXIT_CODE
91
+ `, { mode: 0o755 });
92
+ }
@@ -0,0 +1,57 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import os from "node:os";
5
+ // ── computeFakeSha ────────────────────────────────────────────────────────────
6
+ describe("computeFakeSha", () => {
7
+ it("returns the headSha when it is a real SHA", async () => {
8
+ const { computeFakeSha } = await import("./git-shim.js");
9
+ expect(computeFakeSha("abc123def456")).toBe("abc123def456");
10
+ });
11
+ it("returns the deterministic fake when headSha is HEAD", async () => {
12
+ const { computeFakeSha } = await import("./git-shim.js");
13
+ expect(computeFakeSha("HEAD")).toBe("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa");
14
+ });
15
+ it("returns the deterministic fake when headSha is undefined", async () => {
16
+ const { computeFakeSha } = await import("./git-shim.js");
17
+ expect(computeFakeSha(undefined)).toBe("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa");
18
+ });
19
+ });
20
+ // ── writeGitShim ──────────────────────────────────────────────────────────────
21
+ describe("writeGitShim", () => {
22
+ let tmpDir;
23
+ beforeEach(() => {
24
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "shim-test-"));
25
+ });
26
+ afterEach(() => {
27
+ fs.rmSync(tmpDir, { recursive: true, force: true });
28
+ });
29
+ it("creates an executable git shim with the correct SHA", async () => {
30
+ const { writeGitShim } = await import("./git-shim.js");
31
+ const sha = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
32
+ writeGitShim(tmpDir, sha);
33
+ const shimPath = path.join(tmpDir, "git");
34
+ expect(fs.existsSync(shimPath)).toBe(true);
35
+ const content = fs.readFileSync(shimPath, "utf-8");
36
+ expect(content).toContain("#!/bin/bash");
37
+ expect(content).toContain(sha);
38
+ expect(content).toContain("ls-remote");
39
+ expect(content).toContain("git.real");
40
+ // Check executable permission
41
+ const stat = fs.statSync(shimPath);
42
+ expect(stat.mode & 0o755).toBe(0o755);
43
+ });
44
+ it("includes all required interception clauses", async () => {
45
+ const { writeGitShim } = await import("./git-shim.js");
46
+ writeGitShim(tmpDir, "abc");
47
+ const content = fs.readFileSync(path.join(tmpDir, "git"), "utf-8");
48
+ // All key interception points
49
+ expect(content).toContain("config --local --get remote.origin.url");
50
+ expect(content).toContain("ls-remote");
51
+ expect(content).toContain("fetch");
52
+ expect(content).toContain("rev-parse");
53
+ expect(content).toContain("clean");
54
+ expect(content).toContain("checkout");
55
+ expect(content).toContain("pass-through");
56
+ });
57
+ });