Compare commits
No commits in common. "main" and "0.1.0" have entirely different histories.
68 changed files with 1371 additions and 6555 deletions
|
|
@ -1,49 +0,0 @@
|
|||
# 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.
|
||||
|
|
@ -1,76 +0,0 @@
|
|||
# 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.
|
||||
|
|
@ -1,98 +0,0 @@
|
|||
# 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.
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
# .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).
|
||||
|
|
@ -1,70 +0,0 @@
|
|||
# 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.
|
||||
|
|
@ -1,95 +0,0 @@
|
|||
# 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 2–3 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.
|
||||
39
.github/actions/release-artifact/action.yml
vendored
39
.github/actions/release-artifact/action.yml
vendored
|
|
@ -1,39 +0,0 @@
|
|||
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
4
.github/labeler.yml
vendored
|
|
@ -1,5 +1,5 @@
|
|||
# path → scope:* map for actions/labeler — the PR half of .ceremony/LABELS.md's
|
||||
# scope story (issues are hand-scoped at triage; paths only exist on PRs). Additive
|
||||
# path → scope:* map for actions/labeler — the PR half of LABELS.md's 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.
|
||||
"scope:capture":
|
||||
- changed-files:
|
||||
|
|
|
|||
7
.github/labels.conf
vendored
7
.github/labels.conf
vendored
|
|
@ -1,7 +0,0 @@
|
|||
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
|
||||
228
.github/scripts/labels-reconcile.sh
vendored
Normal file
228
.github/scripts/labels-reconcile.sh
vendored
Normal file
|
|
@ -0,0 +1,228 @@
|
|||
#!/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
|
||||
31
.github/scripts/release-notes.sh
vendored
Normal file
31
.github/scripts/release-notes.sh
vendored
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
#!/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
122
.github/scripts/shellcheck-all.sh
vendored
|
|
@ -1,122 +0,0 @@
|
|||
#!/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"
|
||||
43
.github/workflows/ci.yml
vendored
43
.github/workflows/ci.yml
vendored
|
|
@ -10,12 +10,6 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- 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
|
||||
with:
|
||||
node-version: "22"
|
||||
|
|
@ -28,41 +22,8 @@ jobs:
|
|||
- run: npm test
|
||||
- name: installer is valid bash
|
||||
run: bash -n install.sh bin/cast scripts/*.sh .github/scripts/*.sh
|
||||
# `bash -n` above is a syntax check on a hand-maintained list; it is not
|
||||
# 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
|
||||
- name: labels state-machine tests
|
||||
run: bash test/labels-reconcile.sh
|
||||
|
||||
# The installer, proven by RUNNING it — CAST_INSTALL_SOURCE points it at
|
||||
# this checkout, so CI proves the installer under review (the versioned
|
||||
|
|
|
|||
59
.github/workflows/labels.yml
vendored
59
.github/workflows/labels.yml
vendored
|
|
@ -1,19 +1,56 @@
|
|||
name: labels
|
||||
# The automation LABELS.md promises, now implemented upstream
|
||||
# (heavy-duty/ceremony — ceremony#15 is this conversion): scope labeling and
|
||||
# the state reconciler live in the reusable workflow this caller pins. Cast
|
||||
# keeps the triggers and permissions (a called workflow cannot define them),
|
||||
# its path map in .github/labeler.yml, and its panel + scope taxonomy in
|
||||
# .github/labels.conf.
|
||||
# The automation LABELS.md promises. Two halves:
|
||||
# scope — path-derived scope:* labels on PRs (actions/labeler)
|
||||
# reconcile — the state:* machine + the stale sweep (.github/scripts/labels-reconcile.sh)
|
||||
#
|
||||
# pull_request_target, not pull_request: every PR here arrives from a fork,
|
||||
# where pull_request (and pull_request_review) run with a READ-ONLY token and
|
||||
# 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:
|
||||
schedule: [{cron: "*/15 * * * *"}] # advisory; the handoff label is the real wake
|
||||
workflow_dispatch: # bootstraps missing labels on a fresh repo
|
||||
schedule:
|
||||
- cron: "*/15 * * * *"
|
||||
workflow_dispatch: # also bootstraps missing labels — run once on a fresh repo
|
||||
pull_request_target:
|
||||
types: [opened, reopened, ready_for_review, converted_to_draft, synchronize, labeled, unlabeled]
|
||||
types: [opened, reopened, ready_for_review, converted_to_draft, synchronize]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
labels:
|
||||
uses: heavy-duty/ceremony/.github/workflows/labels.yml@0.1.0
|
||||
scope:
|
||||
if: github.event_name == 'pull_request_target'
|
||||
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
|
||||
|
|
|
|||
260
.github/workflows/release.yml
vendored
260
.github/workflows/release.yml
vendored
|
|
@ -1,24 +1,250 @@
|
|||
name: release
|
||||
# The ceremony moved upstream (heavy-duty/ceremony — the doctrine is its
|
||||
# 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
|
||||
# called workflow cannot define (triggers and permissions) plus its one
|
||||
# genuinely local piece: the prebuilt-asset build, now the artifact hook at
|
||||
# .github/actions/release-artifact/, which both doors invoke.
|
||||
# Triggers and permissions MUST live here (a called workflow cannot define them):
|
||||
# The release publisher (#96; box#83's design) — two ways in, one act (#111;
|
||||
# box#96's design):
|
||||
#
|
||||
# - Merging a `release`-labeled PR into main IS the release. The ceremony
|
||||
# PR carries the bumped version and the stamped changelog; the
|
||||
# maintainer's merge is the ship decision, and tagging after it is
|
||||
# transcription — exactly where humans err silently and machines fail
|
||||
# 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:
|
||||
# ONE push key, both filters — YAML maps are last-key-wins; a second sibling
|
||||
# `push:` silently replaces the first and kills a door (rig's review catch).
|
||||
# ONE push key, both filters — YAML maps are last-key-wins, so a second
|
||||
# sibling `push:` would silently REPLACE the first and kill a door
|
||||
# (grok's round-2 catch: the tag fallback had stopped triggering).
|
||||
push:
|
||||
tags: ["**"] # every tag — a wrong tag must FAIL the assert loudly,
|
||||
# never be skipped by a shape filter that didn't match
|
||||
# Every tag, not a shape filter (box's and rig's precedent): a tag that
|
||||
# mismatches package.json — a habitual v0.1.0, a typo — must fail the
|
||||
# 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]
|
||||
|
||||
permissions:
|
||||
contents: write # tag ref create + release create + the bump push
|
||||
pull-requests: write # decide's label read; the bump-fallback `gh pr create`
|
||||
issues: write # --label on that fallback PR rides the issues API
|
||||
contents: write # tag create via the API + gh release create + the bump push
|
||||
# Two consumers (labels.yml precedent — a declared permissions: block
|
||||
# zeroes every unspecified scope): the decide step's label read
|
||||
# (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:
|
||||
release:
|
||||
uses: heavy-duty/ceremony/.github/workflows/release.yml@0.1.0
|
||||
with:
|
||||
version-source: package-json
|
||||
# Tag pushes and main pushes both enter (the asserts below are the
|
||||
# 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:
|
||||
# Either door: the pushed ref — a tag, or main's new head (the
|
||||
# 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
|
||||
|
|
|
|||
|
|
@ -1,7 +0,0 @@
|
|||
# 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.
|
||||
167
CHANGELOG.md
167
CHANGELOG.md
|
|
@ -5,75 +5,112 @@ since its first commit, but grew its release surface (this file,
|
|||
`cast --version`, tagged releases with a prebuilt asset) on the way to
|
||||
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
|
||||
|
||||
### Fixed
|
||||
|
||||
- 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)
|
||||
- **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.
|
||||
|
||||
### Added
|
||||
|
||||
- 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)
|
||||
- **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.
|
||||
|
|
|
|||
165
CONTRIBUTING.md
165
CONTRIBUTING.md
|
|
@ -1,114 +1,97 @@
|
|||
# Contributing
|
||||
|
||||
This repo is governed by
|
||||
[heavy-duty/ceremony](https://github.com/heavy-duty/ceremony). **Agents:
|
||||
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.
|
||||
How change lands in this repo. The short version: PRs are born as drafts,
|
||||
three reviewer bots take the first rounds, a human takes the last word — and
|
||||
labels tell you where everything is without opening anything.
|
||||
|
||||
## The PR loop, cast specifics
|
||||
## The PR loop
|
||||
|
||||
1. **Fork and branch.** Contributors work from forks; upstream branches are
|
||||
for maintainers. Title the PR conventionally (`feat:`, `fix:`, `docs:`).
|
||||
2. **The review panel** (`.github/labels.conf`'s `panel=` line):
|
||||
`claude-bot-andresmgsl`, `codex-bot-andresmgsl`, `grok-bot-andresmgsl`,
|
||||
`kimi-bot-andresmgsl` —
|
||||
the required verdicts for a PR are the panel minus its author. The
|
||||
maintainer (`danmt`) takes the last word and merges.
|
||||
3. **Checks must be green**: `npm run check`, `npm run build`, and
|
||||
`npm test` locally mirror what CI runs, plus the shellcheck sweep
|
||||
(`npm run check:shell`). The release guards (`changelog-armed`,
|
||||
`changelog-monotonic`, `drill-recorded`, `docs-sync`) run as ceremony's
|
||||
pinned actions.
|
||||
4. **Feature PRs land their changelog entry as part of the PR**: add it
|
||||
under `CHANGELOG.md`'s `## Unreleased` heading — that section becomes
|
||||
the release notes verbatim when a release is cut.
|
||||
|
||||
## Changelog entries
|
||||
|
||||
Every PR that changes behaviour adds one line to `## Unreleased`. One line is
|
||||
the whole rule — if it wraps more than twice in your editor, cut it down.
|
||||
|
||||
- **Say what changed, and stop.** Why it was wrong, how it was found, what it
|
||||
cost, what it implies — that belongs in the PR body and the commit message,
|
||||
which is where anyone chasing the reasoning already goes. This file answers
|
||||
one question: what is different in this version.
|
||||
- **Any word that can be removed, is removed.**
|
||||
- **Lead with the surface, not the mechanism.** "`state:needs-human` is set at
|
||||
handoff" beats "the labels workflow now also wakes on `labeled`".
|
||||
- **Cite the issue or PR** — `(#131)` — and let the reader follow it for the
|
||||
rest.
|
||||
- **Mark a breaking change** with a leading `BREAKING:`.
|
||||
- Group under `### Added` / `### Changed` / `### Fixed` / `### Removed`.
|
||||
- No bold run-in headings, no sub-paragraphs, no code blocks, no prose essays.
|
||||
|
||||
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 — ...
|
||||
2. **Open as a draft** while you build. Drafts are invisible to the reviewer
|
||||
bots on purpose.
|
||||
3. **When it's ready**: mark ready-for-review and request all three bots —
|
||||
`claude-bot-andresmgsl`, `codex-bot-andresmgsl`, `grok-bot-andresmgsl`.
|
||||
They poll roughly every 15 minutes.
|
||||
4. **Rounds are answered whole.** Wait until all three have reviewed, then
|
||||
answer the entire round in a **single reply**, push the fixes, and
|
||||
re-request the bots that didn't approve. Prefer verification over
|
||||
argument: a test settles what a comment thread can't.
|
||||
5. **Reviews end in a verdict.** A reviewer — bot or human — either
|
||||
**approves** or **requests changes**, never a bare comment. A
|
||||
comment-only review is a non-verdict: it doesn't say whether the round
|
||||
passed, and the state machine (and anyone scanning the board) has to
|
||||
guess. The verdict carries *blockingness only*, the body carries the
|
||||
feedback: non-blocking nits ride an **approval** and the author addresses
|
||||
them at their discretion; anything blocking — including a question that
|
||||
gates the verdict — is **request changes**, saying what unblocks it. The
|
||||
reconciler treats a comment-only review as not-approved, so commenting
|
||||
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
|
||||
judgment belongs to the **author** — escalate by requesting the
|
||||
maintainer's review (step 6), and the reconciler flips the label on that
|
||||
request, because an explicit request is a fact it can trust.
|
||||
6. **When the round passes, the author hands the PR to the maintainer** by
|
||||
requesting their review — that request is what flips `state:needs-human`.
|
||||
With three formal head-current approvals the labels workflow requests it
|
||||
automatically; when part of the panel is comment-only, reading their
|
||||
agreement is the author's judgment, so the author makes the request.
|
||||
7. **Checks must be green**: `npm run check`, `npm run build`, and
|
||||
`npm test` locally mirror what CI runs.
|
||||
8. **Feature PRs land their changelog entry as part of the PR** (box's
|
||||
convention): add it under `CHANGELOG.md`'s `## Unreleased` heading —
|
||||
that section becomes the release notes verbatim when a release is cut.
|
||||
|
||||
## Releasing
|
||||
|
||||
A release is a PR, and merging it is the release. The ceremony — the two
|
||||
doors, the decide table, the stamps, the post-release re-arm — is
|
||||
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`.
|
||||
A release is a PR, and merging it IS the release
|
||||
([#111](https://github.com/heavy-duty/cast/issues/111); box#96's design,
|
||||
on box#83's shape):
|
||||
|
||||
What stays cast's beyond that pin:
|
||||
|
||||
- **The prebuilt asset** —
|
||||
[`.github/actions/release-artifact/`](.github/actions/release-artifact/action.yml),
|
||||
the artifact hook both doors invoke: the build happens ONCE, in CI, and
|
||||
`cast-X.Y.Z.tgz` is the runnable tree (`bin/`, `dist/`, production
|
||||
`node_modules/`, `package.json`). That asset is what the installer's
|
||||
release channels download — never a source tarball, never an
|
||||
operator-machine build.
|
||||
- **The drill** — the real-hardware gate before the handoff of a release
|
||||
PR. Cast's drill asserts **promotion**: two live Coolify instances, the
|
||||
full A→B run (team, apply, an idempotent diff, smoke, inventory,
|
||||
emit-draft, fleet, destroy, the read-only guard) — A→B reproduces, and
|
||||
the diff is idempotent. The full meaning — the fixed candidate-ref
|
||||
pinning that dissolves the box↔rig recursion, the per-version record
|
||||
files, the waiver rule — is [`drills/README.md`](drills/README.md); the
|
||||
`drill-recorded` guard enforces the record on every release tree.
|
||||
1. A small PR — `release: X.Y.Z`, labeled `release` — bumps `package.json`'s
|
||||
`version` (and `package-lock.json`; `npm install --package-lock-only`
|
||||
keeps them in step) and stamps `CHANGELOG.md`'s Unreleased section as
|
||||
`## X.Y.Z — YYYY-MM-DD`. CI green on it, same loop as any PR.
|
||||
2. **Merge. That's the ship decision — nothing else to do.**
|
||||
[release.yml](.github/workflows/release.yml) fires on the merged,
|
||||
`release`-labeled PR and asserts, in order, each fail-loud and creating
|
||||
nothing: the merged version is non-`-dev`; the version *changed in this
|
||||
PR* (the `-dev` transition is the interlock — a mislabeled ordinary PR
|
||||
fails here); that version's changelog section extracts non-empty
|
||||
([.github/scripts/release-notes.sh](.github/scripts/release-notes.sh));
|
||||
and no tag or release exists for it yet. Then, in the same job, it tags
|
||||
the merge commit bare `X.Y.Z` (no `v` prefix — box's tag scheme), builds
|
||||
the package once (`npm ci && npm run build && npm prune --omit=dev`), and
|
||||
publishes the release with the runnable tree — `bin/`, `dist/`,
|
||||
production `node_modules/`, `package.json` — attached as
|
||||
`cast-X.Y.Z.tgz`. That asset is what the installer's release channels
|
||||
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
|
||||
|
||||
The taxonomy and state machine are
|
||||
[`.ceremony/LABELS.md`](.ceremony/LABELS.md); cast's `scope:*` rows live in
|
||||
`.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:
|
||||
The full taxonomy lives in [LABELS.md](LABELS.md). 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 |
|
||||
|---|---|
|
||||
| `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. |
|
||||
| `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. |
|
||||
| `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 issues | you, when opening or triaging — issues have no paths to derive from. |
|
||||
| `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. |
|
||||
|
||||
## Issues
|
||||
|
|
|
|||
87
LABELS.md
Normal file
87
LABELS.md
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
# 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
168
README.md
|
|
@ -119,9 +119,6 @@ cast inventory <org>/<repo> --env <env>
|
|||
cast inventory --env <env> [--emit-draft <dir> [--recipient age1…] [--no-secrets]]
|
||||
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 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 team [--env <env>]
|
||||
```
|
||||
|
|
@ -169,12 +166,6 @@ cast team [--env <env>]
|
|||
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.
|
||||
- **`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
|
||||
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,
|
||||
|
|
@ -217,141 +208,6 @@ 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
|
||||
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
|
||||
|
||||
`--instance <name>` reads `<state>/.coolify/<name>.env` instead of
|
||||
|
|
@ -541,19 +397,6 @@ standing over nothing is worse than no guard, because it reads like one.
|
|||
> prints in a diff like any literal. Applications only. See
|
||||
> [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
|
||||
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
|
||||
|
|
@ -726,10 +569,8 @@ whose secrets were silently skipped looks complete and holds not one value.
|
|||
**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),
|
||||
service hostnames (they live per-container on `service.applications[].fqdn`),
|
||||
Basic Auth on a *service* and custom Traefik labels anywhere (an application's
|
||||
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
|
||||
Basic Auth and custom Traefik labels, 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.
|
||||
|
||||
A blueprint that omits these **without saying so** is worse than no blueprint,
|
||||
|
|
@ -1147,9 +988,8 @@ the way back to zero from a half-applied first run.
|
|||
|
||||
## Scripts
|
||||
|
||||
Operational helpers, all argument-driven (`scripts/`): restore a database backup
|
||||
into a target container. (`register-github-app.sh` is gone — it is
|
||||
`cast github-app register` now.)
|
||||
Operational helpers, all argument-driven (`scripts/`): register a GitHub App with
|
||||
Coolify, restore a database backup into a target container.
|
||||
|
||||
**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
|
||||
|
|
|
|||
1
bin/cast
1
bin/cast
|
|
@ -263,7 +263,6 @@ command -v node >/dev/null 2>&1 || {
|
|||
}
|
||||
|
||||
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
|
||||
exit 1
|
||||
fi
|
||||
|
|
|
|||
|
|
@ -291,105 +291,6 @@ 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
|
||||
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_*`)
|
||||
|
||||
Coolify injects a set of values into an application's runtime environment
|
||||
|
|
@ -1162,11 +1063,8 @@ 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
|
||||
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
|
||||
reports and keeps going), Basic Auth on a SERVICE and custom Traefik labels
|
||||
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
|
||||
reports and keeps going), Basic Auth / custom Traefik labels,
|
||||
build and deploy command overrides, what a backup schedule cannot fully say
|
||||
(the schedule itself **is captured** since #75 — the draft reads
|
||||
`GET /databases/{uuid}/backups`, the route `diff`/`apply` have used since #51,
|
||||
and a single enabled schedule becomes a real `backup: { frequency, retention }`
|
||||
|
|
|
|||
|
|
@ -1,62 +0,0 @@
|
|||
# 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
145
drills/README.md
|
|
@ -1,145 +0,0 @@
|
|||
# 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.
|
||||
|
|
@ -410,10 +410,6 @@ profile_for_shell() {
|
|||
}
|
||||
|
||||
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
|
||||
*/config.fish) printf 'fish_add_path %s\n' "$BINDIR" ;;
|
||||
*) printf 'export PATH="%s:$PATH"\n' "$BINDIR" ;;
|
||||
|
|
@ -431,7 +427,6 @@ else
|
|||
log "$PROFILE already puts $BINDIR on PATH — this shell just predates it."
|
||||
else
|
||||
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" \
|
||||
|| die "could not write $PROFILE — add this line yourself: $(path_line_for "$PROFILE")"
|
||||
log "wired $BINDIR onto PATH in $PROFILE"
|
||||
|
|
|
|||
4
package-lock.json
generated
4
package-lock.json
generated
|
|
@ -1,12 +1,12 @@
|
|||
{
|
||||
"name": "cast",
|
||||
"version": "0.2.1-dev",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "cast",
|
||||
"version": "0.2.1-dev",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"yaml": "^2.5.0",
|
||||
"zod": "^3.25.76"
|
||||
|
|
|
|||
13
package.json
13
package.json
|
|
@ -1,20 +1,15 @@
|
|||
{
|
||||
"name": "cast",
|
||||
"version": "0.2.1-dev",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"engines": {
|
||||
"node": ">=22.12.0"
|
||||
},
|
||||
"bin": {
|
||||
"cast": "./dist/cli.js"
|
||||
},
|
||||
"engines": { "node": ">=22.12.0" },
|
||||
"bin": { "cast": "./dist/cli.js" },
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"test": "vitest run",
|
||||
"format": "biome format --write .",
|
||||
"check": "biome check --error-on-warnings .",
|
||||
"check:shell": "bash .github/scripts/shellcheck-all.sh"
|
||||
"check": "biome check --error-on-warnings ."
|
||||
},
|
||||
"dependencies": {
|
||||
"yaml": "^2.5.0",
|
||||
|
|
|
|||
36
scripts/register-github-app.sh
Executable file
36
scripts/register-github-app.sh
Executable file
|
|
@ -0,0 +1,36 @@
|
|||
#!/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}"
|
||||
72
src/apply.ts
72
src/apply.ts
|
|
@ -92,73 +92,6 @@ 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(
|
||||
report: DiffReport,
|
||||
desired: Desired[],
|
||||
|
|
@ -248,9 +181,8 @@ export async function applyPlan(
|
|||
uuid = await exec.createResource(c);
|
||||
} else {
|
||||
uuid = c.uuid as string;
|
||||
const fields = completeBasicAuth(
|
||||
Object.fromEntries(c.fieldDiffs.map((f) => [f.field, f.desired])),
|
||||
spec,
|
||||
const fields = Object.fromEntries(
|
||||
c.fieldDiffs.map((f) => [f.field, f.desired]),
|
||||
);
|
||||
if (Object.keys(fields).length > 0) {
|
||||
await exec.updateFields(uuid, c.kind, fields);
|
||||
|
|
|
|||
351
src/cli.ts
351
src/cli.ts
|
|
@ -84,13 +84,6 @@ import {
|
|||
renderFleetDiff,
|
||||
renderProjectHeading,
|
||||
} from "./fleet.js";
|
||||
import {
|
||||
createGithubApp,
|
||||
generateWebhookSecret,
|
||||
registerGithubApp,
|
||||
resolveAppName,
|
||||
seedGithubAppBinding,
|
||||
} from "./github-app.js";
|
||||
import {
|
||||
type LiveResource,
|
||||
type SweepEnvironment,
|
||||
|
|
@ -131,10 +124,6 @@ const USAGE = `usage: cast apply <org>/<repo> --env <env> [--path <dir>] [--
|
|||
cast inventory --env <env> --emit-draft <dir> [--recipient age1…] [--no-secrets]
|
||||
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 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 team [--env <env>]
|
||||
cast versions # list installed versions
|
||||
|
|
@ -190,32 +179,6 @@ const USAGE = `usage: cast apply <org>/<repo> --env <env> [--path <dir>] [--
|
|||
coordinate: --path, --project, --environment, --resource,
|
||||
--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):
|
||||
--generated <NAME> force NAME to the \`pending-coolify-generated\` placeholder,
|
||||
for a manifest that has not declared generated_secrets yet.
|
||||
|
|
@ -428,53 +391,6 @@ export function projectLiveFields(
|
|||
...(raw.is_static == null
|
||||
? {}
|
||||
: { 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.build_command ? { build_command: raw.build_command } : {}),
|
||||
...(raw.start_command ? { start_command: raw.start_command } : {}),
|
||||
|
|
@ -743,20 +659,6 @@ export async function fetchLive(
|
|||
...(kind === "application" && i.is_static == null
|
||||
? { 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 = [
|
||||
...map("application", env.applications),
|
||||
|
|
@ -1273,17 +1175,7 @@ async function runProject(
|
|||
let secrets: Record<string, string>;
|
||||
if (existsSync(store)) {
|
||||
secrets = decryptSecrets(store, keyFileFor(ctx.envName));
|
||||
} 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;
|
||||
})()
|
||||
) {
|
||||
} else if (requiredSecrets(checkout, ctx.envName).required.length > 0) {
|
||||
throw new Error(
|
||||
[
|
||||
`no secret store for ${orgRepo} in ${ctx.envName}`,
|
||||
|
|
@ -1460,193 +1352,6 @@ function formatVersion(): string {
|
|||
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> {
|
||||
const [command, ...rest] = process.argv.slice(2);
|
||||
if (command === "-h" || command === "--help" || command === "help") {
|
||||
|
|
@ -2522,9 +2227,6 @@ async function main(): Promise<number> {
|
|||
});
|
||||
return 0;
|
||||
}
|
||||
if (command === "github-app") {
|
||||
return await githubAppCommand(rest);
|
||||
}
|
||||
if (command === "smoke") {
|
||||
const { values, positionals } = parseArgs({
|
||||
args: rest,
|
||||
|
|
@ -3049,57 +2751,6 @@ export function applicationApiFields(
|
|||
): Record<string, unknown> {
|
||||
const { port, healthcheck, domains, docker_compose_domains, ...rest } =
|
||||
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 {
|
||||
// is_static/install_command/build_command/start_command ride through `rest`
|
||||
// unchanged: they are valid API params verbatim, accepted on both the create
|
||||
|
|
|
|||
98
src/diff.ts
98
src/diff.ts
|
|
@ -79,27 +79,6 @@ export type Live = {
|
|||
// a create-time setting; a real boolean from a future Coolify (staticNotCompared
|
||||
// unset) is projected and diffed normally.
|
||||
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 = {
|
||||
field: string;
|
||||
|
|
@ -182,38 +161,9 @@ export type DiffReport = {
|
|||
// 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.
|
||||
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;
|
||||
};
|
||||
|
||||
// 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[]> = {
|
||||
application: ["build_pack"],
|
||||
database: ["type", "version"],
|
||||
|
|
@ -361,7 +311,6 @@ export function computeDiff(
|
|||
): DiffReport {
|
||||
const changes: Change[] = [];
|
||||
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
|
||||
// per run when the degradation actually bites (a manifest declares `static:`
|
||||
// on an app whose live value cast could not read), not per application.
|
||||
|
|
@ -401,32 +350,8 @@ export function computeDiff(
|
|||
backupsNotCompared.push({ name: d.name, reason: l.backupNotCompared });
|
||||
}
|
||||
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)
|
||||
.filter(([field]) => !(skipBackup && field === "backup"))
|
||||
.filter(([field]) => !skippedBasicAuth.includes(field))
|
||||
.filter(([field]) => {
|
||||
// The unreadable-is_static escape hatch (cast#68), sibling to the
|
||||
// backup one above. is_static lives on the ApplicationSetting relation,
|
||||
|
|
@ -479,7 +404,6 @@ export function computeDiff(
|
|||
reserved,
|
||||
placement,
|
||||
backupsNotCompared,
|
||||
basicAuthNotCompared,
|
||||
// A split project is drift, and drift is not clean — the same disposition
|
||||
// as an orphan: reported, counted, and NOT repaired (apply moves nothing
|
||||
// between networks; see renderDiff).
|
||||
|
|
@ -530,16 +454,6 @@ export function renderDiff(report: DiffReport): string {
|
|||
for (const c of report.changes) {
|
||||
lines.push(`${c.op} ${c.kind} ${c.name}`);
|
||||
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(
|
||||
` ${f.field}: ${JSON.stringify(f.live)} → ${JSON.stringify(f.desired)}${f.updatable ? "" : " [NOT UPDATABLE IN PLACE]"}`,
|
||||
);
|
||||
|
|
@ -602,18 +516,6 @@ export function renderDiff(report: DiffReport): string {
|
|||
` (${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;
|
||||
if (placement.split) {
|
||||
lines.push(
|
||||
|
|
|
|||
39
src/draft.ts
39
src/draft.ts
|
|
@ -424,10 +424,7 @@ function applicationSpec(
|
|||
// for. Each one changes what the application IS, and each would be silently
|
||||
// absent from a rebuild.
|
||||
const NO_HOME: Array<[string, string]> = [
|
||||
[
|
||||
"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)",
|
||||
],
|
||||
["custom_labels", "custom Traefik/Docker labels (Basic Auth lives here)"],
|
||||
["ports_mappings", "host port mappings"],
|
||||
["pre_deployment_command", "a pre-deployment command"],
|
||||
["post_deployment_command", "a post-deployment command"],
|
||||
|
|
@ -442,26 +439,6 @@ function applicationSpec(
|
|||
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.
|
||||
// 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
|
||||
|
|
@ -899,19 +876,9 @@ const NO_API_COVERAGE: Array<[string, string]> = [
|
|||
"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).",
|
||||
],
|
||||
// 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 on SERVICES",
|
||||
"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).",
|
||||
"Basic Auth / custom Traefik labels",
|
||||
"carried as raw container labels. cast's manifest has no field for them, so a rebuilt resource is UNPROTECTED where the original was not.",
|
||||
],
|
||||
[
|
||||
'"Include Source Commit in Build"',
|
||||
|
|
|
|||
1432
src/github-app.ts
1432
src/github-app.ts
File diff suppressed because it is too large
Load diff
|
|
@ -34,74 +34,6 @@ 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 /`,
|
||||
);
|
||||
|
||||
// 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
|
||||
.object({
|
||||
source: z.object({ repo: z.string(), branch: z.string() }).strict(),
|
||||
|
|
@ -130,7 +62,6 @@ const AppSpecSchema = z
|
|||
healthcheck: z.string().optional(),
|
||||
domains: z.array(z.string()).optional(),
|
||||
service_domains: z.record(z.array(z.string())).optional(),
|
||||
basic_auth: BasicAuthSchema.optional(),
|
||||
env_template: z.string().optional(),
|
||||
})
|
||||
.strict()
|
||||
|
|
@ -258,13 +189,6 @@ const ManifestSchema = z
|
|||
})
|
||||
.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 DatabaseSpec = z.infer<typeof DatabaseSpecSchema>;
|
||||
export type ServiceSpec = z.infer<typeof ServiceSpecSchema>;
|
||||
|
|
|
|||
136
src/resolve.ts
136
src/resolve.ts
|
|
@ -1,5 +1,5 @@
|
|||
import { execFileSync } from "node:child_process";
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
|
||||
import { existsSync, mkdtempSync, readFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { Desired } from "./diff.js";
|
||||
|
|
@ -13,8 +13,8 @@ import {
|
|||
templateRefs,
|
||||
templateResourceRefs,
|
||||
} from "./envtemplate.js";
|
||||
import type { AppSpec, EnvironmentSpec } from "./manifest.js";
|
||||
import { loadManifest, storeRefName } from "./manifest.js";
|
||||
import type { EnvironmentSpec } from "./manifest.js";
|
||||
import { loadManifest } from "./manifest.js";
|
||||
import {
|
||||
type ReservedHit,
|
||||
assertNoReservedEnvNames,
|
||||
|
|
@ -162,42 +162,6 @@ export function refusesPathInProd(opts: {
|
|||
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(
|
||||
orgRepo: string,
|
||||
opts: { env: string; path?: string },
|
||||
|
|
@ -206,7 +170,7 @@ export function resolveCheckout(
|
|||
throw new Error(PATH_IN_PROD_REFUSAL);
|
||||
}
|
||||
if (opts.path) return opts.path;
|
||||
const dir = reapOnExit(mkdtempSync(join(tmpdir(), "infra-checkout-")));
|
||||
const dir = mkdtempSync(join(tmpdir(), "infra-checkout-"));
|
||||
const auth = resolveGitAuth();
|
||||
try {
|
||||
execFileSync(
|
||||
|
|
@ -412,11 +376,7 @@ function assertDomainRefs(
|
|||
export function requiredSecrets(
|
||||
checkoutDir: string,
|
||||
envName: string,
|
||||
): {
|
||||
required: RequiredSecret[];
|
||||
generated: string[];
|
||||
manifestRefs: string[];
|
||||
} {
|
||||
): { required: RequiredSecret[]; generated: string[] } {
|
||||
const manifest = loadManifest(join(checkoutDir, ".infra", "manifest.yaml"));
|
||||
const envSpec = manifest.environments[envName];
|
||||
if (!envSpec) {
|
||||
|
|
@ -492,26 +452,7 @@ export function requiredSecrets(
|
|||
].join("\n"),
|
||||
);
|
||||
}
|
||||
// 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 };
|
||||
return { required, generated };
|
||||
}
|
||||
|
||||
// What the manifest declares for an environment, as names only — no secrets, no
|
||||
|
|
@ -586,65 +527,6 @@ 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(
|
||||
checkoutDir: string,
|
||||
envName: string,
|
||||
|
|
@ -762,12 +644,6 @@ export function desiredFromManifest(
|
|||
? { 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),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import {
|
|||
KIND_ORDER,
|
||||
applyHostnameOverlay,
|
||||
applyPlan,
|
||||
completeBasicAuth,
|
||||
} from "../src/apply.js";
|
||||
import { GENERATED_PLACEHOLDER } from "../src/capture.js";
|
||||
import { type Desired, type Live, computeDiff } from "../src/diff.js";
|
||||
|
|
@ -540,148 +539,3 @@ describe("applyHostnameOverlay", () => {
|
|||
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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import { execFileSync, spawn } from "node:child_process";
|
||||
import { mkdirSync, writeFileSync } from "node:fs";
|
||||
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
|
||||
import { createServer } from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
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
|
||||
// what it exits with. The unit tests prove each half (coolify.ts parses the
|
||||
|
|
@ -21,7 +21,7 @@ let recipient: string;
|
|||
let keyFile: string;
|
||||
|
||||
beforeAll(() => {
|
||||
const dir = tmp("cast-age-");
|
||||
const dir = mkdtempSync(join(tmpdir(), "cast-age-"));
|
||||
keyFile = join(dir, "age.key");
|
||||
execFileSync("age-keygen", ["-o", keyFile], { stdio: "pipe" });
|
||||
recipient = execFileSync("age-keygen", ["-y", keyFile], {
|
||||
|
|
@ -110,11 +110,11 @@ environments:
|
|||
`;
|
||||
|
||||
function fixture(url: string) {
|
||||
const checkout = tmp("cast-co-");
|
||||
const checkout = mkdtempSync(join(tmpdir(), "cast-co-"));
|
||||
mkdirSync(join(checkout, ".infra", "env"), { recursive: true });
|
||||
writeFileSync(join(checkout, ".infra", "manifest.yaml"), MANIFEST);
|
||||
|
||||
const state = tmp("cast-state-");
|
||||
const state = mkdtempSync(join(tmpdir(), "cast-state-"));
|
||||
mkdirSync(join(state, "secrets"));
|
||||
writeFileSync(
|
||||
join(state, ".coolify.env"),
|
||||
|
|
|
|||
|
|
@ -1,319 +0,0 @@
|
|||
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);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,11 +1,17 @@
|
|||
import { execFileSync, spawn } from "node:child_process";
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { createServer } from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||
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
|
||||
// live values. The point is the store that comes out the other side — it is
|
||||
|
|
@ -28,7 +34,7 @@ let keyFile: string;
|
|||
let recipient: string;
|
||||
|
||||
beforeAll(() => {
|
||||
const dir = tmp("cast-age-");
|
||||
const dir = mkdtempSync(join(tmpdir(), "cast-age-"));
|
||||
keyFile = join(dir, "age-staging.key");
|
||||
execFileSync("age-keygen", ["-o", keyFile], { stdio: "pipe" });
|
||||
const pub = execFileSync("age-keygen", ["-y", keyFile], { encoding: "utf8" });
|
||||
|
|
@ -104,7 +110,7 @@ function fixture(
|
|||
url: string,
|
||||
opts: { template?: string; manifest?: string } = {},
|
||||
) {
|
||||
const checkout = tmp("cast-co-");
|
||||
const checkout = mkdtempSync(join(tmpdir(), "cast-co-"));
|
||||
mkdirSync(join(checkout, ".infra", "env"), { recursive: true });
|
||||
writeFileSync(
|
||||
join(checkout, ".infra", "manifest.yaml"),
|
||||
|
|
@ -115,7 +121,7 @@ function fixture(
|
|||
opts.template ?? TEMPLATE,
|
||||
);
|
||||
|
||||
const state = tmp("cast-state-");
|
||||
const state = mkdtempSync(join(tmpdir(), "cast-state-"));
|
||||
mkdirSync(join(state, "secrets"));
|
||||
writeFileSync(
|
||||
join(state, ".coolify.env"),
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { mkdirSync, writeFileSync } from "node:fs";
|
||||
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
|
|
@ -13,7 +14,6 @@ import {
|
|||
resolveGeneratedSources,
|
||||
} from "../src/capture.js";
|
||||
import { requiredSecrets } from "../src/resolve.js";
|
||||
import { tmp } from "./helpers/tmp.js";
|
||||
|
||||
const CTX = {
|
||||
orgRepo: "heavy-duty/incubator",
|
||||
|
|
@ -241,7 +241,7 @@ describe("renderCapturePlan", () => {
|
|||
// the manifest's own templates, read by the same parser apply uses.
|
||||
describe("requiredSecrets", () => {
|
||||
function checkout(manifest: string, templates: Record<string, string>) {
|
||||
const dir = tmp("cast-cap-");
|
||||
const dir = mkdtempSync(join(tmpdir(), "cast-cap-"));
|
||||
mkdirSync(join(dir, ".infra", "env"), { recursive: true });
|
||||
writeFileSync(join(dir, ".infra", "manifest.yaml"), manifest);
|
||||
for (const [name, body] of Object.entries(templates)) {
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import { spawn } from "node:child_process";
|
||||
import { mkdirSync, writeFileSync } from "node:fs";
|
||||
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
|
||||
import { createServer } from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { tmp } from "./helpers/tmp.js";
|
||||
|
||||
// Spawned ASYNCHRONOUSLY, and that is load-bearing: the stub Coolify below
|
||||
// runs in THIS process, so a blocking execFileSync would hold the event loop
|
||||
|
|
@ -74,7 +74,7 @@ function stateWith(opts: {
|
|||
named?: Record<string, string>;
|
||||
boundInstance?: string;
|
||||
}): string {
|
||||
const dir = tmp("cast-cli-");
|
||||
const dir = mkdtempSync(join(tmpdir(), "cast-cli-"));
|
||||
if (opts.default) writeFileSync(join(dir, ".coolify.env"), opts.default);
|
||||
if (opts.named) {
|
||||
mkdirSync(join(dir, ".coolify"));
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { mkdirSync, writeFileSync } from "node:fs";
|
||||
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
|
|
@ -7,14 +8,13 @@ import {
|
|||
knownInstances,
|
||||
loadInstance,
|
||||
} from "../src/config.js";
|
||||
import { tmp } from "./helpers/tmp.js";
|
||||
|
||||
// A state dir with a default .coolify.env and any number of named instances.
|
||||
function stateDir(
|
||||
named: Record<string, string> = {},
|
||||
defaultEnv?: string,
|
||||
): string {
|
||||
const dir = tmp("cast-state-");
|
||||
const dir = mkdtempSync(join(tmpdir(), "cast-state-"));
|
||||
if (defaultEnv !== undefined) {
|
||||
writeFileSync(join(dir, ".coolify.env"), defaultEnv);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import { spawn } from "node:child_process";
|
||||
import { mkdirSync, writeFileSync } from "node:fs";
|
||||
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
|
||||
import { createServer } from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
|
|
@ -13,7 +14,6 @@ import {
|
|||
readBackupState,
|
||||
renderDestroyPlan,
|
||||
} from "../src/destroy.js";
|
||||
import { tmp } from "./helpers/tmp.js";
|
||||
|
||||
// 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
|
||||
|
|
@ -460,11 +460,11 @@ function fixture(
|
|||
url: string,
|
||||
opts: { destroyAllowed?: boolean | undefined; readOnly?: boolean } = {},
|
||||
) {
|
||||
const checkout = tmp("cast-co-");
|
||||
const checkout = mkdtempSync(join(tmpdir(), "cast-co-"));
|
||||
mkdirSync(join(checkout, ".infra"), { recursive: true });
|
||||
writeFileSync(join(checkout, ".infra", "manifest.yaml"), MANIFEST);
|
||||
|
||||
const state = tmp("cast-state-");
|
||||
const state = mkdtempSync(join(tmpdir(), "cast-state-"));
|
||||
writeFileSync(
|
||||
join(state, ".coolify.env"),
|
||||
[
|
||||
|
|
|
|||
|
|
@ -690,250 +690,3 @@ describe("backup schedules", () => {
|
|||
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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,19 +2,20 @@ import { execFileSync, spawn } from "node:child_process";
|
|||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
readdirSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { createServer } from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||
import { loadBindings } from "../src/bindings.js";
|
||||
import { GENERATED_PLACEHOLDER } from "../src/capture.js";
|
||||
import { loadManifest } from "../src/manifest.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
|
||||
// necessary: a Coolify nobody declared, holding our stack under names someone
|
||||
|
|
@ -214,7 +215,7 @@ let KEY_FILE = "";
|
|||
let RECIPIENT = "";
|
||||
|
||||
beforeAll(() => {
|
||||
const dir = tmp("cast-age-");
|
||||
const dir = mkdtempSync(join(tmpdir(), "cast-age-"));
|
||||
KEY_FILE = join(dir, "key.txt");
|
||||
execFileSync("age-keygen", ["-o", KEY_FILE], { stdio: "ignore" });
|
||||
RECIPIENT = execFileSync("age-keygen", ["-y", KEY_FILE], {
|
||||
|
|
@ -223,7 +224,7 @@ beforeAll(() => {
|
|||
});
|
||||
|
||||
function fixture(url: string, opts: { recipient?: string } = {}) {
|
||||
const state = tmp("cast-state-");
|
||||
const state = mkdtempSync(join(tmpdir(), "cast-state-"));
|
||||
writeFileSync(
|
||||
join(state, ".coolify.env"),
|
||||
`COOLIFY_BASE_URL="${url}"\nCOOLIFY_ACCESS_TOKEN="t"\n`,
|
||||
|
|
@ -241,7 +242,7 @@ function fixture(url: string, opts: { recipient?: string } = {}) {
|
|||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
const out = join(tmp("cast-out-"), "draft");
|
||||
const out = join(mkdtempSync(join(tmpdir(), "cast-out-")), "draft");
|
||||
return { state, out };
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { mkdirSync, writeFileSync } from "node:fs";
|
||||
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { GENERATED_PLACEHOLDER } from "../src/capture.js";
|
||||
|
|
@ -13,7 +14,6 @@ import {
|
|||
} from "../src/draft.js";
|
||||
import { templateKeys, templateRefs } from "../src/envtemplate.js";
|
||||
import { loadManifest } from "../src/manifest.js";
|
||||
import { tmp } from "./helpers/tmp.js";
|
||||
|
||||
const ctx = {
|
||||
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("box-b");
|
||||
|
||||
const dir = tmp("cast-draft-");
|
||||
const dir = mkdtempSync(join(tmpdir(), "cast-draft-"));
|
||||
const path = join(dir, "manifest.yaml");
|
||||
writeFileSync(path, manifest?.content ?? "");
|
||||
const loaded = loadManifest(path);
|
||||
|
|
@ -285,7 +285,7 @@ describe("planDraft — the emitted shape", () => {
|
|||
});
|
||||
const plan = planDraft([p], ctx);
|
||||
const manifest = plan.files.find((f) => f.path.endsWith("manifest.yaml"));
|
||||
const dir = tmp("cast-draft-");
|
||||
const dir = mkdtempSync(join(tmpdir(), "cast-draft-"));
|
||||
const path = join(dir, "manifest.yaml");
|
||||
writeFileSync(path, manifest?.content ?? "");
|
||||
const build =
|
||||
|
|
@ -325,7 +325,7 @@ describe("planDraft — the emitted shape", () => {
|
|||
});
|
||||
const plan = planDraft([p], ctx);
|
||||
const manifest = plan.files.find((f) => f.path.endsWith("manifest.yaml"));
|
||||
const dir = tmp("cast-draft-");
|
||||
const dir = mkdtempSync(join(tmpdir(), "cast-draft-"));
|
||||
const path = join(dir, "manifest.yaml");
|
||||
writeFileSync(path, manifest?.content ?? "");
|
||||
// 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
|
||||
// prevent.
|
||||
const manifest = plan.files.find((f) => f.path.endsWith("manifest.yaml"));
|
||||
const dir = tmp("cast-draft-");
|
||||
const dir = mkdtempSync(join(tmpdir(), "cast-draft-"));
|
||||
const path = join(dir, "manifest.yaml");
|
||||
writeFileSync(path, manifest?.content ?? "");
|
||||
const build =
|
||||
|
|
@ -598,7 +598,7 @@ describe("backup schedules — read and drafted, not hand-waved (#75)", () => {
|
|||
});
|
||||
const loadedDb = (plan: ReturnType<typeof planDraft>) => {
|
||||
const manifest = plan.files.find((f) => f.path.endsWith("manifest.yaml"));
|
||||
const dir = tmp("cast-draft-");
|
||||
const dir = mkdtempSync(join(tmpdir(), "cast-draft-"));
|
||||
const path = join(dir, "manifest.yaml");
|
||||
writeFileSync(path, manifest?.content ?? "");
|
||||
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 manifest = plan.files.find((f) => f.path.endsWith("manifest.yaml"));
|
||||
const dir = tmp("cast-draft-");
|
||||
const dir = mkdtempSync(join(tmpdir(), "cast-draft-"));
|
||||
const path = join(dir, "manifest.yaml");
|
||||
writeFileSync(path, manifest?.content ?? "");
|
||||
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", () => {
|
||||
it("refuses a target directory that is not empty", () => {
|
||||
const dir = tmp("cast-draft-");
|
||||
const dir = mkdtempSync(join(tmpdir(), "cast-draft-"));
|
||||
writeFileSync(join(dir, "README.md"), "a repo lives here\n");
|
||||
expect(() => assertEmptyTarget(dir)).toThrow(/is not empty/);
|
||||
expect(() => assertEmptyTarget(dir)).toThrow(/Adoption is one-way/);
|
||||
});
|
||||
|
||||
it("allows a directory that does not exist yet, and an empty one", () => {
|
||||
const dir = tmp("cast-draft-");
|
||||
const dir = mkdtempSync(join(tmpdir(), "cast-draft-"));
|
||||
expect(() => assertEmptyTarget(dir)).not.toThrow();
|
||||
expect(() => assertEmptyTarget(join(dir, "new"))).not.toThrow();
|
||||
});
|
||||
|
||||
it("refuses to write a manifest over one that already exists", () => {
|
||||
const dir = tmp("cast-draft-");
|
||||
const dir = mkdtempSync(join(tmpdir(), "cast-draft-"));
|
||||
mkdirSync(join(dir, ".infra"), { recursive: true });
|
||||
const path = join(dir, ".infra", "manifest.yaml");
|
||||
writeFileSync(path, "project: incubator\n");
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import { execFileSync, spawn } from "node:child_process";
|
||||
import { mkdirSync, writeFileSync } from "node:fs";
|
||||
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
|
||||
import { createServer } from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
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
|
||||
// Coolify carrying three projects.
|
||||
|
|
@ -26,7 +26,7 @@ let recipient: string;
|
|||
let keyFile: string;
|
||||
|
||||
beforeAll(() => {
|
||||
const dir = tmp("cast-age-");
|
||||
const dir = mkdtempSync(join(tmpdir(), "cast-age-"));
|
||||
keyFile = join(dir, "age.key");
|
||||
execFileSync("age-keygen", ["-o", keyFile], { stdio: "pipe" });
|
||||
recipient = execFileSync("age-keygen", ["-y", keyFile], {
|
||||
|
|
@ -134,7 +134,7 @@ function fixture(
|
|||
url: string,
|
||||
opts: { registry?: string[]; registryEnv?: string; refIn?: string } = {},
|
||||
) {
|
||||
const root = tmp("cast-fleet-");
|
||||
const root = mkdtempSync(join(tmpdir(), "cast-fleet-"));
|
||||
for (const repo of REPOS) {
|
||||
const dir = join(root, "repos", "heavy-duty", `${repo}.git`);
|
||||
mkdirSync(join(dir, ".infra"), { recursive: true });
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import { execFileSync, spawn } from "node:child_process";
|
||||
import { mkdirSync, writeFileSync } from "node:fs";
|
||||
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
|
||||
import { createServer } from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
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
|
||||
// 2026-07-19 release drill.)
|
||||
|
|
@ -26,7 +26,7 @@ let recipient: string;
|
|||
let keyFile: string;
|
||||
|
||||
beforeAll(() => {
|
||||
const dir = tmp("cast-age-");
|
||||
const dir = mkdtempSync(join(tmpdir(), "cast-age-"));
|
||||
keyFile = join(dir, "age.key");
|
||||
execFileSync("age-keygen", ["-o", keyFile], { stdio: "pipe" });
|
||||
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.
|
||||
function fixture(url: string, manifest: string) {
|
||||
const checkout = tmp("cast-co-");
|
||||
const checkout = mkdtempSync(join(tmpdir(), "cast-co-"));
|
||||
mkdirSync(join(checkout, ".infra", "env"), { recursive: true });
|
||||
writeFileSync(join(checkout, ".infra", "manifest.yaml"), manifest);
|
||||
|
||||
const state = tmp("cast-state-");
|
||||
const state = mkdtempSync(join(tmpdir(), "cast-state-"));
|
||||
mkdirSync(join(state, "secrets"));
|
||||
writeFileSync(
|
||||
join(state, ".coolify.env"),
|
||||
|
|
|
|||
|
|
@ -1,396 +0,0 @@
|
|||
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");
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,10 +1,10 @@
|
|||
import { execFileSync, spawn } from "node:child_process";
|
||||
import { mkdirSync, writeFileSync } from "node:fs";
|
||||
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
|
||||
import { createServer } from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
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,
|
||||
// registered project, a manifest that declares databases only and refs no
|
||||
|
|
@ -23,7 +23,7 @@ let recipient: string;
|
|||
let keyFile: string;
|
||||
|
||||
beforeAll(() => {
|
||||
const dir = tmp("cast-age-");
|
||||
const dir = mkdtempSync(join(tmpdir(), "cast-age-"));
|
||||
keyFile = join(dir, "age.key");
|
||||
execFileSync("age-keygen", ["-o", keyFile], { stdio: "pipe" });
|
||||
recipient = execFileSync("age-keygen", ["-y", keyFile], {
|
||||
|
|
@ -100,7 +100,7 @@ function fixture(
|
|||
manifest: ZERO_REFS_MANIFEST,
|
||||
},
|
||||
) {
|
||||
const checkout = tmp("cast-co-");
|
||||
const checkout = mkdtempSync(join(tmpdir(), "cast-co-"));
|
||||
mkdirSync(join(checkout, ".infra", "env"), { recursive: true });
|
||||
writeFileSync(join(checkout, ".infra", "manifest.yaml"), opts.manifest);
|
||||
writeFileSync(
|
||||
|
|
@ -108,7 +108,7 @@ function fixture(
|
|||
"API_KEY=${API_KEY}\n",
|
||||
);
|
||||
|
||||
const state = tmp("cast-state-");
|
||||
const state = mkdtempSync(join(tmpdir(), "cast-state-"));
|
||||
mkdirSync(join(state, "secrets"));
|
||||
writeFileSync(
|
||||
join(state, ".coolify.env"),
|
||||
|
|
@ -148,7 +148,7 @@ function run(
|
|||
const { CAST_AGE_KEY_FILE_STAGING: _dropped, ...inherited } = process.env;
|
||||
const env = opts.withKey
|
||||
? { ...inherited, CAST_AGE_KEY_FILE_STAGING: keyFile }
|
||||
: { ...inherited, HOME: tmp("cast-home-") };
|
||||
: { ...inherited, HOME: mkdtempSync(join(tmpdir(), "cast-home-")) };
|
||||
const child = spawn("node", ["dist/cli.js", "diff", ...args], {
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
env,
|
||||
|
|
|
|||
|
|
@ -1,39 +0,0 @@
|
|||
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
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -1,54 +0,0 @@
|
|||
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;
|
||||
}
|
||||
|
|
@ -4,15 +4,16 @@ import {
|
|||
copyFileSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
readlinkSync,
|
||||
realpathSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { tmp } from "./helpers/tmp.js";
|
||||
|
||||
const run = promisify(execFile);
|
||||
|
||||
|
|
@ -58,7 +59,7 @@ type Sandbox = {
|
|||
};
|
||||
|
||||
function sandbox(): Sandbox {
|
||||
const root = tmp("cast-install-");
|
||||
const root = mkdtempSync(join(tmpdir(), "cast-install-"));
|
||||
const stubs = join(root, "stubs");
|
||||
const home = join(root, "home");
|
||||
const dest = join(root, "cast-home");
|
||||
|
|
|
|||
150
test/labels-reconcile.sh
Normal file
150
test/labels-reconcile.sh
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
#!/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 ]
|
||||
|
|
@ -4,14 +4,15 @@ import {
|
|||
copyFileSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
realpathSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { tmp } from "./helpers/tmp.js";
|
||||
|
||||
const run = promisify(execFile);
|
||||
|
||||
|
|
@ -49,7 +50,7 @@ type Sandbox = {
|
|||
};
|
||||
|
||||
async function installedSandbox(versions: string[]): Promise<Sandbox> {
|
||||
const root = tmp("cast-layout-");
|
||||
const root = mkdtempSync(join(tmpdir(), "cast-layout-"));
|
||||
const stubs = join(root, "stubs");
|
||||
const home = join(root, "home");
|
||||
const dest = join(root, "cast-home");
|
||||
|
|
|
|||
|
|
@ -285,124 +285,6 @@ 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", () => {
|
||||
it("parses bindings", () => {
|
||||
const b = loadBindings(`${FIX}environments.yaml`);
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import { execFileSync, spawn } from "node:child_process";
|
||||
import { mkdirSync, writeFileSync } from "node:fs";
|
||||
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
|
||||
import { createServer } from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
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
|
||||
// and what it exits with. The unit tests prove each half (bindings resolve a
|
||||
|
|
@ -20,7 +20,7 @@ let recipient: string;
|
|||
let keyFile: string;
|
||||
|
||||
beforeAll(() => {
|
||||
const dir = tmp("cast-age-");
|
||||
const dir = mkdtempSync(join(tmpdir(), "cast-age-"));
|
||||
keyFile = join(dir, "age.key");
|
||||
execFileSync("age-keygen", ["-o", keyFile], { stdio: "pipe" });
|
||||
recipient = execFileSync("age-keygen", ["-y", keyFile], {
|
||||
|
|
@ -103,11 +103,11 @@ environments:
|
|||
// `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.
|
||||
function fixture(url: string, declared?: string) {
|
||||
const checkout = tmp("cast-co-");
|
||||
const checkout = mkdtempSync(join(tmpdir(), "cast-co-"));
|
||||
mkdirSync(join(checkout, ".infra", "env"), { recursive: true });
|
||||
writeFileSync(join(checkout, ".infra", "manifest.yaml"), MANIFEST);
|
||||
|
||||
const state = tmp("cast-state-");
|
||||
const state = mkdtempSync(join(tmpdir(), "cast-state-"));
|
||||
mkdirSync(join(state, "secrets"));
|
||||
writeFileSync(
|
||||
join(state, ".coolify.env"),
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import { execFileSync, spawn } from "node:child_process";
|
||||
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
||||
import { existsSync, mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
|
||||
import { createServer } from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
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
|
||||
// declared: its project is `Incubator` (capital I), its environment is
|
||||
|
|
@ -31,7 +31,7 @@ const LIVE_ENV = {
|
|||
let recipient: string;
|
||||
|
||||
beforeAll(() => {
|
||||
const dir = tmp("cast-age-");
|
||||
const dir = mkdtempSync(join(tmpdir(), "cast-age-"));
|
||||
const keyFile = join(dir, "age.key");
|
||||
execFileSync("age-keygen", ["-o", keyFile], { stdio: "pipe" });
|
||||
recipient = execFileSync("age-keygen", ["-y", keyFile], {
|
||||
|
|
@ -103,14 +103,14 @@ ADMIN_EMAIL=\${ADMIN_EMAIL}
|
|||
`;
|
||||
|
||||
function fixture(url: string) {
|
||||
const checkout = tmp("cast-co-");
|
||||
const checkout = mkdtempSync(join(tmpdir(), "cast-co-"));
|
||||
mkdirSync(join(checkout, ".infra", "env"), { recursive: true });
|
||||
writeFileSync(join(checkout, ".infra", "manifest.yaml"), MANIFEST);
|
||||
writeFileSync(
|
||||
join(checkout, ".infra", "env", "core.staging.env.template"),
|
||||
TEMPLATE,
|
||||
);
|
||||
const state = tmp("cast-state-");
|
||||
const state = mkdtempSync(join(tmpdir(), "cast-state-"));
|
||||
mkdirSync(join(state, "secrets"));
|
||||
writeFileSync(
|
||||
join(state, ".coolify.env"),
|
||||
|
|
|
|||
|
|
@ -4,41 +4,37 @@ import {
|
|||
cpSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
readlinkSync,
|
||||
realpathSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { tmp } from "./helpers/tmp.js";
|
||||
|
||||
// What remains CAST'S OWN of the release flow, after the ceremony moved
|
||||
// upstream (heavy-duty/ceremony#15): the caller stubs' load-bearing shape,
|
||||
// the artifact hook's install contract, the drill doctrine cast's docs must
|
||||
// not lose, and the installer's three channels — REAL install.sh runs
|
||||
// against throwaway roots, with a stub curl on PATH standing in for GitHub
|
||||
// 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
|
||||
// The release flow (#96), proven offline. Two surfaces: the changelog-section
|
||||
// extraction release.yml publishes (.github/scripts/release-notes.sh), and
|
||||
// the installer's three channels — REAL install.sh runs against throwaway
|
||||
// roots, with a stub curl on PATH standing in for GitHub and a POISONED npm
|
||||
// proving the release channels never build. Nothing here touches the
|
||||
// network. (`cast --version` itself is test/version-cli.test.ts's; the
|
||||
// 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.)
|
||||
|
||||
const ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
|
||||
const NOTES = join(ROOT, ".github/scripts/release-notes.sh");
|
||||
|
||||
function run(
|
||||
cmd: string,
|
||||
args: string[],
|
||||
env: Record<string, string> = {},
|
||||
cwd?: string,
|
||||
): Promise<{ code: number; output: string }> {
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn(cmd, args, {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
cwd,
|
||||
env: { ...process.env, ...env },
|
||||
});
|
||||
let output = "";
|
||||
|
|
@ -52,109 +48,243 @@ function run(
|
|||
});
|
||||
}
|
||||
|
||||
// --- the ceremony callers — the stubs' load-bearing shape -------------------
|
||||
// The workflow logic lives upstream at the pin; what can still break HERE is
|
||||
// the caller: its triggers, its permissions grant, its backend input, and the
|
||||
// pins themselves. Fail-closed, same discipline as the old workflow pins.
|
||||
// --- release-notes.sh — the extraction release.yml publishes ----------------
|
||||
// A fixture changelog carrying every boundary: an Unreleased section that
|
||||
// must never leak into a release, adjacent versions, a version that prefixes
|
||||
// another (0.7.0 vs 0.7.0-rc1), and a stamped-but-empty section that must
|
||||
// refuse.
|
||||
|
||||
describe("the ceremony callers", () => {
|
||||
const FIXTURE = `# Changelog
|
||||
|
||||
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");
|
||||
|
||||
it("ONE push key, both filters — a second sibling push: silently kills a door", () => {
|
||||
// YAML maps are last-key-wins (grok's round-2 catch on the old
|
||||
// workflow: the tag fallback had stopped triggering).
|
||||
expect(RY.match(/^ {2}push:$/gm)).toHaveLength(1);
|
||||
it("triggers on EVERY tag — the manual fallback survives, and a mismatch must fail loudly, not be pattern-skipped", () => {
|
||||
expect(RY).toContain('tags: ["**"]');
|
||||
});
|
||||
|
||||
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]");
|
||||
// The merge door rides push, never pull_request: a fork PR's token is
|
||||
// read-only and permissions: cannot raise it (box#97).
|
||||
// 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).toContain("startsWith(github.ref, 'refs/tags/')");
|
||||
expect(RY).toContain("github.ref == 'refs/heads/main'");
|
||||
expect(RY).toContain("commits/$GITHUB_SHA/pulls");
|
||||
expect(RY).toContain("no merged, release-labeled PR is behind this commit");
|
||||
expect(RY).not.toContain("pull_request:");
|
||||
});
|
||||
|
||||
it("the version backend is package-json", () => {
|
||||
expect(RY).toContain("version-source: package-json");
|
||||
it("the release re-arms main itself — the -dev bump folds into the release act", () => {
|
||||
// Operator decision (#111 followup): the post-release bump PR was
|
||||
// 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("every ceremony reference in .github/ names ONE tag", () => {
|
||||
// CONSUMERS.md's same-tag rule: the two workflow callers and each guard
|
||||
// step pin the same ceremony tag — one reference bumped alone leaves
|
||||
// the repo split across ceremony versions.
|
||||
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],
|
||||
it("asserts tag == package.json version, and the assert precedes the create", () => {
|
||||
expect(RY).toContain('require("./package.json").version');
|
||||
expect(RY).toContain("creating nothing");
|
||||
expect(RY.indexOf("creating nothing")).toBeLessThan(
|
||||
RY.indexOf('gh release create "$RELEASE_VERSION"'),
|
||||
);
|
||||
expect(refs.length).toBeGreaterThanOrEqual(6); // 2 callers + 4 guards
|
||||
expect(new Set(refs).size).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
// --- the artifact hook — the install contract the workflow used to carry ----
|
||||
// The asset name `cast-X.Y.Z.tgz` and the staged layout are what the
|
||||
// installer's release channels download; they never run npm or tsc, so the
|
||||
// build happens ONCE, in the hook, and the asset is the runnable tree.
|
||||
|
||||
describe("release-artifact hook", () => {
|
||||
const HOOK = readFileSync(
|
||||
join(ROOT, ".github/actions/release-artifact/action.yml"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
it("builds the prod-only tree once and stages the runnable layout", () => {
|
||||
expect(HOOK).toContain("npm ci");
|
||||
expect(HOOK).toContain("npm run build");
|
||||
expect(HOOK).toContain("npm prune --omit=dev");
|
||||
expect(HOOK).toContain("cp -R bin dist node_modules package.json");
|
||||
});
|
||||
|
||||
it("drops cast-<version>.tgz into $RELEASE_ASSETS_DIR — the channels' exact download name", () => {
|
||||
expect(HOOK).toContain('"$RELEASE_ASSETS_DIR/cast-$VERSION.tgz"');
|
||||
expect(HOOK).toContain('"$RUNNER_TEMP/stage/cast-$VERSION"');
|
||||
it("the merge path decides, then asserts, IN ORDER, all before tag-create, build, and publish", () => {
|
||||
// The decide step (the fused version asserts — see the workflow's
|
||||
// four-state table): base read from git, versions via node, work under
|
||||
// the label no-ops green, half-ceremonies refuse. Then: the shared
|
||||
// notes extraction, the no-existing-tag/release asserts, and only then
|
||||
// the acts — API-tag the merge commit, build, publish. Every marker
|
||||
// present, strictly in file order, fail-closed.
|
||||
const markers = [
|
||||
'git show "$BASE_SHA:package.json"', // decide — base vs merge
|
||||
// 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
|
||||
"a dev tree is by definition not a release", // -dev endstate: always work (the bump PR no-ops green)
|
||||
"release-flow work merged in the post-release window (before the -dev bump)", // window no-op
|
||||
"Refusing to guess — creating nothing.", // bare, unchanged, unreleased: refuse
|
||||
".github/scripts/release-notes.sh", // assert: notes extract
|
||||
'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("runs no tests — ci.yml gated the merge commit already, and the suite needs age", () => {
|
||||
expect(HOOK).not.toContain("npm test");
|
||||
expect(HOOK).not.toContain("npm run check");
|
||||
it("the -dev interlock reads versions via node, never regex, and names the 0.1.0 first-release edge", () => {
|
||||
expect(RY).not.toMatch(/grep.*version/);
|
||||
expect(RY).toContain("node -p 'require(\"./package.json\").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("owns its toolchain — setup-node moved INTO the hook; the shared workflow is node-free", () => {
|
||||
expect(HOOK).toContain("actions/setup-node");
|
||||
it("tag, build, and publish happen in the SAME job — a GITHUB_TOKEN tag fires no workflows", () => {
|
||||
const jobs = RY.slice(RY.indexOf("\njobs:")).match(/^ {2}\S+:\s*$/gm) ?? [];
|
||||
expect(jobs).toEqual([" release:"]); // one job under jobs:
|
||||
expect(RY).toContain("does not trigger other workflows");
|
||||
expect(RY).toContain('-f "sha=$MERGE_SHA"');
|
||||
});
|
||||
});
|
||||
|
||||
// --- the drill doctrine, in cast's own docs ---------------------------------
|
||||
// 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("the body comes from the shared extraction script", () => {
|
||||
expect(RY).toContain(".github/scripts/release-notes.sh");
|
||||
});
|
||||
|
||||
describe("drills/README.md — the independent, ref-pinned drills", () => {
|
||||
it("documents the record files and the INDEPENDENT, ref-pinned drills", () => {
|
||||
const doc = readFileSync(join(ROOT, "drills/README.md"), "utf8");
|
||||
expect(doc).toContain("`<version>.md`");
|
||||
expect(doc).toMatch(/drills are independent/i);
|
||||
expect(doc).toMatch(/any order/i);
|
||||
expect(doc).toMatch(/pins the same fixed set of\s+candidate\s+refs/i);
|
||||
expect(doc).toMatch(/not sequencing/i);
|
||||
// box and rig stay mutually recursive; the pinning is what makes that a
|
||||
// non-problem, so both halves have to survive together.
|
||||
expect(doc).toMatch(/mutually recursive/);
|
||||
expect(doc).toContain("RIG_REF");
|
||||
expect(doc).toMatch(/candidate refs, not released artifacts/);
|
||||
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);
|
||||
expect(doc).toMatch(/convergence/i);
|
||||
expect(doc).toMatch(/promotion/i);
|
||||
// Separate records, one pinned set: each cites the shared run ID.
|
||||
expect(doc).toMatch(/run ID/i);
|
||||
it("the release is bound to its tag (--verify-tag)", () => {
|
||||
expect(RY).toContain("--verify-tag");
|
||||
});
|
||||
|
||||
it("builds the prod-only tree once and attaches it as the asset", () => {
|
||||
expect(RY).toContain("npm prune --omit=dev");
|
||||
expect(RY).toContain("cp -R bin dist node_modules package.json");
|
||||
expect(RY).toContain("cast-$RELEASE_VERSION.tgz");
|
||||
});
|
||||
|
||||
it("both trigger paths converge on the SAME asset name — one build, one tar, no per-path naming", () => {
|
||||
// Each path's entry step exports RELEASE_VERSION; everything downstream
|
||||
// (notes, stage dir, tarball, release title) reads only that. A second
|
||||
// tar or a $GITHUB_REF_NAME-named asset would be the paths drifting
|
||||
// apart — the exact failure this shape exists to prevent.
|
||||
expect(RY.match(/>> "\$GITHUB_ENV"/g)).toHaveLength(2);
|
||||
expect(RY.match(/tar -C/g)).toHaveLength(1);
|
||||
expect(RY).not.toContain("cast-$GITHUB_REF_NAME");
|
||||
});
|
||||
|
||||
it("runs no tests — ci.yml gated the merge commit already", () => {
|
||||
expect(RY).not.toContain("npm test");
|
||||
expect(RY).not.toContain("npm run check");
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -164,7 +294,7 @@ describe("drills/README.md — the independent, ref-pinned drills", () => {
|
|||
// test opts into the stub build — so any release-channel install that
|
||||
// touches npm fails its assertion by failing the install.
|
||||
|
||||
const STUB = tmp("cast-stub-");
|
||||
const STUB = mkdtempSync(join(tmpdir(), "cast-stub-"));
|
||||
writeFileSync(
|
||||
join(STUB, "curl"),
|
||||
`#!/usr/bin/env bash
|
||||
|
|
@ -248,7 +378,7 @@ async function runInstall(
|
|||
env: Record<string, string>,
|
||||
opts: { preexistingDest?: boolean } = {},
|
||||
): Promise<Install> {
|
||||
const work = tmp("cast-inst-");
|
||||
const work = mkdtempSync(join(tmpdir(), "cast-inst-"));
|
||||
const dest = join(work, "dest");
|
||||
const bin = join(work, "bin");
|
||||
const curlLog = join(work, "curl.log");
|
||||
|
|
@ -281,7 +411,7 @@ async function runInstall(
|
|||
}
|
||||
|
||||
describe("install.sh — the three channels", () => {
|
||||
const work = tmp("cast-tarballs-");
|
||||
const work = mkdtempSync(join(tmpdir(), "cast-tarballs-"));
|
||||
const asset = makeTarball(work, "9.9.9");
|
||||
const mainSrc = makeTarball(work, "main");
|
||||
const brokenAsset = makeTarball(join(work, "broken"), "9.9.9", {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { mkdirSync, writeFileSync } from "node:fs";
|
||||
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { classify } from "../src/capture.js";
|
||||
|
|
@ -11,7 +12,6 @@ import {
|
|||
requiredSecrets,
|
||||
} from "../src/resolve.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
|
||||
// its own injection of a name the resource already carries a var of
|
||||
|
|
@ -58,7 +58,7 @@ describe("the rule", () => {
|
|||
// --- resolve / apply: REFUSE ---------------------------------------------------
|
||||
|
||||
function checkout(template: string, envName = "staging"): string {
|
||||
const dir = tmp("infra-reserved-");
|
||||
const dir = mkdtempSync(join(tmpdir(), "infra-reserved-"));
|
||||
mkdirSync(join(dir, ".infra", "env"), { recursive: true });
|
||||
writeFileSync(
|
||||
join(dir, ".infra", "manifest.yaml"),
|
||||
|
|
|
|||
|
|
@ -1,8 +1,6 @@
|
|||
import { execFileSync } from "node:child_process";
|
||||
import { existsSync, mkdirSync, readdirSync, writeFileSync } from "node:fs";
|
||||
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { computeDiff } from "../src/diff.js";
|
||||
import { DERIVED_UNRESOLVED } from "../src/envtemplate.js";
|
||||
|
|
@ -14,7 +12,6 @@ import {
|
|||
resolveCheckout,
|
||||
resolveGitAuth,
|
||||
} from "../src/resolve.js";
|
||||
import { tmp } from "./helpers/tmp.js";
|
||||
|
||||
describe("resolveCheckout", () => {
|
||||
it("hard-refuses --path with prod", () => {
|
||||
|
|
@ -30,57 +27,6 @@ describe("resolveCheckout", () => {
|
|||
}),
|
||||
).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", () => {
|
||||
|
|
@ -173,7 +119,7 @@ describe("cloneFailureMessage", () => {
|
|||
|
||||
describe("desiredFromManifest", () => {
|
||||
it("maps manifest + templates to Desired[] with resolved env", () => {
|
||||
const dir = tmp("infra-co-");
|
||||
const dir = mkdtempSync(join(tmpdir(), "infra-co-"));
|
||||
mkdirSync(join(dir, ".infra", "env"), { recursive: true });
|
||||
writeFileSync(
|
||||
join(dir, ".infra", "manifest.yaml"),
|
||||
|
|
@ -225,7 +171,7 @@ environments:
|
|||
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)", () => {
|
||||
const dir = tmp("infra-co-");
|
||||
const dir = mkdtempSync(join(tmpdir(), "infra-co-"));
|
||||
mkdirSync(join(dir, ".infra"), { recursive: true });
|
||||
writeFileSync(
|
||||
join(dir, ".infra", "manifest.yaml"),
|
||||
|
|
@ -244,7 +190,7 @@ environments:
|
|||
});
|
||||
// #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", () => {
|
||||
const dir = tmp("infra-co-");
|
||||
const dir = mkdtempSync(join(tmpdir(), "infra-co-"));
|
||||
mkdirSync(join(dir, ".infra"), { recursive: true });
|
||||
writeFileSync(
|
||||
join(dir, ".infra", "manifest.yaml"),
|
||||
|
|
@ -280,7 +226,7 @@ environments:
|
|||
// /databases/{uuid}/backups), and the side channel is what made a `backup:`
|
||||
// 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", () => {
|
||||
const dir = tmp("infra-co-");
|
||||
const dir = mkdtempSync(join(tmpdir(), "infra-co-"));
|
||||
mkdirSync(join(dir, ".infra"), { recursive: true });
|
||||
writeFileSync(
|
||||
join(dir, ".infra", "manifest.yaml"),
|
||||
|
|
@ -303,7 +249,7 @@ environments:
|
|||
});
|
||||
});
|
||||
it("leaves `backup` out of fields entirely when none is declared", () => {
|
||||
const dir = tmp("infra-co-");
|
||||
const dir = mkdtempSync(join(tmpdir(), "infra-co-"));
|
||||
mkdirSync(join(dir, ".infra"), { recursive: true });
|
||||
writeFileSync(
|
||||
join(dir, ".infra", "manifest.yaml"),
|
||||
|
|
@ -325,7 +271,7 @@ environments:
|
|||
expect("backup" in desired[0].fields).toBe(false);
|
||||
});
|
||||
it("emits a service's service_domains into fields, canonicalized (cast#72)", () => {
|
||||
const dir = tmp("infra-co-");
|
||||
const dir = mkdtempSync(join(tmpdir(), "infra-co-"));
|
||||
mkdirSync(join(dir, ".infra"), { recursive: true });
|
||||
writeFileSync(
|
||||
join(dir, ".infra", "manifest.yaml"),
|
||||
|
|
@ -351,7 +297,7 @@ environments:
|
|||
});
|
||||
});
|
||||
it("is clean for a service whose live per-container hostnames match (cast#72, no perpetual update)", () => {
|
||||
const dir = tmp("infra-co-");
|
||||
const dir = mkdtempSync(join(tmpdir(), "infra-co-"));
|
||||
mkdirSync(join(dir, ".infra"), { recursive: true });
|
||||
writeFileSync(
|
||||
join(dir, ".infra", "manifest.yaml"),
|
||||
|
|
@ -385,7 +331,7 @@ environments:
|
|||
expect(report.clean).toBe(true);
|
||||
});
|
||||
it("diffs a service whose declared hostname is missing live (apply will set it)", () => {
|
||||
const dir = tmp("infra-co-");
|
||||
const dir = mkdtempSync(join(tmpdir(), "infra-co-"));
|
||||
mkdirSync(join(dir, ".infra"), { recursive: true });
|
||||
writeFileSync(
|
||||
join(dir, ".infra", "manifest.yaml"),
|
||||
|
|
@ -424,7 +370,7 @@ environments:
|
|||
]);
|
||||
});
|
||||
it("a service with no service_domains carries only its type", () => {
|
||||
const dir = tmp("infra-co-");
|
||||
const dir = mkdtempSync(join(tmpdir(), "infra-co-"));
|
||||
mkdirSync(join(dir, ".infra"), { recursive: true });
|
||||
writeFileSync(
|
||||
join(dir, ".infra", "manifest.yaml"),
|
||||
|
|
@ -441,7 +387,7 @@ environments:
|
|||
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", () => {
|
||||
const dir = tmp("infra-co-");
|
||||
const dir = mkdtempSync(join(tmpdir(), "infra-co-"));
|
||||
mkdirSync(join(dir, ".infra", "env"), { recursive: true });
|
||||
writeFileSync(
|
||||
join(dir, ".infra", "manifest.yaml"),
|
||||
|
|
@ -490,7 +436,7 @@ environments:
|
|||
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)', () => {
|
||||
const dir = tmp("infra-co-");
|
||||
const dir = mkdtempSync(join(tmpdir(), "infra-co-"));
|
||||
mkdirSync(join(dir, ".infra"), { recursive: true });
|
||||
writeFileSync(
|
||||
join(dir, ".infra", "manifest.yaml"),
|
||||
|
|
@ -520,7 +466,7 @@ environments:
|
|||
);
|
||||
});
|
||||
it("does not warn about the source-commit toggle for a non-dockercompose app (the build arg is a compose concern)", () => {
|
||||
const dir = tmp("infra-co-");
|
||||
const dir = mkdtempSync(join(tmpdir(), "infra-co-"));
|
||||
mkdirSync(join(dir, ".infra"), { recursive: true });
|
||||
writeFileSync(
|
||||
join(dir, ".infra", "manifest.yaml"),
|
||||
|
|
@ -540,7 +486,7 @@ environments:
|
|||
warn.mockRestore();
|
||||
});
|
||||
it("throws when the env is missing from the manifest", () => {
|
||||
const dir = tmp("infra-co-");
|
||||
const dir = mkdtempSync(join(tmpdir(), "infra-co-"));
|
||||
mkdirSync(join(dir, ".infra"), { recursive: true });
|
||||
writeFileSync(
|
||||
join(dir, ".infra", "manifest.yaml"),
|
||||
|
|
@ -559,7 +505,7 @@ describe("derived resource refs (#60)", () => {
|
|||
ref = "${resource:postgres.url}",
|
||||
dbBlock = " databases:\n postgres: { type: postgresql }\n",
|
||||
): string => {
|
||||
const dir = tmp("infra-co-");
|
||||
const dir = mkdtempSync(join(tmpdir(), "infra-co-"));
|
||||
mkdirSync(join(dir, ".infra", "env"), { recursive: true });
|
||||
writeFileSync(
|
||||
join(dir, ".infra", "manifest.yaml"),
|
||||
|
|
@ -634,7 +580,7 @@ describe("derived domain refs (#66)", () => {
|
|||
// Assemble a manifest from an applications block plus one env template. The
|
||||
// env_template line is appended to whichever app comes last in `apps`.
|
||||
const write = (apps: string, tmpl: string): string => {
|
||||
const dir = tmp("infra-co-");
|
||||
const dir = mkdtempSync(join(tmpdir(), "infra-co-"));
|
||||
mkdirSync(join(dir, ".infra", "env"), { recursive: true });
|
||||
writeFileSync(
|
||||
join(dir, ".infra", "manifest.yaml"),
|
||||
|
|
|
|||
|
|
@ -1,13 +1,19 @@
|
|||
import { execFileSync, spawn } from "node:child_process";
|
||||
import { closeSync, mkdirSync, openSync, writeFileSync } from "node:fs";
|
||||
import {
|
||||
closeSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
openSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
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.
|
||||
function ageFixture(): { keyFile: string; enc: string } {
|
||||
const dir = tmp("infra-age-");
|
||||
const dir = mkdtempSync(join(tmpdir(), "infra-age-"));
|
||||
const keyFile = join(dir, "key.txt");
|
||||
execFileSync("age-keygen", ["-o", keyFile]);
|
||||
const recipient = execFileSync("age-keygen", ["-y", keyFile], {
|
||||
|
|
@ -64,7 +70,7 @@ describe("secretsFileFor", () => {
|
|||
describe("decryptSecrets identity caching", () => {
|
||||
it("a read-once pipe key survives two decrypts — the --all loop shape", () => {
|
||||
const { keyFile, enc } = ageFixture();
|
||||
const dir = tmp("infra-fifo-");
|
||||
const dir = mkdtempSync(join(tmpdir(), "infra-fifo-"));
|
||||
const fifo = join(dir, "key.fifo");
|
||||
execFileSync("mkfifo", [fifo]);
|
||||
// One writer, one serving of the key: exactly what a process substitution
|
||||
|
|
@ -118,7 +124,7 @@ describe("keyFileFor", () => {
|
|||
// 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).
|
||||
const home = process.env.HOME;
|
||||
process.env.HOME = tmp("cast-home-");
|
||||
process.env.HOME = mkdtempSync(join(tmpdir(), "cast-home-"));
|
||||
try {
|
||||
expect(() => keyFileFor("drill-b")).toThrow(
|
||||
/no age key for drill-b.*CAST_AGE_KEY_FILE_DRILL_B.*age-drill-b\.key/s,
|
||||
|
|
@ -129,7 +135,7 @@ describe("keyFileFor", () => {
|
|||
});
|
||||
it("falls back to a standing key on disk when one exists", () => {
|
||||
const home = process.env.HOME;
|
||||
const dir = tmp("cast-home-");
|
||||
const dir = mkdtempSync(join(tmpdir(), "cast-home-"));
|
||||
const cfg = join(dir, ".config", "cast");
|
||||
mkdirSync(cfg, { recursive: true });
|
||||
writeFileSync(join(cfg, "age-staging.key"), "AGE-SECRET-KEY-1\n");
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import { spawn } from "node:child_process";
|
||||
import { writeFileSync } from "node:fs";
|
||||
import { mkdtempSync, writeFileSync } from "node:fs";
|
||||
import { createServer } from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
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.
|
||||
//
|
||||
|
|
@ -167,7 +167,7 @@ function state(
|
|||
"acme/client-site": "core",
|
||||
},
|
||||
): string {
|
||||
const dir = tmp("cast-smoke-");
|
||||
const dir = mkdtempSync(join(tmpdir(), "cast-smoke-"));
|
||||
writeFileSync(
|
||||
join(dir, ".coolify.env"),
|
||||
`COOLIFY_BASE_URL="${url}"\nCOOLIFY_ACCESS_TOKEN="t"\n`,
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import { spawn } from "node:child_process";
|
||||
import { mkdirSync, writeFileSync } from "node:fs";
|
||||
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
|
||||
import { createServer } from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
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.
|
||||
//
|
||||
|
|
@ -91,10 +91,10 @@ environments:
|
|||
`;
|
||||
|
||||
function fixture(url: string) {
|
||||
const checkout = tmp("cast-co-");
|
||||
const checkout = mkdtempSync(join(tmpdir(), "cast-co-"));
|
||||
mkdirSync(join(checkout, ".infra", "env"), { recursive: true });
|
||||
writeFileSync(join(checkout, ".infra", "manifest.yaml"), MANIFEST);
|
||||
const state = tmp("cast-state-");
|
||||
const state = mkdtempSync(join(tmpdir(), "cast-state-"));
|
||||
mkdirSync(join(state, "secrets"));
|
||||
writeFileSync(
|
||||
join(state, ".coolify.env"),
|
||||
|
|
|
|||
|
|
@ -1,52 +0,0 @@
|
|||
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");
|
||||
});
|
||||
});
|
||||
|
|
@ -46,82 +46,6 @@ 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", () => {
|
||||
it("maps type+version to an image and drops the type/version keys", () => {
|
||||
const out = databaseApiFields({ type: "postgresql", version: "17" });
|
||||
|
|
@ -221,55 +145,6 @@ describe("projectLiveFields", () => {
|
|||
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", () => {
|
||||
const out = projectLiveFields("database", {
|
||||
database_type: "standalone-postgresql",
|
||||
|
|
|
|||
|
|
@ -1,8 +1,2 @@
|
|||
import { defineConfig } from "vitest/config";
|
||||
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"],
|
||||
},
|
||||
});
|
||||
export default defineConfig({ test: { include: ["test/**/*.test.ts"] } });
|
||||
|
|
|
|||
Loading…
Reference in a new issue