@mindlace/chreode 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/README.md +118 -2
  2. package/dist/burn-in/run.d.ts +18 -0
  3. package/dist/burn-in/run.js +71 -0
  4. package/dist/burn-in/select.d.ts +30 -0
  5. package/dist/burn-in/select.js +78 -0
  6. package/dist/burn-in/step.d.ts +10 -0
  7. package/dist/burn-in/step.js +14 -0
  8. package/dist/cli.d.ts +10 -0
  9. package/dist/cli.js +19 -0
  10. package/dist/config/types.d.ts +47 -0
  11. package/dist/config/types.js +12 -0
  12. package/dist/config/validate.js +94 -8
  13. package/dist/flakes/gh.d.ts +5 -0
  14. package/dist/flakes/gh.js +74 -0
  15. package/dist/flakes/ledger.d.ts +53 -0
  16. package/dist/flakes/ledger.js +108 -0
  17. package/dist/flakes/log.d.ts +39 -0
  18. package/dist/flakes/log.js +89 -0
  19. package/dist/flakes/phase.d.ts +34 -0
  20. package/dist/flakes/phase.js +66 -0
  21. package/dist/flakes/report.d.ts +14 -0
  22. package/dist/flakes/report.js +45 -0
  23. package/dist/git/version-only.d.ts +11 -0
  24. package/dist/git/version-only.js +56 -0
  25. package/dist/index.d.ts +3 -1
  26. package/dist/index.js +1 -0
  27. package/dist/reporters/options.d.ts +17 -0
  28. package/dist/reporters/options.js +18 -0
  29. package/dist/reporters/playwright.d.ts +15 -0
  30. package/dist/reporters/playwright.js +47 -0
  31. package/dist/reporters/vitest.d.ts +15 -0
  32. package/dist/reporters/vitest.js +47 -0
  33. package/dist/run/stderr.d.ts +1 -0
  34. package/dist/run/stderr.js +4 -1
  35. package/dist/signoff/plan.d.ts +17 -3
  36. package/dist/signoff/plan.js +28 -11
  37. package/dist/signoff/preconditions.d.ts +3 -1
  38. package/dist/signoff/preconditions.js +11 -1
  39. package/dist/signoff/run.d.ts +5 -0
  40. package/dist/signoff/run.js +45 -8
  41. package/package.json +23 -1
@@ -0,0 +1,45 @@
1
+ import { resolveFlakes } from '../config/types.js';
2
+ import { defaultExec } from '../run/exec.js';
3
+ import { ghLedgerDeps } from './gh.js';
4
+ import { flakeLogPath } from './log.js';
5
+ import { escalationFailure, runFlakePhase } from './phase.js';
6
+ /** `chreode flake-report`: the signoff flake phase on its own, for hosted CI or by hand.
7
+ * Without a `flakes` block it uses the defaults: running it is the opt-in. */
8
+ export function runFlakeReport(config, overrides = {}) {
9
+ const d = {
10
+ exec: defaultExec,
11
+ ledger: ghLedgerDeps(),
12
+ logPath: flakeLogPath(),
13
+ log: (m) => console.log(m),
14
+ warn: (m) => console.warn(m),
15
+ error: (m) => console.error(m),
16
+ ...overrides
17
+ };
18
+ const flakes = config.flakes ?? resolveFlakes({});
19
+ const sha = d.exec('git', ['rev-parse', 'HEAD']).trim();
20
+ let branch = 'HEAD';
21
+ try {
22
+ branch = d.exec('git', ['symbolic-ref', '-q', '--short', 'HEAD']).trim();
23
+ }
24
+ catch {
25
+ // Detached, e.g. a CI checkout of a sha.
26
+ }
27
+ const outcome = runFlakePhase({
28
+ logPath: d.logPath,
29
+ ctx: { branch, sha },
30
+ config: flakes,
31
+ ledger: d.ledger,
32
+ log: d.log,
33
+ warn: d.warn
34
+ });
35
+ if (outcome.kind === 'none') {
36
+ d.log('[chreode] flakes: no flakes recorded');
37
+ return 0;
38
+ }
39
+ const failure = escalationFailure(outcome, flakes);
40
+ if (failure !== undefined) {
41
+ d.error(failure);
42
+ return 1;
43
+ }
44
+ return 0;
45
+ }
@@ -0,0 +1,11 @@
1
+ import { type Exec } from '../run/exec.js';
2
+ /**
3
+ * True when `path` is a package.json or package-lock.json (at any depth) present on both
4
+ * sides whose only difference between base and head is its version field(s) (for the
5
+ * lockfile, the top-level `version` and `packages[""].version`). Anything else, including
6
+ * an added or deleted file or unparseable JSON, is false: the caller falls back to treating
7
+ * the path as an ordinary change.
8
+ */
9
+ export declare function isVersionOnlyChange(base: string, head: string, path: string, exec?: Exec): boolean;
10
+ /** The subset of `paths` that are version-only changes between base and head. */
11
+ export declare function versionOnlyPaths(base: string, head: string, paths: readonly string[], exec?: Exec): string[];
@@ -0,0 +1,56 @@
1
+ import { posix } from 'node:path';
2
+ import { isDeepStrictEqual } from 'node:util';
3
+ import { defaultExec } from '../run/exec.js';
4
+ function isObject(v) {
5
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
6
+ }
7
+ /** Each version-bearing file: the version fields it carries, read and blanked. */
8
+ const VERSIONED = {
9
+ 'package.json': (doc) => {
10
+ const { version, ...rest } = doc;
11
+ return { versions: [version], rest };
12
+ },
13
+ 'package-lock.json': (doc) => {
14
+ const { version, ...rest } = doc;
15
+ const packages = rest.packages;
16
+ if (!isObject(packages) || !isObject(packages['']))
17
+ return { versions: [version], rest };
18
+ const { version: rootVersion, ...root } = packages[''];
19
+ return {
20
+ versions: [version, rootVersion],
21
+ rest: { ...rest, packages: { ...packages, '': root } }
22
+ };
23
+ }
24
+ };
25
+ function readJson(rev, path, exec) {
26
+ try {
27
+ const doc = JSON.parse(exec('git', ['show', `${rev}:${path}`]));
28
+ return isObject(doc) ? doc : null;
29
+ }
30
+ catch {
31
+ return null;
32
+ }
33
+ }
34
+ /**
35
+ * True when `path` is a package.json or package-lock.json (at any depth) present on both
36
+ * sides whose only difference between base and head is its version field(s) (for the
37
+ * lockfile, the top-level `version` and `packages[""].version`). Anything else, including
38
+ * an added or deleted file or unparseable JSON, is false: the caller falls back to treating
39
+ * the path as an ordinary change.
40
+ */
41
+ export function isVersionOnlyChange(base, head, path, exec = defaultExec) {
42
+ const strip = VERSIONED[posix.basename(path)];
43
+ if (strip === undefined)
44
+ return false;
45
+ const before = readJson(base, path, exec);
46
+ const after = readJson(head, path, exec);
47
+ if (before === null || after === null)
48
+ return false;
49
+ const a = strip(before);
50
+ const b = strip(after);
51
+ return isDeepStrictEqual(a.rest, b.rest) && !isDeepStrictEqual(a.versions, b.versions);
52
+ }
53
+ /** The subset of `paths` that are version-only changes between base and head. */
54
+ export function versionOnlyPaths(base, head, paths, exec = defaultExec) {
55
+ return paths.filter((p) => isVersionOnlyChange(base, head, p, exec));
56
+ }
package/dist/index.d.ts CHANGED
@@ -1,2 +1,4 @@
1
1
  export { defineConfig } from './config/types.js';
2
- export type { AllowlistEntry, ChreodeConfig, Matcher, ProtectionConfig, RuleConfig, StepConfig, StepContext } from './config/types.js';
2
+ export { burnInStep, type BurnInStepOptions } from './burn-in/step.js';
3
+ export type { AllowlistEntry, BurnInConfig, BurnInRunner, ChreodeConfig, FlakesConfig, Matcher, ProtectionConfig, RuleConfig, StepConfig, StepContext } from './config/types.js';
4
+ export type { FlakeEvent } from './flakes/log.js';
package/dist/index.js CHANGED
@@ -1 +1,2 @@
1
1
  export { defineConfig } from './config/types.js';
2
+ export { burnInStep } from './burn-in/step.js';
@@ -0,0 +1,17 @@
1
+ import { type FlakeEvent } from '../flakes/log.js';
2
+ export interface FlakeReporterOptions {
3
+ /** Issue-title bucket: fixed, or derived from the runner project name. */
4
+ kind?: string | ((project: string) => string);
5
+ /** Record only these runner projects (default: all). */
6
+ projects?: string[];
7
+ /** Where events go (default: appended to the flake log). A seam for tests. */
8
+ append?: (event: FlakeEvent) => void;
9
+ }
10
+ export declare function resolveKind(kind: FlakeReporterOptions['kind'], project: string, fallback: string): string;
11
+ export declare function wants(projects: readonly string[] | undefined, project: string): boolean;
12
+ /** The sha under test when running under signoff. */
13
+ export declare function headSha(env?: NodeJS.ProcessEnv): {
14
+ sha?: string;
15
+ };
16
+ /** Resolved at write time, so CHREODE_FLAKE_LOG set by signoff is honoured. */
17
+ export declare function defaultAppend(event: FlakeEvent): void;
@@ -0,0 +1,18 @@
1
+ import { appendFlakeEvent, flakeLogPath } from '../flakes/log.js';
2
+ export function resolveKind(kind, project, fallback) {
3
+ if (typeof kind === 'function')
4
+ return kind(project);
5
+ return kind ?? fallback;
6
+ }
7
+ export function wants(projects, project) {
8
+ return projects === undefined || projects.includes(project);
9
+ }
10
+ /** The sha under test when running under signoff. */
11
+ export function headSha(env = process.env) {
12
+ const sha = env.CHREODE_HEAD;
13
+ return sha ? { sha } : {};
14
+ }
15
+ /** Resolved at write time, so CHREODE_FLAKE_LOG set by signoff is honoured. */
16
+ export function defaultAppend(event) {
17
+ appendFlakeEvent(event, flakeLogPath());
18
+ }
@@ -0,0 +1,15 @@
1
+ import type { Reporter, TestCase, TestResult } from 'playwright/types/testReporter';
2
+ import { type FlakeReporterOptions } from './options.js';
3
+ /**
4
+ * Records every test that failed (or timed out) and then passed on a retry. Playwright loads
5
+ * reporters by module specifier, hence the default export. Imports Playwright's types only.
6
+ */
7
+ export default class PlaywrightFlakeReporter implements Reporter {
8
+ private readonly options;
9
+ private readonly append;
10
+ /** The first failed attempt of each test that has not passed yet. */
11
+ private readonly failed;
12
+ constructor(options?: FlakeReporterOptions);
13
+ onTestEnd(test: TestCase, result: TestResult): void;
14
+ printsToStdio(): boolean;
15
+ }
@@ -0,0 +1,47 @@
1
+ import { repoRelative, truncateError } from '../flakes/log.js';
2
+ import { defaultAppend, headSha, resolveKind, wants } from './options.js';
3
+ /**
4
+ * Records every test that failed (or timed out) and then passed on a retry. Playwright loads
5
+ * reporters by module specifier, hence the default export. Imports Playwright's types only.
6
+ */
7
+ export default class PlaywrightFlakeReporter {
8
+ options;
9
+ append;
10
+ /** The first failed attempt of each test that has not passed yet. */
11
+ failed = new Map();
12
+ constructor(options = {}) {
13
+ this.options = options;
14
+ this.append = options.append ?? defaultAppend;
15
+ }
16
+ onTestEnd(test, result) {
17
+ if (result.status === 'failed' || result.status === 'timedOut') {
18
+ if (!this.failed.has(test.id))
19
+ this.failed.set(test.id, result);
20
+ return;
21
+ }
22
+ const first = this.failed.get(test.id);
23
+ if (result.status !== 'passed' || result.retry === 0 || first === undefined)
24
+ return;
25
+ this.failed.delete(test.id);
26
+ // ['', project, file, ...describes, title]
27
+ const titlePath = test.titlePath();
28
+ const project = titlePath[1] ?? '';
29
+ if (!wants(this.options.projects, project))
30
+ return;
31
+ const trace = first.attachments.find((a) => a.name === 'trace')?.path;
32
+ this.append({
33
+ kind: resolveKind(this.options.kind, project, 'e2e'),
34
+ runner: 'playwright',
35
+ project,
36
+ file: repoRelative(test.location.file),
37
+ title: titlePath.slice(3).join(' › '),
38
+ firstAttemptError: truncateError(first.error?.message),
39
+ ...(trace ? { tracePath: trace } : {}),
40
+ ...headSha(),
41
+ at: new Date().toISOString()
42
+ });
43
+ }
44
+ printsToStdio() {
45
+ return false;
46
+ }
47
+ }
@@ -0,0 +1,15 @@
1
+ import type { Reporter, TestModule } from 'vitest/node';
2
+ import { type FlakeReporterOptions } from './options.js';
3
+ /**
4
+ * Records every test that passed after at least one retry. vitest reporters are a root-level
5
+ * option, so instantiate this in the root config's `reporters`; it sees every project and
6
+ * filters with `projects`. Imports vitest's types only.
7
+ */
8
+ export declare class VitestFlakeReporter implements Reporter {
9
+ private readonly options;
10
+ private readonly append;
11
+ constructor(options?: FlakeReporterOptions);
12
+ onTestRunEnd(testModules: ReadonlyArray<TestModule>): void;
13
+ private walk;
14
+ }
15
+ export default VitestFlakeReporter;
@@ -0,0 +1,47 @@
1
+ import { repoRelative, truncateError } from '../flakes/log.js';
2
+ import { defaultAppend, headSha, resolveKind, wants } from './options.js';
3
+ /**
4
+ * Records every test that passed after at least one retry. vitest reporters are a root-level
5
+ * option, so instantiate this in the root config's `reporters`; it sees every project and
6
+ * filters with `projects`. Imports vitest's types only.
7
+ */
8
+ export class VitestFlakeReporter {
9
+ options;
10
+ append;
11
+ constructor(options = {}) {
12
+ this.options = options;
13
+ this.append = options.append ?? defaultAppend;
14
+ }
15
+ onTestRunEnd(testModules) {
16
+ for (const testModule of testModules) {
17
+ const project = testModule.project.name;
18
+ if (!wants(this.options.projects, project))
19
+ continue;
20
+ const where = { project, file: repoRelative(testModule.moduleId) };
21
+ this.walk(testModule.children.array(), [], where);
22
+ }
23
+ }
24
+ walk(entities, titles, where) {
25
+ for (const entity of entities) {
26
+ if (entity.type === 'suite') {
27
+ this.walk(entity.children.array(), [...titles, entity.name], where);
28
+ continue;
29
+ }
30
+ const result = entity.result();
31
+ if (result.state !== 'passed' || (entity.diagnostic()?.retryCount ?? 0) === 0)
32
+ continue;
33
+ this.append({
34
+ kind: resolveKind(this.options.kind, where.project, where.project || 'unit'),
35
+ runner: 'vitest',
36
+ project: where.project,
37
+ file: where.file,
38
+ title: [...titles, entity.name].join(' › '),
39
+ // A test that passed on retry still reports the errors of its failed attempts.
40
+ firstAttemptError: truncateError(result.errors?.[0]?.message),
41
+ ...headSha(),
42
+ at: new Date().toISOString()
43
+ });
44
+ }
45
+ }
46
+ }
47
+ export default VitestFlakeReporter;
@@ -1,4 +1,5 @@
1
1
  import type { AllowlistEntry } from '../config/types.js';
2
+ export declare function stripAnsi(s: string): string;
2
3
  /** Classify captured stderr line by line: blank and allowlisted lines are benign,
3
4
  * everything else is returned ANSI-stripped. */
4
5
  export declare function policeStderr(buffer: string, allowlist: readonly AllowlistEntry[]): {
@@ -1,12 +1,15 @@
1
1
  // ANSI SGR color codes must not defeat allowlist matching. The escape character is built at
2
2
  // runtime so no control character appears in source (keeps no-control-regex quiet).
3
3
  const ANSI_SGR = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, 'g');
4
+ export function stripAnsi(s) {
5
+ return s.replace(ANSI_SGR, '');
6
+ }
4
7
  /** Classify captured stderr line by line: blank and allowlisted lines are benign,
5
8
  * everything else is returned ANSI-stripped. */
6
9
  export function policeStderr(buffer, allowlist) {
7
10
  const offendingLines = buffer
8
11
  .split(/\r?\n/)
9
- .map((line) => line.replace(ANSI_SGR, ''))
12
+ .map(stripAnsi)
10
13
  .filter((line) => line.trim() !== '')
11
14
  .filter((line) => !allowlist.some((entry) => {
12
15
  entry.pattern.lastIndex = 0;
@@ -1,4 +1,4 @@
1
- import type { ResolvedConfig, ResolvedStep, RuleConfig } from '../config/types.js';
1
+ import type { Matcher, ResolvedConfig, ResolvedStep, RuleConfig } from '../config/types.js';
2
2
  export declare class PlanError extends Error {
3
3
  constructor(message: string);
4
4
  }
@@ -9,6 +9,8 @@ export interface PlanInput {
9
9
  base: string;
10
10
  head: string;
11
11
  isWorktree: boolean;
12
+ /** Changed package.json / package-lock.json paths whose only change is the version. */
13
+ versionOnly: readonly string[];
12
14
  }
13
15
  export interface PlannedStep {
14
16
  label: string;
@@ -30,6 +32,9 @@ export interface Plan {
30
32
  triggers: string[];
31
33
  /** Changed paths no rule matched; each one fired every trigger. */
32
34
  unmatched: string[];
35
+ /** Changed paths no rule matched that were version-only bumps; each fired only the
36
+ * config's versionBumpTriggers. */
37
+ versionOnly: string[];
33
38
  steps: PlannedStep[];
34
39
  skipped: {
35
40
  label: string;
@@ -38,12 +43,21 @@ export interface Plan {
38
43
  /** Steps that would have run but for --skip: these go in the status description. */
39
44
  skippedByFlag: string[];
40
45
  }
46
+ export declare function matchesAny(path: string, matchers: Matcher | Matcher[]): boolean;
41
47
  export declare function matchRule(path: string, rules: readonly RuleConfig[]): RuleConfig | null;
42
48
  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[]): {
49
+ export interface VersionBumps {
50
+ /** Paths whose only change is a version bump. */
51
+ versionOnly: readonly string[];
52
+ /** What such a path fires when no rule matched it. */
53
+ versionBumpTriggers: readonly string[];
54
+ }
55
+ /** Rules can only make a run smaller by matching: an unmatched path fires everything,
56
+ * unless it is a version-only bump, which fires just the versionBumpTriggers. */
57
+ export declare function fireTriggers(paths: readonly string[], rules: readonly RuleConfig[], steps: readonly ResolvedStep[], bumps?: VersionBumps): {
45
58
  triggers: string[];
46
59
  unmatched: string[];
60
+ versionOnly: string[];
47
61
  };
48
62
  export declare function planSignoff(config: ResolvedConfig, input: PlanInput): Plan;
49
63
  export declare function describeSkip(reason: SkipReason): string;
@@ -14,10 +14,12 @@ function tester(m) {
14
14
  return m.test(path);
15
15
  };
16
16
  }
17
+ export function matchesAny(path, matchers) {
18
+ return (Array.isArray(matchers) ? matchers : [matchers]).some((m) => tester(m)(path));
19
+ }
17
20
  export function matchRule(path, rules) {
18
21
  for (const rule of rules) {
19
- const matchers = Array.isArray(rule.match) ? rule.match : [rule.match];
20
- if (matchers.some((m) => tester(m)(path)))
22
+ if (matchesAny(path, rule.match))
21
23
  return rule;
22
24
  }
23
25
  return null;
@@ -25,19 +27,31 @@ export function matchRule(path, rules) {
25
27
  export function allTriggers(steps) {
26
28
  return [...new Set(steps.flatMap((s) => s.when))].sort();
27
29
  }
28
- /** Rules can only make a run smaller by matching: an unmatched path fires everything. */
29
- export function fireTriggers(paths, rules, steps) {
30
+ /** Rules can only make a run smaller by matching: an unmatched path fires everything,
31
+ * unless it is a version-only bump, which fires just the versionBumpTriggers. */
32
+ export function fireTriggers(paths, rules, steps, bumps = { versionOnly: [], versionBumpTriggers: [] }) {
30
33
  const fired = new Set();
31
34
  const unmatched = [];
35
+ const versionOnly = [];
36
+ const bumped = new Set(bumps.versionOnly);
32
37
  for (const path of paths) {
33
38
  const rule = matchRule(path, rules);
34
- const triggers = rule === null ? allTriggers(steps) : rule.triggers;
35
- if (rule === null)
39
+ let triggers;
40
+ if (rule !== null) {
41
+ triggers = rule.triggers;
42
+ }
43
+ else if (bumped.has(path)) {
44
+ versionOnly.push(path);
45
+ triggers = bumps.versionBumpTriggers;
46
+ }
47
+ else {
36
48
  unmatched.push(path);
49
+ triggers = allTriggers(steps);
50
+ }
37
51
  for (const t of triggers)
38
52
  fired.add(t);
39
53
  }
40
- return { triggers: [...fired].sort(), unmatched };
54
+ return { triggers: [...fired].sort(), unmatched, versionOnly };
41
55
  }
42
56
  export function planSignoff(config, input) {
43
57
  const known = config.steps.map((s) => s.label);
@@ -45,9 +59,12 @@ export function planSignoff(config, input) {
45
59
  if (unknownSkips.length > 0) {
46
60
  throw new PlanError(`--skip names unknown step(s): ${unknownSkips.join(', ')} (known: ${known.join(', ')})`);
47
61
  }
48
- const { triggers, unmatched } = input.full
49
- ? { triggers: allTriggers(config.steps), unmatched: [] }
50
- : fireTriggers(input.paths, config.rules, config.steps);
62
+ const { triggers, unmatched, versionOnly } = input.full
63
+ ? { triggers: allTriggers(config.steps), unmatched: [], versionOnly: [] }
64
+ : fireTriggers(input.paths, config.rules, config.steps, {
65
+ versionOnly: input.versionOnly,
66
+ versionBumpTriggers: config.versionBumpTriggers
67
+ });
51
68
  const ctx = {
52
69
  base: input.base,
53
70
  head: input.head,
@@ -94,7 +111,7 @@ export function planSignoff(config, input) {
94
111
  timeoutMs: step.timeoutMs
95
112
  });
96
113
  }
97
- return { triggers, unmatched, steps, skipped, skippedByFlag };
114
+ return { triggers, unmatched, versionOnly, steps, skipped, skippedByFlag };
98
115
  }
99
116
  export function describeSkip(reason) {
100
117
  switch (reason.kind) {
@@ -7,4 +7,6 @@ export interface Preconditions {
7
7
  /** git user.email: named in the status description. */
8
8
  signer: string;
9
9
  }
10
- export declare function checkPreconditions(baseBranch: string, exec?: Exec): Preconditions;
10
+ export declare function checkPreconditions(baseBranch: string, exec?: Exec, opts?: {
11
+ flakes: boolean;
12
+ }): Preconditions;
@@ -1,3 +1,4 @@
1
+ import { FLAKE_LOG_FILE } from '../flakes/log.js';
1
2
  import { defaultExec } from '../run/exec.js';
2
3
  export class PreconditionError extends Error {
3
4
  constructor(message) {
@@ -5,7 +6,7 @@ export class PreconditionError extends Error {
5
6
  this.name = 'PreconditionError';
6
7
  }
7
8
  }
8
- export function checkPreconditions(baseBranch, exec = defaultExec) {
9
+ export function checkPreconditions(baseBranch, exec = defaultExec, opts = { flakes: false }) {
9
10
  if (exec('git', ['status', '--porcelain']).trim() !== '') {
10
11
  throw new PreconditionError('working tree is dirty; commit or stash before signing off');
11
12
  }
@@ -29,6 +30,15 @@ export function checkPreconditions(baseBranch, exec = defaultExec) {
29
30
  if (behind !== '') {
30
31
  throw new PreconditionError('HEAD is behind its upstream; pull before signing off');
31
32
  }
33
+ if (opts.flakes) {
34
+ try {
35
+ exec('git', ['check-ignore', '-q', FLAKE_LOG_FILE]);
36
+ }
37
+ catch {
38
+ throw new PreconditionError('`flakes` is configured but .chreode/ is not gitignored (the flake log would dirty ' +
39
+ 'the tree); add the line `.chreode/` to .gitignore');
40
+ }
41
+ }
32
42
  try {
33
43
  exec('gh', ['auth', 'status']);
34
44
  }
@@ -1,4 +1,5 @@
1
1
  import type { ResolvedConfig } from '../config/types.js';
2
+ import type { FlakeLedgerDeps } from '../flakes/ledger.js';
2
3
  import { type MachineLock } from '../lock/machine-lock.js';
3
4
  import { type Exec, type Run } from '../run/exec.js';
4
5
  import { type StepOutcome } from '../run/step.js';
@@ -19,6 +20,10 @@ export interface SignoffDeps {
19
20
  lock: MachineLock;
20
21
  runStep: (step: PlannedStep) => Promise<StepOutcome>;
21
22
  tail: TailEffects;
23
+ /** Files, bumps and lists flake issues. */
24
+ ledger: FlakeLedgerDeps;
25
+ /** Absolute path of the flake log; steps see it as CHREODE_FLAKE_LOG. */
26
+ flakeLog: string;
22
27
  log: (m: string) => void;
23
28
  warn: (m: string) => void;
24
29
  error: (m: string) => void;
@@ -1,5 +1,10 @@
1
+ import { resolve } from 'node:path';
2
+ import { ghLedgerDeps } from '../flakes/gh.js';
3
+ import { FLAKE_LOG_FILE } from '../flakes/log.js';
4
+ import { escalationFailure, runFlakePhase } from '../flakes/phase.js';
1
5
  import { committedPaths, isWorktree, resolveBase, resolveRef } from '../git/change-set.js';
2
6
  import { mergeBaseBranch } from '../git/merge.js';
7
+ import { versionOnlyPaths } from '../git/version-only.js';
3
8
  import { repoInfo } from '../github/status.js';
4
9
  import { signoffLock } from '../lock/machine-lock.js';
5
10
  import { defaultExec, defaultRun } from '../run/exec.js';
@@ -7,9 +12,9 @@ import { StepFailure, runStep, runSteps } from '../run/step.js';
7
12
  import { PlanError, describeSkip, planSignoff } from './plan.js';
8
13
  import { PreconditionError, checkPreconditions } from './preconditions.js';
9
14
  import { defaultTailEffects, runTail } from './tail.js';
10
- function defaultDeps(config, opts) {
15
+ function defaultDeps(config, opts, stepEnv = {}) {
11
16
  // Children can detect the gate (e.g. to quiet build-tool stderr) via CHREODE_SIGNOFF=1.
12
- const env = { ...process.env, CHREODE_SIGNOFF: '1' };
17
+ const env = { ...process.env, CHREODE_SIGNOFF: '1', ...stepEnv };
13
18
  let repo = null;
14
19
  const getRepo = () => (repo ??= repoInfo(defaultExec));
15
20
  return {
@@ -18,6 +23,9 @@ function defaultDeps(config, opts) {
18
23
  lock: signoffLock(),
19
24
  runStep: (step) => runStep(step, { allowStderr: opts.allowStderr, allowlist: config.stderrAllowlist, env }),
20
25
  tail: defaultTailEffects(getRepo, config.statusContext),
26
+ ledger: ghLedgerDeps(defaultExec),
27
+ // The CLI has already chdir'd to the repo root.
28
+ flakeLog: resolve(FLAKE_LOG_FILE),
21
29
  log: (m) => console.log(m),
22
30
  warn: (m) => console.warn(m),
23
31
  error: (m) => console.error(m)
@@ -41,6 +49,9 @@ export function printPlan(plan, where, log) {
41
49
  if (plan.unmatched.length > 0) {
42
50
  log(`[chreode] matched no rule, so every trigger fired: ${plan.unmatched.join(', ')}`);
43
51
  }
52
+ if (plan.versionOnly.length > 0) {
53
+ log(`[chreode] version-only: ${plan.versionOnly.join(', ')}`);
54
+ }
44
55
  log(`[chreode] triggers: ${plan.triggers.length > 0 ? plan.triggers.join(', ') : '(none)'}`);
45
56
  log(`[chreode] plan: ${plan.steps.length > 0 ? plan.steps.map((s) => s.label).join(', ') : '(no steps: a no-op signoff)'}`);
46
57
  for (const { label, reason } of plan.skipped) {
@@ -58,7 +69,7 @@ export async function runSignoff(loadConfig, opts, overrides = {}) {
58
69
  // 1. Preconditions
59
70
  let pre;
60
71
  try {
61
- pre = checkPreconditions(baseBranch, d.exec);
72
+ pre = checkPreconditions(baseBranch, d.exec, { flakes: config.flakes !== null });
62
73
  }
63
74
  catch (err) {
64
75
  if (err instanceof PreconditionError) {
@@ -141,7 +152,8 @@ export async function runSignoff(loadConfig, opts, overrides = {}) {
141
152
  skip: opts.skip,
142
153
  base,
143
154
  head,
144
- isWorktree: isWorktree(d.exec)
155
+ isWorktree: isWorktree(d.exec),
156
+ versionOnly: opts.full ? [] : versionOnlyPaths(base, head, paths, d.exec)
145
157
  });
146
158
  }
147
159
  catch (err) {
@@ -156,16 +168,41 @@ export async function runSignoff(loadConfig, opts, overrides = {}) {
156
168
  d.log('[chreode] --dry-run: stopping before the run');
157
169
  return 0;
158
170
  }
159
- // 5. Run
160
- let allowedStderr;
171
+ // 5. Run. Steps learn the sha under test and where reporters record flakes.
172
+ d = {
173
+ ...defaultDeps(config, opts, { CHREODE_HEAD: head, CHREODE_FLAKE_LOG: d.flakeLog }),
174
+ ...overrides
175
+ };
176
+ let allowedStderr = [];
177
+ let stepsFailed = false;
161
178
  try {
162
179
  allowedStderr = await runSteps(plan.steps, d.lock, d.runStep);
163
180
  }
164
181
  catch (err) {
165
182
  if (!(err instanceof StepFailure))
166
183
  d.error(`[chreode] ${message(err)}`);
167
- return 1;
184
+ stepsFailed = true;
185
+ }
186
+ // 6. Flakes: after the steps, passed or failed, so a flake is charged to the run that saw
187
+ // it. Network, not machine load: the lock is already released.
188
+ let escalated = false;
189
+ if (config.flakes !== null) {
190
+ const outcome = runFlakePhase({
191
+ logPath: d.flakeLog,
192
+ ctx: { branch: pre.branch, sha: head },
193
+ config: config.flakes,
194
+ ledger: d.ledger,
195
+ log: d.log,
196
+ warn: d.warn
197
+ });
198
+ const failure = escalationFailure(outcome, config.flakes);
199
+ if (failure !== undefined) {
200
+ d.error(failure);
201
+ escalated = true;
202
+ }
168
203
  }
204
+ if (stepsFailed || escalated)
205
+ return 1;
169
206
  // What was tested must be exactly HEAD.
170
207
  const changed = checkoutChange(head, d.exec);
171
208
  if (changed) {
@@ -173,7 +210,7 @@ export async function runSignoff(loadConfig, opts, overrides = {}) {
173
210
  `commit the change and re-run signoff. Nothing was posted.`);
174
211
  return 1;
175
212
  }
176
- // 6. Tail
213
+ // 7. Tail
177
214
  try {
178
215
  runTail({
179
216
  sha: head,