@a2f0/agent-tool 0.1.17 → 0.1.19

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.
@@ -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 installation remain project
10
- concerns. Shipping authorizes this workflow within the user's stated scope.
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. Create it with
27
- `git switch --no-track -c <branch>`; settings such as
28
- `branch.autosetupmerge=always` otherwise make it track a local branch.
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. Determine the current PR's base repository and branch, or the repository's
32
- default branch when no PR exists. Fetch and pin its exact base. Integrate it
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. Push the reviewed HEAD through normal hooks, then verify the pushed SHA still
43
- equals the reviewed SHA. If a hook changed content or HEAD, review that result.
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 any configured review bots time to respond, then fetch unresolved PR
46
- review threads. Follow repository policy for feedback: fix valid findings,
47
- reply in the original threads, and resolve only fully addressed comments.
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 for CI, recheck the live base, PR base branch, and local and remote heads.
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 deploys the merged branch, verify the deployment for the
56
- merge commit and run its documented live checks. Report deployment failures
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 for required checks using `gh pr checks --watch --fail-fast`. Refresh the
16
- PR and live base from the repository that owns the PR. Confirm local HEAD and PR
17
- HEAD both equal the reviewed commit, the base branch is unchanged, the live base
18
- still equals the reviewed base, and that base is an ancestor of reviewed HEAD.
19
- If any identity changed, refresh, validate, and review again.
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
+ }
@@ -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
- writeSync(1, `${JSON.stringify({ status: exitCode === 0 ? "passed" : "failed", ...(label ? { label } : {}), exitCode, signal, durationMs: Math.round(performance.now() - started), outputBytes: fstatSync(fd).size, log })}\n`);
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
- process.kill(process.pid, signal);
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
 
@@ -1,10 +1,13 @@
1
- /** Disable object substitution and hooks, retaining existing Git config overrides. */
1
+ /** Keep machine reads free of hooks, object substitution, and signature display. */
2
2
  export function gitEnvironment(): NodeJS.ProcessEnv {
3
3
  const env: NodeJS.ProcessEnv = { ...process.env, GIT_NO_REPLACE_OBJECTS: "1" };
4
4
  const count = Number(env.GIT_CONFIG_COUNT ?? "0");
5
5
  if (!Number.isSafeInteger(count) || count < 0 || count > 1000) throw new Error("Invalid GIT_CONFIG_COUNT.");
6
- env.GIT_CONFIG_COUNT = String(count + 1);
6
+ env.GIT_CONFIG_COUNT = String(count + 2);
7
7
  env[`GIT_CONFIG_KEY_${count}`] = "core.hooksPath";
8
8
  env[`GIT_CONFIG_VALUE_${count}`] = "/dev/null";
9
+ // Display banners corrupt --format output; %G? still verifies signatures.
10
+ env[`GIT_CONFIG_KEY_${count + 1}`] = "log.showSignature";
11
+ env[`GIT_CONFIG_VALUE_${count + 1}`] = "false";
9
12
  return env;
10
13
  }
@@ -108,7 +108,7 @@ export function parsePullRequestMergeTarget(
108
108
  return { baseRefName, headOid, id };
109
109
  }
110
110
 
111
- function resolvePullRequestMergeTarget(
111
+ export function resolvePullRequestMergeTarget(
112
112
  pr: {
113
113
  prNumber: string;
114
114
  repo: string;
@@ -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
+ }