Compare commits

...

65 commits
0.1.0 ... main

Author SHA1 Message Date
Daniel Marin
e4a581169a
Merge pull request #143 from claude-bot-andresmgsl/build/15-convert-cast
Some checks failed
ci / build (push) Has been cancelled
ci / install (push) Has been cancelled
release / release (push) Has been cancelled
feat: convert cast to the shared ceremony — package-json backend and the artifact hook debut
2026-07-23 17:05:39 +01:00
claude-bot-andresmgsl
26129672ea fix: the panel roster is the CURRENT bench — kimi was dropped by porting a stale list
The conversion preserved cast's pre-ceremony CONTRIBUTING panel verbatim,
but that list predates kimi-bot joining the bench — and the omission then
self-licensed: the handoff read the roster this very PR had authored,
counted codex+grok as the full panel, and handed off one reviewer short.
A conversion PR must write the roster from the current family bench, not
inherit whatever the repo last knew.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 15:14:42 +00:00
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
claude-bot-andresmgsl
f223aa699e docs: CONTRIBUTING points at the mirror; docs swept for deleted-path pointers
CONTRIBUTING keeps only what is genuinely cast's — the panel roster, the
local checks, the changelog house style, the prebuilt-asset contract and
the promotion drill meaning; the review-round doctrine and label taxonomy
now live in .ceremony/. drills/README.md and labeler.yml repoint at the
pinned guard and the vendored LABELS.md (the sweep rule from ceremony#12,
learned in rig#112).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 13:59:24 +00:00
claude-bot-andresmgsl
becf41940d docs: vendor the ceremony doctrine at 0.1.0 — .ceremony/ mirror + AGENTS.md stub
docs-sync --fix materializes .ceremony/{AGENTS,TRIAGE,BUILDER,REVIEWER,
LABELS}.md byte-identical to heavy-duty/ceremony@0.1.0 (the pin docs-sync
reads from release.yml — one pin governs machinery and doctrine), plus
the machine-managed README marker and the root AGENTS.md stub. Root
LABELS.md retires: a hand-maintained copy beside a machine-verified
mirror is the drift the mirror exists to end (rig's precedent, rig#112).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 13:58:17 +00:00
claude-bot-andresmgsl
76926785f5 feat(release): consume the shared ceremony at 0.1.0 — callers, artifact hook, labels.conf
The 250-line release.yml becomes the caller stub (version-source:
package-json — the backend's first exercise); the prebuilt-asset build
moves into .github/actions/release-artifact/ per ceremony#9's hook
contract (setup-node moves into the hook — the shared workflow is
node-free). labels.yml becomes its caller; the panel and cast's six
scope rows extract into .github/labels.conf (data only — the parser
refuses comment lines). ci.yml swaps the guard script steps for the
pinned actions and adds changelog-armed (cast regains the guard
reverted in cast#108) and docs-sync. The four shared scripts die;
test/labels-reconcile.sh dies with the reconciler it sourced
(machinery test files go whole — rig #13's rule).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 13:57:10 +00:00
github-actions[bot]
2aa7018db4 chore: bump main to 0.2.1-dev — a dev install must not impersonate 0.2.0 2026-07-21 18:06:54 +00:00
Daniel Marin
38ea73eb55
Merge pull request #137 from dan-claude-bot/release/0.2.0
release: 0.2.0
2026-07-21 19:06:29 +01:00
dan-claude-bot
b2ccce0592 docs: record the 0.2.0 drill as waived, with what that leaves unproven 2026-07-21 17:44:01 +00:00
dan-claude-bot
0bafb9cd47 release: 0.2.0 2026-07-21 17:44:01 +00:00
Daniel Marin
2e6fe1b1b2
Merge pull request #139 from dan-claude-bot/refactor/drills-per-version
refactor: one drill record per version, in drills/
2026-07-21 18:41:48 +01:00
dan-claude-bot
9e6f343d14 test: the guard's verdict must match the tree, not always pass 2026-07-21 16:42:32 +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
Daniel Marin
b2aae24ebe
Merge pull request #138 from dan-claude-bot/feat/drill-gate
feat: CI refuses a release PR with no drill record
2026-07-21 17:17:22 +01:00
dan-claude-bot
9f8df92484 fix: whitespace is not a drill record, and align the heading grammar with box 2026-07-21 15:53:38 +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
Daniel Marin
4e1bc425c2
Merge pull request #136 from dan-claude-bot/docs/changelog-one-line
docs(changelog): one line per entry, and a pass over the whole file
2026-07-21 15:51:54 +01:00
Daniel Marin
a518ac0619
Merge pull request #135 from dan-claude-bot/fix/tmp-guard-offenders
fix: route the #124/#125 test files through tmp()
2026-07-21 15:44:15 +01: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
d442b8cc1b fix: --port is argv too — reject it before the preflight, not at listen()
@claude-bot-andresmgsl's outstanding item from the prior round, which my last
reply passed over in silence. That silence read as an oversight because it was
one.

`--port` on the create path was still bare `Number()`, so `--port abc` became
NaN, reached `server.listen(NaN)` in github-app.ts, and died as an uncaught
ERR_SOCKET_BAD_PORT stack trace — after `detectOwnerType` and the org-admin
preflight had already gone out. It is the same missing argv validation this
round fixed for the two ids, in a command whose stated rule is reject before
any write or network call.

Nothing is destroyed when it fails: no App and no client secret exist at that
point. So this is not about damage, it is about the command honouring its own
rule, and about failing with a sentence rather than a stack trace.

Range-checked as well as digits-only: `--port 99999` passes every test the ids
need and still cannot be listened on.

Scope, stated rather than assumed: `server add --port` (src/cli.ts:2430) has
the identical shape but predates this branch and is not in its diff. It is a
real instance of the same bug and belongs in its own change, not smuggled into
this one.

Four CLI cases — non-numeric, out-of-range, zero, decimal — asserting exit 2,
no stub hits and an unchanged state dir, driven through `create` because that
is the path that reads the flag. Verified by mutation: disabling the check
fails all four.
2026-07-21 13:11:15 +00:00
dan-claude-bot
9c0e6c830c fix: reject a non-integer --app-id/--installation-id before anything happens
Both reviewers' blocker. `githubAppCommand` checked the two ids for truthiness
only, then handed them to `Number()`. `--app-id nope` becomes NaN, and
`JSON.stringify(NaN)` is `null` — so on a path that deliberately persists
BEFORE calling Coolify, a typo wrote a credential record with a null app_id
and could upload the security key before `POST /github-apps` rejected it. A
half-run leaving a corrupt record on disk and a stray key on the server.

Validated with the other ARGV checks, ABOVE openCoolify/assertTeam rather than
in the register branch where I first put it. The first placement still let
`GET /teams/current` go out before the refusal — the new test caught that,
which is the argument for asserting "no stub hits" rather than "no writes". A
typo should cost nothing, not one request.

Digits-only rather than Number.isInteger: `1e3` and `0x10` are integers to
JavaScript but are not how a GitHub App id is written, and quietly storing 1000
for `1e3` is the same class of wrong answer as storing null for `nope`.

Coverage asserts both halves the review asked for — no stub hit AND an
unchanged state directory — across non-numeric (both flags), zero, decimal,
exponent and hex.

A negative id gets its own case rather than joining the loop: parseArgs reads
the leading dash as an option and rejects `-5` as unknown, exiting 1 rather
than 2. The property that matters still holds — refused before any write or
request — but it is a different path with a different exit code, and a
loosened shared assertion would have hidden that rather than recorded it.

Verified by mutation: disabling the check fails all six loop cases.
2026-07-21 13:11:15 +00: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
5075310336 test: release.test.ts allocates through tmp(), like everything else under test/
The rebase onto main was textually clean and behaviourally broken, and this
PR's own guard is what caught it.

This branch removed test/release.test.ts's `mkdtempSync`/`tmpdir` imports when
it converted that file's call sites to `tmp()`. While it was open, #133's
changelog-monotonic work landed on main and added THREE new
`mkdtempSync(join(tmpdir(), ...))` sites to the same file. The two changes
never touch the same line, so git merged them without a word — leaving call
sites whose imports this branch had deleted. 20 tests died on
`ReferenceError: mkdtempSync is not defined`.

Converted all three to `tmp()`, which is what the file already imports and
what every other test file under test/ uses.

Worth noting which test failed and why it matters: test/tmp-guard.test.ts,
this PR's own class check, reported `offenders: ["release.test.ts"]`. It was
written to stop exactly this — a new raw allocation drifting in — and it did
so on a real regression rather than a synthetic one, before the fix existed.
That is the guard earning its place on its first genuine encounter.
2026-07-21 12:58:14 +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
Daniel Marin
2118756ccb
Merge pull request #119 from dan-claude-bot/fix/shellcheck-dotglob
fix: lint every tracked shell script, and prove the set is complete
2026-07-21 13:56:52 +01:00
dan-claude-bot
bf7fd11752 style: biome formatting for the basic-auth completion tests
CI's `npm run check` runs biome with --error-on-warnings, and the new
completeBasicAuth assertions were formatted by hand. No behaviour change.
2026-07-21 12:56:00 +00:00
dan-claude-bot
5c2ccc697c fix: complete the basic-auth triple on username-only drift, at both guards
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.
2026-07-21 12:43:04 +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
601f6168f5 style: apply biome formatting to the step-block extractor
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>
2026-07-20 21:04:17 +00:00
dan-claude-bot
7022894358 test: terminate the ci.yml step block at the job boundary too
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>
2026-07-20 20:56:59 +00: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
Daniel Marin
5939cfabeb
Merge pull request #132 from dan-claude-bot/fix/labels-sweep-on-labeled
fix(labels): sweep on `labeled` so the handoff is immediate
2026-07-20 21:32:00 +01: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
Daniel Marin
715530780b
Merge pull request #130 from dan-claude-bot/docs/contributing-blocker-axis
docs(contributing): document the blocker axis and merge-next ownership
2026-07-20 20:07:22 +01:00
dan-claude-bot
ee723c1126 docs(contributing): document the blocker axis and merge-next ownership
The who-sets-what table is the day-to-day answer to "can I move this by
hand", and it never mentioned blocker:* -- a whole machine-owned family
added when state:needs-rebase was retired. merge-next was missing too, and
that is the one label whose ownership actually needs saying, because it is
the only one in the machine's vocabulary the machine deliberately does not
set.

Step 6 also read as though requesting the maintainer is sufficient to flip
state:needs-human. It is not: needs-human requires zero blockers, so the
request does nothing on a conflicted or red PR.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 18:40:12 +00:00
Daniel Marin
e3e183af6e
Merge pull request #129 from dan-claude-bot/fix/labels-two-axis
refactor(labels): split PR labels into state (whose ball) and blocker (what is in the way)
2026-07-20 19:30:00 +01: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
Daniel Marin
5f9f09a70a
Merge pull request #128 from dan-claude-bot/fix/labels-mergeability-aware
fix(labels): `state:needs-human` means a human could merge it right now
2026-07-20 18:01:32 +01: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
github-actions[bot]
2307746437 chore: bump main to 0.1.2-dev — a dev install must not impersonate 0.1.1 2026-07-19 23:04:18 +00:00
Daniel Marin
2f30105b6e
Merge pull request #116 from dan-claude-bot/release/0.1.1
release: 0.1.1
2026-07-20 00:03:50 +01: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
Daniel Marin
aa5905d954
Merge pull request #115 from dan-claude-bot/chore/bump-0.1.1-dev
chore: bump main to 0.1.1-dev
2026-07-19 22:12:17 +01:00
Daniel Marin
f801f4a28d
Merge pull request #114 from dan-claude-bot/fix/changelog-rearm
fix: the ceremony re-arms the changelog, and CI notices when it doesn't (#113)
2026-07-19 22:07:30 +01: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
aae2726739 chore: bump main to 0.1.1-dev
The post-release step of the ceremony, on the manual tag path where it
stays the author's. Depends on #114 — do not merge before it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 19:49:12 +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
68 changed files with 6556 additions and 1372 deletions

49
.ceremony/AGENTS.md Normal file
View file

@ -0,0 +1,49 @@
# AGENTS.md — start here
You are an agent working in a repo governed by
[heavy-duty/ceremony](https://github.com/heavy-duty/ceremony). This file is
the router: find your role below, read its file, then act. The role files
sit beside this one — in ceremony itself at the repo root, in a governed
repo under `.ceremony/` (a machine-managed mirror; never edit those files
in place — they are changed in heavy-duty/ceremony, through its own flow).
## Your role
You were told your role when you were pointed at this repo ("you are a
reviewer here"). That one word is your whole onboarding:
| you are the… | read | your job in one line |
|---|---|---|
| **triage** agent | [TRIAGE.md](TRIAGE.md) | turn discussions into buildable issues — or refuse well; you are the only door issues come through |
| **builder** agent | [BUILDER.md](BUILDER.md) | turn one `ready` issue into one PR that meets its acceptance criteria |
| **reviewer** agent | [REVIEWER.md](REVIEWER.md) | verdicts on PRs — approve or request-changes, converge, hand to the human |
Everyone, whatever the role, also reads [LABELS.md](LABELS.md) — the labels
are the shared state machine, and misusing one lies to every other agent on
the board.
**Not told a role?** Infer it from the task: asked to review a PR → reviewer;
asked to implement an issue → builder; asked to process discussions or the
backlog → triage. Still ambiguous → ask before acting. Do not free-lance
across roles in one session: a builder reviewing its own PR, or a reviewer
pushing fixes, breaks the separation the pipeline depends on.
## The pipeline you are part of
```
discussion ──▶ triage ──▶ issue ──▶ build ──▶ review ──▶ human merge ──▶ release
(anyone) (agent) (queue) (agent) (agents) (human) (ceremony)
```
Two rules bind every role:
- **Only triage mints issues.** Found work? Open or extend a discussion.
- **Only humans merge.** Convergence ends at `state:needs-human`, never at
a merge button.
## Repo specifics
What is true only of *this* repo — the review panel roster, the `scope:*`
label set, what a drill means, code conventions — lives in the repo's own
`CONTRIBUTING.md`. Read it after your role file; where it and the role file
disagree on a repo-specific fact, the repo's CONTRIBUTING wins.

76
.ceremony/BUILDER.md Normal file
View file

@ -0,0 +1,76 @@
# BUILDER.md — the builder role
You turn one issue into one PR. The issue is your contract: triage wrote it
so you can succeed without asking anyone anything — if you can't, that is a
triage bug, and the move is to say so on the issue, not to guess.
## Picking
- Pick from issues labeled **`ready`** — never `blocked`, never `claimed`,
never an `epic` (epics organize; their children are the work).
- Respect dependency order: inside an epic, take the earliest unblocked
unclaimed child. Between epics and strays, prefer the issue that unblocks
the most other work.
- **One issue at a time.** Finish or release your claim before taking
another.
## Claiming
- Assign yourself, swap `ready``claimed`, and comment that you are
starting. The claim is a promise of a draft PR soon — a claim with no PR
and no activity is what the staleness sweep reclaims.
- **Abandoning is fine; ghosting is not.** If you stop, say where you got to,
push the branch if it holds anything useful, unassign, and restore
`ready`.
## Building
- Branch per issue; open the PR **as a draft early**, `Closes #N` in the
body. Drafts are invisible to the reviewer panel on purpose — the draft
phase is yours.
- **The issue's acceptance criteria are your definition of done.** Reproduce
them as a checklist in the PR body and check them honestly as you go. If
one turns out to be wrong or unreachable, say so on the issue and get it
amended by triage — do not silently ship less than the issue says.
- Every behavior change adds one line to `CHANGELOG.md` under
`## Unreleased` — insert **above** the heading below it, never over it
(the monotonic guard's whole reason to exist).
- Follow the repo's conventions file and match the code you touch. Tests are
not optional: the issue's test plan is the floor, not the ceiling.
- **Scope discipline: the PR does the issue — whole, and nothing else.**
Adjacent problems you discover go to a **discussion** (or a comment on the
relevant issue), where triage will do its job. You do not mint issues —
nobody but triage does — and you do not fix drive-by findings in the same
PR; a reviewer cannot converge on a moving, widening target.
## The review round
(If you are reading this as `.ceremony/BUILDER.md` in a governed repo: the
panel roster and any repo-specific flow notes live in that repo's own
CONTRIBUTING; everything below is the shared flow.)
1. Mark ready-for-review; request **the whole panel** (the roster is in the
repo's CONTRIBUTING).
2. **Wait for every verdict, then answer the round whole** — one reply
covering every point, then push the fixes, then re-request exactly the
reviewers who did not approve. Prefer verification over argument: when a
reviewer doubts behavior, add the test that settles it.
3. Never dismiss a review, never merge, never mark your own work as passed.
A blocking point you disagree with is answered with evidence or escalated
in the PR — a maintainer can be asked for a ruling; silence and
force-forward are not options.
## Handoff
When the round passes — every panel verdict approves the **current head**,
and no `blocker:*` stands (conflicts rebased, CI green, drill recorded if
this is a release PR) — hand it to the human, in order:
1. post the round summary (what changed per round, what was verified);
2. request the human's review;
3. set `state:needs-human` yourself.
The label write is optimistic — the reconciler validates it, and takes it
back if the PR is not actually mergeable-right-now. Then stop: the PR is the
human's. Address what comes back (`state:addressing`) and re-hand-off the
same way.

98
.ceremony/LABELS.md Normal file
View file

@ -0,0 +1,98 @@
# Labels
The taxonomy shared across the heavy-duty repos. Only the `scope:` set
differs per repo (each repo's `.github/labels.conf` names its actual
surfaces); everything else below is core and identical everywhere, created by
the labels workflow's bootstrap dispatch (issue #10).
Two state machines share the taxonomy: the **PR machine** (proven in
box/rig/cast, reconciled by machinery) and the **issue flow** (the
triage → build queue, doctrine-enforced today, machinery to follow —
issue #18). One rule joins everything: **states are machine-owned, intent
labels are hand-set** — a hand-moved state label is a lie waiting to happen,
and the reconciler recomputes it from GitHub's own facts.
## PR state — who is the ball with? (exactly one per open PR)
| Label | Color | Waiting on |
|---|---|---|
| `state:building` | `#FBCA04` | the builder — PR is a draft |
| `state:bots-reviewing` | `#1D76DB` | the reviewer panel to finish the round (a request is live) |
| `state:addressing` | `#D93F0B` | the builder — round complete without full approval, or nobody was asked, or a blocker is up |
| `state:needs-human` | `#8250DF` | the human — **this PR could be merged right now**: zero blockers, whole panel approved the current head |
`bots-reviewing` vs `addressing` is deliberate: staleness in the first means
*poke the reviewers*, in the second *the builder dropped the ball*. And
`state:needs-human` means exactly one thing — a human could merge this now —
so it requires zero blockers and head-current approvals; anything less and
the reconciler takes it back. The author sets it at handoff (the one
hand-set state); the `labeled` event fires the sweep that validates the
write within seconds.
## PR blockers — what is in the way? (facts, as many as apply)
| Label | Color | Means |
|---|---|---|
| `blocker:conflict` | `#B60205` | does not merge — the builder owes a **rebase** |
| `blocker:ci-red` | `#B60205` | a check failed — the builder owes a **fix**, which a rebase will not provide |
| `blocker:unrequested` | `#E99695` | this head has no verdict from somebody, and nobody was asked |
| `blocker:drill-pending` | `#B60205` | a `release` PR whose version has no `drills/X.Y.Z.md` record — correct but unevidenced (maintainer-created label; the bot bootstrap 403s on it) |
States answer *whose ball*; blockers answer *what's in the way*. They are
separate axes because the single-label version kept lying — independent facts
projected onto one totally-ordered label meant one always won and the losers
vanished off the board (box's `state:needs-rebase`, retired: the reconciler
strips it on sight).
## Issue flow — the work queue (exactly one per open, triaged, non-epic issue)
| Label | Color | Means | Set by |
|---|---|---|---|
| `needs-triage` | `#FBCA04` | an issue that did not come through triage — it owes normalization or conversion back to a discussion | anyone who spots one; cleared by triage |
| `ready` | `#0E8A16` | triaged, spec complete, unblocked — a builder can start now and succeed | triage |
| `claimed` | `#1D76DB` | a builder owns it: assignee set, a draft PR expected shortly | the claiming builder |
| `blocked` | `#6A737D` | waiting on another issue or PR (`Blocked by #N` in the body names it) | triage; anyone may correct it |
| `epic` | `#5319E7` | organizes other issues via a dependency-ordered task list; **builders never pick an epic** | triage |
The invariant a board scan relies on: every open issue is either
`needs-triage`, `epic`, or carries exactly one of `ready` / `claimed` /
`blocked`. A `claimed` issue with no open PR and no activity is what the
staleness sweep will reclaim (issue #18); until that machinery exists,
[TRIAGE.md](TRIAGE.md) owns the hygiene by hand.
## Cross-cutting (PRs and issues)
| Label | Color | Meaning |
|---|---|---|
| `stale` | `#B60205` | no activity for 48h — sweep-managed, never hand-applied |
| `blocked` | `#6A737D` | (see above — same label serves PRs waiting on another PR/issue; legitimately quiet, the staleness sweep skips it) |
| `release` | `#0E8A16` | release flow, versioning, packaging work — and the ceremony PR itself |
| `merge-next` | `#0E8A16` | head of the merge queue — merge this one next. Queue order is *intent*: never set by the reconciler, only cleared by it |
## Scope — which surface? (PRs and issues, any number)
All scopes share one calm color, `#C5DEF5` — scopes locate, states alert. The
set is per-repo (`.github/labels.conf`); PRs get theirs from changed paths via
actions/labeler, issues get theirs from triage. This repo's set:
| Label | Covers |
|---|---|
| `scope:release-flow` | the reusable release workflow, decide, the doors |
| `scope:guards` | changelog-armed / changelog-monotonic / drill-recorded |
| `scope:labels` | the labels workflow, reconciler, this taxonomy |
| `scope:docs` | README doctrine, CONSUMERS.md, the role files |
## Issue types
`bug`, `enhancement`, `documentation` — issues only, set by triage. PRs carry
their type in the conventional title (`feat:`, `fix:`, `docs:`); a type label
on a PR would say the same thing twice and drift.
## Maintenance
The labels workflow (issue #10) recomputes PR state statelessly on PR events
plus a 15-minute advisory cron, and bootstraps this taxonomy idempotently on
manual dispatch. Issue-flow labels are doctrine-owned until #18 lands
machinery for them. Default GitHub labels (`duplicate`, `invalid`,
`question`, `wontfix`, `help wanted`, `good first issue`) are deleted at
bootstrap — a `question` is a discussion, not an issue.

14
.ceremony/README.md Normal file
View file

@ -0,0 +1,14 @@
# .ceremony/ — the vendored doctrine mirror
Machine-managed by heavy-duty/ceremony's `actions/docs-sync`. Never edit
these files here: they are byte-identical copies of
[heavy-duty/ceremony](https://github.com/heavy-duty/ceremony) at this
repository's pinned ref, and CI re-diffs them on every PR — a hand edit
goes red. They are changed in heavy-duty/ceremony, through its own flow,
and arrive here when the pin moves.
The pin lives in `.github/workflows/release.yml` — the single
`uses: heavy-duty/ceremony/.github/workflows/release.yml@<ref>` line. One
pin governs machinery and doctrine alike: bump it and re-sync this mirror
in the same PR (`docs-sync --fix`, or let the red check on the bump PR say
what is stale).

70
.ceremony/REVIEWER.md Normal file
View file

@ -0,0 +1,70 @@
# REVIEWER.md — the reviewer role
You are one voice on a panel. The panel's job is to converge — on an
approval the human can trust, or on a precise statement of what is wrong.
The machine reads only your **verdict**; humans read your reasons.
## The verdict doctrine
- **Every review ends in a verdict**: approve, or request changes. A
comment-only review is a non-verdict — it does not say whether the round
passed, the state machine treats it as not-approved, and the PR simply
stalls. If you have an opinion, you have a verdict; commenting without one
only wedges the flow.
- **The verdict carries blockingness only; the body carries the feedback.**
Non-blocking nits ride an **approval**, and the builder addresses them at
their discretion. Anything blocking — including a question whose answer
gates your approval — is **request changes**, saying exactly what
unblocks it.
- An approval you would not defend to the human is a defect. You are not
being asked to be agreeable; you are being asked to be right.
## What you review against
In order of authority:
1. **The issue's acceptance criteria** — the PR's `Closes #N` names your
spec. Check every criterion; a PR that ships less than the issue says is
a request-changes even if the code is beautiful.
2. **The repo's load-bearing constraints** — the rules bought with
incidents (in ceremony itself: issue #1's constraint list; in a governed
repo: its own CONTRIBUTING plus ceremony's README). A change that
"simplifies away" a constraint gets request-changes with a link to the
incident that made the rule.
3. **The code itself** — correctness first, then tests (does the test plan's
floor exist? do the failure cases actually fail?), then conventions.
Changelog line present for behavior changes; comments carry why, not
what.
**Verify over opine.** Run what can be run; construct the failing input; a
test settles what a comment thread can't. A review that says "I ran X and
saw Y" outranks one that says "this looks like it might".
## What you do not do
- **Re-litigate the spec.** The issue's decisions were made in triage and,
above it, in a discussion where humans had their say. If you think the
spec itself is wrong, say so with reasons — as a comment pointing at the
discussion, while still reviewing the implementation against the spec as
written. Spec changes go through triage, not through a review round.
- **Merge, or tell the builder to merge.** Convergence hands the PR to a
human; only humans merge.
- **Approve a moving target.** Your approval is of a specific head. If the
builder pushes after your approval, GitHub stales it — that is correct,
and the builder owes a re-request, not an assumption.
## The round rhythm
- Review the **whole PR at the current head** each round, not just the diff
since your last comments — the fix for someone else's point can break
yours.
- The builder answers rounds whole and re-requests you; until re-requested,
the ball is not yours (`state:addressing` is the builder working — pile-on
reviews mid-address just churn the target).
- Convergence = every panel verdict approves the current head, no
`blocker:*` standing. Then the builder hands off (`state:needs-human`) and
the panel's job is done.
- If a round exposes a disagreement **within the panel**, argue it in the PR
with evidence until one side concedes or the builder escalates to the
maintainer for a ruling. Two reviewers pulling a builder in opposite
directions without resolution is a panel failure, not a builder failure.

95
.ceremony/TRIAGE.md Normal file
View file

@ -0,0 +1,95 @@
# TRIAGE.md — the triage role
You are the only door issues come through. Humans and agents open
**discussions**; you decide what becomes work. The quality of every
downstream stage — a builder succeeding without asking, a reviewer having a
spec to review against — is set here, by you, and nowhere else.
## Why this door exists
Discussions are allowed to be ambiguous; issues are not. An issue is a work
order a builder must be able to execute **without asking anyone anything**.
Keeping one accountable role between the two is what keeps the bar from
eroding — the moment anyone can mint an issue, the backlog fills with
"improve X" entries nobody can build, and builders start guessing. Guessing
is the failure this whole flow exists to prevent.
## Your inputs
- **Every open discussion** in the repo you serve.
- **Stray issues** — anything filed directly, by anyone. Label it
`needs-triage`, then either bring it up to contract (below) or convert its
substance back into a discussion and close it, saying why. Do not shame the
filer; do route the work correctly.
## For each discussion, converge on exactly one outcome
1. **Answer.** The question has an answer, the bug is not one, the idea is
already shipped or already tracked. Reply with the answer (link the code,
the doc, the existing issue), mark answered.
2. **Ask.** Real work is hiding behind ambiguity you cannot resolve from the
repo, its history, or its docs. Ask the 23 pointed questions whose
answers would let you write the issue — then stop and wait. Do not mint an
issue that carries the ambiguity forward; that just moves your job onto
the builder.
3. **Escalate.** The blocker is a *decision* only a human owns — scope,
money, product direction, breaking a public contract. Say precisely what
the decision is, list the options with your recommendation, and name the
decider. The discussion is where humans decide; wait there.
4. **Decline.** Real idea, wrong repo or wrong time. Say why plainly, link
where it belongs if anywhere, close. A refusal with reasons is a good
outcome; a zombie discussion is not.
5. **Accept.** It justifies work → mint the issue(s). The contract below is
the bar.
## The issue contract
Every issue you mint carries, in this order:
- **A title that names the deliverable** — "lib/version.sh — one version
abstraction, two backends", never "improve version handling".
- **Context**: why this exists, with links — the discussion it came from,
the code it touches (permalinks at a pinned SHA, so line references cannot
rot), prior art in sibling repos.
- **The spec**: decisions made, not options listed. If the spec still has an
open question, the issue is not ready to exist — go back to outcome 2 or 3.
- **Tasks**: the steps, checkboxed, in order.
- **Acceptance criteria**: checkboxed, verifiable, and honest — these become
the builder's definition of done and the reviewer's review spec, verbatim.
- **Test plan**: what proves it, including the cases that must fail.
- **Dependencies**: `Blocked by #N` / `Blocks #N`, and `Part of #E` when an
epic organizes it.
- **Labels**: type (`bug`/`enhancement`/`documentation`), `scope:*`, and
exactly one of `ready` / `blocked` (see [LABELS.md](LABELS.md)).
The bar, stated once: **a competent builder who has read only this issue and
the repo can succeed.** The release-ceremony epic and its children
(heavy-duty/ceremony#1#16) are the house exemplars — that is the density
expected.
## Multi-issue work
When an acceptance produces more than one issue, mint an **epic** (`epic`
label): the approach, the decisions, the constraint list, and a
dependency-ordered task list of child issues. Children reference the epic;
the epic's checklist is the progress view. Builders never pick the epic
itself. Keep the checklist current — a stale epic misleads every scan.
## Backlog hygiene (yours until #18 automates it)
- **Dedup before minting** — search issues *and* closed issues; extend or
reopen before duplicating.
- **Flip `blocked` → `ready`** when the named dependency lands.
- **Reclaim abandoned claims**: `claimed` + no open PR + no activity →
comment, unassign, restore `ready`.
- **Close obsolete issues** with the reason and a link to what obsoleted
them. Every label on every open issue stays true; the board is only worth
scanning if it does not lie.
## What you never do
- Write code, review code, or build the thing yourself.
- Assign a builder — builders pick and claim ([BUILDER.md](BUILDER.md)).
- Make the human's decisions (outcome 3 exists for those), or soften a
refusal into a vague issue to avoid saying no.
- Mint an issue to "discuss" something — that is a discussion.

View file

@ -0,0 +1,39 @@
name: release-artifact
description: >-
Build cast's prebuilt release asset — the ceremony's artifact hook
(ceremony#9's contract; ceremony#15 is this conversion). Where cast
differs from its siblings: box and rig are pure bash, so GitHub's source
tarball for the tag IS their package; cast's source tarball is not
runnable — it needs npm ci and tsc first. So the build happens ONCE,
here, and the asset is the runnable tree — bin/, dist/, production
node_modules/, package.json — staged as cast-<version>/ inside
cast-<version>.tgz. That name and layout are the install contract: the
installer's release channels download this exact asset and never run npm
or tsc (test/install-sh.test.ts pins it). The hook owns its own
toolchain (the shared workflow is node-free).
inputs:
version:
description: The release version the asset is named for
required: true
runs:
using: composite
steps:
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: npm
- name: build once, stage the runnable tree, drop the tgz
shell: bash
env:
VERSION: ${{ inputs.version }}
run: |
# Deliberately no tests/checks here: ci.yml already gated the merge
# commit this release names, and cast's suite needs `age`, which
# the release runner does not install. The staged tree is exactly
# what an install needs to run.
npm ci
npm run build
npm prune --omit=dev
mkdir -p "$RUNNER_TEMP/stage/cast-$VERSION"
cp -R bin dist node_modules package.json "$RUNNER_TEMP/stage/cast-$VERSION/"
tar -C "$RUNNER_TEMP/stage" -czf "$RELEASE_ASSETS_DIR/cast-$VERSION.tgz" "cast-$VERSION"

4
.github/labeler.yml vendored
View file

@ -1,5 +1,5 @@
# path → scope:* map for actions/labeler — the PR half of LABELS.md's scope # path → scope:* map for actions/labeler — the PR half of .ceremony/LABELS.md's
# story (issues are hand-scoped at triage; paths only exist on PRs). Additive # scope story (issues are hand-scoped at triage; paths only exist on PRs). Additive
# only: sync-labels stays off in labels.yml, so a hand-applied scope survives. # only: sync-labels stays off in labels.yml, so a hand-applied scope survives.
"scope:capture": "scope:capture":
- changed-files: - changed-files:

7
.github/labels.conf vendored Normal file
View file

@ -0,0 +1,7 @@
panel=claude-bot-andresmgsl codex-bot-andresmgsl grok-bot-andresmgsl kimi-bot-andresmgsl
scope:capture|C5DEF5|draft/capture — reading the live world into a manifest
scope:apply|C5DEF5|apply/diff/destroy — reconciling onto Coolify
scope:secrets|C5DEF5|secrets, age, the encrypted state repo
scope:fleet|C5DEF5|fleet/inventory/server — placement
scope:manifest|C5DEF5|manifest/resolve/envtemplate — the manifest language
scope:coolify-api|C5DEF5|coolify.ts + OpenAPI reference — the client

View file

@ -1,228 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
# labels-reconcile.sh — the automation LABELS.md promises: state labels are
# written by machinery, never by hand. Every run derives each open PR's
# state:* from GitHub's own facts (draft flag, requested reviewers, submitted
# reviews) and converges the labels to it, so a killed run or a hand-moved
# label heals on the next pass. Stale is judged from real activity — commits,
# comments, reviews — never from label churn, or the sweep would un-stale its
# own mark every tick.
#
# The verdict contract (CONTRIBUTING.md): reviews end in approve or
# request-changes. Some live bots are comment-only and post agreement as a
# COMMENTED review — a non-verdict this machine refuses to guess about (body
# parsing is a heuristic, and a wrong guess promotes an unapproved PR). The
# judgment call belongs to the PR AUTHOR, who reads the round and escalates
# by requesting the human's review — an explicit request is a fact, and it is
# the one this machine trusts (see decide_state's top precedence). The
# machine auto-requests the human only in the no-judgment-needed case: three
# formal head-current approvals. Any approval that counts must be bound to
# the CURRENT head SHA: GitHub keeps approvals alive across pushes, and a
# stale approval must never promote unreviewed code to the human.
#
# DRY_RUN=1 narrates every mutation instead of performing it (how this script
# is rehearsed against the live repo). A workflow_dispatch run also bootstraps
# the taxonomy (label create --force) — that heal is dispatch-only; the cron
# sweep tolerates a missing label rather than recreating it.
#
# The state machine below is pure (globals in, state out) and covered by
# fixture tests in test/labels-reconcile.sh.
HUMAN="${HUMAN_REVIEWER:-danmt}"
BOTS=(claude-bot-andresmgsl codex-bot-andresmgsl grok-bot-andresmgsl)
STATES=(state:building state:bots-reviewing state:addressing state:needs-human)
STALE_AFTER=$((48 * 3600))
log() { printf 'labels: %s\n' "$*"; }
run() { # every mutation goes through here — DRY_RUN=1 logs instead of doing
if [ -n "${DRY_RUN:-}" ]; then log "DRY_RUN: $*"; else "$@"; fi
}
# ---------------------------------------------------------------------------
# The state machine. Pure functions over four globals, set per PR:
# DRAFT true|false
# HEAD_SHA the PR's current head commit
# REQUESTED newline-separated logins with a review currently requested
# REVIEWS_JSON JSON array of submitted (non-PENDING) reviews
# ---------------------------------------------------------------------------
requested() { grep -qxF "$1" <<<"$REQUESTED"; }
bot_verdict() { # $1 = login → MISSING | BLOCK | APPROVE | STALE | FEEDBACK
local review state commit
review="$(jq -c --arg u "$1" \
'[.[] | select(.user.login == $u)] | sort_by(.submitted_at) | last // empty' \
<<<"$REVIEWS_JSON")"
if [ -z "$review" ]; then echo MISSING; return; fi
state="$(jq -r '.state' <<<"$review")"
commit="$(jq -r '.commit_id' <<<"$review")"
case "$state" in
CHANGES_REQUESTED)
# blocks at ANY head — GitHub's own semantic: only a newer review
# from the same reviewer clears it
echo BLOCK ;;
APPROVED)
if [ "$commit" = "$HEAD_SHA" ]; then echo APPROVE; else echo STALE; fi ;;
*)
# COMMENTED and anything else: a non-verdict. The machine does not
# read bodies — if the comment is really an agreement, the AUTHOR
# says so by requesting the human's review.
echo FEEDBACK ;;
esac
}
human_request_needed() { # 0 when needs-human requires a FRESH human request
# already requested → the handoff is live; head-current human approval →
# nothing left to ask. Anything else (never reviewed, an old comment, an
# approval of an older head) stalls the handoff unless we request —
# guarding on "has the human ever reviewed" wedged exactly that way.
if requested "$HUMAN"; then return 1; fi
if [ "$(bot_verdict "$HUMAN")" = APPROVE ]; then return 1; fi
return 0
}
decide_state() { # → the one state:* label this PR should carry
if [ "$DRAFT" = true ]; then echo state:building; return; fi
# an explicit human request outranks the bot rounds — it is the final
# gate, and a maintainer pulling a PR to themselves early counts too
if requested "$HUMAN"; then echo state:needs-human; return; fi
local b v verdicts=""
for b in "${BOTS[@]}"; do
if requested "$b"; then echo state:bots-reviewing; return; fi
done
for b in "${BOTS[@]}"; do
v="$(bot_verdict "$b")"
if [ "$v" = MISSING ]; then echo state:bots-reviewing; return; fi
verdicts="$verdicts $v"
done
case "$verdicts" in
# FEEDBACK = a comment with no verdict → the agent owes the round-reply.
# STALE = a verdict for an older head → the agent owes a re-request.
*BLOCK* | *FEEDBACK* | *STALE*) echo state:addressing; return ;;
esac
# the bots all approve — but if the human's standing word is
# changes-requested (and nobody re-requested them yet), the agent owes
# fixes, not the human a nag
if [ "$(bot_verdict "$HUMAN")" = BLOCK ]; then
echo state:addressing
else
echo state:needs-human
fi
}
# ---------------------------------------------------------------------------
# The sweep: fetch facts, decide, converge. One PR's failure never aborts the
# others — each PR reconciles in a subshell and a failure just logs.
# ---------------------------------------------------------------------------
bootstrap_labels() { # dispatch-only: ~20 upserts is too chatty for every cron tick
while IFS='|' read -r name color desc; do
[ -n "$name" ] || continue
run gh label create "$name" -R "$REPO" --color "$color" --description "$desc" --force
done <<'EOF'
state:building|FBCA04|PR is a draft — the coding agent is still building
state:bots-reviewing|1D76DB|Waiting on the bot reviewers to finish the round
state:addressing|D93F0B|All bots reviewed — coding agent owes the single reply + fixes
state:needs-human|8250DF|All bots approve — waiting on the human reviewer
stale|B60205|No activity for 48h — needs a poke (sweep-managed)
blocked|6A737D|Waiting on another PR or issue to land first
release|0E8A16|Release flow and version/packaging work
scope:capture|C5DEF5|draft/capture — reading the live world into a manifest
scope:apply|C5DEF5|apply/diff/destroy — reconciling onto Coolify
scope:secrets|C5DEF5|secrets, age, the encrypted state repo
scope:fleet|C5DEF5|fleet/inventory/server — placement
scope:manifest|C5DEF5|manifest/resolve/envtemplate — the manifest language
scope:coolify-api|C5DEF5|coolify.ts + OpenAPI reference — the client
EOF
}
has_label() { grep -qxF "$1" <<<"$LABELS"; }
reconcile_pr() { # $1 = PR number; relies on the globals set from its fetch
local n="$1" desired remove s args last_activity age
desired="$(decide_state)"
# encode the runbook's last step for the no-judgment case: three formal
# head-current approvals → the human is asked, once. The guard asks whether
# a FRESH human review is needed for THIS head — never "has the human ever
# reviewed", which wedged the handoff after any earlier human comment.
# Idempotent (a live request suppresses it); race-free via the shared
# concurrency group in labels.yml. With a comment-only bot on the panel
# this path stays cold and the AUTHOR requests the human.
if [ "$desired" = state:needs-human ] && human_request_needed; then
run gh api "repos/$REPO/pulls/$n/requested_reviewers" -f "reviewers[]=$HUMAN" --silent
log "#$n: requested $HUMAN (round passed)"
fi
# ---- converge the state:* labels ----
remove=""
for s in "${STATES[@]}"; do
if [ "$s" != "$desired" ] && has_label "$s"; then remove="$remove,$s"; fi
done
remove="${remove#,}"
if ! has_label "$desired" || [ -n "$remove" ]; then
args=(--add-label "$desired")
[ -n "$remove" ] && args+=(--remove-label "$remove")
if run gh issue edit "$n" -R "$REPO" "${args[@]}" >/dev/null; then
log "#$n: state -> $desired${remove:+ (cleared $remove)}"
else
# a deleted label must not wedge the sweep — dispatch heals the taxonomy
log "#$n: WARNING: label edit failed (missing label? run the workflow manually to bootstrap)"
fi
fi
# ---- stale: real activity only, and blocked is legitimately quiet ----
last_activity="$(
{
jq -r '.created_at' <<<"$PR_JSON"
jq -r '.[].submitted_at' <<<"$REVIEWS_JSON"
gh api --paginate "repos/$REPO/issues/$n/comments" --jq '.[].created_at'
gh api --paginate "repos/$REPO/pulls/$n/comments" --jq '.[].created_at'
gh api --paginate "repos/$REPO/pulls/$n/commits" --jq '.[].commit.committer.date'
} | sort | tail -n1
)"
age=$((NOW - $(date -d "$last_activity" +%s)))
if has_label blocked || [ "$age" -le "$STALE_AFTER" ]; then
if has_label stale; then
run gh issue edit "$n" -R "$REPO" --remove-label stale >/dev/null
log "#$n: unstale"
fi
elif ! has_label stale; then
run gh issue edit "$n" -R "$REPO" --add-label stale >/dev/null
log "#$n: stale ($((age / 3600))h quiet)"
fi
}
main() {
REPO="${REPO:?set REPO to owner/name}"
NOW="$(date +%s)"
if [ "${GITHUB_EVENT_NAME:-}" = workflow_dispatch ]; then
log "workflow_dispatch: bootstrapping the taxonomy"
bootstrap_labels
fi
local n
for n in $(gh pr list -R "$REPO" --state open --limit 100 --json number --jq '.[].number'); do
(
PR_JSON="$(gh api "repos/$REPO/pulls/$n")"
DRAFT="$(jq -r '.draft' <<<"$PR_JSON")"
HEAD_SHA="$(jq -r '.head.sha' <<<"$PR_JSON")"
LABELS="$(jq -r '.labels[].name' <<<"$PR_JSON")"
REQUESTED="$(jq -r '.requested_reviewers[].login' <<<"$PR_JSON")"
# PENDING reviews are unsubmitted drafts in someone's browser — not a verdict
REVIEWS_JSON="$(gh api --paginate "repos/$REPO/pulls/$n/reviews" --jq '.[]' \
| jq -s '[.[] | select(.state != "PENDING")]')"
reconcile_pr "$n"
) || log "#$n: reconcile failed — continuing with the remaining PRs"
done
log "reconciled."
}
# sourced by test/labels-reconcile.sh for the fixture tests; executed in CI
if [ "${BASH_SOURCE[0]}" = "$0" ]; then
main "$@"
fi

View file

@ -1,31 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
# release-notes.sh <version> [<changelog>] — print exactly <version>'s
# section of the changelog: every line between its '## <version> — <date>'
# header and the next '## '. This is what release.yml hands to
# 'gh release create', so the release notes are the curated prose we wrote,
# not the PR list GitHub would generate (#96; box#83's extraction). Fails
# loudly when the section is missing or empty — a tag without its changelog
# section is a release ritual skipped, and an empty release body would paper
# over it.
#
# A file of its own (not inlined in release.yml) so test/release.test.ts
# drives the same extraction against fixtures and the real CHANGELOG.md.
ver="${1:-}"
changelog="${2:-CHANGELOG.md}"
[ -n "$ver" ] || { echo "usage: release-notes.sh <version> [<changelog>]" >&2; exit 2; }
[ -f "$changelog" ] || { echo "release-notes: no such file: $changelog" >&2; exit 1; }
# $2 of a section header ('## 0.1.0 — 2026-07-18') is the bare version —
# compared WHOLE, so 0.1.0 can never match a 0.1.0-rc1 section (or vice
# versa), and no regex-escaping of dots. sed drops the blank padding under
# the header; the command substitution eats the trailing blanks.
notes="$(awk -v ver="$ver" '
/^## / { grab = ($2 == ver); next }
grab { print }
' "$changelog" | sed '/./,$!d')"
[ -n "$notes" ] || { echo "release-notes: $changelog has no section for '$ver' — the release PR stamps the Unreleased section with version + date BEFORE the tag (#96)" >&2; exit 1; }
printf '%s\n' "$notes"

122
.github/scripts/shellcheck-all.sh vendored Executable file
View file

@ -0,0 +1,122 @@
#!/usr/bin/env bash
set -euo pipefail
# Lint every tracked shell script in the repo, and prove the set is complete
# (#118). Note for editors: a comment line here must not BEGIN with the word
# "shellcheck" — that is directive syntax, and prose in that position is a
# parse error. This file's own sweep catches it, which is how this note exists.
#
# WHY THE LIST COMES FROM GIT, NOT FROM A GLOB
#
# The obvious sweep is a globstar one:
#
# shopt -s globstar; files=(bin/* **/*.sh)
#
# and it is quietly wrong. Globs do not match dot-prefixed names without
# `dotglob`, so `**/` never descends into `.github/` — which is where
# release-notes.sh lives, the script that produces the published release body.
#
# `shopt -s globstar dotglob` does fix that, and measured in this repo it
# pulls in nothing unwanted: the sweep runs after `npm ci`, but cast's current
# dependency tree happens to ship zero `.sh` files, so `**/*.sh` stays clean.
# "Happens to" is the problem — that is a property of somebody else's package
# tree, re-decided by every `npm install`, and the day a transitive dep vendors
# a shell script the lint silently becomes partly about their code. `git
# ls-files` does not depend on that: it sees the tracked tree exactly, with no
# dotfile blind spot and no untracked noise, and it stays right when files move.
#
# Extensionless scripts (bin/cast) are found by shebang rather than named, so
# adding one does not require editing this file.
cd "$(git rev-parse --show-toplevel)"
# --- the set to lint -------------------------------------------------------
mapfile -t files < <(
{
git ls-files '*.sh'
git ls-files | while IFS= read -r f; do
case "$f" in *.sh) continue ;; esac
[ -f "$f" ] || continue
# `read` returns 1 at EOF even when it populated `line` — which is what
# happens on a file whose FIRST line has no trailing newline (a
# shebang-only file with no final newline). A bare `|| continue` would
# skip exactly that file, silently. Fall through whenever `line` is
# non-empty; the empty case is a genuinely empty file, which has no
# shebang and is meant to be skipped.
IFS= read -r line <"$f" || [ -n "$line" ] || continue
case "$line" in '#!'*) ;; *) continue ;; esac
# reduce the shebang to a bare interpreter name: drop the '#!', drop any
# flags, then keep the last path/word component — so both '#!/bin/sh -e'
# and '#!/usr/bin/env bash' come out as 'sh' and 'bash'.
interp="${line#\#!}"
interp="${interp%% -*}"
interp="${interp##*[ /]}"
# Two known limits of this reduction, both theoretical in this repo:
# - `zsh` is on the allowlist, but shellcheck has no zsh support and
# emits SC1071 for it. So a tracked zsh script makes the sweep fail
# HARD rather than get linted. That is the right end state — a
# script nobody can lint should be loud, not skipped — but the
# outcome is "blocked", not "clean". Drop zsh here only if the repo
# ever gains one and the answer is to exempt it on purpose.
# - `#!/usr/bin/env -S bash` reduces to `env` and is not matched.
# Nothing in the repo uses `-S`; see #121 for why that is left.
case "$interp" in sh | bash | dash | ksh | zsh) printf '%s\n' "$f" ;; esac
done
} | sort -u
)
[ "${#files[@]}" -gt 0 ] || { echo "shellcheck-all: found no shell scripts — the sweep is broken" >&2; exit 1; }
# --- the class check -------------------------------------------------------
#
# Assert the set we are about to lint COVERS every tracked *.sh. Today this
# can't fail, because the list above is derived from the same `git ls-files`
# — and that is the point. It is a guard on the STATE ("no tracked script
# goes unlinted"), not on the instance that broke: the day someone rewrites
# the derivation above into something cheaper that skips a directory, this
# fails loudly instead of the lint silently passing over nothing. #118 stayed
# latent precisely because a shrinking sweep looks exactly like a green one.
unlinted="$(comm -23 <(git ls-files '*.sh' | sort -u) <(printf '%s\n' "${files[@]}" | sort -u))"
if [ -n "$unlinted" ]; then
echo "shellcheck-all: tracked shell scripts that this sweep does not lint:" >&2
printf '%s\n' "$unlinted" | sed 's/^/ /' >&2
echo "the sweep must cover every tracked *.sh — see #118" >&2
exit 1
fi
# --- the floor: extensionless scripts --------------------------------------
#
# The check above is derived from `git ls-files '*.sh'`, so it says nothing
# about scripts that have no `.sh` extension — those enter the set only via
# the shebang scan. `bin/cast` is one, and it is the shipped entrypoint. So
# it is covered by the DERIVATION and not by the ASSERTION: break or delete
# the shebang branch and bin/cast drops out of the sweep while this script
# still exits 0. That is #118's failure mode — a lint quietly narrowing while
# CI stays green — one level in from where the *.sh check closed it (#121).
#
# There is no non-circular way to re-derive "every extensionless shell
# script" here; any second derivation would be the same shebang scan, and
# would break with it. So the floor is named rather than computed: the known
# extensionless scripts are listed, and the sweep must contain them. A rename
# turns this red, which is correct — the floor is the thing that has to be
# updated deliberately.
required=(bin/cast)
for req in "${required[@]}"; do
if ! printf '%s\n' "${files[@]}" | grep -qxF "$req"; then
echo "shellcheck-all: '$req' is not in the swept set" >&2
echo "it has no .sh extension, so it enters only via the shebang scan above —" >&2
echo "that scan is broken, or the file moved. See #121." >&2
exit 1
fi
done
# --- lint ------------------------------------------------------------------
printf 'shellcheck: linting %d tracked scripts\n' "${#files[@]}"
printf ' %s\n' "${files[@]}"
shellcheck -x "${files[@]}"
echo "shellcheck: clean"

View file

@ -10,6 +10,12 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with:
# changelog-monotonic compares HEAD against the merge base; a
# checkout that cannot resolve it is a hard failure in CI, not
# a skip (a guard that can quietly stop guarding is the failure
# shape this repo keeps refusing).
fetch-depth: 0
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
node-version: "22" node-version: "22"
@ -22,8 +28,41 @@ jobs:
- run: npm test - run: npm test
- name: installer is valid bash - name: installer is valid bash
run: bash -n install.sh bin/cast scripts/*.sh .github/scripts/*.sh run: bash -n install.sh bin/cast scripts/*.sh .github/scripts/*.sh
- name: labels state-machine tests # `bash -n` above is a syntax check on a hand-maintained list; it is not
run: bash test/labels-reconcile.sh # a linter and it does not notice a script it was never told about.
# This sweep derives its list from git and asserts the list covers every
# tracked *.sh, so a new script cannot go unlinted quietly (#118).
- name: shellcheck — every tracked shell script
run: bash .github/scripts/shellcheck-all.sh
# The release guards, doctrine in heavy-duty/ceremony's README
# (ceremony#15 is this conversion). Each guard's war story — why it
# exists, what it refuses — lives with its implementation upstream;
# the four pins below and the two workflow callers must always name
# the same ceremony tag.
#
# changelog-armed: the version-keyed arming rule (rig#66 is the
# incident; the unconditional form cast#108 reverted — this is its
# correct return).
- uses: heavy-duty/ceremony/actions/changelog-armed@0.1.0
with:
version-source: package-json
# changelog-monotonic: no shipped heading deleted or duplicated
# (#133; box#122's guard, box#143's ordering fix). Strict by default:
# an unresolvable base ref is red, never a quiet skip — hence the
# fetch-depth: 0 above.
- uses: heavy-duty/ceremony/actions/changelog-monotonic@0.1.0
# drill-recorded: a release version carries drills/<version>.md
# (cast's drill meaning: drills/README.md). Vacuous on -dev trees; it
# requires a RECORD, not a pass — a maintainer waiver is legal, and
# is itself the content of the file.
- uses: heavy-duty/ceremony/actions/drill-recorded@0.1.0
with:
version-source: package-json
# docs-sync: the .ceremony/ doctrine mirror is byte-identical to the
# pin read from release.yml (ceremony#19) — a hand edit or a
# half-done pin bump goes red here.
- uses: heavy-duty/ceremony/actions/docs-sync@0.1.0
# The installer, proven by RUNNING it — CAST_INSTALL_SOURCE points it at # The installer, proven by RUNNING it — CAST_INSTALL_SOURCE points it at
# this checkout, so CI proves the installer under review (the versioned # this checkout, so CI proves the installer under review (the versioned

View file

@ -1,56 +1,19 @@
name: labels name: labels
# The automation LABELS.md promises. Two halves: # The automation LABELS.md promises, now implemented upstream
# scope — path-derived scope:* labels on PRs (actions/labeler) # (heavy-duty/ceremony — ceremony#15 is this conversion): scope labeling and
# reconcile — the state:* machine + the stale sweep (.github/scripts/labels-reconcile.sh) # the state reconciler live in the reusable workflow this caller pins. Cast
# # keeps the triggers and permissions (a called workflow cannot define them),
# pull_request_target, not pull_request: every PR here arrives from a fork, # its path map in .github/labeler.yml, and its panel + scope taxonomy in
# where pull_request (and pull_request_review) run with a READ-ONLY token and # .github/labels.conf.
# cannot label anything. _target is safe in this workflow because no PR code
# is ever checked out or executed — labeler reads changed paths via the API,
# and reconcile checks out the BASE branch only. Keep it that way.
#
# Review-submitted transitions (bots finishing a round) ride the cron: there
# is no pull_request_review_target, so the 15-minute tick is the wake signal —
# the same cadence the reviewer bots poll at.
on: on:
schedule: schedule: [{cron: "*/15 * * * *"}] # advisory; the handoff label is the real wake
- cron: "*/15 * * * *" workflow_dispatch: # bootstraps missing labels on a fresh repo
workflow_dispatch: # also bootstraps missing labels — run once on a fresh repo
pull_request_target: pull_request_target:
types: [opened, reopened, ready_for_review, converted_to_draft, synchronize] types: [opened, reopened, ready_for_review, converted_to_draft, synchronize, labeled, unlabeled]
permissions: permissions:
contents: read contents: read
issues: write issues: write
pull-requests: write pull-requests: write
jobs: jobs:
scope: labels:
if: github.event_name == 'pull_request_target' uses: heavy-duty/ceremony/.github/workflows/labels.yml@0.1.0
runs-on: ubuntu-latest
concurrency:
group: labels-scope-${{ github.event.pull_request.number }}
cancel-in-progress: true
steps:
- uses: actions/labeler@v5
with:
# additive only — a hand-applied scope must survive the machine
sync-labels: false
reconcile:
runs-on: ubuntu-latest
# ONE shared group: every reconcile sweeps every open PR, so cron and
# PR-event runs must serialize or two sweeps race the same PR's labels
# and both pass the request-the-human-once guard. GitHub keeps at most
# one queued run per group (older queued runs are superseded), which
# coalesces bursts instead of piling them up.
concurrency:
group: labels-reconcile
cancel-in-progress: false
steps:
- uses: actions/checkout@v4 # base branch only — never the PR's code
- name: reconcile state + stale
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
run: bash .github/scripts/labels-reconcile.sh

View file

@ -1,250 +1,24 @@
name: release name: release
# The release publisher (#96; box#83's design) — two ways in, one act (#111; # The ceremony moved upstream (heavy-duty/ceremony — the doctrine is its
# box#96's design): # README; ceremony#15 is this conversion). Both doors — merge and tag push —
# # live in the reusable workflow this caller pins; cast keeps only what a
# - Merging a `release`-labeled PR into main IS the release. The ceremony # called workflow cannot define (triggers and permissions) plus its one
# PR carries the bumped version and the stamped changelog; the # genuinely local piece: the prebuilt-asset build, now the artifact hook at
# maintainer's merge is the ship decision, and tagging after it is # .github/actions/release-artifact/, which both doors invoke.
# transcription — exactly where humans err silently and machines fail # Triggers and permissions MUST live here (a called workflow cannot define them):
# loudly. This path asserts four facts (each fail-loud, creating
# nothing), then tags the merge commit and publishes.
# - A bare X.Y.Z tag push (no 'v' prefix — box's and rig's tag scheme)
# stays as the documented manual fallback and backfill.
#
# Both paths converge on the SAME steps below — one notes extraction, one
# build, one asset name, one create — so they cannot drift.
#
# Where cast differs from its siblings: the release carries a PREBUILT
# asset. box and rig are pure bash, so GitHub's source tarball for the tag
# IS their package; cast's source tarball is not runnable — it needs npm ci
# and tsc first. So the build happens ONCE, here, and the asset is the
# runnable tree: bin/, dist/, production node_modules/, package.json.
on: on:
# ONE push key, both filters — YAML maps are last-key-wins, so a second # ONE push key, both filters — YAML maps are last-key-wins; a second sibling
# sibling `push:` would silently REPLACE the first and kill a door # `push:` silently replaces the first and kills a door (rig's review catch).
# (grok's round-2 catch: the tag fallback had stopped triggering).
push: push:
# Every tag, not a shape filter (box's and rig's precedent): a tag that tags: ["**"] # every tag — a wrong tag must FAIL the assert loudly,
# mismatches package.json — a habitual v0.1.0, a typo — must fail the # never be skipped by a shape filter that didn't match
# assert LOUDLY below, not be silently skipped by a pattern that didn't
# match.
tags: ["**"]
# The merge-is-the-release path (#111) rides pushes to MAIN, not
# pull_request events: a pull_request run from a public FORK gets a
# READ-ONLY GITHUB_TOKEN — `permissions:` cannot raise that ceiling —
# and every ceremony PR this org merges is cross-repo from the bot
# fork; the tag create would 403 after green asserts. A push to main
# is an in-repo event with the full write token, whoever authored the
# PR. The steps split on the pushed ref.
branches: [main] branches: [main]
permissions: permissions:
contents: write # tag create via the API + gh release create + the bump push contents: write # tag ref create + release create + the bump push
# Two consumers (labels.yml precedent — a declared permissions: block pull-requests: write # decide's label read; the bump-fallback `gh pr create`
# zeroes every unspecified scope): the decide step's label read issues: write # --label on that fallback PR rides the issues API
# (commits/<sha>/pulls) and the bump fallback's `gh pr create --label`.
pull-requests: write
# ...and the --label on that fallback PR rides the ISSUES API (labels.yml
# grants the same pair for the same reason).
issues: write
jobs: jobs:
release: release:
# Tag pushes and main pushes both enter (the asserts below are the uses: heavy-duty/ceremony/.github/workflows/release.yml@0.1.0
# filter); the steps split on the ref. The hand-set `release` label
# (LABELS.md: `release` is the operator's — automation never guesses
# intent) is read via the API off the merge commit's PR, inside the
# decide step — a push event carries no PR payload, and the PR itself
# lives on a fork (the trigger comment).
if: startsWith(github.ref, 'refs/tags/') || github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with: with:
# Either door: the pushed ref — a tag, or main's new head (the version-source: package-json
# merge commit the maintainer shipped, which the tag created
# below will name).
ref: ${{ github.sha }}
# Depth 2: the pushed head's first parent must be resolvable for
# the decide step's all-zeros fallback (event.before on a
# branch-creation push).
fetch-depth: 2
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: npm
- name: "tag push: the tag must name package.json's version"
if: startsWith(github.ref, 'refs/tags/')
run: |
ver="$(node -p 'require("./package.json").version')"
if [ "$GITHUB_REF_NAME" != "$ver" ]; then
echo "tag '$GITHUB_REF_NAME' does not match package.json version '$ver' — creating nothing." >&2
echo "A release is a PR, then a tag (#96): the release PR bumps package.json (and package-lock.json) and stamps the changelog; the tag goes on its MERGE commit. Delete this tag and re-tag the right commit." >&2
exit 1
fi
echo "RELEASE_VERSION=$ver" >> "$GITHUB_ENV"
# The decide step — the version asserts fused, because the `release`
# label carries TWO legitimate meanings (LABELS.md: "release flow and
# version/packaging work"): the ceremony PR that ships a version, and
# ordinary work ON the release machinery — the PR that added this very
# trigger included. The version tells them apart, in four states:
# -dev, unchanged → work under the label: green NOTICE
# no-op, not a red run per infra PR
# -dev, changed → still a dev tree, so still work —
# the post-release bump PR above all
# (bare -> -dev after every release):
# green NOTICE no-op
# bare, unchanged, released → work merged in the post-release
# window (ceremony landed, the -dev
# bump has not — and cast's ENTIRE
# pre-0.1.1 era, since 0.1.0 never
# carried -dev): green NOTICE no-op
# bare, unchanged, UNreleased→ the label says ship but this PR did
# not mint the version: refuse to
# guess. This is also the known
# first-release edge (#111): the 0.1.0
# ceremony (#110) ships by manual tag,
# the fallback path; the automation
# applies from 0.1.1 on.
# bare, changed → the ceremony: proceed
- name: 'decide: ceremony, or release-flow work under the label?'
id: decide
if: github.ref == 'refs/heads/main'
env:
BASE_SHA: ${{ github.event.before }}
GH_TOKEN: ${{ github.token }}
run: |
# Versions read via node, never regex (the pkg_version discipline).
ver="$(node -p 'require("./package.json").version')"
# event.before is all-zeros on a branch-create push; the pushed
# head's first parent is main the instant before, either way.
case "$BASE_SHA" in *[!0]*) ;; *) BASE_SHA="$(git rev-parse "$GITHUB_SHA^1")" ;; esac
git fetch --depth=1 origin "$BASE_SHA" || true
git show "$BASE_SHA:package.json" > "$RUNNER_TEMP/base-package.json"
base="$(node -p 'require(process.env.RUNNER_TEMP + "/base-package.json").version')"
case "$ver" in
*-dev)
if [ "$base" = "$ver" ]; then
echo "NOTICE: version '$ver' is -dev and unchanged by this PR — release-flow work under the release label, not a ceremony. Nothing to publish."
echo "ceremony=no" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "NOTICE: version changed ('$base' -> '$ver') and still ends -dev — a dev tree is by definition not a release. This is work (the post-release bump, a renumber); nothing to publish."
echo "ceremony=no" >> "$GITHUB_OUTPUT"
exit 0 ;;
esac
if [ "$base" = "$ver" ]; then
if gh release view "$ver" > /dev/null 2>&1; then
echo "NOTICE: version '$ver' is already released and unchanged by this PR — release-flow work merged in the post-release window (before the -dev bump). Nothing to publish."
echo "ceremony=no" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "version '$ver' is bare, unchanged by this PR, and never released — the label says ship but this PR did not mint the version. Refusing to guess — creating nothing." >&2
echo "(If this PR was mislabeled, drop the label; if it was meant to release, it forgot the bump. The 0.1.0 first-release edge ships by manual tag — #111.)" >&2
exit 1
fi
# The version transitioned — now the LABEL, the operator's declared
# intent, read via the API because a push event carries no PR
# payload (and the PR lives on a fork — the trigger comment). No
# merged, release-labeled PR behind this commit = a transition
# nobody declared: refuse.
if ! gh api "repos/$GITHUB_REPOSITORY/commits/$GITHUB_SHA/pulls" \
-q '[.[] | select(.merged_at != null) | .labels[].name] | index("release") != null' | grep -qx true; then
echo "version transitioned ('$base' -> '$ver') but no merged, release-labeled PR is behind this commit — a release is a labeled ceremony PR (#111), not a bare push — creating nothing." >&2
exit 1
fi
echo "ceremony=yes" >> "$GITHUB_OUTPUT"
echo "RELEASE_VERSION=$ver" >> "$GITHUB_ENV"
- name: release notes — the version's own CHANGELOG.md section
if: startsWith(github.ref, 'refs/tags/') || steps.decide.outputs.ceremony == 'yes'
# Assert 3 on the merge path, the same fact on the tag path:
# release-notes.sh fails loudly on a missing/empty section, which
# fails the release here — before anything is created.
run: |
bash .github/scripts/release-notes.sh "$RELEASE_VERSION" > "$RUNNER_TEMP/notes.md"
cat "$RUNNER_TEMP/notes.md"
- name: "merged release PR: nothing exists yet, then tag the merge commit"
if: github.ref == 'refs/heads/main' && steps.decide.outputs.ceremony == 'yes'
env:
GH_TOKEN: ${{ github.token }}
MERGE_SHA: ${{ github.sha }}
run: |
# Assert 4 — no tag and no release exist for this version. Re-runs
# of a completed ceremony REFUSE LOUDLY (red, creating nothing —
# the correct direction), and a manual race (an operator who
# tagged by hand between merge and here) fails the same way
# instead of double-publishing.
if git ls-remote --exit-code origin "refs/tags/$RELEASE_VERSION" > /dev/null; then
echo "tag '$RELEASE_VERSION' already exists — creating nothing (already released, or a manual tag won the race)." >&2
exit 1
fi
if gh release view "$RELEASE_VERSION" > /dev/null 2>&1; then
echo "release '$RELEASE_VERSION' already exists — creating nothing." >&2
exit 1
fi
# The act begins: tag the merge commit via the API. A tag created
# with GITHUB_TOKEN does not trigger other workflows, so the
# tag-push trigger above CANNOT fire on this tag and
# double-publish — which is also why the publish must happen in
# THIS job.
gh api "repos/$GITHUB_REPOSITORY/git/refs" \
-f "ref=refs/tags/$RELEASE_VERSION" -f "sha=$MERGE_SHA"
- name: build the prebuilt dist asset
if: startsWith(github.ref, 'refs/tags/') || steps.decide.outputs.ceremony == 'yes'
# Build ONCE, in CI — the whole point of the asset (#96): the
# installer's release channels never run npm or tsc. Deliberately no
# check/tests here: ci.yml already gated the merge commit this
# release names, and the test suite needs `age`, which this runner
# does not install. The staged tree is exactly what an install needs
# to run.
run: |
npm ci
npm run build
npm prune --omit=dev
mkdir -p "$RUNNER_TEMP/stage/cast-$RELEASE_VERSION"
cp -R bin dist node_modules package.json "$RUNNER_TEMP/stage/cast-$RELEASE_VERSION/"
tar -C "$RUNNER_TEMP/stage" -czf "$RUNNER_TEMP/cast-$RELEASE_VERSION.tgz" "cast-$RELEASE_VERSION"
- name: create the release
if: startsWith(github.ref, 'refs/tags/') || steps.decide.outputs.ceremony == 'yes'
env:
GH_TOKEN: ${{ github.token }}
run: |
gh release create "$RELEASE_VERSION" --verify-tag \
--title "$RELEASE_VERSION" --notes-file "$RUNNER_TEMP/notes.md" \
"$RUNNER_TEMP/cast-$RELEASE_VERSION.tgz"
# The post-release bump, folded into the release act (#111 followup —
# operator decision: a mechanical one-liner deserves no PR of its
# own). X.Y.(Z+1)-dev is arithmetic, not judgment: derived, committed
# straight to main with this job's token. A GITHUB_TOKEN push fires
# no workflows (anti-recursion), so the bump triggers neither the
# merge path nor a red run; should branch protection ever refuse the
# direct push, the step opens the bump PR itself and says so, loudly.
# Merge-door only (the decide gate): the manual tag path stays a
# fallback and does not rewrite main.
- name: bump main to the next -dev — the release re-arms main itself
if: github.ref == 'refs/heads/main' && steps.decide.outputs.ceremony == 'yes'
env:
GH_TOKEN: ${{ github.token }}
run: |
# next is computed from the RELEASE tree (the checkout), then
# applied to whatever main is by the time of the push — if main
# moved in the window, release+1 still lands on the newer head,
# which is the intended arithmetic either way.
next="$(node -p 'const v = require("./package.json").version.split("."); v[2] = String(Number(v[2]) + 1) + "-dev"; v.join(".")')"
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git fetch origin main
git checkout -B main origin/main
npm pkg set version="$next"
npm install --package-lock-only --ignore-scripts
git add package.json package-lock.json
git commit -m "chore: bump main to $next — a dev install must not impersonate $RELEASE_VERSION"
if ! git push origin main; then
echo "direct push refused (branch protection?) — opening the bump PR instead" >&2
git checkout -b "chore/bump-$next"
git push origin "chore/bump-$next"
gh pr create -R "$GITHUB_REPOSITORY" --head "chore/bump-$next" \
--title "chore: bump main to $next" \
--body "The post-release re-arm, opened by release.yml because the direct push was refused." \
--label release
fi

7
AGENTS.md Normal file
View file

@ -0,0 +1,7 @@
# AGENTS.md — start at .ceremony/
This repository is governed by
[heavy-duty/ceremony](https://github.com/heavy-duty/ceremony). Read
`.ceremony/AGENTS.md` first — it routes you to your role file, vendored
beside it. Repo specifics (the review panel roster, the scope labels, what
a drill means here, code conventions) live in CONTRIBUTING.md.

View file

@ -5,112 +5,75 @@ since its first commit, but grew its release surface (this file,
`cast --version`, tagged releases with a prebuilt asset) on the way to `cast --version`, tagged releases with a prebuilt asset) on the way to
actually cutting it, and this file starts there. actually cutting it, and this file starts there.
## Unreleased
### Changed
- The release flow and labels automation now run heavy-duty/ceremony's shared
machinery at 0.1.0; the prebuilt-asset build moves to the release-artifact
hook (heavy-duty/ceremony#15)
## 0.2.0 — 2026-07-21
### Added
- CI refuses a release PR with no drill record at `drills/<version>.md`
- An application can declare HTTP basic auth, and `apply` sets it (#76)
- `cast github-app create` / `cast github-app register` run the App Manifest
flow (#7)
### Changed
- `state:needs-human` is set at handoff, not by the cron (#131)
- PR labels split into two axes: `state:*` (whose ball) and `blocker:*` (what
is in the way); `state:needs-rebase` is retired (heavy-duty/box#138)
- The `NO_API_COVERAGE` row for Basic Auth now says services (#76)
- Changelog entries are one line each, and the whole file now follows the rule
(#136)
### Removed
- BREAKING: `scripts/register-github-app.sh` is gone; use `cast github-app
register` (#7)
### Fixed
- Three test files from #124/#125 allocate through `tmp()`, not raw
`mkdtempSync` (#135)
- A PR that deletes a shipped release heading is now CI-red (#133,
heavy-duty/box#122)
- A duplicate release heading is caught even where the guard cannot see the
base (#133, heavy-duty/box#143)
- A label the repo does not have no longer takes the whole label edit down
with it
- An unreadable check rollup no longer reads as "nothing is failing"
- `state:needs-human` no longer appears on PRs a human cannot merge (#127,
heavy-duty/box#136)
- CI lints every tracked shell script, and proves the set is complete (#118)
- The shellcheck sweep covers extensionless scripts such as `bin/cast` (#121)
- `cast` no longer leaves a full repo clone in the temp dir on every run (#117)
- The test suite reaps its temp directories (#117)
## 0.1.1 — 2026-07-19
### Fixed
- The release ceremony re-arms `## Unreleased`, and CI is red when it does not
(#113)
## 0.1.0 — 2026-07-19 ## 0.1.0 — 2026-07-19
### Fixed ### Fixed
- **The release suite accepts the ceremony's own tree** (#108) — - The release suite accepts the ceremony's own tree (#108)
`test/release.test.ts` demanded the real `CHANGELOG.md`'s literal - `apply` no longer demands a GitHub App for a manifest that declares no
`Unreleased` section extract non-empty and contain `#96`: false by applications (#103)
construction on the `release: X.Y.Z` tree the ceremony's own PR produces - A manifest with no `${…}` refs applies without a secret store (#104)
(it stamps that heading into `## X.Y.Z — date`), so the first real - `CAST_AGE_KEY_FILE_<ENV>` is settable for every environment name (#102)
release PR turned CI red and the flow blocked itself — invisible to the
fork rehearsals, which tag a branch (`release.yml` runs; `ci.yml` never
does). The guard now asserts its actual purpose: whatever the TOP `## `
section is — `Unreleased` between releases, the stamped version on and
right after one — the exact `release-notes.sh` the workflow runs
extracts it non-empty. rig's twin is heavy-duty/rig#44.
- **`apply` no longer demands a GitHub App for a manifest that declares no
applications** (#103) — found live in the 2026-07-19 release drill, where a
databases-only manifest (`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, cast reads it in exactly one call (the application
create), and databases and services never touch it. That unconditional
resolution 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 only when the desired state actually
contains an application; a manifest that does declare one still refuses on
a missing binding exactly as before, clean plan or not, because that
binding is state the next create will need.
- **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 against two fresh Coolify 4.1.2
instances: a registered project whose manifest declared databases only
(zero `${…}` refs) could not take its first `apply` — apply refused with
`no secret store for <org>/<repo> in <env>`, and `capture`, the documented
way to get a store, rightly refuses a project that is absent on the box,
because apply is the verb that would create it. The drill unblocked with a
hand-rolled empty store (`printf '' | age -r … -o secrets/….env.age`),
documented nowhere. Now `diff`/`apply` gate that refusal on the manifest
actually *referencing* a secret, asked via the same parser resolution
uses: when the templates resolve zero `${…}` refs, an absent store is
treated as empty and the run proceeds, printing a loud one-line note
naming the path the store would live at — and since there is nothing to
decrypt, the age key is not demanded either. The moment any template
gains a `${…}` ref, the refusal returns byte-identical to before.
`capture` and `destroy` are untouched.
- **`CAST_AGE_KEY_FILE_<ENV>` is now settable for every environment name**
(#102) — `<ENV>` was the name uppercased verbatim, so env `drill-b`
advertised `CAST_AGE_KEY_FILE_DRILL-B`: a variable no POSIX shell can
export, which walled off the injected-key channel (and its
process-substitution trick) for every hyphenated environment. Found live
in the 2026-07-19 release drill. Characters outside `[A-Z0-9]` now map to
`_` — env `drill-b` reads `CAST_AGE_KEY_FILE_DRILL_B` — and the refusal
advertises the mapped name. The standing-key path keeps the exact
environment name, so two names that collide on the variable still resolve
their own keys on disk.
### Added ### Added
- **Merging a release-labeled PR is the release — and the release re-arms - Merging a release-labeled PR is the release, and the release re-arms main
main itself** (#111; box#96's design) — `release.yml` now also fires on itself (#111)
pushes to main (not `pull_request` events: fork-sourced ceremony PRs get - Tagged releases with a prebuilt dist asset, and an installer that installs
a read-only token there — the round-1 catch). A decide step reads the them (#96)
version transition from the push (`event.before` → the pushed head) and
answers four states: release-flow *work* merged under the `release`
label — `-dev` endstates, and the post-release window — no-ops green
with a NOTICE; the two genuinely ambiguous bare states refuse loudly;
a true transition then requires a merged, `release`-labeled PR behind
the commit (read via the API — the label is the operator's declared
intent) before the door opens. It then tags the merge commit, builds
the `cast-X.Y.Z.tgz` asset once, publishes — and bumps main to
`X.Y.(Z+1)-dev` itself, direct push with a loud open-a-PR fallback, so
no follow-up bump PR exists on the paved road. The tag-push path stays
as the documented fallback and backfill, and both paths run the same
steps so they cannot drift. First-release edge: 0.1.0 never carried
`-dev`, so its ceremony (#110) ships by manual tag; the automation
applies from 0.1.1 on.
- **Tagged releases with a prebuilt dist asset, and an installer that
installs them** (#96) — the cast half of the flow designed in
heavy-duty/box#83, plus the piece unique to cast: a **prebuilt asset**,
because cast is the one repo where the source tarball is *not* the
package. A release is a PR, then a tag: the `release: X.Y.Z` PR bumps
`package.json` (and `package-lock.json`) and stamps this file's Unreleased
section with version + date; the merge commit is tagged bare `X.Y.Z`
(box's tag scheme — no `v` prefix). `release.yml` turns the tag into the
GitHub release — after asserting tag == `package.json` version (a
mismatch fails loudly and creates nothing) — with that version's section
of this file as the body, extracted by the same
`.github/scripts/release-notes.sh` the test harness drives, and with the
runnable tree attached as `cast-X.Y.Z.tgz`: `bin/`, compiled `dist/`,
production `node_modules/`, `package.json`, built once in CI
(`npm ci && npm run build && npm prune --omit=dev`). `install.sh` now
defaults to the **latest release**: the tag is resolved by following the
`releases/latest` redirect and reading the `Location` header — no API, no
token — and the download is that release's asset, so **no `npm ci`, no
`tsc`, no devDependencies ever run on the operator's machine**. `CAST_REF`
picks the other two channels: a tag pins a release (its asset first,
source as the fallback for a ref that has none — `refs/tags` outranks a
same-named branch), a branch (`CAST_REF=main`) tracks the development
tree and is the one channel that still builds from source, the only place
`npm` is required. Until 0.1.0 is cut the default channel has nothing to
resolve and dies saying exactly that, naming `CAST_REF=main` as the way
to install today — it never falls back to main silently, because "I
installed the latest release" must not quietly mean "I installed whatever
main was that second". The channel only decides *which* tree arrives and
whether it is built here — whatever it fetched lands in the versioned
layout (`versions/<package.json version>`, `current` flipped atomically)
like any other install.

View file

@ -1,97 +1,114 @@
# Contributing # Contributing
How change lands in this repo. The short version: PRs are born as drafts, This repo is governed by
three reviewer bots take the first rounds, a human takes the last word — and [heavy-duty/ceremony](https://github.com/heavy-duty/ceremony). **Agents:
labels tell you where everything is without opening anything. read [`.ceremony/AGENTS.md`](.ceremony/AGENTS.md) first** — it routes you to
your role file (builder, reviewer, triage), vendored beside it,
byte-identical to ceremony at the pin named in
[`.github/workflows/release.yml`](.github/workflows/release.yml) and
guarded by the `docs-sync` step in CI. The review-round doctrine — drafts,
whole-round replies, verdicts, the handoff — lives there and in
[`.ceremony/LABELS.md`](.ceremony/LABELS.md); this file keeps only what is
genuinely cast's.
## The PR loop ## The PR loop, cast specifics
1. **Fork and branch.** Contributors work from forks; upstream branches are 1. **Fork and branch.** Contributors work from forks; upstream branches are
for maintainers. Title the PR conventionally (`feat:`, `fix:`, `docs:`). for maintainers. Title the PR conventionally (`feat:`, `fix:`, `docs:`).
2. **Open as a draft** while you build. Drafts are invisible to the reviewer 2. **The review panel** (`.github/labels.conf`'s `panel=` line):
bots on purpose. `claude-bot-andresmgsl`, `codex-bot-andresmgsl`, `grok-bot-andresmgsl`,
3. **When it's ready**: mark ready-for-review and request all three bots — `kimi-bot-andresmgsl`
`claude-bot-andresmgsl`, `codex-bot-andresmgsl`, `grok-bot-andresmgsl`. the required verdicts for a PR are the panel minus its author. The
They poll roughly every 15 minutes. maintainer (`danmt`) takes the last word and merges.
4. **Rounds are answered whole.** Wait until all three have reviewed, then 3. **Checks must be green**: `npm run check`, `npm run build`, and
answer the entire round in a **single reply**, push the fixes, and `npm test` locally mirror what CI runs, plus the shellcheck sweep
re-request the bots that didn't approve. Prefer verification over (`npm run check:shell`). The release guards (`changelog-armed`,
argument: a test settles what a comment thread can't. `changelog-monotonic`, `drill-recorded`, `docs-sync`) run as ceremony's
5. **Reviews end in a verdict.** A reviewer — bot or human — either pinned actions.
**approves** or **requests changes**, never a bare comment. A 4. **Feature PRs land their changelog entry as part of the PR**: add it
comment-only review is a non-verdict: it doesn't say whether the round under `CHANGELOG.md`'s `## Unreleased` heading — that section becomes
passed, and the state machine (and anyone scanning the board) has to the release notes verbatim when a release is cut.
guess. The verdict carries *blockingness only*, the body carries the
feedback: non-blocking nits ride an **approval** and the author addresses ## Changelog entries
them at their discretion; anything blocking — including a question that
gates the verdict — is **request changes**, saying what unblocks it. The Every PR that changes behaviour adds one line to `## Unreleased`. One line is
reconciler treats a comment-only review as not-approved, so commenting the whole rule — if it wraps more than twice in your editor, cut it down.
without a verdict only stalls the PR. The machine never reads review
bodies: when a comment-only reviewer's line is really an agreement, that - **Say what changed, and stop.** Why it was wrong, how it was found, what it
judgment belongs to the **author** — escalate by requesting the cost, what it implies — that belongs in the PR body and the commit message,
maintainer's review (step 6), and the reconciler flips the label on that which is where anyone chasing the reasoning already goes. This file answers
request, because an explicit request is a fact it can trust. one question: what is different in this version.
6. **When the round passes, the author hands the PR to the maintainer** by - **Any word that can be removed, is removed.**
requesting their review — that request is what flips `state:needs-human`. - **Lead with the surface, not the mechanism.** "`state:needs-human` is set at
With three formal head-current approvals the labels workflow requests it handoff" beats "the labels workflow now also wakes on `labeled`".
automatically; when part of the panel is comment-only, reading their - **Cite the issue or PR**`(#131)` — and let the reader follow it for the
agreement is the author's judgment, so the author makes the request. rest.
7. **Checks must be green**: `npm run check`, `npm run build`, and - **Mark a breaking change** with a leading `BREAKING:`.
`npm test` locally mirror what CI runs. - Group under `### Added` / `### Changed` / `### Fixed` / `### Removed`.
8. **Feature PRs land their changelog entry as part of the PR** (box's - No bold run-in headings, no sub-paragraphs, no code blocks, no prose essays.
convention): add it under `CHANGELOG.md`'s `## Unreleased` heading —
that section becomes the release notes verbatim when a release is cut. Good:
- `state:needs-human` is set at handoff, not by the cron (#131)
- An unreadable check rollup no longer reads as "nothing is failing" (#128)
- BREAKING: `scripts/register-github-app.sh` is gone; use `cast github-app` (#7)
Not an entry — that is a PR body:
- **`state:needs-human` no longer waits on the cron to become true** (#131) —
the labels workflow now also wakes on `pull_request_target: labeled` and
`unlabeled`, and the author sets it themselves when handing a PR over. A
review landing was never a trigger. There is no `pull_request_review_target`,
and on fork PRs — which is all of them here — ...
## Releasing ## Releasing
A release is a PR, and merging it IS the release A release is a PR, and merging it is the release. The ceremony — the two
([#111](https://github.com/heavy-duty/cast/issues/111); box#96's design, doors, the decide table, the stamps, the post-release re-arm — is
on box#83's shape): heavy-duty/ceremony's machinery, consumed by reference:
[its README](https://github.com/heavy-duty/ceremony/blob/main/README.md)
is the doctrine, `.github/workflows/release.yml` here is the ≤20-line
caller pinning it (`version-source: package-json` — the version lives in
`package.json`, and the post-release bump keeps `package-lock.json` in
step), and the guards run in `ci.yml` from the same pin. Bare `X.Y.Z`
tags, no `v`.
1. A small PR — `release: X.Y.Z`, labeled `release` — bumps `package.json`'s What stays cast's beyond that pin:
`version` (and `package-lock.json`; `npm install --package-lock-only`
keeps them in step) and stamps `CHANGELOG.md`'s Unreleased section as - **The prebuilt asset**
`## X.Y.Z — YYYY-MM-DD`. CI green on it, same loop as any PR. [`.github/actions/release-artifact/`](.github/actions/release-artifact/action.yml),
2. **Merge. That's the ship decision — nothing else to do.** the artifact hook both doors invoke: the build happens ONCE, in CI, and
[release.yml](.github/workflows/release.yml) fires on the merged, `cast-X.Y.Z.tgz` is the runnable tree (`bin/`, `dist/`, production
`release`-labeled PR and asserts, in order, each fail-loud and creating `node_modules/`, `package.json`). That asset is what the installer's
nothing: the merged version is non-`-dev`; the version *changed in this release channels download — never a source tarball, never an
PR* (the `-dev` transition is the interlock — a mislabeled ordinary PR operator-machine build.
fails here); that version's changelog section extracts non-empty - **The drill** — the real-hardware gate before the handoff of a release
([.github/scripts/release-notes.sh](.github/scripts/release-notes.sh)); PR. Cast's drill asserts **promotion**: two live Coolify instances, the
and no tag or release exists for it yet. Then, in the same job, it tags full A→B run (team, apply, an idempotent diff, smoke, inventory,
the merge commit bare `X.Y.Z` (no `v` prefix — box's tag scheme), builds emit-draft, fleet, destroy, the read-only guard) — A→B reproduces, and
the package once (`npm ci && npm run build && npm prune --omit=dev`), and the diff is idempotent. The full meaning — the fixed candidate-ref
publishes the release with the runnable tree — `bin/`, `dist/`, pinning that dissolves the box↔rig recursion, the per-version record
production `node_modules/`, `package.json` — attached as files, the waiver rule — is [`drills/README.md`](drills/README.md); the
`cast-X.Y.Z.tgz`. That asset is what the installer's release channels `drill-recorded` guard enforces the record on every release tree.
download: the build happens once, in CI, never on an operator's machine.
*Manual fallback and backfill:* push a bare `X.Y.Z` tag on the merge
commit yourself — the same workflow runs the same asserts, build, and
publish from the tag.
3. **The release re-arms main itself**: the same workflow run bumps
`package.json` (and `package-lock.json`) to `X.Y.(Z+1)-dev` and pushes
the commit straight to main — no follow-up PR (it opens one only if
branch protection refuses the direct push, and says so loudly).
Installs are versioned by the tree's `package.json` version, so a
`CAST_REF=main` install between releases must land as
`versions/X.Y.(Z+1)-dev`, never as `versions/X.Y.Z` — main's tree must
not impersonate the release it merely descends from. On the *manual*
tag path the bump stays yours: open the one-line PR after publishing.
## Labels — who sets what ## Labels — who sets what
The full taxonomy lives in [LABELS.md](LABELS.md). What matters day to day is The taxonomy and state machine are
who sets each kind — most of it is machinery, and hand-moving a [`.ceremony/LABELS.md`](.ceremony/LABELS.md); cast's `scope:*` rows live in
machine-owned label just gets corrected on the next pass: `.github/labels.conf` (reconciled by the labels caller) and their path map
in `.github/labeler.yml`. What matters day to day is who sets each kind —
most of it is machinery, and hand-moving a machine-owned label just gets
corrected on the next pass:
| Labels | Set by | | Labels | Set by |
|---|---| |---|---|
| `state:*` | the labels workflow ([.github/workflows/labels.yml](.github/workflows/labels.yml)) — recomputed from GitHub's own facts every 15 minutes and on PR events. Never by hand. | | `state:*` | the labels workflow ([.github/workflows/labels.yml](.github/workflows/labels.yml)) — recomputed from GitHub's own facts on PR events (label changes included) and every 15 minutes. Machine-owned, with one exception: the author sets `state:needs-human` at handoff and the workflow reconciles it. Otherwise never by hand. Exactly one per PR: *whose ball is it.* |
| `blocker:*` | the same workflow, from the same facts — *what is in the way.* Any number per PR, or none. Never by hand: applying one does not stop a merge, and removing one does not unblock anything. Fix the thing and the next sweep drops the label. |
| `stale` | the same workflow — 48h without commits, comments, or reviews. `blocked` PRs are exempt: they are quiet legitimately. | | `stale` | the same workflow — 48h without commits, comments, or reviews. `blocked` PRs are exempt: they are quiet legitimately. |
| `scope:*` on PRs | actions/labeler, from the changed paths ([.github/labeler.yml](.github/labeler.yml)). Additive — you may add more, the machine won't remove them. | | `scope:*` on PRs | actions/labeler, from the changed paths ([.github/labeler.yml](.github/labeler.yml)). Additive — you may add more, the machine won't remove them. |
| `scope:*` on issues | you, when opening or triaging — issues have no paths to derive from. | | `scope:*` on issues | you, when opening or triaging — issues have no paths to derive from. |
| `blocked`, `release` | you — automation never guesses intent. | | `blocked`, `release` | you — automation never guesses intent. |
| `merge-next` | you or the agent owning the queue. Which PR lands first is a judgement about how they conflict, so the workflow never sets it — it only **clears** it, the moment the PR stops being something a human could merge. |
| `bug` / `enhancement` / `documentation` | you, on issues only — a PR's type already lives in its title. | | `bug` / `enhancement` / `documentation` | you, on issues only — a PR's type already lives in its title. |
## Issues ## Issues

View file

@ -1,87 +0,0 @@
# Labels
How this repo uses GitHub labels. The taxonomy is shared across the
heavy-duty repos (box, rig, cast) — only the `scope:` set differs per repo,
because it names this repo's actual surfaces.
## State — who is the ball with? (PRs, exactly one)
Every open PR carries exactly one `state:` label, and it answers the only
question a board scan actually asks: *who is this PR waiting on?* The states
mirror the review loop this repo runs — PRs open as drafts, three reviewer
bots pick up ready PRs with reviews requested, each round is answered in a
single reply, and a human takes the final review.
| Label | Color | Waiting on | Enters when | Leaves when |
|---|---|---|---|---|
| `state:building` | `#FBCA04` | the coding agent, still building | PR opened as draft | marked ready + bot reviews requested |
| `state:bots-reviewing` | `#1D76DB` | the reviewer bots to finish the round | ready with reviews requested, or fixes pushed and reviews re-requested | all three bots have reviewed the round |
| `state:addressing` | `#D93F0B` | the coding agent to reply and push fixes | all bots reviewed the round, not all approved | the single round-reply is posted and fixes pushed |
| `state:needs-human` | `#8250DF` | the human reviewer | the human review is requested — by the author when the round passes, or automatically on three formal head-current approvals | merged — or changes requested, which cycles back to `state:addressing` |
`bots-reviewing` and `addressing` are deliberately distinct: staleness in the
first means *poke the bots*, staleness in the second means *the agent dropped
the ball*. Collapsing them loses exactly the information a sweep needs.
## Cross-cutting (PRs and issues)
| Label | Color | Meaning |
|---|---|---|
| `stale` | `#B60205` | No activity for 48h. Sweep-managed, never hand-applied. `state:building` + `stale` is precisely a forgotten draft. |
| `blocked` | `#6A737D` | Waiting on another PR or issue to land first. Quiet *legitimately* — the staleness sweep skips it. |
| `release` | `#0E8A16` | Release flow, versioning, and packaging work. |
## Scope — which surface? (PRs and issues, any number)
All scopes share one calm color, `#C5DEF5` — scopes locate, states alert.
| Label | Covers |
|---|---|
| `scope:capture` | `draft.ts`, `capture.ts` — reading the live world into a manifest |
| `scope:apply` | `apply.ts`, `diff.ts`, `destroy.ts` — reconciling the manifest onto Coolify |
| `scope:secrets` | `secrets.ts`, age handling, the encrypted state repo |
| `scope:fleet` | `fleet.ts`, `inventory.ts`, `server.ts` — placement and the server side |
| `scope:manifest` | `manifest.ts`, `resolve.ts`, `envtemplate.ts` — the manifest language itself |
| `scope:coolify-api` | `coolify.ts`, the OpenAPI reference — the client surface |
## Issue types
`bug`, `enhancement`, `documentation` — issues only. PRs carry their type in
the conventional title (`feat:`, `fix:`, `docs:`), so typing a PR with a label
would just say the same thing twice, drifting apart eventually.
## Maintenance
State labels are written by automation, never by hand. Every state above is
derivable from GitHub's own facts — the draft flag, requested reviewers,
review states, push timestamps — so the labels workflow
([.github/workflows/labels.yml](.github/workflows/labels.yml)) recomputes the
state and reconciles labels statelessly, on a 15-minute cron plus PR events.
A hand-moved label is a lie waiting to happen; the workflow asserts the
effective state instead. `scope:` labels on PRs are applied from the changed
paths by actions/labeler ([.github/labeler.yml](.github/labeler.yml));
[CONTRIBUTING.md](CONTRIBUTING.md) says who sets what.
The same workflow bootstraps the taxonomy: a manual dispatch creates any
missing label idempotently. To create them by hand (needs push access):
```sh
gh label create "state:building" --color FBCA04 --description "PR is a draft — the coding agent is still building" --force
gh label create "state:bots-reviewing" --color 1D76DB --description "Waiting on the bot reviewers to finish the round" --force
gh label create "state:addressing" --color D93F0B --description "All bots reviewed — coding agent owes the single reply + fixes" --force
gh label create "state:needs-human" --color 8250DF --description "All bots approve — waiting on the human reviewer" --force
gh label create "stale" --color B60205 --description "No activity for 48h — needs a poke (sweep-managed)" --force
gh label create "blocked" --color 6A737D --description "Waiting on another PR or issue to land first" --force
gh label create "release" --color 0E8A16 --description "Release flow and version/packaging work" --force
gh label create "scope:capture" --color C5DEF5 --description "draft/capture — reading the live world into a manifest" --force
gh label create "scope:apply" --color C5DEF5 --description "apply/diff/destroy — reconciling onto Coolify" --force
gh label create "scope:secrets" --color C5DEF5 --description "secrets, age, the encrypted state repo" --force
gh label create "scope:fleet" --color C5DEF5 --description "fleet/inventory/server — placement" --force
gh label create "scope:manifest" --color C5DEF5 --description "manifest/resolve/envtemplate — the manifest language" --force
gh label create "scope:coolify-api" --color C5DEF5 --description "coolify.ts + OpenAPI reference — the client" --force
# delete is not an upsert: a label that is already gone exits non-zero. Swallow
# that, so this block converges on re-run instead of erroring after first success.
for L in duplicate invalid question wontfix "help wanted" "good first issue"; do
gh label delete "$L" --yes 2>/dev/null || true
done
```

168
README.md
View file

@ -119,6 +119,9 @@ cast inventory <org>/<repo> --env <env>
cast inventory --env <env> [--emit-draft <dir> [--recipient age1…] [--no-secrets]] cast inventory --env <env> [--emit-draft <dir> [--recipient age1…] [--no-secrets]]
cast destroy <org>/<repo> --env <env> [--instance <name>] [--with-project] cast destroy <org>/<repo> --env <env> [--instance <name>] [--with-project]
cast server add <name> --ip <ip> --key <file> --env <env> [--user root] [--port 22] cast server add <name> --ip <ip> --key <file> --env <env> [--user root] [--port 22]
cast github-app create <org>/<repo> --env <env> [--name <n>] [--port 8765]
cast github-app register <org>/<repo> --env <env> --app-id <id> --installation-id <id> \
--client-id <id> --client-secret-stdin --private-key <file>
cast smoke <org>/<repo> --env <env> [--project <name>] [--environment <name>] cast smoke <org>/<repo> --env <env> [--project <name>] [--environment <name>]
cast team [--env <env>] cast team [--env <env>]
``` ```
@ -166,6 +169,12 @@ cast team [--env <env>]
environment's name at a plan that says, for every database, whether it is backed environment's name at a plan that says, for every database, whether it is backed
up and when the last backup landed. See *Tearing an environment down* below. up and when the last backup landed. See *Tearing an environment down* below.
- **`server add`** — uploads a server's private key and registers it with Coolify. - **`server add`** — uploads a server's private key and registers it with Coolify.
- **`github-app create`** — creates the GitHub App Coolify clones private repos
with, by running GitHub's App Manifest flow, then registers it. Two browser
clicks, zero transcription. See *The GitHub App* below.
- **`github-app register`** — adopts an App you already hold: one created by hand,
or a disaster-recovery restore from a stored private key. `create` ends by
running exactly this.
- **`smoke`** — contract test against the project's `smoke_target`: proves - **`smoke`** — contract test against the project's `smoke_target`: proves
Coolify's bulk env endpoint still *upserts* rather than replacing. Run it after Coolify's bulk env endpoint still *upserts* rather than replacing. Run it after
every Coolify upgrade — `apply`'s never-delete guarantee rests on that behavior, every Coolify upgrade — `apply`'s never-delete guarantee rests on that behavior,
@ -208,6 +217,141 @@ no credentials at all it says so, and names the fix.
The token is never put in the clone URL or in `http.extraheader` — both leak it The token is never put in the clone URL or in `http.extraheader` — both leak it
into `ps`, and the latter persists it into the clone's git config. into `ps`, and the latter persists it into the clone's git config.
## The GitHub App: `cast github-app`
That is how *cast* clones. **Coolify** clones with a GitHub App, and the App used
to be the one piece of a Coolify instance cast could not reproduce: created by
hand in a browser, its four identifiers copied out of the UI by eye, its private
key downloaded to `~/Downloads`, its details fed to a shell script as a
six-variable env pile. Nothing about that survived in state. Rebuild the instance
and you redid the hoops from memory.
```sh
cast github-app create heavy-duty/incubator --env prod --name hdb-coolify-prod
```
There is **no REST endpoint that creates a GitHub App** — no `POST /apps`, no
GraphQL mutation, no `gh app` subcommand, and no PAT scope that unlocks one. The
only programmatic path is GitHub's [App Manifest
flow](https://docs.github.com/en/apps/sharing-github-apps/registering-a-github-app-from-a-manifest):
a browser form POST whose authentication is your existing GitHub session,
followed by an unauthenticated code exchange. It is how Coolify's own *Create
GitHub App* button works, and it is why this command serves you a page instead of
calling an API.
What `create` does:
1. If `gh` is on `PATH` and authenticated, checks you are an **admin** of the org
— so you learn you cannot create Apps there *before* the browser dance, not
after. `gh` is never required; an absent one skips the check silently.
2. Resolves the App's Coolify-facing name from **`github_apps.<org>/<repo>` in
`environments.yaml`**, which is what every later `cast apply` resolves this
repo's App by. `--name` seeds that entry when it is absent and is **refused**
when it disagrees with one that exists.
3. Serves a one-shot page on `127.0.0.1` that submits an App manifest —
`contents: read` + `metadata: read`, webhook inactive, private.
4. You click *Create GitHub App*; GitHub redirects back to the loopback server,
which checks the CSRF `state` and shuts down.
5. Exchanges the code. **This response is the only moment GitHub ever hands over
the private key, the client secret and the webhook secret together.**
6. **Writes all three to disk immediately**, before waiting on anything —
see [Where the credentials land](#where-the-credentials-land). Everything
after this point can fail for ordinary reasons (a slow install screen, a
dropped network, `Ctrl-C`), and none of those may cost you a key GitHub will
not reissue.
7. Prints (and tries to open) the install URL; you pick the repository.
8. Recovers the installation id by minting an RS256 JWT with the App's own key —
never from the `installation_id` GitHub appends to a redirect, which GitHub
documents as a spoofable hint — then fills it into the record from step 6.
9. Uploads the key to Coolify and creates the App record — unless a Source of
that name already exists, in which case it verifies that one rather than
registering a second (Coolify does not enforce unique Source names).
10. **Asks Coolify which repositories the App can actually see, and fails if
`<org>/<repo>` is not among them.** This is the step that matters most:
without it a misconfigured App fails silently and surfaces hours later, in a
different command, as an unresolvable source at `cast apply` time.
If the install never lands, `create` stops at step 8 and tells you the exact
`register` command that finishes the job against the files from step 6. Nothing
is lost and nothing has to be recreated — in particular, do **not** re-run
`create`, which would mint a second App. For that same reason `create` refuses
up front, before the browser flow, when `<name>.pem` already exists.
`register` is the same command from step 9 onwards, for an App you already hold —
one made by hand, or a disaster-recovery restore from a stored PEM:
```sh
pbpaste | cast github-app register heavy-duty/incubator --env prod \
--app-id 12345 --installation-id 99887766 --client-id Iv23li… \
--client-secret-stdin --private-key ~/Downloads/app.private-key.pem
```
The client secret is read from **stdin only** — argv is visible in `ps` and kept
in shell history. `--webhook-secret` is optional: a webhook-**inactive** App is
the right shape for a tailnet-only Coolify where deliveries can never arrive and
deploys are CI-triggered, and cast generates a value rather than making you
invent one.
### Where the credentials land
Into the state directory you point cast at — cast itself stores nothing:
```
<state>/github-apps/
├── .gitignore # `*` — written by cast
├── <name>.pem # 0600, the private key
└── <name>.json # 0600, app id, installation id, client id + secret, webhook secret
```
Both are written the instant GitHub yields them, which is *before* `create`
waits for you to install the App. Until the install lands, `<name>.json` carries
`"installation_id": null` — that is the one field GitHub will answer again as
often as it is asked, and it is filled in on success. Re-running against an
existing file is idempotent on identical content and a **refusal** otherwise;
`--force` is the deliberate escape hatch for a stale half-run.
All three secrets, because GitHub shows them once and `register` needs the client
secret to be re-runnable at all — a disaster-recovery restore that is missing it
is not a restore. They are written **plaintext at 0600**, not into `secrets/`:
that store holds per-repo-per-env *application* env vars, whose whole purpose is
to be decrypted and injected into the running container, which is the last place
an App private key belongs — and its age identity may not exist on the machine
doing the bootstrap at all. Encrypting the one credential that makes recovery
possible behind a key that might not be there is how DR fails at the moment it is
needed.
So the guard is structural rather than cryptographic: the `.gitignore` means
`git add -A` in your state repo cannot commit these by accident. Committing them
stays possible and has to be deliberate — encrypt them yourself and commit the
ciphertext, or keep the directory out of the repo and back it up somewhere that
is not a git remote.
### Until it has worked once
`create`'s design rests on GitHub accepting a `redirect_url` on
`http://127.0.0.1:<port>`. The manifest docs are silent on the scheme (loopback
HTTP is documented for *OAuth* redirect URIs), and the precedent is strong —
Probot's setup flow does exactly this — but it is unvalidated, because validating
it needs a logged-in GitHub session. **The manual path below stays supported
until `create` has succeeded against a real GitHub once.** If it fails, create
the App by hand in the browser and use `github-app register`, which does not
depend on the assumption at all.
<details>
<summary>The manual path</summary>
1. Org → Settings → Developer settings → GitHub Apps → **New GitHub App**.
Permissions: **Contents: Read-only**, **Metadata: Read-only**. Uncheck
*Active* under Webhook. Uncheck *Any account* (keep it private).
2. Note the **App ID** and **Client ID**; generate a **client secret**; generate
and download a **private key**.
3. **Install App** → pick the repository. The installation id is the last path
segment of the URL you land on (`…/settings/installations/<id>`).
4. Feed all of it to `cast github-app register` (above), which validates the name
against state and verifies the repo is reachable.
</details>
## Many Coolifys ## Many Coolifys
`--instance <name>` reads `<state>/.coolify/<name>.env` instead of `--instance <name>` reads `<state>/.coolify/<name>.env` instead of
@ -397,6 +541,19 @@ standing over nothing is worse than no guard, because it reads like one.
> prints in a diff like any literal. Applications only. See > prints in a diff like any literal. Applications only. See
> [semantics.md](docs/semantics.md) → *Derived domains*. > [semantics.md](docs/semantics.md) → *Derived domains*.
> **An application can declare HTTP basic auth, and its password is a `${REF}`
> like any other secret.** Write
> `basic_auth: { enabled: true, username: ops, password: ${ADMIN_PW_PROD} }` on
> the application, and `apply` sets it — closing the "a rebuilt resource comes
> back UNPROTECTED" hole for applications (services have no API for it at all, on
> 4.1.2 or v4.2). The schema **refuses a literal password**: a manifest is a
> committed file, so a literal there is a password in git forever. What cast
> cannot do is *verify* it — the password reads back only to a token with
> sensitive-data reads, so `diff` compares the toggle and the username (a UI flip
> is still caught) and says on every run that the password was not compared,
> rather than implying it matches. See [semantics.md](docs/semantics.md) → *HTTP
> Basic Auth on an application*.
An **`--override`**'s value is read from `$CAST_CAPTURE_<NAME>`, never from the An **`--override`**'s value is read from `$CAST_CAPTURE_<NAME>`, never from the
command line: argv is visible in `ps` to every process on the box. It exists for command line: argv is visible in `ps` to every process on the box. It exists for
values that must not survive the copy — staging and prod sharing a Mailgun values that must not survive the copy — staging and prod sharing a Mailgun
@ -569,8 +726,10 @@ whose secrets were silently skipped looks complete and holds not one value.
**2. Silent losses.** cast cannot express everything a Coolify holds: **2. Silent losses.** cast cannot express everything a Coolify holds:
destinations (which Docker network a resource sits on — no API at all in 4.1.2), destinations (which Docker network a resource sits on — no API at all in 4.1.2),
service hostnames (they live per-container on `service.applications[].fqdn`), service hostnames (they live per-container on `service.applications[].fqdn`),
Basic Auth and custom Traefik labels, the *Include Source Commit in Build* Basic Auth on a *service* and custom Traefik labels anywhere (an application's
toggle, whole database kinds (a MySQL is invisible to cast's manifest), backup Basic Auth is expressible — see below — but its **password** is not readable, so
a draft reports it instead of emitting a block a rebuild could not honour), the
*Include Source Commit in Build* toggle, whole database kinds (a MySQL is invisible to cast's manifest), backup
schedules, and anything else configured in the UI with no manifest field. schedules, and anything else configured in the UI with no manifest field.
A blueprint that omits these **without saying so** is worse than no blueprint, A blueprint that omits these **without saying so** is worse than no blueprint,
@ -988,8 +1147,9 @@ the way back to zero from a half-applied first run.
## Scripts ## Scripts
Operational helpers, all argument-driven (`scripts/`): register a GitHub App with Operational helpers, all argument-driven (`scripts/`): restore a database backup
Coolify, restore a database backup into a target container. into a target container. (`register-github-app.sh` is gone — it is
`cast github-app register` now.)
**They run where cast runs — off the box.** They drive the Coolify API, or reach a **They run where cast runs — off the box.** They drive the Coolify API, or reach a
box over SSH; none of them expects to be executing *on* a server. Anything that box over SSH; none of them expects to be executing *on* a server. Anything that

View file

@ -263,6 +263,7 @@ command -v node >/dev/null 2>&1 || {
} }
if [ ! -f "$ROOT/dist/cli.js" ]; then if [ ! -f "$ROOT/dist/cli.js" ]; then
# shellcheck disable=SC2016 # intentional: the backticked text is advice for a human to type, not something to expand here
printf 'cast: %s/dist/cli.js is missing — run the installer, or `npm ci && npm run build` in %s\n' "$ROOT" "$ROOT" >&2 printf 'cast: %s/dist/cli.js is missing — run the installer, or `npm ci && npm run build` in %s\n' "$ROOT" "$ROOT" >&2
exit 1 exit 1
fi fi

View file

@ -291,6 +291,105 @@ draft always loads) — they used to sit in its `NO_HOME` list of settings a
rebuild silently dropped, and `is_static` was not even there, which is exactly rebuild silently dropped, and `is_static` was not even there, which is exactly
how a rebuilt static site came back wrong. how a rebuilt static site came back wrong.
## HTTP Basic Auth on an application (`basic_auth:`)
An application can declare HTTP basic auth, and `apply` sets it:
```yaml
admin:
source: { repo: acme/widget, branch: main }
build: { pack: nixpacks, base_directory: / }
domains: ["https://admin.widget.example.com"]
basic_auth:
enabled: true
username: ops
password: ${ADMIN_BASIC_AUTH_PROD}
```
This closes the hole `UNCAPTURED.md` used to describe as *"a rebuilt resource is
UNPROTECTED where the original was not"* — for **applications**. It stays open
for **services**, and that is an API gap rather than a vocabulary one:
`ServicesController` carries no basic-auth fields and no `custom_labels`, on
v4.1.2 or on the v4.2 train, so no manifest field could set them (cast#72
finding 7). The `NO_API_COVERAGE` row now says *services* specifically.
**The password is a `${REF}`, and only a `${REF}`.** The schema refuses a
literal — not discourages it. A manifest is a reviewed, committed artifact, so a
literal there is a live password in git forever, in the file everyone reads to
understand the system. The value lives in the environment's age store under that
name, resolved by the same mechanism every env-template `${REF}` uses, and a
missing or empty entry fails the run **before** anything is written, naming the
ref and the store.
**Managing basic auth is opt-in**, exactly like `is_static`. An omitted
`basic_auth:` block says *nothing* about basic auth, and that is deliberate: an
unconditional `is_http_basic_auth_enabled: false` would make the first apply
after this ships **strip the protection off every application somebody enabled by
hand in the UI** whose manifest had not yet been migrated. So: `enabled: true` to
protect, `enabled: false` to actively assert it is off, omit to leave it alone.
Enabling requires both credentials and disabling forbids them — both are
parse-time refusals, and the first is also Coolify's own rule
(`ApplicationsController.php:2446-2463` @ v4.1.2 rejects an enable without them),
enforced again at the wire so no path can reach a mid-run 422.
### What the diff can and cannot see
The three fields do not read back alike, and the report says which is which
rather than averaging them into a single confident answer:
| field | read back? | consequence |
| --- | --- | --- |
| `is_http_basic_auth_enabled` | ✅ a plain column | compared — a toggle flipped in the UI **is** caught |
| `http_basic_auth_username` | ✅ a plain column | compared — a changed username **is** caught |
| `http_basic_auth_password` | ❌ | never compared, always **written** on any basic-auth write |
The password is gated behind a sensitive-data-enabled token at 4.1.2, and on
v4.2 behind the `read:sensitive` token ability (cast#72, #77) — so whether it
arrives depends on the token, the route *and* the release. cast therefore never
projects it into the comparison vocabulary at all, on any box: a field that means
different things on different instances is worse than a field that means one
thing everywhere. (It would also have to be *printed*`renderDiff` renders
every field diff as `field: <live> → <desired>` — and cast prints no secret
anywhere. The field name is redacted in the renderer as a backstop; not
projecting it is the actual guarantee.)
So `cast diff` prints, on every run against an application that declares
`basic_auth:`:
```
basic_auth on application admin declared, http_basic_auth_password NOT compared — verify in the Coolify UI
```
Same disposition as an unverifiable backup schedule or a declared
`destination_uuid`: reported, **not** counted as drift (an absence of evidence is
not evidence of drift, and a run that fails on it is a run operators learn to
force past). A read that returns *none* of the three — a Coolify or a token that
does not serve those columns — names all three on that line instead, and cast
claims nothing at all about that application's basic auth.
**The honest limit that follows:** rotating *only* the password in the store
produces no field diff, therefore no PATCH. The rotation lands on the next apply
that writes basic auth for any other reason (the toggle or username drifting, or
a create) — because Coolify requires both credentials on any write that enables
basic auth, so cast completes the whole triple whenever it sends one of them.
Until then the diff says the password was not compared rather than implying it
matches. To force a rotation today, flip `enabled` off, apply, flip it back on,
apply — or set it in the UI.
### Why `custom_labels` is deliberately absent
`custom_labels` **is** API-settable at 4.1.2 (base64-validated,
`ApplicationsController.php:3836-3854`, in both allowlists), and cast still has
no field for it. Enabling basic auth or changing domains makes Coolify regenerate
an application's proxy labels via `generateLabelsApplication()`, which
**overwrites `custom_labels`** unless `is_container_label_readonly_enabled` — and
*that* flag is itself not API-settable until v4.2. A manifest that declared both
`custom_labels` and domains or basic auth on one application would therefore have
cast silently destroy the labels it was just told to write. Basic-auth-only is
the safe slice until v4.2; raw labels wait for a real use and for the readonly
flag (cast#76, #77). Hand-written labels on a live box stay reported per resource
in `UNCAPTURED.md`, never silently dropped.
## Reserved env var names (`SOURCE_COMMIT`, `COOLIFY_*`) ## Reserved env var names (`SOURCE_COMMIT`, `COOLIFY_*`)
Coolify injects a set of values into an application's runtime environment Coolify injects a set of values into an application's runtime environment
@ -1063,8 +1162,11 @@ the per-service `GET /services/{uuid}` that `diff`/`apply` have made since
projection the diff's read-back uses, so a drafted service diffs clean once projection the diff's read-back uses, so a drafted service diffs clean once
applied; a service whose GET is unreachable or unrecognized is reported per applied; a service whose GET is unreachable or unrecognized is reported per
resource instead — where a one-project diff fails closed, a whole-instance sweep resource instead — where a one-project diff fails closed, a whole-instance sweep
reports and keeps going), Basic Auth / custom Traefik labels, reports and keeps going), Basic Auth on a SERVICE and custom Traefik labels
build and deploy command overrides, what a backup schedule cannot fully say anywhere (an application's basic auth **is** expressible since #76 — what a draft
still cannot carry is its PASSWORD, which no read returns, so an enabled app is
reported per resource rather than emitted as a `basic_auth:` block a rebuild
could not honour), build and deploy command overrides, what a backup schedule cannot fully say
(the schedule itself **is captured** since #75 — the draft reads (the schedule itself **is captured** since #75 — the draft reads
`GET /databases/{uuid}/backups`, the route `diff`/`apply` have used since #51, `GET /databases/{uuid}/backups`, the route `diff`/`apply` have used since #51,
and a single enabled schedule becomes a real `backup: { frequency, retention }` and a single enabled schedule becomes a real `backup: { frequency, retention }`

62
drills/0.2.0.md Normal file
View file

@ -0,0 +1,62 @@
# cast 0.2.0 — release drill
**WAIVED. No drill was run for this release.**
Waived by the maintainer (@danmt) on 2026-07-21. This file exists because the
gate requires a *record*, not a passing result — so a skip is a line in a diff
somebody reviewed, rather than a silence nobody notices.
## Why
The drill harness is not in a state to produce a trustworthy run, and cast is
the least automated of the three: **cast has no drill harness of its own**, and
its legs are the heaviest to stand up. The A→B promotion path needs two live
Coolify instances, working credentials, an age store and a registered GitHub
App. In the 2026-07-19 drill, instance B had to be built from a hand-written
compose file.
A run improvised under those conditions produces numbers nobody should rely on,
and `drills/` is worth nothing if it fills with records like that.
Deferred deliberately, and tracked: #142.
## What this release therefore does NOT prove
Stated plainly so it is not rediscovered as a surprise:
- **The A→B promotion path** against two genuinely live instances: `team`,
`apply`, an idempotent re-diff, `smoke` (the bulk-upsert contract),
`inventory`, `emit-draft`, `fleet --all`, `destroy` behind its typed gate with
the orphan preserved, and the read-only guard refusing.
- **The two headline features of this release under real use.** Declarative
HTTP basic auth (#76) writes to a live Coolify and is only partly readable
back — the password is deliberately never compared, so a drill is the only
place the write is observed end to end. And `cast github-app create` (#7) runs
GitHub's App Manifest flow, whose `redirect_url` on `127.0.0.1` is documented
in that PR as **unvalidated and load-bearing** — no automated test covers it,
and this release does not either.
- **The `### Removed` breaking change in practice**`scripts/register-github-app.sh`
is gone; the replacement path is exercised only by unit tests.
## What CI did prove on this tree
Not nothing, and worth recording so the gap is precise rather than total:
| Job | Result |
|---|---|
| `install` — real `install.sh` against throwaway roots | pass |
| `scope` | pass |
| `reconcile` | pass |
| `npm test` | 784 passed, 0 failed (39 files) |
| `npm run check` (biome, `--error-on-warnings`) | clean |
| `npm run build` (tsc) | clean |
| `shellcheck-all.sh` | clean |
The command surface, the schema and the installer are covered. Nothing that
requires a live Coolify is.
## The commitment
The next release carries a real drill record at `drills/<version>.md` — what
ran, on what host, the pinned refs and SHAs, the numbers, and what failed. A
failed drill is a valid record; another waiver is not. Tracked in #142.

145
drills/README.md Normal file
View file

@ -0,0 +1,145 @@
# Drills
Per-release evidence: **one file per version**, named `<version>.md`, where
`<version>` matches `package.json`'s `version` exactly — `0.2.0` is recorded in
`0.2.0.md`, `0.2.0-rc1` in `0.2.0-rc1.md`.
A release PR's version must have its file here, holding at least one
non-whitespace character, before CI will let it merge (the
`heavy-duty/ceremony/actions/drill-recorded` guard, pinned in ci.yml).
## One file per version, and why the parser went away
Records used to share a single `drill/RUNS.md`, which meant the gate had to
*parse* it: a heading grammar, an em-dash field match, an optional ` — DATE`
tail, a whole-version comparison, a non-blank-body rule. That machinery existed
only because records shared a file — and it shipped two defects in review: a
`sed '/./,$!d'` extraction where `.` matches a space (so a heading followed by
one tab satisfied the gate), and heading-grammar drift from box's stricter form.
One file per version makes nearly all of that unrepresentable. `0.2.0.md` and
`0.2.0-rc1.md` are different files, so the whole-version rule is the
filesystem's rather than a comparison anyone can get wrong, and there is no
heading to drift. One rule survives: a file of only whitespace is not a record.
The directory is plain `drills/`, **not** `.drills/`. Dot-prefixed directories
are invisible to globs without `dotglob` — the blind spot behind #118, #121 and
box#116.
## What a record should contain
- **What ran** — which legs, against which manifest.
- **On what host** — the instances, their versions, who operated it.
- **The pinned candidate refs** — box, rig and cast SHAs under test.
- **The numbers** — counts, elapsed time, whatever the legs emit.
- **What failed** — and "nothing failed" is itself a finding worth a line.
**A failed drill is still a valid record.** The gate wants evidence, not
success. A maintainer may ship on a failed or partial drill — what they may not
do is ship on silence, so a **waiver** is also a legitimate record: say who
waived it, why, and what is untested. Requiring a record makes skipping a
deliberate, reviewable commit instead of the default outcome of forgetting.
## This directory is the record, not the instrument
cast has **no drill harness script of its own**. Its legs run by hand against
the documented procedure: two live Coolify instances and the full A→B
promotion —
team → apply → diff (idempotent) → smoke → inventory → emit-draft →
fleet → destroy → read-only guard
A harness would make the run reproducible; it would not make it recorded. Those
are separate problems, and this directory is the second one.
## Per-repo, by construction
cast records **cast's own** legs. It does not read box's or rig's drill records
to decide whether cast may ship: a cross-repo lookup silently degrades to
"pass" the moment it fails to resolve — the unreadable-rollup class of bug,
where a guard that cannot read its input reports the happy answer.
**The three repos' drills are independent.** Run them in any order, on any
schedule, in separate sittings. They are not phases of one script.
What makes that safe is that every drill **pins the same fixed set of candidate
refs** (`RIG_REPO` / `RIG_REF` are mint-time variables, default
`heavy-duty/rig@main`), so each drill exercises exactly the combination that
will ship rather than whatever `main` happens to be that afternoon. The run
drills **candidate refs, not released artifacts**.
That pinning — **not sequencing** — is what dissolves the box↔rig recursion.
box and rig *are* mutually recursive: rig builds the host that runs box, and
box's seed calls rig back to converge the guest. But candidate refs are static
identifiers that exist as soon as the release branches do, long before any
drill runs, so a cycle at runtime becomes independent tests against one fixed
pair. No repo has to be released before another can be drilled, and there is
**no fixed order in which the three releases must be published**.
Each repo also drills a **different thing**: box asserts the isolation contract
(the VM trust boundary), rig asserts convergence (a machine reaches its role,
idempotently), cast asserts promotion (A→B reproduces, and the diff is
idempotent). Three different exercises sharing a substrate — which is exactly
why the records are per-repo.
cast's legs are the **least coupled** of the three. Two Coolify instances can
be stood up by hand; the July drill did exactly that for instance B, via a
parameterised compose file. Nothing about cast's drill requires box or rig to
have been drilled first, or at all, on that day.
Within a single drill you obviously bring the substrate up before probing it —
a host before a guest before Coolify. That is how you run *a* drill, not an
ordering rule *between repos*.
If a defect shows up only in the combination: patch, re-drill, re-record. The
three releases converge on a set that holds together; they are not required to
be right in one pass.
**Drilling the candidate is drilling the release.** A release PR's diff is the
version file and `CHANGELOG.md` — nothing executable differs between the tree
that was drilled and the tree that ships, so the evidence carries across the
ceremony commit.
Each record cites the shared **run ID** naming the pinned set, plus the other
two repos' SHAs — which is what lets separate records be reassembled into one
picture, while each repo's evidence still lives in its own tree.
## Worked example
Illustrative only. The version below is a **placeholder that can never collide
with a real release** — a realistic-looking version here would be a real record
for it, and the gate would wave that release through on documentation. The
mechanism changed with the move to one file per version; the caution did not.
`drills/9.9.9.md`:
```markdown
# Release drill — 9.9.9 — YYYY-MM-DD
Run ID: `drill-YYYYMMDD-NN` (names the pinned candidate set).
Stack under test: box `<sha>`, rig `<sha>`, cast `<sha>` — candidate refs,
pinned at mint time via RIG_REPO/RIG_REF.
Instances: A `coolify-a.example` (v4.x), B `coolify-b.example` (v4.x),
B stood up by hand from the parameterised compose file.
Manifest: `examples/two-env.yaml`, 3 applications, 2 environments.
Operator: @maintainer. Elapsed: 41m.
| Leg | Result | Notes |
|---|---|---|
| team | pass | 2 teams, 4 members reconciled |
| apply (A→B) | pass | 3 apps created, 11 env vars set |
| diff (idempotent) | pass | second apply: 0 changes |
| smoke | pass | 3/3 endpoints 200 |
| inventory | pass | 3 apps, 2 envs, matches manifest |
| emit-draft | pass | draft matches inventory round-trip |
| fleet | pass | both instances listed, versions read |
| destroy | pass | 3 apps removed, absence asserted |
| read-only guard | pass | write refused against B with the guard on |
Failures: none. One rough edge: `smoke` needed a 20s retry window on B —
filed as #NNN, not a release blocker.
```
## Records
*None yet.* cast has recorded no drill runs.

View file

@ -410,6 +410,10 @@ profile_for_shell() {
} }
path_line_for() { path_line_for() {
# $PATH below must land in the profile LITERALLY — it is expanded by the
# user's future shells, not by this one. The directive sits on the whole
# case because a directive on an individual branch is rejected (SC1124).
# shellcheck disable=SC2016
case "$1" in case "$1" in
*/config.fish) printf 'fish_add_path %s\n' "$BINDIR" ;; */config.fish) printf 'fish_add_path %s\n' "$BINDIR" ;;
*) printf 'export PATH="%s:$PATH"\n' "$BINDIR" ;; *) printf 'export PATH="%s:$PATH"\n' "$BINDIR" ;;
@ -427,6 +431,7 @@ else
log "$PROFILE already puts $BINDIR on PATH — this shell just predates it." log "$PROFILE already puts $BINDIR on PATH — this shell just predates it."
else else
mkdir -p "$(dirname "$PROFILE")" mkdir -p "$(dirname "$PROFILE")"
# shellcheck disable=SC2094 # false positive: path_line_for only cases on its argument's NAME, it never reads the file
{ printf '\n%s\n' "$MARKER"; path_line_for "$PROFILE"; } >>"$PROFILE" \ { printf '\n%s\n' "$MARKER"; path_line_for "$PROFILE"; } >>"$PROFILE" \
|| die "could not write $PROFILE — add this line yourself: $(path_line_for "$PROFILE")" || die "could not write $PROFILE — add this line yourself: $(path_line_for "$PROFILE")"
log "wired $BINDIR onto PATH in $PROFILE" log "wired $BINDIR onto PATH in $PROFILE"

4
package-lock.json generated
View file

@ -1,12 +1,12 @@
{ {
"name": "cast", "name": "cast",
"version": "0.1.0", "version": "0.2.1-dev",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "cast", "name": "cast",
"version": "0.1.0", "version": "0.2.1-dev",
"dependencies": { "dependencies": {
"yaml": "^2.5.0", "yaml": "^2.5.0",
"zod": "^3.25.76" "zod": "^3.25.76"

View file

@ -1,15 +1,20 @@
{ {
"name": "cast", "name": "cast",
"version": "0.1.0", "version": "0.2.1-dev",
"type": "module", "type": "module",
"private": true, "private": true,
"engines": { "node": ">=22.12.0" }, "engines": {
"bin": { "cast": "./dist/cli.js" }, "node": ">=22.12.0"
},
"bin": {
"cast": "./dist/cli.js"
},
"scripts": { "scripts": {
"build": "tsc", "build": "tsc",
"test": "vitest run", "test": "vitest run",
"format": "biome format --write .", "format": "biome format --write .",
"check": "biome check --error-on-warnings ." "check": "biome check --error-on-warnings .",
"check:shell": "bash .github/scripts/shellcheck-all.sh"
}, },
"dependencies": { "dependencies": {
"yaml": "^2.5.0", "yaml": "^2.5.0",

View file

@ -1,36 +0,0 @@
#!/usr/bin/env bash
# Register a GitHub App (created via the manifest flow on your org) with a
# Coolify instance, so cast can create applications from private repos.
#
# usage: CAST_STATE=~/infra ./register-github-app.sh
#
# Takes everything as input — the app's identity is yours, not this tool's.
# APP_NAME must match the `github_apps.<repo>` value in your environments.yaml:
# that is the name cast resolves when it creates an application.
set -euo pipefail
STATE="${CAST_STATE:-.}"
# shellcheck disable=SC1091
source "${STATE}/.coolify.env"
: "${APP_NAME:?the Coolify-facing GitHub App name (must match github_apps.<repo> in environments.yaml)}"
: "${ORG:?the GitHub org or user the App is installed on}"
: "${APP_ID:?}"; : "${INSTALLATION_ID:?}"; : "${CLIENT_ID:?}"; : "${CLIENT_SECRET:?}"
: "${WEBHOOK_SECRET:?}"; : "${PRIVATE_KEY_FILE:?path to the App private key PEM}"
api() { curl -fsS -H "Authorization: Bearer ${COOLIFY_ACCESS_TOKEN}" -H "Content-Type: application/json" "$@"; }
KEY_UUID=$(api -X POST "${COOLIFY_BASE_URL}/api/v1/security/keys" \
-d "$(jq -n --arg name "${APP_NAME}-key" --rawfile pk "$PRIVATE_KEY_FILE" \
'{name:$name, private_key:$pk}')" | jq -r .uuid)
api -X POST "${COOLIFY_BASE_URL}/api/v1/github-apps" -d "$(jq -n \
--arg name "$APP_NAME" --arg org "$ORG" \
--arg app_id "$APP_ID" --arg inst "$INSTALLATION_ID" --arg cid "$CLIENT_ID" \
--arg csec "$CLIENT_SECRET" --arg wh "$WEBHOOK_SECRET" --arg key "$KEY_UUID" \
--arg api_url "https://api.github.com" --arg html_url "https://github.com" \
'{name:$name, organization:$org, api_url:$api_url, html_url:$html_url,
app_id:($app_id|tonumber), installation_id:($inst|tonumber), client_id:$cid, client_secret:$csec,
webhook_secret:$wh, private_key_uuid:$key}')"
echo "github app registered as ${APP_NAME}"

View file

@ -92,6 +92,73 @@ export function applyHostnameOverlay(
}); });
} }
// Put the whole basic-auth triple back into an UPDATE payload that carries only
// part of it.
//
// An update body is assembled from the field DIFFS — the fields that actually
// changed — and that is wrong for basic auth in both directions:
//
// - Coolify requires username AND password on any write that enables basic
// auth (ApplicationsController.php:2446-2463 @ v4.1.2). So a drift in the
// toggle alone, or in the username alone, would PATCH an enable with a
// missing credential and 422 mid-run.
// - The password is never read back (see projectLiveFields), so it never
// appears as a diff on its own. Sending it alongside every basic-auth write
// is what makes a store-side rotation land at all: it rides on the next
// apply that touches basic auth for any reason.
//
// What it deliberately does NOT do is manufacture a write. A run where nothing
// about basic auth drifted still sends nothing — this only completes a payload
// that was already going to be sent. So the honest limit stands and is printed
// on every diff: rotating ONLY the password in the store produces no field diff,
// therefore no PATCH, and `cast diff` says the password was not compared rather
// than implying it matches.
const BASIC_AUTH_KEYS = [
"is_http_basic_auth_enabled",
"http_basic_auth_username",
"http_basic_auth_password",
] as const;
export function completeBasicAuth(
fields: Record<string, unknown>,
spec: Desired | undefined,
): Record<string, unknown> {
const declared = spec?.fields ?? {};
// Only complete a payload that is ALREADY touching basic auth. This is what
// keeps the function from manufacturing a write, and it is the reason the
// honest limit above still holds.
if (!BASIC_AUTH_KEYS.some((k) => fields[k] !== undefined)) return fields;
// Read the INTENT from the declared spec, not from the payload. Keying on
// `fields.is_http_basic_auth_enabled === true` was the bug (cast#76 review):
// the toggle is absent from an update body exactly when it already MATCHES,
// so on username-only drift — auth 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 failure this function exists to prevent, on the one path it
// was not looking at.
//
// A payload that explicitly DISABLES (toggle === false) is left alone —
// completing it with credentials would be manufacturing the opposite write.
const enabled =
fields.is_http_basic_auth_enabled === true ||
(fields.is_http_basic_auth_enabled === undefined &&
declared.is_http_basic_auth_enabled === true);
if (!enabled) return fields;
const completed = { ...fields };
// The toggle is completed too, not just the credentials: Coolify's presence
// rule is about the write as a whole, and a username+password PATCH with no
// toggle asks it to infer what cast can simply state.
for (const k of BASIC_AUTH_KEYS) {
if (completed[k] === undefined && declared[k] !== undefined)
completed[k] = declared[k];
}
return completed;
}
export async function applyPlan( export async function applyPlan(
report: DiffReport, report: DiffReport,
desired: Desired[], desired: Desired[],
@ -181,8 +248,9 @@ export async function applyPlan(
uuid = await exec.createResource(c); uuid = await exec.createResource(c);
} else { } else {
uuid = c.uuid as string; uuid = c.uuid as string;
const fields = Object.fromEntries( const fields = completeBasicAuth(
c.fieldDiffs.map((f) => [f.field, f.desired]), Object.fromEntries(c.fieldDiffs.map((f) => [f.field, f.desired])),
spec,
); );
if (Object.keys(fields).length > 0) { if (Object.keys(fields).length > 0) {
await exec.updateFields(uuid, c.kind, fields); await exec.updateFields(uuid, c.kind, fields);

View file

@ -84,6 +84,13 @@ import {
renderFleetDiff, renderFleetDiff,
renderProjectHeading, renderProjectHeading,
} from "./fleet.js"; } from "./fleet.js";
import {
createGithubApp,
generateWebhookSecret,
registerGithubApp,
resolveAppName,
seedGithubAppBinding,
} from "./github-app.js";
import { import {
type LiveResource, type LiveResource,
type SweepEnvironment, type SweepEnvironment,
@ -124,6 +131,10 @@ const USAGE = `usage: cast apply <org>/<repo> --env <env> [--path <dir>] [--
cast inventory --env <env> --emit-draft <dir> [--recipient age1] [--no-secrets] cast inventory --env <env> --emit-draft <dir> [--recipient age1] [--no-secrets]
cast destroy <org>/<repo> --env <env> [--instance <name>] [--path <dir>] [--with-project] cast destroy <org>/<repo> --env <env> [--instance <name>] [--path <dir>] [--with-project]
cast server add <name> --ip <ip> --key <file> --env <env> [--user root] [--port 22] cast server add <name> --ip <ip> --key <file> --env <env> [--user root] [--port 22]
cast github-app create <org>/<repo> --env <env> [--name <n>] [--port 8765] [--force]
cast github-app register <org>/<repo> --env <env> --app-id <id> --installation-id <id>
--client-id <id> --client-secret-stdin --private-key <file>
[--webhook-secret <v>] [--name <n>] [--force]
cast smoke <org>/<repo> --env <env> [--project <name>] [--environment <name>] cast smoke <org>/<repo> --env <env> [--project <name>] [--environment <name>]
cast team [--env <env>] cast team [--env <env>]
cast versions # list installed versions cast versions # list installed versions
@ -179,6 +190,32 @@ const USAGE = `usage: cast apply <org>/<repo> --env <env> [--path <dir>] [--
coordinate: --path, --project, --environment, --resource, coordinate: --path, --project, --environment, --resource,
--hostname-overlay. --hostname-overlay.
github-app (the credential Coolify clones private repos with):
create runs GitHub's App Manifest flow the ONLY programmatic way to make a
GitHub App then falls through into exactly what \`register\` does. It
serves a one-shot page on 127.0.0.1, your browser session authenticates
the form, and the conversion response hands over the private key, the
client secret and the webhook secret in one body. Nothing is transcribed.
All three are written to disk the instant they arrive BEFORE the wait
for you to install the App so a timeout or a Ctrl-C during that wait
cannot lose a key GitHub shows exactly once. If the install never lands,
cast prints the \`register\` command that finishes the job; do not re-run
\`create\`, which would mint a second App.
register adopts credentials you already hold: an App created by hand, or a
disaster-recovery restore from a stored PEM. The client secret is read
from STDIN (never argv); --webhook-secret is optional, because a
webhook-INACTIVE App is the right shape for a tailnet-only Coolify.
--name seeds \`github_apps.<org>/<repo>\` in environments.yaml when it is
ABSENT, and is refused when it disagrees with an entry that exists.
The state file is the authority: its value is what every later
\`cast apply\` resolves this repo's App by.
--force overwrite an existing PEM/credentials file under <state>/github-apps/.
Both verbs end by asking Coolify which repositories the App can actually see
and failing if <org>/<repo> is not among them the check that turns a silent
misconfiguration into an error next to the thing that caused it. Re-running
\`register\` after that failure RE-VERIFIES an existing Coolify Source of the
same name rather than registering a second one.
capture (adopt a hand-built instance into the age secret store): capture (adopt a hand-built instance into the age secret store):
--generated <NAME> force NAME to the \`pending-coolify-generated\` placeholder, --generated <NAME> force NAME to the \`pending-coolify-generated\` placeholder,
for a manifest that has not declared generated_secrets yet. for a manifest that has not declared generated_secrets yet.
@ -391,6 +428,53 @@ export function projectLiveFields(
...(raw.is_static == null ...(raw.is_static == null
? {} ? {}
: { is_static: raw.is_static === true || raw.is_static === 1 }), : { is_static: raw.is_static === true || raw.is_static === 1 }),
// Basic auth, read back as far as the read path will say (cast#76).
//
// `is_http_basic_auth_enabled` and `http_basic_auth_username` are ordinary
// `applications` columns and come back on this route; they are projected
// whenever they are actually THERE, and omitted when they are not, so a
// Coolify (or a token) that hides them produces "not compared" rather than
// a phantom `undefined`. Same three-way discipline as `is_static` above,
// and note that "absent" and "false" are different answers: a real `false`
// is projected and diffs normally, which is what catches somebody turning
// basic auth off in the UI.
//
// `http_basic_auth_password` is NEVER projected, whatever the read
// returned, and that is a deliberate policy rather than a limitation:
//
// - It is gated. `ApplicationsController@removeSensitiveData` hides it
// from a token without sensitive-data reads at 4.1.2, and on `next`
// the hiding moves to the model behind the `read:sensitive` ability
// (cast#72, #77) — so whether it arrives depends on the token AND the
// route AND the release, and a diff must not silently mean different
// things on different boxes.
// - Even where it DOES arrive, putting a plaintext password into
// `fields` puts it into the diff report, and cast prints no secret,
// anywhere. (renderDiff redacts the field name as a backstop; not
// projecting it is the actual guarantee.)
//
// The consequence is stated rather than hidden: `fetchLive` flags the app
// `basicAuthNotCompared`, computeDiff skips the password, and every diff of
// an app declaring basic auth prints a line saying the password was not
// compared. A store-side password rotation therefore needs an apply that
// has some other reason to write — see completeBasicAuth in apply.ts, and
// the caveat in semantics.md.
// The username rides on the toggle's readability rather than on its own
// presence: the two are plain columns on the same row, serialized (or
// hidden) together, so a readable toggle means the username was readable
// too — and a NULL one then means "no username is set", a real value worth
// diffing against, not an unreadable one. Projecting it as absent instead
// would turn "somebody cleared the username" into "cast could not look".
...(raw.is_http_basic_auth_enabled == null
? {}
: {
is_http_basic_auth_enabled:
raw.is_http_basic_auth_enabled === true ||
raw.is_http_basic_auth_enabled === 1,
http_basic_auth_username: String(
raw.http_basic_auth_username ?? "",
),
}),
...(raw.install_command ? { install_command: raw.install_command } : {}), ...(raw.install_command ? { install_command: raw.install_command } : {}),
...(raw.build_command ? { build_command: raw.build_command } : {}), ...(raw.build_command ? { build_command: raw.build_command } : {}),
...(raw.start_command ? { start_command: raw.start_command } : {}), ...(raw.start_command ? { start_command: raw.start_command } : {}),
@ -659,6 +743,20 @@ export async function fetchLive(
...(kind === "application" && i.is_static == null ...(kind === "application" && i.is_static == null
? { staticNotCompared: true } ? { staticNotCompared: true }
: {}), : {}),
// Why this application's basic auth could not be fully verified (cast#76).
// Set on EVERY application, not only the ones a manifest protects —
// computeDiff decides whether it is relevant, because only it knows what
// the desired side declared. The password half is unconditional at 4.1.2
// (projectLiveFields never projects it, on purpose); the whole-block half
// fires when the read returned no toggle at all.
...(kind === "application"
? {
basicAuthNotCompared:
i.is_http_basic_auth_enabled == null
? "this read returned no is_http_basic_auth_enabled for this application, so cast saw none of its basic-auth state — a Coolify or a token that does not serve these columns on GET /projects/{uuid}/{env}"
: "http_basic_auth_password is never read back: Coolify 4.1.2 hides it from a token without sensitive-data reads, v4.2 moves it behind the read:sensitive ability, and cast prints no secret — so the toggle and the username are compared and the password is written, not verified (cast#76)",
}
: {}),
})); }));
const live = [ const live = [
...map("application", env.applications), ...map("application", env.applications),
@ -1175,7 +1273,17 @@ async function runProject(
let secrets: Record<string, string>; let secrets: Record<string, string>;
if (existsSync(store)) { if (existsSync(store)) {
secrets = decryptSecrets(store, keyFileFor(ctx.envName)); secrets = decryptSecrets(store, keyFileFor(ctx.envName));
} else if (requiredSecrets(checkout, ctx.envName).required.length > 0) { } else if (
// `manifestRefs` alongside `required` (cast#76): a `basic_auth.password`
// ref is read from the store exactly like a template's, but it is not a
// RequiredSecret (see requiredSecrets). Asking only about `required` would
// let a manifest whose one secret is a basic-auth password proceed on `{}`
// — straight into desiredFromManifest's refusal, with a worse message.
(() => {
const s = requiredSecrets(checkout, ctx.envName);
return s.required.length > 0 || s.manifestRefs.length > 0;
})()
) {
throw new Error( throw new Error(
[ [
`no secret store for ${orgRepo} in ${ctx.envName}`, `no secret store for ${orgRepo} in ${ctx.envName}`,
@ -1352,6 +1460,193 @@ function formatVersion(): string {
return `cast ${typeof version === "string" ? version : "unknown"} (${dirname(pkgPath)})`; return `cast ${typeof version === "string" ? version : "unknown"} (${dirname(pkgPath)})`;
} }
// `--client-secret-stdin`, mirroring `docker login --password-stdin`: argv is
// visible in `ps` and lands in shell history, and a GitHub App client secret is
// shown by GitHub exactly once.
async function readAllStdin(): Promise<string> {
const chunks: Buffer[] = [];
for await (const chunk of process.stdin) chunks.push(Buffer.from(chunk));
return Buffer.concat(chunks).toString("utf8").trim();
}
// `cast github-app register|create` — one command surface, ONE registration
// implementation (see src/github-app.ts). Everything up to the point where
// credentials exist differs between the two verbs; everything from there on is
// registerGithubApp, which `create` calls rather than reimplements.
async function githubAppCommand(rest: string[]): Promise<number> {
const verb = rest[0];
if (verb !== "register" && verb !== "create") {
console.error(USAGE);
return 2;
}
const { values, positionals } = parseArgs({
args: rest.slice(1),
allowPositionals: true,
options: {
state: { type: "string" },
env: { type: "string" },
instance: { type: "string" },
name: { type: "string" },
force: { type: "boolean" },
// create
port: { type: "string" },
// register
"app-id": { type: "string" },
"installation-id": { type: "string" },
"client-id": { type: "string" },
"client-secret-stdin": { type: "boolean" },
"private-key": { type: "string" },
"webhook-secret": { type: "string" },
},
});
const orgRepo = positionals[0];
// --env is required for the same reason `server add` requires it: this
// writes to a live Coolify, and every write first asserts that the token
// belongs to the environment's declared team.
if (!orgRepo || !orgRepo.includes("/") || !values.env) {
console.error(USAGE);
return 2;
}
// `register`'s two ids reach `Number()` far below, and a non-numeric string
// becomes NaN silently. That matters more here than it usually would, because
// `register` deliberately persists BEFORE it talks to Coolify:
// `JSON.stringify(NaN)` is `null`, so `--app-id nope` would write a credential
// record whose app_id is null and could upload the security key before
// `POST /github-apps` rejects it — a half-run leaving a corrupt record on disk
// and a stray key on the server (cast#7 review).
//
// This sits with the other ARGV checks, above openCoolify/assertTeam, because
// "reject before any write or network call" has to mean the team read too. A
// typo should cost nothing, not one request.
//
// Digits-only rather than Number.isInteger: `1e3` and `0x10` are integers to
// JavaScript but are not how a GitHub App id is written, and quietly storing
// 1000 for `1e3` is the same class of wrong answer this check exists to stop.
if (verb === "register") {
for (const [flag, raw] of [
["--app-id", values["app-id"]],
["--installation-id", values["installation-id"]],
] as const) {
if (raw !== undefined && (!/^\d+$/.test(raw) || Number(raw) <= 0)) {
console.error(
`${flag} must be a positive integer (got ${JSON.stringify(raw)})`,
);
return 2;
}
}
}
// `--port` on the create path has the same defect the ids had, and the same
// rule applies: `Number("abc")` is NaN, which reaches `server.listen(NaN)` in
// github-app.ts and dies as an uncaught ERR_SOCKET_BAD_PORT stack trace —
// after `detectOwnerType` and the org-admin preflight have already gone out.
// Nothing is lost when it fails (no App and no secret exist yet), so this is
// about the command honouring its own stated rule rather than about damage:
// reject before any write or network call, and fail with a sentence instead
// of a stack trace.
//
// Range-checked as well as digits-only, because `--port 99999` is accepted by
// every check the ids need and still cannot be listened on.
if (values.port !== undefined) {
const p = Number(values.port);
if (!/^\d+$/.test(values.port) || p < 1 || p > 65535) {
console.error(
`--port must be a port number between 1 and 65535 (got ${JSON.stringify(values.port)})`,
);
return 2;
}
}
const stateDir = stateDirFrom(values.state);
const bindingsPath = join(stateDir, "environments.yaml");
const bindings = loadBindings(bindingsPath);
const binding = bindings.environments[values.env];
if (!binding) {
console.error(`environment ${values.env} not in environments.yaml`);
return 2;
}
// Step 2, before anything reaches a network: the Coolify-facing name comes
// from state. See resolveAppName — this is #5's footgun 1, dissolved.
const { name, seed } = resolveAppName({
bindings,
orgRepo,
nameFlag: values.name,
});
const { instance, client } = openCoolify(stateDir, values.instance, binding);
assertWritable(instance, `github-app ${verb}`);
const team = await assertTeam(client, binding.team, values.env);
console.log(`team ${formatTeam(team)}`);
console.log(
`github app name: ${name}${seed ? " (from --name, not yet in environments.yaml)" : " (from environments.yaml)"}`,
);
const org = orgRepo.split("/")[0] ?? orgRepo;
if (verb === "create") {
await createGithubApp({
client,
orgRepo,
name,
stateDir,
force: values.force,
port: values.port ? Number(values.port) : undefined,
});
} else {
const appId = values["app-id"];
const installationId = values["installation-id"];
const clientId = values["client-id"];
const privateKey = values["private-key"];
if (!appId || !installationId || !clientId || !privateKey) {
console.error(USAGE);
return 2;
}
if (!values["client-secret-stdin"]) {
console.error(
"--client-secret-stdin is required: the client secret is read from stdin,\nnever from argv (which `ps` shows and shell history keeps).",
);
return 2;
}
const clientSecret = await readAllStdin();
if (!clientSecret) {
console.error("no client secret on stdin");
return 2;
}
// #5's footgun 3: a webhook-inactive App is the right configuration for a
// tailnet-only Coolify, and the old script still demanded a secret for it.
const webhookSecret = values["webhook-secret"] ?? generateWebhookSecret();
if (!values["webhook-secret"]) {
console.log(
"no --webhook-secret: generated one (fine for a webhook-inactive App)",
);
}
await registerGithubApp({
client,
name,
org,
orgRepo,
stateDir,
force: values.force,
creds: {
appId: Number(appId),
installationId: Number(installationId),
clientId,
clientSecret,
webhookSecret,
privateKeyPem: readFileSync(privateKey, "utf8"),
},
});
}
// Only after the App is registered AND verified: a state file that names an
// App which does not work is worse than one that names none.
if (seed) {
seedGithubAppBinding(bindingsPath, orgRepo, name);
console.log(
`environments.yaml: github_apps["${orgRepo}"] = ${name} (added)`,
);
}
return 0;
}
async function main(): Promise<number> { async function main(): Promise<number> {
const [command, ...rest] = process.argv.slice(2); const [command, ...rest] = process.argv.slice(2);
if (command === "-h" || command === "--help" || command === "help") { if (command === "-h" || command === "--help" || command === "help") {
@ -2227,6 +2522,9 @@ async function main(): Promise<number> {
}); });
return 0; return 0;
} }
if (command === "github-app") {
return await githubAppCommand(rest);
}
if (command === "smoke") { if (command === "smoke") {
const { values, positionals } = parseArgs({ const { values, positionals } = parseArgs({
args: rest, args: rest,
@ -2751,6 +3049,57 @@ export function applicationApiFields(
): Record<string, unknown> { ): Record<string, unknown> {
const { port, healthcheck, domains, docker_compose_domains, ...rest } = const { port, healthcheck, domains, docker_compose_domains, ...rest } =
fields; fields;
// Coolify's presence rule, enforced at the wire (cast#76). PATCH
// /applications/{uuid} rejects an enable without both credentials
// (ApplicationsController.php:2446-2463 @ v4.1.2) and the create allowlist
// takes the same three keys (:914, :2368).
//
// The manifest schema already refuses a half-declared block, so this is the
// BELT, not the braces — and it is worth having because it guards the paths
// the schema cannot see: apply's own field completion, a hostname overlay, and
// any future caller assembling a payload by hand. Failing here costs one
// exception; failing at Coolify costs a 422 in the middle of a run that has
// already created a project, an environment and possibly a database.
// Any write that TOUCHES basic auth without disabling it must carry the whole
// triple. Keying this on `=== true` alone shared apply's blind spot: a PATCH
// body of `{http_basic_auth_username}` — the username-only drift — has no
// toggle to be true, so the belt never tightened either. An explicit
// `false` is a legitimate disable and needs no credentials.
const touchesBasicAuth = (
[
"is_http_basic_auth_enabled",
"http_basic_auth_username",
"http_basic_auth_password",
] as const
).some((k) => rest[k] !== undefined);
if (touchesBasicAuth && rest.is_http_basic_auth_enabled !== false) {
const missing = (
[
"is_http_basic_auth_enabled",
"http_basic_auth_username",
"http_basic_auth_password",
] as const
).filter((k) => {
const v = rest[k];
if (k === "is_http_basic_auth_enabled") return v !== true;
return typeof v !== "string" || v === "";
});
if (missing.length > 0) {
throw new Error(
[
`refusing a partial HTTP basic auth write — missing ${missing.join(" and ")}`,
"",
"Coolify requires the toggle, a username AND a password on any write that",
"enables basic auth, and would answer 422 mid-run. A write carrying only some",
"of the three is that 422 waiting to happen — including a lone username, which",
"is what a username-only drift produces if nothing completes it. Half-configured",
"basic auth protects nothing anyway: declare the pair under the application's",
"`basic_auth:` (the password as a ${REF} held by the environment's age store),",
"or set `basic_auth.enabled: false`.",
].join("\n"),
);
}
}
return { return {
// is_static/install_command/build_command/start_command ride through `rest` // is_static/install_command/build_command/start_command ride through `rest`
// unchanged: they are valid API params verbatim, accepted on both the create // unchanged: they are valid API params verbatim, accepted on both the create

View file

@ -79,6 +79,27 @@ export type Live = {
// a create-time setting; a real boolean from a future Coolify (staticNotCompared // a create-time setting; a real boolean from a future Coolify (staticNotCompared
// unset) is projected and diffed normally. // unset) is projected and diffed normally.
staticNotCompared?: boolean; staticNotCompared?: boolean;
// Why one or more of this application's basic-auth fields could not be read
// back this run — printed verbatim. Third of the same family as
// backupNotCompared and staticNotCompared, and the one whose absence would be
// most dangerous, because the field it hides is a protection.
//
// Which fields it covers is not fixed: it is exactly the BASIC_AUTH_FIELDS
// that are ABSENT from `fields` (projectLiveFields omits what it could not
// read). Two shapes occur at 4.1.2, and the reason string says which:
//
// - the password alone is unreadable — the usual case. `enabled` and
// `username` are ordinary columns and diff normally, so a UI flip of the
// toggle or a changed username IS still caught; only a store-side password
// rotation is invisible.
// - nothing is readable — a read path or a token that returns none of the
// three. Then all three are skipped, and cast claims nothing at all about
// this app's basic auth.
//
// As with the other two, leaving the fields merely absent from `fields` is NOT
// equivalent: computeDiff would diff `true` against `undefined` and report
// confident drift, and apply would rewrite a protection it never read.
basicAuthNotCompared?: string;
}; };
export type FieldDiff = { export type FieldDiff = {
field: string; field: string;
@ -161,9 +182,38 @@ export type DiffReport = {
// against `clean` — but printed on every run that has any, because the whole // against `clean` — but printed on every run that has any, because the whole
// point is that the assumption goes on screen at the moment it is made. // point is that the assumption goes on screen at the moment it is made.
backupsNotCompared: { name: string; reason: string }[]; backupsNotCompared: { name: string; reason: string }[];
// Applications whose declared `basic_auth:` cast could not fully verify this
// run, with the fields it had to skip. Same disposition as
// backupsNotCompared — a finding, printed always, NOT counted against `clean`:
// it is an absence of evidence, not evidence of drift, and a run that failed
// because a read failed is a run operators learn to force past.
basicAuthNotCompared: { name: string; fields: string[]; reason: string }[];
clean: boolean; clean: boolean;
}; };
// The three Coolify fields an application's `basic_auth:` block becomes. Named
// once, here, because three separate places have to agree on the set: the
// not-compared skip below, the redaction in renderDiff, and apply's
// completeBasicAuth.
export const BASIC_AUTH_FIELDS = [
"is_http_basic_auth_enabled",
"http_basic_auth_username",
"http_basic_auth_password",
] as const;
// Field names whose VALUES never reach the terminal, on either side of a diff.
//
// renderDiff prints every field diff as `field: <live> → <desired>`, so an
// ordinary field carrying a password would print it twice — into a scrollback
// buffer, and into the CI log of every run. That is the same rule cast already
// holds for env vars (`secret X differs`, never the value) and for capture's
// disposition table; this is it, extended to the first RESOURCE FIELD that is a
// secret. The diff still says the field changed — what is withheld is only what
// it changed from and to.
export const REDACTED_FIELDS: ReadonlySet<string> = new Set([
"http_basic_auth_password",
]);
export const NON_UPDATABLE: Record<ResourceKind, string[]> = { export const NON_UPDATABLE: Record<ResourceKind, string[]> = {
application: ["build_pack"], application: ["build_pack"],
database: ["type", "version"], database: ["type", "version"],
@ -311,6 +361,7 @@ export function computeDiff(
): DiffReport { ): DiffReport {
const changes: Change[] = []; const changes: Change[] = [];
const backupsNotCompared: { name: string; reason: string }[] = []; const backupsNotCompared: { name: string; reason: string }[] = [];
const basicAuthNotCompared: DiffReport["basicAuthNotCompared"] = [];
// is_static is unreadable on Coolify 4.1.2's read path (cast#68); warn once // is_static is unreadable on Coolify 4.1.2's read path (cast#68); warn once
// per run when the degradation actually bites (a manifest declares `static:` // per run when the degradation actually bites (a manifest declares `static:`
// on an app whose live value cast could not read), not per application. // on an app whose live value cast could not read), not per application.
@ -350,8 +401,32 @@ export function computeDiff(
backupsNotCompared.push({ name: d.name, reason: l.backupNotCompared }); backupsNotCompared.push({ name: d.name, reason: l.backupNotCompared });
} }
const skipBackup = l.backupNotCompared !== undefined; const skipBackup = l.backupNotCompared !== undefined;
// The basic-auth escape hatch, third sibling of the backup and is_static
// ones. A field is skipped when the live read could not supply it (it is
// absent from `l.fields`) AND the read said why (`basicAuthNotCompared`) —
// never merely because it is absent, which would silently swallow a real
// "this app has no basic auth" into "cast could not tell".
//
// Recorded per APPLICATION, once, with the fields it covers, and only when
// the desired side declares basic auth at all: an app whose manifest is
// silent about it must not produce a line about something it never asked
// for.
const skippedBasicAuth =
l.basicAuthNotCompared === undefined
? []
: BASIC_AUTH_FIELDS.filter(
(f) => f in d.fields && !(f in l.fields),
).map(String);
if (skippedBasicAuth.length > 0) {
basicAuthNotCompared.push({
name: d.name,
fields: skippedBasicAuth,
reason: l.basicAuthNotCompared as string,
});
}
const fieldDiffs: FieldDiff[] = Object.entries(d.fields) const fieldDiffs: FieldDiff[] = Object.entries(d.fields)
.filter(([field]) => !(skipBackup && field === "backup")) .filter(([field]) => !(skipBackup && field === "backup"))
.filter(([field]) => !skippedBasicAuth.includes(field))
.filter(([field]) => { .filter(([field]) => {
// The unreadable-is_static escape hatch (cast#68), sibling to the // The unreadable-is_static escape hatch (cast#68), sibling to the
// backup one above. is_static lives on the ApplicationSetting relation, // backup one above. is_static lives on the ApplicationSetting relation,
@ -404,6 +479,7 @@ export function computeDiff(
reserved, reserved,
placement, placement,
backupsNotCompared, backupsNotCompared,
basicAuthNotCompared,
// A split project is drift, and drift is not clean — the same disposition // A split project is drift, and drift is not clean — the same disposition
// as an orphan: reported, counted, and NOT repaired (apply moves nothing // as an orphan: reported, counted, and NOT repaired (apply moves nothing
// between networks; see renderDiff). // between networks; see renderDiff).
@ -454,6 +530,16 @@ export function renderDiff(report: DiffReport): string {
for (const c of report.changes) { for (const c of report.changes) {
lines.push(`${c.op} ${c.kind} ${c.name}`); lines.push(`${c.op} ${c.kind} ${c.name}`);
for (const f of c.fieldDiffs) { for (const f of c.fieldDiffs) {
// A redacted field says THAT it changes and never what to or from — see
// REDACTED_FIELDS. `f.live` is undefined here whenever the read could not
// see it, which is the common case, so even the shape of the old value
// would be a claim cast cannot make.
if (REDACTED_FIELDS.has(f.field)) {
lines.push(
` ${f.field}: differs — apply will set it (secret; value not printed)`,
);
continue;
}
lines.push( lines.push(
` ${f.field}: ${JSON.stringify(f.live)}${JSON.stringify(f.desired)}${f.updatable ? "" : " [NOT UPDATABLE IN PLACE]"}`, ` ${f.field}: ${JSON.stringify(f.live)}${JSON.stringify(f.desired)}${f.updatable ? "" : " [NOT UPDATABLE IN PLACE]"}`,
); );
@ -516,6 +602,18 @@ export function renderDiff(report: DiffReport): string {
` (${b.reason})`, ` (${b.reason})`,
); );
} }
// The same honest fallback as the backup line above, for the field where
// silence is most expensive: a diff that said "clean" over an unreadable basic
// auth would be a tool reporting an admin panel as protected without having
// looked. It names the fields it skipped so the line distinguishes "only the
// password" (the routine 4.1.2 case, where the toggle and username ARE
// compared) from "all of it" (a read that told cast nothing).
for (const b of report.basicAuthNotCompared) {
lines.push(
`basic_auth on application ${b.name} declared, ${b.fields.join(", ")} NOT compared — verify in the Coolify UI`,
` (${b.reason})`,
);
}
const { placement } = report; const { placement } = report;
if (placement.split) { if (placement.split) {
lines.push( lines.push(

View file

@ -424,7 +424,10 @@ function applicationSpec(
// for. Each one changes what the application IS, and each would be silently // for. Each one changes what the application IS, and each would be silently
// absent from a rebuild. // absent from a rebuild.
const NO_HOME: Array<[string, string]> = [ const NO_HOME: Array<[string, string]> = [
["custom_labels", "custom Traefik/Docker labels (Basic Auth lives here)"], [
"custom_labels",
"custom Traefik/Docker labels (basic auth has its own fields, and its own manifest block since cast#76 — these are the hand-written labels beside it)",
],
["ports_mappings", "host port mappings"], ["ports_mappings", "host port mappings"],
["pre_deployment_command", "a pre-deployment command"], ["pre_deployment_command", "a pre-deployment command"],
["post_deployment_command", "a post-deployment command"], ["post_deployment_command", "a post-deployment command"],
@ -439,6 +442,26 @@ function applicationSpec(
flag(field, `${what} is set on the box. The manifest has no field for it.`); flag(field, `${what} is set on the box. The manifest has no field for it.`);
} }
// Basic auth is EXPRESSIBLE now (cast#76) and still not CAPTURABLE, and those
// are different sentences. The manifest has a `basic_auth:` block, so a draft
// could emit the toggle and the username — but the password reads back only to
// a token with sensitive-data reads (4.1.2; `read:sensitive` on v4.2) and a
// draft that emitted `enabled: true` with a password cast never saw would
// refuse to apply, or worse, apply with the wrong one.
//
// So the draft emits NOTHING here and says so per application, which is the
// same disposition as a generated secret: the name is reported, the value is
// the operator's to supply. Not emitting a half-block is the point — an
// `enabled: true` a rebuild cannot honour is the failure this whole file exists
// to prevent, and an unprotected admin panel is the one that costs most.
if (r.raw.is_http_basic_auth_enabled) {
const user = r.raw.http_basic_auth_username;
flag(
"basic_auth",
`HTTP basic auth is ENABLED on this application${typeof user === "string" && user !== "" ? ` (username ${user})` : ""}, and its PASSWORD cannot be read back — so no \`basic_auth:\` block was written and a rebuilt application would be PUBLIC. Add one by hand: \`basic_auth: { enabled: true, username: …, password: \${REF} }\`, with the value in the environment's age store.`,
);
}
// `port` is one number in a manifest and a comma-separated list on the wire. // `port` is one number in a manifest and a comma-separated list on the wire.
// The draft writes the first and says so — a rebuilt app exposing one of the // The draft writes the first and says so — a rebuilt app exposing one of the
// three ports it used to is the kind of difference that surfaces as a broken // three ports it used to is the kind of difference that surfaces as a broken
@ -876,9 +899,19 @@ const NO_API_COVERAGE: Array<[string, string]> = [
"destinations", "destinations",
"Coolify 4.1.2 serves no destinations endpoint. A resource's `destination_id` comes back; the UUID that names it never does. Placement must be read from the UI (#21).", "Coolify 4.1.2 serves no destinations endpoint. A resource's `destination_id` comes back; the UUID that names it never does. Placement must be read from the UI (#21).",
], ],
// Narrowed to SERVICES (cast#76). Applications can now say `basic_auth:`, so
// the blanket row overstated the gap for half the resources it covered — and
// the half it still covers is a real API gap, not a cast vocabulary one:
// `ServicesController` has no basic-auth fields and no `custom_labels`, on
// v4.1.2 or on `next` (cast#72, finding 7). The row must not imply cast could
// express this for a service if only someone wrote the field.
[ [
"Basic Auth / custom Traefik labels", "Basic Auth / custom Traefik labels on SERVICES",
"carried as raw container labels. cast's manifest has no field for them, so a rebuilt resource is UNPROTECTED where the original was not.", "no API surface at all — Coolify's ServicesController carries neither basic-auth fields nor custom_labels, on 4.1.2 or on the v4.2 train, so no manifest field could set them. A service that was protected on the source box comes back UNPROTECTED and must be re-protected by hand. (Applications are a different story: they take `basic_auth:` in the manifest since cast#76 — what is uncapturable there is the PASSWORD, reported per application above.)",
],
[
"custom Traefik/Docker labels on applications",
"`custom_labels` IS writable at 4.1.2, but cast deliberately has no field for it: enabling basic auth or changing domains makes Coolify regenerate an application's labels and overwrite whatever was there, unless `is_container_label_readonly_enabled` — which is itself not API-settable until v4.2. Declaring both would be a footgun, so labels set by hand stay uncaptured and are reported per application above (cast#72, #76).",
], ],
[ [
'"Include Source Commit in Build"', '"Include Source Commit in Build"',

1432
src/github-app.ts Normal file

File diff suppressed because it is too large Load diff

View file

@ -34,6 +34,74 @@ const repoDirectoryPath = (field: string) =>
`${field} must be an absolute path inside the repo checkout (Coolify 4.1.2 rejects the create otherwise) — write /apps/core, not apps/core; the checkout root is /`, `${field} must be an absolute path inside the repo checkout (Coolify 4.1.2 rejects the create otherwise) — write /apps/core, not apps/core; the checkout root is /`,
); );
// A store REF — `${NAME}` and nothing else. The one syntax cast already uses for
// a secret, in env templates (envtemplate.ts), reused verbatim rather than
// invented a second time: the value lives in the environment's age store, keyed
// by NAME, and the manifest carries only the name.
//
// This is a REFUSAL, not a preference. `http_basic_auth_password` is the first
// secret cast writes that is a resource FIELD rather than an env var, and a
// manifest is a reviewed, committed artifact — a literal here is a password in
// git, permanently, in the file everyone reads to understand the system. There is
// no ergonomic case that outweighs that, so the schema makes the mistake
// unrepresentable rather than warning about it.
const STORE_REF = /^\$\{([A-Za-z_][A-Za-z0-9_]*)\}$/;
// HTTP Basic Auth on an application, as Coolify 4.1.2 can actually set it:
// `is_http_basic_auth_enabled`, `http_basic_auth_username` and
// `http_basic_auth_password` are in the create allowlist
// (`ApplicationsController.php:914`) and the PATCH allowlist (`:2368`), and PATCH
// enforces username/password presence when enabling (`:2446-2463`).
//
// `enabled` is explicit rather than inferred from the block's presence, because
// the two halves of the vocabulary are not symmetric: `enabled: true` needs
// credentials, `enabled: false` must have none (a password ref standing over a
// disabled auth is dead config that reads like a guard). Spelling it out is also
// what lets the presence rule below fail with a message about the field the
// operator got wrong, instead of a union mismatch about two shapes.
//
// OMITTING the block leaves basic auth alone entirely — the `is_static` rule
// (see resolve.ts), for the same reason: emitting `is_http_basic_auth_enabled:
// false` on every application would make the first apply after this ships strip
// basic auth off every app protected by hand in the UI whose manifest has not yet
// been migrated. Protection removed, silently, by an upgrade. So: declare
// `enabled: true` to protect, `enabled: false` to actively assert it is off, omit
// to say nothing.
const BasicAuthSchema = z
.object({
enabled: z.boolean(),
username: z.string().optional(),
password: z
.string()
.regex(
STORE_REF,
"basic_auth.password must be a store ref (${NAME}) whose value lives in the environment's age store — never a literal, which would be a password committed to git",
)
.optional(),
})
.strict()
.superRefine((auth, ctx) => {
// Coolify's own rule, enforced HERE so it fails in the file rather than as a
// bare 422 from a PATCH that has already half-applied a run
// (ApplicationsController.php:2446-2463 @ v4.1.2 requires both when
// enabling). Same reasoning as the checkout-path patterns above.
if (auth.enabled) {
for (const k of ["username", "password"] as const)
if (auth[k] === undefined || auth[k] === "")
ctx.addIssue({
code: "custom",
message: `basic_auth.${k} is required when basic_auth.enabled is true (Coolify rejects the write otherwise, and half-protected basic auth protects nothing)`,
});
} else {
for (const k of ["username", "password"] as const)
if (auth[k] !== undefined)
ctx.addIssue({
code: "custom",
message: `basic_auth.${k} is not allowed when basic_auth.enabled is false — a credential declared for a disabled auth is dead config that reads like a guard`,
});
}
});
const AppSpecSchema = z const AppSpecSchema = z
.object({ .object({
source: z.object({ repo: z.string(), branch: z.string() }).strict(), source: z.object({ repo: z.string(), branch: z.string() }).strict(),
@ -62,6 +130,7 @@ const AppSpecSchema = z
healthcheck: z.string().optional(), healthcheck: z.string().optional(),
domains: z.array(z.string()).optional(), domains: z.array(z.string()).optional(),
service_domains: z.record(z.array(z.string())).optional(), service_domains: z.record(z.array(z.string())).optional(),
basic_auth: BasicAuthSchema.optional(),
env_template: z.string().optional(), env_template: z.string().optional(),
}) })
.strict() .strict()
@ -189,6 +258,13 @@ const ManifestSchema = z
}) })
.strict(); .strict();
// The NAME inside a `${NAME}` store ref, or undefined if this is not one. The
// single reader of STORE_REF outside the schema, so the syntax the manifest
// ACCEPTS and the syntax resolution UNDERSTANDS cannot drift apart.
export function storeRefName(value: string): string | undefined {
return STORE_REF.exec(value)?.[1];
}
export type AppSpec = z.infer<typeof AppSpecSchema>; export type AppSpec = z.infer<typeof AppSpecSchema>;
export type DatabaseSpec = z.infer<typeof DatabaseSpecSchema>; export type DatabaseSpec = z.infer<typeof DatabaseSpecSchema>;
export type ServiceSpec = z.infer<typeof ServiceSpecSchema>; export type ServiceSpec = z.infer<typeof ServiceSpecSchema>;

View file

@ -1,5 +1,5 @@
import { execFileSync } from "node:child_process"; import { execFileSync } from "node:child_process";
import { existsSync, mkdtempSync, readFileSync } from "node:fs"; import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import type { Desired } from "./diff.js"; import type { Desired } from "./diff.js";
@ -13,8 +13,8 @@ import {
templateRefs, templateRefs,
templateResourceRefs, templateResourceRefs,
} from "./envtemplate.js"; } from "./envtemplate.js";
import type { EnvironmentSpec } from "./manifest.js"; import type { AppSpec, EnvironmentSpec } from "./manifest.js";
import { loadManifest } from "./manifest.js"; import { loadManifest, storeRefName } from "./manifest.js";
import { import {
type ReservedHit, type ReservedHit,
assertNoReservedEnvNames, assertNoReservedEnvNames,
@ -162,6 +162,42 @@ export function refusesPathInProd(opts: {
return opts.path !== undefined && opts.env === "prod"; return opts.path !== undefined && opts.env === "prod";
} }
// Checkouts this process cloned, to be removed when it exits.
const ephemeralCheckouts: string[] = [];
let reaperArmed = false;
// A checkout resolved WITHOUT `--path` is ours: we made the directory, we cloned
// into it, and nothing outside this process refers to it. It has to outlive
// resolveCheckout's return — every caller reads the tree afterwards — so the
// lifetime that actually fits is the process, not the call. Hence an exit hook
// rather than a `finally`, which would delete the checkout out from under the
// command that just asked for it.
//
// A `--path` checkout is the operator's own working tree and is never registered
// here; deleting that would be catastrophic and is the reason this wraps the
// mkdtemp result specifically, not the function's return value.
//
// Without this, every `cast apply` / `diff` / `capture` run without `--path`
// left a full shallow clone in the temp dir forever (#117).
function reapOnExit(dir: string): string {
ephemeralCheckouts.push(dir);
if (!reaperArmed) {
reaperArmed = true;
process.once("exit", () => {
for (const d of ephemeralCheckouts) {
// Best-effort: failing to clean up must never change a command's exit
// status. The work is already done by the time we get here.
try {
rmSync(d, { recursive: true, force: true });
} catch {
// ignore
}
}
});
}
return dir;
}
export function resolveCheckout( export function resolveCheckout(
orgRepo: string, orgRepo: string,
opts: { env: string; path?: string }, opts: { env: string; path?: string },
@ -170,7 +206,7 @@ export function resolveCheckout(
throw new Error(PATH_IN_PROD_REFUSAL); throw new Error(PATH_IN_PROD_REFUSAL);
} }
if (opts.path) return opts.path; if (opts.path) return opts.path;
const dir = mkdtempSync(join(tmpdir(), "infra-checkout-")); const dir = reapOnExit(mkdtempSync(join(tmpdir(), "infra-checkout-")));
const auth = resolveGitAuth(); const auth = resolveGitAuth();
try { try {
execFileSync( execFileSync(
@ -376,7 +412,11 @@ function assertDomainRefs(
export function requiredSecrets( export function requiredSecrets(
checkoutDir: string, checkoutDir: string,
envName: string, envName: string,
): { required: RequiredSecret[]; generated: string[] } { ): {
required: RequiredSecret[];
generated: string[];
manifestRefs: string[];
} {
const manifest = loadManifest(join(checkoutDir, ".infra", "manifest.yaml")); const manifest = loadManifest(join(checkoutDir, ".infra", "manifest.yaml"));
const envSpec = manifest.environments[envName]; const envSpec = manifest.environments[envName];
if (!envSpec) { if (!envSpec) {
@ -452,7 +492,26 @@ export function requiredSecrets(
].join("\n"), ].join("\n"),
); );
} }
return { required, generated }; // Store refs the MANIFEST itself carries, as opposed to the ones its env
// templates carry. Today that is exactly `basic_auth.password`.
//
// Kept OUT of `required`, deliberately. A RequiredSecret is `{ref, resource,
// key}` where `key` is a live ENV VAR name — that triple is what `capture`
// reads the source box's value from — and a basic-auth password is not an env
// var on any resource. Putting it in `required` would have capture look for an
// env var named after a field, fail to find it, and refuse the whole run as
// "missing". It is returned separately so the one caller that asks a different
// question — "would anything at all be read from the store?", the gate on
// whether a missing store is fatal (cli.ts, #104) — gets the right answer for
// a manifest whose only secret is a basic-auth password.
const manifestRefs = Object.values(envSpec.applications).flatMap((app) => {
const ref =
app.basic_auth?.enabled && app.basic_auth.password
? storeRefName(app.basic_auth.password)
: undefined;
return ref ? [ref] : [];
});
return { required, generated, manifestRefs };
} }
// What the manifest declares for an environment, as names only — no secrets, no // What the manifest declares for an environment, as names only — no secrets, no
@ -527,6 +586,65 @@ export function canonicalizeServiceDomains(
); );
} }
// The Coolify fields an application's `basic_auth:` block becomes, with the
// password resolved out of the age store.
//
// Empty when the manifest declares nothing: managing basic auth is OPT-IN, the
// same rule as `is_static` (see the comment on that field below) and for a
// sharper reason — an unconditional `is_http_basic_auth_enabled: false` would
// have the first apply after this ships REMOVE the protection from every
// application somebody enabled by hand in the UI. A tool that silently
// unprotects an admin panel during a routine apply is worse than one that cannot
// protect it at all.
//
// The password is resolved HERE, at plan time, from the same store every
// `${REF}` in an env template resolves against — so a missing ref fails before
// anything is written, naming the ref and the store, rather than 422ing
// mid-apply or (worse) writing an empty password over a working one.
export function basicAuthFields(
envName: string,
appName: string,
app: Pick<AppSpec, "basic_auth">,
secrets: Record<string, string>,
): Record<string, unknown> {
const auth = app.basic_auth;
if (!auth) return {};
if (!auth.enabled) return { is_http_basic_auth_enabled: false };
// Both are guaranteed present by the schema's superRefine; the checks are
// repeated at the value level because THIS is where an empty store entry
// becomes an empty password, which the schema cannot see.
const ref = storeRefName(String(auth.password));
if (ref === undefined) {
throw new Error(
`manifest environment ${envName}: application ${appName} basic_auth.password is not a store ref (\${NAME})`,
);
}
const value = secrets[ref];
if (value === undefined || value === "") {
throw new Error(
[
`manifest environment ${envName}: application ${appName} declares basic_auth.password \${${ref}}, and the age store ${value === "" ? "holds an EMPTY value for it" : "does not hold it"}`,
"",
" the store is the environment's `secrets/<repo>.<env>.env.age` — the same one",
" every env-template ${REF} resolves against. Add the name to it (a store is a",
" KEY=value file, encrypted to the environment's age recipient) and re-run.",
"",
"Writing an empty password would enable basic auth on a public URL and protect",
"nothing, and Coolify would accept it — so cast refuses before it writes anything.",
].join("\n"),
);
}
return {
is_http_basic_auth_enabled: true,
http_basic_auth_username: auth.username,
// The PLAINTEXT, in the desired field bag — the only place it exists in this
// process besides the decrypted store. It is never printed: renderDiff
// redacts this field name by name (see REDACTED_FIELDS in diff.ts), which is
// the same contract every secret env var already has.
http_basic_auth_password: value,
};
}
export function desiredFromManifest( export function desiredFromManifest(
checkoutDir: string, checkoutDir: string,
envName: string, envName: string,
@ -644,6 +762,12 @@ export function desiredFromManifest(
? { start_command: app.build.start_command } ? { start_command: app.build.start_command }
: {}), : {}),
}), }),
// Outside the pack branch: basic auth is a property of the APPLICATION
// (Coolify sets it on the app's proxy labels, not on anything the build
// pack decides), so it is equally declarable on a compose app and a
// nixpacks one. The three keys are already Coolify's own names, so
// applicationApiFields passes them through untranslated.
...basicAuthFields(envName, name, app, secrets),
}, },
env: resolveEnvFile(name, app.env_template), env: resolveEnvFile(name, app.env_template),
}); });

View file

@ -4,6 +4,7 @@ import {
KIND_ORDER, KIND_ORDER,
applyHostnameOverlay, applyHostnameOverlay,
applyPlan, applyPlan,
completeBasicAuth,
} from "../src/apply.js"; } from "../src/apply.js";
import { GENERATED_PLACEHOLDER } from "../src/capture.js"; import { GENERATED_PLACEHOLDER } from "../src/capture.js";
import { type Desired, type Live, computeDiff } from "../src/diff.js"; import { type Desired, type Live, computeDiff } from "../src/diff.js";
@ -539,3 +540,148 @@ describe("applyHostnameOverlay", () => {
expect(out[0].fields.domains).toEqual(["http://plain.example.net"]); expect(out[0].fields.domains).toEqual(["http://plain.example.net"]);
}); });
}); });
// cast#76. An update body is built from the fields that CHANGED, and basic auth
// cannot be written that way: Coolify requires both credentials on any write
// that enables it, and the password never shows up as a change because it is
// never read back. So the payload is completed from the declared spec — and
// only when a write was already happening.
describe("completeBasicAuth", () => {
const spec: Desired = {
kind: "application",
name: "admin",
fields: {
build_pack: "nixpacks",
is_http_basic_auth_enabled: true,
http_basic_auth_username: "ops",
http_basic_auth_password: "s3cret",
},
};
it("fills in the credentials when only the toggle drifted", () => {
expect(
completeBasicAuth({ is_http_basic_auth_enabled: true }, spec),
).toEqual({
is_http_basic_auth_enabled: true,
http_basic_auth_username: "ops",
http_basic_auth_password: "s3cret",
});
});
it("fills in the password when only the username drifted", () => {
expect(
completeBasicAuth(
{ is_http_basic_auth_enabled: true, http_basic_auth_username: "ops" },
spec,
).http_basic_auth_password,
).toBe("s3cret");
});
// The case the #76 review found, and the one the test above only LOOKED like
// it covered: that payload carries the toggle, so it never exercised the
// guard. When basic auth is already on at both ends and only the username is
// edited in the UI, the toggle MATCHES — so computeDiff emits no fieldDiff
// for it and the payload arrives as a lone username. The old guard keyed on
// the toggle being present and returned early, and the PATCH went out
// incomplete: a 422 mid-run, which is the exact failure this function exists
// to prevent.
it("completes the whole triple from a lone username — no toggle in the payload", () => {
expect(
completeBasicAuth({ http_basic_auth_username: "ops" }, spec),
).toEqual({
is_http_basic_auth_enabled: true,
http_basic_auth_username: "ops",
http_basic_auth_password: "s3cret",
});
});
// Same shape, other credential: a stored-password rotation riding along.
it("completes from a lone password too", () => {
expect(
completeBasicAuth({ http_basic_auth_password: "rotated" }, spec),
).toEqual({
is_http_basic_auth_enabled: true,
http_basic_auth_username: "ops",
http_basic_auth_password: "rotated",
});
});
// Intent comes from the SPEC, so a spec that does not enable basic auth must
// not have credentials completed into its payload — otherwise reading intent
// from the declaration would trade one silent wrong write for another.
it("does not complete when the spec does not enable basic auth", () => {
const off: Desired = {
kind: "application",
name: "admin",
fields: { is_http_basic_auth_enabled: false },
};
expect(completeBasicAuth({ http_basic_auth_username: "ops" }, off)).toEqual(
{
http_basic_auth_username: "ops",
},
);
});
it("leaves a payload that is not enabling basic auth completely alone", () => {
// The load-bearing half: this must not MANUFACTURE a write. A run where
// nothing about basic auth drifted sends nothing about basic auth.
expect(completeBasicAuth({ domains: ["https://a"] }, spec)).toEqual({
domains: ["https://a"],
});
});
it("adds no credentials to a disable", () => {
expect(
completeBasicAuth({ is_http_basic_auth_enabled: false }, spec),
).toEqual({ is_http_basic_auth_enabled: false });
});
it("does not invent values the spec does not carry", () => {
// Then applicationApiFields refuses at the wire — one clear error, rather
// than a request Coolify 422s halfway through a run.
expect(
completeBasicAuth({ is_http_basic_auth_enabled: true }, undefined),
).toEqual({ is_http_basic_auth_enabled: true });
});
});
// The honest limit, asserted rather than described: a password rotated in the
// store with nothing else changed produces NO write, because there is no field
// diff to carry it. `cast diff` prints "NOT compared" on that run — the failure
// is visible, not silent — and this test exists so the day someone makes the
// password diffable, it goes red and they read the comment.
describe("applyPlan — a password-only rotation writes nothing (#76)", () => {
it("makes no call at all when the readable halves agree", async () => {
const { calls, exec } = recorder();
const declared: Desired[] = [
{
kind: "application",
name: "admin",
fields: {
build_pack: "nixpacks",
is_http_basic_auth_enabled: true,
http_basic_auth_username: "ops",
http_basic_auth_password: "the-NEW-password",
},
},
];
const live: Live[] = [
{
kind: "application",
name: "admin",
uuid: "u1",
fields: {
build_pack: "nixpacks",
is_http_basic_auth_enabled: true,
http_basic_auth_username: "ops",
},
basicAuthNotCompared: "password is never read back",
},
];
const report = computeDiff(declared, live, "full");
await applyPlan(report, declared, exec);
expect(calls).toEqual([]);
// …and the run says so, rather than reading as a verified match.
expect(report.basicAuthNotCompared).toHaveLength(1);
});
});

View file

@ -1,10 +1,10 @@
import { execFileSync, spawn } from "node:child_process"; import { execFileSync, spawn } from "node:child_process";
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; import { mkdirSync, writeFileSync } from "node:fs";
import { createServer } from "node:http"; import { createServer } from "node:http";
import type { AddressInfo } from "node:net"; import type { AddressInfo } from "node:net";
import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { afterEach, beforeAll, describe, expect, it } from "vitest"; import { afterEach, beforeAll, describe, expect, it } from "vitest";
import { tmp } from "./helpers/tmp.js";
// Backup schedules, end to end: manifest -> `cast diff` -> what it prints and // Backup schedules, end to end: manifest -> `cast diff` -> what it prints and
// what it exits with. The unit tests prove each half (coolify.ts parses the // what it exits with. The unit tests prove each half (coolify.ts parses the
@ -21,7 +21,7 @@ let recipient: string;
let keyFile: string; let keyFile: string;
beforeAll(() => { beforeAll(() => {
const dir = mkdtempSync(join(tmpdir(), "cast-age-")); const dir = tmp("cast-age-");
keyFile = join(dir, "age.key"); keyFile = join(dir, "age.key");
execFileSync("age-keygen", ["-o", keyFile], { stdio: "pipe" }); execFileSync("age-keygen", ["-o", keyFile], { stdio: "pipe" });
recipient = execFileSync("age-keygen", ["-y", keyFile], { recipient = execFileSync("age-keygen", ["-y", keyFile], {
@ -110,11 +110,11 @@ environments:
`; `;
function fixture(url: string) { function fixture(url: string) {
const checkout = mkdtempSync(join(tmpdir(), "cast-co-")); const checkout = tmp("cast-co-");
mkdirSync(join(checkout, ".infra", "env"), { recursive: true }); mkdirSync(join(checkout, ".infra", "env"), { recursive: true });
writeFileSync(join(checkout, ".infra", "manifest.yaml"), MANIFEST); writeFileSync(join(checkout, ".infra", "manifest.yaml"), MANIFEST);
const state = mkdtempSync(join(tmpdir(), "cast-state-")); const state = tmp("cast-state-");
mkdirSync(join(state, "secrets")); mkdirSync(join(state, "secrets"));
writeFileSync( writeFileSync(
join(state, ".coolify.env"), join(state, ".coolify.env"),

319
test/basic-auth-cli.test.ts Normal file
View file

@ -0,0 +1,319 @@
import { execFileSync, spawn } from "node:child_process";
import { mkdirSync, writeFileSync } from "node:fs";
import { createServer } from "node:http";
import type { AddressInfo } from "node:net";
import { join } from "node:path";
import { afterEach, beforeAll, describe, expect, it } from "vitest";
import { tmp } from "./helpers/tmp.js";
// HTTP basic auth on an application, end to end: manifest -> the real binary ->
// what goes on the wire and what reaches the terminal (cast#76).
//
// The unit tests prove each half (the schema refuses a literal password;
// projectLiveFields never projects one; computeDiff skips what it could not
// read; completeBasicAuth puts the triple back into a partial payload). This
// proves they are wired to each other, and pins the two facts that only a real
// request can show: that the CREATE body carries all three keys, and that a
// PATCH triggered by a drifted TOGGLE still carries the credentials Coolify
// requires alongside it.
//
// BOUNDARY, stated because it matters: the Coolify here is a stub of this
// repo's own making. These tests prove what cast SENDS. They cannot prove that a
// real 4.1.2 accepts it, that label regeneration behaves as cast#72 read it, or
// that a sensitive-data token returns the password on any given route — every
// one of those is a claim about Coolify, sourced from reading Coolify, and none
// has been run against a live instance.
let recipient: string;
let keyFile: string;
beforeAll(() => {
const dir = tmp("cast-age-");
keyFile = join(dir, "age.key");
execFileSync("age-keygen", ["-o", keyFile], { stdio: "pipe" });
recipient = execFileSync("age-keygen", ["-y", keyFile], {
encoding: "utf8",
}).trim();
});
type Stub = {
url: string;
hits: string[];
bodies: Record<string, Record<string, unknown>>;
close: () => Promise<void>;
};
const stubs: Stub[] = [];
// `app` is the knob: what the live application row looks like, or `null` for an
// environment with nothing in it (so the plan is a create). Everything else on
// the row matches the manifest below, so anything the diff reports is basic auth
// and nothing else.
async function stubCoolify(app: Record<string, unknown> | null): Promise<Stub> {
const hits: string[] = [];
const bodies: Record<string, Record<string, unknown>> = {};
const server = createServer((req, res) => {
const path = new URL(req.url ?? "", "http://x").pathname.replace(
"/api/v1",
"",
);
hits.push(`${req.method} ${path}`);
let raw = "";
req.on("data", (d) => {
raw += String(d);
});
req.on("end", () => {
if (raw !== "") {
try {
bodies[`${req.method} ${path}`] = JSON.parse(raw);
} catch {
/* not JSON — not a body this test asks about */
}
}
const json = (body: unknown) => {
res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify(body));
};
if (path === "/teams/current") return json({ id: 0, name: "Root Team" });
if (path === "/servers")
return json([{ uuid: "s1", name: "shared-box" }]);
if (path === "/github-apps")
return json([{ uuid: "gh1", name: "hdb-coolify" }]);
if (path === "/projects" && req.method === "GET")
return json([{ uuid: "p1", name: "incubator" }]);
if (path === "/projects/p1/environments")
return json([{ name: "staging" }]);
if (path === "/projects/p1/staging")
return json({ applications: app === null ? [] : [app] });
if (path === "/applications" && req.method === "GET") return json([]);
if (path === "/applications/private-github-app" && req.method === "POST")
return json({ uuid: "app-1" });
if (path === "/applications/app-1" && req.method === "PATCH")
return json({ uuid: "app-1" });
if (path === "/applications/app-1/envs") return json([]);
if (path === "/deploy") return json({});
res.writeHead(404);
res.end("{}");
});
});
await new Promise<void>((r) => {
server.listen(0, "127.0.0.1", r);
});
const stub: Stub = {
url: `http://127.0.0.1:${(server.address() as AddressInfo).port}`,
hits,
bodies,
close: () =>
new Promise<void>((r) => {
server.close(() => r());
}),
};
stubs.push(stub);
return stub;
}
afterEach(async () => {
await Promise.all(stubs.splice(0).map((s) => s.close()));
});
// A live application row as Coolify's environment_details serializes one. Note
// what is NOT here on purpose: `http_basic_auth_password`. That is the whole
// read-side story — the column is hidden from an ordinary token at 4.1.2, and
// cast would not project it even if it arrived.
const liveApp = (over: Record<string, unknown> = {}) => ({
name: "admin",
uuid: "app-1",
git_repository: "heavy-duty/incubator",
git_branch: "main",
build_pack: "nixpacks",
base_directory: "/",
fqdn: "https://admin.example.com",
destination_id: 1,
is_http_basic_auth_enabled: true,
http_basic_auth_username: "ops",
...over,
});
const PASSWORD = "correct-horse-battery-staple";
const MANIFEST = `project: incubator
environments:
staging:
applications:
admin:
source: { repo: heavy-duty/incubator, branch: main }
build: { pack: nixpacks, base_directory: / }
domains: ["https://admin.example.com"]
basic_auth:
enabled: true
username: ops
password: \${ADMIN_BASIC_AUTH}
`;
function fixture(url: string, store = `ADMIN_BASIC_AUTH=${PASSWORD}\n`) {
const checkout = tmp("cast-co-");
mkdirSync(join(checkout, ".infra", "env"), { recursive: true });
writeFileSync(join(checkout, ".infra", "manifest.yaml"), MANIFEST);
const state = tmp("cast-state-");
mkdirSync(join(state, "secrets"));
writeFileSync(
join(state, ".coolify.env"),
`COOLIFY_BASE_URL="${url}"\nCOOLIFY_ACCESS_TOKEN="t"\n`,
);
execFileSync("age", ["-r", recipient, "-o", "incubator.staging.env.age"], {
input: store,
cwd: join(state, "secrets"),
stdio: ["pipe", "pipe", "pipe"],
});
writeFileSync(
join(state, "environments.yaml"),
[
"environments:",
" staging:",
" server: shared-box",
" team: { id: 0, name: Root Team }",
"github_apps:",
" incubator: hdb-coolify",
"",
].join("\n"),
);
return { checkout, state };
}
function run(
verb: "diff" | "apply",
f: { checkout: string; state: string },
): Promise<{ code: number; output: string }> {
return new Promise((resolve) => {
const child = spawn(
"node",
[
"dist/cli.js",
verb,
"heavy-duty/incubator",
"--env",
"staging",
"--path",
f.checkout,
"--state",
f.state,
],
{
stdio: ["ignore", "pipe", "pipe"],
env: { ...process.env, CAST_AGE_KEY_FILE_STAGING: keyFile },
},
);
let output = "";
child.stdout.on("data", (d) => {
output += String(d);
});
child.stderr.on("data", (d) => {
output += String(d);
});
child.on("close", (code) => resolve({ code: code ?? 0, output }));
});
}
describe("cast apply — basic auth reaches the wire (#76)", () => {
it("sends all three keys on the CREATE, with the password out of the age store", async () => {
const stub = await stubCoolify(null);
const r = await run("apply", fixture(stub.url));
expect(r.code).toBe(0);
const body = stub.bodies["POST /applications/private-github-app"];
expect(body.is_http_basic_auth_enabled).toBe(true);
expect(body.http_basic_auth_username).toBe("ops");
// The value came from the encrypted store via the manifest's ${REF} — the
// manifest itself holds only the name.
expect(body.http_basic_auth_password).toBe(PASSWORD);
});
// The case an update body assembled from field diffs alone would get wrong:
// only the toggle drifted, and Coolify rejects an enable without credentials.
it("sends the credentials alongside a toggle-only PATCH", async () => {
const stub = await stubCoolify(
liveApp({ is_http_basic_auth_enabled: false }),
);
const r = await run("apply", fixture(stub.url));
expect(r.code).toBe(0);
const body = stub.bodies["PATCH /applications/app-1"];
expect(body.is_http_basic_auth_enabled).toBe(true);
expect(body.http_basic_auth_username).toBe("ops");
expect(body.http_basic_auth_password).toBe(PASSWORD);
});
// The other half of the same rule: no drift, no write. cast does not PATCH
// basic auth onto every apply just because it cannot verify the password.
it("writes nothing when the readable halves already agree", async () => {
const stub = await stubCoolify(liveApp());
const r = await run("apply", fixture(stub.url));
expect(r.code).toBe(0);
expect(stub.hits).not.toContain("PATCH /applications/app-1");
});
it("refuses before touching Coolify when the store does not hold the ref", async () => {
const stub = await stubCoolify(null);
const r = await run("apply", fixture(stub.url, "SOMETHING_ELSE=x\n"));
expect(r.code).not.toBe(0);
expect(r.output).toContain("does not hold it");
// Nothing was created on the way to finding out.
expect(stub.hits).not.toContain("POST /applications/private-github-app");
});
});
describe("cast diff — basic auth is honest about the password (#76)", () => {
it("says the password was NOT compared, on a run it still calls clean", async () => {
const r = await run("diff", fixture((await stubCoolify(liveApp())).url));
expect(r.code).toBe(0);
expect(r.output).toContain(
"basic_auth on application admin declared, http_basic_auth_password NOT compared",
);
expect(r.output).toMatch(/^clean$/m);
});
it("never prints the password", async () => {
const r = await run("diff", fixture((await stubCoolify(null)).url));
expect(r.output).not.toContain(PASSWORD);
});
// The defect this feature closes, from the other direction: somebody turned
// basic auth off on the box. An unreadable password must not make that
// invisible.
it("reports drift when the toggle was flipped off in the UI", async () => {
const r = await run(
"diff",
fixture(
(await stubCoolify(liveApp({ is_http_basic_auth_enabled: false }))).url,
),
);
expect(r.code).toBe(1);
expect(r.output).toContain("is_http_basic_auth_enabled: false → true");
});
it("reports drift when the username was changed on the box", async () => {
const r = await run(
"diff",
fixture(
(await stubCoolify(liveApp({ http_basic_auth_username: "someone" })))
.url,
),
);
expect(r.code).toBe(1);
expect(r.output).toContain("http_basic_auth_username");
});
// A Coolify (or a token) that serves none of these columns must produce
// neither a clean bill nor invented drift.
it("claims nothing at all when the read carried no basic-auth state", async () => {
const stub = await stubCoolify(
liveApp({
is_http_basic_auth_enabled: undefined,
http_basic_auth_username: undefined,
}),
);
const r = await run("diff", fixture(stub.url));
expect(r.output).toContain(
"is_http_basic_auth_enabled, http_basic_auth_username, http_basic_auth_password NOT compared",
);
expect(r.output).toMatch(/^clean$/m);
});
});

View file

@ -1,17 +1,11 @@
import { execFileSync, spawn } from "node:child_process"; import { execFileSync, spawn } from "node:child_process";
import { import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
writeFileSync,
} from "node:fs";
import { createServer } from "node:http"; import { createServer } from "node:http";
import type { AddressInfo } from "node:net"; import type { AddressInfo } from "node:net";
import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { afterEach, beforeAll, describe, expect, it } from "vitest"; import { afterEach, beforeAll, describe, expect, it } from "vitest";
import { decryptSecrets, encryptSecrets } from "../src/secrets.js"; import { decryptSecrets, encryptSecrets } from "../src/secrets.js";
import { tmp } from "./helpers/tmp.js";
// End-to-end: the real CLI, a real age identity, a stub Coolify holding real // End-to-end: the real CLI, a real age identity, a stub Coolify holding real
// live values. The point is the store that comes out the other side — it is // live values. The point is the store that comes out the other side — it is
@ -34,7 +28,7 @@ let keyFile: string;
let recipient: string; let recipient: string;
beforeAll(() => { beforeAll(() => {
const dir = mkdtempSync(join(tmpdir(), "cast-age-")); const dir = tmp("cast-age-");
keyFile = join(dir, "age-staging.key"); keyFile = join(dir, "age-staging.key");
execFileSync("age-keygen", ["-o", keyFile], { stdio: "pipe" }); execFileSync("age-keygen", ["-o", keyFile], { stdio: "pipe" });
const pub = execFileSync("age-keygen", ["-y", keyFile], { encoding: "utf8" }); const pub = execFileSync("age-keygen", ["-y", keyFile], { encoding: "utf8" });
@ -110,7 +104,7 @@ function fixture(
url: string, url: string,
opts: { template?: string; manifest?: string } = {}, opts: { template?: string; manifest?: string } = {},
) { ) {
const checkout = mkdtempSync(join(tmpdir(), "cast-co-")); const checkout = tmp("cast-co-");
mkdirSync(join(checkout, ".infra", "env"), { recursive: true }); mkdirSync(join(checkout, ".infra", "env"), { recursive: true });
writeFileSync( writeFileSync(
join(checkout, ".infra", "manifest.yaml"), join(checkout, ".infra", "manifest.yaml"),
@ -121,7 +115,7 @@ function fixture(
opts.template ?? TEMPLATE, opts.template ?? TEMPLATE,
); );
const state = mkdtempSync(join(tmpdir(), "cast-state-")); const state = tmp("cast-state-");
mkdirSync(join(state, "secrets")); mkdirSync(join(state, "secrets"));
writeFileSync( writeFileSync(
join(state, ".coolify.env"), join(state, ".coolify.env"),

View file

@ -1,5 +1,4 @@
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; import { mkdirSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { import {
@ -14,6 +13,7 @@ import {
resolveGeneratedSources, resolveGeneratedSources,
} from "../src/capture.js"; } from "../src/capture.js";
import { requiredSecrets } from "../src/resolve.js"; import { requiredSecrets } from "../src/resolve.js";
import { tmp } from "./helpers/tmp.js";
const CTX = { const CTX = {
orgRepo: "heavy-duty/incubator", orgRepo: "heavy-duty/incubator",
@ -241,7 +241,7 @@ describe("renderCapturePlan", () => {
// the manifest's own templates, read by the same parser apply uses. // the manifest's own templates, read by the same parser apply uses.
describe("requiredSecrets", () => { describe("requiredSecrets", () => {
function checkout(manifest: string, templates: Record<string, string>) { function checkout(manifest: string, templates: Record<string, string>) {
const dir = mkdtempSync(join(tmpdir(), "cast-cap-")); const dir = tmp("cast-cap-");
mkdirSync(join(dir, ".infra", "env"), { recursive: true }); mkdirSync(join(dir, ".infra", "env"), { recursive: true });
writeFileSync(join(dir, ".infra", "manifest.yaml"), manifest); writeFileSync(join(dir, ".infra", "manifest.yaml"), manifest);
for (const [name, body] of Object.entries(templates)) { for (const [name, body] of Object.entries(templates)) {

View file

@ -1,10 +1,10 @@
import { spawn } from "node:child_process"; import { spawn } from "node:child_process";
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; import { mkdirSync, writeFileSync } from "node:fs";
import { createServer } from "node:http"; import { createServer } from "node:http";
import type { AddressInfo } from "node:net"; import type { AddressInfo } from "node:net";
import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest"; import { afterEach, describe, expect, it } from "vitest";
import { tmp } from "./helpers/tmp.js";
// Spawned ASYNCHRONOUSLY, and that is load-bearing: the stub Coolify below // Spawned ASYNCHRONOUSLY, and that is load-bearing: the stub Coolify below
// runs in THIS process, so a blocking execFileSync would hold the event loop // runs in THIS process, so a blocking execFileSync would hold the event loop
@ -74,7 +74,7 @@ function stateWith(opts: {
named?: Record<string, string>; named?: Record<string, string>;
boundInstance?: string; boundInstance?: string;
}): string { }): string {
const dir = mkdtempSync(join(tmpdir(), "cast-cli-")); const dir = tmp("cast-cli-");
if (opts.default) writeFileSync(join(dir, ".coolify.env"), opts.default); if (opts.default) writeFileSync(join(dir, ".coolify.env"), opts.default);
if (opts.named) { if (opts.named) {
mkdirSync(join(dir, ".coolify")); mkdirSync(join(dir, ".coolify"));

View file

@ -1,5 +1,4 @@
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; import { mkdirSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { import {
@ -8,13 +7,14 @@ import {
knownInstances, knownInstances,
loadInstance, loadInstance,
} from "../src/config.js"; } from "../src/config.js";
import { tmp } from "./helpers/tmp.js";
// A state dir with a default .coolify.env and any number of named instances. // A state dir with a default .coolify.env and any number of named instances.
function stateDir( function stateDir(
named: Record<string, string> = {}, named: Record<string, string> = {},
defaultEnv?: string, defaultEnv?: string,
): string { ): string {
const dir = mkdtempSync(join(tmpdir(), "cast-state-")); const dir = tmp("cast-state-");
if (defaultEnv !== undefined) { if (defaultEnv !== undefined) {
writeFileSync(join(dir, ".coolify.env"), defaultEnv); writeFileSync(join(dir, ".coolify.env"), defaultEnv);
} }

View file

@ -1,8 +1,7 @@
import { spawn } from "node:child_process"; import { spawn } from "node:child_process";
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; import { mkdirSync, writeFileSync } from "node:fs";
import { createServer } from "node:http"; import { createServer } from "node:http";
import type { AddressInfo } from "node:net"; import type { AddressInfo } from "node:net";
import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest"; import { afterEach, describe, expect, it } from "vitest";
import { import {
@ -14,6 +13,7 @@ import {
readBackupState, readBackupState,
renderDestroyPlan, renderDestroyPlan,
} from "../src/destroy.js"; } from "../src/destroy.js";
import { tmp } from "./helpers/tmp.js";
// The refusals ARE the product. `destroy` is the only verb in cast that removes // The refusals ARE the product. `destroy` is the only verb in cast that removes
// something a manifest declared, and the difference between it and a hand // something a manifest declared, and the difference between it and a hand
@ -460,11 +460,11 @@ function fixture(
url: string, url: string,
opts: { destroyAllowed?: boolean | undefined; readOnly?: boolean } = {}, opts: { destroyAllowed?: boolean | undefined; readOnly?: boolean } = {},
) { ) {
const checkout = mkdtempSync(join(tmpdir(), "cast-co-")); const checkout = tmp("cast-co-");
mkdirSync(join(checkout, ".infra"), { recursive: true }); mkdirSync(join(checkout, ".infra"), { recursive: true });
writeFileSync(join(checkout, ".infra", "manifest.yaml"), MANIFEST); writeFileSync(join(checkout, ".infra", "manifest.yaml"), MANIFEST);
const state = mkdtempSync(join(tmpdir(), "cast-state-")); const state = tmp("cast-state-");
writeFileSync( writeFileSync(
join(state, ".coolify.env"), join(state, ".coolify.env"),
[ [

View file

@ -690,3 +690,250 @@ describe("backup schedules", () => {
expect(out).toMatch(/^clean$/m); expect(out).toMatch(/^clean$/m);
}); });
}); });
// Basic auth, read-side (cast#76). The password is a field cast WRITES and
// cannot READ, and the three ways that could go wrong are all worse than saying
// so: reporting a false "no change", reporting drift cast has no evidence for,
// or printing the value it does have.
describe("computeDiff — basic auth is fail-honest about what it could read", () => {
const wantAuth = {
kind: "application" as const,
name: "admin",
fields: {
build_pack: "nixpacks",
is_http_basic_auth_enabled: true,
http_basic_auth_username: "ops",
http_basic_auth_password: "s3cret",
},
};
// What projectLiveFields produces at 4.1.2: the toggle and the username, never
// the password — plus the reason, which fetchLive attaches.
const liveApp = (
fields: Record<string, unknown>,
over: Record<string, unknown> = {},
) => ({
kind: "application" as const,
name: "admin",
uuid: "app-1",
fields: { build_pack: "nixpacks", ...fields },
...over,
});
const PW_REASON = "http_basic_auth_password is never read back";
it("compares the toggle and the username, and skips only the password", () => {
const r = computeDiff(
[wantAuth],
[
liveApp(
{ is_http_basic_auth_enabled: true, http_basic_auth_username: "ops" },
{ basicAuthNotCompared: PW_REASON },
),
],
"full",
);
// The two readable halves agree, so there is no drift to report — and the
// password does NOT become a phantom change against `undefined`.
expect(r.changes).toEqual([]);
expect(r.basicAuthNotCompared).toEqual([
{
name: "admin",
fields: ["http_basic_auth_password"],
reason: PW_REASON,
},
]);
});
// The case the whole feature exists for: somebody turned basic auth off in the
// UI. The password being unreadable must not make cast blind to that.
it("still catches a toggle flipped off on the box", () => {
const r = computeDiff(
[wantAuth],
[
liveApp(
{
is_http_basic_auth_enabled: false,
http_basic_auth_username: "ops",
},
{ basicAuthNotCompared: PW_REASON },
),
],
"full",
);
expect(r.changes).toHaveLength(1);
expect(r.changes[0].fieldDiffs.map((f) => f.field)).toEqual([
"is_http_basic_auth_enabled",
]);
expect(r.clean).toBe(false);
});
it("catches a username changed on the box", () => {
const r = computeDiff(
[wantAuth],
[
liveApp(
{
is_http_basic_auth_enabled: true,
http_basic_auth_username: "someone-else",
},
{ basicAuthNotCompared: PW_REASON },
),
],
"full",
);
expect(r.changes[0].fieldDiffs.map((f) => f.field)).toEqual([
"http_basic_auth_username",
]);
});
// The other shape: a read that returned none of it. Then cast claims nothing
// about any of the three — not "clean", not "drifted".
it("skips all three when the read returned no basic-auth state at all", () => {
const r = computeDiff(
[wantAuth],
[liveApp({}, { basicAuthNotCompared: "no toggle on this read" })],
"full",
);
expect(r.changes).toEqual([]);
expect(r.basicAuthNotCompared[0].fields).toEqual([
"is_http_basic_auth_enabled",
"http_basic_auth_username",
"http_basic_auth_password",
]);
});
it("says so on screen, on a run it still calls clean", () => {
const out = renderDiff(
computeDiff(
[wantAuth],
[
liveApp(
{
is_http_basic_auth_enabled: true,
http_basic_auth_username: "ops",
},
{ basicAuthNotCompared: PW_REASON },
),
],
"full",
),
);
expect(out).toContain(
"basic_auth on application admin declared, http_basic_auth_password NOT compared — verify in the Coolify UI",
);
// Absence of evidence, not evidence of drift — the backup precedent.
expect(out).toMatch(/^clean$/m);
});
it("says nothing about basic auth for an application that declares none", () => {
const out = renderDiff(
computeDiff(
[
{
kind: "application" as const,
name: "admin",
fields: { build_pack: "nixpacks" },
},
],
[liveApp({}, { basicAuthNotCompared: PW_REASON })],
"full",
),
);
expect(out).not.toContain("basic_auth");
expect(out).toMatch(/^clean$/m);
});
// A live resource that reports the fields fine (a future Coolify, or a
// sensitive-token read path) must NOT be told it was uncompared.
it("reports nothing uncompared when the read supplied everything it needed", () => {
const r = computeDiff(
[
{
kind: "application" as const,
name: "admin",
fields: { build_pack: "nixpacks", is_http_basic_auth_enabled: false },
},
],
[
liveApp(
{ is_http_basic_auth_enabled: false },
{ basicAuthNotCompared: PW_REASON },
),
],
"full",
);
expect(r.basicAuthNotCompared).toEqual([]);
});
});
// The password must not reach a terminal, on either side of the arrow, on any
// path — including a CREATE, where every desired field becomes a field diff.
describe("renderDiff — the basic-auth password is redacted (#76)", () => {
const create = () =>
renderDiff(
computeDiff(
[
{
kind: "application" as const,
name: "admin",
fields: {
build_pack: "nixpacks",
is_http_basic_auth_enabled: true,
http_basic_auth_username: "ops",
http_basic_auth_password: "s3cret-value",
},
},
],
[],
"full",
),
);
it("never prints the value, on a create plan", () => {
expect(create()).not.toContain("s3cret-value");
});
it("still says the field is being set — redacted is not silent", () => {
expect(create()).toContain(
"http_basic_auth_password: differs — apply will set it (secret; value not printed)",
);
});
it("prints the username in the clear — it is not a secret", () => {
expect(create()).toContain("http_basic_auth_username");
expect(create()).toContain("ops");
});
it("never prints the value on an update plan either", () => {
const out = renderDiff(
computeDiff(
[
{
kind: "application" as const,
name: "admin",
fields: {
build_pack: "nixpacks",
http_basic_auth_password: "s3cret-value",
},
},
],
// No basicAuthNotCompared: this live side CAN see the password (a future
// Coolify), so it is compared — and still not printed.
[
{
kind: "application" as const,
name: "admin",
uuid: "app-1",
fields: {
build_pack: "nixpacks",
http_basic_auth_password: "the-old-one",
},
},
],
"full",
),
);
expect(out).not.toContain("s3cret-value");
expect(out).not.toContain("the-old-one");
expect(out).toContain("http_basic_auth_password: differs");
});
});

View file

@ -2,20 +2,19 @@ import { execFileSync, spawn } from "node:child_process";
import { import {
existsSync, existsSync,
mkdirSync, mkdirSync,
mkdtempSync,
readFileSync, readFileSync,
readdirSync, readdirSync,
writeFileSync, writeFileSync,
} from "node:fs"; } from "node:fs";
import { createServer } from "node:http"; import { createServer } from "node:http";
import type { AddressInfo } from "node:net"; import type { AddressInfo } from "node:net";
import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { afterEach, beforeAll, describe, expect, it } from "vitest"; import { afterEach, beforeAll, describe, expect, it } from "vitest";
import { loadBindings } from "../src/bindings.js"; import { loadBindings } from "../src/bindings.js";
import { GENERATED_PLACEHOLDER } from "../src/capture.js"; import { GENERATED_PLACEHOLDER } from "../src/capture.js";
import { loadManifest } from "../src/manifest.js"; import { loadManifest } from "../src/manifest.js";
import { decryptSecrets } from "../src/secrets.js"; import { decryptSecrets } from "../src/secrets.js";
import { tmp } from "./helpers/tmp.js";
// `cast inventory --emit-draft` against a stub shaped like the box that made it // `cast inventory --emit-draft` against a stub shaped like the box that made it
// necessary: a Coolify nobody declared, holding our stack under names someone // necessary: a Coolify nobody declared, holding our stack under names someone
@ -215,7 +214,7 @@ let KEY_FILE = "";
let RECIPIENT = ""; let RECIPIENT = "";
beforeAll(() => { beforeAll(() => {
const dir = mkdtempSync(join(tmpdir(), "cast-age-")); const dir = tmp("cast-age-");
KEY_FILE = join(dir, "key.txt"); KEY_FILE = join(dir, "key.txt");
execFileSync("age-keygen", ["-o", KEY_FILE], { stdio: "ignore" }); execFileSync("age-keygen", ["-o", KEY_FILE], { stdio: "ignore" });
RECIPIENT = execFileSync("age-keygen", ["-y", KEY_FILE], { RECIPIENT = execFileSync("age-keygen", ["-y", KEY_FILE], {
@ -224,7 +223,7 @@ beforeAll(() => {
}); });
function fixture(url: string, opts: { recipient?: string } = {}) { function fixture(url: string, opts: { recipient?: string } = {}) {
const state = mkdtempSync(join(tmpdir(), "cast-state-")); const state = tmp("cast-state-");
writeFileSync( writeFileSync(
join(state, ".coolify.env"), join(state, ".coolify.env"),
`COOLIFY_BASE_URL="${url}"\nCOOLIFY_ACCESS_TOKEN="t"\n`, `COOLIFY_BASE_URL="${url}"\nCOOLIFY_ACCESS_TOKEN="t"\n`,
@ -242,7 +241,7 @@ function fixture(url: string, opts: { recipient?: string } = {}) {
"", "",
].join("\n"), ].join("\n"),
); );
const out = join(mkdtempSync(join(tmpdir(), "cast-out-")), "draft"); const out = join(tmp("cast-out-"), "draft");
return { state, out }; return { state, out };
} }

View file

@ -1,5 +1,4 @@
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; import { mkdirSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { GENERATED_PLACEHOLDER } from "../src/capture.js"; import { GENERATED_PLACEHOLDER } from "../src/capture.js";
@ -14,6 +13,7 @@ import {
} from "../src/draft.js"; } from "../src/draft.js";
import { templateKeys, templateRefs } from "../src/envtemplate.js"; import { templateKeys, templateRefs } from "../src/envtemplate.js";
import { loadManifest } from "../src/manifest.js"; import { loadManifest } from "../src/manifest.js";
import { tmp } from "./helpers/tmp.js";
const ctx = { const ctx = {
env: "prod", env: "prod",
@ -240,7 +240,7 @@ describe("planDraft — the emitted shape", () => {
expect(manifest?.content).toContain("`apply` does not read this file"); expect(manifest?.content).toContain("`apply` does not read this file");
expect(manifest?.content).toContain("box-b"); expect(manifest?.content).toContain("box-b");
const dir = mkdtempSync(join(tmpdir(), "cast-draft-")); const dir = tmp("cast-draft-");
const path = join(dir, "manifest.yaml"); const path = join(dir, "manifest.yaml");
writeFileSync(path, manifest?.content ?? ""); writeFileSync(path, manifest?.content ?? "");
const loaded = loadManifest(path); const loaded = loadManifest(path);
@ -285,7 +285,7 @@ describe("planDraft — the emitted shape", () => {
}); });
const plan = planDraft([p], ctx); const plan = planDraft([p], ctx);
const manifest = plan.files.find((f) => f.path.endsWith("manifest.yaml")); const manifest = plan.files.find((f) => f.path.endsWith("manifest.yaml"));
const dir = mkdtempSync(join(tmpdir(), "cast-draft-")); const dir = tmp("cast-draft-");
const path = join(dir, "manifest.yaml"); const path = join(dir, "manifest.yaml");
writeFileSync(path, manifest?.content ?? ""); writeFileSync(path, manifest?.content ?? "");
const build = const build =
@ -325,7 +325,7 @@ describe("planDraft — the emitted shape", () => {
}); });
const plan = planDraft([p], ctx); const plan = planDraft([p], ctx);
const manifest = plan.files.find((f) => f.path.endsWith("manifest.yaml")); const manifest = plan.files.find((f) => f.path.endsWith("manifest.yaml"));
const dir = mkdtempSync(join(tmpdir(), "cast-draft-")); const dir = tmp("cast-draft-");
const path = join(dir, "manifest.yaml"); const path = join(dir, "manifest.yaml");
writeFileSync(path, manifest?.content ?? ""); writeFileSync(path, manifest?.content ?? "");
// The whole point: it loads (does not throw), and simply carries no `static`. // The whole point: it loads (does not throw), and simply carries no `static`.
@ -374,7 +374,7 @@ describe("planDraft — the emitted shape", () => {
// `static: true` would be exactly the fabrication UNCAPTURED.md exists to // `static: true` would be exactly the fabrication UNCAPTURED.md exists to
// prevent. // prevent.
const manifest = plan.files.find((f) => f.path.endsWith("manifest.yaml")); const manifest = plan.files.find((f) => f.path.endsWith("manifest.yaml"));
const dir = mkdtempSync(join(tmpdir(), "cast-draft-")); const dir = tmp("cast-draft-");
const path = join(dir, "manifest.yaml"); const path = join(dir, "manifest.yaml");
writeFileSync(path, manifest?.content ?? ""); writeFileSync(path, manifest?.content ?? "");
const build = const build =
@ -598,7 +598,7 @@ describe("backup schedules — read and drafted, not hand-waved (#75)", () => {
}); });
const loadedDb = (plan: ReturnType<typeof planDraft>) => { const loadedDb = (plan: ReturnType<typeof planDraft>) => {
const manifest = plan.files.find((f) => f.path.endsWith("manifest.yaml")); const manifest = plan.files.find((f) => f.path.endsWith("manifest.yaml"));
const dir = mkdtempSync(join(tmpdir(), "cast-draft-")); const dir = tmp("cast-draft-");
const path = join(dir, "manifest.yaml"); const path = join(dir, "manifest.yaml");
writeFileSync(path, manifest?.content ?? ""); writeFileSync(path, manifest?.content ?? "");
return loadManifest(path).environments.prod.databases?.[ return loadManifest(path).environments.prod.databases?.[
@ -690,7 +690,7 @@ describe("service hostnames — read and drafted via the per-service GET (#83)",
}); });
const loadedSvc = (plan: ReturnType<typeof planDraft>) => { const loadedSvc = (plan: ReturnType<typeof planDraft>) => {
const manifest = plan.files.find((f) => f.path.endsWith("manifest.yaml")); const manifest = plan.files.find((f) => f.path.endsWith("manifest.yaml"));
const dir = mkdtempSync(join(tmpdir(), "cast-draft-")); const dir = tmp("cast-draft-");
const path = join(dir, "manifest.yaml"); const path = join(dir, "manifest.yaml");
writeFileSync(path, manifest?.content ?? ""); writeFileSync(path, manifest?.content ?? "");
return loadManifest(path).environments.prod.services?.["Incubator Umami"]; return loadManifest(path).environments.prod.services?.["Incubator Umami"];
@ -739,20 +739,20 @@ describe("service hostnames — read and drafted via the per-service GET (#83)",
describe("the emit refusals — adoption is one-way", () => { describe("the emit refusals — adoption is one-way", () => {
it("refuses a target directory that is not empty", () => { it("refuses a target directory that is not empty", () => {
const dir = mkdtempSync(join(tmpdir(), "cast-draft-")); const dir = tmp("cast-draft-");
writeFileSync(join(dir, "README.md"), "a repo lives here\n"); writeFileSync(join(dir, "README.md"), "a repo lives here\n");
expect(() => assertEmptyTarget(dir)).toThrow(/is not empty/); expect(() => assertEmptyTarget(dir)).toThrow(/is not empty/);
expect(() => assertEmptyTarget(dir)).toThrow(/Adoption is one-way/); expect(() => assertEmptyTarget(dir)).toThrow(/Adoption is one-way/);
}); });
it("allows a directory that does not exist yet, and an empty one", () => { it("allows a directory that does not exist yet, and an empty one", () => {
const dir = mkdtempSync(join(tmpdir(), "cast-draft-")); const dir = tmp("cast-draft-");
expect(() => assertEmptyTarget(dir)).not.toThrow(); expect(() => assertEmptyTarget(dir)).not.toThrow();
expect(() => assertEmptyTarget(join(dir, "new"))).not.toThrow(); expect(() => assertEmptyTarget(join(dir, "new"))).not.toThrow();
}); });
it("refuses to write a manifest over one that already exists", () => { it("refuses to write a manifest over one that already exists", () => {
const dir = mkdtempSync(join(tmpdir(), "cast-draft-")); const dir = tmp("cast-draft-");
mkdirSync(join(dir, ".infra"), { recursive: true }); mkdirSync(join(dir, ".infra"), { recursive: true });
const path = join(dir, ".infra", "manifest.yaml"); const path = join(dir, ".infra", "manifest.yaml");
writeFileSync(path, "project: incubator\n"); writeFileSync(path, "project: incubator\n");

View file

@ -1,10 +1,10 @@
import { execFileSync, spawn } from "node:child_process"; import { execFileSync, spawn } from "node:child_process";
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; import { mkdirSync, writeFileSync } from "node:fs";
import { createServer } from "node:http"; import { createServer } from "node:http";
import type { AddressInfo } from "node:net"; import type { AddressInfo } from "node:net";
import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { afterEach, beforeAll, describe, expect, it } from "vitest"; import { afterEach, beforeAll, describe, expect, it } from "vitest";
import { tmp } from "./helpers/tmp.js";
// `cast diff --all` / `cast apply --all` (#26), end to end against a stub // `cast diff --all` / `cast apply --all` (#26), end to end against a stub
// Coolify carrying three projects. // Coolify carrying three projects.
@ -26,7 +26,7 @@ let recipient: string;
let keyFile: string; let keyFile: string;
beforeAll(() => { beforeAll(() => {
const dir = mkdtempSync(join(tmpdir(), "cast-age-")); const dir = tmp("cast-age-");
keyFile = join(dir, "age.key"); keyFile = join(dir, "age.key");
execFileSync("age-keygen", ["-o", keyFile], { stdio: "pipe" }); execFileSync("age-keygen", ["-o", keyFile], { stdio: "pipe" });
recipient = execFileSync("age-keygen", ["-y", keyFile], { recipient = execFileSync("age-keygen", ["-y", keyFile], {
@ -134,7 +134,7 @@ function fixture(
url: string, url: string,
opts: { registry?: string[]; registryEnv?: string; refIn?: string } = {}, opts: { registry?: string[]; registryEnv?: string; refIn?: string } = {},
) { ) {
const root = mkdtempSync(join(tmpdir(), "cast-fleet-")); const root = tmp("cast-fleet-");
for (const repo of REPOS) { for (const repo of REPOS) {
const dir = join(root, "repos", "heavy-duty", `${repo}.git`); const dir = join(root, "repos", "heavy-duty", `${repo}.git`);
mkdirSync(join(dir, ".infra"), { recursive: true }); mkdirSync(join(dir, ".infra"), { recursive: true });

View file

@ -1,10 +1,10 @@
import { execFileSync, spawn } from "node:child_process"; import { execFileSync, spawn } from "node:child_process";
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; import { mkdirSync, writeFileSync } from "node:fs";
import { createServer } from "node:http"; import { createServer } from "node:http";
import type { AddressInfo } from "node:net"; import type { AddressInfo } from "node:net";
import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { afterEach, beforeAll, describe, expect, it } from "vitest"; import { afterEach, beforeAll, describe, expect, it } from "vitest";
import { tmp } from "./helpers/tmp.js";
// When does `apply` need a GitHub App at all? (#103, found live in the // When does `apply` need a GitHub App at all? (#103, found live in the
// 2026-07-19 release drill.) // 2026-07-19 release drill.)
@ -26,7 +26,7 @@ let recipient: string;
let keyFile: string; let keyFile: string;
beforeAll(() => { beforeAll(() => {
const dir = mkdtempSync(join(tmpdir(), "cast-age-")); const dir = tmp("cast-age-");
keyFile = join(dir, "age.key"); keyFile = join(dir, "age.key");
execFileSync("age-keygen", ["-o", keyFile], { stdio: "pipe" }); execFileSync("age-keygen", ["-o", keyFile], { stdio: "pipe" });
recipient = execFileSync("age-keygen", ["-y", keyFile], { recipient = execFileSync("age-keygen", ["-y", keyFile], {
@ -112,11 +112,11 @@ environments:
// The state file the issue is about: no github_apps entry for this repo at all. // The state file the issue is about: no github_apps entry for this repo at all.
function fixture(url: string, manifest: string) { function fixture(url: string, manifest: string) {
const checkout = mkdtempSync(join(tmpdir(), "cast-co-")); const checkout = tmp("cast-co-");
mkdirSync(join(checkout, ".infra", "env"), { recursive: true }); mkdirSync(join(checkout, ".infra", "env"), { recursive: true });
writeFileSync(join(checkout, ".infra", "manifest.yaml"), manifest); writeFileSync(join(checkout, ".infra", "manifest.yaml"), manifest);
const state = mkdtempSync(join(tmpdir(), "cast-state-")); const state = tmp("cast-state-");
mkdirSync(join(state, "secrets")); mkdirSync(join(state, "secrets"));
writeFileSync( writeFileSync(
join(state, ".coolify.env"), join(state, ".coolify.env"),

View file

@ -0,0 +1,396 @@
import { spawn } from "node:child_process";
import { generateKeyPairSync } from "node:crypto";
import { readFileSync, readdirSync, writeFileSync } from "node:fs";
import { createServer } from "node:http";
import type { AddressInfo } from "node:net";
import { join } from "node:path";
import { afterEach, beforeAll, describe, expect, it } from "vitest";
import { tmp } from "./helpers/tmp.js";
// `cast github-app register` through the real CLI: argv parsing, the stdin-only
// client secret, the team assert, the name resolved from state, the
// post-condition check, and WHEN environments.yaml is written.
//
// The Coolify here is a stub. Registration against a live instance is
// operator-only territory (#7's testability boundary) and nothing in this file
// pretends otherwise — what it proves is that cast sends the right things and
// reacts correctly to each answer.
let privateKeyPem: string;
beforeAll(() => {
privateKeyPem = generateKeyPairSync("rsa", { modulusLength: 2048 })
.privateKey.export({ type: "pkcs8", format: "pem" })
.toString();
});
type Stub = {
url: string;
hits: string[];
bodies: Record<string, Record<string, unknown>>;
close: () => Promise<void>;
};
const stubs: Stub[] = [];
async function stubCoolify(opts: { repositories: unknown }): Promise<Stub> {
const hits: string[] = [];
const bodies: Record<string, Record<string, unknown>> = {};
const server = createServer((req, res) => {
const path = new URL(req.url ?? "", "http://x").pathname.replace(
"/api/v1",
"",
);
const key = `${req.method} ${path}`;
hits.push(key);
let raw = "";
req.on("data", (d) => {
raw += String(d);
});
req.on("end", () => {
if (raw) bodies[key] = JSON.parse(raw);
const json = (body: unknown) => {
res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify(body));
};
if (path === "/teams/current") return json({ id: 0, name: "Root Team" });
if (path === "/security/keys") return json({ uuid: "key-uuid-1" });
if (path === "/github-apps" && req.method === "POST")
return json({ id: 7, uuid: "app-uuid" });
// A clean instance: nothing registered under this name yet, so register
// goes on to create. (The list read is how it avoids a duplicate Source
// on a re-run — Coolify does not enforce unique names.)
if (path === "/github-apps" && req.method === "GET") return json([]);
if (path === "/github-apps/7/repositories")
return json({ repositories: opts.repositories });
res.writeHead(404);
res.end("{}");
});
});
await new Promise<void>((r) => {
server.listen(0, "127.0.0.1", r);
});
const stub: Stub = {
url: `http://127.0.0.1:${(server.address() as AddressInfo).port}`,
hits,
bodies,
close: () =>
new Promise<void>((r) => {
server.close(() => r());
}),
};
stubs.push(stub);
return stub;
}
afterEach(async () => {
await Promise.all(stubs.splice(0).map((s) => s.close()));
});
function fixture(
url: string,
githubApps: string,
): { state: string; pem: string } {
const state = tmp("cast-state-");
writeFileSync(
join(state, ".coolify.env"),
`COOLIFY_BASE_URL="${url}"\nCOOLIFY_ACCESS_TOKEN="t"\n`,
);
writeFileSync(
join(state, "environments.yaml"),
[
"# hand-maintained",
"environments:",
" prod:",
" server: prod-box",
" team: { id: 0, name: Root Team }",
githubApps,
"",
].join("\n"),
);
const pem = join(state, "downloaded.pem");
writeFileSync(pem, privateKeyPem);
return { state, pem };
}
function run(
args: string[],
stdin: string | null,
): Promise<{ code: number; output: string }> {
return new Promise((resolve) => {
const child = spawn("node", ["dist/cli.js", ...args], {
stdio: [stdin === null ? "ignore" : "pipe", "pipe", "pipe"],
});
if (stdin !== null) {
child.stdin?.end(stdin);
}
let output = "";
child.stdout.on("data", (d) => {
output += String(d);
});
child.stderr.on("data", (d) => {
output += String(d);
});
child.on("close", (code) => resolve({ code: code ?? 0, output }));
});
}
const REGISTER = (state: string, pem: string) => [
"github-app",
"register",
"heavy-duty/incubator",
"--env",
"prod",
"--state",
state,
"--app-id",
"12345",
"--installation-id",
"99887766",
"--client-id",
"Iv23liABCDEF",
"--client-secret-stdin",
"--private-key",
pem,
];
describe("cast github-app register", () => {
it("registers against the name in state, verifies the repo, and never takes the secret from argv", async () => {
const stub = await stubCoolify({
repositories: [{ full_name: "heavy-duty/incubator" }],
});
const f = fixture(
stub.url,
"github_apps:\n heavy-duty/incubator: hdb-coolify-prod",
);
const r = await run(REGISTER(f.state, f.pem), "the-client-secret\n");
expect(r.code).toBe(0);
expect(r.output).toContain('team id=0 name="Root Team" ✓');
expect(r.output).toContain("(from environments.yaml)");
expect(r.output).toContain(
"verified: hdb-coolify-prod can clone heavy-duty/incubator ✓",
);
// The secret reached Coolify, and it came off stdin — it is nowhere in
// argv, which `ps` shows and shell history keeps.
expect(stub.bodies["POST /github-apps"].client_secret).toBe(
"the-client-secret",
);
expect(stub.bodies["POST /security/keys"].name).toBe(
"hdb-coolify-prod-key",
);
// A webhook-INACTIVE App is the right shape for a tailnet-only Coolify, so
// no operator has to invent a placeholder any more (#5 footgun 3).
expect(r.output).toContain("generated one");
expect(
String(stub.bodies["POST /github-apps"].webhook_secret).length,
).toBeGreaterThan(0);
// The credentials landed in the state dir, under a git-ignored directory.
expect(
readFileSync(
join(f.state, "github-apps", "hdb-coolify-prod.pem"),
"utf8",
),
).toBe(privateKeyPem);
expect(
readFileSync(join(f.state, "github-apps", ".gitignore"), "utf8"),
).toContain("*");
});
it("seeds an ABSENT binding from --name, keyed by the full slug, comments intact", async () => {
const stub = await stubCoolify({
repositories: [{ full_name: "heavy-duty/incubator" }],
});
const f = fixture(stub.url, "github_apps: {}");
const r = await run(
[...REGISTER(f.state, f.pem), "--name", "hdb-coolify-prod"],
"s\n",
);
expect(r.code).toBe(0);
const after = readFileSync(join(f.state, "environments.yaml"), "utf8");
expect(after).toContain("heavy-duty/incubator: hdb-coolify-prod");
expect(after).toContain("# hand-maintained");
});
it("REFUSES a --name that disagrees with the state file", async () => {
const stub = await stubCoolify({ repositories: [] });
const f = fixture(
stub.url,
"github_apps:\n heavy-duty/incubator: hdb-coolify-prod",
);
const r = await run(
[...REGISTER(f.state, f.pem), "--name", "My Cool App"],
"s\n",
);
expect(r.code).toBe(1);
expect(r.output).toContain("disagrees with environments.yaml");
// Refused before it touched Coolify at all — not even the team assert.
expect(stub.hits).toEqual([]);
});
it("refuses a client secret passed any way other than stdin", async () => {
const stub = await stubCoolify({ repositories: [] });
const f = fixture(
stub.url,
"github_apps:\n heavy-duty/incubator: hdb-coolify-prod",
);
const withoutFlag = REGISTER(f.state, f.pem).filter(
(a) => a !== "--client-secret-stdin",
);
const r = await run(withoutFlag, null);
expect(r.code).toBe(2);
expect(r.output).toContain("--client-secret-stdin is required");
});
it("fails, and does NOT seed state, when the App cannot see the repo", async () => {
// A state file naming an App that does not work is worse than one naming
// none: the next `cast apply` resolves it, uses it, and fails at clone time.
const stub = await stubCoolify({
repositories: [{ full_name: "heavy-duty/something-else" }],
});
const f = fixture(stub.url, "github_apps: {}");
const r = await run(
[...REGISTER(f.state, f.pem), "--name", "hdb-coolify-prod"],
"s\n",
);
expect(r.code).toBe(1);
expect(r.output).toContain("cannot see heavy-duty/incubator");
expect(r.output).toContain("can see: heavy-duty/something-else");
expect(readFileSync(join(f.state, "environments.yaml"), "utf8")).toContain(
"github_apps: {}",
);
});
it("refuses a read-only instance before any write", async () => {
const stub = await stubCoolify({ repositories: [] });
const f = fixture(
stub.url,
"github_apps:\n heavy-duty/incubator: hdb-coolify-prod",
);
writeFileSync(
join(f.state, ".coolify.env"),
`COOLIFY_BASE_URL="${stub.url}"\nCOOLIFY_ACCESS_TOKEN="t"\nCOOLIFY_READ_ONLY=true\n`,
);
const r = await run(REGISTER(f.state, f.pem), "s\n");
expect(r.code).toBe(1);
expect(r.output).toContain("refusing to github-app register");
expect(stub.hits).toEqual([]);
});
// Invalid ids must be refused before ANYTHING happens (cast#7 review).
// `register` persists the credential record before it calls Coolify, and
// `Number("nope")` is NaN which `JSON.stringify` writes as `null` — so
// without this gate a typo produces a credential file with a null app_id AND
// a security key uploaded to a live Coolify, from a run that then fails.
// Both halves are asserted: no stub hit, and no file written.
for (const [what, argv] of [
["a non-numeric --app-id", ["--app-id", "nope"]],
["a non-numeric --installation-id", ["--installation-id", "nope"]],
["a zero --app-id", ["--app-id", "0"]],
["a decimal --app-id", ["--app-id", "12.5"]],
// Integers to JavaScript, but not how an id is written — and silently
// storing 1000 for "1e3" is the quiet wrong answer, not a convenience.
["an exponent --app-id", ["--app-id", "1e3"]],
["a hex --app-id", ["--app-id", "0x10"]],
] as const) {
it(`refuses ${what} before touching disk or Coolify`, async () => {
const stub = await stubCoolify({
repositories: [{ full_name: "heavy-duty/incubator" }],
});
const f = fixture(
stub.url,
"github_apps:\n heavy-duty/incubator: hdb-coolify-prod",
);
const before = readdirSync(f.state).sort();
const base = REGISTER(f.state, f.pem);
const i = base.indexOf(argv[0]);
const args = [...base];
args[i + 1] = argv[1];
const r = await run(args, "s\n");
expect(r.code).toBe(2);
expect(r.output).toContain("must be a positive integer");
// Nothing reached the network...
expect(stub.hits).toEqual([]);
// ...and nothing was created or rewritten in the state dir.
expect(readdirSync(f.state).sort()).toEqual(before);
});
}
// A NEGATIVE id never reaches the check above: parseArgs reads a leading dash
// as an option and rejects `-5` as unknown, exiting 1 rather than 2. That is
// still a refusal before any write or request, which is the property that
// matters — but it is a different code path with a different exit code, so it
// gets its own case rather than a loosened assertion hiding the difference.
it("refuses a negative --app-id before touching disk or Coolify", async () => {
const stub = await stubCoolify({
repositories: [{ full_name: "heavy-duty/incubator" }],
});
const f = fixture(
stub.url,
"github_apps:\n heavy-duty/incubator: hdb-coolify-prod",
);
const before = readdirSync(f.state).sort();
const base = REGISTER(f.state, f.pem);
const args = [...base];
args[base.indexOf("--app-id") + 1] = "-5";
const r = await run(args, "s\n");
expect(r.code).not.toBe(0);
expect(stub.hits).toEqual([]);
expect(readdirSync(f.state).sort()).toEqual(before);
});
// `--port` belongs to the CREATE path, and had the same defect the ids did:
// `Number("abc")` is NaN, which reaches server.listen(NaN) and dies as an
// uncaught ERR_SOCKET_BAD_PORT stack trace — after detectOwnerType and the
// org-admin preflight have already gone out. Nothing is lost when it fails
// (no App and no secret exist yet), so this is about the command honouring
// its own rule — reject before any write or network call — and failing with
// a sentence rather than a stack trace.
//
// Driven through `create` because that is the path that reads the flag. The
// validation sits in the shared preamble, above openCoolify, so the run ends
// before the browser flow this command would otherwise need.
for (const [what, port] of [
["a non-numeric --port", "abc"],
["an out-of-range --port", "99999"],
["a zero --port", "0"],
["a decimal --port", "80.5"],
] as const) {
it(`refuses ${what} before touching disk or Coolify`, async () => {
const stub = await stubCoolify({ repositories: [] });
const f = fixture(
stub.url,
"github_apps:\n heavy-duty/incubator: hdb-coolify-prod",
);
const before = readdirSync(f.state).sort();
const r = await run(
[
"github-app",
"create",
"heavy-duty/incubator",
"--env",
"prod",
"--state",
f.state,
"--port",
port,
],
null,
);
expect(r.code).toBe(2);
expect(r.output).toContain("--port must be a port number");
expect(stub.hits).toEqual([]);
expect(readdirSync(f.state).sort()).toEqual(before);
});
}
it("prints usage for an unknown subcommand", async () => {
const r = await run(["github-app", "wat"], null);
expect(r.code).toBe(2);
expect(r.output).toContain("cast github-app create");
expect(r.output).toContain("cast github-app register");
});
});

1312
test/github-app.test.ts Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,10 +1,10 @@
import { execFileSync, spawn } from "node:child_process"; import { execFileSync, spawn } from "node:child_process";
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; import { mkdirSync, writeFileSync } from "node:fs";
import { createServer } from "node:http"; import { createServer } from "node:http";
import type { AddressInfo } from "node:net"; import type { AddressInfo } from "node:net";
import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { afterEach, beforeAll, describe, expect, it } from "vitest"; import { afterEach, beforeAll, describe, expect, it } from "vitest";
import { tmp } from "./helpers/tmp.js";
// The greenfield manifest-first bootstrap (#104), end to end: fresh box, // The greenfield manifest-first bootstrap (#104), end to end: fresh box,
// registered project, a manifest that declares databases only and refs no // registered project, a manifest that declares databases only and refs no
@ -23,7 +23,7 @@ let recipient: string;
let keyFile: string; let keyFile: string;
beforeAll(() => { beforeAll(() => {
const dir = mkdtempSync(join(tmpdir(), "cast-age-")); const dir = tmp("cast-age-");
keyFile = join(dir, "age.key"); keyFile = join(dir, "age.key");
execFileSync("age-keygen", ["-o", keyFile], { stdio: "pipe" }); execFileSync("age-keygen", ["-o", keyFile], { stdio: "pipe" });
recipient = execFileSync("age-keygen", ["-y", keyFile], { recipient = execFileSync("age-keygen", ["-y", keyFile], {
@ -100,7 +100,7 @@ function fixture(
manifest: ZERO_REFS_MANIFEST, manifest: ZERO_REFS_MANIFEST,
}, },
) { ) {
const checkout = mkdtempSync(join(tmpdir(), "cast-co-")); const checkout = tmp("cast-co-");
mkdirSync(join(checkout, ".infra", "env"), { recursive: true }); mkdirSync(join(checkout, ".infra", "env"), { recursive: true });
writeFileSync(join(checkout, ".infra", "manifest.yaml"), opts.manifest); writeFileSync(join(checkout, ".infra", "manifest.yaml"), opts.manifest);
writeFileSync( writeFileSync(
@ -108,7 +108,7 @@ function fixture(
"API_KEY=${API_KEY}\n", "API_KEY=${API_KEY}\n",
); );
const state = mkdtempSync(join(tmpdir(), "cast-state-")); const state = tmp("cast-state-");
mkdirSync(join(state, "secrets")); mkdirSync(join(state, "secrets"));
writeFileSync( writeFileSync(
join(state, ".coolify.env"), join(state, ".coolify.env"),
@ -148,7 +148,7 @@ function run(
const { CAST_AGE_KEY_FILE_STAGING: _dropped, ...inherited } = process.env; const { CAST_AGE_KEY_FILE_STAGING: _dropped, ...inherited } = process.env;
const env = opts.withKey const env = opts.withKey
? { ...inherited, CAST_AGE_KEY_FILE_STAGING: keyFile } ? { ...inherited, CAST_AGE_KEY_FILE_STAGING: keyFile }
: { ...inherited, HOME: mkdtempSync(join(tmpdir(), "cast-home-")) }; : { ...inherited, HOME: tmp("cast-home-") };
const child = spawn("node", ["dist/cli.js", "diff", ...args], { const child = spawn("node", ["dist/cli.js", "diff", ...args], {
stdio: ["pipe", "pipe", "pipe"], stdio: ["pipe", "pipe", "pipe"],
env, env,

View file

@ -0,0 +1,39 @@
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
// The env var that tells `tmp()` (test/helpers/tmp.ts) where to allocate.
export const RUN_ROOT_ENV = "CAST_TEST_TMP_ROOT";
// One directory per `vitest run`, holding every temp dir the suite allocates.
//
// This is what actually reaps the suite's temp dirs (#117). The obvious design —
// each worker cleaning up after itself in a `process.once("exit")` hook — does
// NOT work under vitest, and it fails silently, which is worse: vitest recycles
// its pool workers by killing them, so `exit` handlers registered inside a test
// file never run. Measured, not assumed: a probe test that wrote a file from an
// `exit` hook produced no file, and a full suite run with per-worker exit hooks
// still left 750 directories behind.
//
// globalSetup's teardown runs in vitest's MAIN process, after every worker has
// finished, and vitest awaits it. That makes it the only hook in the run with
// both of the properties this needs: it is guaranteed to execute, and it sees
// the whole run rather than one worker's slice of it.
//
// Collapsing the whole run into a single root is what makes that teardown one
// `rmSync` instead of a list to keep in sync across processes — the workers do
// not have to report anything back, because the parent already knows the one
// path that contains everything.
export function setup(): () => void {
const root = mkdtempSync(join(tmpdir(), "cast-testrun-"));
process.env[RUN_ROOT_ENV] = root;
return function teardown(): void {
// Best-effort: a failure to clean up must not turn a green run red.
try {
rmSync(root, { recursive: true, force: true });
} catch {
// ignore
}
};
}

54
test/helpers/tmp.ts Normal file
View file

@ -0,0 +1,54 @@
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { RUN_ROOT_ENV } from "./global-setup.js";
// Every temp dir this process has handed out, in creation order.
const created: string[] = [];
// Where to allocate. Under `vitest run` this is the per-run root that
// global-setup.ts created and will remove wholesale when the run ends; the
// fallback keeps `tmp()` usable if a file is ever executed outside that config.
function base(): string {
return process.env[RUN_ROOT_ENV] ?? tmpdir();
}
// Belt-and-braces reaper for the fallback case only.
//
// It is deliberately NOT the primary mechanism. Vitest recycles its pool workers
// by killing them, so an `exit` handler registered from a test file does not run
// — verified with a probe test, and by a full suite run that still leaked 750
// directories with this hook in place. The real cleanup is global-setup.ts's
// teardown, which runs in the main process where an exit IS orderly. This hook
// only earns its keep when `tmp()` is called with no run root set, where nothing
// else would ever remove the directory.
let armed = false;
function arm(): void {
if (armed) return;
armed = true;
process.once("exit", () => {
for (const dir of created) {
try {
rmSync(dir, { recursive: true, force: true });
} catch {
// ignore
}
}
created.length = 0;
});
}
/**
* Create a temp dir and register it for cleanup. Drop-in replacement for
* `mkdtempSync(join(tmpdir(), prefix))` the prefix survives as the directory's
* basename, so paths stay as greppable as they were.
*
* @param prefix e.g. `"cast-home-"` mkdtemp appends six random characters.
*/
export function tmp(prefix: string): string {
arm();
const dir = mkdtempSync(join(base(), prefix));
created.push(dir);
return dir;
}

View file

@ -4,16 +4,15 @@ import {
copyFileSync, copyFileSync,
existsSync, existsSync,
mkdirSync, mkdirSync,
mkdtempSync,
readFileSync, readFileSync,
readlinkSync, readlinkSync,
realpathSync, realpathSync,
writeFileSync, writeFileSync,
} from "node:fs"; } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { promisify } from "node:util"; import { promisify } from "node:util";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { tmp } from "./helpers/tmp.js";
const run = promisify(execFile); const run = promisify(execFile);
@ -59,7 +58,7 @@ type Sandbox = {
}; };
function sandbox(): Sandbox { function sandbox(): Sandbox {
const root = mkdtempSync(join(tmpdir(), "cast-install-")); const root = tmp("cast-install-");
const stubs = join(root, "stubs"); const stubs = join(root, "stubs");
const home = join(root, "home"); const home = join(root, "home");
const dest = join(root, "cast-home"); const dest = join(root, "cast-home");

View file

@ -1,150 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
# Fixture tests for the labels-reconcile state machine: a comment is a
# non-verdict whatever its body says (the AUTHOR escalates by requesting the
# human), a stale approval does not promote unreviewed code, and an explicit
# human request outranks everything.
# Dependency-free beyond jq; no network, no daemon — pure decide_state.
cd "$(dirname "$0")/.."
# shellcheck source=.github/scripts/labels-reconcile.sh
. .github/scripts/labels-reconcile.sh
# The DRAFT/HEAD_SHA/REQUESTED/REVIEWS_JSON assignments below are the state
# machine's inputs, consumed inside the sourced decide_state — not unused.
# shellcheck disable=SC2034
BOT1="${BOTS[0]}" BOT2="${BOTS[1]}" BOT3="${BOTS[2]}"
pass=0 fail=0
expect() { # $1 = description, $2 = want, $3 = got
if [ "$2" = "$3" ]; then
pass=$((pass + 1))
else
fail=$((fail + 1))
printf 'FAIL: %s — want %s, got %s\n' "$1" "$2" "$3"
fi
}
rev() { # $1=login $2=state $3=commit $4=body $5=submitted_at → one review object
jq -n --arg u "$1" --arg s "$2" --arg c "$3" --arg b "$4" --arg t "$5" \
'{user: {login: $u}, state: $s, commit_id: $c, body: $b, submitted_at: $t}'
}
reviews() { jq -s '.' <<<"$*"; } # collect review objects into an array
# -- drafts are building, whoever is requested --------------------------------
DRAFT=true HEAD_SHA=head1 REQUESTED="" REVIEWS_JSON='[]'
expect "draft PR is building" state:building "$(decide_state)"
# -- fresh ready PR with bots requested ---------------------------------------
DRAFT=false REQUESTED="$BOT1
$BOT2
$BOT3" REVIEWS_JSON='[]'
expect "requested bots mean bots-reviewing" state:bots-reviewing "$(decide_state)"
# -- a bot that never reviewed keeps the round open ---------------------------
REQUESTED="" REVIEWS_JSON="$(reviews \
"$(rev "$BOT1" APPROVED head1 "" t1)" \
"$(rev "$BOT2" APPROVED head1 "" t2)")"
expect "missing bot review means bots-reviewing" state:bots-reviewing "$(decide_state)"
# -- a comment is a non-verdict, agreement body or not: the author escalates --
REVIEWS_JSON="$(reviews \
"$(rev "$BOT1" COMMENTED head1 "✅ **Reviewed — I agree with everything.**" t1)" \
"$(rev "$BOT2" APPROVED head1 "" t2)" \
"$(rev "$BOT3" APPROVED head1 "" t3)")"
expect "comment-only agreement still parks on the author" state:addressing "$(decide_state)"
# ...and the author's escalation — requesting the human — flips it
REQUESTED="$HUMAN"
expect "author escalation flips to needs-human" state:needs-human "$(decide_state)"
REQUESTED=""
# -- three formal approvals need no author judgment ---------------------------
REVIEWS_JSON="$(reviews \
"$(rev "$BOT1" APPROVED head1 "" t1)" \
"$(rev "$BOT2" APPROVED head1 "" t2)" \
"$(rev "$BOT3" APPROVED head1 "" t3)")"
expect "three formal approvals reach needs-human" state:needs-human "$(decide_state)"
# -- a comment WITHOUT a verdict parks the PR on the agent --------------------
REVIEWS_JSON="$(reviews \
"$(rev "$BOT1" COMMENTED head1 "🔧 Reviewed — I agree with most; feedback below." t1)" \
"$(rev "$BOT2" APPROVED head1 "" t2)" \
"$(rev "$BOT3" APPROVED head1 "" t3)")"
expect "comment without verdict is addressing" state:addressing "$(decide_state)"
# -- changes requested blocks, at any head ------------------------------------
REVIEWS_JSON="$(reviews \
"$(rev "$BOT1" CHANGES_REQUESTED old1 "blockers below" t1)" \
"$(rev "$BOT2" APPROVED head1 "" t2)" \
"$(rev "$BOT3" APPROVED head1 "" t3)")"
expect "changes-requested blocks even from an old head" state:addressing "$(decide_state)"
# -- a stale approval must not promote unreviewed code ------------------------
REVIEWS_JSON="$(reviews \
"$(rev "$BOT1" APPROVED old1 "" t1)" \
"$(rev "$BOT2" APPROVED head1 "" t2)" \
"$(rev "$BOT3" APPROVED head1 "" t3)")"
expect "stale approval is addressing (agent owes re-request)" state:addressing "$(decide_state)"
# -- a re-requested bot reopens the round even with an old approval on file ---
REQUESTED="$BOT1"
expect "re-requested bot means bots-reviewing" state:bots-reviewing "$(decide_state)"
REQUESTED=""
# -- only the LATEST review per bot counts ------------------------------------
REVIEWS_JSON="$(reviews \
"$(rev "$BOT1" CHANGES_REQUESTED head1 "blockers" t1)" \
"$(rev "$BOT1" APPROVED head1 "" t2)" \
"$(rev "$BOT2" APPROVED head1 "" t3)" \
"$(rev "$BOT3" APPROVED head1 "" t4)")"
expect "later approval supersedes earlier block" state:needs-human "$(decide_state)"
# -- an explicit human request outranks the bot rounds ------------------------
REQUESTED="$HUMAN" REVIEWS_JSON="$(reviews \
"$(rev "$BOT1" COMMENTED head1 "feedback, no verdict" t1)")"
expect "human requested outranks bots" state:needs-human "$(decide_state)"
REQUESTED=""
# -- human CHANGES_REQUESTED puts the ball back on the agent ------------------
REVIEWS_JSON="$(reviews \
"$(rev "$BOT1" APPROVED head1 "" t1)" \
"$(rev "$BOT2" APPROVED head1 "" t2)" \
"$(rev "$BOT3" APPROVED head1 "" t3)" \
"$(rev "$HUMAN" CHANGES_REQUESTED head1 "not yet" t4)")"
expect "human block with bots approving is addressing" state:addressing "$(decide_state)"
# ...and re-requesting the human hands it back to them
REQUESTED="$HUMAN"
expect "re-requested human is needs-human again" state:needs-human "$(decide_state)"
REQUESTED=""
# -- an old human comment must not wedge the handoff (codex, #85 round 3) -----
REVIEWS_JSON="$(reviews \
"$(rev "$HUMAN" COMMENTED old1 "early thoughts" t0)" \
"$(rev "$BOT1" APPROVED head1 "" t1)" \
"$(rev "$BOT2" APPROVED head1 "" t2)" \
"$(rev "$BOT3" APPROVED head1 "" t3)")"
expect "old human comment + three approvals is needs-human" state:needs-human "$(decide_state)"
expect "old human comment still needs a fresh request" needed "$(human_request_needed && echo needed || echo not-needed)"
# ...a stale human APPROVAL likewise needs a re-request for the new head
REVIEWS_JSON="$(reviews \
"$(rev "$HUMAN" APPROVED old1 "" t0)" \
"$(rev "$BOT1" APPROVED head1 "" t1)" \
"$(rev "$BOT2" APPROVED head1 "" t2)" \
"$(rev "$BOT3" APPROVED head1 "" t3)")"
expect "stale human approval needs a fresh request" needed "$(human_request_needed && echo needed || echo not-needed)"
# ...a HEAD-CURRENT human approval needs nothing more
REVIEWS_JSON="$(reviews \
"$(rev "$HUMAN" APPROVED head1 "" t0)" \
"$(rev "$BOT1" APPROVED head1 "" t1)" \
"$(rev "$BOT2" APPROVED head1 "" t2)" \
"$(rev "$BOT3" APPROVED head1 "" t3)")"
expect "head-current human approval needs no request" not-needed "$(human_request_needed && echo needed || echo not-needed)"
# ...and a live request suppresses re-requesting
REQUESTED="$HUMAN"
expect "live human request suppresses re-request" not-needed "$(human_request_needed && echo needed || echo not-needed)"
REQUESTED=""
printf 'labels-reconcile tests: %d passed, %d failed\n' "$pass" "$fail"
[ "$fail" -eq 0 ]

View file

@ -4,15 +4,14 @@ import {
copyFileSync, copyFileSync,
existsSync, existsSync,
mkdirSync, mkdirSync,
mkdtempSync,
readFileSync, readFileSync,
realpathSync, realpathSync,
writeFileSync, writeFileSync,
} from "node:fs"; } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { promisify } from "node:util"; import { promisify } from "node:util";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { tmp } from "./helpers/tmp.js";
const run = promisify(execFile); const run = promisify(execFile);
@ -50,7 +49,7 @@ type Sandbox = {
}; };
async function installedSandbox(versions: string[]): Promise<Sandbox> { async function installedSandbox(versions: string[]): Promise<Sandbox> {
const root = mkdtempSync(join(tmpdir(), "cast-layout-")); const root = tmp("cast-layout-");
const stubs = join(root, "stubs"); const stubs = join(root, "stubs");
const home = join(root, "home"); const home = join(root, "home");
const dest = join(root, "cast-home"); const dest = join(root, "cast-home");

View file

@ -285,6 +285,124 @@ environments:
}); });
}); });
// HTTP basic auth on an application (cast#76). The schema carries three rules,
// and each is here because breaking it is expensive somewhere else: the password
// must be a store ref (a literal is a password in git, forever), enabling needs
// both credentials (Coolify 422s mid-run otherwise, and half-configured basic
// auth protects nothing), and a disabled block must carry none (a credential
// standing over a disabled auth reads like a guard and is not one).
describe("loadManifest — basic_auth (#76)", () => {
const app = (basicAuth: string) => `
project: x
environments:
prod:
applications:
admin:
source: { repo: o/r, branch: main }
build: { pack: nixpacks, base_directory: / }
domains: ["https://admin.example.com"]
basic_auth: ${basicAuth}
`;
it("accepts an enabled block whose password is a ${REF}", () => {
const m = loadManifest(`${FIX}manifest.yaml`, {
overrideText: app(
"{ enabled: true, username: ops, password: '${ADMIN_PW}' }",
),
});
expect(m.environments.prod.applications.admin.basic_auth).toEqual({
enabled: true,
username: "ops",
password: "${ADMIN_PW}",
});
});
it("accepts a bare `enabled: false` — the way to assert basic auth is OFF", () => {
const m = loadManifest(`${FIX}manifest.yaml`, {
overrideText: app("{ enabled: false }"),
});
expect(m.environments.prod.applications.admin.basic_auth).toEqual({
enabled: false,
});
});
it("treats an omitted block as saying nothing at all", () => {
const m = loadManifest(`${FIX}manifest.yaml`, {
overrideText: `
project: x
environments:
prod:
applications:
admin:
source: { repo: o/r, branch: main }
build: { pack: nixpacks, base_directory: / }
domains: ["https://admin.example.com"]
`,
});
expect(m.environments.prod.applications.admin.basic_auth).toBeUndefined();
});
// The non-negotiable. A literal here would be a live password in a reviewed,
// committed file — so it is unrepresentable, not discouraged.
it("REFUSES a literal password", () => {
expect(() =>
loadManifest(`${FIX}manifest.yaml`, {
overrideText: app(
"{ enabled: true, username: ops, password: hunter2 }",
),
}),
).toThrow(/must be a store ref/);
});
it("refuses a password that is a ref with anything around it", () => {
expect(() =>
loadManifest(`${FIX}manifest.yaml`, {
overrideText: app(
"{ enabled: true, username: ops, password: 'pre-${ADMIN_PW}' }",
),
}),
).toThrow(/must be a store ref/);
});
// Coolify's own presence rule, failing in the FILE rather than as a 422 from a
// PATCH in the middle of a run.
it("refuses enabling without a password", () => {
expect(() =>
loadManifest(`${FIX}manifest.yaml`, {
overrideText: app("{ enabled: true, username: ops }"),
}),
).toThrow(
/basic_auth.password is required when basic_auth.enabled is true/,
);
});
it("refuses enabling without a username", () => {
expect(() =>
loadManifest(`${FIX}manifest.yaml`, {
overrideText: app("{ enabled: true, password: '${ADMIN_PW}' }"),
}),
).toThrow(
/basic_auth.username is required when basic_auth.enabled is true/,
);
});
it("refuses credentials declared alongside `enabled: false`", () => {
expect(() =>
loadManifest(`${FIX}manifest.yaml`, {
overrideText: app("{ enabled: false, username: ops }"),
}),
).toThrow(/not allowed when basic_auth.enabled is false/);
});
it("refuses a block with no `enabled` at all — the toggle is never inferred", () => {
expect(() =>
loadManifest(`${FIX}manifest.yaml`, {
overrideText: app("{ username: ops, password: '${ADMIN_PW}' }"),
}),
).toThrow(/enabled/);
});
});
describe("loadBindings", () => { describe("loadBindings", () => {
it("parses bindings", () => { it("parses bindings", () => {
const b = loadBindings(`${FIX}environments.yaml`); const b = loadBindings(`${FIX}environments.yaml`);

View file

@ -1,10 +1,10 @@
import { execFileSync, spawn } from "node:child_process"; import { execFileSync, spawn } from "node:child_process";
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; import { mkdirSync, writeFileSync } from "node:fs";
import { createServer } from "node:http"; import { createServer } from "node:http";
import type { AddressInfo } from "node:net"; import type { AddressInfo } from "node:net";
import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { afterEach, beforeAll, describe, expect, it } from "vitest"; import { afterEach, beforeAll, describe, expect, it } from "vitest";
import { tmp } from "./helpers/tmp.js";
// Placement, end to end: `environments.yaml` -> `cast diff` -> what it prints // Placement, end to end: `environments.yaml` -> `cast diff` -> what it prints
// and what it exits with. The unit tests prove each half (bindings resolve a // and what it exits with. The unit tests prove each half (bindings resolve a
@ -20,7 +20,7 @@ let recipient: string;
let keyFile: string; let keyFile: string;
beforeAll(() => { beforeAll(() => {
const dir = mkdtempSync(join(tmpdir(), "cast-age-")); const dir = tmp("cast-age-");
keyFile = join(dir, "age.key"); keyFile = join(dir, "age.key");
execFileSync("age-keygen", ["-o", keyFile], { stdio: "pipe" }); execFileSync("age-keygen", ["-o", keyFile], { stdio: "pipe" });
recipient = execFileSync("age-keygen", ["-y", keyFile], { recipient = execFileSync("age-keygen", ["-y", keyFile], {
@ -103,11 +103,11 @@ environments:
// `declared` is the destination_uuid the state file names for this project — // `declared` is the destination_uuid the state file names for this project —
// undefined means the state file says nothing, which is every state file today. // undefined means the state file says nothing, which is every state file today.
function fixture(url: string, declared?: string) { function fixture(url: string, declared?: string) {
const checkout = mkdtempSync(join(tmpdir(), "cast-co-")); const checkout = tmp("cast-co-");
mkdirSync(join(checkout, ".infra", "env"), { recursive: true }); mkdirSync(join(checkout, ".infra", "env"), { recursive: true });
writeFileSync(join(checkout, ".infra", "manifest.yaml"), MANIFEST); writeFileSync(join(checkout, ".infra", "manifest.yaml"), MANIFEST);
const state = mkdtempSync(join(tmpdir(), "cast-state-")); const state = tmp("cast-state-");
mkdirSync(join(state, "secrets")); mkdirSync(join(state, "secrets"));
writeFileSync( writeFileSync(
join(state, ".coolify.env"), join(state, ".coolify.env"),

View file

@ -1,10 +1,10 @@
import { execFileSync, spawn } from "node:child_process"; import { execFileSync, spawn } from "node:child_process";
import { existsSync, mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; import { existsSync, mkdirSync, writeFileSync } from "node:fs";
import { createServer } from "node:http"; import { createServer } from "node:http";
import type { AddressInfo } from "node:net"; import type { AddressInfo } from "node:net";
import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { afterEach, beforeAll, describe, expect, it } from "vitest"; import { afterEach, beforeAll, describe, expect, it } from "vitest";
import { tmp } from "./helpers/tmp.js";
// The read side, end to end, against a stub Coolify shaped like a box nobody // The read side, end to end, against a stub Coolify shaped like a box nobody
// declared: its project is `Incubator` (capital I), its environment is // declared: its project is `Incubator` (capital I), its environment is
@ -31,7 +31,7 @@ const LIVE_ENV = {
let recipient: string; let recipient: string;
beforeAll(() => { beforeAll(() => {
const dir = mkdtempSync(join(tmpdir(), "cast-age-")); const dir = tmp("cast-age-");
const keyFile = join(dir, "age.key"); const keyFile = join(dir, "age.key");
execFileSync("age-keygen", ["-o", keyFile], { stdio: "pipe" }); execFileSync("age-keygen", ["-o", keyFile], { stdio: "pipe" });
recipient = execFileSync("age-keygen", ["-y", keyFile], { recipient = execFileSync("age-keygen", ["-y", keyFile], {
@ -103,14 +103,14 @@ ADMIN_EMAIL=\${ADMIN_EMAIL}
`; `;
function fixture(url: string) { function fixture(url: string) {
const checkout = mkdtempSync(join(tmpdir(), "cast-co-")); const checkout = tmp("cast-co-");
mkdirSync(join(checkout, ".infra", "env"), { recursive: true }); mkdirSync(join(checkout, ".infra", "env"), { recursive: true });
writeFileSync(join(checkout, ".infra", "manifest.yaml"), MANIFEST); writeFileSync(join(checkout, ".infra", "manifest.yaml"), MANIFEST);
writeFileSync( writeFileSync(
join(checkout, ".infra", "env", "core.staging.env.template"), join(checkout, ".infra", "env", "core.staging.env.template"),
TEMPLATE, TEMPLATE,
); );
const state = mkdtempSync(join(tmpdir(), "cast-state-")); const state = tmp("cast-state-");
mkdirSync(join(state, "secrets")); mkdirSync(join(state, "secrets"));
writeFileSync( writeFileSync(
join(state, ".coolify.env"), join(state, ".coolify.env"),

View file

@ -4,37 +4,41 @@ import {
cpSync, cpSync,
existsSync, existsSync,
mkdirSync, mkdirSync,
mkdtempSync,
readFileSync, readFileSync,
readlinkSync, readlinkSync,
realpathSync, realpathSync,
writeFileSync, writeFileSync,
} from "node:fs"; } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path"; import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { tmp } from "./helpers/tmp.js";
// The release flow (#96), proven offline. Two surfaces: the changelog-section // What remains CAST'S OWN of the release flow, after the ceremony moved
// extraction release.yml publishes (.github/scripts/release-notes.sh), and // upstream (heavy-duty/ceremony#15): the caller stubs' load-bearing shape,
// the installer's three channels — REAL install.sh runs against throwaway // the artifact hook's install contract, the drill doctrine cast's docs must
// roots, with a stub curl on PATH standing in for GitHub and a POISONED npm // not lose, and the installer's three channels — REAL install.sh runs
// proving the release channels never build. Nothing here touches the // against throwaway roots, with a stub curl on PATH standing in for GitHub
// network. (`cast --version` itself is test/version-cli.test.ts's; the // and a POISONED npm proving the release channels never build. Nothing here
// touches the network. The machinery the old halves of this file drove —
// notes extraction, arming, monotonicity, the drill gate — is tested
// upstream in ceremony's test/ and enforced here by the pinned actions in
// ci.yml. (`cast --version` itself is test/version-cli.test.ts's; the
// versioned LAYOUT every channel lands in is test/install-sh.test.ts's — // versioned LAYOUT every channel lands in is test/install-sh.test.ts's —
// here the layout is asserted only where a channel decides what fills it.) // here the layout is asserted only where a channel decides what fills it.)
const ROOT = dirname(dirname(fileURLToPath(import.meta.url))); const ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
const NOTES = join(ROOT, ".github/scripts/release-notes.sh");
function run( function run(
cmd: string, cmd: string,
args: string[], args: string[],
env: Record<string, string> = {}, env: Record<string, string> = {},
cwd?: string,
): Promise<{ code: number; output: string }> { ): Promise<{ code: number; output: string }> {
return new Promise((resolve) => { return new Promise((resolve) => {
const child = spawn(cmd, args, { const child = spawn(cmd, args, {
stdio: ["ignore", "pipe", "pipe"], stdio: ["ignore", "pipe", "pipe"],
cwd,
env: { ...process.env, ...env }, env: { ...process.env, ...env },
}); });
let output = ""; let output = "";
@ -48,243 +52,109 @@ function run(
}); });
} }
// --- release-notes.sh — the extraction release.yml publishes ---------------- // --- the ceremony callers — the stubs' load-bearing shape -------------------
// A fixture changelog carrying every boundary: an Unreleased section that // The workflow logic lives upstream at the pin; what can still break HERE is
// must never leak into a release, adjacent versions, a version that prefixes // the caller: its triggers, its permissions grant, its backend input, and the
// another (0.7.0 vs 0.7.0-rc1), and a stamped-but-empty section that must // pins themselves. Fail-closed, same discipline as the old workflow pins.
// refuse.
const FIXTURE = `# Changelog describe("the ceremony callers", () => {
Intro prose that belongs to no section.
## Unreleased
- **Not yet released** must never appear in a release body.
## 0.7.0 2026-07-20
### Added
- **The seven-oh entry** prose for 0.7.0, and only 0.7.0.
## 0.7.0-rc1 2026-07-19
- **The rc entry** must not ride along with 0.7.0.
## 0.6.0 2026-07-18
- **The six-oh entry** the previous release's prose.
## 0.5.0 2026-07-15
`;
describe("release-notes.sh", () => {
const work = mkdtempSync(join(tmpdir(), "cast-relnotes-"));
const fix = join(work, "CHANGELOG.md");
writeFileSync(fix, FIXTURE);
const notes = (ver: string, file = fix) => run("bash", [NOTES, ver, file]);
it("prints the asked-for version's section, subheaders included", async () => {
const r = await notes("0.7.0");
expect(r.code).toBe(0);
expect(r.output).toContain("The seven-oh entry");
expect(r.output).toContain("### Added");
});
it("stops at the next section and never prints a header", async () => {
const r = await notes("0.7.0");
expect(r.output).not.toContain("The rc entry");
expect(r.output).not.toContain("six-oh");
expect(r.output).not.toMatch(/^## /m);
});
it("never leaks Unreleased into a release body", async () => {
const r = await notes("0.7.0");
expect(r.output).not.toContain("Not yet released");
});
it("matches the version WHOLE — 0.7.0-rc1 is its own section", async () => {
const r = await notes("0.7.0-rc1");
expect(r.code).toBe(0);
expect(r.output).toContain("The rc entry");
expect(r.output).not.toContain("seven-oh");
});
it("an adjacent older version still resolves", async () => {
const r = await notes("0.6.0");
expect(r.code).toBe(0);
expect(r.output).toContain("six-oh");
});
it("a missing version refuses by name, citing the ritual", async () => {
const r = await notes("9.9.9");
expect(r.code).toBe(1);
expect(r.output).toContain("no section for '9.9.9'");
expect(r.output).toContain("#96");
});
it("a stamped-but-EMPTY section refuses", async () => {
const r = await notes("0.5.0");
expect(r.code).toBe(1);
expect(r.output).toContain("no section for '0.5.0'");
});
it("no version argument is a usage error", async () => {
const r = await run("bash", [NOTES]);
expect(r.code).toBe(2);
expect(r.output).toContain("usage:");
});
it("a missing changelog refuses by path", async () => {
const r = await notes("1.0.0", join(work, "nope.md"));
expect(r.code).toBe(1);
expect(r.output).toContain("no such file");
});
// The REAL changelog: the guard against header-format drift. The file has
// two legitimate states, and this test used to know only one (#108, found
// the day the first release PR turned CI red): BETWEEN releases the top
// section is `## Unreleased`; on a `release: X.Y.Z` tree — the ceremony's
// own PR stamps that heading into `## X.Y.Z — date` — and on main right
// after it, the top section IS the stamped release. Demanding the literal
// Unreleased (with an issue number inside it, rotting per release) made
// the release PR unshippable by construction, invisible to fork
// rehearsals (a tag push runs release.yml, never ci.yml). Whatever the
// top section is called, the exact tool release.yml runs must extract it
// non-empty.
it("the real CHANGELOG.md's top section extracts", async () => {
const changelog = readFileSync(join(ROOT, "CHANGELOG.md"), "utf8");
const top = changelog.match(/^## (\S+)/m);
if (!top) throw new Error("CHANGELOG.md has no ## section at all");
const r = await notes(top[1], join(ROOT, "CHANGELOG.md"));
expect(r.code).toBe(0);
expect(r.output.trim()).not.toBe("");
});
});
// --- release.yml — the wiring, pinned --------------------------------------
// The workflow itself only runs on a tag push upstream, so its load-bearing
// pieces are pinned here, fail-closed (the house discipline: the labels
// harness greps its workflow the same way).
describe("release.yml", () => {
const RY = readFileSync(join(ROOT, ".github/workflows/release.yml"), "utf8"); const RY = readFileSync(join(ROOT, ".github/workflows/release.yml"), "utf8");
it("triggers on EVERY tag — the manual fallback survives, and a mismatch must fail loudly, not be pattern-skipped", () => { it("ONE push key, both filters — a second sibling push: silently kills a door", () => {
expect(RY).toContain('tags: ["**"]'); // YAML maps are last-key-wins (grok's round-2 catch on the old
}); // workflow: the tag fallback had stopped triggering).
it("the merge door rides pushes to main — fork PR tokens are read-only (#111 r1)", () => {
// 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 — the tag create would 403 after
// green asserts. The door triggers on push to main; the doors split on
// the pushed ref; the release label — still the operator's declared
// intent — is read via the API off the merge commit's PR, and a
// transition with no labeled PR behind it refuses.
expect(RY).toContain("branches: [main]");
// YAML maps are last-key-wins: a second sibling push: key silently
// replaces the first and kills a door (grok's round-2 catch — the tag
// fallback had stopped triggering). Exactly ONE push key may exist.
expect(RY.match(/^ {2}push:$/gm)).toHaveLength(1); expect(RY.match(/^ {2}push:$/gm)).toHaveLength(1);
expect(RY).toContain("startsWith(github.ref, 'refs/tags/')"); expect(RY).toContain('tags: ["**"]');
expect(RY).toContain("github.ref == 'refs/heads/main'"); expect(RY).toContain("branches: [main]");
expect(RY).toContain("commits/$GITHUB_SHA/pulls"); // The merge door rides push, never pull_request: a fork PR's token is
expect(RY).toContain("no merged, release-labeled PR is behind this commit"); // read-only and permissions: cannot raise it (box#97).
expect(RY).not.toContain("pull_request:"); expect(RY).not.toContain("pull_request:");
}); });
it("the release re-arms main itself — the -dev bump folds into the release act", () => { it("the version backend is package-json", () => {
// Operator decision (#111 followup): the post-release bump PR was expect(RY).toContain("version-source: package-json");
// ceremony debris. Direct push with the job's token, PR fallback when
// branch protection refuses, merge-door only.
expect(RY).toContain("bump main to the next -dev");
expect(RY).toContain("opening the bump PR instead");
expect(RY).toContain("npm install --package-lock-only");
}); });
it("asserts tag == package.json version, and the assert precedes the create", () => { it("every ceremony reference in .github/ names ONE tag", () => {
expect(RY).toContain('require("./package.json").version'); // CONSUMERS.md's same-tag rule: the two workflow callers and each guard
expect(RY).toContain("creating nothing"); // step pin the same ceremony tag — one reference bumped alone leaves
expect(RY.indexOf("creating nothing")).toBeLessThan( // the repo split across ceremony versions.
RY.indexOf('gh release create "$RELEASE_VERSION"'), const all = [
"workflows/release.yml",
"workflows/labels.yml",
"workflows/ci.yml",
]
.map((f) => readFileSync(join(ROOT, ".github", f), "utf8"))
.join("\n");
const refs = [...all.matchAll(/heavy-duty\/ceremony\/[^@\s]+@(\S+)/g)].map(
(m) => m[1],
); );
expect(refs.length).toBeGreaterThanOrEqual(6); // 2 callers + 4 guards
expect(new Set(refs).size).toBe(1);
});
}); });
it("the merge path decides, then asserts, IN ORDER, all before tag-create, build, and publish", () => { // --- the artifact hook — the install contract the workflow used to carry ----
// The decide step (the fused version asserts — see the workflow's // The asset name `cast-X.Y.Z.tgz` and the staged layout are what the
// four-state table): base read from git, versions via node, work under // installer's release channels download; they never run npm or tsc, so the
// the label no-ops green, half-ceremonies refuse. Then: the shared // build happens ONCE, in the hook, and the asset is the runnable tree.
// notes extraction, the no-existing-tag/release asserts, and only then
// the acts — API-tag the merge commit, build, publish. Every marker describe("release-artifact hook", () => {
// present, strictly in file order, fail-closed. const HOOK = readFileSync(
const markers = [ join(ROOT, ".github/actions/release-artifact/action.yml"),
'git show "$BASE_SHA:package.json"', // decide — base vs merge "utf8",
// Code-unique phrasings (the workflow's own comment table paraphrases );
// these states, so the pins anchor on the echo strings, not prose):
"release-flow work under the release label, not a ceremony. Nothing to publish.", // work no-op, green it("builds the prod-only tree once and stages the runnable layout", () => {
"a dev tree is by definition not a release", // -dev endstate: always work (the bump PR no-ops green) expect(HOOK).toContain("npm ci");
"release-flow work merged in the post-release window (before the -dev bump)", // window no-op expect(HOOK).toContain("npm run build");
"Refusing to guess — creating nothing.", // bare, unchanged, unreleased: refuse expect(HOOK).toContain("npm prune --omit=dev");
".github/scripts/release-notes.sh", // assert: notes extract expect(HOOK).toContain("cp -R bin dist node_modules package.json");
'git ls-remote --exit-code origin "refs/tags/$RELEASE_VERSION"', // assert: no tag
'gh release view "$RELEASE_VERSION"', // assert: no release (the decide's own view sits earlier — count checked below)
'gh api "repos/$GITHUB_REPOSITORY/git/refs"', // act: tag the merge commit
"npm prune --omit=dev", // act: build
'gh release create "$RELEASE_VERSION"', // act: publish
];
let at = -1;
for (const m of markers) {
const i = RY.indexOf(m);
expect(i, m).toBeGreaterThan(at);
at = i;
}
}); });
it("the -dev interlock reads versions via node, never regex, and names the 0.1.0 first-release edge", () => { it("drops cast-<version>.tgz into $RELEASE_ASSETS_DIR — the channels' exact download name", () => {
expect(RY).not.toMatch(/grep.*version/); expect(HOOK).toContain('"$RELEASE_ASSETS_DIR/cast-$VERSION.tgz"');
expect(RY).toContain("node -p 'require(\"./package.json\").version'"); expect(HOOK).toContain('"$RUNNER_TEMP/stage/cast-$VERSION"');
// 0.1.0 never carried -dev, so the interlock correctly skips #110's
// ceremony — the workflow must say so where the next reader will look.
expect(RY).toContain("applies from 0.1.1");
}); });
it("tag, build, and publish happen in the SAME job — a GITHUB_TOKEN tag fires no workflows", () => { it("runs no tests — ci.yml gated the merge commit already, and the suite needs age", () => {
const jobs = RY.slice(RY.indexOf("\njobs:")).match(/^ {2}\S+:\s*$/gm) ?? []; expect(HOOK).not.toContain("npm test");
expect(jobs).toEqual([" release:"]); // one job under jobs: expect(HOOK).not.toContain("npm run check");
expect(RY).toContain("does not trigger other workflows");
expect(RY).toContain('-f "sha=$MERGE_SHA"');
}); });
it("the body comes from the shared extraction script", () => { it("owns its toolchain — setup-node moved INTO the hook; the shared workflow is node-free", () => {
expect(RY).toContain(".github/scripts/release-notes.sh"); expect(HOOK).toContain("actions/setup-node");
});
}); });
it("the release is bound to its tag (--verify-tag)", () => { // --- the drill doctrine, in cast's own docs ---------------------------------
expect(RY).toContain("--verify-tag"); // The drill-recorded GATE is ceremony's (actions/drill-recorded, tested
}); // upstream); the MEANING is cast's. The three drills are INDEPENDENT — any
// order, any schedule, separate sittings — because each pins the same fixed
// candidate refs: static identifiers that exist as soon as the release
// branches do, which is what dissolves the box<->rig recursion. The docs
// must not re-acquire an ordering rule between repos.
it("builds the prod-only tree once and attaches it as the asset", () => { describe("drills/README.md — the independent, ref-pinned drills", () => {
expect(RY).toContain("npm prune --omit=dev"); it("documents the record files and the INDEPENDENT, ref-pinned drills", () => {
expect(RY).toContain("cp -R bin dist node_modules package.json"); const doc = readFileSync(join(ROOT, "drills/README.md"), "utf8");
expect(RY).toContain("cast-$RELEASE_VERSION.tgz"); expect(doc).toContain("`<version>.md`");
}); expect(doc).toMatch(/drills are independent/i);
expect(doc).toMatch(/any order/i);
it("both trigger paths converge on the SAME asset name — one build, one tar, no per-path naming", () => { expect(doc).toMatch(/pins the same fixed set of\s+candidate\s+refs/i);
// Each path's entry step exports RELEASE_VERSION; everything downstream expect(doc).toMatch(/not sequencing/i);
// (notes, stage dir, tarball, release title) reads only that. A second // box and rig stay mutually recursive; the pinning is what makes that a
// tar or a $GITHUB_REF_NAME-named asset would be the paths drifting // non-problem, so both halves have to survive together.
// apart — the exact failure this shape exists to prevent. expect(doc).toMatch(/mutually recursive/);
expect(RY.match(/>> "\$GITHUB_ENV"/g)).toHaveLength(2); expect(doc).toContain("RIG_REF");
expect(RY.match(/tar -C/g)).toHaveLength(1); expect(doc).toMatch(/candidate refs, not released artifacts/);
expect(RY).not.toContain("cast-$GITHUB_REF_NAME"); expect(doc).toMatch(/no fixed order/i);
}); // Each repo drills a different thing — which is WHY records are per-repo.
expect(doc).toMatch(/isolation\s+contract/i);
it("runs no tests — ci.yml gated the merge commit already", () => { expect(doc).toMatch(/convergence/i);
expect(RY).not.toContain("npm test"); expect(doc).toMatch(/promotion/i);
expect(RY).not.toContain("npm run check"); // Separate records, one pinned set: each cites the shared run ID.
expect(doc).toMatch(/run ID/i);
}); });
}); });
@ -294,7 +164,7 @@ describe("release.yml", () => {
// test opts into the stub build — so any release-channel install that // test opts into the stub build — so any release-channel install that
// touches npm fails its assertion by failing the install. // touches npm fails its assertion by failing the install.
const STUB = mkdtempSync(join(tmpdir(), "cast-stub-")); const STUB = tmp("cast-stub-");
writeFileSync( writeFileSync(
join(STUB, "curl"), join(STUB, "curl"),
`#!/usr/bin/env bash `#!/usr/bin/env bash
@ -378,7 +248,7 @@ async function runInstall(
env: Record<string, string>, env: Record<string, string>,
opts: { preexistingDest?: boolean } = {}, opts: { preexistingDest?: boolean } = {},
): Promise<Install> { ): Promise<Install> {
const work = mkdtempSync(join(tmpdir(), "cast-inst-")); const work = tmp("cast-inst-");
const dest = join(work, "dest"); const dest = join(work, "dest");
const bin = join(work, "bin"); const bin = join(work, "bin");
const curlLog = join(work, "curl.log"); const curlLog = join(work, "curl.log");
@ -411,7 +281,7 @@ async function runInstall(
} }
describe("install.sh — the three channels", () => { describe("install.sh — the three channels", () => {
const work = mkdtempSync(join(tmpdir(), "cast-tarballs-")); const work = tmp("cast-tarballs-");
const asset = makeTarball(work, "9.9.9"); const asset = makeTarball(work, "9.9.9");
const mainSrc = makeTarball(work, "main"); const mainSrc = makeTarball(work, "main");
const brokenAsset = makeTarball(join(work, "broken"), "9.9.9", { const brokenAsset = makeTarball(join(work, "broken"), "9.9.9", {

View file

@ -1,5 +1,4 @@
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; import { mkdirSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { classify } from "../src/capture.js"; import { classify } from "../src/capture.js";
@ -12,6 +11,7 @@ import {
requiredSecrets, requiredSecrets,
} from "../src/resolve.js"; } from "../src/resolve.js";
import { SMOKE_KEEP_KEY, SMOKE_PROBE_KEY } from "../src/smoke.js"; import { SMOKE_KEEP_KEY, SMOKE_PROBE_KEY } from "../src/smoke.js";
import { tmp } from "./helpers/tmp.js";
// #50. Coolify injects SOURCE_COMMIT and the COOLIFY_* family itself, and SKIPS // #50. Coolify injects SOURCE_COMMIT and the COOLIFY_* family itself, and SKIPS
// its own injection of a name the resource already carries a var of // its own injection of a name the resource already carries a var of
@ -58,7 +58,7 @@ describe("the rule", () => {
// --- resolve / apply: REFUSE --------------------------------------------------- // --- resolve / apply: REFUSE ---------------------------------------------------
function checkout(template: string, envName = "staging"): string { function checkout(template: string, envName = "staging"): string {
const dir = mkdtempSync(join(tmpdir(), "infra-reserved-")); const dir = tmp("infra-reserved-");
mkdirSync(join(dir, ".infra", "env"), { recursive: true }); mkdirSync(join(dir, ".infra", "env"), { recursive: true });
writeFileSync( writeFileSync(
join(dir, ".infra", "manifest.yaml"), join(dir, ".infra", "manifest.yaml"),

View file

@ -1,6 +1,8 @@
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; import { execFileSync } from "node:child_process";
import { existsSync, mkdirSync, readdirSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { pathToFileURL } from "node:url";
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { computeDiff } from "../src/diff.js"; import { computeDiff } from "../src/diff.js";
import { DERIVED_UNRESOLVED } from "../src/envtemplate.js"; import { DERIVED_UNRESOLVED } from "../src/envtemplate.js";
@ -12,6 +14,7 @@ import {
resolveCheckout, resolveCheckout,
resolveGitAuth, resolveGitAuth,
} from "../src/resolve.js"; } from "../src/resolve.js";
import { tmp } from "./helpers/tmp.js";
describe("resolveCheckout", () => { describe("resolveCheckout", () => {
it("hard-refuses --path with prod", () => { it("hard-refuses --path with prod", () => {
@ -27,6 +30,57 @@ describe("resolveCheckout", () => {
}), }),
).toBe("/tmp/x"); ).toBe("/tmp/x");
}); });
// #117: the ephemeral checkout used to survive the process that made it, so
// every `cast apply`/`diff`/`capture` without --path left a full clone behind.
// This has to run in a real child process — the reaper is an exit hook, and
// the thing under test is precisely what happens when the process ends.
//
// A stub `git` on PATH makes it hermetic and fast: the clone fails, which is
// the *harder* case, since the directory is created before the clone runs and
// the failure path rethrows. If the dir is gone after a failed clone, the
// registration happens early enough to cover the successful one too.
it("removes the ephemeral checkout when the process exits", () => {
const bin = tmp("cast-fakebin-");
writeFileSync(join(bin, "git"), "#!/bin/sh\nexit 1\n", { mode: 0o755 });
// Other suites (and other machines) have their own checkouts lying around;
// only the one this child allocates is ours to assert on.
const preexisting = new Set(
readdirSync(tmpdir()).filter((d) => d.startsWith("infra-checkout-")),
);
const script = `
const { resolveCheckout } = await import(${JSON.stringify(
pathToFileURL(join(process.cwd(), "dist/resolve.js")).href,
)});
try { resolveCheckout("acme/widget", { env: "dev" }); } catch {}
// Report what was allocated, then let the process exit normally.
const fs = await import("node:fs");
const os = await import("node:os");
console.log(JSON.stringify(
fs.readdirSync(os.tmpdir()).filter((d) => d.startsWith("infra-checkout-")),
));
`;
const out = execFileSync(
process.execPath,
["--input-type=module", "-e", script],
{
env: { ...process.env, PATH: `${bin}:${process.env.PATH}` },
encoding: "utf8",
},
);
const allocated: string[] = JSON.parse(
out.trim().split("\n").pop() ?? "[]",
);
const mine = allocated.filter((d) => !preexisting.has(d));
// It must have allocated exactly one — otherwise this test proves nothing.
expect(mine).toHaveLength(1);
// ...and that one must be gone now that the child has exited.
expect(existsSync(join(tmpdir(), mine[0]))).toBe(false);
});
}); });
describe("resolveGitAuth", () => { describe("resolveGitAuth", () => {
@ -119,7 +173,7 @@ describe("cloneFailureMessage", () => {
describe("desiredFromManifest", () => { describe("desiredFromManifest", () => {
it("maps manifest + templates to Desired[] with resolved env", () => { it("maps manifest + templates to Desired[] with resolved env", () => {
const dir = mkdtempSync(join(tmpdir(), "infra-co-")); const dir = tmp("infra-co-");
mkdirSync(join(dir, ".infra", "env"), { recursive: true }); mkdirSync(join(dir, ".infra", "env"), { recursive: true });
writeFileSync( writeFileSync(
join(dir, ".infra", "manifest.yaml"), join(dir, ".infra", "manifest.yaml"),
@ -171,7 +225,7 @@ environments:
expect(desired[0].fields).not.toHaveProperty("start_command"); expect(desired[0].fields).not.toHaveProperty("start_command");
}); });
it("emits is_static:false when static:false is explicitly declared (a guard against a UI flip)", () => { it("emits is_static:false when static:false is explicitly declared (a guard against a UI flip)", () => {
const dir = mkdtempSync(join(tmpdir(), "infra-co-")); const dir = tmp("infra-co-");
mkdirSync(join(dir, ".infra"), { recursive: true }); mkdirSync(join(dir, ".infra"), { recursive: true });
writeFileSync( writeFileSync(
join(dir, ".infra", "manifest.yaml"), join(dir, ".infra", "manifest.yaml"),
@ -190,7 +244,7 @@ environments:
}); });
// #63: the static-site build settings a workspace monorepo needs. // #63: the static-site build settings a workspace monorepo needs.
it("emits is_static:true and the three commands for a non-compose app that declares them", () => { it("emits is_static:true and the three commands for a non-compose app that declares them", () => {
const dir = mkdtempSync(join(tmpdir(), "infra-co-")); const dir = tmp("infra-co-");
mkdirSync(join(dir, ".infra"), { recursive: true }); mkdirSync(join(dir, ".infra"), { recursive: true });
writeFileSync( writeFileSync(
join(dir, ".infra", "manifest.yaml"), join(dir, ".infra", "manifest.yaml"),
@ -226,7 +280,7 @@ environments:
// /databases/{uuid}/backups), and the side channel is what made a `backup:` // /databases/{uuid}/backups), and the side channel is what made a `backup:`
// block added to an existing database silently do nothing (#51). // block added to an existing database silently do nothing (#51).
it("puts a database backup block in fields, so it is diffed like any other", () => { it("puts a database backup block in fields, so it is diffed like any other", () => {
const dir = mkdtempSync(join(tmpdir(), "infra-co-")); const dir = tmp("infra-co-");
mkdirSync(join(dir, ".infra"), { recursive: true }); mkdirSync(join(dir, ".infra"), { recursive: true });
writeFileSync( writeFileSync(
join(dir, ".infra", "manifest.yaml"), join(dir, ".infra", "manifest.yaml"),
@ -249,7 +303,7 @@ environments:
}); });
}); });
it("leaves `backup` out of fields entirely when none is declared", () => { it("leaves `backup` out of fields entirely when none is declared", () => {
const dir = mkdtempSync(join(tmpdir(), "infra-co-")); const dir = tmp("infra-co-");
mkdirSync(join(dir, ".infra"), { recursive: true }); mkdirSync(join(dir, ".infra"), { recursive: true });
writeFileSync( writeFileSync(
join(dir, ".infra", "manifest.yaml"), join(dir, ".infra", "manifest.yaml"),
@ -271,7 +325,7 @@ environments:
expect("backup" in desired[0].fields).toBe(false); expect("backup" in desired[0].fields).toBe(false);
}); });
it("emits a service's service_domains into fields, canonicalized (cast#72)", () => { it("emits a service's service_domains into fields, canonicalized (cast#72)", () => {
const dir = mkdtempSync(join(tmpdir(), "infra-co-")); const dir = tmp("infra-co-");
mkdirSync(join(dir, ".infra"), { recursive: true }); mkdirSync(join(dir, ".infra"), { recursive: true });
writeFileSync( writeFileSync(
join(dir, ".infra", "manifest.yaml"), join(dir, ".infra", "manifest.yaml"),
@ -297,7 +351,7 @@ environments:
}); });
}); });
it("is clean for a service whose live per-container hostnames match (cast#72, no perpetual update)", () => { it("is clean for a service whose live per-container hostnames match (cast#72, no perpetual update)", () => {
const dir = mkdtempSync(join(tmpdir(), "infra-co-")); const dir = tmp("infra-co-");
mkdirSync(join(dir, ".infra"), { recursive: true }); mkdirSync(join(dir, ".infra"), { recursive: true });
writeFileSync( writeFileSync(
join(dir, ".infra", "manifest.yaml"), join(dir, ".infra", "manifest.yaml"),
@ -331,7 +385,7 @@ environments:
expect(report.clean).toBe(true); expect(report.clean).toBe(true);
}); });
it("diffs a service whose declared hostname is missing live (apply will set it)", () => { it("diffs a service whose declared hostname is missing live (apply will set it)", () => {
const dir = mkdtempSync(join(tmpdir(), "infra-co-")); const dir = tmp("infra-co-");
mkdirSync(join(dir, ".infra"), { recursive: true }); mkdirSync(join(dir, ".infra"), { recursive: true });
writeFileSync( writeFileSync(
join(dir, ".infra", "manifest.yaml"), join(dir, ".infra", "manifest.yaml"),
@ -370,7 +424,7 @@ environments:
]); ]);
}); });
it("a service with no service_domains carries only its type", () => { it("a service with no service_domains carries only its type", () => {
const dir = mkdtempSync(join(tmpdir(), "infra-co-")); const dir = tmp("infra-co-");
mkdirSync(join(dir, ".infra"), { recursive: true }); mkdirSync(join(dir, ".infra"), { recursive: true });
writeFileSync( writeFileSync(
join(dir, ".infra", "manifest.yaml"), join(dir, ".infra", "manifest.yaml"),
@ -387,7 +441,7 @@ environments:
expect(desired[0].fields).toEqual({ type: "plausible" }); expect(desired[0].fields).toEqual({ type: "plausible" });
}); });
it("resolves a dockercompose app to docker_compose_location/docker_compose_domains and no port/healthcheck/domains keys", () => { it("resolves a dockercompose app to docker_compose_location/docker_compose_domains and no port/healthcheck/domains keys", () => {
const dir = mkdtempSync(join(tmpdir(), "infra-co-")); const dir = tmp("infra-co-");
mkdirSync(join(dir, ".infra", "env"), { recursive: true }); mkdirSync(join(dir, ".infra", "env"), { recursive: true });
writeFileSync( writeFileSync(
join(dir, ".infra", "manifest.yaml"), join(dir, ".infra", "manifest.yaml"),
@ -436,7 +490,7 @@ environments:
expect(desired[0].fields).not.toHaveProperty("start_command"); expect(desired[0].fields).not.toHaveProperty("start_command");
}); });
it('warns that apply cannot enable "Include Source Commit in Build" on a dockercompose app (unsettable via the Coolify 4.1.2 API)', () => { it('warns that apply cannot enable "Include Source Commit in Build" on a dockercompose app (unsettable via the Coolify 4.1.2 API)', () => {
const dir = mkdtempSync(join(tmpdir(), "infra-co-")); const dir = tmp("infra-co-");
mkdirSync(join(dir, ".infra"), { recursive: true }); mkdirSync(join(dir, ".infra"), { recursive: true });
writeFileSync( writeFileSync(
join(dir, ".infra", "manifest.yaml"), join(dir, ".infra", "manifest.yaml"),
@ -466,7 +520,7 @@ environments:
); );
}); });
it("does not warn about the source-commit toggle for a non-dockercompose app (the build arg is a compose concern)", () => { it("does not warn about the source-commit toggle for a non-dockercompose app (the build arg is a compose concern)", () => {
const dir = mkdtempSync(join(tmpdir(), "infra-co-")); const dir = tmp("infra-co-");
mkdirSync(join(dir, ".infra"), { recursive: true }); mkdirSync(join(dir, ".infra"), { recursive: true });
writeFileSync( writeFileSync(
join(dir, ".infra", "manifest.yaml"), join(dir, ".infra", "manifest.yaml"),
@ -486,7 +540,7 @@ environments:
warn.mockRestore(); warn.mockRestore();
}); });
it("throws when the env is missing from the manifest", () => { it("throws when the env is missing from the manifest", () => {
const dir = mkdtempSync(join(tmpdir(), "infra-co-")); const dir = tmp("infra-co-");
mkdirSync(join(dir, ".infra"), { recursive: true }); mkdirSync(join(dir, ".infra"), { recursive: true });
writeFileSync( writeFileSync(
join(dir, ".infra", "manifest.yaml"), join(dir, ".infra", "manifest.yaml"),
@ -505,7 +559,7 @@ describe("derived resource refs (#60)", () => {
ref = "${resource:postgres.url}", ref = "${resource:postgres.url}",
dbBlock = " databases:\n postgres: { type: postgresql }\n", dbBlock = " databases:\n postgres: { type: postgresql }\n",
): string => { ): string => {
const dir = mkdtempSync(join(tmpdir(), "infra-co-")); const dir = tmp("infra-co-");
mkdirSync(join(dir, ".infra", "env"), { recursive: true }); mkdirSync(join(dir, ".infra", "env"), { recursive: true });
writeFileSync( writeFileSync(
join(dir, ".infra", "manifest.yaml"), join(dir, ".infra", "manifest.yaml"),
@ -580,7 +634,7 @@ describe("derived domain refs (#66)", () => {
// Assemble a manifest from an applications block plus one env template. The // Assemble a manifest from an applications block plus one env template. The
// env_template line is appended to whichever app comes last in `apps`. // env_template line is appended to whichever app comes last in `apps`.
const write = (apps: string, tmpl: string): string => { const write = (apps: string, tmpl: string): string => {
const dir = mkdtempSync(join(tmpdir(), "infra-co-")); const dir = tmp("infra-co-");
mkdirSync(join(dir, ".infra", "env"), { recursive: true }); mkdirSync(join(dir, ".infra", "env"), { recursive: true });
writeFileSync( writeFileSync(
join(dir, ".infra", "manifest.yaml"), join(dir, ".infra", "manifest.yaml"),

View file

@ -1,19 +1,13 @@
import { execFileSync, spawn } from "node:child_process"; import { execFileSync, spawn } from "node:child_process";
import { import { closeSync, mkdirSync, openSync, writeFileSync } from "node:fs";
closeSync,
mkdirSync,
mkdtempSync,
openSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { decryptSecrets, keyFileFor, secretsFileFor } from "../src/secrets.js"; import { decryptSecrets, keyFileFor, secretsFileFor } from "../src/secrets.js";
import { tmp } from "./helpers/tmp.js";
// A key file and a store encrypted to it, for the decrypt tests. // A key file and a store encrypted to it, for the decrypt tests.
function ageFixture(): { keyFile: string; enc: string } { function ageFixture(): { keyFile: string; enc: string } {
const dir = mkdtempSync(join(tmpdir(), "infra-age-")); const dir = tmp("infra-age-");
const keyFile = join(dir, "key.txt"); const keyFile = join(dir, "key.txt");
execFileSync("age-keygen", ["-o", keyFile]); execFileSync("age-keygen", ["-o", keyFile]);
const recipient = execFileSync("age-keygen", ["-y", keyFile], { const recipient = execFileSync("age-keygen", ["-y", keyFile], {
@ -70,7 +64,7 @@ describe("secretsFileFor", () => {
describe("decryptSecrets identity caching", () => { describe("decryptSecrets identity caching", () => {
it("a read-once pipe key survives two decrypts — the --all loop shape", () => { it("a read-once pipe key survives two decrypts — the --all loop shape", () => {
const { keyFile, enc } = ageFixture(); const { keyFile, enc } = ageFixture();
const dir = mkdtempSync(join(tmpdir(), "infra-fifo-")); const dir = tmp("infra-fifo-");
const fifo = join(dir, "key.fifo"); const fifo = join(dir, "key.fifo");
execFileSync("mkfifo", [fifo]); execFileSync("mkfifo", [fifo]);
// One writer, one serving of the key: exactly what a process substitution // One writer, one serving of the key: exactly what a process substitution
@ -124,7 +118,7 @@ describe("keyFileFor", () => {
// Isolate $HOME: a standing age-drill-b.key on the dev machine must not // Isolate $HOME: a standing age-drill-b.key on the dev machine must not
// turn the refusal into a hit (os.homedir() reads $HOME on POSIX). // turn the refusal into a hit (os.homedir() reads $HOME on POSIX).
const home = process.env.HOME; const home = process.env.HOME;
process.env.HOME = mkdtempSync(join(tmpdir(), "cast-home-")); process.env.HOME = tmp("cast-home-");
try { try {
expect(() => keyFileFor("drill-b")).toThrow( expect(() => keyFileFor("drill-b")).toThrow(
/no age key for drill-b.*CAST_AGE_KEY_FILE_DRILL_B.*age-drill-b\.key/s, /no age key for drill-b.*CAST_AGE_KEY_FILE_DRILL_B.*age-drill-b\.key/s,
@ -135,7 +129,7 @@ describe("keyFileFor", () => {
}); });
it("falls back to a standing key on disk when one exists", () => { it("falls back to a standing key on disk when one exists", () => {
const home = process.env.HOME; const home = process.env.HOME;
const dir = mkdtempSync(join(tmpdir(), "cast-home-")); const dir = tmp("cast-home-");
const cfg = join(dir, ".config", "cast"); const cfg = join(dir, ".config", "cast");
mkdirSync(cfg, { recursive: true }); mkdirSync(cfg, { recursive: true });
writeFileSync(join(cfg, "age-staging.key"), "AGE-SECRET-KEY-1\n"); writeFileSync(join(cfg, "age-staging.key"), "AGE-SECRET-KEY-1\n");

View file

@ -1,10 +1,10 @@
import { spawn } from "node:child_process"; import { spawn } from "node:child_process";
import { mkdtempSync, writeFileSync } from "node:fs"; import { writeFileSync } from "node:fs";
import { createServer } from "node:http"; import { createServer } from "node:http";
import type { AddressInfo } from "node:net"; import type { AddressInfo } from "node:net";
import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest"; import { afterEach, describe, expect, it } from "vitest";
import { tmp } from "./helpers/tmp.js";
// `cast smoke`, end to end, against the instance shape #29 is actually about. // `cast smoke`, end to end, against the instance shape #29 is actually about.
// //
@ -167,7 +167,7 @@ function state(
"acme/client-site": "core", "acme/client-site": "core",
}, },
): string { ): string {
const dir = mkdtempSync(join(tmpdir(), "cast-smoke-")); const dir = tmp("cast-smoke-");
writeFileSync( writeFileSync(
join(dir, ".coolify.env"), join(dir, ".coolify.env"),
`COOLIFY_BASE_URL="${url}"\nCOOLIFY_ACCESS_TOKEN="t"\n`, `COOLIFY_BASE_URL="${url}"\nCOOLIFY_ACCESS_TOKEN="t"\n`,

View file

@ -1,10 +1,10 @@
import { spawn } from "node:child_process"; import { spawn } from "node:child_process";
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; import { mkdirSync, writeFileSync } from "node:fs";
import { createServer } from "node:http"; import { createServer } from "node:http";
import type { AddressInfo } from "node:net"; import type { AddressInfo } from "node:net";
import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest"; import { afterEach, describe, expect, it } from "vitest";
import { tmp } from "./helpers/tmp.js";
// The sweep, against a stub shaped like the box that made it necessary. // The sweep, against a stub shaped like the box that made it necessary.
// //
@ -91,10 +91,10 @@ environments:
`; `;
function fixture(url: string) { function fixture(url: string) {
const checkout = mkdtempSync(join(tmpdir(), "cast-co-")); const checkout = tmp("cast-co-");
mkdirSync(join(checkout, ".infra", "env"), { recursive: true }); mkdirSync(join(checkout, ".infra", "env"), { recursive: true });
writeFileSync(join(checkout, ".infra", "manifest.yaml"), MANIFEST); writeFileSync(join(checkout, ".infra", "manifest.yaml"), MANIFEST);
const state = mkdtempSync(join(tmpdir(), "cast-state-")); const state = tmp("cast-state-");
mkdirSync(join(state, "secrets")); mkdirSync(join(state, "secrets"));
writeFileSync( writeFileSync(
join(state, ".coolify.env"), join(state, ".coolify.env"),

52
test/tmp-guard.test.ts Normal file
View file

@ -0,0 +1,52 @@
import { readFileSync, readdirSync, statSync } from "node:fs";
import { join, relative } from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
const TEST_DIR = fileURLToPath(new URL(".", import.meta.url));
// The only files allowed to call the raw API: the allocator and the per-run
// root that global setup reaps. Everything else goes through `tmp()`.
const HELPER = "helpers/tmp.ts";
const ALLOWED = new Set([HELPER, "helpers/global-setup.ts"]);
// Assembled at runtime rather than written out, so THIS file is not itself a
// hit. Exempting the guard by path instead would punch a permanent hole in the
// very check it performs.
const NEEDLE = ["mkdtemp", "Sync"].join("");
function walk(dir: string, acc: string[] = []): string[] {
for (const entry of readdirSync(dir)) {
if (entry === "node_modules") continue;
const full = join(dir, entry);
if (statSync(full).isDirectory()) walk(full, acc);
else acc.push(full);
}
return acc;
}
// The class guard the issue asks for (#117): the suite leaked 6731 temp dirs
// because cleanup was opt-in and 68 sites opted out. Making `tmp()` the only
// way to allocate is what keeps the next site clean by default — and this test
// is what stops the next raw call surviving review, the same shape as box#112's
// eof_guard_sweep. It is a source-text check on purpose: it fails at the point
// the habit returns, not after a machine-day of accumulation.
describe("temp dir allocation", () => {
it(`uses the tmp() helper everywhere — no raw ${NEEDLE} under test/`, () => {
const offenders = walk(TEST_DIR)
.filter((f) => /\.(ts|js|mjs|sh)$/.test(f))
.filter((f) => !ALLOWED.has(relative(TEST_DIR, f)))
.filter((f) => readFileSync(f, "utf8").includes(NEEDLE))
.map((f) => relative(TEST_DIR, f))
.sort();
expect(offenders).toEqual([]);
});
it("keeps the helper as the single allocation point", () => {
const helper = readFileSync(join(TEST_DIR, HELPER), "utf8");
expect(helper).toContain(NEEDLE);
// and it must actually reap
expect(helper).toContain("rmSync");
});
});

View file

@ -46,6 +46,82 @@ describe("applicationApiFields", () => {
}); });
}); });
// cast#76. The three basic-auth keys are already Coolify's own names, so the
// wire layer's job here is not translation — it is the presence rule, enforced
// before the request rather than discovered as a 422 halfway through a run.
describe("applicationApiFields — basic auth (#76)", () => {
it("passes the three basic-auth keys through untranslated", () => {
const out = applicationApiFields({
is_http_basic_auth_enabled: true,
http_basic_auth_username: "ops",
http_basic_auth_password: "s3cret",
});
expect(out).toEqual({
is_http_basic_auth_enabled: true,
http_basic_auth_username: "ops",
http_basic_auth_password: "s3cret",
});
});
it("passes a bare disable through — no credentials needed to turn it off", () => {
expect(applicationApiFields({ is_http_basic_auth_enabled: false })).toEqual(
{
is_http_basic_auth_enabled: false,
},
);
});
for (const [what, fields] of [
["no password", { http_basic_auth_username: "ops" }],
["no username", { http_basic_auth_password: "s3cret" }],
[
"an empty password",
{
http_basic_auth_username: "ops",
http_basic_auth_password: "",
},
],
["neither", {}],
] as const) {
it(`refuses to enable basic auth with ${what}`, () => {
expect(() =>
applicationApiFields({ is_http_basic_auth_enabled: true, ...fields }),
).toThrow(/refusing a partial HTTP basic auth write/);
});
}
// The hole the #76 review found, at the wire: a username-only drift produces
// a PATCH with no toggle at all, so a guard keyed on `=== true` never looked
// at it. These are the shapes apply must never hand over uncompleted.
for (const [what, fields] of [
["a lone username", { http_basic_auth_username: "ops" }],
["a lone password", { http_basic_auth_password: "s3cret" }],
[
"credentials with no toggle",
{ http_basic_auth_username: "ops", http_basic_auth_password: "s3cret" },
],
] as const) {
it(`refuses ${what} — no toggle is not an exemption`, () => {
expect(() => applicationApiFields({ ...fields })).toThrow(
/refusing a partial HTTP basic auth write/,
);
});
}
// A disable is a legitimate one-key write: it needs no credentials, and
// demanding them would make turning basic auth off impossible.
it("allows an explicit disable to travel alone", () => {
expect(() =>
applicationApiFields({ is_http_basic_auth_enabled: false }),
).not.toThrow();
});
// And a payload that says nothing about basic auth is not a basic-auth write.
it("ignores a payload that does not mention basic auth at all", () => {
expect(() => applicationApiFields({ is_static: true })).not.toThrow();
});
});
describe("databaseApiFields", () => { describe("databaseApiFields", () => {
it("maps type+version to an image and drops the type/version keys", () => { it("maps type+version to an image and drops the type/version keys", () => {
const out = databaseApiFields({ type: "postgresql", version: "17" }); const out = databaseApiFields({ type: "postgresql", version: "17" });
@ -145,6 +221,55 @@ describe("projectLiveFields", () => {
expect(out.healthcheck).toBe("/health"); expect(out.healthcheck).toBe("/health");
}); });
// cast#76 — the read side of basic auth, and the rule that the password never
// enters the comparison vocabulary at all.
it("projects the basic-auth toggle and username, and NEVER the password", () => {
const out = projectLiveFields("application", {
git_repository: "org/repo",
git_branch: "main",
build_pack: "nixpacks",
base_directory: "/",
fqdn: "https://admin.example.com",
is_http_basic_auth_enabled: true,
http_basic_auth_username: "ops",
// Even when the read DOES carry it — a sensitive-data-enabled token — it
// must not reach `fields`, because a field in `fields` is a field
// renderDiff prints.
http_basic_auth_password: "s3cret",
});
expect(out.is_http_basic_auth_enabled).toBe(true);
expect(out.http_basic_auth_username).toBe("ops");
expect(out).not.toHaveProperty("http_basic_auth_password");
expect(JSON.stringify(out)).not.toContain("s3cret");
});
it("projects a real `false` toggle — off is an answer, not an absence", () => {
const out = projectLiveFields("application", {
git_repository: "org/repo",
git_branch: "main",
build_pack: "nixpacks",
base_directory: "/",
fqdn: "https://admin.example.com",
is_http_basic_auth_enabled: 0,
});
expect(out.is_http_basic_auth_enabled).toBe(false);
// No username on the row means no username IS set — a value to diff against,
// not a field cast failed to read.
expect(out.http_basic_auth_username).toBe("");
});
it("omits both when the read carries no toggle at all", () => {
const out = projectLiveFields("application", {
git_repository: "org/repo",
git_branch: "main",
build_pack: "nixpacks",
base_directory: "/",
fqdn: "https://admin.example.com",
});
expect(out).not.toHaveProperty("is_http_basic_auth_enabled");
expect(out).not.toHaveProperty("http_basic_auth_username");
});
it("normalizes a live database's database_type to the manifest vocabulary", () => { it("normalizes a live database's database_type to the manifest vocabulary", () => {
const out = projectLiveFields("database", { const out = projectLiveFields("database", {
database_type: "standalone-postgresql", database_type: "standalone-postgresql",

View file

@ -1,2 +1,8 @@
import { defineConfig } from "vitest/config"; import { defineConfig } from "vitest/config";
export default defineConfig({ test: { include: ["test/**/*.test.ts"] } }); export default defineConfig({
test: {
include: ["test/**/*.test.ts"],
// Creates the per-run temp root and removes it when the run ends (#117).
globalSetup: ["./test/helpers/global-setup.ts"],
},
});