forgedock 1.27.0 → 1.28.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 +4 -1
- package/bin/bootstrap.mjs +55 -0
- package/bin/engine/outcome-codes.mjs +10 -0
- package/bin/engine/telemetry-seam.mjs +37 -8
- package/bin/engine/telemetry-seam.test.mjs +31 -0
- package/bin/engine-cli.mjs +542 -38
- package/bin/engine.mjs +8 -4
- package/bin/forgedock.mjs +75 -9
- package/bin/gc.mjs +580 -0
- package/bin/lean/admission.mjs +33 -3
- package/bin/lean/batch.mjs +22 -4
- package/bin/lean/batch.test.mjs +5 -3
- package/bin/lean/checks.mjs +199 -0
- package/bin/lean/checks.test.mjs +183 -0
- package/bin/lean/conformance.mjs +190 -58
- package/bin/lean/conformance.test.mjs +116 -7
- package/bin/lean/credentials.mjs +29 -0
- package/bin/lean/depcache.mjs +312 -0
- package/bin/lean/depcache.test.mjs +298 -0
- package/bin/lean/drift.mjs +198 -0
- package/bin/lean/drift.test.mjs +223 -0
- package/bin/lean/effects.mjs +85 -8
- package/bin/lean/execution.e2e.test.mjs +81 -0
- package/bin/lean/execution.mjs +431 -0
- package/bin/lean/execution.test.mjs +583 -0
- package/bin/lean/heavy.mjs +289 -0
- package/bin/lean/heavy.test.mjs +207 -0
- package/bin/lean/lean.mjs +572 -139
- package/bin/lean/lean.test.mjs +1193 -31
- package/bin/lean/ledger.mjs +27 -2
- package/bin/lean/ledger.test.mjs +36 -1
- package/bin/lean/limits.mjs +58 -0
- package/bin/lean/limits.test.mjs +150 -0
- package/bin/lean/orchestrate.mjs +312 -19
- package/bin/lean/prompts.mjs +89 -15
- package/bin/lean/refactor.mjs +276 -0
- package/bin/lean/refactor.test.mjs +359 -0
- package/bin/lean/render.mjs +24 -5
- package/bin/lean/review-trigger.mjs +134 -0
- package/bin/lean/review-trigger.test.mjs +104 -0
- package/bin/lean/sandbox-provider.contract.test.mjs +257 -0
- package/bin/lean/sandbox-remote.mjs +142 -0
- package/bin/lean/sched.mjs +221 -0
- package/bin/lean/sched.test.mjs +310 -0
- package/bin/lean/schedd.mjs +247 -0
- package/bin/lean/schedd.test.mjs +180 -0
- package/bin/lean/toolchain.mjs +228 -0
- package/bin/lean/toolchain.test.mjs +154 -0
- package/bin/lean/verify.mjs +20 -9
- package/bin/lean/verify.test.mjs +26 -3
- package/bin/lean/worktree.mjs +69 -0
- package/bin/lean/worktree.test.mjs +141 -0
- package/bin/runner.mjs +53 -10
- package/bin/stats/github-source.mjs +1 -1
- package/bin/stats/index.mjs +14 -2
- package/bin/stats/query.mjs +72 -0
- package/bin/stats/stats.test.mjs +54 -0
- package/commands/audit.md +2 -0
- package/commands/autopilot.md +2 -0
- package/commands/bootstrap.md +35 -0
- package/commands/cleanup.md +7 -22
- package/commands/forge-stats.md +2 -1
- package/commands/forgedock-init.md +7 -0
- package/commands/issue.md +89 -830
- package/commands/milestone.md +55 -6
- package/commands/orchestrate.md +10 -2
- package/commands/upgrade-deps.md +3 -3
- package/commands/work-on/build/validate.md +4 -0
- package/commands/work-on/close.md +2 -0
- package/commands/work-on/decompose.md +24 -6
- package/docs/CI.md +3 -3
- package/docs/CONFIG.md +177 -2
- package/docs/OUTCOME-CODES.md +8 -0
- package/docs/TELEMETRY.md +1 -1
- package/docs/site/command-reference.md +22 -0
- package/docs/site/getting-started.md +2 -0
- package/docs/site/start-a-new-project.md +327 -0
- package/package.json +1 -1
- package/scripts/affected-files-check.sh +91 -0
- package/scripts/affected-files-check.test.sh +80 -0
- package/scripts/bootstrap-create.mjs +393 -0
- package/scripts/bootstrap-validate.mjs +36 -2
- package/scripts/calibration.mjs +116 -0
- package/scripts/check-devdocs-freshness.sh +9 -4
- package/scripts/check-devdocs-freshness.test.sh +11 -1
- package/scripts/check-spec-markers.sh +3 -0
- package/scripts/check-spec-size.sh +29 -11
- package/scripts/check-spec-size.test.sh +27 -4
- package/scripts/ci-local/lib.mjs +8 -1
- package/scripts/ci-local/parallelism.test.mjs +27 -0
- package/scripts/ci-local/run.mjs +3 -3
- package/scripts/ci-local/scoping.mjs +82 -0
- package/scripts/ci-local.test.sh +1 -1
- package/scripts/fixtures/symbol-index/other/sample.rb +3 -0
- package/scripts/fixtures/symbol-index/py/pkg/__init__.py +1 -0
- package/scripts/fixtures/symbol-index/py/pkg/a.py +5 -0
- package/scripts/fixtures/symbol-index/py/pkg/b.py +14 -0
- package/scripts/fixtures/symbol-index/py/pkg/c.py +1 -0
- package/scripts/fixtures/symbol-index/py/sample.py +41 -0
- package/scripts/fixtures/symbol-index/ts/sample.ts +13 -0
- package/scripts/fixtures/symbol-move/gen_big.py +201 -0
- package/scripts/fixtures/symbol-move-ts/gen_big.mjs +56 -0
- package/scripts/forge-root.test.sh +1 -1
- package/scripts/hotspots.sh +100 -0
- package/scripts/hotspots.test.sh +101 -0
- package/scripts/issue-create.sh +197 -0
- package/scripts/issue-create.test.sh +166 -0
- package/scripts/maintenance-scan.sh +185 -0
- package/scripts/maintenance-scan.test.sh +89 -0
- package/scripts/milestone-drift.sh +206 -0
- package/scripts/milestone-drift.test.sh +120 -0
- package/scripts/refactor-verify.sh +368 -0
- package/scripts/refactor-verify.test.sh +202 -0
- package/scripts/structure-delta.sh +10 -5
- package/scripts/structure-delta.test.sh +3 -2
- package/scripts/symbol-index.mjs +253 -0
- package/scripts/symbol-index.py +354 -0
- package/scripts/symbol-index.test.sh +119 -0
- package/scripts/symbol-move-ts.test.sh +161 -0
- package/scripts/symbol-move.mjs +379 -0
- package/scripts/symbol-move.py +383 -0
- package/scripts/symbol-move.test.sh +168 -0
- package/templates/bootstrap/interview.md +34 -0
- package/templates/bootstrap/issue-body.md +42 -0
- package/templates/bootstrap/needs-human-issue.md +45 -0
- package/templates/bootstrap/stack-profiles.md +34 -0
- package/templates/bootstrap/technical-spike.md +75 -0
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
import { describe, it } from "node:test";
|
|
4
|
+
import assert from "node:assert/strict";
|
|
5
|
+
import { writeFileSync, mkdirSync, chmodSync, existsSync, readFileSync, statSync } from "node:fs";
|
|
6
|
+
import { join, delimiter } from "node:path";
|
|
7
|
+
import { execFileSync } from "node:child_process";
|
|
8
|
+
import { tmpDir } from "../tests/_tmp.mjs";
|
|
9
|
+
import { detectPins, parsePackageManager, nodeSatisfies, nodeWarning, shimScripts, prepareToolchain, toolchainEnv, writeShims, summary } from "./toolchain.mjs";
|
|
10
|
+
|
|
11
|
+
const tmp = (p) => tmpDir(p);
|
|
12
|
+
const repo = (files) => {
|
|
13
|
+
const d = tmp("tc-repo-");
|
|
14
|
+
for (const [f, t] of Object.entries(files)) writeFileSync(join(d, f), typeof t === "string" ? t : JSON.stringify(t));
|
|
15
|
+
return d;
|
|
16
|
+
};
|
|
17
|
+
const exe = (dir, name, body) => { mkdirSync(dir, { recursive: true }); const p = join(dir, name); writeFileSync(p, `#!/bin/sh\n${body}\n`); chmodSync(p, 0o755); return p; };
|
|
18
|
+
/**
|
|
19
|
+
* A host whose PATH has pnpm 11.0.0 and a fake corepack/npx that print the requested version for `--version`
|
|
20
|
+
* (`corepack pnpm@X --version`, `npx -y -p pnpm@X pnpm --version`) without any network.
|
|
21
|
+
*/
|
|
22
|
+
const fakeHost = ({ corepack = true } = {}) => {
|
|
23
|
+
const d = tmp("tc-host-");
|
|
24
|
+
exe(d, "pnpm", 'echo 11.0.0');
|
|
25
|
+
const pick = 'for a in "$@"; do case "$a" in pnpm@*) v="${a#pnpm@}";; esac; done; for a in "$@"; do [ "$a" = "--version" ] && { echo "$v"; exit 0; }; done; exit 0';
|
|
26
|
+
if (corepack) exe(d, "corepack", pick);
|
|
27
|
+
exe(d, "npx", pick);
|
|
28
|
+
return d;
|
|
29
|
+
};
|
|
30
|
+
const sysPath = process.env.PATH ?? "";
|
|
31
|
+
|
|
32
|
+
describe("toolchain pins (#4163)", () => {
|
|
33
|
+
it("parsePackageManager accepts pm@x.y.z (with a +sha suffix) and nothing else", () => {
|
|
34
|
+
assert.deepEqual(parsePackageManager("pnpm@10.4.1+sha512.abc"), { name: "pnpm", version: "10.4.1" });
|
|
35
|
+
assert.deepEqual(parsePackageManager("yarn@4.1.0"), { name: "yarn", version: "4.1.0" });
|
|
36
|
+
for (const bad of ["pnpm@10", "bun@1.0.0", "pnpm@latest", "pnpm@1.0.0;rm -rf /", "", null]) assert.equal(parsePackageManager(bad), null, String(bad));
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("detects packageManager and node pins in priority order", async () => {
|
|
40
|
+
const d = repo({ "package.json": { packageManager: "pnpm@10.4.1", engines: { node: ">=18" } }, ".nvmrc": "20.11.0\n", ".node-version": "22\n" });
|
|
41
|
+
const p = await detectPins(d);
|
|
42
|
+
assert.deepEqual(p.pm, { name: "pnpm", version: "10.4.1", source: "package.json packageManager" });
|
|
43
|
+
assert.deepEqual(p.node, { spec: "20.11.0", source: ".nvmrc" });
|
|
44
|
+
const e = await detectPins(repo({ "package.json": { engines: { node: "^20" } } }));
|
|
45
|
+
assert.deepEqual(e.node, { spec: "^20", source: "package.json engines.node" });
|
|
46
|
+
assert.equal(e.pm, null);
|
|
47
|
+
const tv = await detectPins(repo({ ".tool-versions": "# pins\nnodejs 20.1.0\npnpm 9.1.0\n" }));
|
|
48
|
+
assert.deepEqual(tv.pm, { name: "pnpm", version: "9.1.0", source: ".tool-versions" });
|
|
49
|
+
assert.deepEqual(tv.node, { spec: "20.1.0", source: ".tool-versions" });
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("forge.yaml env.toolchain overrides the pm pin; off disables detection; junk is a note", async () => {
|
|
53
|
+
const d = repo({ "package.json": { packageManager: "pnpm@10.4.1" }, ".nvmrc": "20" });
|
|
54
|
+
assert.deepEqual((await detectPins(d, { override: "pnpm@9.0.0" })).pm, { name: "pnpm", version: "9.0.0", source: "forge.yaml env.toolchain" });
|
|
55
|
+
const off = await detectPins(d, { override: "off" });
|
|
56
|
+
assert.equal(off.pm, null); assert.equal(off.node, null);
|
|
57
|
+
const junk = await detectPins(d, { override: "pnpm" });
|
|
58
|
+
assert.equal(junk.pm.version, "10.4.1");
|
|
59
|
+
assert.match(junk.notes[0], /env\.toolchain "pnpm" is not/);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("nodeSatisfies covers exact, partial, x, caret, tilde, comparators and ||", () => {
|
|
63
|
+
const t = [["20", "v20.11.0", true], ["20", "v22.1.0", false], ["20.11", "v20.11.4", true], ["20.x", "v20.0.0", true], ["^20.5", "v20.4.0", false],
|
|
64
|
+
["^20.5", "v20.9.0", true], ["~20.5.1", "v20.6.0", false], [">=18", "v26.7.0", true], [">=18 <21", "v22.0.0", false], ["18 || 20", "v20.1.0", true],
|
|
65
|
+
["< 20", "v20.0.0", false], ["<=20", "v20.9.9", true], [">20", "v20.9.9", false], [">20", "v21.0.0", true], ["v20.11.0", "v20.11.0", true]];
|
|
66
|
+
for (const [spec, v, want] of t) assert.equal(nodeSatisfies(spec, v), want, `${spec} vs ${v}`);
|
|
67
|
+
assert.equal(nodeSatisfies("18 - 20", "v19.0.0"), true);
|
|
68
|
+
assert.equal(nodeSatisfies("18 - 20", "v21.0.0"), false);
|
|
69
|
+
assert.equal(nodeSatisfies("hydrogen", "v19.0.0"), null);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("an unsatisfied node pin yields a clear warning; a satisfied or alias pin yields none", () => {
|
|
73
|
+
assert.match(nodeWarning({ spec: "18.19.0", source: ".nvmrc" }, "v26.7.0"), /host node v26\.7\.0 does not satisfy the \.nvmrc pin "18\.19\.0"/);
|
|
74
|
+
assert.equal(nodeWarning({ spec: ">=18", source: "package.json engines.node" }, "v26.7.0"), null);
|
|
75
|
+
assert.equal(nodeWarning({ spec: "lts/iron", source: ".nvmrc" }, "v26.7.0"), null);
|
|
76
|
+
assert.match(nodeWarning({ spec: "iron-ish", source: ".nvmrc" }, "v26.7.0"), /cannot evaluate/);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("shims: corepack first, npx fallback, yarn berry via @yarnpkg/cli-dist, values quoted", () => {
|
|
80
|
+
const pm = { name: "pnpm", version: "10.4.1" };
|
|
81
|
+
assert.match(shimScripts(pm, { corepack: "/h/corepack", npx: "/h/npx" }).pnpm, /exec '\/h\/corepack' 'pnpm@10\.4\.1' "\$@"/);
|
|
82
|
+
assert.match(shimScripts(pm, { corepack: "/h/corepack" }).pnpx, /'pnpm@10\.4\.1' dlx "\$@"/);
|
|
83
|
+
assert.match(shimScripts(pm, { npx: "/h/npx" }).pnpm, /exec '\/h\/npx' -y -p 'pnpm@10\.4\.1' pnpm "\$@"/);
|
|
84
|
+
assert.match(shimScripts({ name: "yarn", version: "4.1.0" }, { npx: "/h/npx" }).yarn, /-p '@yarnpkg\/cli-dist@4\.1\.0' yarn/);
|
|
85
|
+
assert.match(shimScripts({ name: "yarn", version: "1.22.19" }, { npx: "/h/npx" }).yarn, /-p 'yarn@1\.22\.19' yarn/);
|
|
86
|
+
const npm = shimScripts({ name: "npm", version: "10.2.0" }, { corepack: "/h/c", npx: "/h/npx" });
|
|
87
|
+
assert.deepEqual(Object.keys(npm), ["npm", "npx"]);
|
|
88
|
+
assert.match(npm.npx, /exec '\/h\/npx' -y -p 'npm@10\.2\.0' npx "\$@"/);
|
|
89
|
+
assert.deepEqual(shimScripts(pm, {}), {});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
for (const corepack of [true, false]) {
|
|
93
|
+
it(`R1: with packageManager pnpm@10.4.1 and pnpm 11 on PATH, runner/validate/hook shells resolve pnpm --version to 10.4.1 (${corepack ? "corepack" : "npx"})`, async () => {
|
|
94
|
+
const host = fakeHost({ corepack });
|
|
95
|
+
const basePath = `${host}${delimiter}${sysPath}`;
|
|
96
|
+
assert.equal(execFileSync("sh", ["-c", "pnpm --version"], { env: { ...process.env, PATH: basePath } }).toString().trim(), "11.0.0", "the host pnpm is the wrong one");
|
|
97
|
+
const d = repo({ "package.json": { name: "x", packageManager: "pnpm@10.4.1+sha512.ff" } });
|
|
98
|
+
const binDir = join(tmp("tc-run-"), "toolchain", "bin");
|
|
99
|
+
const tc = await prepareToolchain({ dir: d, binDir, basePath, hostNode: () => "v20.0.0" });
|
|
100
|
+
assert.ok(tc.env && tc.env.PATH.startsWith(binDir + delimiter), "shim dir first on PATH");
|
|
101
|
+
assert.equal(tc.summary, "pnpm@10.4.1 (package.json packageManager)");
|
|
102
|
+
const env = { ...process.env, ...tc.env };
|
|
103
|
+
// runner: the claude CLI child gets this env (bin/runner.mjs applies `env` last); its Bash tool shells inherit it.
|
|
104
|
+
assert.equal(execFileSync("bash", ["-lc", "pnpm --version"], { env, cwd: d }).toString().trim(), "10.4.1", "runner shell");
|
|
105
|
+
// validate: a validate command is a shell command in the worktree.
|
|
106
|
+
assert.equal(execFileSync("sh", ["-c", "cd . && pnpm --version"], { env, cwd: d }).toString().trim(), "10.4.1", "validate command");
|
|
107
|
+
// hooks: git runs pre-commit / commit-msg hooks with the committing shell's PATH.
|
|
108
|
+
const git = (a) => execFileSync("git", a, { env: { ...env, GIT_AUTHOR_NAME: "t", GIT_AUTHOR_EMAIL: "t@t", GIT_COMMITTER_NAME: "t", GIT_COMMITTER_EMAIL: "t@t" }, cwd: d }).toString();
|
|
109
|
+
git(["init", "-q"]);
|
|
110
|
+
exe(join(d, ".git", "hooks"), "pre-commit", `pnpm --version > "${join(d, "pre.out")}"`);
|
|
111
|
+
exe(join(d, ".git", "hooks"), "commit-msg", `pnpm --version > "${join(d, "msg.out")}"`);
|
|
112
|
+
git(["add", "package.json"]);
|
|
113
|
+
git(["commit", "-q", "-m", "x"]);
|
|
114
|
+
assert.equal(readFileSync(join(d, "pre.out"), "utf8").trim(), "10.4.1", "pre-commit hook");
|
|
115
|
+
assert.equal(readFileSync(join(d, "msg.out"), "utf8").trim(), "10.4.1", "commit-msg hook");
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
it("R2: a node pin the host does not satisfy produces a warning (and no shim without a pm pin)", async () => {
|
|
120
|
+
const binDir = join(tmp("tc-run-"), "bin");
|
|
121
|
+
const tc = await prepareToolchain({ dir: repo({ ".nvmrc": "18.19.0" }), binDir, hostNode: () => "v26.7.0" });
|
|
122
|
+
assert.match(tc.warning, /^host node v26\.7\.0 does not satisfy the \.nvmrc pin "18\.19\.0"/);
|
|
123
|
+
assert.equal(tc.summary, "node 18.19.0 (.nvmrc)");
|
|
124
|
+
assert.equal(tc.env, null); assert.equal(existsSync(binDir), false);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("R4: a repo without pins gets nothing: no env, no shim dir, no warning, no notes", async () => {
|
|
128
|
+
const binDir = join(tmp("tc-run-"), "bin");
|
|
129
|
+
for (const files of [{}, { "package.json": { name: "x", scripts: { test: "node --test" } } }]) {
|
|
130
|
+
const tc = await prepareToolchain({ dir: repo(files), binDir, hostNode: () => { throw new Error("must not probe node"); } });
|
|
131
|
+
assert.equal(tc.env, null); assert.equal(tc.summary, null); assert.equal(tc.warning, null);
|
|
132
|
+
assert.deepEqual(tc.shims, []); assert.deepEqual(tc.notes, []);
|
|
133
|
+
assert.equal(existsSync(binDir), false);
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("toolchainEnv prepends the shim dir", () => {
|
|
138
|
+
assert.deepEqual(toolchainEnv("/s", "/a:/b"), { PATH: `/s${delimiter}/a:/b`, COREPACK_ENABLE_DOWNLOAD_PROMPT: "0", COREPACK_ENABLE_STRICT: "0" });
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("writeShims writes 0755 shims that never resolve the launcher from the shim dir; summary names the pins", async () => {
|
|
142
|
+
const host = fakeHost();
|
|
143
|
+
const binDir = join(tmp("tc-ws-"), "bin");
|
|
144
|
+
exe(binDir, "corepack", "echo self");
|
|
145
|
+
const names = await writeShims(binDir, { name: "pnpm", version: "10.4.1" }, { path: `${binDir}${delimiter}${host}` });
|
|
146
|
+
assert.deepEqual(names, ["pnpm", "pnpx"]);
|
|
147
|
+
for (const n of names) assert.equal(statSync(join(binDir, n)).mode & 0o777, 0o755);
|
|
148
|
+
assert.match(readFileSync(join(binDir, "pnpm"), "utf8"), new RegExp(`exec '${join(host, "corepack")}' 'pnpm@10\\.4\\.1'`));
|
|
149
|
+
assert.deepEqual(await writeShims(join(tmp("tc-ws-"), "bin"), { name: "yarn", version: "4.1.0" }, { path: "/nonexistent" }), []);
|
|
150
|
+
assert.equal(summary({ pm: { name: "pnpm", version: "10.4.1", source: "package.json packageManager" }, node: { spec: "20", source: ".nvmrc" } }), "pnpm@10.4.1 (package.json packageManager), node 20 (.nvmrc)");
|
|
151
|
+
assert.equal(summary({ pm: null, node: null }), null);
|
|
152
|
+
assert.equal(nodeWarning({ spec: "1", source: ".nvmrc" }) !== null, true, "defaults to process.version");
|
|
153
|
+
});
|
|
154
|
+
});
|
package/bin/lean/verify.mjs
CHANGED
|
@@ -11,6 +11,8 @@ export const VERIFY_MODES = Object.freeze(["local", "ci", "hybrid"]);
|
|
|
11
11
|
/** Changed files that never need the CI matrix (docs-only lanes skip CI verification). */
|
|
12
12
|
export const DEFAULT_SKIP_PATHS = Object.freeze(["*.md", "*.mdx", "*.rst", "*.txt", "docs/**", "devdocs/**"]);
|
|
13
13
|
export const DEFAULT_MAX_ROUNDS = 2;
|
|
14
|
+
/** Per-command timeout of the pre-PR declared check commands (#4240, `verify.precheck_timeout_seconds`). */
|
|
15
|
+
export const DEFAULT_PRECHECK_TIMEOUT_SECONDS = 300;
|
|
14
16
|
/** Total characters of failed-job log handed to the builder. */
|
|
15
17
|
export const LOG_CAP = 12000;
|
|
16
18
|
/** No run or check for the head after this long means CI is unavailable (local fallback). */
|
|
@@ -20,7 +22,7 @@ const OK = ["success", "neutral", "skipped"];
|
|
|
20
22
|
|
|
21
23
|
/**
|
|
22
24
|
* forge.yaml `verify:` section (block style; parseForgeYaml gives strings) → {mode, workflow, pathsInput, skipPaths,
|
|
23
|
-
* maxRounds}. Invalid values fall back to the defaults; mode defaults to local (today's behaviour).
|
|
25
|
+
* maxRounds, precheckTimeoutSeconds}. Invalid values fall back to the defaults; mode defaults to local (today's behaviour).
|
|
24
26
|
*/
|
|
25
27
|
export function parseVerify(sec = {}) {
|
|
26
28
|
const s = sec && typeof sec === "object" ? sec : {};
|
|
@@ -32,7 +34,9 @@ export function parseVerify(sec = {}) {
|
|
|
32
34
|
const skipPaths = rawSkip ? rawSkip.map((x) => String(x).trim()).filter(Boolean) : [...DEFAULT_SKIP_PATHS];
|
|
33
35
|
const n = Number(str(s.max_rounds));
|
|
34
36
|
const maxRounds = str(s.max_rounds) !== "" && Number.isInteger(n) && n >= 0 && n <= 10 ? n : DEFAULT_MAX_ROUNDS;
|
|
35
|
-
|
|
37
|
+
const t = Number(str(s.precheck_timeout_seconds));
|
|
38
|
+
const precheckTimeoutSeconds = str(s.precheck_timeout_seconds) !== "" && Number.isInteger(t) && t >= 1 && t <= 3600 ? t : DEFAULT_PRECHECK_TIMEOUT_SECONDS;
|
|
39
|
+
return { mode, workflow, pathsInput, skipPaths, maxRounds, precheckTimeoutSeconds };
|
|
36
40
|
}
|
|
37
41
|
|
|
38
42
|
const globRe = (g) => new RegExp(`^${g.split("**").map((p) => p.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, "[^/]*").replace(/\?/g, "[^/]")).join(".*")}$`);
|
|
@@ -55,6 +59,19 @@ export function capLogs(failures, cap = LOG_CAP) {
|
|
|
55
59
|
return failures.map((f) => { const l = String(f.log ?? ""); return { ...f, log: l.length > per ? `…${l.slice(-per)}` : l }; });
|
|
56
60
|
}
|
|
57
61
|
const runIdOf = (url) => /\/actions\/runs\/(\d+)/.exec(String(url ?? ""))?.[1] ?? null;
|
|
62
|
+
/**
|
|
63
|
+
* Failed checks [{name, url}] → capped [{job, url, log}]: one `failedLogs` fetch per Actions run id, the log attached to
|
|
64
|
+
* the first check of that run only (#4240: shared by runCiVerify and the merge-time fix rounds).
|
|
65
|
+
*/
|
|
66
|
+
export async function collectFailedLogs(io, failedChecks) {
|
|
67
|
+
const logs = {}, out = [];
|
|
68
|
+
for (const c of failedChecks ?? []) {
|
|
69
|
+
const id = runIdOf(c.url);
|
|
70
|
+
if (id && !(id in logs)) { try { logs[id] = String((await io.failedLogs?.(id)) ?? ""); } catch { logs[id] = ""; } }
|
|
71
|
+
out.push({ job: c.name, url: c.url ?? null, log: id && !out.some((x) => runIdOf(x.url) === id) ? logs[id] : "" });
|
|
72
|
+
}
|
|
73
|
+
return capLogs(out);
|
|
74
|
+
}
|
|
58
75
|
const sameSha = (a, b) => Boolean(a && b) && (String(a).startsWith(b) || String(b).startsWith(a));
|
|
59
76
|
|
|
60
77
|
/**
|
|
@@ -97,13 +114,7 @@ export async function runCiVerify({ io, branch, head, changedFiles, verify, ciMa
|
|
|
97
114
|
if (checks.length && checks.every((c) => c.state !== "pending")) {
|
|
98
115
|
const failed = checks.filter((c) => c.state === "failure");
|
|
99
116
|
if (!failed.length) return { result: "pass", failures: [], runUrl: null, detail: `${checks.length} check(s) green` };
|
|
100
|
-
|
|
101
|
-
for (const c of failed) {
|
|
102
|
-
const id = runIdOf(c.url);
|
|
103
|
-
if (id && !(id in logs)) { try { logs[id] = String((await io.failedLogs?.(id)) ?? ""); } catch { logs[id] = ""; } }
|
|
104
|
-
out.push({ job: c.name, url: c.url ?? null, log: id && !out.some((x) => runIdOf(x.url) === id) ? logs[id] : "" });
|
|
105
|
-
}
|
|
106
|
-
return { result: "fail", failures: capLogs(out), runUrl: failed[0].url ?? null, detail: `${failed.length} check(s) failed: ${failed.map((c) => c.name).join(", ")}` };
|
|
117
|
+
return { result: "fail", failures: await collectFailedLogs(io, failed), runUrl: failed[0].url ?? null, detail: `${failed.length} check(s) failed: ${failed.map((c) => c.name).join(", ")}` };
|
|
107
118
|
}
|
|
108
119
|
}
|
|
109
120
|
if (now() - startedAt >= ciMaxMinutes * 60000) return { result: "timeout", failures: [], runUrl: null, detail: `CI still pending after ${ciMaxMinutes} min` };
|
package/bin/lean/verify.test.mjs
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
3
|
import { describe, it } from "node:test";
|
|
4
4
|
import assert from "node:assert/strict";
|
|
5
|
-
import { parseVerify, matchesGlob, skipsCi, capLogs, runCiVerify, DEFAULT_SKIP_PATHS, LOG_CAP } from "./verify.mjs";
|
|
5
|
+
import { parseVerify, matchesGlob, skipsCi, capLogs, runCiVerify, collectFailedLogs, DEFAULT_SKIP_PATHS, LOG_CAP } from "./verify.mjs";
|
|
6
6
|
import { precheck } from "./conformance.mjs";
|
|
7
7
|
|
|
8
8
|
const HEAD = "abc123abc123abc123";
|
|
@@ -22,9 +22,9 @@ function fake(o = {}) {
|
|
|
22
22
|
|
|
23
23
|
describe("verify-by-CI config (#4167)", () => {
|
|
24
24
|
it("parseVerify defaults to local and rejects invalid values", () => {
|
|
25
|
-
assert.deepEqual(parseVerify({}), { mode: "local", workflow: null, pathsInput: null, skipPaths: [...DEFAULT_SKIP_PATHS], maxRounds: 2 });
|
|
25
|
+
assert.deepEqual(parseVerify({}), { mode: "local", workflow: null, pathsInput: null, skipPaths: [...DEFAULT_SKIP_PATHS], maxRounds: 2, precheckTimeoutSeconds: 300 });
|
|
26
26
|
const v = parseVerify({ mode: "ci", workflow: ".github/workflows/verify.yml", paths_input: "changed_paths", skip_paths: "*.md, docs/** ,", max_rounds: "3" });
|
|
27
|
-
assert.deepEqual(v, { mode: "ci", workflow: ".github/workflows/verify.yml", pathsInput: "changed_paths", skipPaths: ["*.md", "docs/**"], maxRounds: 3 });
|
|
27
|
+
assert.deepEqual(v, { mode: "ci", workflow: ".github/workflows/verify.yml", pathsInput: "changed_paths", skipPaths: ["*.md", "docs/**"], maxRounds: 3, precheckTimeoutSeconds: 300 });
|
|
28
28
|
assert.equal(parseVerify({ mode: "remote" }).mode, "local");
|
|
29
29
|
assert.equal(parseVerify({ workflow: "x; rm -rf /" }).workflow, null);
|
|
30
30
|
assert.equal(parseVerify({ max_rounds: "-1" }).maxRounds, 2);
|
|
@@ -93,3 +93,26 @@ describe("runCiVerify (#4167)", () => {
|
|
|
93
93
|
assert.equal((await runCiVerify(g.args({ verify: parseVerify({ mode: "ci" }) }))).result, "unavailable");
|
|
94
94
|
});
|
|
95
95
|
});
|
|
96
|
+
|
|
97
|
+
describe("merge-time fix rounds and declared check commands (#4240)", () => {
|
|
98
|
+
it("collectFailedLogs fetches one log per Actions run and caps the total", async () => {
|
|
99
|
+
const fetched = [];
|
|
100
|
+
const io = { failedLogs: async (id) => { fetched.push(id); return "x".repeat(LOG_CAP * 2); } };
|
|
101
|
+
const out = await collectFailedLogs(io, [
|
|
102
|
+
{ name: "Unit tests", url: "https://github.com/o/r/actions/runs/9/job/1" },
|
|
103
|
+
{ name: "Shell tests", url: "https://github.com/o/r/actions/runs/9/job/2" },
|
|
104
|
+
{ name: "status", url: null },
|
|
105
|
+
]);
|
|
106
|
+
assert.deepEqual(fetched, ["9"]);
|
|
107
|
+
assert.deepEqual(out.map((f) => f.job), ["Unit tests", "Shell tests", "status"]);
|
|
108
|
+
assert.ok(out[0].log.length > 0 && out[1].log === "" && out[2].log === "");
|
|
109
|
+
assert.ok(out.reduce((n, f) => n + f.log.length, 0) <= LOG_CAP + 3);
|
|
110
|
+
assert.deepEqual(await collectFailedLogs({ failedLogs: async () => { throw new Error("gone"); } }, [{ name: "a", url: "https://x/actions/runs/1" }]), [{ job: "a", url: "https://x/actions/runs/1", log: "" }]);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("parseVerify precheck_timeout_seconds: default 300, integer 1-3600 accepted, others fall back", () => {
|
|
114
|
+
assert.equal(parseVerify({}).precheckTimeoutSeconds, 300);
|
|
115
|
+
assert.equal(parseVerify({ precheck_timeout_seconds: "60" }).precheckTimeoutSeconds, 60);
|
|
116
|
+
for (const bad of ["0", "-5", "1.5", "abc", "3601"]) assert.equal(parseVerify({ precheck_timeout_seconds: bad }).precheckTimeoutSeconds, 300, bad);
|
|
117
|
+
});
|
|
118
|
+
});
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
/**
|
|
4
|
+
* Sparse lane worktrees (#4221). A lane checks out every tracked file except regular blobs at or above
|
|
5
|
+
* `maxBlobBytes` (forge.yaml `env.sparse_max_kb`, default 1024; 0 = off). Lockfiles, manifests, toolchain pins and
|
|
6
|
+
* git/forge config are always checked out (the #4220 dep cache and #4163 toolchain pins read them). The patterns are
|
|
7
|
+
* non-cone (`/*` then `!/<path>`), kept in the worktree's own `info/sparse-checkout`; `core.sparseCheckout` is per-worktree
|
|
8
|
+
* config (git sets `extensions.worktreeConfig` in the shared config), so the main checkout is untouched. A repo with no
|
|
9
|
+
* such blob gets today's plain `git worktree add`. A failed sparse step falls back to a full checkout; never fails a lane.
|
|
10
|
+
*/
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
|
|
13
|
+
export const DEFAULT_SPARSE_MAX_KB = 1024;
|
|
14
|
+
|
|
15
|
+
const KEEP_RE = /^(package\.json|package-lock\.json|npm-shrinkwrap\.json|pnpm-lock\.yaml|pnpm-workspace\.yaml|yarn\.lock|\.yarnrc\.yml|uv\.lock|poetry\.lock|pyproject\.toml|requirements.*\.txt|setup\.py|setup\.cfg|\.nvmrc|\.node-version|\.tool-versions|tsconfig.*\.json|forge\.yaml|\.gitattributes|\.gitignore)$/;
|
|
16
|
+
|
|
17
|
+
/** Blobs to leave out, from `git ls-tree -r -l -z <ref>` output: {excludes, bytes}. Symlinks, gitlinks and kept files never. */
|
|
18
|
+
export function sparsePlan(lsTree, { maxBlobBytes = DEFAULT_SPARSE_MAX_KB * 1024 } = {}) {
|
|
19
|
+
const excludes = [];
|
|
20
|
+
let bytes = 0;
|
|
21
|
+
if (!(maxBlobBytes > 0)) return { excludes, bytes };
|
|
22
|
+
for (const rec of String(lsTree ?? "").split("\0")) {
|
|
23
|
+
const m = /^(\d{6}) blob [0-9a-f]+ +(\d+)\t([\s\S]+)$/.exec(rec.replace(/^\n+/, ""));
|
|
24
|
+
if (!m || !["100644", "100755"].includes(m[1])) continue;
|
|
25
|
+
const size = Number(m[2]), path = m[3];
|
|
26
|
+
if (size < maxBlobBytes || /[\n\r]/.test(path) || KEEP_RE.test(path.split("/").at(-1))) continue;
|
|
27
|
+
excludes.push(path);
|
|
28
|
+
bytes += size;
|
|
29
|
+
}
|
|
30
|
+
return { excludes, bytes };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** gitignore-syntax escape for a literal path: \ * ? [ anywhere, trailing spaces. */
|
|
34
|
+
const esc = (p) => p.replace(/[\\*?[]| (?= *$)/g, (c) => `\\${c}`);
|
|
35
|
+
|
|
36
|
+
/** Non-cone sparse patterns that include everything except `excludes`. */
|
|
37
|
+
export function sparsePatterns(excludes) {
|
|
38
|
+
return ["/*", ...excludes.map((p) => `!/${esc(p)}`)];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Add a lane worktree, sparse when the plan is non-empty. `git(args, {cwd})` is the injected io.git; `write(path, text)`
|
|
43
|
+
* writes the pattern file (argv-safe for any number of patterns). `addArgs` is the plain `["worktree","add",...]` argv.
|
|
44
|
+
* A failing `worktree add` is rethrown unchanged (the caller retries lock races). Returns {sparse, excluded, bytes, fallback?}.
|
|
45
|
+
*/
|
|
46
|
+
export async function addLaneWorktree(git, write, { cwd, wt, addArgs, ref, maxBlobBytes = DEFAULT_SPARSE_MAX_KB * 1024 }) {
|
|
47
|
+
let plan = { excludes: [], bytes: 0 };
|
|
48
|
+
if (maxBlobBytes > 0 && typeof write === "function") {
|
|
49
|
+
try { plan = sparsePlan(await git(["ls-tree", "-r", "-l", "-z", ref], { cwd }), { maxBlobBytes }); } catch { /* no plan: full checkout */ }
|
|
50
|
+
}
|
|
51
|
+
if (!plan.excludes.length) {
|
|
52
|
+
await git(addArgs, { cwd });
|
|
53
|
+
return { sparse: false, excluded: 0, bytes: 0 };
|
|
54
|
+
}
|
|
55
|
+
await git([...addArgs.slice(0, 2), "--no-checkout", ...addArgs.slice(2)], { cwd });
|
|
56
|
+
const g = (a) => git(a, { cwd: wt });
|
|
57
|
+
try {
|
|
58
|
+
await g(["sparse-checkout", "set", "--no-cone", "/*"]);
|
|
59
|
+
const file = String(await g(["rev-parse", "--git-path", "info/sparse-checkout"])).trim();
|
|
60
|
+
await write(file.startsWith("/") ? file : join(wt, file), `${sparsePatterns(plan.excludes).join("\n")}\n`);
|
|
61
|
+
await g(["read-tree", "-mu", "HEAD"]);
|
|
62
|
+
return { sparse: true, excluded: plan.excludes.length, bytes: plan.bytes };
|
|
63
|
+
} catch (e) {
|
|
64
|
+
const reason = String(e?.stderr || e?.message || e).trim().split("\n").slice(-2).join(" ").slice(0, 300);
|
|
65
|
+
try { await g(["sparse-checkout", "disable"]); } catch { /* full read-tree below */ }
|
|
66
|
+
await g(["read-tree", "-mu", "HEAD"]);
|
|
67
|
+
return { sparse: false, excluded: 0, bytes: 0, fallback: reason || "sparse checkout failed" };
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
import { describe, it } from "node:test";
|
|
4
|
+
import assert from "node:assert/strict";
|
|
5
|
+
import { execFileSync } from "node:child_process";
|
|
6
|
+
import { mkdirSync, writeFileSync, readdirSync, statSync, existsSync, rmSync } from "node:fs";
|
|
7
|
+
import { tmpDir } from "../tests/_tmp.mjs";
|
|
8
|
+
import { join, dirname } from "node:path";
|
|
9
|
+
import { sparsePlan, sparsePatterns, addLaneWorktree } from "./worktree.mjs";
|
|
10
|
+
|
|
11
|
+
const MiB = 1024 * 1024;
|
|
12
|
+
const gitSync = (args, cwd) => execFileSync("git", args, { cwd, encoding: "utf8", maxBuffer: 64 * MiB, stdio: ["ignore", "pipe", "pipe"] });
|
|
13
|
+
const git = async (args, { cwd } = {}) => gitSync(args, cwd);
|
|
14
|
+
const write = async (p, t) => { mkdirSync(dirname(p), { recursive: true }); writeFileSync(p, t); };
|
|
15
|
+
const blob = (n, ch = "x") => ch.repeat(n);
|
|
16
|
+
|
|
17
|
+
/** A fixture repo: {path: content}. Returns its dir. */
|
|
18
|
+
function repo(files) {
|
|
19
|
+
const dir = tmpDir("wt4221-");
|
|
20
|
+
const r = join(dir, "r");
|
|
21
|
+
mkdirSync(r);
|
|
22
|
+
gitSync(["init", "-q", "-b", "main"], r);
|
|
23
|
+
for (const [p, c] of Object.entries(files)) { mkdirSync(dirname(join(r, p)), { recursive: true }); writeFileSync(join(r, p), c); }
|
|
24
|
+
gitSync(["add", "-A"], r);
|
|
25
|
+
gitSync(["-c", "user.name=t", "-c", "user.email=t@t", "commit", "-qm", "init"], r);
|
|
26
|
+
return { dir, r, wt: join(dir, "wt") };
|
|
27
|
+
}
|
|
28
|
+
/** Bytes of regular files on disk under `d`, skipping .git. */
|
|
29
|
+
function diskBytes(d) {
|
|
30
|
+
let n = 0;
|
|
31
|
+
for (const e of readdirSync(d, { withFileTypes: true })) {
|
|
32
|
+
if (e.name === ".git") continue;
|
|
33
|
+
const p = join(d, e.name);
|
|
34
|
+
if (e.isDirectory()) n += diskBytes(p); else if (e.isFile()) n += statSync(p).size;
|
|
35
|
+
}
|
|
36
|
+
return n;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const FIXTURES = {
|
|
40
|
+
"npm JS/TS": {
|
|
41
|
+
files: { "package.json": '{"name":"a","version":"1.0.0"}\n', "package-lock.json": `{"lockfileVersion":3,"pad":"${blob(MiB + 10)}"}\n`,
|
|
42
|
+
"tsconfig.json": "{}\n", "src/index.ts": "export const x = 1;\n", "public/asset.json": blob(2 * MiB) },
|
|
43
|
+
big: "public/asset.json", kept: ["package.json", "package-lock.json", "src/index.ts", "tsconfig.json"],
|
|
44
|
+
},
|
|
45
|
+
"uv Python": {
|
|
46
|
+
files: { "pyproject.toml": '[project]\nname = "a"\nversion = "0.1.0"\n', "uv.lock": `version = 1\n# ${blob(MiB + 10)}\n`,
|
|
47
|
+
"app/main.py": "print(1)\n", "data/blob.bin": blob(2 * MiB, "\u0001") },
|
|
48
|
+
big: "data/blob.bin", kept: ["pyproject.toml", "uv.lock", "app/main.py"],
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
describe("sparse lane worktrees (#4221)", () => {
|
|
53
|
+
for (const [name, fx] of Object.entries(FIXTURES)) {
|
|
54
|
+
it(`${name}: large blobs stay out, lockfiles and manifests stay in, git sees no change`, async () => {
|
|
55
|
+
const { dir, r, wt } = repo(fx.files);
|
|
56
|
+
try {
|
|
57
|
+
const res = await addLaneWorktree(git, write, { cwd: r, wt, addArgs: ["worktree", "add", "-B", "feat/issue-1-lean", wt, "main"], ref: "main", maxBlobBytes: MiB });
|
|
58
|
+
assert.deepEqual(res, { sparse: true, excluded: 1, bytes: 2 * MiB });
|
|
59
|
+
assert.equal(existsSync(join(wt, fx.big)), false);
|
|
60
|
+
assert.match(gitSync(["ls-files", "-t", fx.big], wt), /^S /);
|
|
61
|
+
for (const k of fx.kept) assert.ok(existsSync(join(wt, k)), `${k} checked out`);
|
|
62
|
+
assert.equal(gitSync(["status", "--porcelain"], wt), "");
|
|
63
|
+
gitSync(["add", "-A"], wt);
|
|
64
|
+
assert.equal(gitSync(["diff", "--cached", "--name-only"], wt), "", "git add -A stages no deletion");
|
|
65
|
+
// R5: on-disk checkout is no more than the included blobs (+1 MiB slack): the left-out blob costs nothing.
|
|
66
|
+
const included = Object.entries(fx.files).filter(([p]) => p !== fx.big).reduce((n, [, c]) => n + Buffer.byteLength(c), 0);
|
|
67
|
+
assert.ok(diskBytes(wt) <= included + MiB, `disk ${diskBytes(wt)} <= ${included + MiB}`);
|
|
68
|
+
assert.throws(() => gitSync(["config", "--get", "core.sparseCheckout"], r), "main checkout has no core.sparseCheckout");
|
|
69
|
+
assert.equal(Object.keys(fx.files).every((p) => existsSync(join(r, p))), true);
|
|
70
|
+
mkdirSync(dirname(join(wt, fx.big)), { recursive: true });
|
|
71
|
+
writeFileSync(join(wt, fx.big), "regenerated\n");
|
|
72
|
+
assert.match(gitSync(["status", "--porcelain"], wt), new RegExp(` M ${fx.big}`));
|
|
73
|
+
gitSync(["checkout", "--", fx.big], wt);
|
|
74
|
+
gitSync(["sparse-checkout", "add", `/${fx.big}`], wt);
|
|
75
|
+
assert.equal(statSync(join(wt, fx.big)).size, 2 * MiB);
|
|
76
|
+
} finally { rmSync(dir, { recursive: true, force: true }); }
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
it("a repo with no large blob gets today's plain worktree add", async () => {
|
|
81
|
+
const { dir, r, wt } = repo({ "package.json": "{}\n", "src/a.js": "1\n" });
|
|
82
|
+
try {
|
|
83
|
+
const calls = [];
|
|
84
|
+
const spy = async (a, o) => { calls.push(a); return git(a, o); };
|
|
85
|
+
const addArgs = ["worktree", "add", "-B", "feat/issue-1-lean", wt, "main"];
|
|
86
|
+
assert.deepEqual(await addLaneWorktree(spy, write, { cwd: r, wt, addArgs, ref: "main" }), { sparse: false, excluded: 0, bytes: 0 });
|
|
87
|
+
assert.deepEqual(calls.filter((a) => a[0] === "worktree"), [addArgs]);
|
|
88
|
+
assert.equal(calls.some((a) => a[0] === "sparse-checkout"), false);
|
|
89
|
+
} finally { rmSync(dir, { recursive: true, force: true }); }
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("maxBlobBytes 0 disables it; a failing sparse step falls back to a full checkout", async () => {
|
|
93
|
+
const { dir, r, wt } = repo({ "a.bin": blob(2 * MiB), "b.txt": "1\n" });
|
|
94
|
+
try {
|
|
95
|
+
const calls = [];
|
|
96
|
+
const spy = async (a, o) => { calls.push(a); return git(a, o); };
|
|
97
|
+
await addLaneWorktree(spy, write, { cwd: r, wt, addArgs: ["worktree", "add", "-B", "lane", wt, "main"], ref: "main", maxBlobBytes: 0 });
|
|
98
|
+
assert.equal(calls.some((a) => a[0] === "ls-tree" || a.includes("--no-checkout")), false);
|
|
99
|
+
assert.ok(existsSync(join(wt, "a.bin")));
|
|
100
|
+
gitSync(["worktree", "remove", "--force", wt], r);
|
|
101
|
+
const failing = async (a, o) => { if (a[0] === "read-tree" && !failing.done) { failing.done = true; throw Object.assign(new Error("boom"), { stderr: "fatal: boom" }); } return git(a, o); };
|
|
102
|
+
const res = await addLaneWorktree(failing, write, { cwd: r, wt, addArgs: ["worktree", "add", "-B", "lane", wt, "main"], ref: "main", maxBlobBytes: MiB });
|
|
103
|
+
assert.equal(res.sparse, false);
|
|
104
|
+
assert.match(res.fallback, /boom/);
|
|
105
|
+
assert.ok(existsSync(join(wt, "a.bin")) && existsSync(join(wt, "b.txt")));
|
|
106
|
+
assert.equal(gitSync(["status", "--porcelain"], wt), "");
|
|
107
|
+
} finally { rmSync(dir, { recursive: true, force: true }); }
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("a failing worktree add is rethrown unchanged (the caller retries lock races)", async () => {
|
|
111
|
+
const e = Object.assign(new Error("lock"), { stderr: "could not lock config file" });
|
|
112
|
+
const g = async (a) => { if (a[0] === "worktree") throw e; return "100644 blob " + "a".repeat(40) + " 2000000\tbig.bin\0"; };
|
|
113
|
+
await assert.rejects(addLaneWorktree(g, write, { cwd: "/x", wt: "/x/wt", addArgs: ["worktree", "add", "/x/wt", "main"], ref: "main" }), (x) => x === e);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("sparsePlan: threshold, keep-list, symlinks and gitlinks; patterns escape glob characters", () => {
|
|
117
|
+
const h = "a".repeat(40);
|
|
118
|
+
const out = [
|
|
119
|
+
`100644 blob ${h} 2000000\tweb/openapi.json`, `100755 blob ${h} 1048576\tbin/tool`, `100644 blob ${h} 1048575\tsmall.txt`,
|
|
120
|
+
`120000 blob ${h} 40\tlink`, `160000 commit ${h} -\tsub`, `100644 blob ${h} 3000000\tpnpm-lock.yaml`,
|
|
121
|
+
`100644 blob ${h} 3000000\tsvc/requirements-dev.txt`, `100644 blob ${h} 3000000\tpkg/tsconfig.base.json`, `100644 blob ${h} 5000000\ta [x]*?.bin `,
|
|
122
|
+
].join("\0") + "\0";
|
|
123
|
+
const p = sparsePlan(out, { maxBlobBytes: MiB });
|
|
124
|
+
assert.deepEqual(p.excludes, ["web/openapi.json", "bin/tool", "a [x]*?.bin "]);
|
|
125
|
+
assert.equal(p.bytes, 2000000 + 1048576 + 5000000);
|
|
126
|
+
assert.deepEqual(sparsePlan(out, { maxBlobBytes: 0 }).excludes, []);
|
|
127
|
+
assert.deepEqual(sparsePatterns(p.excludes), ["/*", "!/web/openapi.json", "!/bin/tool", "!/a \\[x]\\*\\?.bin\\ "]);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("an escaped pattern really matches its odd path", async () => {
|
|
131
|
+
const odd = "d/a [x]*?.bin";
|
|
132
|
+
const { dir, r, wt } = repo({ [odd]: blob(2 * MiB), "d/a x.bin": "keep\n", "f.txt": "1\n" });
|
|
133
|
+
try {
|
|
134
|
+
const res = await addLaneWorktree(git, write, { cwd: r, wt, addArgs: ["worktree", "add", "-B", "lane", wt, "main"], ref: "main", maxBlobBytes: MiB });
|
|
135
|
+
assert.equal(res.sparse, true);
|
|
136
|
+
assert.equal(existsSync(join(wt, odd)), false);
|
|
137
|
+
assert.ok(existsSync(join(wt, "d/a x.bin")));
|
|
138
|
+
assert.equal(gitSync(["status", "--porcelain"], wt), "");
|
|
139
|
+
} finally { rmSync(dir, { recursive: true, force: true }); }
|
|
140
|
+
});
|
|
141
|
+
});
|
package/bin/runner.mjs
CHANGED
|
@@ -359,6 +359,37 @@ export function resolveConfiguredDefaultModel(cwd) {
|
|
|
359
359
|
}
|
|
360
360
|
}
|
|
361
361
|
|
|
362
|
+
/**
|
|
363
|
+
* Whether the owner approved the paid API backend as the `auto` fallback (#4257).
|
|
364
|
+
* Needs both forge.yaml `execution.agent_backend: api` in `cwd` (repo owner) and
|
|
365
|
+
* env `FORGEDOCK_ALLOW_API_BACKEND=1` (host owner), so a committed config edit
|
|
366
|
+
* alone cannot start billing an API key. Never throws.
|
|
367
|
+
*
|
|
368
|
+
* @param {{cwd?: string, env?: object}} [opts]
|
|
369
|
+
* @returns {boolean}
|
|
370
|
+
*/
|
|
371
|
+
export function isApiBackendApproved({ cwd = process.cwd(), env = process.env } = {}) {
|
|
372
|
+
if (String(env?.FORGEDOCK_ALLOW_API_BACKEND ?? "").trim() !== "1") return false;
|
|
373
|
+
try {
|
|
374
|
+
const forgeYamlPath = join(cwd, "forge.yaml");
|
|
375
|
+
if (!existsSync(forgeYamlPath)) return false;
|
|
376
|
+
return parseForgeYaml(readFileSync(forgeYamlPath, "utf-8"))?.execution?.agent_backend === "api";
|
|
377
|
+
} catch {
|
|
378
|
+
return false;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/** #4257: typed error when `auto` finds no usable CLI and the API backend is not approved. */
|
|
383
|
+
function cliUnavailableError(detail = "The claude CLI is not available") {
|
|
384
|
+
const err = new Error(
|
|
385
|
+
`${detail}, and the API backend is never used implicitly. Install and authenticate the \`claude\` CLI, ` +
|
|
386
|
+
"or opt in to the API backend (forge.yaml `execution.agent_backend: api` plus FORGEDOCK_ALLOW_API_BACKEND=1), " +
|
|
387
|
+
"or pass --backend api.",
|
|
388
|
+
);
|
|
389
|
+
err.code = "CLI_BACKEND_UNAVAILABLE";
|
|
390
|
+
return err;
|
|
391
|
+
}
|
|
392
|
+
|
|
362
393
|
/**
|
|
363
394
|
* Resolve the CLI backend timeout from forge.yaml's
|
|
364
395
|
* `pipeline.cli_timeout_minutes` field, if it is a positive integer.
|
|
@@ -854,6 +885,7 @@ export function checkExecutionBackend({
|
|
|
854
885
|
apiKey = env.ANTHROPIC_API_KEY,
|
|
855
886
|
resolveCliFn = resolveClaudeCliBinary,
|
|
856
887
|
spawnImpl = spawnSync,
|
|
888
|
+
apiApproved = isApiBackendApproved({ cwd, env }),
|
|
857
889
|
sdkAvailableFn = () => {
|
|
858
890
|
try {
|
|
859
891
|
createRequire(import.meta.url).resolve("@anthropic-ai/sdk");
|
|
@@ -884,7 +916,8 @@ export function checkExecutionBackend({
|
|
|
884
916
|
return { ready: true, backend: "cli", reason: "resolved-cli" };
|
|
885
917
|
}
|
|
886
918
|
}
|
|
887
|
-
|
|
919
|
+
// #4257: auto never crosses to the paid API backend without the owner's opt-in.
|
|
920
|
+
if (requested === "cli" || !apiApproved) {
|
|
888
921
|
return { ready: false, backend: "cli", reason: "cli-unavailable" };
|
|
889
922
|
}
|
|
890
923
|
}
|
|
@@ -905,7 +938,8 @@ export function checkExecutionBackend({
|
|
|
905
938
|
* CLI and prefer it when present — but diverge on (a) which override
|
|
906
939
|
* values are valid and whether an invalid one throws or is silently
|
|
907
940
|
* ignored, and (b) what to return when the CLI probe fails (`resolveBackend`
|
|
908
|
-
*
|
|
941
|
+
* falls back to `"api"` only with the owner's opt-in, else `"cli"` (#4257);
|
|
942
|
+
* `resolveEnrichBackend` falls back to `"api"`
|
|
909
943
|
* only if `ANTHROPIC_API_KEY` is set, else `"none"`). Rather than force
|
|
910
944
|
* those into one shape, this shared helper implements exactly the common
|
|
911
945
|
* two steps and defers to a `cliFallback` callback for the diverging tail,
|
|
@@ -954,24 +988,27 @@ export function resolveBackendLadder({
|
|
|
954
988
|
* 2. `"auto"` (the default) probes `isClaudeCliAvailable()`: prefers the
|
|
955
989
|
* CLI backend when a working `claude` install is detected (reuses
|
|
956
990
|
* whatever credentials the CLI already has — Pro/Max OAuth or a
|
|
957
|
-
* CLI-managed key — with no ANTHROPIC_API_KEY required)
|
|
958
|
-
* falls back to the API backend
|
|
959
|
-
*
|
|
991
|
+
* CLI-managed key — with no ANTHROPIC_API_KEY required). Without a CLI it
|
|
992
|
+
* falls back to the API backend ONLY when the owner opted in
|
|
993
|
+
* (`isApiBackendApproved()`, #4257); otherwise it returns `"cli"` and
|
|
994
|
+
* `runCommand()` fails with a typed `CLI_BACKEND_UNAVAILABLE`. The API
|
|
995
|
+
* backend is never the implicit default.
|
|
960
996
|
*
|
|
961
997
|
* Delegates its shared "override-or-probe-CLI" shape to
|
|
962
998
|
* `resolveBackendLadder()` (issue #2026) — this function retains its own
|
|
963
999
|
* `VALID_BACKENDS` throw-on-invalid check (NOT shared; see that helper's
|
|
964
|
-
* doc comment) and its own
|
|
965
|
-
*
|
|
1000
|
+
* doc comment) and its own opt-in-gated fallback (`"api"` only when
|
|
1001
|
+
* approved — the API key itself is validated lazily downstream, only
|
|
966
1002
|
* if/when the api backend is actually selected).
|
|
967
1003
|
*
|
|
968
1004
|
* @param {object} [opts]
|
|
969
1005
|
* @param {string} [opts.requested] - "cli" | "api" | "auto" (default "auto").
|
|
970
1006
|
* @param {string} [opts.cwd] - Working directory for the CLI probe.
|
|
971
1007
|
* @param {object} [opts.env] - Runtime environment used for capability checks.
|
|
1008
|
+
* @param {Function} [opts.isCliAvailableFn] - Injectable CLI-presence probe (test seam).
|
|
972
1009
|
* @returns {"cli"|"api"}
|
|
973
1010
|
*/
|
|
974
|
-
export function resolveBackend({ requested = "auto", cwd = process.cwd(), env = process.env } = {}) {
|
|
1011
|
+
export function resolveBackend({ requested = "auto", cwd = process.cwd(), env = process.env, isCliAvailableFn = isClaudeCliAvailable } = {}) {
|
|
975
1012
|
if (!VALID_BACKENDS.has(requested)) {
|
|
976
1013
|
throw new Error(
|
|
977
1014
|
`Invalid backend "${requested}". Must be one of: ${[...VALID_BACKENDS].join(", ")}.`,
|
|
@@ -982,7 +1019,8 @@ export function resolveBackend({ requested = "auto", cwd = process.cwd(), env =
|
|
|
982
1019
|
override: requested === "auto" ? undefined : requested,
|
|
983
1020
|
validOverrides: new Set(["cli", "api"]),
|
|
984
1021
|
cwd,
|
|
985
|
-
|
|
1022
|
+
isCliAvailableFn,
|
|
1023
|
+
cliFallback: () => (isApiBackendApproved({ cwd, env }) ? "api" : "cli"),
|
|
986
1024
|
});
|
|
987
1025
|
}
|
|
988
1026
|
|
|
@@ -2789,7 +2827,9 @@ export function renderSummaryCard(ctx) {
|
|
|
2789
2827
|
* @param {string} [opts.backend] - "cli" | "api" | "auto" (default
|
|
2790
2828
|
* "auto"). Resolution order when omitted: $FORGEDOCK_BACKEND env > "auto".
|
|
2791
2829
|
* "auto" prefers the local Claude Code CLI when detected (no
|
|
2792
|
-
* ANTHROPIC_API_KEY needed)
|
|
2830
|
+
* ANTHROPIC_API_KEY needed); without a CLI it uses the API backend only
|
|
2831
|
+
* with the owner's opt-in (`isApiBackendApproved()`, #4257), else it fails
|
|
2832
|
+
* with a typed CLI_BACKEND_UNAVAILABLE — api is never the default. This
|
|
2793
2833
|
* precedence is intentional/shipped (issue #2003's accepted acceptance
|
|
2794
2834
|
* criteria) and is NOT changed by issue #2020 — that issue only adds a
|
|
2795
2835
|
* discoverability notice (below) for the case where the ladder silently
|
|
@@ -2840,6 +2880,7 @@ export async function runCommand(opts = {}) {
|
|
|
2840
2880
|
|
|
2841
2881
|
if (resolvedBackend === "cli" && backend === "auto") {
|
|
2842
2882
|
const readiness = checkExecutionBackend({ requested: "auto", cwd, apiKey, env: runtimeEnv });
|
|
2883
|
+
// #4257: readiness only reports api when the owner approved it.
|
|
2843
2884
|
if (readiness.backend === "api") resolvedBackend = "api";
|
|
2844
2885
|
}
|
|
2845
2886
|
|
|
@@ -2938,6 +2979,8 @@ export async function runCommand(opts = {}) {
|
|
|
2938
2979
|
sessionFlags = buildMinimalSessionFlags(commandName, caps);
|
|
2939
2980
|
}
|
|
2940
2981
|
if (!resolvedBin && backend === "auto") {
|
|
2982
|
+
// #4257: never cross to the paid API backend without the owner's opt-in.
|
|
2983
|
+
if (!isApiBackendApproved({ cwd, env: runtimeEnv })) throw cliUnavailableError();
|
|
2941
2984
|
resolvedBackend = "api";
|
|
2942
2985
|
} else if (!resolvedBin) {
|
|
2943
2986
|
const err = new Error("The configured claude CLI path disappeared before invocation.");
|
|
@@ -163,7 +163,7 @@ async function* listIssuePages(gh, repo, startSince) {
|
|
|
163
163
|
}
|
|
164
164
|
|
|
165
165
|
const meta = (it) => ({ number: it.number, isPr: Boolean(it.pull_request), createdAt: it.created_at ?? null, closedAt: it.closed_at ?? null,
|
|
166
|
-
updatedAt: it.updated_at ?? null, labels: (it.labels ?? []).map((l) => (typeof l === "string" ? l : l.name)).filter(Boolean), authorType: it.user?.type ?? null });
|
|
166
|
+
updatedAt: it.updated_at ?? null, milestone: it.milestone?.title ?? null, labels: (it.labels ?? []).map((l) => (typeof l === "string" ? l : l.name)).filter(Boolean), authorType: it.user?.type ?? null });
|
|
167
167
|
|
|
168
168
|
const CHECKPOINT_EVERY = 1000;
|
|
169
169
|
const RATE_CHECK_EVERY = 50;
|