@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.
- package/README.md +118 -2
- package/dist/burn-in/run.d.ts +18 -0
- package/dist/burn-in/run.js +71 -0
- package/dist/burn-in/select.d.ts +30 -0
- package/dist/burn-in/select.js +78 -0
- package/dist/burn-in/step.d.ts +10 -0
- package/dist/burn-in/step.js +14 -0
- package/dist/cli.d.ts +10 -0
- package/dist/cli.js +19 -0
- package/dist/config/types.d.ts +47 -0
- package/dist/config/types.js +12 -0
- package/dist/config/validate.js +94 -8
- package/dist/flakes/gh.d.ts +5 -0
- package/dist/flakes/gh.js +74 -0
- package/dist/flakes/ledger.d.ts +53 -0
- package/dist/flakes/ledger.js +108 -0
- package/dist/flakes/log.d.ts +39 -0
- package/dist/flakes/log.js +89 -0
- package/dist/flakes/phase.d.ts +34 -0
- package/dist/flakes/phase.js +66 -0
- package/dist/flakes/report.d.ts +14 -0
- package/dist/flakes/report.js +45 -0
- package/dist/git/version-only.d.ts +11 -0
- package/dist/git/version-only.js +56 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.js +1 -0
- package/dist/reporters/options.d.ts +17 -0
- package/dist/reporters/options.js +18 -0
- package/dist/reporters/playwright.d.ts +15 -0
- package/dist/reporters/playwright.js +47 -0
- package/dist/reporters/vitest.d.ts +15 -0
- package/dist/reporters/vitest.js +47 -0
- package/dist/run/stderr.d.ts +1 -0
- package/dist/run/stderr.js +4 -1
- package/dist/signoff/plan.d.ts +17 -3
- package/dist/signoff/plan.js +28 -11
- package/dist/signoff/preconditions.d.ts +3 -1
- package/dist/signoff/preconditions.js +11 -1
- package/dist/signoff/run.d.ts +5 -0
- package/dist/signoff/run.js +45 -8
- 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
|
|
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
|
@@ -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;
|
package/dist/run/stderr.d.ts
CHANGED
|
@@ -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[]): {
|
package/dist/run/stderr.js
CHANGED
|
@@ -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(
|
|
12
|
+
.map(stripAnsi)
|
|
10
13
|
.filter((line) => line.trim() !== '')
|
|
11
14
|
.filter((line) => !allowlist.some((entry) => {
|
|
12
15
|
entry.pattern.lastIndex = 0;
|
package/dist/signoff/plan.d.ts
CHANGED
|
@@ -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
|
-
|
|
44
|
-
|
|
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;
|
package/dist/signoff/plan.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
35
|
-
if (rule
|
|
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
|
|
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
|
}
|
package/dist/signoff/run.d.ts
CHANGED
|
@@ -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;
|
package/dist/signoff/run.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
213
|
+
// 7. Tail
|
|
177
214
|
try {
|
|
178
215
|
runTail({
|
|
179
216
|
sha: head,
|