Skip to content

Commit 18c0ffb

Browse files
committed
feat(diff): identify a repository by remote, not by absolute path
A baseline is only useful in CI if it can be pinned on one machine and graded on another. Comparability answered "are these the same repository?" by comparing absolute RepoPath, which answers a question about location instead. A baseline pinned at /home/runner/work/app/app and restored against /Users/dev/src/app always raised different_repo, which the gate treats as blocking — so a restored baseline artifact could never grade. The delta underneath was correct the whole time; only the verdict was wrong. Record the origin remote on GitInfo, normalized to host/path with scheme, credentials, port and a trailing .git removed, and decide sameness from it when both sides have one, falling back to the checkout directory name. Normalization is what makes this work in practice: a runner cloning over HTTPS with an injected token and a developer cloning over SSH are looking at one repository, and an unnormalized comparison would decline to grade every CI diff. A remote rather than the root commit. `git rev-list --max-parents=0` is the purer identity and works locally, but it is unreachable in a shallow clone, which is what CI checkouts default to — it would fail in exactly the environment this targets. Git stays a witness: one more read-only call beside the three already there, degrading to empty the same way. Two details that only show up across machines: - Remotes are normalized again on read, not just at capture, so a baseline written by an older build — carrying a raw URL, or no remote at all — still compares correctly. A baseline outliving the build that wrote it is the point of a portable artifact. - The directory-name fallback accepts either separator. A baseline written on a Linux runner and read on Windows carries the separator of the machine that wrote it, while filepath.Base uses the reader's, so one direction would compare a whole path against a single segment and always mismatch. The mismatch message names which signal decided and keeps both paths: a remote mismatch means the wrong baseline was fetched, a name mismatch on one repository means a checkout was renamed, and the remedies differ. Also: - CI gains an advisory `architecture` job: enola grading enola on every PR, reporting the verdict to the step summary and never failing the build - the example workflow becomes publish-on-main / restore-on-PR, so the base branch is never re-indexed and no merge-base checkout is needed - baseline show reports the remote — after a restore, "which repo is this?" is no longer obvious from the path
1 parent 74256e7 commit 18c0ffb

11 files changed

Lines changed: 569 additions & 37 deletions

File tree

.github/workflows/ci.yml

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,93 @@ jobs:
5353
- name: Golden + determinism
5454
run: go test -count=1 -run 'TestGolden|TestDeterminism' ./internal/engine/...
5555

56+
# Enola grading Enola: the gate this repo ships, run on this repo's own changes.
57+
#
58+
# ADVISORY — this job reports the verdict and always succeeds. It is here to prove the
59+
# gate works on a real PR stream and to surface structural regressions for a human to
60+
# judge, not to block merges yet. To make it enforcing, delete the `exit 0` at the end
61+
# of "Grade the change" (see the comment there).
62+
#
63+
# The baseline comes from the PR's own merge base rather than a published artifact.
64+
# That costs one extra index — which for this repo is ~200ms — and in exchange needs no
65+
# cross-workflow artifact plumbing, no third-party action, and no push trigger. A repo
66+
# large enough for that trade to hurt should use the publish/restore shape in
67+
# examples/ci/architecture-gate.yml instead.
68+
architecture:
69+
runs-on: ubuntu-latest
70+
steps:
71+
- uses: actions/checkout@v4
72+
with:
73+
fetch-depth: 0 # merge-base needs history; the default depth-1 clone has none
74+
75+
- name: Set up Go
76+
uses: actions/setup-go@v5
77+
with:
78+
go-version-file: go.mod
79+
cache: true
80+
81+
# Built ONCE, from the PR head, and kept outside the tree so it survives the
82+
# checkouts below. Using one binary for both snapshots keeps the enola version and
83+
# config hash identical on the two sides — rebuilding at the merge base would make
84+
# them differ and the gate would (correctly) decline to grade.
85+
- name: Build enola from this PR
86+
run: go build -o /tmp/enola ./cmd/enola
87+
88+
- name: Pin a baseline from the merge base
89+
run: |
90+
# Be explicit about fetching the base branch: actions/checkout leaves a PR on a
91+
# merge ref, and origin/<base> is not guaranteed to be present.
92+
git fetch --no-tags --quiet origin \
93+
"+refs/heads/${{ github.base_ref }}:refs/remotes/origin/${{ github.base_ref }}"
94+
95+
# Record the head as an explicit SHA: `git checkout -` is unreliable after a
96+
# --detach, and returning to the wrong commit would grade the base against
97+
# itself and always report clean.
98+
head=$(git rev-parse HEAD)
99+
base=$(git merge-base "$head" "origin/${{ github.base_ref }}")
100+
echo "Baseline: $base"
101+
echo "PR head: $head"
102+
103+
git checkout --quiet --detach "$base"
104+
/tmp/enola baseline pin
105+
# .enola/ is gitignored, so the pinned baseline survives the checkout back.
106+
git checkout --quiet --detach "$head"
107+
108+
- name: Grade the change
109+
run: |
110+
# Run without --warn-only so the verdict text stays honest about what the
111+
# policy WOULD do; the job's advisory status comes from the exit 0 below.
112+
set +e
113+
/tmp/enola check 2>/dev/null | tee verdict.txt
114+
code=${PIPESTATUS[0]}
115+
set -e
116+
117+
{
118+
echo '### Architecture'
119+
case "$code" in
120+
0) echo 'No structural regression.' ;;
121+
1) echo '**Structural regression introduced** — advisory for now, see the verdict below.' ;;
122+
2) echo 'The gate could not run (exit 2).' ;;
123+
3) echo 'Declined to grade: the baseline was not comparable (exit 3). Not a statement about this change.' ;;
124+
*) echo "Unexpected exit $code." ;;
125+
esac
126+
echo '```'
127+
cat verdict.txt
128+
echo '```'
129+
} >> "$GITHUB_STEP_SUMMARY"
130+
131+
# Advisory: report, never block. Delete this line to make the gate enforcing —
132+
# the exit code above is already the verdict.
133+
exit 0
134+
135+
- name: Upload the verdict
136+
if: always()
137+
uses: actions/upload-artifact@v4
138+
with:
139+
name: architecture-verdict
140+
path: verdict.txt
141+
if-no-files-found: ignore
142+
56143
# golangci-lint v2 (action @v8). The repo baseline is clean, so this gates the
57144
# whole tree — any new finding fails the build. Linter set in .golangci.yml.
58145
lint:

ARCHITECTURE.md

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -595,7 +595,7 @@ The engine lives in [`internal/diff`](internal/diff/diff.go) (pure `Compute` + d
595595

596596
| Kind | Raised when | Treated as |
597597
|------|-------------|-----------|
598-
| `different_repo` | the two snapshots are of different repositories | blocking |
598+
| `different_repo` | the two snapshots are of different repositories (see [Repository identity](#repository-identity-portable-baselines)*not* merely a different path) | blocking |
599599
| `version_mismatch` | different enola versions (extractor changes read as churn) | blocking |
600600
| `extractor_set` | a language present on one side only | blocking |
601601
| `ignore_globs` | the set of files parsed changed | blocking |
@@ -608,6 +608,19 @@ The engine lives in [`internal/diff`](internal/diff/diff.go) (pure `Compute` + d
608608

609609
> Note on timestamps: `GeneratedAt` is RFC3339, i.e. **second** resolution, so a baseline pinned and then diffed inside the same second yields a zero gap. Zero is *simultaneous*, not inverted — `inverted_pair` requires a strictly negative gap. Treating zero as inverted made a no-op check on an untouched repository report "the current snapshot does not contain your change".
610610
611+
#### Repository identity (portable baselines)
612+
613+
A baseline is only useful in CI if it can be **pinned on one machine and graded on another**. That makes "are these the same repository?" a question about *identity*, not *location* — and comparing absolute `RepoPath` answered it with location. A baseline pinned at `/home/runner/work/app/app` and restored against `/Users/dev/src/app` always tripped `different_repo`, which the gate treats as blocking, so a downloaded baseline artifact could never grade. (The delta underneath was correct the whole time; only the verdict was wrong.)
614+
615+
`facts.SameRepo` ([`internal/facts/repoidentity.go`](internal/facts/repoidentity.go)) decides it from two signals, strongest first:
616+
617+
1. **Normalized git remotes**, when both snapshots have one. `facts.NormalizeRemote` reduces a remote URL to `host/path` — scheme, credentials, port and a trailing `.git` removed, lowercased — so every way of cloning one repository collapses to one identity: `git@github.com:org/app.git`, `https://github.com/org/app`, and a CI URL carrying an injected token all normalize to `github.com/org/app`. Without that, a runner cloning over HTTPS and a developer over SSH would read as two repositories.
618+
2. **The checkout directory name**, otherwise. Weaker — two unrelated repositories both checked out as `api/` look alike — but it is what exists for a repo with no remote, and it is strictly better than the absolute path it replaces. It compares the last segment across *either* separator, because a baseline written on a Linux runner and read on Windows carries the separator of the machine that **wrote** it.
619+
620+
`GitInfo.Remote` is populated by one more read-only `git remote get-url origin` alongside the three calls already in `gitInfo()`, and degrades to empty exactly as they do (no git, no repo, no origin). It is normalized again on read, so a baseline written by an older build — carrying a raw URL, or no remote at all — still compares correctly. **A remote rather than the root commit**: `git rev-list --max-parents=0` looks like the purer identity, but it is unreachable in a shallow clone, which is what `actions/checkout` does by default.
621+
622+
Both absolute paths stay in the warning text, and it names which signal decided, because the remedies differ: differing remotes mean the wrong baseline was fetched; differing directory names on one repository mean a checkout was renamed.
623+
611624
---
612625

613626
### The gate (`pkg/check`) — `diff_snapshot` as an exit code

README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -697,7 +697,9 @@ New coupling (4):
697697

698698
Lists cap at 12 entries with a `--detail` pointer, and `declares` edges - the mechanical one-per-new-symbol link to their module - always sort last, since they say nothing about what got coupled.
699699

700-
Ready-made wiring: [`examples/hooks/pre-commit`](examples/hooks/pre-commit) (blocks only on exit `1`; a missing or incomparable baseline skips the gate rather than blocking someone over setup they haven't done) and [`examples/ci/architecture-gate.yml`](examples/ci/architecture-gate.yml) (a GitHub Action that pins a baseline from the PR's merge base).
700+
**Baselines are portable.** A baseline is identified by the repository's normalized git remote (falling back to the checkout directory name), not by the absolute path it was pinned at - so one pinned on a CI runner grades against a checkout anywhere else. That's what makes the CI shape cheap: the default branch publishes `.enola/baseline/` once, every PR restores it and diffs against it, and no job ever indexes the base a second time.
701+
702+
Ready-made wiring: [`examples/hooks/pre-commit`](examples/hooks/pre-commit) (blocks only on exit `1`; a missing or incomparable baseline skips the gate rather than blocking someone over setup they haven't done) and [`examples/ci/architecture-gate.yml`](examples/ci/architecture-gate.yml) (publish-on-main, restore-on-PR).
701703

702704
### What it saved you - `--status`
703705

cmd/enola/check.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -324,6 +324,12 @@ func describeBaseline(snap *facts.Snapshot) {
324324
state = "dirty (uncommitted changes)"
325325
}
326326
fmt.Printf(" Git: %s @ %s — %s\n", orUnknown(r.Git.Ref), shortCommit(r.Git.Commit), state)
327+
// The repository identity a restored baseline is matched on. Shown because
328+
// "which repo does this baseline describe?" is the question `show` exists to
329+
// answer, and after an import the answer is no longer obvious from the path.
330+
if r.Git.Remote != "" {
331+
fmt.Printf(" Remote: %s\n", r.Git.Remote)
332+
}
327333
}
328334
fmt.Printf(" Facts: %d · Insights: %d\n", len(snap.Facts), len(snap.Insights))
329335
fmt.Printf(" Snapshot: %s\n", orUnknown(m.Receipt().SnapshotID))

examples/ci/architecture-gate.yml

Lines changed: 62 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -6,68 +6,98 @@
66
#
77
# HOW THE BASELINE GETS HERE
88
# --------------------------
9-
# The gate needs something to compare against. This workflow pins a baseline from the PR's
10-
# own merge-base checkout, which requires no artifact plumbing and works on a fresh clone.
9+
# The gate needs a "before" to compare against. The default branch publishes its snapshot
10+
# once; every PR restores that artifact and diffs against it. The PR job therefore indexes
11+
# the tree ONCE — it never re-indexes the base — and needs no merge-base checkout and no
12+
# full-history clone.
1113
#
12-
# `fetch-depth: 0` is required. actions/checkout defaults to a depth-1 clone, which has no
13-
# merge-base to check out, and the gate would silently have nothing to compare against.
14+
# This works because a baseline is portable: `enola check` identifies a repository by its
15+
# normalized git remote (falling back to the checkout directory name), not by the absolute
16+
# path it was snapshotted at, so a baseline pinned on one runner grades against a checkout
17+
# anywhere else.
1418
#
15-
# A faster arrangement — publish the snapshot from the default branch once and restore it
16-
# here instead of re-indexing the base — needs baselines to survive moving between
17-
# machines, which they do not yet: comparability compares the absolute repo path, so a
18-
# baseline pinned under one checkout path declines to grade under another.
19+
# Sizing: roughly 550 KB of gzipped artifact per ~17k facts. For a very large monorepo,
20+
# prefer actions/cache over a per-run artifact.
1921
name: architecture
2022

2123
on:
24+
push:
25+
branches: [main]
2226
pull_request:
2327

2428
jobs:
25-
gate:
29+
# Publishes the baseline every time the default branch moves. PRs consume the most
30+
# recent successful run of this job.
31+
publish-baseline:
32+
if: github.event_name == 'push'
2633
runs-on: ubuntu-latest
2734
steps:
2835
- uses: actions/checkout@v4
29-
with:
30-
fetch-depth: 0
3136

3237
- name: Install enola
3338
run: |
3439
curl -fsSL https://raw.githubusercontent.com/enola-labs/enola/main/install.sh | sh
3540
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
3641
37-
- name: Pin a baseline from the merge base
38-
run: |
39-
# Record the PR head as an explicit SHA before moving: `git checkout -` is
40-
# unreliable after a --detach, and returning to the wrong commit would make the
41-
# gate grade the base branch against itself and always report clean.
42-
head=$(git rev-parse HEAD)
43-
base=$(git merge-base "$head" "origin/${{ github.base_ref }}")
44-
echo "Baseline commit: $base"
45-
echo "PR head: $head"
42+
- name: Pin the baseline
43+
run: enola baseline pin
44+
45+
- uses: actions/upload-artifact@v4
46+
with:
47+
name: enola-baseline
48+
path: .enola/baseline/
49+
retention-days: 30
50+
51+
gate:
52+
if: github.event_name == 'pull_request'
53+
runs-on: ubuntu-latest
54+
steps:
55+
- uses: actions/checkout@v4
4656

47-
git checkout --quiet --detach "$base"
48-
enola --generate
49-
enola baseline pin
57+
- name: Install enola
58+
run: |
59+
curl -fsSL https://raw.githubusercontent.com/enola-labs/enola/main/install.sh | sh
60+
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
5061
51-
# .enola/ is untracked, so the pinned baseline survives the checkout back.
52-
git checkout --quiet --detach "$head"
62+
# The baseline comes from the newest successful publish-baseline run on the default
63+
# branch. `continue-on-error` covers the first-ever run, before any baseline has been
64+
# published — see the next step.
65+
- name: Fetch the published baseline
66+
id: baseline
67+
continue-on-error: true
68+
uses: dawidd6/action-download-artifact@v6
69+
with:
70+
workflow: architecture.yml
71+
branch: main
72+
name: enola-baseline
73+
path: baseline
74+
if_no_artifact_found: fail
5375

5476
- name: Grade the change
55-
run: enola check
77+
run: |
78+
if [ "${{ steps.baseline.outcome }}" != "success" ]; then
79+
echo "No published baseline yet — skipping the gate."
80+
echo "It will start enforcing once publish-baseline has run on the default branch."
81+
exit 0
82+
fi
83+
enola check --baseline baseline
5684
5785
# `enola check` exit codes:
5886
# 0 clean · 1 regression (job fails) · 2 error · 3 declined (not comparable)
5987
#
60-
# Codes 2 and 3 fail the job here by design: in CI they mean the gate did not
61-
# actually run, and a gate that silently passes when it could not run is worse than
62-
# no gate. Add `|| [ $? -eq 3 ]` if you would rather treat "declined" as a pass
63-
# while rolling this out.
88+
# Codes 2 and 3 fail the job by design: in CI they mean the gate did not actually
89+
# run, and a gate that silently passes when it could not run is worse than no gate.
90+
# Add `|| [ $? -eq 3 ]` if you would rather treat "declined" as a pass while rolling
91+
# this out — a stale baseline does NOT produce 3, it warns and still grades, so 3
92+
# really does mean the comparison was unsound.
6493

6594
- name: Publish the verdict as JSON
66-
if: always()
67-
run: enola check --json > architecture-verdict.json || true
95+
if: always() && steps.baseline.outcome == 'success'
96+
run: enola check --baseline baseline --json > architecture-verdict.json || true
6897

6998
- uses: actions/upload-artifact@v4
7099
if: always()
71100
with:
72101
name: architecture-verdict
73102
path: architecture-verdict.json
103+
if-no-files-found: ignore

internal/diff/diff.go

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -296,6 +296,31 @@ func Compute(baseline, current *facts.Snapshot) *SnapshotDiff {
296296
return d
297297
}
298298

299+
// repoMismatchDetail says which signal decided that two snapshots are of different
300+
// repositories, and always carries both absolute paths.
301+
//
302+
// Naming the signal matters because the remedy differs: differing remotes mean the wrong
303+
// baseline was fetched, while differing directory names on the same repository mean a
304+
// checkout was renamed — recoverable by giving the checkout the expected name, or by
305+
// pushing a remote so the stronger signal applies. A bare "different repositories" left
306+
// the reader to guess which.
307+
func repoMismatchDetail(base, cur facts.SnapshotMeta) string {
308+
baseRemote, curRemote := remoteOf(base), remoteOf(cur)
309+
if baseRemote != "" && curRemote != "" {
310+
return fmt.Sprintf("remote %s vs %s; paths %s vs %s",
311+
baseRemote, curRemote, orDash(base.RepoPath), orDash(cur.RepoPath))
312+
}
313+
return fmt.Sprintf("%s vs %s — no git remote recorded on both sides, so the checkout "+
314+
"directory name was compared", orDash(base.RepoPath), orDash(cur.RepoPath))
315+
}
316+
317+
func remoteOf(m facts.SnapshotMeta) string {
318+
if m.Git == nil {
319+
return ""
320+
}
321+
return facts.NormalizeRemote(m.Git.Remote)
322+
}
323+
299324
// compareMeta checks that two snapshots were generated over equivalent inputs and
300325
// returns comparability warnings for each mismatch that would distort the delta.
301326
// An auto-loaded baseline carries an empty Meta (only RepoPath) — its unknown
@@ -304,10 +329,14 @@ func Compute(baseline, current *facts.Snapshot) *SnapshotDiff {
304329
func compareMeta(base, cur facts.SnapshotMeta) Comparability {
305330
var c Comparability
306331

307-
if base.RepoPath != "" && cur.RepoPath != "" && base.RepoPath != cur.RepoPath {
332+
// Identity, not location. Comparing absolute paths made a baseline taken on a CI
333+
// runner incomparable with the same repository on a workstation, which blocked the
334+
// whole point of a portable baseline artifact. facts.SameRepo prefers the normalized
335+
// git remote and falls back to the checkout directory name.
336+
if !facts.SameRepo(base, cur) {
308337
c.add(WarnDifferentRepo,
309-
"baseline and current are different repositories (%s vs %s) — the delta is unlikely to be meaningful",
310-
base.RepoPath, cur.RepoPath)
338+
"baseline and current are different repositories (%s) — the delta is unlikely to be meaningful",
339+
repoMismatchDetail(base, cur))
311340
}
312341

313342
if base.EnolaVersion == "" || cur.EnolaVersion == "" {

0 commit comments

Comments
 (0)