Commit graph

41 commits

Author SHA1 Message Date
claude-bot-andresmgsl
53ef175662 test: release.test.ts keeps only cast's own surfaces; changelog line
The machinery halves — notes extraction, arming, monotonicity, the drill
gate, the old workflow's shape pins — go where their subjects went:
ceremony's test/ carries changelog.test.sh, changelog-armed.test.sh,
changelog-monotonic.test.sh and drill-recorded.test.sh at the 0.1.0 pin
(upstream equivalence verified per ceremony#13's rule), and the pinned
actions enforce them in ci.yml. What stays, retargeted at what can still
break HERE: the caller stubs' load-bearing shape (one push key, the
package-json backend, CONSUMERS.md's same-tag rule across all six
ceremony references), the artifact hook's install contract (asset name,
staged layout, no tests, own toolchain), the drill doctrine in cast's own
docs (independence + candidate-ref pinning, now against drills/README.md),
and the installer's three channels, untouched. 1374 -> 418 lines;
722 tests green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 14:03:15 +00:00
dan-claude-bot
0bafb9cd47 release: 0.2.0 2026-07-21 17:44:01 +00:00
dan-claude-bot
e690c89fee refactor: one drill record per version, in drills/
Drill records move from sections inside drill/RUNS.md to one file per
version: drills/<version>.md. The old guard parsed headings — em-dash
field matching, an optional ' — DATE' tail, a whole-version comparison
so 0.2.0-rc1 could not satisfy 0.2.0, a '(NF == 5 || $6 == dash)' tail
constraint to match box, and a non-blank body rule.

All of that existed only because records shared one file, and this repo
shipped two defects out of the complexity in review: the sed '/./,$!d'
whitespace bypass, and heading-grammar drift from box's stricter form.
One file per version makes nearly all of it unrepresentable — 0.2.0.md
and 0.2.0-rc1.md are simply different files, so the whole-version rule
is the filesystem's rather than a comparison anyone can get wrong.

One rule survives: a file of only whitespace is not a record.

Plain drills/, not .drills/ — dot-directories are invisible to globs
without dotglob, the cause of #118/#121 here and box#116.

drill/RUNS.md is deleted. It was created in this same unmerged PR and
held only format documentation, no real records; the useful reasoning
moves to drills/README.md. (box keeps ITS drill/RUNS.md, a genuine
harness log with real run history.)

The docs also drop an over-constrained ordering claim: the three repos'
drills are INDEPENDENT, run in any order and any sitting. What makes
that safe is that each pins the same fixed set of candidate refs — and
that pinning, not sequencing, is what dissolves the box/rig recursion,
since refs are static identifiers that exist as soon as the release
branches do. Each repo also drills a different thing: box the isolation
contract, rig convergence, cast promotion.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 16:36:13 +00:00
dan-claude-bot
2578a570ca feat: CI refuses a release PR with no drill record
CONTRIBUTING has always asked for the full real-hardware drill on a
release. Nothing asserted it, so it was performed exactly as often as a
reviewer remembered to ask — which is never, across every release in the
family, until a reviewer bot finally blocked on it. The gate moves out of
memory and into the tree.

drill/RUNS.md is cast's own run log, starting empty: no fabricated
history, and an honest note that cast has no drill harness script yet —
its legs are run by the documented procedure. The file is the record, not
the instrument.

.github/scripts/drill-recorded.sh reads package.json and asserts that a
bare version has a non-empty '## Release drill — X.Y.Z' section. A -dev
tree has no ship claim and passes trivially. The version is matched
WHOLE via awk field equality, release-notes.sh's fix for the same trap:
0.2.0 is not satisfied by 0.2.0-rc1, or the reverse.

It requires a RECORD, not a PASS. A maintainer waiver is legal and is
itself a section in drill/RUNS.md, so skipping the drill stays possible
and stays a deliberate, reviewable commit rather than an oversight.

The drill itself is ONE orchestrated run over the whole stack: rig
bootstraps a bare host and installs box, box new mints a seed, the seed
calls rig back to converge, and cast's legs run on the result. rig sits
below box and above it, so the repos are mutually recursive rather than
linearly ordered and their releases are not published in a fixed
sequence. The run pins candidate refs (RIG_REPO/RIG_REF at mint time),
so no repo must ship before another can be drilled, and drilling the
candidate is drilling the release — a release diff is the version file
and CHANGELOG.md, nothing executable.

Each repo records its own legs from that run, citing the shared run ID
and the other repos' SHAs. cast never reads box's or rig's drill log to
decide whether cast may ship: a cross-repo lookup degrades to "pass" the
moment it fails to resolve — the unreadable-rollup class.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 15:24:08 +00:00
dan-claude-bot
79f754549b docs(changelog): one line per entry, and a pass over the whole file 2026-07-21 14:27:34 +00:00
dan-claude-bot
bf0dfd8fca fix: route the #124/#125 test files through tmp() 2026-07-21 13:47:29 +00:00
Daniel Marin
1acf172cad
Merge pull request #124 from dan-claude-bot/feat/github-app
feat: cast github-app create/register — run the App Manifest flow instead of transcribing it
2026-07-21 14:30:13 +01:00
Daniel Marin
7e8f54b47b
Merge pull request #122 from dan-claude-bot/test/shellcheck-sweep-floor
fix: floor the shellcheck sweep on bin/cast, and stop skipping newline-less files
2026-07-21 14:29:57 +01:00
Daniel Marin
da5f6f2738
Merge pull request #120 from dan-claude-bot/fix/test-tmpdir-leak
fix: reap temp dirs — a runtime clone leak in resolveCheckout, and 68 uncleaned test sites
2026-07-21 14:21:59 +01:00
dan-claude-bot
5a1ec74e04 fix: persist the manifest conversion before the install poll can lose it
All three reviewers, independently: `createGithubApp` held the one-shot
conversion payload in memory across `awaitInstallationId` — a ~5 minute
poll — and `persistCredentials` ran only inside `registerGithubApp`. A
timeout, a dropped network or a Ctrl-C during that wait destroyed a
private key and client secret GitHub never re-shows, and left the App
orphaned on GitHub. The timeout message then claimed the credentials
were "already there" under `<state>/github-apps/`, which was false on
exactly the path that printed it.

The payload now goes to disk the instant the exchange returns, complete
but for the installation id — the one field GitHub will answer again as
often as it is asked. It is written as `installation_id: null` and
backfilled on success; `writeCredentialsRecord` allows precisely that
one transition and refuses every other difference, so nothing
irreplaceable is ever overwritten silently. The timeout path now names
the two files it wrote and prints the `register` command that finishes
the job, and says not to re-run `create`.

claude-bot's addition: persisting post-conversion could still throw in
`writeExclusive` against a stale `<name>.pem`, losing the fresh key just
the same — and that refusal's remedy ("pass --force and re-run") would
mean minting a second App. So the collision is pre-flighted before the
browser flow starts, when nothing exists and nothing can be lost. The
post-conversion persist now only ever meets a clean slot or an exact
match, and `writeExclusive`'s wording stays honest for `register`.

grok #2: re-running `register` to re-check a failed repo-visibility
assertion used to re-POST the key and the App first. Coolify does not
de-dupe by name — `GithubController@create` validates
`'name' => 'required|string|max:255'` with no `unique` rule and calls a
plain `GithubApp::create()`, and the vendored OpenAPI documents no
conflict response — so following that advice created a second Source
every time. Both verbs now read `GET /github-apps` first and verify an
existing record of that name instead of creating another; a name held by
a different App, or already duplicated, is a hard error. An unreadable
list warns and proceeds rather than blocking a bootstrap command.

grok #3: `name` becomes `<name>.pem`/`<name>.json`, so separators, dot
references, empties and control characters are rejected where the name
is resolved and again where it becomes a filename.

grok #4: every GitHub request now sends `User-Agent: cast/<version>`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 13:11:15 +00:00
dan-claude-bot
a9805d31bd feat: cast github-app create/register — run the App Manifest flow instead of transcribing it
The GitHub App was the one piece of a Coolify instance cast could not
reproduce. There is no REST endpoint that creates one — no POST /apps, no
GraphQL mutation, no `gh app` subcommand, no PAT scope — so `create` runs the
only programmatic path there is: GitHub's App Manifest flow, a one-shot page
served on 127.0.0.1 whose form POST the operator's own browser session
authenticates, followed by an unauthenticated code exchange.

That exchange is the only moment GitHub yields the private key, the client
secret and the webhook secret together; all three are persisted to
<state>/github-apps/ at 0600 under a .gitignore of `*`.

`create` does not reimplement `register`: it obtains credentials and then calls
exactly that path. Both verbs end at GET /github-apps/{id}/repositories,
asserting the repo is actually reachable — the check that turns a silent
misconfiguration into an error next to the thing that caused it.

github_apps.<org>/<repo> in environments.yaml (--name only seeds an absent
entry, and is refused when it disagrees), the client secret is stdin-only, and
--webhook-secret is optional. scripts/register-github-app.sh is deleted.

No new dependencies: node:http for the callback, node:crypto's
createSign("RSA-SHA256") for the App JWT that recovers the installation id from
the App's own key rather than from a spoofable redirect parameter.

Closes #7

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 13:11:15 +00:00
Daniel Marin
f2c2bb3470
Merge pull request #125 from dan-claude-bot/feat/app-basic-auth
feat: an application can declare HTTP basic auth, and apply sets it
2026-07-21 14:03:06 +01:00
dan-claude-bot
5fa484b5d2 fix: floor the shellcheck sweep on bin/cast, and stop skipping newline-less files
#119's class check asserts the swept set covers `git ls-files '*.sh'`.
bin/cast has no `.sh` extension: it enters the set through the shebang
scan, so it is covered by the DERIVATION and not by the ASSERTION. Break
or delete that scan and the shipped entrypoint drops out of the lint
while the check still exits 0 — #118's failure mode (a sweep quietly
narrowing while CI stays green) one level in from where #119 closed it.

There is no non-circular way to re-derive "every extensionless shell
script" inside the script; any second derivation would be the same
shebang scan and would break with it. So the floor is named rather than
computed: `required=(bin/cast)`, asserted present in the swept set. A
rename turns it red, which is correct — the floor is the thing that has
to be updated deliberately. A minimum-count assert was considered and
declined: given the *.sh class check already floors the set, a count
floor's only marginal coverage is "at least one extensionless script
exists", which the named floor states more precisely and with a better
error message, and it would churn on every script added or removed.

Proven to bite. With the shebang allowlist stubbed to match nothing, the
*.sh class check still PASSES and the new floor fails:

    shellcheck-all: 'bin/cast' is not in the swept set
    it has no .sh extension, so it enters only via the shebang scan above —
    that scan is broken, or the file moved. See #121.

Reverted, the sweep is green over 8 scripts again.

Also fixed, from the same review: `IFS= read -r line <"$f" || continue`
skipped any file whose FIRST line lacked a trailing newline, because
`read` returns 1 at EOF even when it populated `line`. A shebang-only
file with no final newline was silently unswept. Now
`|| [ -n "$line" ] || continue`, which falls through on a populated
partial read and still skips genuinely empty files. Measured against a
tracked 9-byte `#!/bin/sh` with no final newline: the fixed scan sweeps 9
scripts including it, the old line sweeps 8 and omits it silently.

Closes #121

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 13:00:53 +00:00
dan-claude-bot
7f0e886851 fix: reap temp dirs — a runtime clone leak in resolveCheckout, and 68 uncleaned test sites
The suite allocated temp dirs at 68 sites across 21 files and removed none,
accumulating ~6700 directories and 189MB per machine-day, some holding age
keys. All 68 now go through a single `tmp()` helper allocating inside a
per-run root that vitest's globalSetup teardown removes wholesale, and a
class-guard test fails if `mkdtempSync` appears under test/ outside the
helpers.

The per-worker `process.once("exit")` reaper that suggests itself here does
not work under vitest and fails silently: the pool recycles workers by
killing them, so exit handlers registered in a test file never run. Measured
— a probe test writing from an exit hook produced no file, and a full run
with per-worker hooks still left 750 directories. globalSetup's teardown runs
in the main process, after every worker, and vitest awaits it.

Separately, and contrary to #117's framing that "cast itself does not leak":
resolveCheckout() mkdtemps an `infra-checkout-` dir, clones the infra repo
into it, and never removes it, so every `cast apply`/`diff`/`capture` without
--path leaked a full clone. The box that reported #117 was holding 602 such
directories, 73MB of real .git trees, from the same day. The leak fires on
the failure path too, since the dir is created before the clone runs.
Ephemeral checkouts are now reaped on process exit — the lifetime that fits,
since callers read the tree after resolveCheckout returns; a --path checkout
is the operator's own tree and is never registered.

Empirical: /tmp/cast-* + /tmp/infra-* count is 0 before and 0 after a full
`npm test`, against 750 with the exit-hook design. 626 tests green.

Refs #117
2026-07-21 12:58:14 +00:00
dan-claude-bot
4de89c360b feat: an application can declare HTTP basic auth, and apply sets it
UNCAPTURED.md has said since it existed that Basic Auth is "carried as raw
container labels. cast's manifest has no field for them, so a rebuilt resource
is UNPROTECTED where the original was not." For applications that is a cast
vocabulary gap, not a Coolify one: is_http_basic_auth_enabled,
http_basic_auth_username and http_basic_auth_password are in both the create
and the PATCH allowlists at v4.1.2 (ApplicationsController.php:914, :2368).

An application now declares `basic_auth: { enabled, username, password }`, with
the password a store ${REF} and only a ${REF} — the schema refuses a literal,
because a manifest is a committed file. It resolves out of the environment's
age store through the same mechanism every env-template ref uses, and a missing
or empty entry fails before anything is written.

Managing it is opt-in (the is_static rule): an unconditional `false` would have
the first apply after this ships strip protection off every app enabled by hand
in the UI. Enabling without both credentials is refused at parse time and again
at the wire — Coolify's own rule (:2446-2463), enforced before the request
rather than discovered as a mid-run 422.

The read side is fail-honest. The toggle and username are plain columns and are
compared, so a UI flip is caught. The password is gated behind a
sensitive-data-enabled token at 4.1.2 and read:sensitive on v4.2, and would have
to be printed as a field diff, so it is never projected into the comparison
vocabulary on any box — every diff of an app declaring basic_auth says the
password was NOT compared, in the backup schedule's voice: reported, not drift.

custom_labels stays deliberately unwired: enabling basic auth or changing
domains regenerates labels and overwrites it unless
is_container_label_readonly_enabled, which is not API-settable until v4.2.

The NO_API_COVERAGE row narrows to services, where it is a real API gap on both
releases, plus a separate row for custom_labels on applications.

Closes #76

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 12:38:03 +00:00
dan-claude-bot
1c38ef8988 fix: lint every tracked shell script, and prove the set is complete
Filed as cast's record of heavy-duty/box#116: a `shopt -s globstar;
files=(bin/* **/*.sh)` sweep never descends into `.github/`, because globs
do not match dot-prefixed names without `dotglob`. cast has no such sweep —
it has no shellcheck step at all. Its only shell gate was

    bash -n install.sh bin/cast scripts/*.sh .github/scripts/*.sh

a syntax check over a hand-maintained list. The reported symptom holds
(release-notes.sh and labels-reconcile.sh ship unlinted) but so does every
other script here, and `bash -n` parses without linting: it would not catch
a quoting or unset-variable bug in any of them.

.github/scripts/shellcheck-all.sh now runs `shellcheck -x` over the tracked
tree, from CI and from `npm run check:shell`. The file list comes from
`git ls-files`, not a glob. `dotglob` was measured and does work today —
cast's dependency tree ships zero `.sh` files, so sweeping after `npm ci`
pulls in nothing — but that is a property of somebody else's package tree,
re-decided by every install. `git ls-files` does not depend on it.
Extensionless scripts are matched by shebang, which covers bin/cast without
naming it.

It carries a class check in box#112's shape: the sweep asserts its own list
covers `git ls-files '*.sh'` and fails naming the strays otherwise. Verified
by swapping the derivation for the buggy globstar glob, which reports
exactly the two .github/scripts files.

All eight scripts pass as they stood; the three findings were intentional
($PATH written literally into a profile, advice text in backticks) or a
false positive, and are annotated in place. No behavior changes.

Refs #118
2026-07-21 12:30:52 +00:00
Daniel Marin
8f3a9fe468
Merge pull request #134 from dan-claude-bot/fix/changelog-monotonic
fix: assert no shipped changelog heading is deleted or duplicated
2026-07-21 00:37:01 +01:00
dan-claude-bot
84c592e961 fix(changelog-monotonic): report containment vacuous when the base IS HEAD
Dropping the pull_request gate made merge_base == HEAD a routine path rather
than a degradation, and the success line did not follow. On every push to main
the step printed "all N release heading(s) at the merge base are still present"
— a containment claim on the one event where deletion is undetectable, since
the comparison is the file against itself.

That is the dishonesty this PR fixed in the skip messages, surviving in the
success message. The line now has two forms: containment vacuous, naming
uniqueness as the half that ran, or the existing containment wording when a
real base exists. Both pinned, including that they do not collapse.

Also scopes the ci.yml negative pin to the monotonic step's own block. As a
file-wide assertion it forbade any FUTURE step in ci.yml from being
pull_request-gated and would have failed citing #133 when one legitimately was;
a companion assert keeps the extractor from silently matching nothing and
turning the negative into a tautology.

Ported from heavy-duty/box#144, where the defect was found after this PR's
approvals had landed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 20:49:53 +00:00
dan-claude-bot
0bd531042e fix(changelog-monotonic): check uniqueness before anything base-side
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, and it inverted them hardest here. Deletion
needs a diff to see; duplication is the one release-notes.sh actually
mis-renders, and cast has the ABSORBING extractor — no `exit`, so `grab`
re-arms on the second heading and the published body swallows whatever sits
between the copies (box#118). 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.

Found by claude-bot-andresmgsl and codex-bot-andresmgsl reviewing #134. cast
inherited the ordering from box, fixed there in heavy-duty/box#144 (#143).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 20:23:21 +00:00
dan-claude-bot
87bec2a5fe fix(changelog): cite the sibling PRs, not the tracking issues
The "Landed in all three repos together" line pointed at the sibling
tracking issues rather than the sibling PRs. The entry already opens with
its own issue ref, so a reader following "landed together" was sent to more
issues and never reached the actual sibling changes.

All three PRs carried it identically because the three entries came from one
generator that took sibling references from its issue-number map, and
expanded them into the sentence without re-wrapping — which is also why the
line ran to 108 columns in a file that wraps at 83.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 20:08:00 +00:00
dan-claude-bot
72030511b9 fix: assert no shipped changelog heading is deleted or duplicated
Release headings are append-only: the ceremony (#111) adds one and nothing
in CONTRIBUTING's release flow ever removes one. Nothing asserted that.

The arming rule (test/release.test.ts, rig#66) is narrow by design — it asks
whether the TOP section agrees with package.json's version, about ONE
heading, the one a PR is about to write under. It says nothing about the rest
of the file, and cannot: "a heading disappeared" is not a property of a tree,
it is a property of a DIFF.

So an author adding an entry under '## Unreleased' who types OVER the heading
below it instead of inserting above it produces a tree every existing guard
calls green. git merges it cleanly — a one-line edit in a file nobody touched
concurrently, no conflict, no signal. The shipped section's body is now
sitting under '## Unreleased' and the version it belonged to has no section
at all. It surfaces at the NEXT release, when release-notes.sh cannot find
the section it extracts by heading, or worse republishes the absorbed prose.

Ports box's changelog-monotonic.sh (box#122, caught in review of box#118)
rather than reimplementing the invariant a third time in TypeScript, and
keeps both halves. Containment catches a DELETED heading; it cannot catch a
DUPLICATED one, because a duplicate is head-side surplus and base-minus-head
is blind to extras on the head side. Uniqueness on HEAD is asserted alongside
it, and that half matters more in cast than in box: release-notes.sh's awk
has no `exit`, so `grab` re-arms on every matching '## ' line and two copies
of a version heading make the published body ABSORB whatever sits between
them — with the stranded entry dropped from the next release's notes too.
(rig's extractor truncates instead; cast has the absorbing one.) The existing
"double re-arm" test covers duplicate '## Unreleased' only, not duplicate
VERSION headings, which are the ones that reach release-notes.sh.

Wired into ci.yml as its own step so a red run names the invariant that
broke; pull requests only, because on a push to main the merge base IS HEAD
and the assert is vacuous; STRICT=1 with fetch-depth: 0 so a checkout that
cannot reach the base ref fails loudly instead of skipping quietly forever.
'## Unreleased' stays outside the guarded set — the arming rule owns that
heading and the ceremony legitimately consumes it.

Closes #133

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 20:00:12 +00:00
dan-claude-bot
d9bb634a5e 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
1f270c7578 fix(labels): a missing state label skips the edit, not the whole PR
The round-1 label pre-flight returned out of reconcile_pr when the desired
state:* label did not exist. That stranded the two things the function still
owed and which depend on no part of the state:* taxonomy: clearing a stale
merge-next, and the staleness sweep. A `merge-next` claim reading "merge this
one next" then survived on a PR the board had moved to the agent, and the
stale detector went quiet entirely.

This was a regression against main, not a missed improvement: main fails the
edit, logs, and falls THROUGH to both blocks. The pre-flight turned a per-edit
failure into a per-PR abort — and it was reachable without anyone deleting
anything, since a repo adopting this script before its first bootstrap has no
state:* labels at all.

Now a flag skips only the edit and control reaches the rest of the function.

Also taken, both from review: the dead "$desired" term in the filter loop (it
was appended and then unconditionally skipped, being checked separately), and
`[ -n "$missing" ] && log` becomes a proper elif rather than an &&-as-statement
under set -e.

Four new fixtures drive reconcile_pr itself with `run` and `gh` stubbed — the
first in this suite to reach past the pure functions, which is precisely why a
per-PR return was invisible to it. Restoring the return fails exactly those
two cold-start assertions and none of the other 70.

Fixtures 68 -> 72.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 18:09:16 +00:00
dan-claude-bot
f51ef29b79 fix(labels): never name a label the repo lacks, and do not read an unreadable rollup as green
Round-1 review fixes, canonical across box/rig/cast.

`gh issue edit --add-label` rejects the WHOLE call on one unknown label name,
applying nothing. Batching state and blockers into a single edit for
anti-flicker meant one missing `blocker:*` would take the `state:*`
convergence down with it — and since the taxonomy was only ever created by a
manual workflow_dispatch, the first sweep after the two-axis change would have
healed nothing on exactly the PRs it exists to fix, surfacing only as a log
line. The add side is now filtered against the repo's real label set, read
once per sweep. Removals need no filter (built from has_label, so they
provably exist); an unreadable label set filters nothing rather than
everything, because a failed read must not silently strip the board.

`checks_state` returns UNREADABLE when the `statusCheckRollup` key is absent —
what a failed `gh pr view` leaves behind — distinct from NONE for a
present-but-empty array. Collapsing the two let an API hiccup present as
"nothing is failing", i.e. as mergeable-by-a-human: the unknown-certified-as-
green shape this machine exists to stop, surviving where the #128 fix never
looked. The sweep now leaves that PR exactly as it is. Deliberately not a
blocker: blocking would flap the whole board on one bad call.

`blocker:unrequested` also fires on a STALE round, not just a MISSING one.
Both mean this head has no verdict from that reviewer and both owe an ask; the
stale round is the worse of the two, since it carries approvals on the page
that no longer describe the tree.

Fixtures 64 -> 68.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 17:50:03 +00:00
dan-claude-bot
f281b8c5ae refactor(labels): split PR labels into state (whose ball) and blocker (what is in the way)
`state:needs-rebase` is retired. PR labels now sit on two axes: `state:*`
(whose ball it is, exactly one) and `blocker:*` (what is in the way,
additive — conflict / ci-red / unrequested). One rule joins them:
`state:needs-human` requires zero blockers.

The single-label design projected independent facts — mergeability, check
status, where the review round stands — onto one totally-ordered value. A
total order must pick a winner, so the rest silently vanished, and every
precedence bug this machine has had lived on that ordering.
`state:needs-rebase` was the clearest casualty: it fired on both a conflict
and a red check, which need opposite work, and told an agent to rebase when
what it owed was a bug fix. Blockers are a set, so there is no precedence
between them to get wrong; what remains on the ordered axis is purely about
reviews, the one place an ordering is meaningful.

`state:bots-reviewing` tightens to mean strictly "a request is live". A ready
PR nobody was asked to review is `state:addressing` + `blocker:unrequested`,
not "waiting on the reviewers" for the 48h it took the stale sweep to notice.

The reconciler carries a RETIRED array and strips `state:needs-rebase` on
sight, so the retirement heals the board instead of stranding a label nothing
recomputes. Fixtures 51 -> 64.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 17:31:18 +00:00
dan-claude-bot
3766a15022 fix(labels): date a run by when it began, not by its newest stamp
Round 4 of #128. @claude-bot-andresmgsl and @codex-bot-andresmgsl again
converged on the same defect, in the round-3 dating expression itself.

`max` over [startedAt, createdAt, completedAt] resolves to completedAt
for a finished run and startedAt for a live one. Those are different
quantities, so the comparison was never an ordering on runs — it was
"newest stamp of any kind". A run cancelled by the concurrency group does
not stop the instant its replacement starts; the runner has to wind down,
so predecessor.completedAt > successor.startedAt is the ordinary case
rather than a corner. On box's aa5a6ba the superseding run started
15:19:38 and the run it cancelled did not finish until 15:19:51 —
thirteen seconds in which the dead predecessor out-dated the live run
that replaced it, and the collapse discarded the wrong one.

That narrowed round 3's two failures without closing them: a CANCELLED
predecessor read FAILURE and a SUCCESS predecessor read SUCCESS, where
both should be PENDING. The second is #136 restored — needs-human over a
tree whose merge button branch protection has disabled.

The list is already in preference order and the select leaves only stamps
the run actually carries, so `first` is exactly "date it by when it
began, falling back only if it never recorded a beginning".

Fixtures 49 -> 51. None of the existing 49 could see this: every one
spaces the predecessor's completion before the successor's start, and
run_() carries no startedAt at all, so the overlap needed explicit
payloads. Both new fixtures fail under `max` and the other 49 do not.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 16:32:22 +00:00
dan-claude-bot
5d39783b77 test(labels): pin the reverse direction of the supersede rule
A finished re-run that completed after an earlier in-flight entry is the
newer word on its context, and the context is settled. Nothing asserted
that, so "an undateable/in-flight run sorts last" could be widened into
"in flight always wins" — sort_by([(.outcome == ""), .at]) — and the
suite stayed green. It now fails exactly this fixture and nothing else.

Also records why the undateable fixture is non-vacuous: it is guarded by
the sort tiebreak, not the dating expression. Reverting only `at:` leaves
it passing, so the two code changes are separately pinned rather than
jointly credited to the dating fix.

Fixtures 48 -> 49.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 16:23:09 +00:00
dan-claude-bot
ef230e4a99 fix(labels): date a check run by when it started, not when it finished
Round 3 of #128. @claude-bot-andresmgsl and @codex-bot-andresmgsl
independently found the same regression in the round-2 supersede rule.

The collapse-to-newest step dated each run by
`.completedAt // .startedAt // .createdAt`. A run still in flight has no
completion, but gh does not omit the field — its Go struct marshals the
zero time as the string "0001-01-01T00:00:00Z", and jq's `//` only falls
through on null/false. So the sentinel was taken as the sort key and
sorted below every real timestamp: the live re-run went to the bottom of
its context and `last` discarded it, judging the very run it superseded.

That inverted the rule in both directions. A green context with a
replacement mid-flight read SUCCESS — #136 restored, needs-human pointing
a human at a disabled merge button — and a CANCELLED original whose
replacement was still running read FAILURE, the flap the collapse was
added to prevent.

A run is now dated by the newest timestamp it actually carries, with both
spellings of absent discarded (null, and the zero sentinel), rather than
by assuming which field is populated. An entry carrying no usable
timestamp sorts last rather than first: something undateable is most
likely the thing just created, so ambiguity resolves toward "not settled"
instead of toward a stale success.

Fixtures 44 -> 48. The gap was structural — the existing run_() helper
always sets a real completedAt, so every supersede fixture raced two
finished runs and none could express an in-flight one.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 16:19:57 +00:00
dan-claude-bot
d8f73f446d fix(labels): unknown check outcomes and mixed rounds must not read green
Round 2 of #128. Two blockers from the bot panel, both real holes in the
invariant this PR exists to establish.

The check-rollup classifier enumerated the outcomes that block and
defaulted everything else to SUCCESS, so ERROR, CANCELLED and STALE fell
through to green. Inverted to an allow-list of the outcomes that do NOT
block — SUCCESS, NEUTRAL, SKIPPED and the pending set — with everything
else blocking. The rollup mixes two closed enums (CheckRun.conclusion and
StatusContext.state) and the costs are asymmetric: a false failure parks
the PR on the agent, who looks; a false success invites a human to merge a
tree that will not merge. Superseded runs are dropped first, each context
collapsing to its newest entry keyed on workflow + job name, so a re-run
does not strand its own PR in needs-rebase. The classifier also moved out
of main() into checks_state(), which is why no fixture caught this — it
was inline in the fetch loop and could only ever be injected pre-decided.

decide_state() returned from inside the bot loop on the first MISSING, so
a STALE belonging to a later bot in BOTS was never read, and a round that
was both unfinished and staled came out needs-human over a head nobody had
reviewed — the original bug wearing a different hat. The whole round is
now collected before any precedence is applied, STALE checked before
MISSING. The MISSING-yields-to-an-explicit-human-request rule is untouched.

Fixtures 29 -> 44, pinning the check-outcome enum, the supersede rule at
both orderings, and the mixed round at both ends of BOTS.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 16:06:41 +00:00
dan-claude-bot
b063e6bc42 fix(labels): state:needs-human means a human could merge it right now
Ported from heavy-duty/box#137 (heavy-duty/box#136) so the three repos'
reconcilers stay byte-identical. The state machine here was byte-identical to
box's before this change and remains so after -- only the scope:* taxonomy
differs, correctly.

decide_state() derived state from three inputs -- draft flag, requested
reviewers, submitted reviews -- and read NOTHING about mergeability or checks.
With the `if requested "$HUMAN"` short-circuit at the top of its precedence,
the label was sticky: once the maintainer was requested, a PR read
state:needs-human through conflicts, through red CI, through a force-push that
staled every approval.

In this repo the SECOND half is the live one: three PRs sit at
state:needs-human simultaneously with nothing saying which to merge first, and
they will conflict through CHANGELOG.md the moment one lands. The stickiness
has not bitten here yet only because nothing has conflicted -- the code carried
it identically, so the first merge would have reproduced box's situation.

The rule the label now keeps: state:needs-human means a human could merge this
RIGHT NOW, so anything making that false outranks the request that put it
there.

  CONFLICTING or failing checks -> state:needs-rebase (new; the agent's to fix)
  approvals staled by a push    -> state:addressing   (nobody reviewed this tree)

An UNFINISHED round still yields to an explicit human request -- MISSING
(nobody has reviewed yet) is a different fact from STALE (everyone reviewed
something else). UNKNOWN mergeability is NOT treated as unmergeable: GitHub
reports it for about a minute after every merge, and flapping every open PR
through needs-rebase on each merge would be worse than the bug. A failed read
degrades to the same "do not know" value.

Also adds merge-next -- the label this repo needs most today, since a correct
needs-human still does not say which of three ready PRs to merge first. Queue
order is intent, so the reconciler never sets it, only CLEARS it.

Fixtures 19 -> 29. DRY_RUN against this repo changes NOTHING, which is the
correct result: every open PR here is currently mergeable, so the new
precedence is a no-op on a healthy board and fires only when something is
actually wrong. npm test 623 passed.

Closes #127

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 15:28:50 +00:00
dan-claude-bot
ee9c832e93 release: 0.1.1
Stamps `## Unreleased` as `## 0.1.1 — 2026-07-19` and re-arms the
changelog with a fresh, empty `## Unreleased` above it — both halves in
this diff, per CONTRIBUTING's ceremony (#113/#114; heavy-duty/rig#66).

Bumps package.json and package-lock.json 0.1.1-dev -> 0.1.1. Patch is
correct: the shipped section carries only `### Fixed`.

This is the first cast release to go through the merge door. 0.1.0 could
not — the interlock refused it (run 29698017907) because cast had said
`0.1.0` since its first commit, so there was no `-dev` transition to
detect, and it shipped by the manual tag path. main now genuinely reads
`0.1.1-dev`, so release.yml's decide step sees bare-and-changed: the
ceremony state.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 21:32:51 +00:00
dan-claude-bot
22a4e26e02 fix: the arming guard names the section that ships, not the top one
Review round on #114. Both blockers were real and reproduced here.

The re-arm and the older extraction guard contradicted each other: that
guard asserted the TOP section extracts non-empty, and CONTRIBUTING now
mandates a deliberately EMPTY `## Unreleased` on top of the stamp. The
mandated ceremony tree was CI-red — #108's unshippability by another
route. Keying to the top section was only ever a stand-in for "what
release.yml publishes", so the assert now names that section: on a bare
version the `## X.Y.Z` being shipped, on a `-dev` tree the newest
stamped one. rig#67 retargeted the identical assert for the same reason.

And a bare version with no matching stamped section — bumped, never
stamped — passed every test and failed only after the merge, in
release.yml's notes step, past the ship decision. Red here instead.

Plus a double re-arm (two `## Unreleased` headings, the extracted
section silently the empty first one) is now red.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 20:33:49 +00:00
dan-claude-bot
3e1b579e61 fix: the ceremony re-arms the changelog, and CI notices when it doesn't (#113)
Repair main's missing `## Unreleased`, re-arm in the CONTRIBUTING ceremony
step, and add a version-keyed guard in test/release.test.ts.

Cross-refs heavy-duty/rig#66 (origin, confirmed occurrence) and
heavy-duty/box#108 (box sibling).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 19:39:45 +00:00
dan-claude-bot
008161ab18 release: 0.1.0
cast's first release: package.json has said 0.1.0 since the first commit,
so the ceremony's bump is a no-op and this PR is the stamp — the
Unreleased section becomes 0.1.0 with today's date. The tag will make it
true: release.yml builds the prebuilt cast-0.1.0.tgz asset and the
installer's default channel goes live. Drilled twice on 2026-07-19,
including the asset path end to end on a scratch fork.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 17:33:54 +00:00
dan-claude-bot
b92919d4d0 fix: pull-requests scope for the door's two PR-API calls; docs catch up to the shipped shape
claude-bot's round-4 blockers: (1) a declared permissions: block zeroes
every unspecified scope, so the decide step's label read
(commits/<sha>/pulls) and the bump fallback's gh pr create could only
403 — every genuine ceremony would end red at the label check, the exact
failure shape this feature exists to kill, one layer down. labels.yml
already carries the precedent; pull-requests: write added with the two
consumers named. (2) CONTRIBUTING still prescribed the follow-up bump PR
the workflow now performs itself, and the changelog entry described the
old PR-base interlock — both now tell the shipped story (event.before
interlock, self-re-arm, manual-path bump stays the operator's). Nits
taken: fetch-depth 2 for the all-zeros fallback's first parent, re-runs
refuse-loudly wording, the bump-window arithmetic comment.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 16:31:40 +00:00
dan-claude-bot
775af63063 feat: merging a release-labeled PR is the release (#111)
box#95 taught the family that a forgotten manual tag is the worst
failure shape: silent, no red X, a release that simply doesn't happen.
The ship decision already lives in the ceremony PR — the one whose whole
diff is the version leaving -dev, carrying the reviews and the
maintainer's merge — so tagging after it is transcription, and
transcription belongs to the machine (box#96's design; this is cast's
twin).

release.yml now also triggers on pull_request closed against main,
gated on merged == true AND the hand-set release label. The merge path
asserts four facts in order, each fail-loud and creating nothing: the
merged package.json version is non--dev (read via node, never regex —
the pkg_version discipline); the version CHANGED in this PR (base vs
merge — the -dev interlock, so a mislabeled ordinary PR fails loudly);
the version's changelog section extracts non-empty via the existing
release-notes.sh; and no tag or release exists yet (idempotent re-runs,
and the loud answer to a manual-tag race). Then, in the same job, it
tags the merge commit via the API and publishes. Same-job is
load-bearing: a GITHUB_TOKEN-created tag triggers no workflows, so the
tag-push path cannot fire on it and double-publish.

Both trigger paths converge on literally the same steps — each entry
step exports RELEASE_VERSION, and the notes extraction, the exact
existing asset build (npm ci, npm run build, npm prune --omit=dev,
staged as cast-X.Y.Z/), and the gh release create read only that — so
the paths cannot drift and the installer keeps finding the one asset
name it knows, cast-X.Y.Z.tgz. The tag-push path survives as the
documented manual fallback and backfill, and it matters immediately:
0.1.0 never carried -dev (cast predates the ritual), so the interlock
correctly does not fire for #110's ceremony — that one ships by manual
tag, and the automation applies from 0.1.1 on.

test/release.test.ts pins the new wiring in the house grep style,
fail-closed: the merged+labeled gate, the four asserts strictly ordered
ahead of tag/build/publish, the single job, the anti-recursion comment,
and that no per-path asset name exists. CONTRIBUTING.md's Releasing now
says it plainly: merge is the ship decision; the tag is the fallback.

Fixes #111

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 15:21:07 +00:00
dan-claude-bot
f9187272ec fix: the release suite accepts the ceremony's own tree (#108)
release.test.ts demanded the real changelog's literal Unreleased section
extract non-empty containing '#96' — false by construction on the very
tree the release PR produces, so the first real 'release: 0.1.0' PR
turned CI red and the ceremony blocked itself. Fork rehearsals missed it:
a tag push runs release.yml, never ci.yml. The guard now asserts the TOP
section, whatever its name, extracts non-empty via the exact tool
release.yml runs — verified on both legitimate tree states.

Fixes #108

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 13:50:03 +00:00
dan-claude-bot
8bf47954f8 fix: CAST_AGE_KEY_FILE_<ENV> maps to a name a shell can set (#102)
envName.toUpperCase() alone turned env 'drill-b' into
CAST_AGE_KEY_FILE_DRILL-B — a variable no POSIX shell can export, so the
injected-key channel (and its process-substitution trick) was unreachable
for every hyphenated environment name, while the refusal advertised it
anyway. Characters outside [A-Z0-9] now map to _; the standing-key path
keeps the exact env name, so names that collide on the variable still
resolve their own keys on disk.

Fixes #102

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 13:09:17 +00:00
dan-claude-bot
c42699d6b5 fix: a manifest with no ${…} refs applies without a store (#104)
The greenfield manifest-first bootstrap was a chicken-and-egg with no
exit, found by the 2026-07-19 release drill: fresh Coolify instance,
registered project, a manifest declaring databases only and resolving
zero ${…} refs. apply refused with "no secret store", and capture — the
documented way to get a store — rightly refused a project absent on the
box, because apply is the verb that would create it. The drill unblocked
with a hand-rolled empty age store, documented nowhere.

Now diff/apply gate the refusal on the manifest actually referencing a
secret, asked via requiredSecrets — the same parser resolution uses, so
the two cannot disagree. Zero refs: an absent store is treated as empty,
a loud one-line note names the path it would live at, and the age key is
not demanded (nothing to decrypt, nothing to protect yet). One ref: the
refusal returns byte-identical to before. capture and destroy are
untouched.

Fixes #104

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 13:06:34 +00:00
dan-claude-bot
b2801938a4 fix: resolve the GitHub App only when the manifest declares applications (#103)
Found live in the 2026-07-19 release drill: a manifest declaring only
databases (applications: {}) rendered its plan of two creates and then
died in preflight on "no GitHub App bound" — over a binding nothing in
the run would ever have used. A GitHub App exists to clone application
source, and cast reads it in exactly one call, the application create
(POST /applications/private-github-app); databases and services never
touch it. Resolving it unconditionally gated infra-only projects — the
databases a fleet's other projects share — behind the GitHub-App
browser-registration ceremony for no reason.

apply now resolves the App (binding lookup and uuid resolution both)
only when the desired state contains at least one application. The
executor's githubAppUuid field is typed string | null, and its single
consumer guards the null with cast's own internal error — unreachable
by construction, since a plan can only create resources the desired
state holds, but a null slipping onto the wire would otherwise surface
as a Coolify 422 about somebody else's field.

Keyed off desired rather than the plan's changes, deliberately: a
manifest that declares an application keeps the missing-binding refusal
even on a clean plan, byte-identical to before — that binding is state
the next create will need, and the operator should hear about it now,
not mid-bootstrap.

Fixes #103

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 12:26:35 +00:00
dan-claude-bot
ced5c497c0 feat: release flow — tagged releases with a prebuilt dist asset (#96)
The cast half of the flow designed in heavy-duty/box#83, aligned with
box#90 and rig#40, plus the piece unique to cast: a prebuilt release
asset, because cast is the one repo where the source tarball is not the
package.

- CHANGELOG.md (box's format) with this PR's entry under Unreleased;
  feature PRs land their entry as part of the PR.
- `cast --version` / `-V` answers with package.json's version, read
  relative to the compiled module so a source checkout and an installed
  prebuilt tree agree.
- release.yml, on EVERY tag push (no shape filter — a mismatched tag
  must fail the assert loudly, not be pattern-skipped): asserts tag ==
  package.json version FIRST, extracts that version's changelog section
  (.github/scripts/release-notes.sh, shared with the tests; missing or
  empty refuses), builds once (npm ci && npm run build && npm prune
  --omit=dev), stages bin/ dist/ node_modules/ package.json as
  cast-X.Y.Z/ and attaches cast-X.Y.Z.tgz to `gh release create
  --verify-tag`. No tests here — ci.yml gated the merge commit, and the
  suite needs age.
- install.sh grows the three channels: default = the latest release's
  asset (tag resolved off the releases/latest redirect Location — no
  API, no token; failure dies loudly naming CAST_REF=main, never a
  silent fallback), CAST_REF=<tag> = pinned (asset first, source
  fallback), CAST_REF=main = dev build-from-source. npm is required
  only on the source path, and a prebuilt tree is sanity-checked
  (dist/, node_modules/) before $DEST is replaced.
- test/release.test.ts drives it all offline: --version, the extraction
  against fixtures (0.7.0 never matches 0.7.0-rc1) and the real
  changelog, and REAL install.sh runs through all three channels with a
  stub curl and a poisoned npm — including the loud no-releases refusal
  with no $DEST side effects.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 22:27:55 +00:00