Commit graph

33 commits

Author SHA1 Message Date
codex-bot-andresmgsl
10975d9ac8 fix: complete ceremony 0.3.0 labels adoption 2026-07-24 18:37:44 +00:00
codex-bot-andresmgsl
89a7fcc5b6 chore: update ceremony pin to 0.3.0 2026-07-24 18:05:09 +00:00
codex-bot-andresmgsl
e22e0dd4a8 chore: bump ceremony pin to 0.2.0 2026-07-24 13:54:11 +00:00
claude-bot-andresmgsl
5a8badd532 feat: CI drills the install lifecycle — four beats against a real tree (#106)
install from this checkout via RIG_INSTALL_SOURCE, assert what landed,
a converging re-run proven by an empty diff, uninstall --all ending in
the absence assert (! -e AND ! -L). test/install-lifecycle.sh carries
the beats plus the instrument-honesty checks (the diff and the absence
assert are broken against scratch trees on every run, so 'demonstrated,
not assumed' is mechanical); test/release.sh gains the local channel's
contract — dir, tarball, loud refusal, never a silent download fallback.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 08:58:53 +00:00
claude-bot-andresmgsl
77cb4bdd64 test: the instrument's honesty, proven without hardware
test/drill.sh awk-extracts the harness's decision functions (the
release.sh pattern) and drives them against fixtures: the ref refusal
names both refs, a loud skip never classifies as a pass, the idempotence
verdict is a real diff that goes non-empty when convergence is broken —
demonstrated mechanically on every CI run — and the record emitter
cannot produce a clean-sweep reading over a skipped leg. CI runs it in
the check job.

The tests caught three real harness bugs before any reviewer could:
printf eating a '- '-leading format as options (a silently empty Failed
section — the exact lie the record exists to prevent), tree_of trusting
GNU readlink -f's exit 0 on a dangling final component, and the arg
refusals sitting behind the root check in violation of the repo's own
validated-before-root doctrine.

(ceremony flow: issue #105)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 00:31:53 +00:00
claude-bot-andresmgsl
be71e1c8b2 feat: the machinery hands over — guard scripts deleted, CI pins ceremony's actions at 0.1.0 (ceremony#13)
changelog-armed returns (rig#44's revert, now version-keyed upstream);
docs-sync guards the doctrine mirror the next commit vendors.
test/release.sh keeps rig's own surfaces — installer channels and
latest-tag resolution; the machinery halves and the workflow-shape pins
are tested in ceremony's own test/. test/labels-reconcile.sh goes whole:
it drove the deleted reconciler.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 00:27:10 +00:00
claude-bot-andresmgsl
e6584ceb2b feat: labels.yml becomes the ceremony caller — panel and scope taxonomy extracted to labels.conf (ceremony#13)
The conf format takes no comment lines: every non-blank, non-panel line
must parse as name|color|description, so the file is data only.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 00:26:11 +00:00
claude-bot-andresmgsl
d8a0e22894 feat: release.yml becomes the ceremony caller stub — both doors move upstream (ceremony#13)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 00:25:05 +00:00
dan-claude-bot
11dfeb2ecd refactor: one drill record per version, in drills/
Drill records move from sections inside drill/RUNS.md to one file per
version at drills/<version>.md.

The old guard had to parse headings: an em-dash prefix match, an optional
" — DATE" tail, a whole-version comparison so 0.3.0-rc1 could not satisfy
0.3.0, and a separate non-blank-body rule. All of it existed only because
records shared one file, and both sibling repos shipped a defect out of
that complexity in review — a `sed '/./,$!d'` extractor where `.` matches
a space (box#149, cast#138), and heading-grammar drift. One file per
version makes nearly all of it unrepresentable: 0.3.0.md and 0.3.0-rc1.md
are different files, and the filesystem does the whole-version comparison.

The awk drill_section() machinery is gone. What survives is the one rule
splitting the files does not make unrepresentable: a file of only
whitespace is not a record.

Plain drills/, NOT .drills/ — a dot-directory is invisible to globs
without dotglob, the cause of #70 here and box#116/box#118.

drill/RUNS.md is deleted; it was created in this same unmerged PR, held no
real records, and its useful reasoning moves to drills/README.md. (box
keeps ITS drill/RUNS.md — that one is a genuine harness log.)

Also corrects the ordering framing in CONTRIBUTING and the new README: the
three repos' drills are INDEPENDENT, run in any order. What makes that safe
is that each pins the same fixed set of candidate refs, so box and rig
measure the same pair — that, not sequencing, is what dissolves the mutual
recursion.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 16:35:51 +00:00
dan-claude-bot
77a9a1ad76 feat: CI refuses a release PR with no drill record
CONTRIBUTING has always required a real-hardware drill on a release, and
nothing enforced it — so no release in this family has ever carried one.
Every other ceremony step is checked by a script; the one that costs an
afternoon was checked by a reviewer remembering. A reviewer bot finally
blocked on it.

- drill/RUNS.md: rig's own run log, starting EMPTY of records. rig has no
  drill harness of its own yet; the harness lives in box's drill/ and this
  file is the record, not the instrument.
- .github/scripts/drill-recorded.sh: a -dev tree asserts nothing; a bare
  VERSION requires a non-empty '## Release drill — X.Y.Z' section, version
  matched WHOLE so an -rc1 record is not evidence for the final.
- Per-repo on purpose. A cross-repo lookup into box fails on a token, a fork
  checkout or a network blip, and all of those degrade to 'pass' on precisely
  the tree that ships — the UNREADABLE-vs-NONE shape #90 fixed.
- It asks for a RECORD, not a RESULT, so a maintainer waiver stays possible
  but has to be written down under that version.
- Fixtures carry their own VERSION and RUNS.md (heavy-duty/box#146: fixtures
  reading the repo's real VERSION exercised only the -dev branch and went red
  first while cutting a release).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 15:24:42 +00:00
Daniel Marin
9adf45a229
Merge pull request #99 from dan-claude-bot/fix/changelog-monotonic
fix: catch a deleted or duplicated release heading in CHANGELOG.md
2026-07-21 00:36:37 +01:00
dan-claude-bot
7ffc30bacc fix(changelog-monotonic): check uniqueness before anything base-side (#98)
Uniqueness is a property of HEAD alone — no base ref, no merge base, no base
blob. It sat downstream of all three, so every degradation path returned
success on a tree carrying a duplicate.

The base-blob path was the worst: a branch that introduces CHANGELOG.md hit a
bare `exit 0` on a message that was true about deletion and silent about the
duplicate in front of it. STRICT could not reach it — STRICT guards the two
skip() calls, and that is not one of them.

That inverted the two halves. Deletion needs a diff to see; duplication is the
one changelog_section() actually mis-renders, stopping at the second heading
and truncating the release's real body. The half with the live extraction bug
behind it had the most ways to silently not run.

Moved, not rewritten. The skip messages now say containment skipped and that
uniqueness already passed. The CI step is no longer pull_request-only, with a
`github.ref_name` fallback because base_ref is empty on a push and a bare
`origin/` under STRICT would redden every push to main. The script is also now
100755, matching cast's copy of the same file.

Regression cases pin the ORDER, not the exit code: verified they go red
against the pre-fix script (7 failures) and green against the fixed one.

Same defect fixed upstream in heavy-duty/box#144 (heavy-duty/box#143), which
rig's copy of this script was ported from. Found by claude-bot-andresmgsl and
codex-bot-andresmgsl reviewing #99.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 20:22:43 +00:00
dan-claude-bot
b2e7febf08 fix: catch a deleted or duplicated release heading in CHANGELOG.md (#98)
The arming rule (#66) guards ONE heading — does the top section agree with
VERSION? — and is silent about the rest of the file. The failure that lives
there is an author adding an entry under `## Unreleased` who replaces the
shipped heading below it instead of inserting above it. git merges the
one-line edit cleanly, `changelog_armed()` stays green (correctly: the top
section is still right), and the shipped release loses its section entirely.
It surfaces a whole release later, when release.yml refuses to publish a
section `changelog_section()` can no longer find by heading.

"A heading disappeared" is a property of a DIFF, not of a tree, so this is
its own script rather than a clause in the arming check — which is also
driven from test/release.sh against constructed non-git VERSION + CHANGELOG
pairs that could not express it. The rule needs no tuning: release headings
are append-only, so SUPERSET is exact, and the ceremony's stamp passes by
construction because `Unreleased` fails the version shape.

Ported from heavy-duty/box#122, with box's second half intact: containment
cannot catch a DUPLICATED heading, since the duplicate is head-side surplus
and `comm -23` is blind to extras there — so uniqueness on HEAD is asserted
alongside it. rig's symptom differs from box's and the comments say so:
box's extractor re-arms on every `## ` line and ABSORBS what sits between
the copies, while rig's `changelog_section()` has `if (found) exit` and
TRUNCATES at the second copy, dropping the release's real body.

Wired on pull requests only (on a push to main the merge base is HEAD, so
the assert is vacuous), with CHANGELOG_MONOTONIC_STRICT=1 and fetch-depth: 0
so a checkout that cannot reach the base ref fails rather than skipping
quietly forever.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 20:00:48 +00:00
dan-claude-bot
23cca65855 fix(labels): sweep on labeled so the handoff is immediate
A review landing was never a trigger for the labels workflow, so the exact
moment `state:needs-human` became true — the third bot approving — fired
nothing, and the label waited on the `*/15` cron. That cron does not run at
its declared rate: measured across box, rig and cast over a two-hour window
on 2026-07-20, one scheduled run each against the eight `*/15` implies.

The obvious fix does not work. There is no `pull_request_review_target`, and
on fork PRs — all of them here — `pull_request_review` runs with a read-only
token and cannot label anything.

So the handoff wakes the sweep itself:

- `pull_request_target` also fires on `labeled`/`unlabeled`
- the author sets `state:needs-human` at handoff, as the third act after the
  round summary and the review request

The author's own label write fires the sweep that validates it — an
optimistic write, not a transfer of ownership. The reconciler confirms or
corrects it seconds later, and the cron falls back to a last resort. It
cannot loop: the reconciler writes with GITHUB_TOKEN, which does not create
workflow runs; agent writes use a PAT, which does.

`labels-reconcile.sh` is unchanged — it already recomputes every open PR
from scratch on every run, which is what makes the optimistic write safe.
The `scope` job is skipped on label events, where no path can have changed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 19:44:07 +00:00
dan-claude-bot
3f6f05766f refactor(labels): split PR labels into state and blocker axes
Retires `state:needs-rebase` in favour of two independent axes: `state:*`
(whose ball, exactly one) and `blocker:*` (what is in the way, additive).
One rule joins them: `state:needs-human` requires zero blockers.

The single-label design projected independent facts — mergeability, check
status, review round — onto one totally-ordered value, so one always won
and the rest vanished. Every precedence bug this machine has had lived on
that ordering. Blockers are a set, so there is no precedence between them
to get wrong.

`state:bots-reviewing` tightens to mean strictly "a request is live"; a
ready PR nobody was asked to review is now `state:addressing` +
`blocker:unrequested`. The reconciler strips the retired
`state:needs-rebase` on sight via a RETIRED array.

Also wires test/labels-reconcile.sh into CI, where it had never run.

Fixtures 51 -> 64.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 17:30:53 +00:00
dan-claude-bot
26ff38390b fix: lint .github/scripts with dotglob, and assert the sweep is total
CI's shellcheck step set `globstar` and globbed `bin/* **/*.sh`. Globs do
not match dot-prefixed names without `dotglob`, so `**/` never descended
into `.github/` and two tracked scripts were linted by nothing:
`.github/scripts/labels-reconcile.sh` and `.github/scripts/release-lib.sh`.

release-lib.sh is the one that matters: it holds `changelog_section`, which
release.yml sources to build the published release body and which
test/release.sh's `changelog_armed` guard calls to decide whether main is
armed. The script that decides both what ships and whether the changelog is
safe was the script CI never read.

Measured rather than assumed: `dotglob` adds exactly those two files to
rig's line and nothing else, and `**` descending into `.git/` matches no
`.sh` on a checkout. Both files already pass `shellcheck -x`, so this
closes a hole in the net rather than fixing a defect behind it.

Paired with a class check — `comm` against `git ls-files '*.sh'` — that
fails the step naming any tracked script outside the globbed set, so the
gap cannot reopen quietly. It also covers an escape `dotglob` does not:
`globstar` declines to traverse symlinked directories.

Refs #70
2026-07-20 12:07:20 +00:00
dan-claude-bot
e533e86322 fix: issues: write — the fallback PR's --label rides the issues API (labels.yml precedent)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 16:35:50 +00:00
dan-claude-bot
0b6318fe24 chore: the permissions comment must not pre-match the ordering pin's grep
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 16:34:56 +00:00
dan-claude-bot
700e2185eb fix: pull-requests scope for the door's two PR-API calls; docs catch up
The cast-twin blocker (claude-bot): a declared permissions: block zeroes
unspecified scopes, so the label read and the bump fallback's gh pr
create could only 403 — every genuine ceremony would end red at the
label check. pull-requests: write added, consumers named. CONTRIBUTING
step 3 and the changelog entry now tell the shipped story: push-to-main
door, event.before interlock, self-re-arm, manual-path bump stays the
operator's.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 16:34:29 +00:00
dan-claude-bot
4b5d62e405 fix: one on.push key — YAML last-key-wins had dropped the tag door
grok's round-2 catch: two sibling push: maps under on: leave only the
second (branches: [main]) alive — the tag-push fallback stopped
triggering entirely. Both filters now live under one push key, the jobs
still split on the pushed ref, and a pin counts exactly one on.push.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 16:14:13 +00:00
dan-claude-bot
053e12d51e feat: the release re-arms main — the -dev bump folds into the release act
Operator decision: the post-release bump PR is ceremony debris — a
derivable one-liner with no judgment for a review to add. After tag +
publish, the same job computes X.Y.(Z+1)-dev and pushes it to main
directly (a GITHUB_TOKEN push fires no workflows, so no recursion and no
red run); if branch protection refuses, the step opens the bump PR
itself, loudly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 16:02:53 +00:00
dan-claude-bot
44717863a1 fix: the merge door rides pushes to main — fork PR tokens are read-only
Round-1 blocker (grok, claude on box#97's twin): a pull_request run from
a public fork gets a read-only GITHUB_TOKEN — permissions: cannot raise
it — and every ceremony PR this org merges is cross-repo from the bot
fork, so the tag create would 403 after green asserts, red on main per
release. The door now triggers on push to main (in-repo event, full
token): the decide step reads the version transition from event.before
(first-parent fallback for the all-zeros edge), and the release label —
still the operator's declared intent — is read via the API off the merge
commit's PR. A transition with no labeled PR behind it refuses. The two
doors now split on the pushed ref: tags to the tag door, main to this one.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 15:44:52 +00:00
dan-claude-bot
c0cd4a1b15 fix: a -dev endstate is always work — the post-release bump must not run red
The four-state table called '-dev but changed' half a ceremony and
refused — but that state IS the mandatory post-release bump PR
(bare -> X.Y.(Z+1)-dev after every release), a red run on main once per
release, forever. A tree that ends -dev is by definition not a release:
every such merge is work, green NOTICE no-op. Red now guards only bare
endstates.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 15:42:27 +00:00
dan-claude-bot
d08ec8c6f6 fix: the release label's two meanings part ways in a decide step
LABELS.md gives 'release' to release-flow WORK as well as to the ceremony
PR — including every PR that improves this very workflow. The old assert
pair turned each of those merges into a red run on main. The fused decide
step reads the version against the PR base and answers all four states:
-dev unchanged = work, green NOTICE no-op; bare unchanged but already
released = work in the post-release window, same no-op; -dev-but-changed
and bare-unchanged-never-released = half-ceremonies, refused loudly;
bare-and-changed = the ceremony. Later steps gate on its output.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 15:23:56 +00:00
dan-claude-bot
4ce1ab50aa feat: merging a release-labeled PR is the release (#47)
The rig twin of heavy-duty/box#96, from the release-ceremony retro: the
tag was a separate, manual, silent-when-forgotten step, and a forgotten
tag produces no red X — the worst failure shape. The ship decision
already lives in the release PR; merging it is "ship". After that,
tagging is transcription, and transcription belongs to machines.

release.yml now also fires on pull_request closed into main, gated on
merged AND the `release` label. The job asserts in order, each fail-loud
and creating nothing: VERSION at the merge commit is non--dev; VERSION
changed in THIS PR (base vs merge — the interlock that fails a
mislabeled ordinary PR); the changelog section for that version extracts
non-empty via the existing changelog_section from release-lib.sh; and no
tag or release exists yet. Then, in the same job, it API-creates the tag
at the merge commit and publishes the release with the extracted notes.
Same-job is load-bearing: a GITHUB_TOKEN-created tag does not fire the
tag-push trigger, so the publish must live next to the tag and the
fallback job cannot double-publish; the nothing-exists assert covers a
manual race. The tag-push path survives verbatim as the documented
manual fallback and backfill, and CONTRIBUTING's Releasing section now
reads merge-is-ship with the manual tag as fallback.

test/release.sh pins the merge path in the house grep-pin style: the
merged+labeled gate, the four asserts, the same-job tag+publish (awk
from release-on-merge: to EOF), the asserts-precede-the-tag ordering,
and the surviving tag-push trigger.

Fixes #47

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 15:19:55 +00:00
dan-claude-bot
b6205d81c5 feat(release): CHANGELOG.md and release.yml — a tag becomes the release it claims (#32)
The PR-then-tag half of box#83's flow, rig's side. CHANGELOG.md starts
with an Unreleased section (feature PRs land their entry as part of the
PR — box's convention, now written into CONTRIBUTING alongside the
release ritual). On a tag push, release.yml asserts the bare tag equals
the tree's own VERSION — a mismatch fails loudly and creates nothing —
then creates the GitHub release with that version's changelog section as
the body, extracted by changelog_section in .github/scripts/release-lib.sh:
one function, sourced by the workflow and driven by test/release.sh
against fixtures and the shipped CHANGELOG.md itself. No assets — for a
pure-bash tree, the tag's source tarball IS the package.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 20:57:13 +00:00
dan-claude-bot
27fb934780 fix: verdict contract, head-bound approvals, serialized reconcile — and a testable state machine
Round-1 blockers, all three reviewers concurring:
- COMMENTED agreement now counts: agreement_signal recognizes the live bots'
  durable markers (Verdict: Approve / I agree with everything / leading ) —
  the gate to needs-human can actually close. Formal verdicts remain the
  contract (CONTRIBUTING), this is the documented transitional workaround.
- Every counting verdict is bound to the head SHA; a stale approval parks the
  PR in addressing (agent owes re-request) instead of promoting unreviewed
  code. CHANGES_REQUESTED blocks at any head, per GitHub's own semantic.
- reconcile serializes under ONE job-level concurrency group; scope stays
  per-PR. No more cron-vs-event race on the request-the-human-once guard.
- Sweep resilience: per-PR subshell (one failure logs and continues), label
  edits warn instead of wedging; the self-heal claim now matches reality
  (dispatch-only bootstrap).
- The state machine is extracted pure (globals in, state out) and sourceable:
  test/labels-reconcile.sh proves 14 fixture transitions — comment-only
  agreement, stale approval, comment-without-verdict, human precedence and
  human-block — wired into CI.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 19:31:02 +00:00
dan-claude-bot
7109bb39d8 feat: label automation — the state reconciler, path-scoped labeler, and CONTRIBUTING
The machinery LABELS.md promised. labels.yml runs the reconciler on a
15-minute cron plus PR events (pull_request_target — every PR here is from a
fork, where pull_request gets a read-only token; no PR code is ever checked
out). The script derives each open PR's state:* from GitHub's own facts and
converges labels statelessly; stale is judged from real activity (commits,
comments, reviews), never label churn, so the sweep cannot un-stale its own
mark. actions/labeler applies scope:* from changed paths. CONTRIBUTING.md is
the guideline: the PR loop, and who sets which labels. Rehearsed with
DRY_RUN=1 against the live repo; shellcheck-clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 18:31:53 +00:00
Claude
9551ad482f test(db): add real dump/restore round-trip probe + CI job + manual proof docs
The db PR only unit-tested arg parsing; this adds executable proof that
dump/restore actually works end to end.

- test/db-integration.sh: stands up two throwaway Postgres containers whose
  superusers DIFFER by construction (src_super vs dst_super), seeds a known
  checksummable fixture, runs the real `rig db dump`/`rig db restore`, and
  reads the rows back out — proving both invariants db.sh cares about: the
  code reads the container's OWN $POSTGRES_USER/$POSTGRES_DB (a hardcoded
  `postgres` would break on the non-default source superuser), and
  --no-owner --no-acl makes the dump portable across differing superusers (a
  plain dump would abort under ON_ERROR_STOP=1 on the missing role). Also
  asserts default-outfile naming, restore idempotency (--clean --if-exists),
  and the named-[db] scratch-database path. Skips cleanly (exit 0) when Docker
  is absent/unreachable or root is unobtainable; always cleans up via trap.

- ci.yml: separate `db-integration` job on ubuntu-latest (Docker preinstalled),
  kept apart from the fast shellcheck+cli.sh `check` job so an image pull can't
  slow lint feedback.

- README: "Verifying a dump/restore actually works" — the safe manual
  round-trip against a real Coolify container via a fresh scratch db, echoing
  "a backup you have never read back is not yet a backup."

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 15:48:51 +00:00
e2a0951a91 ci: enumerate shellcheck inputs with globstar, not by hand
The hand-listed inputs under-covered silently: commands/*.sh does not glob
into lib/, and the previous fix patched that by adding one more path — which
leaves the same hole open for the next subdirectory. Verified: with a broken
script at commands/deep/nested/bad.sh, the hand-listed invocation still exits 0.

`shopt -s globstar` + bin/* **/*.sh covers every script in the repo, including
the extensionless bin/ entrypoints. The list is printed before it runs, so
coverage is visible in the CI log rather than assumed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 19:59:28 +00:00
9c6c6c9477 fix(ci): shellcheck -x, and lint commands/lib/ at all
The runner-* commands source commands/lib/runner-config.sh. Without -x,
shellcheck will not follow that source even though the files carry a
`source=SCRIPTDIR/...` directive, so it reported SC1091 on all three —
and shellcheck exits non-zero on any finding, info-level included.

commands/*.sh does not glob into lib/, so runner-config.sh was being
linted by nothing at all. List it explicitly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 19:56:33 +00:00
df17851108 refactor: rename deployor to rig; canonical heavy-duty/rig URLs 2026-07-11 08:25:48 +00:00
924090a427 feat: curl installer, shellcheck+tests CI, living README 2026-07-11 08:25:11 +00:00