Skip to content

ci: add stable runtime-closure check (inbox#21) - #280

Merged
bryan-minimal merged 3 commits into
mainfrom
bryan/stable-closure-check
Jun 22, 2026
Merged

ci: add stable runtime-closure check (inbox#21)#280
bryan-minimal merged 3 commits into
mainfrom
bryan/stable-closure-check

Conversation

@bryan-minimal

@bryan-minimal bryan-minimal commented Jun 20, 2026

Copy link
Copy Markdown
Member

What

A report-only dependency-closure helper for the stable channel (gominimal/inbox#21).

  • .github/scripts/stable-closure-check.py — given a package set (or --changed from the PR diff), computes the transitive runtime closure and reports whether stable already satisfies it (missing = closure − stable − co-promoted), with a PASS / NOT-CLOSED verdict.
  • .github/workflows/stable-closure-check.yml — runs it on pull_request, continue-on-error, posts to the job summary.

Why it's safe (two-way door)

Additive new files; read-only; continue-on-error + --no-fail so it never blocks. De-risks the highest-stakes promotion invariant ("stable must stay dependency-closed") without touching builds/cache/branches. Revert = delete the two files.

Keep off required checks until #20 turns it into a gate (drop --no-fail/continue-on-error — no code change).

The correctness fix worth noting

A naive BFS over runtime_deps under-counts: the engine (transitives.rs) injects every needed_for_internet provider (ca-certificates) into the runtime closure of any needs.internet package (~60: gh, curl, go, rust…). Without replicating that it would false-PASS (e.g. promoting gh before ca-certificates). The injection is replicated here, with a regression test (closure(gh) must contain ca-certificates).

Honest caveats (for #20)

Runtime-only (if stable is a buildable channel, add build_deps — one flag) and name-presence-only (not version/ABI-skew aware). With stable currently frozen ~48 commits behind main, expect near-universal NOT-CLOSED today — correct, and why it must stay non-blocking for now.

Refs gominimal/inbox#21.

Summary by CodeRabbit

Chores

  • Added an automated validation script and GitHub workflow to verify that promoting selected packages to the stable channel preserves the expected runtime dependency closure.
  • The workflow runs on pull requests targeting main (and manual runs), and reports a clear PASS/NOT CLOSED/UNVERIFIED verdict in the build summary to help prevent regressions.

A report-only helper + workflow: given a package set (or the PR diff), compute
the transitive runtime closure and report whether branch `stable` already
contains it (missing = closure - stable - co-promoted), de-risking the "stable
must stay dependency-closed" promotion invariant.

Replicates the engine's needed_for_internet injection (transitives.rs pulls
ca-certificates into the runtime closure of any needs.internet package) so the
BFS doesn't under-count and false-PASS (e.g. promoting gh/curl before
ca-certificates). Runtime-only, name-presence-only -- both honest caveats for #20.

Additive, contents:read, continue-on-error + --no-fail; keep off required checks
until #20 turns it into a gate (no code change needed to flip it).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jun 20, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Adds a new Python script (.github/scripts/stable-closure-check.py) that validates stable-channel promotion closure by computing a BFS runtime-dependency closure from seed packages and comparing it against the current stable package set. A companion GitHub Actions workflow runs this check on pull requests targeting main.

Changes

Stable Runtime-Closure Check

Layer / File(s) Summary
Module contract and utility helpers
.github/scripts/stable-closure-check.py
Defines module docstring describing the stable-closure invariant, closure construction with internet-injection semantics, and behavior modes. Implements eprint(), fail(), and run() helpers with exit-code-2 error handling and subprocess execution.
Catalog extraction and runtime graph building
.github/scripts/stable-closure-check.py
Parses per-architecture minimal dump JSON and implements build_runtime_map() to extract runtime_deps edges, build a consolidated runtime adjacency map, and compute needs_internet and internet_providers sets from package attributes.
Runtime closure with internet-injection parity
.github/scripts/stable-closure-check.py
Implements runtime_closure() as a BFS-style reachability algorithm. When a visited node is in needs_internet, it additionally enqueues all internet_providers to match engine-parity "internet injection" behavior.
Stable set enumeration
.github/scripts/stable-closure-check.py
Provides three methods to determine seed and stable packages: stable_set_lstree() via git ls-tree on a stable ref, stable_set_worktree() by dumping a checked-out stable worktree, and changed_packages() by extracting package basenames from git diff.
CLI orchestration and entrypoint
.github/scripts/stable-closure-check.py
Implements main() with argument parsing for seeds/--changed, arch selection, stable source, output format, and --no-fail mode. Computes closure, detects missing packages, renders verdicts (PASS/NOT CLOSED/UNVERIFIED), and returns exit codes respecting fail-open behavior.
GitHub Actions workflow
.github/workflows/stable-closure-check.yml
Defines stable-closure-check workflow triggered on PRs to main and manual dispatch. The non-blocking closure job checks out with full history, fetches origin/stable, runs the Python script against origin/main, and appends results to the step summary.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Poem

🐇 Hopping through the dep graph, BFS in paw,
Checking every closure, following each draw.
needs_internet? Pull providers in too!
No package left missing before we push through.
PASS or NOT CLOSED — the verdict is clear,
Stable channel guarded by this careful hare! 🌟

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 27.27% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'ci: add stable runtime-closure check (inbox#21)' directly and concisely describes the main change: adding a CI check for stable runtime closure verification, with issue reference.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch bryan/stable-closure-check

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In @.github/scripts/stable-closure-check.py:
- Around line 203-208: The early exit when no seed packages are found (in the
condition checking if not seeds) returns exit code 2 unconditionally, but does
not respect the args.no_fail flag which is meant to enable report-only mode.
When --no-fail is set, a PR with no package changes should be treated as a
successful case (nothing to promote) rather than an error. Modify the early exit
logic to check if args.no_fail is True before returning 2; if --no-fail is
enabled, return 0 instead to allow the script to complete successfully for PRs
that only modify non-package files like .github/.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 9069f554-06c2-4a08-ac1d-da3f205b7613

📥 Commits

Reviewing files that changed from the base of the PR and between f7f44e1 and 1d76740.

📒 Files selected for processing (2)
  • .github/scripts/stable-closure-check.py
  • .github/workflows/stable-closure-check.yml

Comment thread .github/scripts/stable-closure-check.py

@edge-delta edge-delta Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good addition — the script logic is solid and the internet-injection parity with transitives.rs is well-reasoned. Two comments below (one on the workflow shell, one concurring with the existing CodeRabbit finding on the no-seeds path).

@edge-delta edge-delta Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One line-level finding in the workflow shell. The Python script itself is well-structured — the BFS + internet-injection parity logic is sound, error handling is consistent, and the --no-fail / exit-code contract is clearly documented. CodeRabbit's existing finding about the "no seeds → exit 2" path not respecting --no-fail is valid and complementary to this shell issue.

- uses: ./.github/actions/setup-minimal # puts `minimal` on PATH
- name: Runtime-closure vs stable
run: |
set -uo pipefail

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: missing -e makes the step always exit 0, masking script failures.

set -uo pipefail without -e (errexit) means non-zero exit codes from intermediate commands don't halt execution. If the Python script exits 2 (operational error — not caught by --no-fail), the shell discards that exit code and continues to the step-summary block. Since the final command (} >> "$GITHUB_STEP_SUMMARY") always succeeds, the step's exit code will be 0 regardless of the script's actual result.

This undermines the intent of --no-fail being the sole control for exit behavior — the shell inadvertently makes every run appear successful.

Suggested change
set -uo pipefail
set -euo pipefail

With -e, a script exit-2 (e.g. the "no seeds" case when it's eventually fixed, or minimal dump failing) will properly propagate through the step, letting continue-on-error: true at the job level show the step as red in the summary while still not blocking the workflow.

bryan-minimal and others added 2 commits June 20, 2026 12:52
A report-only run that touches no packages (the workflow's --changed mode on a
PR with no packages/* changes, e.g. this PR's own CI) found no seeds and exited
2, which under set -o pipefail turned the closure job red. Report-only checks
must not fail on no-op PRs. Treat no-seeds as "nothing to check" (exit 0) in
--changed or --no-fail mode; keep exit 2 only for an explicit bare invocation.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…contract

An adversarial verification against the engine's runtime-closure semantics
(transitives.rs) confirmed the good news first: the gated needed_for_internet /
ca-certificates injection is faithfully replicated, so curl/gh/go correctly
require ca-certificates -- no false-PASS from the closure math itself.

But the guard could still go green without verifying anything, via two
fail-OPEN input paths:

- An unknown / namespace-mismatched seed only warned, then its closure =={itself}
  and was subtracted away -> empty missing -> exit 0 PASS. A typo or a natural
  name (python3, nodejs) silently verified nothing. Now: verdict UNVERIFIED,
  exit 2 (blocking); --no-fail still exits 0 but prints UNVERIFIED, never PASS.
- An empty-but-valid dump ([]) made every closure == its seeds -> vacuous PASS.
  Now guarded in build_runtime_map (covers the --stable-worktree path too) ->
  exit 2.

Also:
- --no-fail now genuinely never blocks: operational failures (missing minimal
  binary, unfetched origin/stable, dump error) route through a no-fail-aware
  fail() that returns 0 with an explicit UNVERIFIED verdict instead of exiting
  2 before the guard. Exit 1 stays reserved for a computed non-empty `missing`.
- git calls now honor --repo (cwd=args.repo), not just `minimal dump`.
- A missing stable ref is detected up front (git rev-parse) with the actionable
  fetch hint, instead of a raw git error.

Verified against the real catalog: ca-certificates injection is load-bearing
(gh's closure includes it only with the injection); PASS / NOT CLOSED /
UNVERIFIED / empty-dump exit codes are 0 / 1 / 2 / 2.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@bryan-minimal

Copy link
Copy Markdown
Member Author

Verified against the engine's runtime-closure semantics + closed two fail-open holes

Ran an adversarial verification of this helper against the real engine (crates/graph/src/transitives.rs + need_classes.ncl) — agents establishing ground truth, reviewing across false-PASS / set-math / robustness lenses, then verifying each finding.

The good news (the part I was most worried about): the closure math is faithful. The helper correctly replicates the engine's gated needed_for_internet injection — it pulls ca-certificates into the runtime closure of any needs.internet package (59 of them: curl/gh/go/…), exactly as transitives.rs does. Verified load-bearing: gh's closure includes ca-certificates only with the injection. No false-PASS from the closure logic. (It's even slightly more thorough than the engine — it recurses the injected provider's runtime_deps; harmless since ca-certificates has none.)

The fixes (latest commit): the guard could still go green without verifying anything, via two fail-open input paths:

  1. Unknown / namespace-mismatched seed only warned → its closure == {itself} → subtracted away → empty missing → exit 0 PASS. A typo or a natural name (python3, nodejs) silently verified nothing. (Mitigant: all 344 packages/<dir> basenames map 1:1 to catalog names today, so the --changed path is correct right now; the live trigger is the manual/positional path.) → now UNVERIFIED, exit 2.
  2. Empty-but-valid dump ([]) → every closure == its seeds → vacuous PASS. → now guarded in build_runtime_map (covers the --stable-worktree path too), exit 2.

Plus: --no-fail now genuinely never blocks (operational errors route through a no-fail-aware exit that prints UNVERIFIED and returns 0, never PASS); git calls honor --repo (cwd); a missing origin/stable is detected up front with the fetch hint instead of a raw git error.

Verified against the real catalog: PASS / NOT CLOSED / UNVERIFIED / empty-dump exit codes are 0 / 1 / 2 / 2.

Deferred (residual, for whoever wires the workflow): the --changed seed→name resolution relies on the unenforced dir == name invariant (a future rename is the latent trigger); and when wired into CI this needs fetch-depth: 0 (or an explicit git fetch origin stable) so origin/stable resolves.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In @.github/scripts/stable-closure-check.py:
- Around line 53-56: The early exit paths in the script, specifically the
NO_FAIL block where it prints the unverified verdict message and the no-seed
success path around line 242-244, print plain text output using print() without
checking the --format json flag. To fix this, add a conditional check before
both print statements to determine if JSON format has been requested, and if so,
construct and output a JSON-formatted response instead of the plain text
message. The fix should apply to both the NO_FAIL exit path and the other early
exit path mentioned, ensuring that when --format json is specified, the output
is machine-readable JSON instead of plain text, while preserving plain text
output when JSON format is not requested.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: f88c9ec9-5410-4ae3-a263-936b1316187f

📥 Commits

Reviewing files that changed from the base of the PR and between b218141 and 2168537.

📒 Files selected for processing (1)
  • .github/scripts/stable-closure-check.py

Comment on lines +53 to +56
if NO_FAIL:
print(f"VERDICT: UNVERIFIED — could not complete the closure check "
f"({msg}). Non-blocking (--no-fail).")
sys.exit(0)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Honor --format json on early exit paths.

Both the fail-open path and the no-seed success path can exit 0 while writing plain text despite --format json, which breaks machine-readable consumers of this report-only tool.

Proposed fix
 NO_FAIL = False
+OUTPUT_FORMAT = "text"
 
 
 def fail(msg: str, code: int = 2):
     eprint(f"error: {msg}")
     if NO_FAIL:
-        print(f"VERDICT: UNVERIFIED — could not complete the closure check "
-              f"({msg}). Non-blocking (--no-fail).")
+        if OUTPUT_FORMAT == "json":
+            print(json.dumps({
+                "seeds": [],
+                "unknown_seeds": [],
+                "arches": [],
+                "stable_source": None,
+                "closure": [],
+                "on_stable": [],
+                "missing": [],
+                "verdict": "UNVERIFIED",
+                "closed": False,
+                "error": msg,
+            }, indent=2))
+        else:
+            print(f"VERDICT: UNVERIFIED — could not complete the closure check "
+                  f"({msg}). Non-blocking (--no-fail).")
         sys.exit(0)
     sys.exit(code)
-    global NO_FAIL
+    global NO_FAIL, OUTPUT_FORMAT
     NO_FAIL = args.no_fail
+    OUTPUT_FORMAT = args.format
     arches = args.arches or ["amd64", "arm64"]
         # --changed with no changed packages (or any --no-fail run) is normal,
         # not an error: there is simply nothing to check. Exit 0 so the
         # report-only workflow stays green on PRs that touch no packages.
         if args.changed is not None or args.no_fail:
-            print("no seed packages (no packages/* changed) -- nothing to check.")
+            if args.format == "json":
+                print(json.dumps({
+                    "seeds": [],
+                    "unknown_seeds": [],
+                    "arches": arches,
+                    "stable_source": None,
+                    "closure": [],
+                    "on_stable": [],
+                    "missing": [],
+                    "verdict": "PASS",
+                    "closed": True,
+                    "note": "no seed packages",
+                }, indent=2))
+            else:
+                print("no seed packages (no packages/* changed) -- nothing to check.")
             return 0

Also applies to: 242-244

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/scripts/stable-closure-check.py around lines 53 - 56, The early exit
paths in the script, specifically the NO_FAIL block where it prints the
unverified verdict message and the no-seed success path around line 242-244,
print plain text output using print() without checking the --format json flag.
To fix this, add a conditional check before both print statements to determine
if JSON format has been requested, and if so, construct and output a
JSON-formatted response instead of the plain text message. The fix should apply
to both the NO_FAIL exit path and the other early exit path mentioned, ensuring
that when --format json is specified, the output is machine-readable JSON
instead of plain text, while preserving plain text output when JSON format is
not requested.

@bryan-minimal
bryan-minimal added this pull request to the merge queue Jun 22, 2026
Merged via the queue into main with commit 8a8e429 Jun 22, 2026
5 checks passed
@bryan-minimal
bryan-minimal deleted the bryan/stable-closure-check branch June 22, 2026 17:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants