javi-forge 1.13.0 → 1.14.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/dist/cli/dispatch/ci.js +27 -0
- package/dist/commands/ci.d.ts +46 -2
- package/dist/commands/ci.js +130 -12
- package/dist/lib/git-diff.d.ts +4 -1
- package/dist/lib/git-diff.js +14 -2
- package/package.json +1 -1
package/dist/cli/dispatch/ci.js
CHANGED
|
@@ -103,6 +103,33 @@ export async function handleCi(cli, ctx) {
|
|
|
103
103
|
: cli.flags.quick
|
|
104
104
|
? "quick"
|
|
105
105
|
: "full";
|
|
106
|
+
// Headless gate-run JSON (slice 4): `--json` on the RUN path is a NEW branch,
|
|
107
|
+
// NOT flag reuse — the flag is otherwise consumed only by `ci validate`. It
|
|
108
|
+
// bypasses the Ink render, drives the gate phase collecting structured
|
|
109
|
+
// outcomes, prints `{ ok, gates }`, and sets the process exit code EXPLICITLY
|
|
110
|
+
// (CI.tsx's error boundary is unreachable without a render, so this branch
|
|
111
|
+
// owns its exit code). `ok` is false iff a BLOCKING gate errored.
|
|
112
|
+
//
|
|
113
|
+
// JDA-A-002 / JDB-101: `ok` is deliberately scoped to blocking GATES (spec
|
|
114
|
+
// contract), so a blocking RUNNER/phase failure makes runCI throw yet leaves
|
|
115
|
+
// `ok:true`. A consumer keying on the object alone would misread that as
|
|
116
|
+
// success. Surfacing the top-level `exitCode` (non-zero on ANY run failure,
|
|
117
|
+
// including a crash) closes that gap without reinterpreting `ok`.
|
|
118
|
+
if (cli.flags.json) {
|
|
119
|
+
const { collectGateOutcomes } = await import("../../commands/ci.js");
|
|
120
|
+
const result = await collectGateOutcomes({
|
|
121
|
+
projectDir: process.cwd(),
|
|
122
|
+
mode: ciMode,
|
|
123
|
+
noDocker: !cli.flags.docker,
|
|
124
|
+
noGhagga: !cli.flags.ciGhagga,
|
|
125
|
+
noSecurity: !cli.flags.security,
|
|
126
|
+
timeout: cli.flags.timeout,
|
|
127
|
+
config: cli.flags.config || undefined,
|
|
128
|
+
stack: cli.flags.stack || undefined,
|
|
129
|
+
});
|
|
130
|
+
console.log(JSON.stringify({ ok: result.ok, exitCode: result.exitCode, gates: result.gates }, null, 2));
|
|
131
|
+
process.exit(result.exitCode);
|
|
132
|
+
}
|
|
106
133
|
render(React.createElement(CIContextProvider, { isCI: true },
|
|
107
134
|
React.createElement(CI, { projectDir: process.cwd(), mode: ciMode, noDocker: !cli.flags.docker, noGhagga: !cli.flags.ciGhagga, noSecurity: !cli.flags.security, timeout: cli.flags.timeout, config: cli.flags.config || undefined, stack: cli.flags.stack || undefined })), { stdin: ctx.inkStdin });
|
|
108
135
|
}
|
package/dist/commands/ci.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type CIGateConfig } from "../lib/ci-config.js";
|
|
1
|
+
import { type CIGateConfig, type GateMode, type GateScope } from "../lib/ci-config.js";
|
|
2
2
|
import type { Stack } from "../types/index.js";
|
|
3
3
|
export type CIMode = "full" | "quick" | "shell" | "detect";
|
|
4
4
|
export interface CIOptions {
|
|
@@ -79,7 +79,7 @@ export interface ResolveRunnerOptions {
|
|
|
79
79
|
* 3. otherwise → single auto-detected runner (zero-config default)
|
|
80
80
|
*/
|
|
81
81
|
export declare function resolveCIRunners(projectDir: string, options?: ResolveRunnerOptions): Promise<ResolvedRunners>;
|
|
82
|
-
export declare function runCI(options: CIOptions, onStep: CIStepCallback): Promise<void>;
|
|
82
|
+
export declare function runCI(options: CIOptions, onStep: CIStepCallback, onGateOutcome?: (outcome: GateOutcome) => void): Promise<void>;
|
|
83
83
|
/**
|
|
84
84
|
* Execute a single gate command HOST-NATIVE via `bash -c`, at the repo root,
|
|
85
85
|
* with the provided env MAP. Modeled on `runSemgrep`/`runGhagga` (a spawned
|
|
@@ -100,6 +100,50 @@ export declare function runCI(options: CIOptions, onStep: CIStepCallback): Promi
|
|
|
100
100
|
* `bash -c` command — so metacharacters in a value cannot break out of the shell.
|
|
101
101
|
*/
|
|
102
102
|
export declare function runGateNative(cmd: string, cwd: string, env: Record<string, string>): Promise<number>;
|
|
103
|
+
/**
|
|
104
|
+
* A single gate's structured result, collected for the headless JSON run path.
|
|
105
|
+
* Mirrors the `{ id, mode, scope, status, blocking, changedFiles?, exitCode? }`
|
|
106
|
+
* JSON shape.
|
|
107
|
+
*/
|
|
108
|
+
export interface GateOutcome {
|
|
109
|
+
id: string;
|
|
110
|
+
mode: GateMode;
|
|
111
|
+
scope: GateScope;
|
|
112
|
+
status: CIStepStatus;
|
|
113
|
+
/** `true` when `mode === blocking` — an errored blocking gate drives `ok:false`. */
|
|
114
|
+
blocking: boolean;
|
|
115
|
+
/** The changed-file set a scope:changed gate saw (present only when resolved). */
|
|
116
|
+
changedFiles?: string[];
|
|
117
|
+
/** First non-zero command code for a failed gate. */
|
|
118
|
+
exitCode?: number;
|
|
119
|
+
/**
|
|
120
|
+
* Human-readable cause of a degrade/skip, surfaced so the headless JSON
|
|
121
|
+
* consumer sees the degrade LOUDLY — not just in the Ink stream. Populated for
|
|
122
|
+
* the scope:changed skip variants: base ref null, changed-file resolution
|
|
123
|
+
* failure (shallow clone / missing ref), and the empty changed-set skip.
|
|
124
|
+
*/
|
|
125
|
+
reason?: string;
|
|
126
|
+
}
|
|
127
|
+
/** Structured result of a headless (`--json`) gate run. */
|
|
128
|
+
export interface HeadlessGateResult {
|
|
129
|
+
/** `false` iff a BLOCKING gate errored; informative failures keep it `true`. */
|
|
130
|
+
ok: boolean;
|
|
131
|
+
gates: GateOutcome[];
|
|
132
|
+
/** The process exit code to set explicitly (1 on a blocking failure or crash). */
|
|
133
|
+
exitCode: number;
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Drive `runCI` headlessly (no Ink render), collecting each gate's structured
|
|
137
|
+
* outcome for the `--json` run path. `runCI` throws on a blocking gate failure
|
|
138
|
+
* (and on any non-gate error); the outcomes are captured regardless via the
|
|
139
|
+
* `onOutcome` callback, so the JSON is always complete.
|
|
140
|
+
*
|
|
141
|
+
* `ok` is `false` iff a BLOCKING gate errored (spec contract); informative
|
|
142
|
+
* failures keep `ok:true`. `exitCode` is `1` when a blocking gate errored OR
|
|
143
|
+
* `runCI` threw for any other reason (a real crash still exits non-zero), else
|
|
144
|
+
* `0`. The caller (dispatch) prints `{ ok, gates }` and sets `process.exitCode`.
|
|
145
|
+
*/
|
|
146
|
+
export declare function collectGateOutcomes(options: CIOptions): Promise<HeadlessGateResult>;
|
|
103
147
|
/**
|
|
104
148
|
* Classification of an existing `.git/hooks/<name>` before anything is written
|
|
105
149
|
* (design D6). Every state has exactly one write policy, so no hook is ever
|
package/dist/commands/ci.js
CHANGED
|
@@ -6,10 +6,11 @@ import os from "node:os";
|
|
|
6
6
|
import path from "node:path";
|
|
7
7
|
import fs from "fs-extra";
|
|
8
8
|
import { HOOK_ASSETS_DIR } from "../constants.js";
|
|
9
|
-
import { CI_STACKS, findCIConfig, GATE_MODE, loadCIConfig, } from "../lib/ci-config.js";
|
|
9
|
+
import { CI_STACKS, findCIConfig, GATE_MODE, GATE_SCOPE, loadCIConfig, } from "../lib/ci-config.js";
|
|
10
10
|
import { refreshContextDir } from "../lib/context.js";
|
|
11
11
|
import { ensureImage, isDockerAvailable, openShell, runInContainer, } from "../lib/docker.js";
|
|
12
12
|
import { execFileAsync } from "../lib/exec.js";
|
|
13
|
+
import { changedFiles, resolveBaseRef } from "../lib/git-diff.js";
|
|
13
14
|
// =============================================================================
|
|
14
15
|
// Stack detection
|
|
15
16
|
// =============================================================================
|
|
@@ -313,7 +314,7 @@ function describeRunners(resolved) {
|
|
|
313
314
|
.join(", ");
|
|
314
315
|
return `Config: ${resolved.runners.length} runner(s) — ${summary}`;
|
|
315
316
|
}
|
|
316
|
-
export async function runCI(options, onStep) {
|
|
317
|
+
export async function runCI(options, onStep, onGateOutcome) {
|
|
317
318
|
const { projectDir = process.cwd(), mode = "full", noDocker = false, noGhagga = false, noSecurity = false, timeout = 600, } = options;
|
|
318
319
|
// ── Resolve runners (once — nothing downstream re-detects) ─────────────────
|
|
319
320
|
const stepDetect = "detect";
|
|
@@ -342,7 +343,7 @@ export async function runCI(options, onStep) {
|
|
|
342
343
|
report(onStep, mode, `${mode} mode`, "error", detail);
|
|
343
344
|
throw new Error(`no runners resolved — ${mode} mode requires at least one runner`);
|
|
344
345
|
}
|
|
345
|
-
await runGates(resolved.gates, projectDir, onStep);
|
|
346
|
+
await runGates(resolved.gates, projectDir, onStep, onGateOutcome);
|
|
346
347
|
return;
|
|
347
348
|
}
|
|
348
349
|
// Legacy single-runner view for the zero-config auto path. Keeping this
|
|
@@ -514,7 +515,7 @@ export async function runCI(options, onStep) {
|
|
|
514
515
|
// already `full` or `quick` here; the guard makes the contract explicit.
|
|
515
516
|
// `runGates` no-ops on an empty gate list (a v1 repo carries none).
|
|
516
517
|
if (mode === "full" || mode === "quick") {
|
|
517
|
-
await runGates(resolved.gates, projectDir, onStep);
|
|
518
|
+
await runGates(resolved.gates, projectDir, onStep, onGateOutcome);
|
|
518
519
|
}
|
|
519
520
|
}
|
|
520
521
|
// =============================================================================
|
|
@@ -792,6 +793,19 @@ export async function runGateNative(cmd, cwd, env) {
|
|
|
792
793
|
proc.on("error", reject);
|
|
793
794
|
});
|
|
794
795
|
}
|
|
796
|
+
/**
|
|
797
|
+
* Env var carrying a scope:changed gate's newline-joined, root-relative paths.
|
|
798
|
+
*
|
|
799
|
+
* KNOWN LIMITATION (JDB-103): the list is newline-joined, so a path that itself
|
|
800
|
+
* contains a literal `\n` (git can emit such a path when `core.quotePath` is off)
|
|
801
|
+
* would corrupt line-based parsing on the gate side. This is a low-likelihood
|
|
802
|
+
* edge — repo paths with embedded newlines are pathological — and is accepted as
|
|
803
|
+
* a documented caveat rather than switched to NUL-joining, which would force
|
|
804
|
+
* every gate consumer to change its parser.
|
|
805
|
+
*/
|
|
806
|
+
const CHANGED_FILES_ENV = "JAVI_FORGE_CHANGED_FILES";
|
|
807
|
+
/** Env var carrying a gate's optional baseline artifact path. */
|
|
808
|
+
const BASELINE_ENV = "JAVI_FORGE_BASELINE";
|
|
795
809
|
/**
|
|
796
810
|
* Repo-level gate phase. Each gate runs host-native via `runGateNative` at the
|
|
797
811
|
* repo root. Outcome semantics:
|
|
@@ -808,29 +822,101 @@ export async function runGateNative(cmd, cwd, env) {
|
|
|
808
822
|
* Multi-command gates run in order and STOP at the first non-zero exit
|
|
809
823
|
* (fail-fast, matching the runner precedent); that first code is reported.
|
|
810
824
|
*
|
|
811
|
-
*
|
|
812
|
-
*
|
|
813
|
-
*
|
|
825
|
+
* `scope: changed` consumes the injectable `git-diff.ts` engine: the base ref is
|
|
826
|
+
* resolved and the changed set computed ONCE, then shared. A non-empty set runs
|
|
827
|
+
* the gate with `$JAVI_FORGE_CHANGED_FILES` (newline-joined, root-relative); an
|
|
828
|
+
* empty set skips the gate; a null base OR a `changedFiles` throw skips every
|
|
829
|
+
* scope:changed gate with a named warning (loud-degrade, never widens, never
|
|
830
|
+
* crashes). `baseline` is injected as `$JAVI_FORGE_BASELINE`. Gate `env` spreads
|
|
831
|
+
* LAST (documented last-wins over the engine-injected keys).
|
|
832
|
+
*
|
|
833
|
+
* `onOutcome` (optional) receives each gate's structured result for the headless
|
|
834
|
+
* JSON run path.
|
|
814
835
|
*/
|
|
815
|
-
async function runGates(gates, projectDir, onStep) {
|
|
836
|
+
async function runGates(gates, projectDir, onStep, onOutcome) {
|
|
816
837
|
if (gates.length === 0)
|
|
817
838
|
return;
|
|
818
839
|
const blockingFailures = [];
|
|
819
|
-
// Engine-injected keys only in slice 3; gate `env` + JAVI_FORGE_CHANGED_FILES
|
|
820
|
-
// are added in slice 4.
|
|
821
840
|
const baseEnv = {
|
|
822
841
|
...filterDefinedEnv(process.env),
|
|
823
842
|
CI: "true",
|
|
824
843
|
};
|
|
844
|
+
// Lazily resolved and memoized — computed only when a scope:changed gate
|
|
845
|
+
// exists, and only once (shared across all scope:changed gates).
|
|
846
|
+
let changedScope = null;
|
|
847
|
+
const resolveChangedScope = async () => {
|
|
848
|
+
if (changedScope !== null)
|
|
849
|
+
return changedScope;
|
|
850
|
+
const base = await resolveBaseRef(process.env, projectDir);
|
|
851
|
+
if (base === null) {
|
|
852
|
+
changedScope = {
|
|
853
|
+
kind: "skip",
|
|
854
|
+
reason: "no base ref resolved — skipping scope:changed",
|
|
855
|
+
};
|
|
856
|
+
return changedScope;
|
|
857
|
+
}
|
|
858
|
+
try {
|
|
859
|
+
changedScope = {
|
|
860
|
+
kind: "files",
|
|
861
|
+
files: await changedFiles(base, projectDir),
|
|
862
|
+
};
|
|
863
|
+
}
|
|
864
|
+
catch {
|
|
865
|
+
// Shallow clone / base sha absent from local history: caught here so the
|
|
866
|
+
// throw never aborts the phase and the gate never widens to scope:all.
|
|
867
|
+
changedScope = {
|
|
868
|
+
kind: "skip",
|
|
869
|
+
reason: "changed-file diff failed (shallow clone / missing ref) — skipping scope:changed",
|
|
870
|
+
};
|
|
871
|
+
}
|
|
872
|
+
return changedScope;
|
|
873
|
+
};
|
|
825
874
|
for (const gate of gates) {
|
|
826
875
|
const stepId = `gate:${gate.id}`;
|
|
827
876
|
const label = `Gate: ${gate.id}`;
|
|
877
|
+
const blocking = gate.mode === GATE_MODE.BLOCKING;
|
|
878
|
+
const emit = (status, extra) => onOutcome?.({
|
|
879
|
+
id: gate.id,
|
|
880
|
+
mode: gate.mode,
|
|
881
|
+
scope: gate.scope,
|
|
882
|
+
status,
|
|
883
|
+
blocking,
|
|
884
|
+
...extra,
|
|
885
|
+
});
|
|
828
886
|
report(onStep, stepId, label, "running");
|
|
887
|
+
// Per-gate env map: engine keys, then baseline, then gate.env LAST (last-wins).
|
|
888
|
+
const gateEnv = { ...baseEnv };
|
|
889
|
+
let gateChangedFiles;
|
|
890
|
+
if (gate.scope === GATE_SCOPE.CHANGED) {
|
|
891
|
+
const scope = await resolveChangedScope();
|
|
892
|
+
if (scope.kind === "skip") {
|
|
893
|
+
report(onStep, stepId, `${label} skipped`, "skipped", scope.reason);
|
|
894
|
+
// Loud-degrade: carry the named reason into the JSON outcome too, not
|
|
895
|
+
// only the Ink `onStep` stream (which is a no-op under --json).
|
|
896
|
+
emit("skipped", { reason: scope.reason });
|
|
897
|
+
continue;
|
|
898
|
+
}
|
|
899
|
+
if (scope.files.length === 0) {
|
|
900
|
+
const noChanges = "no changed files";
|
|
901
|
+
report(onStep, stepId, `${label} skipped`, "skipped", noChanges);
|
|
902
|
+
emit("skipped", { changedFiles: [], reason: noChanges });
|
|
903
|
+
continue;
|
|
904
|
+
}
|
|
905
|
+
gateChangedFiles = scope.files;
|
|
906
|
+
gateEnv[CHANGED_FILES_ENV] = scope.files.join("\n");
|
|
907
|
+
}
|
|
908
|
+
if (gate.baseline !== undefined) {
|
|
909
|
+
gateEnv[BASELINE_ENV] = gate.baseline;
|
|
910
|
+
}
|
|
911
|
+
if (gate.env !== undefined) {
|
|
912
|
+
// Gate env spreads LAST — a gate MAY override CI / CHANGED_FILES / BASELINE.
|
|
913
|
+
Object.assign(gateEnv, gate.env);
|
|
914
|
+
}
|
|
829
915
|
let exitCode = 0;
|
|
830
916
|
let spawnError;
|
|
831
917
|
try {
|
|
832
918
|
for (const cmd of gate.run) {
|
|
833
|
-
exitCode = await runGateNative(cmd, projectDir,
|
|
919
|
+
exitCode = await runGateNative(cmd, projectDir, gateEnv);
|
|
834
920
|
if (exitCode !== 0)
|
|
835
921
|
break; // fail-fast: skip the remaining commands
|
|
836
922
|
}
|
|
@@ -840,21 +926,53 @@ async function runGates(gates, projectDir, onStep) {
|
|
|
840
926
|
}
|
|
841
927
|
if (spawnError === undefined && exitCode === 0) {
|
|
842
928
|
report(onStep, stepId, `${label} passed`, "done");
|
|
929
|
+
emit("done", { changedFiles: gateChangedFiles });
|
|
843
930
|
continue;
|
|
844
931
|
}
|
|
845
932
|
const detail = spawnError !== undefined ? String(spawnError) : `exit ${exitCode}`;
|
|
846
|
-
if (
|
|
933
|
+
if (blocking) {
|
|
847
934
|
blockingFailures.push(gate.id);
|
|
848
935
|
report(onStep, stepId, `${label} failed`, "error", detail);
|
|
936
|
+
emit("error", { changedFiles: gateChangedFiles, exitCode });
|
|
849
937
|
}
|
|
850
938
|
else {
|
|
851
939
|
report(onStep, stepId, `${label} failed (informative)`, "warning", detail);
|
|
940
|
+
emit("warning", { changedFiles: gateChangedFiles, exitCode });
|
|
852
941
|
}
|
|
853
942
|
}
|
|
854
943
|
if (blockingFailures.length > 0) {
|
|
855
944
|
throw new Error(`blocking gate(s) failed: ${blockingFailures.join(", ")}`);
|
|
856
945
|
}
|
|
857
946
|
}
|
|
947
|
+
/**
|
|
948
|
+
* Drive `runCI` headlessly (no Ink render), collecting each gate's structured
|
|
949
|
+
* outcome for the `--json` run path. `runCI` throws on a blocking gate failure
|
|
950
|
+
* (and on any non-gate error); the outcomes are captured regardless via the
|
|
951
|
+
* `onOutcome` callback, so the JSON is always complete.
|
|
952
|
+
*
|
|
953
|
+
* `ok` is `false` iff a BLOCKING gate errored (spec contract); informative
|
|
954
|
+
* failures keep `ok:true`. `exitCode` is `1` when a blocking gate errored OR
|
|
955
|
+
* `runCI` threw for any other reason (a real crash still exits non-zero), else
|
|
956
|
+
* `0`. The caller (dispatch) prints `{ ok, gates }` and sets `process.exitCode`.
|
|
957
|
+
*/
|
|
958
|
+
export async function collectGateOutcomes(options) {
|
|
959
|
+
const gates = [];
|
|
960
|
+
let threw = false;
|
|
961
|
+
try {
|
|
962
|
+
await runCI(options, () => { }, (outcome) => gates.push(outcome));
|
|
963
|
+
}
|
|
964
|
+
catch {
|
|
965
|
+
// Blocking failure (aggregate throw) or a non-gate error — outcomes are
|
|
966
|
+
// already captured; the headless path never propagates the throw.
|
|
967
|
+
threw = true;
|
|
968
|
+
}
|
|
969
|
+
const blockingErrored = gates.some((g) => g.blocking && g.status === "error");
|
|
970
|
+
return {
|
|
971
|
+
ok: !blockingErrored,
|
|
972
|
+
gates,
|
|
973
|
+
exitCode: blockingErrored || threw ? 1 : 0,
|
|
974
|
+
};
|
|
975
|
+
}
|
|
858
976
|
// =============================================================================
|
|
859
977
|
// CI Hooks Installation
|
|
860
978
|
// =============================================================================
|
package/dist/lib/git-diff.d.ts
CHANGED
|
@@ -3,7 +3,10 @@
|
|
|
3
3
|
* 1. `$CI_MERGE_REQUEST_DIFF_BASE_SHA` (GitLab MR) when non-empty.
|
|
4
4
|
* 2. `$CI_COMMIT_BEFORE_SHA` (GitLab push) when non-empty AND not the
|
|
5
5
|
* all-zeros new-branch sentinel.
|
|
6
|
-
* 3. `$GITHUB_BASE_REF` (GitHub Actions PR) → `git merge-base origin/<ref> HEAD
|
|
6
|
+
* 3. `$GITHUB_BASE_REF` (GitHub Actions PR) → `git merge-base origin/<ref> HEAD`;
|
|
7
|
+
* else on a push, `$GITHUB_EVENT_BEFORE` (github.event.before — the real push
|
|
8
|
+
* base, unless the all-zeros new-branch sentinel) before falling to
|
|
9
|
+
* `$GITHUB_SHA` (which equals HEAD after actions/checkout → empty diff).
|
|
7
10
|
* 4. Local fallback: `git merge-base <candidate> HEAD` over
|
|
8
11
|
* `origin/main`, `origin/master`, `main`, `master` — first that resolves.
|
|
9
12
|
* 5. Nothing resolves → `null` (caller loud-degrades).
|
package/dist/lib/git-diff.js
CHANGED
|
@@ -52,7 +52,10 @@ async function tryMergeBase(ref, cwd) {
|
|
|
52
52
|
* 1. `$CI_MERGE_REQUEST_DIFF_BASE_SHA` (GitLab MR) when non-empty.
|
|
53
53
|
* 2. `$CI_COMMIT_BEFORE_SHA` (GitLab push) when non-empty AND not the
|
|
54
54
|
* all-zeros new-branch sentinel.
|
|
55
|
-
* 3. `$GITHUB_BASE_REF` (GitHub Actions PR) → `git merge-base origin/<ref> HEAD
|
|
55
|
+
* 3. `$GITHUB_BASE_REF` (GitHub Actions PR) → `git merge-base origin/<ref> HEAD`;
|
|
56
|
+
* else on a push, `$GITHUB_EVENT_BEFORE` (github.event.before — the real push
|
|
57
|
+
* base, unless the all-zeros new-branch sentinel) before falling to
|
|
58
|
+
* `$GITHUB_SHA` (which equals HEAD after actions/checkout → empty diff).
|
|
56
59
|
* 4. Local fallback: `git merge-base <candidate> HEAD` over
|
|
57
60
|
* `origin/main`, `origin/master`, `main`, `master` — first that resolves.
|
|
58
61
|
* 5. Nothing resolves → `null` (caller loud-degrades).
|
|
@@ -68,12 +71,21 @@ export async function resolveBaseRef(env, cwd) {
|
|
|
68
71
|
return env.CI_COMMIT_BEFORE_SHA;
|
|
69
72
|
}
|
|
70
73
|
// 3. GitHub Actions — a PR sets GITHUB_BASE_REF (merge-base against the
|
|
71
|
-
// target branch)
|
|
74
|
+
// target branch). A push has no base ref: prefer github.event.before
|
|
75
|
+
// (GITHUB_EVENT_BEFORE — the commit the branch pointed at before the push,
|
|
76
|
+
// the real diff base), unless it is the all-zeros new-branch sentinel, and
|
|
77
|
+
// only then fall back to GITHUB_SHA (which equals HEAD after
|
|
78
|
+
// actions/checkout → an empty diff → scope:changed gates would silently
|
|
79
|
+
// skip; a visible skip, not a false-green — the before-sha avoids it).
|
|
72
80
|
if (isNonEmpty(env.GITHUB_BASE_REF)) {
|
|
73
81
|
const base = await tryMergeBase(`origin/${env.GITHUB_BASE_REF}`, cwd);
|
|
74
82
|
if (base !== null)
|
|
75
83
|
return base;
|
|
76
84
|
}
|
|
85
|
+
else if (isNonEmpty(env.GITHUB_EVENT_BEFORE) &&
|
|
86
|
+
env.GITHUB_EVENT_BEFORE !== NEW_BRANCH_SENTINEL) {
|
|
87
|
+
return env.GITHUB_EVENT_BEFORE;
|
|
88
|
+
}
|
|
77
89
|
else if (isNonEmpty(env.GITHUB_SHA)) {
|
|
78
90
|
return env.GITHUB_SHA;
|
|
79
91
|
}
|