fix: lint the release path — globstar does not descend into dot-directories #118
Labels
No labels
blocked
blocker:ci-red
blocker:conflict
blocker:drill-pending
blocker:unrequested
bug
claimed
documentation
enhancement
epic
merge-next
needs-triage
ready
release
scope:cli
scope:drill
scope:host
scope:installer
scope:templates
scope:tiers
stale
state:addressing
state:bots-reviewing
state:building
state:needs-human
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference: heavy-duty/box#118
Loading…
Reference in a new issue
No description provided.
Delete branch "fix/shellcheck-dotglob"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
The defect
.github/workflows/ci.ymlbuilt its shellcheck file list as:globstarmakes**descend into subdirectories — but a glob still does not match a dot-prefixed name..githubis dot-prefixed, so**/never entered it. The two behaviours are easy to conflate, and conflating them is the whole bug: the sweep looked recursive, printed a plausible 15-file list, and passed.Measured on
main: 15 files globbed, 17 tracked*.sh.What was escaping
Not incidental files — the release path, in full:
.github/scripts/changelog-armed.sh— the #108/#110 guard that stops a release disarming the changelog. It gates every PR and had never been linted..github/scripts/release-notes.sh— produces the published release body;release.ymlruns it at :128 and :208..github/scripts/labels-reconcile.sh— drives the label state machine.And the step's own comment stated the intent this defeated:
That is the sharp end. A script added under
.github/scripts/was silently unlinted and the comment told the next author it was covered.Latent, not broken
All three pass shellcheck as-is — confirmed before touching anything:
So this lands as a no-op on current code, not a bug fix in disguise. What changes is that a regression in them would now be caught.
The fix — measured, not assumed
dotglobalongsideglobstar. The issue warned to check what elsedotglobdrags intobin/*and**/*.shrather than assume, so I ran it:Exactly the three, nothing else. The
.gitconcern is real but empty in practice: a checkout's.gitcarries no*.sh— its hooks ship as*.sample— verified against a realgit inittree, not just this worktree (where.gitis a file). Set difference againstgit ls-filesalso confirms nothing untracked is pulled in. So the elegant fix holds and the explicit-list alternative, which reintroduces the "remember to edit this list" problem the comment was written to avoid, is not needed.The class check (#112 precedent)
dotglobis the one-time fix. What keeps the gap shut is the class check, in the same shape as theeof_guard_sweepof #112 — assert the property repo-wide, so the class cannot reopen rather than patching the instance:git ls-filesis the authority on what the repo contains. If the glob ever drifts from it again — another dot-directory, another shopt subtlety — CI names the escaped files instead of quietly linting a subset and passing green. It lives inline in the step rather than intest/cli.shdeliberately: it checks the realfilesarray CI is about to lint, so the assertion cannot drift from the thing it asserts about.Surprise:
eof_guard_sweephad the identical blind spottest/cli.sh:964rebuilds the same glob —shopt -s globstarwithoutdotglob— so the #112 class check was itself skipping.github/scripts/*.sh. Same defect, same cause, one layer down. Widened the same way. A no-op today: all three scripts set errexit (so they are in that class by construction), but none of them contains areadat all.Verification
shellcheckvia CI's exact invocation — 18 files, exit 0.dotglobdeliberately off, it exits 1 and names precisely the three files. It fails when it should, not just passes when it should.bash test/cli.sh— 475 passed, 0 failed.bash test/labels-reconcile.sh— 19 passed, 0 failed.bash test/release.sh— 90 passed, 0 failed.bash .github/scripts/changelog-armed.sh— armed, agrees withVERSION0.8.1-dev.Not run here: the multi-user Incus rehearsal, which needs a real daemon — it runs in CI. This change touches no runtime code, only the lint sweep, the EOF sweep's file set, and the changelog.
Siblings (
heavy-duty/rig,heavy-duty/cast) carry the same defect; per the issue they are filed separately so each record lives where its fix goes.Closes #116
Verdict: request changes
The lint fix itself is correct, measured, and verified — but the CHANGELOG edit deletes the published
## 0.8.0 — 2026-07-19release heading, silently folding the entire 0.8.0 section back into "Unreleased". That must be fixed before merge.The blocker: CHANGELOG.md erases the 0.8.0 release record
CHANGELOG.md:8— the diff hunk replaces the line## 0.8.0 — 2026-07-19with the new### Fixedsection instead of inserting above it:Verified against the tree: on
origin/main,grep -n '^## 'showsUnreleased,0.8.0,0.7.0,0.6.0,0.5.0; on this branch the0.8.0heading is gone and its full### Addedcontent (roughly 270 lines, the #96 release-automation entry onward, down to what was line 296) now sits under "Unreleased". Consequences:release-notes.shextracts the release body by heading, so the record of what 0.8.0 shipped no longer exists as a section.bash .github/scripts/changelog-armed.shon this head — it exits 0 (VERSION '0.8.1-dev' agrees with the top section (Unreleased)), because the guard checks only the top section againstVERSION, not the survival of prior release headings. So this is precisely the "release path corrupted while everything passes" shape this repo keeps guarding against — an ironic near-miss in a PR about closing silent gaps.The fix is one line: restore
## 0.8.0 — 2026-07-19between the new### Fixedblock and the existing### Addedheading.Everything else verified — all claims reproduce
Mechanism (
.github/workflows/ci.yml:37):shopt -s globstar dotglobis the right fix.globstarmakes**descend but a glob still does not match dot-prefixed names;dotglobcloses exactly that. Correctly a two-part change: shopt for the instance, totality assertion for the class.Sweep totality — enumerated and run myself on the worktree:
git ls-files '*.sh'→ 17 tracked scripts. Extensionless shebang scan over all tracked files → exactly one,bin/box, covered bybin/*. Total inventory: 18.files=(bin/* **/*.sh)withglobstar dotglob→ 18 files, andcomm -13 <(globbed)> <(tracked)>→ empty. Complete.dotgloboff → 15 files, and the new assertion names exactly.github/scripts/{changelog-armed,labels-reconcile,release-notes}.shand exits 1. It fails when it should.git ls-files '*.sh'— the right authority; it catches any future dot-directory/shopt drift for*.shfiles. Noted limitation (not blocking, same scope as #116): an extensionless script added outsidebin/would escape both glob and assertion, sincels-files '*.sh'cannot see it.Newly covered scripts pass clean:
shellcheck -x(v0.10.0) over the full 18-file set exits 0. The PR needed no fixes to the three scripts — the diff touches onlyci.yml,test/cli.sh,CHANGELOG.md— so "latent, not broken" is accurate and there are no behavior-preservation concerns.eof_guard_sweep(test/cli.sh:971): same one-line widening of the same glob, correctly identified as the identical blind spot one layer down; a no-op today since none of the three scripts contains aread.Sibling rig#71: same pattern —
dotglobplus acommtotality check againstgit ls-files '*.sh'. Differences are cosmetic only (comm -23tracked-first vscomm -13globbed-first — set-equivalent; stderr vs stdout for the failure message; rig has noeof_guard_sweepto widen). No material divergence.Tests, run here:
bash test/cli.sh→ 475 passed, 0 failed (includes the widenedeof_guard_sweep).bash test/release.sh→ 90 passed, 0 failed.bash test/labels-reconcile.sh→ 19 passed, 0 failed. All match the PR's claims.Nit (
.github/workflows/ci.yml:44):printf ' %s\n' $missingrelies on unquoted word-splitting to print one path per line — fine for this repo's space-free paths, and workflowrun:blocks are outside shellcheck's reach, but awhile readloop orprintf '%s\n' "$missing" | sed 's/^/ /'would be splitting-proof. Take or leave.Restore the 0.8.0 heading and this is an approve — the lint fix itself is exemplary.
— automated review by
claude-bot-andresmgsl· heavy-duty-review-botVerdict: Request changes
Blockers
CHANGELOG.mddeletes the published## 0.8.0 — 2026-07-19heading.The hunk replaces that line with a new
### Fixedunder Unreleased instead of inserting above it. On tip, 0.8.0’s section content is folded back into Unreleased — the published release loses its extractable notes anchor, and the next cut would re-ship 0.8.0’s body as new work.changelog-armed.shstill passes (top section / VERSION only), so CI will not catch this.Fix: restore
## 0.8.0 — 2026-07-19between the new Unreleased### Fixedblock and the existing content that belongs under 0.8.0.What is fine
dotglob+commagainstgit ls-files '*.sh'is the right class fix for #116; eof_guard_sweep widen is correct. Agree with @claude-bot-andresmgsl.Happy to re-review once the 0.8.0 heading is restored.
Verdict: I have feedback.
Blocking:
CHANGELOG.mdremoves the published## 0.8.0 — 2026-07-19heading instead of inserting this PR’s new### Fixedsection above it. That folds the existing 0.8.0 notes back intoUnreleased, removes the release-notes anchor, and risks republishing those entries in the next release. Restore the## 0.8.0 — 2026-07-19heading between the new Unreleased entry and the existing### Addedsection. The shellcheck and EOF-sweep changes are otherwise acceptable.Round answered — all three of you found the same blocker, and you were right. Fixed in 12200f3.
The blocker: the 0.8.0 heading
Restored.
## 0.8.0 — 2026-07-19now sits between this PR's### Fixedentry and the### Addedsection that belongs to it. Verified rather than eyeballed:@claude-bot-andresmgsl's reconstruction was exactly right: the entry was written over the heading rather than above it, and ~270 lines of shipped 0.8.0 content ended up under Unreleased.
I also audited the other nine PRs open from this batch for the same mistake —
comm -23of each branch's^##headings against its merge base. This one was the only one; the other nine are heading-for-heading identical to main.The part worth keeping: CI was green anyway
@claude-bot-andresmgsl flagged this and it deserves to outlive the PR.
changelog-armed.shexits 0 on the broken tree — correctly, on its own terms. It asks whether the top section agrees withVERSION; deleting## 0.8.0leaves## Unreleasedon top, so its invariant held. The guard is narrow, not wrong.That is the same shape as #108 and heavy-duty/rig#66: a changelog edit git merges cleanly, no human diff-reads, and no check objects to — surfacing only at release time. Filed as #122, suggesting a monotonicity assert (the
^## X.Y.Zset on a PR head must be a superset of the merge base's). Release headings are append-only, so the rule has no false positives to tune, and a stamp rewriting## Unreleased→## X.Y.Zadds a heading without removing one. Same class-check shape aseof_guard_sweep(#112) and this PR's own totality assert.An ironic near-miss for a PR about closing silent gaps, as @claude-bot-andresmgsl put it — and the reason it was caught here is that three reviewers diff-read the changelog, which is precisely what #122 argues should not be load-bearing.
The nit: unquoted word-splitting
Taken.
printf " %s\n" $missing→printf "%s\n" "$missing" | sed "s/^/ /". Splitting-proof regardless of paths, and it costs nothing.Re-verified after the change
.github/scriptsfilesshellcheck -xexit 0test/cli.sh475/0 ·test/release.sh90/0 ·test/labels-reconcile.sh19/0changelog-armed.sh: armedNo change to the lint fix itself — the diff since your reviews is the restored heading plus the printf nit.
Re-requesting all three, since all three were blocking.
Verdict: approve
The blocker from my previous review is resolved.
12200f3restores the published## 0.8.0 — 2026-07-19heading, and I verified it against the release record itself, not just against main:CHANGELOG.md:30now carries## 0.8.0 — 2026-07-19between this PR's new### Fixedentry (which sits under## Unreleased,CHANGELOG.md:6-28, where it belongs) and the 0.8.0### Addedcontent.0.8.0tag'sCHANGELOG.md(git show 0.8.0:CHANGELOG.md): byte-for-byte identical, both the section alone and the entire tail from the 0.8.0 heading onward. Published history is intact, nothing else was disturbed.bash .github/scripts/release-notes.sh 0.8.0extracts the full 264-line 0.8.0 body (previously it had lost its anchor).changelog-armed.shexits 0:VERSION '0.8.1-dev' agrees with the top section (Unreleased).The rest of the delta is benign
git diff 92b1ef8e..12200f38touches exactly two things:.github/workflows/ci.yml:43—printf ' %s\n' $missingis nowprintf '%s\n' "$missing" | sed 's/^/ /', splitting-proof. I re-ran the class check's negative test (dotglob off) with the new line: it still exits nonzero and prints exactly the three.github/scriptsfiles, one per line, indented.No change to the lint fix itself, which was already verified at 92b1ef8 (18-file sweep total against
git ls-files '*.sh', negative test failing correctly,eof_guard_sweepwidened the same way) — that verdict stands.Tests, re-run on this head
bash test/cli.sh— 475 passed, 0 failedbash test/release.sh— 90 passed, 0 failedThe follow-up #122 (changelog heading monotonicity assert) is the right class check for the near-miss this PR just had; good that it is filed rather than folded in here.
— automated review by
claude-bot-andresmgsl· heavy-duty-review-botVerdict: I agree with everything and have no additional feedback.
Verdict: Approve — I agree with this as-is.
Prior blocker closed
CHANGELOG.md: tip12200f3restores## 0.8.0 — 2026-07-19between the new Unreleased### Fixedblock and the shipped 0.8.0 body. Heading set matches main; release-notes anchor is intact.Rest of tip
dotglob+commtotality check vsgit ls-files '*.sh'(class fix for #116).eof_guard_sweepwidened the same way.printf '%s\n' "$missing" | sed 's/^/ /'is splitting-proof.No remaining blockers.
Re-review passed — three approvals at head
12200f3, changelog blocker resolved. Thanks all three for catching it independently.Recording the outcome for the maintainer, since the interesting part outlived the fix: the defect (an Unreleased entry written over
## 0.8.0 — 2026-07-19rather than above it, folding ~270 lines of shipped release notes into Unreleased) passed CI green.changelog-armed.shis correct on its own terms — it checks only that the top section agrees withVERSION, and deleting the 0.8.0 heading left## Unreleasedon top. It was caught by three reviewers diff-reading the changelog, which is exactly the thing #122 argues should not be load-bearing.Handing to the maintainer: requesting @danmt for the human round.
Rebased onto
mainafter #125 merged.CHANGELOG.mdonly —.github/workflows/ci.ymlandtest/cli.shrebased clean.The two sides were disjoint sections (
### Changedfrom #125,### Fixedfrom this PR), so they compose: both kept in order, no prose edited on either side, section orderAdded → Changed → Fixedpreserved.Re-requesting all three bots — the force-push moved head, so every prior approval is stale regardless of what it said.
Verdict: I have feedback.
Blocking: the rebase onto
mainreintroduced a duplicate## 0.8.0 - 2026-07-19heading inCHANGELOG.md. The current diff adds this PR's### Fixedentry immediately below the existing 0.8.0 heading, then adds the same 0.8.0 heading again before### Added. That splits one release into two same-named sections; heading-based release-note extraction can stop at the duplicate and omit the existing 0.8.0 content. Remove the newly added second heading so the PR contributes only the### Fixedblock under the single existing 0.8.0 heading.🔧 Changes requested — I agree with most; feedback below.
The rebase itself was clean where it claimed to be: I diffed this head against both
mainand12200f3—.github/workflows/ci.ymlandtest/cli.shcarry exactly the changes I approved, nothing more. That approval stands untouched. But the changelog conflict resolution reintroduced the round-1 defect in mirror image, and it must be fixed before merge.The blocker: the changelog now has two
## 0.8.0headings, and the new entry sits inside the released sectionOn this head,
grep -n '^## ' CHANGELOG.mdgives6: Unreleased, 62: 0.8.0, 86: 0.8.0, 354: 0.7.0, …—mainhas exactly one## 0.8.0 — 2026-07-19. The rebase placed this PR's### Fixedentry (CHANGELOG.md:64-85) after a## 0.8.0heading atCHANGELOG.md:62, with a duplicate heading atCHANGELOG.md:86in front of the shipped### Addedbody. Round 1 folded released content into Unreleased; this folds unreleased content into a release. Consequences, verified againstrelease-notes.sh's actual extraction:release-notes.shre-armsgrabon every##line whose$2matches, so with two0.8.0headings,release-notes.sh 0.8.0now emits this PR's### Fixedblock concatenated in front of the shipped body — the published 0.8.0 record silently absorbs a fix that shipped after 0.8.0.## Unreleased, so the next cut stamps #123's### Changedas 0.8.1 and omits this fix from its release notes entirely — it vanishes into the back-record of a release it wasn't in.changelog-armed.shis green again (Unreleasedis still the top section, agreeing with0.8.1-dev), so CI does not object. Third silent-green changelog corruption on this PR's thread alone.The fix: delete the heading at
CHANGELOG.md:62and move the### Fixedblock up under## Unreleased, after #123's### Changedsection (repo order Added → Changed → Fixed), leaving the single## 0.8.0 — 2026-07-19heading directly above its shipped### Addedbody.Two new points that fall out of this
^## X.Y.Zset ⊇ merge base's) passes here — nothing was removed; a heading was duplicated. The assert should also require release headings to be unique (compare multisets, or simply fail on any repeated^## X.Y.Zline). Worth folding into #122 while it's still a sketch — this thread has now produced a live counterexample for it.diff <(git show origin/main:CHANGELOG.md | grep '^## ') <(grep '^## ' CHANGELOG.md)) that you yourself used to verify the round-1 fix. Run here, it flags this immediately (one extra0.8.0line). Until #122 exists in CI, that one-liner is the manual guard for every changelog-touching rebase in this repo.Everything else about this PR remains exemplary; restore the single heading, move the entry under Unreleased, and this is an approve again.
— automated review by
claude-bot-andresmgsl· heavy-duty-review-botVerdict: Request changes — lint fix still holds; CHANGELOG rebase is broken.
Tip
e6f6af0(rebased after #125). Prior approve was at12200f3.What still holds
shopt -s globstar dotglob+commagainstgit ls-files '*.sh'for the release-path blind spoteof_guard_sweepwidened the same wayBlockers
CHANGELOG.mdhas two## 0.8.0 — 2026-07-19headings (tip lines ~62 and ~86). Main has exactly one. The conflict resolution inserted this PR's### Fixedunder the existing shipped heading, then re-added a second## 0.8.0before the real### Addedbody.The #116 entry is under shipped 0.8.0, not
## Unreleased. This is unreleased work after 0.8.0 cut. Placing it in the shipped section:release-notes.shextraction stop at the first heading (or ship the wrong body)Fix: one
## 0.8.0 — 2026-07-19only; move the #116### Fixedblock under## Unreleased(compose with main's existing Unreleased### Changedfrom #125 — same additive resolution you described, wrong section target this time). Heading set vs main must match for every shipped## X.Y.Z.Peers (claude/codex) already RC'd the same shape — agreeing, not reopening a different thread.
All three of you are right, and the fault was mine — in the rebase, not in the PR. Fixed and force-pushed.
What I broke
My conflict resolver keeps both sides when they are disjoint
###sections. Here the conflict region contained the## 0.8.0release heading, so "keep both" duplicated it and left this PR's### Fixedentry sitting inside the shipped release. Exactly as @claude-bot-andresmgsl reconstructed it: spurious heading at:62, entry at:64-85, real heading at:86.Why my verification missed it
I checked conflict markers,
test/cli.sh,test/release.shandshellcheck. All four were green — and would have stayed green. None of them can see a duplicated heading. That is the third silent-green changelog corruption on this thread, and the common factor is that every guard I ran looks at content, while the damage was to structure.@claude-bot-andresmgsl is right that I had already used the heading-set diff to verify round 1 and then dropped it from the rebase checklist. It is back, permanently:
I also added a hard stop to the resolver itself: it now refuses outright if either side of a conflict contains a
##release heading, and prints that diff as the instruction. Release headings are the file's spine; keeping both is never the answer for them. A tool that can silently corrupt a shipped release record does not get to be clever.The fix
Single
## 0.8.0 — 2026-07-19directly above its shipped### Addedbody; this PR's### Fixedmoved under## Unreleasedafter #123's### Changed, per repo order Added → Changed → Fixed.Verified, including the consequence @claude-bot-andresmgsl traced through
release-notes.sh:On the #122 point
@claude-bot-andresmgsl is right that a set-⊇ monotonicity check passes this — nothing was removed, a heading was duplicated. Raising that on #126 with this thread as the live counterexample, so the assert lands as uniqueness rather than containment.
The lint change itself is untouched —
.github/workflows/ci.ymlandtest/cli.shcarry exactly what you approved at12200f3.✅ Approved — I agree with all of this, no concerns.
All three points from my last review are resolved at
c68c519, each verified against the tree rather than the description:## 0.8.0heading —grep -c '^## 0.8.0' CHANGELOG.mdon this head is 1, and the full^##heading set is identical tomain's (Unreleased, 0.8.0, 0.7.0, 0.6.0, 0.5.0). The spurious heading formerly at :62 is gone.## Unreleased— the### Fixedblock now sits atCHANGELOG.md:62, after #123's### Changed, per repo order Added → Changed → Fixed. The 0.8.0 section from its heading down to## 0.7.0is byte-identical to main, sorelease-notes.sh 0.8.0extraction is unchanged by construction and the next cut will carry this fix in its own notes.##headings inside a conflict region (the right hard stop — "keep both" is never the answer for the file's spine), and the uniqueness-not-containment shape for #122's assert is being raised on #126 with this thread as the live counterexample.The lint fix itself is untouched: the diff vs main for
.github/workflows/ci.ymlandtest/cli.shcarries exactly what I approved at12200f3—dotglob, the totality check againstgit ls-fileswith the splitting-proof printf, and the widenedeof_guard_sweep— nothing more.Nit, take or leave: the new entry leaves two blank lines before
## 0.8.0(CHANGELOG.md:83-84) where the file's convention is one.— automated review by
claude-bot-andresmgsl· heavy-duty-review-botVerdict: I agree with everything and have no additional feedback.
Verdict: Approve — I agree with this as-is.
Tip
c68c519closes the rebase CHANGELOG damage frome6f6af0.Blockers closed
## 0.8.0 — 2026-07-19— count is 1; shipped### Addedsits under it again.## Unreleasedafter main's### Changed(#123), section order Added → Changed → Fixed preserved.## X.Y.Z.Lint half (unchanged, still right)
globstar+dotglob+commvsgit ls-files '*.sh'eof_guard_sweepsame wideningNo remaining blockers. CI green.