|
|
|
|
@ -9,627 +9,62 @@ actually cutting it, and this file starts there.
|
|
|
|
|
|
|
|
|
|
### Added
|
|
|
|
|
|
|
|
|
|
- **An application can declare HTTP basic auth, and `apply` sets it**
|
|
|
|
|
(#76) — `UNCAPTURED.md` has said for as long as it has 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." The #72 audit found that half of 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`). So an application now
|
|
|
|
|
declares it:
|
|
|
|
|
|
|
|
|
|
```yaml
|
|
|
|
|
admin:
|
|
|
|
|
basic_auth:
|
|
|
|
|
enabled: true
|
|
|
|
|
username: ops
|
|
|
|
|
password: ${ADMIN_BASIC_AUTH_PROD}
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
The password is a **store ref and only a store ref** — the schema
|
|
|
|
|
refuses a literal rather than warning about one, because a manifest is
|
|
|
|
|
a reviewed, committed artifact and a literal there is a live password
|
|
|
|
|
in git forever. 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 the run before anything is written.
|
|
|
|
|
|
|
|
|
|
Managing it is **opt-in**, the `is_static` rule for the same reason one
|
|
|
|
|
notch sharper: an unconditional `is_http_basic_auth_enabled: false`
|
|
|
|
|
would make the first apply after this ships strip the protection off
|
|
|
|
|
every app somebody enabled by hand in the UI. Omit the block to say
|
|
|
|
|
nothing, `enabled: false` to assert it is off. Enabling without both
|
|
|
|
|
credentials is refused at parse time *and* at the wire — Coolify's own
|
|
|
|
|
rule (`:2446-2463`), enforced before the request rather than discovered
|
|
|
|
|
as a 422 halfway through a run.
|
|
|
|
|
|
|
|
|
|
**The read side is fail-honest, because the three fields do not read
|
|
|
|
|
back alike.** The toggle and the username are plain columns and are
|
|
|
|
|
compared like anything else — somebody turning basic auth off in the UI
|
|
|
|
|
*is* caught, which is most of the value. The password is gated behind a
|
|
|
|
|
sensitive-data-enabled token at 4.1.2 and behind the `read:sensitive`
|
|
|
|
|
ability on v4.2 (#77), so whether it arrives depends on the token, the
|
|
|
|
|
route and the release — and it would have to be *printed*, since a
|
|
|
|
|
field diff renders as `field: <live> → <desired>`. So cast never
|
|
|
|
|
projects it into the comparison vocabulary on any box, and every diff
|
|
|
|
|
of an app declaring `basic_auth:` prints `http_basic_auth_password NOT
|
|
|
|
|
compared — verify in the Coolify UI`. Same disposition as an
|
|
|
|
|
unverifiable backup schedule: reported, not counted as drift. A read
|
|
|
|
|
returning none of the three names all three on that line instead, and
|
|
|
|
|
claims nothing at all.
|
|
|
|
|
|
|
|
|
|
The limit that follows is stated rather than hidden: rotating *only*
|
|
|
|
|
the password in the store produces no field diff and therefore no
|
|
|
|
|
write. It lands on the next apply that touches basic auth for any other
|
|
|
|
|
reason — Coolify requires both credentials on any write that enables
|
|
|
|
|
it, so cast completes the whole triple whenever it sends one.
|
|
|
|
|
|
|
|
|
|
`custom_labels` is **deliberately still absent**, though it is equally
|
|
|
|
|
settable: enabling basic auth or changing domains makes Coolify
|
|
|
|
|
regenerate an application's labels and overwrite `custom_labels` unless
|
|
|
|
|
`is_container_label_readonly_enabled`, which is itself not API-settable
|
|
|
|
|
until v4.2. Declaring both on one app would have cast silently destroy
|
|
|
|
|
what it was told to write. Basic-auth-only is the safe slice until
|
|
|
|
|
then.
|
|
|
|
|
|
|
|
|
|
- **`cast github-app create` / `cast github-app register`** (#7, superseding
|
|
|
|
|
#5) — the GitHub App was the one piece of a Coolify instance cast could
|
|
|
|
|
not reproduce: made 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 `scripts/register-github-app.sh` as a six-variable env pile.
|
|
|
|
|
Nothing about that survived in state. There is no REST endpoint that
|
|
|
|
|
creates a GitHub App — 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 your own browser session authenticates,
|
|
|
|
|
followed by an unauthenticated code exchange. That exchange is the only
|
|
|
|
|
moment GitHub ever yields the private key, the client secret and the
|
|
|
|
|
webhook secret together, and cast now persists all three to
|
|
|
|
|
`<state>/github-apps/` at 0600 under a `.gitignore` of `*`, so `git add
|
|
|
|
|
-A` in the state repo cannot commit them by accident. `register` adopts
|
|
|
|
|
credentials you already hold (an App made by hand, or a DR restore from a
|
|
|
|
|
stored PEM) and reads the client secret from **stdin only** — argv is
|
|
|
|
|
visible in `ps`. `create` does not reimplement it: it obtains credentials
|
|
|
|
|
and then calls exactly the `register` path.
|
|
|
|
|
|
|
|
|
|
#5's three footguns are gone structurally rather than by validation. The
|
|
|
|
|
Coolify-facing name is resolved from `github_apps.<org>/<repo>` in
|
|
|
|
|
`environments.yaml` — the value every later `cast apply` resolves the App
|
|
|
|
|
by — and `--name` only *seeds* an absent entry (keyed by full slug, #6),
|
|
|
|
|
and is refused outright when it disagrees with one that exists. The state
|
|
|
|
|
file is written only after the App is registered *and* verified, because a
|
|
|
|
|
state file naming an App that does not work is worse than one naming none.
|
|
|
|
|
`--webhook-secret` is optional: a webhook-inactive App is the correct shape
|
|
|
|
|
for a tailnet-only Coolify, and nobody has to invent a placeholder.
|
|
|
|
|
|
|
|
|
|
The one-shot secrets are written the instant GitHub yields them, *before*
|
|
|
|
|
`create` waits ~5 minutes for you to install the App — so a timeout, a
|
|
|
|
|
dropped connection or a `Ctrl-C` during that wait cannot destroy a private
|
|
|
|
|
key GitHub will never re-show. Until the install lands the record carries
|
|
|
|
|
`"installation_id": null`, the single field GitHub will answer again, and
|
|
|
|
|
it is backfilled on success. A name collision is refused *before* the
|
|
|
|
|
browser flow starts, when no App exists yet and nothing can be lost.
|
|
|
|
|
|
|
|
|
|
Both verbs end at the step that matters most: `GET
|
|
|
|
|
/github-apps/{id}/repositories`, asserting the repo is actually reachable.
|
|
|
|
|
Until now a misconfigured App failed silently and surfaced hours later, in
|
|
|
|
|
a different command, as an unresolvable source at `cast apply` time. When
|
|
|
|
|
that check fails its advice is to re-run `register` — which now re-verifies
|
|
|
|
|
the existing Coolify Source instead of registering a second one, because
|
|
|
|
|
Coolify does not enforce unique Source names (`GithubController@create`
|
|
|
|
|
validates `name` without `unique` and calls a plain `GithubApp::create`).
|
|
|
|
|
|
|
|
|
|
No new dependencies — `node:http` serves the callback, `node:crypto`'s
|
|
|
|
|
`createSign("RSA-SHA256")` mints the App JWT that recovers the installation
|
|
|
|
|
id from the App's own key (never from the `installation_id` GitHub appends
|
|
|
|
|
to a redirect, which GitHub documents as a spoofable hint).
|
|
|
|
|
|
|
|
|
|
**Unvalidated, and load-bearing**: that GitHub accepts a `redirect_url` on
|
|
|
|
|
`http://127.0.0.1:<port>` at all. The manifest docs are silent on the
|
|
|
|
|
scheme, the precedent (Probot's setup flow) is strong, and validating it
|
|
|
|
|
requires a logged-in GitHub session — so the first real run is an
|
|
|
|
|
operator's, and README keeps the manual browser path documented until
|
|
|
|
|
`create` has succeeded once.
|
|
|
|
|
- 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` 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 `state:needs-human` themselves when handing
|
|
|
|
|
a PR to the maintainer.
|
|
|
|
|
|
|
|
|
|
A review landing was never a trigger. There is no
|
|
|
|
|
`pull_request_review_target`, and on fork PRs — which is all of them here —
|
|
|
|
|
`pull_request_review` runs with a read-only token and cannot label anything.
|
|
|
|
|
So the exact moment the label became true, the third bot approving, fired
|
|
|
|
|
nothing at all: the label waited for the `*/15` cron, or for somebody to
|
|
|
|
|
touch an unrelated PR. And that cron does not run at its declared rate —
|
|
|
|
|
GitHub deprioritises short intervals hard enough that, measured across the
|
|
|
|
|
three repos over a two-hour window on 2026-07-20, each got **one** scheduled
|
|
|
|
|
run against the eight `*/15` implies. heavy-duty/rig#94 took its third
|
|
|
|
|
approval and sat on `state:bots-reviewing` for hours; box and cast happened
|
|
|
|
|
to catch a tick and flipped correctly, on byte-identical workflow files. The
|
|
|
|
|
lag was worst on the quietest repo, which depends on the cron most and
|
|
|
|
|
receives it least.
|
|
|
|
|
|
|
|
|
|
The two halves fix each other: the author's own label write is what fires
|
|
|
|
|
the sweep that validates it. That makes it an optimistic write rather than a
|
|
|
|
|
transfer of ownership — seconds later the reconciler either confirms the
|
|
|
|
|
label or corrects it, and the cron falls back to being a genuine last
|
|
|
|
|
resort, for the round an agent forgets to hand off. It cannot loop, because
|
|
|
|
|
the reconciler's own writes use `GITHUB_TOKEN` and GitHub does not create
|
|
|
|
|
workflow runs from `GITHUB_TOKEN`-triggered events, while agent writes use a
|
|
|
|
|
PAT and do. `labels-reconcile.sh` is unchanged: it already recomputes every
|
|
|
|
|
open PR from scratch on every run, which is exactly what makes the
|
|
|
|
|
optimistic write safe. The `scope` job is skipped on the two new actions,
|
|
|
|
|
where no path can have changed and labeler has nothing to derive.
|
|
|
|
|
|
|
|
|
|
Landed in all three repos together (heavy-duty/box#142,
|
|
|
|
|
heavy-duty/rig#97) — `labels.yml` and the label taxonomy are shared.
|
|
|
|
|
|
|
|
|
|
- **PR labels split into two axes: `state:*` (whose ball) and `blocker:*`
|
|
|
|
|
(what is in the way)** (heavy-duty/box#138) — `state:needs-rebase` is
|
|
|
|
|
retired, replaced by `blocker:conflict`, `blocker:ci-red` and
|
|
|
|
|
`blocker:unrequested`. One rule joins the axes: `state:needs-human` requires
|
|
|
|
|
zero blockers.
|
|
|
|
|
|
|
|
|
|
The entry below this one, added a day ago, closed by noting that cast had
|
|
|
|
|
not yet been bitten only because nothing had conflicted — that three PRs sat
|
|
|
|
|
at `state:needs-human` at once and would conflict through this very file the
|
|
|
|
|
moment one landed. #128 landed. A dry sweep now finds #119, #120 and #122
|
|
|
|
|
all still wearing `state:needs-human` over branches GitHub calls
|
|
|
|
|
`CONFLICTING`, which is the predicted failure arriving on schedule.
|
|
|
|
|
|
|
|
|
|
Fixing *that* is what the previous entry did. What this one fixes is the
|
|
|
|
|
shape that kept regrowing it. The single-label design projected independent
|
|
|
|
|
facts — mergeability, check status, where the review round stands — onto one
|
|
|
|
|
totally-ordered value, and a total order must pick a winner, so the rest
|
|
|
|
|
silently vanish. Every precedence bug this machine has had lived on that
|
|
|
|
|
ordering: `needs-human` surviving a conflict, `MISSING` swallowing `STALE`,
|
|
|
|
|
and `state:needs-rebase` firing on both a conflict and a red check when
|
|
|
|
|
those need opposite work — telling an agent to rebase when what it owed was
|
|
|
|
|
a bug fix. Blockers are a set. A set has no precedence between its members
|
|
|
|
|
to get wrong, and what remains on the ordered axis is purely about reviews,
|
|
|
|
|
the one place an ordering actually means something.
|
|
|
|
|
|
|
|
|
|
`state:bots-reviewing` tightens with it, to mean strictly *a request is live
|
|
|
|
|
and an answer is coming*. A ready PR nobody was asked to review used to read
|
|
|
|
|
"waiting on the reviewers" for the 48 hours it took the stale sweep to
|
|
|
|
|
notice; it is now `state:addressing` + `blocker:unrequested`, because the
|
|
|
|
|
ask is the agent's to make. Drafts are exempt, and so is an explicit human
|
|
|
|
|
request — a maintainer claiming a PR early is deliberate, not a dropped ball.
|
|
|
|
|
|
|
|
|
|
The reconciler carries a `RETIRED` array and strips `state:needs-rebase` on
|
|
|
|
|
sight, so retiring a label heals the board instead of stranding one that
|
|
|
|
|
nothing recomputes. A verdict is owed in two shapes and both raise
|
|
|
|
|
`blocker:unrequested`: `MISSING` (nobody reviewed) and `STALE` (everybody
|
|
|
|
|
reviewed an older head). Fixtures 51 → 72.
|
|
|
|
|
|
|
|
|
|
- **The `NO_API_COVERAGE` row for Basic Auth now says *services*** (#76)
|
|
|
|
|
— the blanket row covered applications and services alike, and became
|
|
|
|
|
wrong for half of them the moment applications could say `basic_auth:`.
|
|
|
|
|
What survives is a real 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 ever set them. A separate row now covers `custom_labels` on
|
|
|
|
|
applications — writable, deliberately unwired, with the overwrite
|
|
|
|
|
caveat spelled out — so neither row implies cast can express something
|
|
|
|
|
it cannot. `inventory --emit-draft` also reports, per application, an
|
|
|
|
|
app whose basic auth is enabled on the box: it emits no `basic_auth:`
|
|
|
|
|
block, because the password cannot be read and a block a rebuild cannot
|
|
|
|
|
honour is exactly the failure `UNCAPTURED.md` exists to prevent.
|
|
|
|
|
- `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
|
|
|
|
|
|
|
|
|
|
- **`scripts/register-github-app.sh`** — replaced by `cast github-app
|
|
|
|
|
register`. Kept as a thin wrapper it would have preserved exactly the
|
|
|
|
|
interface #5 catalogued as producing three live footguns, while adding a
|
|
|
|
|
second surface to keep in step with the CLI.
|
|
|
|
|
- BREAKING: `scripts/register-github-app.sh` is gone; use `cast github-app
|
|
|
|
|
register` (#7)
|
|
|
|
|
|
|
|
|
|
### Fixed
|
|
|
|
|
|
|
|
|
|
- **A PR that deletes a shipped release heading is now CI-red** (#133,
|
|
|
|
|
heavy-duty/box#122) — `.github/scripts/changelog-monotonic.sh` asserts that
|
|
|
|
|
the set of `## X.Y.Z` headings on HEAD is a superset of the set at the merge
|
|
|
|
|
base, and that no version heading appears twice. Wired into `ci.yml` on
|
|
|
|
|
every event, with `CHANGELOG_MONOTONIC_STRICT=1` and `fetch-depth: 0` so a
|
|
|
|
|
checkout that cannot reach the base ref fails loudly rather than skipping
|
|
|
|
|
quietly forever.
|
|
|
|
|
|
|
|
|
|
- **...and a duplicate heading no longer slips through on the paths where the
|
|
|
|
|
guard cannot see the base** (#133, heavy-duty/box#143) — the uniqueness half
|
|
|
|
|
is a property of HEAD alone, but it sat downstream of the base-ref,
|
|
|
|
|
merge-base and base-blob conditions, so each of those degradations returned
|
|
|
|
|
success on a tree with a duplicate in plain sight.
|
|
|
|
|
|
|
|
|
|
The base-blob case was the worst of the three because it was not a skip at
|
|
|
|
|
all: a branch that *introduces* `CHANGELOG.md` exited 0 through a bare
|
|
|
|
|
`exit 0`, on a message that was true about deletion and silent about the
|
|
|
|
|
duplicate in front of it. `STRICT=1` could not reach it — STRICT guards the
|
|
|
|
|
two `skip()` calls, and that path is not one of them. Off CI the two skips
|
|
|
|
|
had the same shape, so a shallow clone or an unpacked tarball would not look
|
|
|
|
|
at a duplicate the author was about to push.
|
|
|
|
|
|
|
|
|
|
That inverted the two halves, and it inverted them hardest here. Deletion is
|
|
|
|
|
the failure that needs a diff to see; duplication is the one cast's
|
|
|
|
|
`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 (heavy-duty/box#118).
|
|
|
|
|
The half with the live extraction bug behind it was the half with the most
|
|
|
|
|
ways to silently not run.
|
|
|
|
|
|
|
|
|
|
Fixed by moving, not rewriting: uniqueness now runs directly after the file
|
|
|
|
|
exists, before any git access. The skip messages say *containment* skipped
|
|
|
|
|
and that uniqueness already passed, so a skip no longer claims nothing was
|
|
|
|
|
checked — and the success line got the same treatment, because dropping the
|
|
|
|
|
gate made `merge_base == HEAD` a routine path rather than a degradation. On a
|
|
|
|
|
push to main containment compares the file against itself and asserts
|
|
|
|
|
nothing, so the line now reports containment *vacuous* and names uniqueness
|
|
|
|
|
as the half that ran, instead of claiming N headings were verified present by
|
|
|
|
|
a comparison that could not have detected their absence.
|
|
|
|
|
|
|
|
|
|
The guard is also no longer gated to `pull_request` — deletion is
|
|
|
|
|
vacuous on a push to main, but duplication is vacuous on no tree, so a
|
|
|
|
|
duplicate reaching main by any other route went unasserted. That gate could
|
|
|
|
|
not simply be dropped: `github.base_ref` is empty on a push, and a bare
|
|
|
|
|
`origin/` under `STRICT=1` is a hard failure on every push to main, so the
|
|
|
|
|
base ref falls back to `github.ref_name`.
|
|
|
|
|
|
|
|
|
|
Found by `claude-bot-andresmgsl` and `codex-bot-andresmgsl` reviewing #134;
|
|
|
|
|
cast inherited the ordering from box, fixed there in heavy-duty/box#144.
|
|
|
|
|
|
|
|
|
|
The failure it catches leaves no trace. An author adding an entry under
|
|
|
|
|
`## Unreleased` types *over* the heading below it instead of inserting above
|
|
|
|
|
it — a one-line edit, in a file nobody touched concurrently, so git merges it
|
|
|
|
|
cleanly with no conflict and no signal. The arming rule stays green and is
|
|
|
|
|
not wrong to: the top section is still the right one for the version. But
|
|
|
|
|
the shipped section's body is now sitting under `## Unreleased`, and the
|
|
|
|
|
version it belonged to has no section at all. Nothing surfaces until the
|
|
|
|
|
*next* release, when `release-notes.sh` cannot find the section it extracts
|
|
|
|
|
by heading — or worse, republishes the absorbed prose as if it were new.
|
|
|
|
|
|
|
|
|
|
The uniqueness half matters more here than in box. `release-notes.sh`'s awk
|
|
|
|
|
has no `exit`, so `grab` re-arms on every matching `## ` line: two
|
|
|
|
|
`## 0.1.1` headings make the published body **absorb** whatever sits between
|
|
|
|
|
the copies, and an entry stranded there is dropped from the next release's
|
|
|
|
|
notes as well. Containment alone cannot see it — a duplicate is head-side
|
|
|
|
|
surplus, and base-minus-head is blind to extras on the head side — so
|
|
|
|
|
uniqueness on HEAD is asserted alongside it. `## Unreleased` is deliberately
|
|
|
|
|
outside the guarded set: the arming rule owns that heading, and the ceremony
|
|
|
|
|
legitimately consumes it.
|
|
|
|
|
|
|
|
|
|
- **A label the repo does not have no longer takes the whole edit down with
|
|
|
|
|
it** — `gh issue edit --add-label` rejects the *entire* call on one unknown
|
|
|
|
|
name, applying nothing. Batching state and blockers into a single edit (for
|
|
|
|
|
anti-flicker) meant one missing `blocker:*` would also drop the `state:*`
|
|
|
|
|
convergence, and the taxonomy was only created by a manual
|
|
|
|
|
`workflow_dispatch` — so the first sweep after this change would have healed
|
|
|
|
|
*nothing* on precisely 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 (they are built from `has_label`, so
|
|
|
|
|
they provably exist), and an unreadable label set filters *nothing* rather
|
|
|
|
|
than everything — a failed read must not silently strip the board. A missing
|
|
|
|
|
*state* label skips only the label edit, not the rest of the PR: clearing a
|
|
|
|
|
stale `merge-next` and the staleness sweep depend on no part of the `state:*`
|
|
|
|
|
taxonomy, and a cold-start repo that skipped them would leave "merge this one
|
|
|
|
|
next" sitting on a PR the board had moved to the agent — the same false
|
|
|
|
|
invitation, one scope smaller.
|
|
|
|
|
|
|
|
|
|
- **An unreadable check rollup is no longer read as "nothing is failing"** —
|
|
|
|
|
when `gh pr view` failed, the fallback left the `statusCheckRollup` key
|
|
|
|
|
absent, and `(.statusCheckRollup // [])` collapsed that into the same `NONE`
|
|
|
|
|
as a PR that genuinely has no checks. `NONE` blocks nothing, so an API
|
|
|
|
|
hiccup presented as mergeable-by-a-human — the unknown-certified-as-green
|
|
|
|
|
shape this machine exists to stop, surviving in the one place the #128 fix
|
|
|
|
|
never looked. `checks_state` now returns `UNREADABLE` for the absent key,
|
|
|
|
|
distinct from `NONE` for a present-but-empty array, and the sweep leaves
|
|
|
|
|
that PR exactly as it is rather than recomputing on facts it did not read.
|
|
|
|
|
Deliberately *not* a blocker: blocking would flap the whole board on one bad
|
|
|
|
|
call, and the next tick is 15 minutes away.
|
|
|
|
|
|
|
|
|
|
- **`state:needs-human` no longer appears on PRs a human cannot merge**
|
|
|
|
|
(#127, heavy-duty/box#136) — `decide_state()` derived state from three inputs
|
|
|
|
|
(draft flag, requested reviewers, submitted reviews) and read *nothing* about
|
|
|
|
|
mergeability or checks. Combined with the `if requested "$HUMAN"`
|
|
|
|
|
short-circuit at the top of its precedence, the label was **sticky**: once
|
|
|
|
|
the maintainer was requested, the PR read `state:needs-human` through
|
|
|
|
|
conflicts, through red CI, through a force-push that staled every approval.
|
|
|
|
|
Nothing demoted it.
|
|
|
|
|
|
|
|
|
|
In this repo the *second* half is the live one: three PRs currently 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 exactly.
|
|
|
|
|
|
|
|
|
|
The rule the label now keeps is that **`state:needs-human` means a human
|
|
|
|
|
could merge this right now**, so anything making that false outranks the
|
|
|
|
|
request that put it there. A `CONFLICTING` branch or a failing check is the
|
|
|
|
|
agent's to fix: new `state:needs-rebase`. Approvals staled by a push mean
|
|
|
|
|
nobody reviewed this tree: `state:addressing`, because the agent owes a
|
|
|
|
|
re-request. An *unfinished* round still yields to an explicit human request —
|
|
|
|
|
a maintainer pulling a PR to themselves early is deliberate, and `MISSING`
|
|
|
|
|
(nobody has reviewed yet) is a different fact from `STALE` (everyone reviewed
|
|
|
|
|
something else). Precedence is applied to the round as a whole, after every
|
|
|
|
|
verdict is collected: deciding inside the loop let the order of `BOTS` pick
|
|
|
|
|
the answer, so a round that was *both* unfinished and staled returned on the
|
|
|
|
|
`MISSING` before any later bot's `STALE` was read — and came out
|
|
|
|
|
`needs-human` over a head nobody had reviewed, the original bug wearing a
|
|
|
|
|
different hat.
|
|
|
|
|
|
|
|
|
|
Whether a check blocks is judged by listing the outcomes that *don't* —
|
|
|
|
|
`SUCCESS`, `NEUTRAL`, `SKIPPED`, and the pending set — rather than the
|
|
|
|
|
outcomes that do. The rollup mixes two closed enums (`CheckRun.conclusion`
|
|
|
|
|
and `StatusContext.state`), and an outcome the list forgets is one the label
|
|
|
|
|
cannot certify as mergeable: `ERROR`, `CANCELLED` and `STALE` all read as
|
|
|
|
|
green under an allow-list of failures. The costs are not symmetric — 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: a re-run does not evict the run it
|
|
|
|
|
replaced, and the rollup keeps both. That shape is live on this board — this
|
|
|
|
|
PR's own tip carried two `scope` and two `reconcile` entries — and on
|
|
|
|
|
heavy-duty/box#137's tip the superseded half was `CANCELLED`, so once
|
|
|
|
|
`CANCELLED` blocks, judging every entry rather than the newest would strand
|
|
|
|
|
every re-run PR in `needs-rebase`.
|
|
|
|
|
|
|
|
|
|
Dating a run turned out to be the subtle half, and getting it wrong restored
|
|
|
|
|
the bug. A run still in flight has no completion, but `gh` does not omit the
|
|
|
|
|
field — its Go struct marshals the zero time as `"0001-01-01T00:00:00Z"`, a
|
|
|
|
|
string, which jq's `//` will not fall through. Ordering on completion
|
|
|
|
|
therefore sorted the *live* re-run below every finished one and let the
|
|
|
|
|
collapse discard it, judging the very run it superseded: a green context with
|
|
|
|
|
a replacement mid-flight read `SUCCESS` — the original bug restored, pointing
|
|
|
|
|
a human at a disabled merge button — and a `CANCELLED` original whose
|
|
|
|
|
replacement was still running read `FAILURE`, the flap the collapse exists to
|
|
|
|
|
prevent. So a run is dated by when it **began**, with both spellings of
|
|
|
|
|
absent discarded (`null`, and the zero sentinel) and a fallback only for a
|
|
|
|
|
run that never recorded a beginning — not by the newest stamp of any kind,
|
|
|
|
|
which compares the completion of a finished run against the start of a live
|
|
|
|
|
one. Those are different quantities, and a run cancelled by the concurrency
|
|
|
|
|
group does not stop the instant its replacement starts: the runner winds
|
|
|
|
|
down, so a predecessor routinely finishes *after* its successor began, and
|
|
|
|
|
dating by "newest stamp" let the dead run out-rank the live one that
|
|
|
|
|
replaced it. An entry carrying no usable timestamp at all sorts **last**
|
|
|
|
|
rather than first: something undateable is most likely the thing just
|
|
|
|
|
created, and every ambiguity here resolves toward "not settled" rather than
|
|
|
|
|
toward a stale success.
|
|
|
|
|
|
|
|
|
|
`UNKNOWN` mergeability is deliberately not treated as unmergeable: GitHub
|
|
|
|
|
reports it for about a minute after every merge while it recomputes, and
|
|
|
|
|
flapping every open PR through `needs-rebase` on each merge would be worse
|
|
|
|
|
than the bug. A failed read of either fact degrades to the same "do not know"
|
|
|
|
|
value, for the same reason.
|
|
|
|
|
|
|
|
|
|
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; it only
|
|
|
|
|
**clears** it once the PR stops being mergeable-by-a-human. Ported from
|
|
|
|
|
heavy-duty/box#137 so the three repos' reconcilers stay byte-identical; both
|
|
|
|
|
live shapes, the mixed round, the in-flight run superseding a finished one —
|
|
|
|
|
in both spellings of an absent completion, in both directions, and across the
|
|
|
|
|
wind-down window where the two overlap — and the whole check-outcome enum are
|
|
|
|
|
pinned in `test/labels-reconcile.sh` (fixtures 19 → 51).
|
|
|
|
|
|
|
|
|
|
- **CI now lints every tracked shell script, and proves the set is
|
|
|
|
|
complete** (#118) — filed as cast's record of heavy-duty/box#116, whose
|
|
|
|
|
defect is a `shopt -s globstar; files=(bin/* **/*.sh)` sweep that skips
|
|
|
|
|
`.github/` because globs do not match dot-prefixed names without
|
|
|
|
|
`dotglob`. cast's CI turned out never to have had a shellcheck step at
|
|
|
|
|
all: the only shell gate was `bash -n install.sh bin/cast scripts/*.sh
|
|
|
|
|
.github/scripts/*.sh`, a syntax check over a hand-maintained list. So the
|
|
|
|
|
reported symptom was right — `release-notes.sh` and
|
|
|
|
|
`labels-reconcile.sh` shipped unlinted — but so did every other script in
|
|
|
|
|
the repo, including `install.sh` and `bin/cast`, and `bash -n` would not
|
|
|
|
|
have caught a quoting or unset-variable bug in any of them.
|
|
|
|
|
[.github/scripts/shellcheck-all.sh](.github/scripts/shellcheck-all.sh)
|
|
|
|
|
now runs `shellcheck -x` over the tracked tree, from CI and from
|
|
|
|
|
`npm run check:shell`. It takes its file list from `git ls-files` rather
|
|
|
|
|
than from a glob. `shopt -s globstar dotglob` was measured and does work
|
|
|
|
|
here — cast's dependency tree ships no `.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 found by shebang, which is how
|
|
|
|
|
`bin/cast` is covered without being named. All seven scripts passed 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 as such, so this lands as a no-op on behavior. It ships
|
|
|
|
|
with 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, so a
|
|
|
|
|
future sweep that quietly narrows is red rather than green over nothing.
|
|
|
|
|
- **The shellcheck sweep's own blind spot: extensionless scripts** (#121) —
|
|
|
|
|
the class check above asserts the swept set covers `git ls-files '*.sh'`,
|
|
|
|
|
which says nothing about scripts with no `.sh` extension. `bin/cast` is
|
|
|
|
|
one, and it enters the set only through the shebang scan — covered by the
|
|
|
|
|
derivation, not by the assertion. Break that scan and the shipped
|
|
|
|
|
entrypoint drops out of the lint while the check still exits 0: #118's
|
|
|
|
|
failure mode one level in. The sweep now also asserts a named floor of
|
|
|
|
|
known extensionless scripts, verified to go red when the shebang branch is
|
|
|
|
|
broken. Fixed alongside it: `IFS= read -r line <"$f" || continue` skipped
|
|
|
|
|
any file whose first line had no trailing newline, because `read` returns
|
|
|
|
|
1 at EOF even having populated `line` — a shebang-only file with no final
|
|
|
|
|
newline was silently unswept.
|
|
|
|
|
|
|
|
|
|
- **`cast` no longer leaves a full repo clone in the temp dir on every run**
|
|
|
|
|
(#117) — `resolveCheckout()` mkdtemps an `infra-checkout-` directory and
|
|
|
|
|
`git clone`s the infra repo into it, and nothing ever removed it. Every
|
|
|
|
|
`cast apply`, `diff`, or `capture` invoked *without* `--path` — the normal
|
|
|
|
|
way to run all three — left a shallow clone behind permanently. This is a
|
|
|
|
|
runtime leak, not a test one: #117 was filed as test-suite hygiene and
|
|
|
|
|
explicitly scoped the runtime out ("`cast` itself does not leak"), but the
|
|
|
|
|
box that found it was also holding 602 `infra-checkout-*` directories,
|
|
|
|
|
73 MB of real `.git` trees, from the same day. The leak fires on the
|
|
|
|
|
failure path too, since the directory is created before the clone runs.
|
|
|
|
|
Ephemeral checkouts are now registered and removed on process exit, which
|
|
|
|
|
is the lifetime that fits: the tree has to outlive `resolveCheckout`'s
|
|
|
|
|
return — every caller reads it — so a `finally` would delete the checkout
|
|
|
|
|
out from under the command that asked for it. A `--path` checkout is the
|
|
|
|
|
operator's own working tree and is never registered.
|
|
|
|
|
|
|
|
|
|
- **The test suite reaps its temp directories** (#117) — 68 `mkdtempSync`
|
|
|
|
|
call sites across 21 files, zero cleanups, accumulating ~6700 directories
|
|
|
|
|
and 189 MB per machine-day, some holding age keys. All 68 now go through a
|
|
|
|
|
single `tmp()` helper (`test/helpers/tmp.ts`) that allocates inside a
|
|
|
|
|
per-run root, which vitest's `globalSetup` teardown removes wholesale. A
|
|
|
|
|
class-guard test (`test/tmp-guard.test.ts`) fails if `mkdtempSync` appears
|
|
|
|
|
anywhere under `test/` outside those helpers, so the next raw call is
|
|
|
|
|
caught at review rather than after a day of accumulation.
|
|
|
|
|
- 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 the changelog, and CI notices when it
|
|
|
|
|
doesn't** (#113) — stamping `## Unreleased` into `## X.Y.Z — DATE` is
|
|
|
|
|
done by hand in the ceremony PR; no workflow writes this file, and
|
|
|
|
|
nothing put the heading back. So `main` sat with the shipped section on
|
|
|
|
|
top and no `## Unreleased` above it — this repo's state from 0.1.0
|
|
|
|
|
until this entry. A PR authored before a release and merged after has
|
|
|
|
|
its entry land under whatever heading now occupies that position: the
|
|
|
|
|
release that already shipped. Git does that *cleanly*. The stamped
|
|
|
|
|
heading and the incoming entry never overlap textually, so the one
|
|
|
|
|
signal an author trusts — "git told me to look" — is missing exactly
|
|
|
|
|
when the result is wrong. rig watched it happen (heavy-duty/rig#66, the
|
|
|
|
|
origin of this fix): an entry landed inside published `## 0.1.0` an
|
|
|
|
|
hour after 0.1.0 shipped, and was caught only because someone was
|
|
|
|
|
reading. The published release body is never at risk — `release.yml`
|
|
|
|
|
extracts notes from the tree at the tag, before anything late can merge
|
|
|
|
|
— which is also why nobody notices: the file that drifts is the one
|
|
|
|
|
only maintainers read. Three moves. `## Unreleased` is back above
|
|
|
|
|
`## 0.1.0` (this entry re-creating it *is* the repair). CONTRIBUTING's
|
|
|
|
|
ceremony step now re-arms in the same diff that stamps. And
|
|
|
|
|
`test/release.test.ts` keys the rule to `package.json`: a stamped top
|
|
|
|
|
section is legal while the version is bare — the ceremony's own tree,
|
|
|
|
|
and main until the `-dev` bump — but once the version says `-dev`, the
|
|
|
|
|
top section must be `## Unreleased`. That is the distinction #108 had
|
|
|
|
|
to collapse to make the ceremony shippable at all, recovered rather
|
|
|
|
|
than reverted: the ceremony stays green at every step, and a disarmed
|
|
|
|
|
dev `main` goes red. The re-arm also forced the older extraction guard
|
|
|
|
|
to move. It asserted that the **top** section extracts non-empty, which
|
|
|
|
|
the re-armed ceremony tree — a deliberately empty `## Unreleased` above
|
|
|
|
|
the stamp — makes false by construction: the re-arm and the guard would
|
|
|
|
|
have contradicted each other, and the next release PR would have been
|
|
|
|
|
unshippable for a second time, the way #108 was. Keying to the top
|
|
|
|
|
section was only ever a stand-in for "the section `release.yml` will
|
|
|
|
|
publish", so the assert now names that section directly — on a bare
|
|
|
|
|
version the `## X.Y.Z` being shipped, on a `-dev` tree the newest
|
|
|
|
|
stamped one. Existence is checked with it: a bare version with no
|
|
|
|
|
matching section is a bump that never stamped, which used to pass every
|
|
|
|
|
test and fail only *after* the merge, in `release.yml`'s notes step,
|
|
|
|
|
past the ship decision and leaving `main` with a minted, unreleased
|
|
|
|
|
version to repair by hand. A double re-arm — two `## Unreleased`
|
|
|
|
|
headings, the extracted section silently the empty one — is red too.
|
|
|
|
|
box and rig carry the same fix (heavy-duty/box#110,
|
|
|
|
|
heavy-duty/rig#67); rig#67 retargeted the identical assert for the
|
|
|
|
|
identical reason.
|
|
|
|
|
- The release ceremony re-arms `## Unreleased`, and CI is red when it does not
|
|
|
|
|
(#113)
|
|
|
|
|
|
|
|
|
|
## 0.1.0 — 2026-07-19
|
|
|
|
|
|
|
|
|
|
### Fixed
|
|
|
|
|
|
|
|
|
|
- **The release suite accepts the ceremony's own tree** (#108) —
|
|
|
|
|
`test/release.test.ts` demanded the real `CHANGELOG.md`'s literal
|
|
|
|
|
`Unreleased` section extract non-empty and contain `#96`: false by
|
|
|
|
|
construction on the `release: X.Y.Z` tree the ceremony's own PR produces
|
|
|
|
|
(it stamps that heading into `## X.Y.Z — date`), so the first real
|
|
|
|
|
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.
|
|
|
|
|
- The release suite accepts the ceremony's own tree (#108)
|
|
|
|
|
- `apply` no longer demands a GitHub App for a manifest that declares no
|
|
|
|
|
applications (#103)
|
|
|
|
|
- A manifest with no `${…}` refs applies without a secret store (#104)
|
|
|
|
|
- `CAST_AGE_KEY_FILE_<ENV>` is settable for every environment name (#102)
|
|
|
|
|
|
|
|
|
|
### Added
|
|
|
|
|
|
|
|
|
|
- **Merging a release-labeled PR is the release — and the release re-arms
|
|
|
|
|
main itself** (#111; box#96's design) — `release.yml` now also fires on
|
|
|
|
|
pushes to main (not `pull_request` events: fork-sourced ceremony PRs get
|
|
|
|
|
a read-only token there — the round-1 catch). A decide step reads the
|
|
|
|
|
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.
|
|
|
|
|
- Merging a release-labeled PR is the release, and the release re-arms main
|
|
|
|
|
itself (#111)
|
|
|
|
|
- Tagged releases with a prebuilt dist asset, and an installer that installs
|
|
|
|
|
them (#96)
|
|
|
|
|
|