@mindlace/chreode 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,182 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { decidePolicedOutcome } from './stderr.js';
3
+ /** After exit, how long to wait for a captured stderr pipe to drain. A grandchild that
4
+ * inherited the pipe can hold it open; this bounds the wait. */
5
+ const STDERR_DRAIN_MS = 2_000;
6
+ /** After forwarding SIGINT/SIGTERM, how long a step gets to exit before its group is SIGKILLed. */
7
+ export const INTERRUPT_GRACE_MS = 3_000;
8
+ /**
9
+ * Run a shell command with a hard wall-clock timeout. The command runs in its OWN process
10
+ * group (detached) so a timeout can SIGKILL the whole tree, not just the wrapping shell.
11
+ * Being in its own group, it no longer receives the terminal's Ctrl-C, so SIGINT/SIGTERM
12
+ * delivered to us are forwarded to the group. Never rejects.
13
+ */
14
+ export function runWithTimeout(cmd, timeoutMs, opts = {}) {
15
+ const capture = opts.captureStderr ?? false;
16
+ const signals = opts.signals ?? process;
17
+ return new Promise((resolve) => {
18
+ const child = spawn(cmd, {
19
+ shell: true,
20
+ stdio: capture ? ['inherit', 'inherit', 'pipe'] : 'inherit',
21
+ detached: true,
22
+ env: opts.env ?? process.env,
23
+ cwd: opts.cwd
24
+ });
25
+ let stderrBuf = '';
26
+ let stderrEnded = !capture || child.stderr === null;
27
+ let exitCode;
28
+ let timedOut = false;
29
+ let interrupted = false;
30
+ let settled = false;
31
+ const killGroup = (signal) => {
32
+ try {
33
+ if (child.pid !== undefined)
34
+ process.kill(-child.pid, signal);
35
+ }
36
+ catch {
37
+ // Group already gone.
38
+ }
39
+ };
40
+ let graceTimer;
41
+ const onSignal = () => {
42
+ if (interrupted) {
43
+ // A second Ctrl-C means "now".
44
+ killGroup('SIGKILL');
45
+ return;
46
+ }
47
+ interrupted = true;
48
+ killGroup('SIGTERM');
49
+ graceTimer = setTimeout(() => killGroup('SIGKILL'), INTERRUPT_GRACE_MS);
50
+ graceTimer.unref();
51
+ };
52
+ const timer = setTimeout(() => {
53
+ timedOut = true;
54
+ killGroup('SIGKILL');
55
+ }, timeoutMs);
56
+ signals.on('SIGINT', onSignal);
57
+ signals.on('SIGTERM', onSignal);
58
+ const finish = (code) => {
59
+ if (settled)
60
+ return;
61
+ settled = true;
62
+ clearTimeout(timer);
63
+ clearTimeout(graceTimer);
64
+ // Reap survivors of the leader's exit (e.g. TERM-ignoring grandchildren).
65
+ if (interrupted)
66
+ killGroup('SIGKILL');
67
+ signals.off('SIGINT', onSignal);
68
+ signals.off('SIGTERM', onSignal);
69
+ resolve({ code, timedOut, interrupted, stderr: capture ? stderrBuf : undefined });
70
+ };
71
+ if (capture && child.stderr) {
72
+ // setEncoding decodes through a StringDecoder, so a multi-byte character split across
73
+ // chunks is not corrupted.
74
+ child.stderr.setEncoding('utf8');
75
+ child.stderr.on('data', (chunk) => {
76
+ stderrBuf += chunk;
77
+ });
78
+ child.stderr.on('end', () => {
79
+ stderrEnded = true;
80
+ if (exitCode !== undefined)
81
+ finish(exitCode);
82
+ });
83
+ }
84
+ child.on('exit', (code) => {
85
+ exitCode = code;
86
+ if (stderrEnded)
87
+ finish(code);
88
+ else
89
+ setTimeout(() => finish(code), STDERR_DRAIN_MS).unref();
90
+ });
91
+ child.on('error', () => finish(1));
92
+ });
93
+ }
94
+ export class StepFailure extends Error {
95
+ label;
96
+ constructor(label, message) {
97
+ super(message);
98
+ this.label = label;
99
+ this.name = 'StepFailure';
100
+ }
101
+ }
102
+ function formatDuration(ms) {
103
+ return ms < 60_000 ? `${Math.round(ms / 1000)}s` : `${Math.round(ms / 60_000)}m`;
104
+ }
105
+ export function timeoutMessage(label, timeoutMs) {
106
+ return (`\n[chreode] TIMED OUT at: ${label} after ${formatDuration(timeoutMs)}. Suspect a ` +
107
+ `contending process or a genuinely slow machine. Killed the step's process tree.`);
108
+ }
109
+ /** Run one planned step. Every failure throws StepFailure so the caller's `finally`
110
+ * releases the lock before the run exits. */
111
+ export async function runStep(step, opts) {
112
+ const log = opts.log ?? ((m) => console.log(m));
113
+ const error = opts.error ?? ((m) => console.error(m));
114
+ const writeStderr = opts.writeStderr ?? ((s) => void process.stderr.write(s));
115
+ const runImpl = opts.runImpl ?? runWithTimeout;
116
+ log(`\n[chreode] ${step.label}: ${step.command}`);
117
+ const r = await runImpl(step.command, step.timeoutMs, {
118
+ captureStderr: step.policeStderr,
119
+ env: { ...(opts.env ?? process.env), CHREODE_SIGNOFF: '1' }
120
+ });
121
+ if (r.timedOut) {
122
+ if (r.stderr)
123
+ writeStderr(r.stderr);
124
+ error(timeoutMessage(step.label, step.timeoutMs));
125
+ throw new StepFailure(step.label, `${step.label} timed out`);
126
+ }
127
+ if (r.interrupted) {
128
+ if (r.stderr)
129
+ writeStderr(r.stderr);
130
+ error(`\n[chreode] INTERRUPTED at: ${step.label}`);
131
+ throw new StepFailure(step.label, `${step.label} interrupted`);
132
+ }
133
+ if (!step.policeStderr) {
134
+ if (r.code !== 0) {
135
+ error(`\n[chreode] FAILED at: ${step.label} (exit ${r.code})`);
136
+ throw new StepFailure(step.label, `${step.label} failed`);
137
+ }
138
+ return 'passed';
139
+ }
140
+ const outcome = decidePolicedOutcome({ code: r.code, stderr: r.stderr }, opts.allowlist);
141
+ if (outcome.kind === 'fail-exit') {
142
+ if (outcome.rawStderr)
143
+ writeStderr(outcome.rawStderr);
144
+ error(`\n[chreode] FAILED at: ${step.label} (exit ${r.code})`);
145
+ throw new StepFailure(step.label, `${step.label} failed`);
146
+ }
147
+ if (outcome.kind === 'fail-stderr') {
148
+ if (opts.allowStderr) {
149
+ error(`\n[chreode] ${step.label} wrote unexpected stderr, allowed by --allow-stderr:`);
150
+ for (const line of outcome.offendingLines)
151
+ writeStderr(`${line}\n`);
152
+ return 'allowed-stderr';
153
+ }
154
+ error(`\n[chreode] FAILED at: ${step.label}: it exited 0 but wrote unexpected stderr.\n` +
155
+ `[chreode] A passing step that logs errors is still a failure: fix it at the source.\n` +
156
+ `[chreode] Allowlisting genuinely benign noise is a reviewed change to stderrAllowlist\n` +
157
+ `[chreode] in chreode.config.ts, with a reason. --allow-stderr bypasses for one run.\n` +
158
+ `[chreode] Offending lines:`);
159
+ for (const line of outcome.offendingLines)
160
+ writeStderr(`${line}\n`);
161
+ throw new StepFailure(step.label, `${step.label} wrote unexpected stderr`);
162
+ }
163
+ return 'passed';
164
+ }
165
+ /** Run the plan holding the machine lock; an empty plan touches no lock. The lock is
166
+ * released on every exit path. Returns the labels whose stderr --allow-stderr waived. */
167
+ export async function runSteps(steps, lock, run) {
168
+ if (steps.length === 0)
169
+ return [];
170
+ const release = await lock.acquire();
171
+ const allowed = [];
172
+ try {
173
+ for (const step of steps) {
174
+ if ((await run(step)) === 'allowed-stderr')
175
+ allowed.push(step.label);
176
+ }
177
+ return allowed;
178
+ }
179
+ finally {
180
+ await release();
181
+ }
182
+ }
@@ -0,0 +1,49 @@
1
+ import type { ResolvedConfig, ResolvedStep, RuleConfig } from '../config/types.js';
2
+ export declare class PlanError extends Error {
3
+ constructor(message: string);
4
+ }
5
+ export interface PlanInput {
6
+ paths: readonly string[];
7
+ full: boolean;
8
+ skip: readonly string[];
9
+ base: string;
10
+ head: string;
11
+ isWorktree: boolean;
12
+ }
13
+ export interface PlannedStep {
14
+ label: string;
15
+ command: string;
16
+ policeStderr: boolean;
17
+ timeoutMs: number;
18
+ }
19
+ export type SkipReason = {
20
+ kind: 'not-triggered';
21
+ } | {
22
+ kind: 'if-false';
23
+ } | {
24
+ kind: 'skip-flag';
25
+ } | {
26
+ kind: 'superseded';
27
+ by: string;
28
+ };
29
+ export interface Plan {
30
+ triggers: string[];
31
+ /** Changed paths no rule matched; each one fired every trigger. */
32
+ unmatched: string[];
33
+ steps: PlannedStep[];
34
+ skipped: {
35
+ label: string;
36
+ reason: SkipReason;
37
+ }[];
38
+ /** Steps that would have run but for --skip: these go in the status description. */
39
+ skippedByFlag: string[];
40
+ }
41
+ export declare function matchRule(path: string, rules: readonly RuleConfig[]): RuleConfig | null;
42
+ export declare function allTriggers(steps: readonly ResolvedStep[]): string[];
43
+ /** Rules can only make a run smaller by matching: an unmatched path fires everything. */
44
+ export declare function fireTriggers(paths: readonly string[], rules: readonly RuleConfig[], steps: readonly ResolvedStep[]): {
45
+ triggers: string[];
46
+ unmatched: string[];
47
+ };
48
+ export declare function planSignoff(config: ResolvedConfig, input: PlanInput): Plan;
49
+ export declare function describeSkip(reason: SkipReason): string;
@@ -0,0 +1,110 @@
1
+ import picomatch from 'picomatch';
2
+ export class PlanError extends Error {
3
+ constructor(message) {
4
+ super(message);
5
+ this.name = 'PlanError';
6
+ }
7
+ }
8
+ function tester(m) {
9
+ if (typeof m === 'string')
10
+ return picomatch(m, { dot: true });
11
+ return (path) => {
12
+ // A /g or /y RegExp carries lastIndex between calls; reset so every path is tested fresh.
13
+ m.lastIndex = 0;
14
+ return m.test(path);
15
+ };
16
+ }
17
+ export function matchRule(path, rules) {
18
+ for (const rule of rules) {
19
+ const matchers = Array.isArray(rule.match) ? rule.match : [rule.match];
20
+ if (matchers.some((m) => tester(m)(path)))
21
+ return rule;
22
+ }
23
+ return null;
24
+ }
25
+ export function allTriggers(steps) {
26
+ return [...new Set(steps.flatMap((s) => s.when))].sort();
27
+ }
28
+ /** Rules can only make a run smaller by matching: an unmatched path fires everything. */
29
+ export function fireTriggers(paths, rules, steps) {
30
+ const fired = new Set();
31
+ const unmatched = [];
32
+ for (const path of paths) {
33
+ const rule = matchRule(path, rules);
34
+ const triggers = rule === null ? allTriggers(steps) : rule.triggers;
35
+ if (rule === null)
36
+ unmatched.push(path);
37
+ for (const t of triggers)
38
+ fired.add(t);
39
+ }
40
+ return { triggers: [...fired].sort(), unmatched };
41
+ }
42
+ export function planSignoff(config, input) {
43
+ const known = config.steps.map((s) => s.label);
44
+ const unknownSkips = input.skip.filter((l) => !known.includes(l));
45
+ if (unknownSkips.length > 0) {
46
+ throw new PlanError(`--skip names unknown step(s): ${unknownSkips.join(', ')} (known: ${known.join(', ')})`);
47
+ }
48
+ const { triggers, unmatched } = input.full
49
+ ? { triggers: allTriggers(config.steps), unmatched: [] }
50
+ : fireTriggers(input.paths, config.rules, config.steps);
51
+ const ctx = {
52
+ base: input.base,
53
+ head: input.head,
54
+ paths: input.paths,
55
+ triggers,
56
+ isWorktree: input.isWorktree
57
+ };
58
+ const fired = new Set(triggers);
59
+ const skipSet = new Set(input.skip);
60
+ const skipped = [];
61
+ const skippedByFlag = [];
62
+ const survivors = [];
63
+ for (const step of config.steps) {
64
+ if (!step.when.some((t) => fired.has(t))) {
65
+ skipped.push({ label: step.label, reason: { kind: 'not-triggered' } });
66
+ }
67
+ else if (step.if && !step.if(ctx)) {
68
+ skipped.push({ label: step.label, reason: { kind: 'if-false' } });
69
+ }
70
+ else if (skipSet.has(step.label)) {
71
+ skipped.push({ label: step.label, reason: { kind: 'skip-flag' } });
72
+ skippedByFlag.push(step.label);
73
+ }
74
+ else {
75
+ survivors.push(step);
76
+ }
77
+ }
78
+ const surviving = new Set(survivors.map((s) => s.label));
79
+ const steps = [];
80
+ for (const step of survivors) {
81
+ const by = step.supersededBy.find((l) => surviving.has(l));
82
+ if (by !== undefined) {
83
+ skipped.push({ label: step.label, reason: { kind: 'superseded', by } });
84
+ continue;
85
+ }
86
+ const command = typeof step.run === 'function' ? step.run(ctx) : step.run;
87
+ if (typeof command !== 'string' || command.trim() === '') {
88
+ throw new PlanError(`step "${step.label}": run() must return a non-empty command`);
89
+ }
90
+ steps.push({
91
+ label: step.label,
92
+ command,
93
+ policeStderr: step.policeStderr,
94
+ timeoutMs: step.timeoutMs
95
+ });
96
+ }
97
+ return { triggers, unmatched, steps, skipped, skippedByFlag };
98
+ }
99
+ export function describeSkip(reason) {
100
+ switch (reason.kind) {
101
+ case 'not-triggered':
102
+ return 'no relevant changes';
103
+ case 'if-false':
104
+ return '`if` returned false';
105
+ case 'skip-flag':
106
+ return '--skip';
107
+ case 'superseded':
108
+ return `superseded by ${reason.by}`;
109
+ }
110
+ }
@@ -0,0 +1,10 @@
1
+ import { type Exec } from '../run/exec.js';
2
+ export declare class PreconditionError extends Error {
3
+ constructor(message: string);
4
+ }
5
+ export interface Preconditions {
6
+ branch: string;
7
+ /** git user.email: named in the status description. */
8
+ signer: string;
9
+ }
10
+ export declare function checkPreconditions(baseBranch: string, exec?: Exec): Preconditions;
@@ -0,0 +1,49 @@
1
+ import { defaultExec } from '../run/exec.js';
2
+ export class PreconditionError extends Error {
3
+ constructor(message) {
4
+ super(message);
5
+ this.name = 'PreconditionError';
6
+ }
7
+ }
8
+ export function checkPreconditions(baseBranch, exec = defaultExec) {
9
+ if (exec('git', ['status', '--porcelain']).trim() !== '') {
10
+ throw new PreconditionError('working tree is dirty; commit or stash before signing off');
11
+ }
12
+ let branch;
13
+ try {
14
+ branch = exec('git', ['symbolic-ref', '-q', '--short', 'HEAD']).trim();
15
+ }
16
+ catch {
17
+ throw new PreconditionError('HEAD is detached; check out a branch before signing off');
18
+ }
19
+ if (branch === baseBranch) {
20
+ throw new PreconditionError(`on ${baseBranch} itself; signoff is for branches that merge into ${baseBranch}`);
21
+ }
22
+ let behind = '';
23
+ try {
24
+ behind = exec('git', ['rev-list', 'HEAD..@{u}']).trim();
25
+ }
26
+ catch {
27
+ // No upstream yet: nothing to be behind.
28
+ }
29
+ if (behind !== '') {
30
+ throw new PreconditionError('HEAD is behind its upstream; pull before signing off');
31
+ }
32
+ try {
33
+ exec('gh', ['auth', 'status']);
34
+ }
35
+ catch {
36
+ throw new PreconditionError('gh is not authenticated; run `gh auth login`');
37
+ }
38
+ let signer = '';
39
+ try {
40
+ signer = exec('git', ['config', 'user.email']).trim();
41
+ }
42
+ catch {
43
+ // Reported below.
44
+ }
45
+ if (signer === '') {
46
+ throw new PreconditionError('git config user.email is unset; the status description names the signer');
47
+ }
48
+ return { branch, signer };
49
+ }
@@ -0,0 +1,36 @@
1
+ import type { ResolvedConfig } from '../config/types.js';
2
+ import { type MachineLock } from '../lock/machine-lock.js';
3
+ import { type Exec, type Run } from '../run/exec.js';
4
+ import { type StepOutcome } from '../run/step.js';
5
+ import { type Plan, type PlannedStep } from './plan.js';
6
+ import { type TailEffects } from './tail.js';
7
+ export interface SignoffOptions {
8
+ full: boolean;
9
+ dryRun: boolean;
10
+ noMerge: boolean;
11
+ noPush: boolean;
12
+ allowStderr: boolean;
13
+ base: string | null;
14
+ skip: string[];
15
+ }
16
+ export interface SignoffDeps {
17
+ exec: Exec;
18
+ run: Run;
19
+ lock: MachineLock;
20
+ runStep: (step: PlannedStep) => Promise<StepOutcome>;
21
+ tail: TailEffects;
22
+ log: (m: string) => void;
23
+ warn: (m: string) => void;
24
+ error: (m: string) => void;
25
+ }
26
+ export declare function printPlan(plan: Plan, where: {
27
+ base: string;
28
+ head: string;
29
+ paths: readonly string[];
30
+ full: boolean;
31
+ }, log: (m: string) => void): void;
32
+ /**
33
+ * `loadConfig` is called once up front and again after a merge that changed the branch, so
34
+ * a merge that brings in a new chreode.config.ts is planned and run under that config.
35
+ */
36
+ export declare function runSignoff(loadConfig: () => Promise<ResolvedConfig>, opts: SignoffOptions, overrides?: Partial<SignoffDeps>): Promise<number>;
@@ -0,0 +1,194 @@
1
+ import { committedPaths, isWorktree, resolveBase, resolveRef } from '../git/change-set.js';
2
+ import { mergeBaseBranch } from '../git/merge.js';
3
+ import { repoInfo } from '../github/status.js';
4
+ import { signoffLock } from '../lock/machine-lock.js';
5
+ import { defaultExec, defaultRun } from '../run/exec.js';
6
+ import { StepFailure, runStep, runSteps } from '../run/step.js';
7
+ import { PlanError, describeSkip, planSignoff } from './plan.js';
8
+ import { PreconditionError, checkPreconditions } from './preconditions.js';
9
+ import { defaultTailEffects, runTail } from './tail.js';
10
+ function defaultDeps(config, opts) {
11
+ // Children can detect the gate (e.g. to quiet build-tool stderr) via CHREODE_SIGNOFF=1.
12
+ const env = { ...process.env, CHREODE_SIGNOFF: '1' };
13
+ let repo = null;
14
+ const getRepo = () => (repo ??= repoInfo(defaultExec));
15
+ return {
16
+ exec: defaultExec,
17
+ run: (command) => defaultRun(command, env),
18
+ lock: signoffLock(),
19
+ runStep: (step) => runStep(step, { allowStderr: opts.allowStderr, allowlist: config.stderrAllowlist, env }),
20
+ tail: defaultTailEffects(getRepo, config.statusContext),
21
+ log: (m) => console.log(m),
22
+ warn: (m) => console.warn(m),
23
+ error: (m) => console.error(m)
24
+ };
25
+ }
26
+ function message(err) {
27
+ return err instanceof Error ? err.message : String(err);
28
+ }
29
+ /** Why the checkout no longer matches `head`, or null when it is clean and unmoved. */
30
+ function checkoutChange(head, exec) {
31
+ if (exec('git', ['status', '--porcelain']).trim() !== '')
32
+ return 'the working tree is dirty';
33
+ const now = exec('git', ['rev-parse', 'HEAD']).trim();
34
+ return now !== head ? `HEAD moved to ${now}` : null;
35
+ }
36
+ export function printPlan(plan, where, log) {
37
+ log(`[chreode] base=${where.base} head=${where.head}${where.full ? ' (--full)' : ''}`);
38
+ log(`[chreode] changed paths (${where.paths.length}):`);
39
+ for (const p of where.paths)
40
+ log(` ${p}`);
41
+ if (plan.unmatched.length > 0) {
42
+ log(`[chreode] matched no rule, so every trigger fired: ${plan.unmatched.join(', ')}`);
43
+ }
44
+ log(`[chreode] triggers: ${plan.triggers.length > 0 ? plan.triggers.join(', ') : '(none)'}`);
45
+ log(`[chreode] plan: ${plan.steps.length > 0 ? plan.steps.map((s) => s.label).join(', ') : '(no steps: a no-op signoff)'}`);
46
+ for (const { label, reason } of plan.skipped) {
47
+ log(`[chreode] skipping ${label} (${describeSkip(reason)})`);
48
+ }
49
+ }
50
+ /**
51
+ * `loadConfig` is called once up front and again after a merge that changed the branch, so
52
+ * a merge that brings in a new chreode.config.ts is planned and run under that config.
53
+ */
54
+ export async function runSignoff(loadConfig, opts, overrides = {}) {
55
+ let config = await loadConfig();
56
+ let d = { ...defaultDeps(config, opts), ...overrides };
57
+ const baseBranch = config.baseBranch;
58
+ // 1. Preconditions
59
+ let pre;
60
+ try {
61
+ pre = checkPreconditions(baseBranch, d.exec);
62
+ }
63
+ catch (err) {
64
+ if (err instanceof PreconditionError) {
65
+ d.error(`[chreode] ${err.message}`);
66
+ return 1;
67
+ }
68
+ throw err;
69
+ }
70
+ // 2. Merge
71
+ const merge = mergeBaseBranch(baseBranch, { noMerge: opts.noMerge }, d.exec, d.warn);
72
+ if (merge.kind === 'conflict') {
73
+ d.error(`\n[chreode] merging origin/${baseBranch} hit a conflict. The merge is in progress.\n` +
74
+ `Resolve it, run \`git commit --no-edit\`, then re-run signoff.\n` +
75
+ `To bail out: \`git merge --abort\`.`);
76
+ return 1;
77
+ }
78
+ if (merge.kind === 'failed') {
79
+ d.error(`[chreode] merging origin/${baseBranch} failed: ${merge.message}`);
80
+ return 1;
81
+ }
82
+ if (merge.kind === 'merged') {
83
+ d.log(`[chreode] merged origin/${baseBranch} into ${pre.branch}`);
84
+ if (config.afterMerge) {
85
+ d.log(`[chreode] afterMerge: ${config.afterMerge}`);
86
+ const before = d.exec('git', ['rev-parse', 'HEAD']).trim();
87
+ try {
88
+ d.run(config.afterMerge);
89
+ }
90
+ catch {
91
+ d.error('[chreode] afterMerge failed');
92
+ return 1;
93
+ }
94
+ const moved = checkoutChange(before, d.exec);
95
+ if (moved) {
96
+ d.error(`[chreode] afterMerge changed the checkout (${moved}); commit the change and re-run signoff.`);
97
+ return 1;
98
+ }
99
+ }
100
+ // The merge may have changed the config itself: plan under what is now checked out.
101
+ try {
102
+ config = await loadConfig();
103
+ }
104
+ catch (err) {
105
+ d.error(`[chreode] the config merged in from origin/${baseBranch} failed to load: ${message(err)}\n` +
106
+ `Fix it in a commit on this branch, then re-run signoff.`);
107
+ return 1;
108
+ }
109
+ d = { ...defaultDeps(config, opts), ...overrides };
110
+ }
111
+ // 3. Resolve base
112
+ const head = d.exec('git', ['rev-parse', 'HEAD']).trim();
113
+ let base;
114
+ let source;
115
+ try {
116
+ if (opts.base !== null) {
117
+ base = resolveRef(opts.base, d.exec);
118
+ source = 'flag';
119
+ }
120
+ else {
121
+ ({ base, source } = resolveBase(baseBranch, d.exec));
122
+ }
123
+ }
124
+ catch (err) {
125
+ d.error(`[chreode] could not resolve the base: ${message(err)}`);
126
+ return 1;
127
+ }
128
+ if (!opts.full && base === head) {
129
+ d.log(source === 'note'
130
+ ? `[chreode] already signed off at ${head}; nothing to do (--full re-runs everything)`
131
+ : `[chreode] nothing to sign off: ${base} is HEAD`);
132
+ return 0;
133
+ }
134
+ // 4. Plan
135
+ const paths = committedPaths(base, head, d.exec);
136
+ let plan;
137
+ try {
138
+ plan = planSignoff(config, {
139
+ paths,
140
+ full: opts.full,
141
+ skip: opts.skip,
142
+ base,
143
+ head,
144
+ isWorktree: isWorktree(d.exec)
145
+ });
146
+ }
147
+ catch (err) {
148
+ if (err instanceof PlanError) {
149
+ d.error(`[chreode] ${err.message}`);
150
+ return 1;
151
+ }
152
+ throw err;
153
+ }
154
+ printPlan(plan, { base, head, paths, full: opts.full }, d.log);
155
+ if (opts.dryRun) {
156
+ d.log('[chreode] --dry-run: stopping before the run');
157
+ return 0;
158
+ }
159
+ // 5. Run
160
+ let allowedStderr;
161
+ try {
162
+ allowedStderr = await runSteps(plan.steps, d.lock, d.runStep);
163
+ }
164
+ catch (err) {
165
+ if (!(err instanceof StepFailure))
166
+ d.error(`[chreode] ${message(err)}`);
167
+ return 1;
168
+ }
169
+ // What was tested must be exactly HEAD.
170
+ const changed = checkoutChange(head, d.exec);
171
+ if (changed) {
172
+ d.error(`[chreode] the steps changed the checkout (${changed}); ` +
173
+ `commit the change and re-run signoff. Nothing was posted.`);
174
+ return 1;
175
+ }
176
+ // 6. Tail
177
+ try {
178
+ runTail({
179
+ sha: head,
180
+ signer: pre.signer,
181
+ noPush: opts.noPush,
182
+ ranLabels: plan.steps.map((s) => s.label),
183
+ skippedByFlag: plan.skippedByFlag,
184
+ allowedStderr,
185
+ baseFlag: opts.base,
186
+ context: config.statusContext
187
+ }, d.tail, d.log);
188
+ }
189
+ catch (err) {
190
+ d.error(`[chreode] ${message(err)}`);
191
+ return 1;
192
+ }
193
+ return 0;
194
+ }
@@ -0,0 +1,32 @@
1
+ import { type RepoInfo } from '../github/status.js';
2
+ import { type Exec, type Run } from '../run/exec.js';
3
+ export interface TailEffects {
4
+ push(): void;
5
+ postStatus(sha: string, description: string): void;
6
+ writeNote(sha: string, ranLabels: readonly string[]): void;
7
+ prUrl(): string | null;
8
+ }
9
+ export interface TailWork {
10
+ sha: string;
11
+ signer: string;
12
+ noPush: boolean;
13
+ ranLabels: readonly string[];
14
+ skippedByFlag: readonly string[];
15
+ /** Policed steps that passed only because --allow-stderr waived their stderr. */
16
+ allowedStderr: readonly string[];
17
+ /** The --base ref as given, or null when the base came from the last signoff. */
18
+ baseFlag: string | null;
19
+ context: string;
20
+ }
21
+ /** Why this run must not become an incremental base: the note means "everything the
22
+ * changes since the last note needed passed", and a partial run cannot vouch for that. */
23
+ export declare function noteWithheldReasons(work: TailWork): string[];
24
+ /**
25
+ * After every step passed: push, post the status, then write the note. The note is the
26
+ * incremental cache ("this commit is signed off"), so it goes last: a failed post must not
27
+ * leave a note that makes the next run a no-op with no status on GitHub. A partial run
28
+ * (--skip, --base, or stderr waived by --allow-stderr) posts a status that says so but
29
+ * writes no note, so the next signoff diffs from the last full one.
30
+ */
31
+ export declare function runTail(work: TailWork, effects: TailEffects, log?: (m: string) => void): void;
32
+ export declare function defaultTailEffects(repo: () => RepoInfo, context: string, exec?: Exec, run?: Run): TailEffects;