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>
All three reviewers found the same hole, and it contradicted this PR's own
documentation rather than merely being incomplete.
`completeBasicAuth` keyed on `fields.is_http_basic_auth_enabled !== true` —
the toggle being present IN THE PAYLOAD. But an update body is assembled from
the field diffs, and the toggle is absent exactly when it MATCHES. So on the
real drift case — basic auth already on at both ends, username edited in the
UI — computeDiff emits `http_basic_auth_username` alone, the guard returned
early, and the PATCH went out as a lone username. Coolify requires the whole
triple on any write that enables basic auth, so that is a 422 mid-run: the
precise failure the function exists to prevent, on the one path it was not
looking at.
The fix reads INTENT from the declared spec instead of from the payload, and
completes whenever the payload touches basic auth at all. Two properties are
kept deliberately:
- it still never MANUFACTURES a write — a payload mentioning no basic-auth
field is returned untouched, so the honest limit printed on every diff
still holds;
- a spec that does not enable basic auth completes nothing, so reading
intent from the declaration does not trade one silent wrong write for
another.
The toggle is now completed alongside the credentials: Coolify's presence rule
is about the write as a whole, and a credentials-only PATCH asks it to infer
what cast can state.
`applicationApiFields` shared the blind spot for the same reason — a lone
username has no toggle to be true, so the belt never tightened either. It now
refuses any partial basic-auth write, while still letting an explicit disable
travel alone and ignoring payloads that do not mention basic auth.
No documentation changed: docs/semantics.md:374 and the function's own comment
already promised the triple is completed "whenever it sends one of them". The
code simply did not do it. This makes them true.
Tests: the existing "only the username drifted" case passed the toggle in its
payload, so it never exercised the guard — which is why the hole survived
review-by-suite. Added the real shape (lone username, lone password, no
toggle), the spec-says-off case, three wire-level partial writes, and the two
non-write cases. Verified by mutation: restoring the payload-keyed guard fails
both new completion assertions.
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>
The new findIndex callback and the monoBlock array literal exceeded biome's
line budget, so `biome check --error-on-warnings .` failed and took the build
job red with it. Formatter output applied verbatim; no logic change, and the
extractor mutations still behave (unrelated job gated -> green, monotonic step
gated -> red).
My miss, and the same shape as the shellcheck one on box#144: I tailed two
lines of `npm run check` and never saw "Found 1 error". Ran CI's exact command
and read all of its output this time.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The monotonic step is the LAST step of its job, so stopping only at the next
`- name:` ran the block into the job below and swallowed that job level `if:`.
Unanchored `grep -q "if:"` then fired on it — the same bug the scoping was
meant to fix, moved from "any step in the file" to "this step plus the head of
the next job".
Terminates on a new step OR a new job now, and the key is anchored so an `if:`
inside a `run:` line is not mistaken for a step condition.
Found by claude-bot-andresmgsl on heavy-duty/box#144; this port carried the
identical awk.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
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>
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>
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>
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>
`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>
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>
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>
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>
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>
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>
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>
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>
grok's round-2 catches: (1) two sibling push: maps under on: leave only
the second alive — the tag-push fallback stopped triggering entirely;
both filters now live under one push key with the steps still split on
the pushed ref, and a pin counts exactly one on.push. (2) CI red was the
unformatted pin block — biome now clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Operator decision: the post-release bump PR is ceremony debris — a
derivable one-liner (package.json + lock, via npm, never regex) with no
judgment for a review to add. After tag + build + publish, the same job
computes X.Y.(Z+1)-dev and pushes it to main directly (a GITHUB_TOKEN
push fires no workflows: no recursion, no red run); if branch protection
refuses, the step opens the bump PR itself, loudly. Merge-door only —
the manual tag fallback does not rewrite main.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Round-1 blocker (grok; claude's box 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 transition from event.before (first-
parent fallback for the all-zeros edge) and the release label — still
the operator's declared intent — via the API off the merge commit's PR.
A transition with no labeled PR behind it refuses. The steps split on
the pushed ref: tags to the tag path, main to the merge path.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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>
LABELS.md gives 'release' to release-flow WORK as well as to the ceremony
PR — the PR that added the merge path included. The old assert pair turned
every such merge into a red run on main. The fused decide step reads the
version against the PR base and answers all states: -dev unchanged = work,
green NOTICE no-op; bare unchanged but already released = work in the
post-release window (cast's whole pre-0.1.1 era included), same no-op;
-dev-but-changed and bare-unchanged-never-released = half-ceremonies,
refused loudly; bare-and-changed = the ceremony. Shared steps gate on the
decide (tag-push path unaffected). Pins anchor on the echo strings, since
the workflow's own comment table paraphrases the states.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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>
biome (error-on-warnings in CI) rejects the ! assertion; an explicit
throw narrows properly and says what broke if the file ever has no
heading at all.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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>
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>
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>
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>
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>
claude-bot's round-2 catch: cast is the sibling that runs on the
operator's own machine, and the layout port carried two Linux
assumptions in with it.
- The atomic current flip spelled 'replace, don't descend' the GNU way
(mv -Tf); BSD/macOS mv has no -T and dies. The flip now rides node's
fs.renameSync — rename(2) is POSIX, node is a cast prerequisite on
every platform — as one flip_current(), byte-identical in install.sh
and bin/cast, added to the anti-drift diff.
- cmd_uninstall's de-dup used mapfile — bash 4, and macOS ships bash
3.2. Now a portable while-read append.
- readlink -f: Apple's readlink grew -f in macOS 12.3 (March 2022); the
installer now probes it once among the prerequisites and refuses
loudly on older systems instead of failing weirdly mid-flip.
- A portability test pins both spellings out of the two scripts, so a
reintroduction fails in CI, not on the first operator Mac.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Maintainer direction: this PR's one goal is the versioned layout, the same
one box#79 built and rig#36 ported — the release flow (tags, release.yml,
prebuilt assets, CHANGELOG) is its own PR later, the shape rig#40 has.
So: release.yml, changelog-section.sh, CHANGELOG.md and the asset-aware
installer channels leave this branch, and in their place cast gets the
family layout for real:
- install.sh lands each build at $DEST/versions/<package.json version>,
'current' names the default (atomic rename flips), $BINDIR/cast points
through it. Converging no-op on an installed version (nothing rebuilt),
CAST_REINSTALL=1 replaces, a new version installs beside and becomes
default. Pre-versioning flat installs migrate in place, bit for bit.
CAST_INSTALL_SOURCE=<dir|tarball> installs locally (CI/tests, rig's
RIG_INSTALL_SOURCE precedent). No flip gate: box refuses under live
boxes, rig warns on a converged host — cast is an API client, a flip
strands nothing, 'cast use <old>' is one command away.
- bin/cast grows the layout verbs in bash (they must work when dist/ is
broken): versions (marks current+running), use (atomic flip, then
asserts the chain ANSWERS the new version), uninstall (consent gate,
CURRENT guard, dangling-current guard, ends with the absence assert).
valid_version/pkg_version are byte-identical copies in both files; a
test diffs them so the gates cannot drift.
- cast --version stays: package.json is the single source of truth,
printed with the install root, rig-style.
- ci.yml gains the install job: the real installer, from this checkout,
layout asserted, converge no-op asserted, uninstall --all asserted
absent — the box CI precedent.
- Tests drive the REAL install.sh and bin/cast offline (npm shim, local
source): the layout, the chain answering end to end, no-op/reinstall/
side-by-side/migration semantics, the hostile-version gates, refs/heads
download, every uninstall refusal.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
cast gets the family's release flow (box#83's shape), plus the piece
unique to cast: because cast compiles, the source tarball is not the
package — so release.yml builds ONCE in CI and attaches cast-X.Y.Z.tgz,
and the installer's default channel extracts that asset instead of
running npm ci + tsc on the operator's machine.
- cast --version: package.json is the single source of truth (no VERSION
file); prints the install root too, rig-style.
- CHANGELOG.md with Unreleased; release notes are the curated section
(scripts/changelog-section.sh), never the auto-generated PR list.
- release.yml on a bare X.Y.Z tag: assert tag == package.json version,
check + build + test, prune, tar the runnable tree, gh release create.
- install.sh channels: unset → latest release asset (resolved via the
releases/latest redirect — no API, no token); CAST_REF=X.Y.Z → that
tag's asset; CAST_REF=<branch> → build-from-source, the old path.
- Tests drive the REAL install.sh offline via curl/npm PATH shims (all
three channels, plus the broken-asset and no-release refusals), and
the real changelog-section.sh against fixture changelogs.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
codex's late #85/#98 round-3 finding, valid post-merge: the needs-human
auto-request fired only when the human had NEVER reviewed, so any earlier
human comment or stale approval left a fully-approved PR labeled
needs-human with nobody actually requested — a wedged handoff.
human_request_needed() now asks whether a fresh head-current human review
is missing (live request or head-current approval → nothing to ask;
anything else → request). Five new fixtures cover the wedge, the stale
approval, the satisfied handoff, and request suppression (19 total).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Maintainer direction: body-parsing agreement was a guess, and the machine
must not guess. COMMENTED is now unconditionally a non-verdict; the judgment
that a comment-only reviewer's round passed belongs to the PR AUTHOR, who
escalates by requesting the human's review — an explicit request is a fact,
and it is the machine's top-precedence input. Auto-request survives only for
the no-judgment case: three formal head-current approvals. CONTRIBUTING and
LABELS.md state the handoff; fixtures updated (14 transitions, including
author-escalation and the three-formal-approvals path).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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>
#73/#81 made a service's per-container hostnames settable (urls) and
readable (GET /services/{uuid} -> applications[].fqdn), and diff/apply
carry them as service_domains — but the draft path was never brought
along: the inventory sweep's environment-list GET does not eager-load
service.applications, so --emit-draft emitted every service with no
hostnames and an UNCAPTURED hand-wave.
Now the draft loop makes the same supplementary per-service GET that
diff/apply make (sibling of #75's per-database backups read — one
design, both reads: ungated for DRAFTED resources only, sequential,
per-resource failure degrades to an UNCAPTURED entry instead of
aborting the whole-instance sweep).
The projection is SHARED, not duplicated: projectServiceDomains is
extracted out of attachServiceDomains and exported, so the draft emits
applications[].fqdn through the exact projection + canonicalization
(canonicalizeServiceDomains) the diff's read-back uses — a drafted
manifest diffs clean the moment it is applied. Its two absences stay
distinct: {} is an answer (no hostnames; nothing emitted, nothing
reported), undefined is "not read" — attachServiceDomains still fails
a one-project diff closed on it, while serviceSpec reports it per
resource and keeps sweeping.
The stale "service hostnames" NO_API_COVERAGE row and the
service_domains (hostnames) always-uncaptured entry are gone, and
semantics.md's "does not yet make the per-service GET" line now tells
the truth.
Closes#83
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A process substitution (`CAST_AGE_KEY_FILE_<ENV>=<(pm read ...)`) is a
read-once pipe, but `diff --all` / `apply --all` call decryptSecrets once
per project. The first project drained the pipe; every later project
re-read the key file, handed age an empty identity, and failed — the
fleet loop then misreported the project as unreachable (diff) or aborted
the fleet (apply). Latent today because only one registered project has
a prod store; real the moment a second one gains one.
Cache the key bytes by key path, module-level, so the identity is read
exactly once per process. Exposure is unchanged: the key already
transits this process's memory on every call.
The regression test uses a FIFO, which really drains — unlike the
existing /proc/self/fd test, whose regular file re-opens at offset 0 on
every read. A second writer serves emptiness after the first decrypt so
a regression fails loudly (age: no secret keys found) instead of
blocking the suite on a writerless FIFO open.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
--emit-draft still told every reader that backup schedules "are not
exposed by Coolify's API" — the exact pre-#51 claim that issue disproved:
GET /databases/{uuid}/backups is a route, and diff/apply have read it on
every run since. The draft path was never brought along, so it warned
instead of reading, and a rebuild from a draft came up with no backups.
Now the draft loop makes the same supplementary per-database GET
(databaseBackupSchedules) for every DRAFTED database and databaseSpec
emits a real backup: { frequency, retention } block for the one shape
the manifest can express — a single, enabled schedule. Ungated on
purpose: fetchLive's opts.backups gate exists because the read-side
sweeps never look at the answer, and the draft is the sweep that does.
The read stays sequential (like the existing per-resource env GETs) and
a failed read degrades to an UNCAPTURED entry per resource rather than
aborting the whole-instance sweep — a draft's reader is a human, not an
apply about to write.
UNCAPTURED keeps only what the route genuinely cannot answer:
- the S3 target: save_s3 now rides on LiveBackup, and a schedule that
saves to S3 gets a per-database entry saying the target reads back
only as s3_storage_id, an int nothing maps to a storage UUID
- a DISABLED schedule (declaring the block would make apply re-enable it)
- several schedules where a manifest declares one
- an unreadable route (reported, never read as "no backups")
The stale NO_API_COVERAGE "backup schedules" row becomes "a backup
schedule's S3 target", and semantics.md's draft section now tells the
truth about what is captured.
Closes#75
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Coolify 4.1.2 never serializes is_static on any read path — it lives on
the ApplicationSetting relation, which no read endpoint loads (cast#68).
diff already degrades the field honestly (staticNotCompared, warn-and-
skip, #69), but the draft path did not: applicationSpec emits
`static: true` only for a present truthy raw.is_static, so on 4.1.2 the
key is simply absent, the drafted manifest of a live static site
silently omits the flag, and UNCAPTURED.md said nothing. That breaks
the draft's own contract (#27) — a reviewer approving the draft has no
cue the field even exists to lose, and the #63 failure mode (static
site rebuilt and run as a plain app) re-enters through the draft door.
Now, when raw.is_static is absent/null (the same predicate the diff
path's staticNotCompared uses) and the app is plausibly static — a
nixpacks/static build pack with a publish_directory — the draft flags
is_static in UNCAPTURED.md as unreadable on this Coolify, telling the
reviewer to check the box in the UI and add `static: true` by hand if
set. A real boolean (a future Coolify) behaves exactly as before:
expressed in the manifest, never flagged.
Part of #70; the remaining items there are blocked on Coolify v4.2.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
/proc does not exist on macOS, and cast runs on the operator's
workstation. /dev/fd resolves on both platforms (on Linux it is a
symlink to /proc/self/fd) and is closer to what bash expands <(...) to.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A prod box with zero drift could not make `cast diff` say clean: sixteen
lines of `live-only (orphan var — apply never removes)`, every one of them a
var Coolify MINTED — `SERVICE_FQDN_API` for a compose app's per-container
domains, `SERVICE_PASSWORD_POSTGRES`/`POSTGRES_*` for the one-click umami
service's bundled datastore. They held two resources permanently in `change`.
`remove-candidate` means "a live-only var the manifest does not declare;
apply never removes it; read it by eye". For a name cast did not put there,
cannot declare in any vocabulary, and will never remove, that is a category
error — and a report that can never say clean is how an operator learns to
stop reading it. #78's own Impact section made the argument: "an operator who
learns these always show change stops trusting the diff."
cast already knew: draft.ts has held this exact judgment since #27 and used
it to refuse copying these into a draft. diffEnv just never asked. So the
vocabulary moves to reserved.ts — which already owns "names the platform, not
the manifest, controls" — and both callers consult it.
TWO WIDTHS, deliberately, because over-matching is safe in a draft and unsafe
in a diff:
- draft (WIDE): over-matching withholds a value for review — loud and
recoverable. Under-matching copies the source box's DATABASE_URL into a
new box that boots against the OLD box's database. It errs wide.
- diff, applications (NARROW): over-matching HIDES a live-only var. A
hand-left DATABASE_URL still pointing at a box nobody declares is the one
orphan most worth printing — and it matches the wide rule. Probed against
prod: the wide bucket on a real application held DATABASE_URL and
REDIS_URL, both of them cast's OWN declared vars.
- diff, services (WIDE): a Coolify service is a vendored bundle whose
internals cast does not model — `type` + `service_domains` + an
env_template is the whole vocabulary, and the rest is the template's.
Also fixes a real gap the #87 tests found: the pair-rule missed `POSTGRES_DB`
outright, because [POSTGRES, DB] is datastore + datastore with no connection
word. A db NAME is a connection coordinate like any other, so `DB` joins them
— it is exactly the var a one-click service mints for its bundled Postgres.
And corrects LiveEnvVar's comment: it still cited #79's "stale real_value, a
stored column Coolify does not refresh". That was false — an accessor cannot
go stale, and real_value tracks value on every row of a real box. The split
is still right (real_value is an ESCAPED rendering: 'true' is not true); only
its motivation was wrong. The drift it chased was #85's preview shadow.
Tests: an application carrying only SERVICE_* reads clean; a hand-left
DATABASE_URL on an application is STILL reported; a service carrying the
one-click template's wiring reads clean; a non-generated live-only var on a
service is STILL reported.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>