@a2f0/agent-tool 0.1.17 → 0.1.18
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 +50 -420
- package/package.json +2 -2
- package/skills/cross-agent-review/SKILL.md +2 -1
- package/skills/open-pr/SKILL.md +6 -7
- package/skills/reset/SKILL.md +22 -12
- package/skills/ship-pr/SKILL.md +26 -15
- package/skills/squash-merge/SKILL.md +7 -5
- package/src/cli.ts +36 -0
- package/src/commands/group.ts +88 -0
- package/src/commands/run.ts +12 -3
- package/src/config.ts +11 -3
- package/src/pr/squashMerge.ts +1 -1
- package/src/pr/validateCommitSubject.ts +5 -1
- package/src/release/commands.ts +67 -0
- package/src/release/npm.ts +65 -0
- package/src/skills/bundled.ts +6 -6
- package/src/workflows/branch.ts +80 -0
- package/src/workflows/checks.ts +117 -0
- package/src/workflows/config.ts +39 -0
- package/src/workflows/core.ts +65 -0
- package/src/workflows/hooks.ts +112 -0
- package/src/workflows/pr.ts +137 -0
- package/src/workflows/repository.ts +109 -0
package/skills/ship-pr/SKILL.md
CHANGED
|
@@ -6,8 +6,9 @@ description: Complete an authorized commit, independent review, repair, PR, CI,
|
|
|
6
6
|
# Ship PR
|
|
7
7
|
|
|
8
8
|
Use the installed `agent-tool`. Read repository guidance and policy first;
|
|
9
|
-
validation, versioning, package management, and hook
|
|
10
|
-
concerns.
|
|
9
|
+
validation, versioning, package management, and hook bodies remain project
|
|
10
|
+
concerns. Use shared branch, hook, check, cleanup, and release commands where
|
|
11
|
+
configured; keep application-specific commands in repository guidance. Shipping authorizes this workflow within the user's stated scope.
|
|
11
12
|
Preserve unrelated edits and honor requested stopping points such as open-PR
|
|
12
13
|
only, report-only review, or keeping the feature branch.
|
|
13
14
|
|
|
@@ -23,13 +24,16 @@ prepare`, `review`, and other commands whose output drives a decision directly.
|
|
|
23
24
|
Prefer documented compact test reporters when they preserve required evidence;
|
|
24
25
|
do not reduce test scope, skip checks, silence warnings, or mask failures.
|
|
25
26
|
|
|
26
|
-
1. Finish the requested change on a feature branch.
|
|
27
|
-
`
|
|
28
|
-
|
|
27
|
+
1. Finish the requested change on a feature branch. Use
|
|
28
|
+
`agent-tool branch create <branch> --base <base-branch> --remote <remote>`
|
|
29
|
+
from a clean checkout; it fetches and pins the base, checks naming policy and
|
|
30
|
+
collisions, and creates without a local upstream. Use `branch check` with
|
|
31
|
+
explicit destination names in CI or hooks.
|
|
29
32
|
Validate it with appropriate project checks and commit only the intended
|
|
30
33
|
files.
|
|
31
|
-
2.
|
|
32
|
-
default branch when no PR exists
|
|
34
|
+
2. Use `agent-tool repo context --fetch` to resolve the PR's base repository
|
|
35
|
+
and branch, or the default branch when no PR exists, and fetch its exact base.
|
|
36
|
+
Record the returned base/head identities. Integrate the fetched base
|
|
33
37
|
using the repository's normal workflow, resolving conflicts without losing
|
|
34
38
|
user work. If project policy versions packages, prepare versions against the
|
|
35
39
|
pinned base after every integration or repair and before each review, using
|
|
@@ -39,21 +43,28 @@ do not reduce test scope, skip checks, silence warnings, or mask failures.
|
|
|
39
43
|
version fields. Review the resulting committed HEAD with an independent
|
|
40
44
|
reviewer using the bundled cross-agent-review skill. Repair blocking findings
|
|
41
45
|
and review every changed HEAD again. Review failure blocks shipping.
|
|
42
|
-
3.
|
|
43
|
-
|
|
46
|
+
3. Run `agent-tool branch push <reviewed-head> --remote <remote>` through
|
|
47
|
+
normal hooks; it verifies local and remote identity afterward. If a hook
|
|
48
|
+
changed content or HEAD, review that result.
|
|
44
49
|
Open the PR using the open-pr skill, or update the existing PR as needed.
|
|
45
|
-
4. Allow
|
|
46
|
-
review threads. Follow repository policy for feedback: fix valid findings,
|
|
47
|
-
reply
|
|
50
|
+
4. Allow configured review bots time to respond, then use
|
|
51
|
+
`agent-tool pr threads list` to fetch review threads and all comments. Follow repository policy for feedback: fix valid findings,
|
|
52
|
+
reply with `pr threads reply <comment-id>` (body file or stdin), and use
|
|
53
|
+
`pr threads resolve <thread-id>` only for fully addressed findings.
|
|
48
54
|
Validate, commit, push, and independently review every feedback or CI repair.
|
|
49
|
-
Wait
|
|
55
|
+
Wait with `agent-tool pr checks --wait --head <reviewed-head>`, then run
|
|
56
|
+
`agent-tool pr preflight <reviewed-head> <base-branch> <reviewed-base>` to
|
|
57
|
+
recheck CI, live base, PR identity, local state, and explicit version policy.
|
|
50
58
|
A changed base requires integration, validation, and another review. When
|
|
51
59
|
versions are managed, `agent-tool versions check <base-oid>` must pass for the
|
|
52
60
|
live base. Use the squash-merge skill to merge the exact reviewed HEAD.
|
|
53
61
|
5. After confirmed MERGED, honor keep-branch; otherwise use the reset skill to
|
|
54
62
|
return to the updated default branch and apply any documented project setup.
|
|
55
|
-
6. When the project
|
|
56
|
-
|
|
63
|
+
6. When the project publishes npm packages, use `agent-tool release verify
|
|
64
|
+
<package> <version> <merge-oid> --workflow <publish-workflow>` to verify the
|
|
65
|
+
exact workflow and tarball, allowing registry propagation time. When the
|
|
66
|
+
project deploys the merged branch, verify that deployment and documented
|
|
67
|
+
live checks. Report deployment failures
|
|
57
68
|
separately from a successful merge; follow project policy for repairs.
|
|
58
69
|
|
|
59
70
|
Never infer a clean review from process success alone or carry a review across
|
|
@@ -12,11 +12,13 @@ findings require repair and a new review before merging.
|
|
|
12
12
|
Follow the repository's review-bot policy and require all actionable blocking
|
|
13
13
|
review feedback to be addressed before merging.
|
|
14
14
|
|
|
15
|
-
Wait
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
15
|
+
Wait with `agent-tool pr checks --wait --head <reviewed-head>`, which waits for
|
|
16
|
+
checks to appear and for every workflow/job/event entry to finish. Run
|
|
17
|
+
`agent-tool pr preflight <reviewed-head> <base-branch> <reviewed-base>` immediately
|
|
18
|
+
before merging. It checks local and PR identity, live base, ancestry, explicit
|
|
19
|
+
version policy, merge readiness, configured strict rulesets, and passing checks.
|
|
20
|
+
If an identity changed, refresh, integrate, validate, and review again. Read
|
|
21
|
+
unresolved feedback with `pr threads list` and follow project review policy.
|
|
20
22
|
|
|
21
23
|
Use a title that satisfies `agent-tool.json` and pass both reviewed HEAD and
|
|
22
24
|
base branch:
|
package/src/cli.ts
CHANGED
|
@@ -16,16 +16,40 @@ import { squashMerge } from "./pr/squashMerge";
|
|
|
16
16
|
import metadata from "../package.json";
|
|
17
17
|
import { runCommand } from "./commands/run";
|
|
18
18
|
import { inspectLog } from "./commands/output";
|
|
19
|
+
import { runGroup, listRuns } from "./commands/group";
|
|
20
|
+
import { branchCommand } from "./workflows/branch";
|
|
21
|
+
import { commitsCommand, binariesCommand, gateCommand } from "./workflows/checks";
|
|
22
|
+
import { hooksCommand } from "./workflows/hooks";
|
|
23
|
+
import { repoCommand, policyCommand, toolchainCommand } from "./workflows/repository";
|
|
24
|
+
import { prWorkflowCommand } from "./workflows/pr";
|
|
25
|
+
import { releaseCommand } from "./release/commands";
|
|
19
26
|
|
|
20
27
|
export const HELP = `agent-tool ${metadata.version}
|
|
21
28
|
|
|
22
29
|
Usage: agent-tool [--repo <directory>] [--config <file>] <command>
|
|
23
30
|
|
|
24
31
|
run [--label text] [--max-bytes N] [--tail-lines N] -- <command> [args...]
|
|
32
|
+
run-group <plan.json> Sequential checks with durable source-bound receipts
|
|
33
|
+
runs list [--head oid] [--branch name] [--since ISO-date]
|
|
34
|
+
branch check [name...|--pre-push] | status
|
|
35
|
+
branch create <name> [--base branch] [--remote origin]
|
|
36
|
+
branch push <reviewed-head> [--remote origin]
|
|
37
|
+
commits check --range <range> [--exclude ref] [--exclude-remotes remote]
|
|
38
|
+
commits check --message-file <file> Commit-msg attribution and subject policy
|
|
39
|
+
checks binaries [--staged|--all|--range range|--from-upstream]
|
|
40
|
+
hooks install [--apply] [--adopt] | check
|
|
41
|
+
repo context [--fetch] | cleanup <PR> <shipped-head> [--apply] [--keep-branch]
|
|
42
|
+
policy check | toolchain check
|
|
43
|
+
ci gate <needs.json|-> [--policy policy.json]
|
|
44
|
+
release plan <package.json> [--github-output] [--allow-private-source]
|
|
45
|
+
release verify <package> <version> <merge-oid> --workflow name [--timeout seconds]
|
|
25
46
|
logs <file> [--max-bytes N] [--tail-lines N] Bounded local log tail
|
|
26
47
|
review <claude|codex|opencode> [effort] [--base <local-ref>]
|
|
27
48
|
pr open [title] Body from stdin; branch already pushed
|
|
28
49
|
pr merge <subject-or-empty> <head-oid> <base-branch>
|
|
50
|
+
pr context | checks [--wait] [--head oid] [--timeout seconds]
|
|
51
|
+
pr preflight <reviewed-head> <base-branch> <reviewed-base>
|
|
52
|
+
pr threads list | reply <comment-id> [--body-file file] | resolve <thread-id>
|
|
29
53
|
versions <plan|bump|check|prepare> <base-oid>
|
|
30
54
|
versions resolve-conflicts
|
|
31
55
|
dependencies check-terraform-plan <json-file> Read-only destructive-plan check
|
|
@@ -67,6 +91,8 @@ export function main(argv = process.argv.slice(2)): number | Promise<number> {
|
|
|
67
91
|
}
|
|
68
92
|
if (command === "--version") { usage(args.length === 0, "Unexpected version arguments."); writeSync(1, `${metadata.version}\n`); return 0; }
|
|
69
93
|
if (command === "run") return runCommand(args);
|
|
94
|
+
if (command === "ci") { usage(args.shift() === "gate", "Usage: ci gate <needs.json|-> [--policy policy.json]"); return gateCommand(args); }
|
|
95
|
+
if (command === "release") return releaseCommand(args);
|
|
70
96
|
if (command === "logs") return inspectLog(args);
|
|
71
97
|
if (command === "dependencies") {
|
|
72
98
|
usage(args.length === 2 && args[0] === "check-terraform-plan", "Usage: agent-tool dependencies check-terraform-plan <json-file>");
|
|
@@ -124,6 +150,15 @@ export function main(argv = process.argv.slice(2)): number | Promise<number> {
|
|
|
124
150
|
const root = gitRoot();
|
|
125
151
|
process.chdir(root);
|
|
126
152
|
loadConfig(root); // Validate policy before running a reviewer or mutating GitHub.
|
|
153
|
+
if (command === "branch") return branchCommand(args);
|
|
154
|
+
if (command === "commits") return commitsCommand(args);
|
|
155
|
+
if (command === "hooks") return hooksCommand(args);
|
|
156
|
+
if (command === "repo") return repoCommand(args);
|
|
157
|
+
if (command === "policy") return policyCommand(args);
|
|
158
|
+
if (command === "toolchain") return toolchainCommand(args);
|
|
159
|
+
if (command === "run-group") return runGroup(args);
|
|
160
|
+
if (command === "runs") return listRuns(args);
|
|
161
|
+
if (command === "checks") { usage(args.shift() === "binaries", "Usage: checks binaries [--staged|--all|--range range|--from-upstream]"); return binariesCommand(args); }
|
|
127
162
|
if (command === "review") {
|
|
128
163
|
const name = args.shift(); usage(!!name, "Usage: agent-tool review <claude|codex|opencode> [effort] [--base ref]");
|
|
129
164
|
let effort: string | undefined, base: string | undefined;
|
|
@@ -135,6 +170,7 @@ export function main(argv = process.argv.slice(2)): number | Promise<number> {
|
|
|
135
170
|
return review(root, name!, effort, base);
|
|
136
171
|
}
|
|
137
172
|
if (command === "pr") {
|
|
173
|
+
if (["context", "checks", "preflight", "threads"].includes(args[0]!)) return prWorkflowCommand(args);
|
|
138
174
|
const action = args.shift();
|
|
139
175
|
if (action === "open") { usage(args.length <= 1, "Usage: agent-tool pr open [title]"); return openPr(root, args[0]); }
|
|
140
176
|
usage(action === "merge" && args.length === 3, "Usage: agent-tool pr merge <subject-or-empty> <reviewed-head-oid> <base-branch>");
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { execFileSync } from "node:child_process";
|
|
3
|
+
import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, readlinkSync, renameSync, writeFileSync } from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { runCommand, type RunReceipt } from "./run";
|
|
6
|
+
import { assert, git, head, oid, option, output } from "../workflows/core";
|
|
7
|
+
import { gitEnvironment } from "../git/environment";
|
|
8
|
+
|
|
9
|
+
export interface SourceState { headOid: string; treeOid: string; branch: string | null; fingerprint: string }
|
|
10
|
+
export function sourceState(): SourceState {
|
|
11
|
+
const hash = createHash("sha256");
|
|
12
|
+
const add = (value: string | Buffer) => { hash.update(String(Buffer.byteLength(value))); hash.update(":" ); hash.update(value); };
|
|
13
|
+
const rawDiff = (args: string[]) => execFileSync("git", ["diff", "--no-ext-diff", "--no-textconv", "--binary", ...args, "--"], { env: gitEnvironment(), maxBuffer: 64 * 1024 * 1024, stdio: ["ignore", "pipe", "pipe"] });
|
|
14
|
+
add(rawDiff(["HEAD"])); add(rawDiff(["--cached"]));
|
|
15
|
+
for (const record of git(["status", "--porcelain=v2", "--ignore-submodules=none", "-z"]).split("\0")) {
|
|
16
|
+
if (/^[12] /.test(record)) { const submodule = record.split(" ")[2]!; assert(!submodule.startsWith("S") || submodule === "S...", "Dirty submodules cannot be fingerprinted; validate from a clean submodule state."); }
|
|
17
|
+
}
|
|
18
|
+
for (const file of git(["ls-files", "--others", "--exclude-standard", "-z"]).split("\0").filter(Boolean).sort()) {
|
|
19
|
+
add(file); const stat = lstatSync(file); add(String(stat.mode));
|
|
20
|
+
if (stat.isSymbolicLink()) add(readlinkSync(file));
|
|
21
|
+
else { assert(stat.isFile(), `Cannot fingerprint non-file '${file}'.`); add(readFileSync(file)); }
|
|
22
|
+
}
|
|
23
|
+
return { headOid: head(), treeOid: git(["rev-parse", "HEAD^{tree}"]), branch: git(["branch", "--show-current"]) || null, fingerprint: hash.digest("hex") };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function reportsDirectory(): string { return path.join(git(["rev-parse", "--path-format=absolute", "--git-common-dir"]), "agent-tool", "runs"); }
|
|
27
|
+
function save(file: string, value: unknown): void {
|
|
28
|
+
const temporary = `${file}.${randomUUID()}.tmp`;
|
|
29
|
+
writeFileSync(temporary, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600, flag: "wx" });
|
|
30
|
+
renameSync(temporary, file);
|
|
31
|
+
}
|
|
32
|
+
interface Step { label: string; command: string[] }
|
|
33
|
+
export function parseGroup(source: string): { label: string; steps: Step[] } {
|
|
34
|
+
const plan = JSON.parse(source) as { label: string; steps: Step[] };
|
|
35
|
+
const label = (value: unknown) => typeof value === "string" && value.length > 0 && value.length <= 120 && !/[\x00-\x1f]/.test(value);
|
|
36
|
+
assert(plan && typeof plan === "object" && !Array.isArray(plan) && Object.keys(plan).every(key => ["label", "steps"].includes(key)) && label(plan.label) && Array.isArray(plan.steps) && plan.steps.length > 0, "Invalid run-group plan; expected label and nonempty steps.");
|
|
37
|
+
const labels = new Set<string>();
|
|
38
|
+
for (const step of plan.steps) {
|
|
39
|
+
assert(step && Object.keys(step).every(key => ["label", "command"].includes(key)) && label(step.label) && !labels.has(step.label) && Array.isArray(step.command) && step.command.length > 0 && step.command.every(arg => typeof arg === "string" && !arg.includes("\0")) && step.command[0], "Each step needs a unique label and nonempty command argv.");
|
|
40
|
+
labels.add(step.label);
|
|
41
|
+
}
|
|
42
|
+
return plan;
|
|
43
|
+
}
|
|
44
|
+
export async function runGroup(args: string[]): Promise<number> {
|
|
45
|
+
assert(args.length === 1, "Usage: run-group <plan.json>");
|
|
46
|
+
const plan = parseGroup(readFileSync(args[0]!, "utf8"));
|
|
47
|
+
const before = sourceState();
|
|
48
|
+
const directory = reportsDirectory(); mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
49
|
+
const id = `${new Date().toISOString().replaceAll(":", "-")}-${randomUUID()}`;
|
|
50
|
+
const report = path.join(directory, `${id}.json`);
|
|
51
|
+
const started = performance.now();
|
|
52
|
+
const record = { schemaVersion: 1, id, label: plan.label, startedAt: new Date().toISOString(), endedAt: null as string | null, durationMs: 0, status: "running", before, after: null as SourceState | null, steps: plan.steps.map(step => ({ label: step.label, status: "pending", receipt: null as RunReceipt | null })), error: null as string | null };
|
|
53
|
+
save(report, record);
|
|
54
|
+
let code = 0;
|
|
55
|
+
try {
|
|
56
|
+
for (const [index, step] of plan.steps.entries()) {
|
|
57
|
+
record.steps[index]!.status = "running"; save(report, record);
|
|
58
|
+
code = await runCommand(["--label", step.label, "--", ...step.command], receipt => { record.steps[index]!.receipt = receipt; record.steps[index]!.status = receipt.status; save(report, record); }, { quiet: true, reraiseChildSignal: false });
|
|
59
|
+
const after = sourceState();
|
|
60
|
+
assert(JSON.stringify(after) === JSON.stringify(before), "Source state changed during validation; results do not establish the current checkout.");
|
|
61
|
+
if (code !== 0) break;
|
|
62
|
+
}
|
|
63
|
+
} catch (error) { code = 1; record.error = (error as Error).message; }
|
|
64
|
+
record.status = code === 0 ? "passed" : "failed";
|
|
65
|
+
record.endedAt = new Date().toISOString(); record.durationMs = Math.round(performance.now() - started);
|
|
66
|
+
try { record.after = sourceState(); } catch (error) { code = 1; record.status = "failed"; record.error ??= (error as Error).message; }
|
|
67
|
+
save(report, record);
|
|
68
|
+
output({ ...record, report }); return code;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function listRuns(args: string[]): number {
|
|
72
|
+
assert(args.shift() === "list", "Usage: runs list [--head <oid>] [--branch <name>] [--since <ISO-date>]");
|
|
73
|
+
const expectedHead = option(args, "--head"); const branch = option(args, "--branch"); const since = option(args, "--since");
|
|
74
|
+
assert(!args.length && (!since || Number.isFinite(Date.parse(since))), "Invalid run history filter.");
|
|
75
|
+
if (expectedHead) oid(expectedHead);
|
|
76
|
+
const directory = reportsDirectory();
|
|
77
|
+
const skipped: { file: string; reason: string }[] = [];
|
|
78
|
+
const runs = (existsSync(directory) ? readdirSync(directory) : []).filter(file => file.endsWith(".json")).flatMap(file => {
|
|
79
|
+
try {
|
|
80
|
+
const full = path.join(directory, file); const stat = lstatSync(full);
|
|
81
|
+
assert(stat.isFile() && stat.size <= 1024 * 1024, "Invalid validation receipt file.");
|
|
82
|
+
const run = JSON.parse(readFileSync(full, "utf8")) as { startedAt: string; before: SourceState };
|
|
83
|
+
assert(typeof run.startedAt === "string" && Number.isFinite(Date.parse(run.startedAt)) && typeof run.before?.headOid === "string" && (run.before.branch === null || typeof run.before.branch === "string"), "Invalid validation receipt shape.");
|
|
84
|
+
return [run];
|
|
85
|
+
} catch (error) { skipped.push({ file, reason: (error as Error).message }); return []; }
|
|
86
|
+
}).filter(run => (!expectedHead || run.before.headOid === expectedHead) && (!branch || run.before.branch === branch) && (!since || Date.parse(run.startedAt) >= Date.parse(since))).sort((a, b) => b.startedAt.localeCompare(a.startedAt));
|
|
87
|
+
output({ runs, skipped }); return 0;
|
|
88
|
+
}
|
package/src/commands/run.ts
CHANGED
|
@@ -6,8 +6,10 @@ import { createLog } from "./logFile";
|
|
|
6
6
|
|
|
7
7
|
const FORWARDED_SIGNALS = ["SIGINT", "SIGTERM", "SIGHUP"] as const;
|
|
8
8
|
|
|
9
|
+
export interface RunReceipt { status: string; label?: string; exitCode: number; signal: string | null; durationMs: number; outputBytes: number; log: string }
|
|
10
|
+
|
|
9
11
|
/** Run directly into a private file: child output never accumulates in memory. */
|
|
10
|
-
export async function runCommand(args: string[]): Promise<number> {
|
|
12
|
+
export async function runCommand(args: string[], record?: (receipt: RunReceipt) => void, options: { quiet?: boolean; reraiseChildSignal?: boolean } = {}): Promise<number> {
|
|
11
13
|
const limits = { ...DEFAULT_LIMITS };
|
|
12
14
|
let label: string | undefined;
|
|
13
15
|
while (args[0] !== "--" && args.length) {
|
|
@@ -77,7 +79,9 @@ export async function runCommand(args: string[]): Promise<number> {
|
|
|
77
79
|
forward("SIGKILL");
|
|
78
80
|
}
|
|
79
81
|
const exitCode = signal ? 128 + (constants.signals[signal] ?? 0) : status ?? 1;
|
|
80
|
-
|
|
82
|
+
const receipt: RunReceipt = { status: exitCode === 0 ? "passed" : "failed", ...(label ? { label } : {}), exitCode, signal, durationMs: Math.round(performance.now() - started), outputBytes: fstatSync(fd).size, log };
|
|
83
|
+
record?.(receipt);
|
|
84
|
+
if (!options.quiet) writeSync(1, `${JSON.stringify(receipt)}\n`);
|
|
81
85
|
if (exitCode !== 0) printLogTail(log, limits, 2);
|
|
82
86
|
} finally {
|
|
83
87
|
if (timer) clearTimeout(timer);
|
|
@@ -86,7 +90,12 @@ export async function runCommand(args: string[]): Promise<number> {
|
|
|
86
90
|
}
|
|
87
91
|
// Preserve signal termination for calling shells/supervisors, after the receipt.
|
|
88
92
|
if (signal) {
|
|
89
|
-
|
|
93
|
+
if (interrupted || options.reraiseChildSignal !== false) {
|
|
94
|
+
process.kill(process.pid, signal);
|
|
95
|
+
// Let the runtime deliver its signal before main sets process.exitCode
|
|
96
|
+
// can turn signal termination into an ordinary numeric exit under load.
|
|
97
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
98
|
+
}
|
|
90
99
|
return 128 + (constants.signals[signal] ?? 0);
|
|
91
100
|
}
|
|
92
101
|
return status ?? 1;
|
package/src/config.ts
CHANGED
|
@@ -1,15 +1,16 @@
|
|
|
1
1
|
import { existsSync, lstatSync, readFileSync } from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { parseImages, type ImagesConfig } from "./images/config";
|
|
4
|
+
import { WORKFLOW_DEFAULTS, validateWorkflowConfig, type WorkflowConfig } from "./workflows/config";
|
|
4
5
|
|
|
5
6
|
export interface RequiredCheck {
|
|
6
7
|
name: string;
|
|
7
8
|
workflow?: string;
|
|
8
9
|
}
|
|
9
10
|
|
|
10
|
-
export interface AgentToolConfig {
|
|
11
|
+
export interface AgentToolConfig extends WorkflowConfig {
|
|
11
12
|
schemaVersion: 1;
|
|
12
|
-
subject: { conventional: boolean; maxLength: number; types: string[] };
|
|
13
|
+
subject: { conventional: boolean; maxLength: number; types: string[]; lowercaseInitial: boolean; rejectTerminalPeriod: boolean; ignoreGenerated: boolean };
|
|
13
14
|
merge: { requireChecks: boolean; requiredChecks: RequiredCheck[]; requireStrictBaseFreshness: boolean };
|
|
14
15
|
review: { claudeModel: string | null; codexModel: string | null; opencodeModel: string; opencodeVariants: Record<string, string>; timeoutMs: number };
|
|
15
16
|
versions: { packages: string[] | null; bundles: string[]; lockfile: "bun.lock" | null; validate: string[][]; commitMessage: string };
|
|
@@ -18,11 +19,15 @@ export interface AgentToolConfig {
|
|
|
18
19
|
}
|
|
19
20
|
|
|
20
21
|
export const DEFAULT_CONFIG: AgentToolConfig = {
|
|
22
|
+
...WORKFLOW_DEFAULTS,
|
|
21
23
|
schemaVersion: 1,
|
|
22
24
|
subject: {
|
|
23
25
|
conventional: true,
|
|
24
26
|
maxLength: 72,
|
|
25
27
|
types: ["feat", "fix", "docs", "style", "refactor", "perf", "test", "build", "ci", "chore", "revert"],
|
|
28
|
+
lowercaseInitial: false,
|
|
29
|
+
rejectTerminalPeriod: false,
|
|
30
|
+
ignoreGenerated: false,
|
|
26
31
|
},
|
|
27
32
|
merge: { requireChecks: true, requiredChecks: [], requireStrictBaseFreshness: false },
|
|
28
33
|
review: { claudeModel: null, codexModel: null, opencodeModel: "deepseek/deepseek-v4-pro", opencodeVariants: { low: "low", medium: "medium", high: "high", xhigh: "max", max: "max" }, timeoutMs: 600_000 },
|
|
@@ -54,15 +59,17 @@ export function parseConfig(source: string): AgentToolConfig {
|
|
|
54
59
|
keys(input, Object.keys(DEFAULT_CONFIG), "config");
|
|
55
60
|
if (input.schemaVersion !== 1) throw new Error("Unsupported config schemaVersion; expected 1.");
|
|
56
61
|
const result = structuredClone(DEFAULT_CONFIG);
|
|
57
|
-
for (const section of ["subject", "merge", "review", "versions", "pr"] as const) {
|
|
62
|
+
for (const section of ["subject", "merge", "review", "versions", "pr", "branch", "commits", "hooks", "binaries"] as const) {
|
|
58
63
|
if (input[section] === undefined) continue;
|
|
59
64
|
const settings = object(input[section], section);
|
|
60
65
|
keys(settings, Object.keys(result[section]), section);
|
|
61
66
|
Object.assign(result[section], settings);
|
|
62
67
|
}
|
|
63
68
|
if (input.images !== undefined) result.images = parseImages(input.images);
|
|
69
|
+
if (input.branch !== undefined && !Object.hasOwn(input.branch as object, "enforce")) result.branch.enforce = true;
|
|
64
70
|
const invalid = (setting: string): never => { throw new Error(`Invalid config setting '${setting}'.`); };
|
|
65
71
|
if (typeof result.subject.conventional !== "boolean") invalid("subject.conventional");
|
|
72
|
+
for (const key of ["lowercaseInitial", "rejectTerminalPeriod", "ignoreGenerated"] as const) if (typeof result.subject[key] !== "boolean") invalid(`subject.${key}`);
|
|
66
73
|
if (!Number.isSafeInteger(result.subject.maxLength) || result.subject.maxLength < 1) invalid("subject.maxLength");
|
|
67
74
|
if (!strings(result.subject.types) || result.subject.types.length === 0 || result.subject.types.some(type => !/^[a-z]+$/.test(type))) invalid("subject.types");
|
|
68
75
|
if (typeof result.merge.requireChecks !== "boolean") invalid("merge.requireChecks");
|
|
@@ -89,6 +96,7 @@ export function parseConfig(source: string): AgentToolConfig {
|
|
|
89
96
|
if (!Array.isArray(result.versions.validate) || result.versions.validate.some(command => !strings(command) || command.length === 0 || !/^[\w.+-]+$/.test(command[0]!))) invalid("versions.validate");
|
|
90
97
|
if (typeof result.versions.commitMessage !== "string" || !result.versions.commitMessage.trim() || /[\r\n]/.test(result.versions.commitMessage)) invalid("versions.commitMessage");
|
|
91
98
|
if (typeof result.pr.rejectClaudeBranding !== "boolean") invalid("pr.rejectClaudeBranding");
|
|
99
|
+
validateWorkflowConfig(result);
|
|
92
100
|
return result;
|
|
93
101
|
}
|
|
94
102
|
|
package/src/pr/squashMerge.ts
CHANGED
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
import { loadConfig } from "../config";
|
|
2
2
|
|
|
3
3
|
/** Validate a data-only header policy without running branch-controlled code. */
|
|
4
|
-
export function validateCommitSubject(rootDir: string, subject: string): void {
|
|
4
|
+
export function validateCommitSubject(rootDir: string, subject: string, options: { allowGenerated?: boolean } = {}): void {
|
|
5
5
|
const policy = loadConfig(rootDir).subject;
|
|
6
6
|
if (!subject.trim() || /[\r\n]/.test(subject)) throw new Error("Commit subject must be a nonempty single line.");
|
|
7
|
+
if (options.allowGenerated && policy.ignoreGenerated && /^(?:Merge\b|Revert\b|(?:fixup|squash|amend)! |Initial commit$)/.test(subject)) return;
|
|
7
8
|
if ([...subject].length > policy.maxLength) throw new Error(`Commit subject exceeds ${policy.maxLength} characters.`);
|
|
8
9
|
if (policy.conventional) {
|
|
9
10
|
const match = /^([a-z]+)(?:\([^\r\n()]+\))?!?: \S.*$/.exec(subject);
|
|
10
11
|
if (!match || !policy.types.includes(match[1]!)) throw new Error(`Commit subject must use a conventional type: ${policy.types.join(", ")}.`);
|
|
11
12
|
}
|
|
13
|
+
const text = subject.replace(/^[a-z]+(?:\([^\r\n()]+\))?!?: /, "");
|
|
14
|
+
if (policy.lowercaseInitial && /^[A-Z]/.test(text)) throw new Error("Commit subject must start with lowercase text.");
|
|
15
|
+
if (policy.rejectTerminalPeriod && text.endsWith(".")) throw new Error("Commit subject must not end with a period.");
|
|
12
16
|
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { appendFileSync, readFileSync } from "node:fs";
|
|
4
|
+
import { decidePublish, readRegistryState } from "./npm";
|
|
5
|
+
import { assert, flag, json, oid, option, output, poll, repository, timeoutOptions } from "../workflows/core";
|
|
6
|
+
|
|
7
|
+
function packageName(name: unknown): asserts name is string { assert(typeof name === "string" && /^(?:@[a-z0-9._-]+\/)?[a-z0-9][a-z0-9._-]*$/.test(name), "Invalid npm package name."); }
|
|
8
|
+
export interface ReleaseRun { databaseId: number; status: string; conclusion: string; headSha: string; url: string }
|
|
9
|
+
export async function verifyTarball(metadata: { name: string; version: string; dist: { tarball: string; integrity: string } }): Promise<{ tarball: string; integrity: string }> {
|
|
10
|
+
const url = new URL(metadata.dist.tarball);
|
|
11
|
+
assert(url.protocol === "https:" && url.hostname === "registry.npmjs.org", "Expected an HTTPS npm registry tarball.");
|
|
12
|
+
const integrity = /^sha512-([A-Za-z0-9+/]+={0,2})$/.exec(metadata.dist.integrity);
|
|
13
|
+
assert(integrity, "Package must have SHA-512 tarball integrity.");
|
|
14
|
+
const response = await fetch(url, { signal: AbortSignal.timeout(60_000), redirect: "error" });
|
|
15
|
+
assert(response.ok && response.body, "Could not download the published tarball.");
|
|
16
|
+
const chunks: Uint8Array[] = []; let size = 0;
|
|
17
|
+
for await (const chunk of response.body) { size += chunk.length; assert(size <= 100 * 1024 * 1024, "Tarball exceeds the 100 MiB verification limit."); chunks.push(chunk); }
|
|
18
|
+
const archive = Buffer.concat(chunks);
|
|
19
|
+
assert(createHash("sha512").update(archive).digest("base64") === integrity[1], "Published tarball integrity does not match registry metadata.");
|
|
20
|
+
// Read one member to stdout: do not extract untrusted archive paths to disk.
|
|
21
|
+
const manifest = JSON.parse(execFileSync("tar", ["-xzOf", "-", "package/package.json"], { input: archive, encoding: "utf8", maxBuffer: 1024 * 1024, timeout: 30_000 }));
|
|
22
|
+
assert(manifest.name === metadata.name && manifest.version === metadata.version, "Published tarball manifest does not match the requested package/version.");
|
|
23
|
+
return { tarball: url.href, integrity: metadata.dist.integrity };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function inspectRelease(
|
|
27
|
+
runs: ReleaseRun[],
|
|
28
|
+
expected: { name: string; version: string; commit: string },
|
|
29
|
+
readMetadata = () => fetch(`https://registry.npmjs.org/${encodeURIComponent(expected.name)}/${expected.version}`, { signal: AbortSignal.timeout(30_000) }),
|
|
30
|
+
checkArtifact = verifyTarball,
|
|
31
|
+
) {
|
|
32
|
+
const run = runs.filter(run => run.headSha === expected.commit).sort((a, b) => b.databaseId - a.databaseId)[0];
|
|
33
|
+
if (!run || run.status !== "completed") return { done: false, value: { ok: false, reason: "Publishing workflow has not completed.", run: run ?? null } };
|
|
34
|
+
assert(run.conclusion === "success", `Publishing workflow failed: ${run.url} (${run.conclusion}).`);
|
|
35
|
+
const response = await readMetadata();
|
|
36
|
+
if (response.status === 404) return { done: false, value: { ok: false, reason: "Version has not propagated to npm.", run } };
|
|
37
|
+
assert(response.ok, `npm registry returned HTTP ${response.status}.`);
|
|
38
|
+
const metadata = await response.json() as { name: string; version: string; gitHead?: string; dist: { tarball: string; integrity: string } };
|
|
39
|
+
assert(metadata.name === expected.name && metadata.version === expected.version, "Registry returned a different package version.");
|
|
40
|
+
assert(!metadata.gitHead || metadata.gitHead === expected.commit, "Registry gitHead differs from the publishing merge commit.");
|
|
41
|
+
const artifact = await checkArtifact(metadata);
|
|
42
|
+
return { done: true, value: { ok: true, reason: "Workflow and published tarball verified.", run, ...artifact, gitHead: metadata.gitHead ?? null } };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function releaseCommand(args: string[]): Promise<number> {
|
|
46
|
+
const action = args.shift();
|
|
47
|
+
if (action === "plan") {
|
|
48
|
+
const githubOutput = flag(args, "--github-output");
|
|
49
|
+
const allowPrivateSource = flag(args, "--allow-private-source");
|
|
50
|
+
assert(args.length === 1, "Usage: release plan <package.json> [--github-output] [--allow-private-source]");
|
|
51
|
+
const manifest = JSON.parse(readFileSync(args[0]!, "utf8"));
|
|
52
|
+
packageName(manifest.name);
|
|
53
|
+
assert(manifest.private !== true || allowPrivateSource, "Private source manifest; use --allow-private-source only when the build creates a publishable manifest.");
|
|
54
|
+
const decision = decidePublish(manifest.version, readRegistryState(manifest.name));
|
|
55
|
+
if (githubOutput) { assert(process.env.GITHUB_OUTPUT, "--github-output requires GITHUB_OUTPUT."); appendFileSync(process.env.GITHUB_OUTPUT, `publish=${decision.publish}\n`); }
|
|
56
|
+
output({ name: manifest.name, version: manifest.version, privateSource: manifest.private === true, ...decision }); return 0;
|
|
57
|
+
}
|
|
58
|
+
const workflow = option(args, "--workflow");
|
|
59
|
+
const options = timeoutOptions(args);
|
|
60
|
+
assert(action === "verify" && args.length === 3 && workflow, "Usage: release verify <package> <version> <merge-oid> --workflow <name> [--timeout seconds] [--interval seconds]");
|
|
61
|
+
const [name, version, commit] = args as [string, string, string];
|
|
62
|
+
packageName(name); oid(commit);
|
|
63
|
+
assert(/^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)$/.test(version), "Expected a stable package version.");
|
|
64
|
+
const repo = repository().nameWithOwner;
|
|
65
|
+
const result = await poll(() => inspectRelease(json<ReleaseRun[]>(["run", "list", "-R", repo, "--workflow", workflow, "--commit", commit, "--limit", "100", "--json", "databaseId,status,conclusion,headSha,url"]), { name, version, commit }), options);
|
|
66
|
+
output({ repo, name, version, commit, ...result }); return 0;
|
|
67
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
const registry = "https://registry.npmjs.org";
|
|
4
|
+
|
|
5
|
+
/** Every version npm has for the package, and its latest tag. */
|
|
6
|
+
export interface RegistryState {
|
|
7
|
+
latest: string;
|
|
8
|
+
versions: string[];
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface PublishDecision {
|
|
12
|
+
publish: boolean;
|
|
13
|
+
reason: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Plain major.minor.patch, as the version tooling bumps; a prerelease published
|
|
17
|
+
// without a dist-tag would become npm's latest. Bun also accepts "1.0".
|
|
18
|
+
const versionPattern = /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)$/;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* CI publishes only a version newer than npm's latest. A merge that keeps the
|
|
22
|
+
* version, a re-run, and a run that finishes after a newer release succeed
|
|
23
|
+
* without publishing, so the latest tag never moves backwards.
|
|
24
|
+
*/
|
|
25
|
+
export function decidePublish(version: string, state: RegistryState): PublishDecision {
|
|
26
|
+
if (!versionPattern.test(version)) throw new Error(`invalid package version: ${JSON.stringify(version)}`);
|
|
27
|
+
if (state.versions.includes(version)) return { publish: false, reason: `${version} is already on npm` };
|
|
28
|
+
if (Bun.semver.order(version, state.latest) <= 0) {
|
|
29
|
+
return { publish: false, reason: `${version} is not newer than npm's latest (${state.latest})` };
|
|
30
|
+
}
|
|
31
|
+
return { publish: true, reason: `${version} is newer than npm's latest (${state.latest})` };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Parses `npm view <name> versions dist-tags --json`, which prints versions as
|
|
36
|
+
* a string rather than an array when only one exists. A package npm does not
|
|
37
|
+
* have yet fails: npm configures a trusted publisher only for an existing
|
|
38
|
+
* package, so its first version is published by hand.
|
|
39
|
+
*/
|
|
40
|
+
export function parseRegistryState(json: string): RegistryState {
|
|
41
|
+
const view = JSON.parse(json);
|
|
42
|
+
if (view?.error?.code === "E404") {
|
|
43
|
+
throw new Error("the package is not on npm yet; publish its first version by hand, then add the trusted publisher");
|
|
44
|
+
}
|
|
45
|
+
const versions: unknown = view?.versions;
|
|
46
|
+
const latest: unknown = view?.["dist-tags"]?.latest;
|
|
47
|
+
const list = typeof versions === "string" ? [versions] : versions;
|
|
48
|
+
if (typeof latest !== "string" || !Array.isArray(list) || !list.every(version => typeof version === "string")) {
|
|
49
|
+
throw new Error(`unexpected npm view output: ${json}`);
|
|
50
|
+
}
|
|
51
|
+
return { latest, versions: list };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function readRegistryState(name: string): RegistryState {
|
|
55
|
+
// A scope registry overrides --registry, so pin the scope as well.
|
|
56
|
+
const result = spawnSync("npm", [
|
|
57
|
+
"view", name, "versions", "dist-tags", "--json",
|
|
58
|
+
"--registry", registry, ...(name.startsWith("@") ? [`--${name.split("/")[0]}:registry=${registry}`] : []),
|
|
59
|
+
], { encoding: "utf8", stdio: ["ignore", "pipe", "inherit"], timeout: 60_000 });
|
|
60
|
+
if (result.error) throw result.error;
|
|
61
|
+
// A failed view, including a missing package, still prints a JSON error.
|
|
62
|
+
const state = result.stdout.trim() ? parseRegistryState(result.stdout) : null;
|
|
63
|
+
if (result.status !== 0 || !state) throw new Error(`npm view ${name} exited with ${result.status}`);
|
|
64
|
+
return state;
|
|
65
|
+
}
|