Skip to content

Release v1.19.2 - #1113

Merged
VijitSingh97 merged 22 commits into
mainfrom
develop
Aug 18, 2026
Merged

Release v1.19.2#1113
VijitSingh97 merged 22 commits into
mainfrom
develop

Conversation

@VijitSingh97

Copy link
Copy Markdown
Collaborator

Release merge for v1.19.2 — published and signature-verified, main catching up to the released state.

main is released-only and must stay an ancestor of develop, so this is a real merge, never a squash. The tag v1.19.2 is develop's tip (20111d6), so nothing landed after the cut and this is the clean straight-merge shape.

Verified before this merge

  • Published and independently signature-verified. cosign verify-blob on the published bundle → Verified OK; the dashboard image signature verifies and its digest matches what was promoted. pithead.tar.gz.sig is attached (96 bytes) — the asset whose absence this release makes impossible to ship by accident.
  • The cut ran through the new preflight gate, which printed Release signing ON and Release verifier validated end to end — and the --dry-run rehearsal beforehand printed the same, which was not possible before this release.
  • Pre-cut targeted e2e on the bench with a borrowed rig: 609 passed / 5 skipped / 0 failed.
  • Post-publish smoke: all green, including the real Dashboard: new-version warning + one-click upgrade button #59 one-click upgrade of the bench box 1.19.1 → 1.19.2 over the download → verify → rollback-guard → extract path.
  • Post-deploy live sweep on the bench: 62 passed / 0 failed. doctor reports Control runner units target this install and the pinned verifier present.

🤖 Generated with Claude Code

VijitSingh97 and others added 22 commits August 15, 2026 21:36
…1024)

A release bundle carries the CLI, the compose file, the config files and
cosign.pub — deliberately no docs/. Nine operator messages named a repo-relative
doc anyway, so on a release install (most of the fleet) they read like a local
path and resolve to nothing. The cosign-missing refusals were the worst case:
the one operator who sees them is mid-refusal on a box they cannot upgrade.

Add DOCS_URL and route all nine through it — main is the released-only branch,
so it is the documentation for the version an operator is actually running.
Bundles stay lean; no docs/ is added to the artifact.

Guard it with a second rule in lint-operator-strings, which already exists to
police this text. The new rule runs at full width — the doctor's dr_* reporters
and the runner's _upg_* result strings included — because three of the nine hits
live in shapes the #NNN rule never scanned. The #NNN rule stays narrow: those
shapes carry twelve references predating the linter, filed as #1026 rather than
buried here. The rule found a ninth site by itself, a describe_change preview a
hand grep had missed.

The script's --self-test harness was never wired to anything, so its scanners
had no check of their own; make lint-operator-strings now runs it before the
live scan, and it covers both new rules.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
chore: back-merge main into develop after v1.19.1
A failed upgrade can leave the box-global control units naming a directory that
is no longer the install. Nothing reports it. The dashboard keeps writing
requests into its own spool, the path unit keeps watching a tree nobody writes
to, and the config editor and one-click upgrade simply never complete — no error
on either side.

Found live on the production box: a v1.18.1 upgrade repointed the units, then
aborted at the cosign gate and rolled the stack back to v1.17.0, leaving the
units on the half-installed tree. Two operator upgrade requests (v1.19.0, then
v1.19.1) sat unread in the live spool for a day. `doctor` reported a healthy box
throughout, because it had no check for this at all.

#1070 fixed the ordering that causes it, but it cannot reach a box already
stranded: the channel the fix would arrive through is the broken one. So the
diagnostic has to be able to see it.

- `control_units_owner_dir` lifts the ExecStart parse and physical-path
  resolution out of `provision_control_runner`'s removal branch, which now calls
  it instead of repeating it. Net fewer lines there.
- `check_control_units` reports, under a new "Dashboard control channel" heading:
  units missing while control is on, units owning a different directory (naming
  both paths), and a path unit watching a different spool than this install
  writes to. Disabled control is INFO, not a fault.
- The two spellings of one checkout — the versioned dir and the `current`
  symlink — resolve to one install, so a production box is not told it is broken.

Tier 1, per the testing strategy: this is decision logic over a parsed unit file,
provable with stubs.

Mutation proof, run before this was committed — every assertion goes red under
the defect it guards:

  drop the owner-mismatch arm     -> "units point elsewhere" goes green
  flip that `!=` to `=`           -> mismatch AND aligned both flip
  drop the spool comparison arm   -> "different spool" goes green
  drop the `-z "$owner"` arm      -> "no units installed" loses its message

That last one is why the no-units assertion checks the message rather than bare
FAIL: with no units the owner is empty, so the mismatch arm fires anyway and an
assertion for "FAIL" would have stayed green with the arm it names deleted.
The new check landed between check_stratum_exposure's doc comment and the
function it describes, so that comment read as documentation for
check_control_units. Moved below check_stratum_exposure's closing brace.

No behaviour change: mutation proof re-run (all four mutations still turn their
assertion red) and tests/stack/run.sh still 1735 passed, 0 failed.
… real

Two blocking defects found by an independent review of this branch before
merge. Both were reproduced in a container harness, and both are fixed here.

1. FALSE FAIL ON THE DOCUMENTED LAYOUT — the worse of the two.

   The canonical layout keeps the previous version dir for rollback, and the
   one-click upgrade runs in the browser, so an operator's shell is routinely
   still sitting in that older dir. Comparing the units against $PWD called
   that healthy box broken:

     FAIL The control runner units point at .../pithead-v1.19.1, but this
          install is .../pithead-v1.19.0 — ... silently never run.

   Worse than noise: following the printed fix from there repointed the units
   AT the rollback dir, which breaks the live install for real. A check meant
   to catch a silent failure could cause it.

   A versioned dir that `current` no longer names is a superseded copy, not a
   stranded install. It now reports INFO naming the live dir, and gives no
   verdict — verdicts about the control channel belong to the live install.
   Recognised with the same pattern update_current_symlink already uses.

2. THE PRESCRIBED FIX WAS A NO-OP.

   All three messages say to run './pithead apply'. A box whose units point at
   a dead install has an unchanged config by definition — the fault is in the
   unit files, not config.json — so apply returned at "No configuration
   changes detected" nine lines before provision_control_runner. The fix
   printed on the only box the check fires for did nothing.

   apply now converges the control units before that return. It is idempotent
   and sudo-free when they already match, and apply's own comment already
   claimed it converged them.

Mutation proof for both new assertions, run before committing:

  delete the `current`-symlink guard   -> superseded dir goes back to FAIL
  remove the call from apply's no-change branch -> "provision" disappears

tests/stack/run.sh: 1739 passed, 0 failed. make lint: clean.
…-unit-target

fix(cli): doctor names control units that point at another install
Both scripts opened an absolute path inside a specific user's home directory,
which is neither reproducible for anyone else nor something a public repo
should carry. They now take the log path as an argument and default to a
relative one — one line each, and it makes them runnable.

Companion to the develop-v2 fixture cleanup; this is the only occurrence on
develop.
…ath-in-research-scripts

chore: stop hardcoding a home directory in the research analysis scripts
…lp's heredoc

The rule this PR added reported the tree clean while `show_help` printed a bare
`docs/operations.md` to every operator who runs `pithead --help`. msg_lines only ever saw call
shapes — log/warn/error/info/echo and describe_change's msg= — so a heredoc body was invisible,
and the review that introduced the rule inspected the same blind extractor.

The extractor now reads printed heredoc bodies: `cat <<EOF` with nothing after the tag. An opener
carrying a redirect writes a file — a unit, a config, a template — which is not operator text, so
the `$` anchor keeps those out. Both rules widen together, since a #NNN in a usage block is the
same leak as a #NNN in an error.

That widening then caught a false positive in the fix itself. `$DOCS_URL/docs/getting-started.md#1-prerequisites`
is a GitHub heading anchor, which numbered headings render as `#1-…` — indistinguishable from an
issue reference to a pattern looking for `#NNN`. A $DOCS_URL pointer is now stripped before the
issue-number check, the same way it already is before the docs-path check.

Three self-test cases cover the extractor (printed heredoc flagged, redirected heredoc exempt,
#NNN in a heredoc flagged) and one covers the anchor exemption.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…t by a list of keys

Whether `apply` restarts caddy was decided by a hand-maintained list of .env keys. A list like
that only works while someone remembers to extend it, and it had already drifted: on the
appliance, `dashboard.expose_public_ip` is not in it, so turning OFF the opt-in that serves the
dashboard on a globally-routable address re-rendered the Caddyfile without its bind lines and left
caddy holding the wildcard listener. The operator sees the setting saved and the file change, and
the box stays exposed (#1052).

The guard is now the content: snapshot the Caddyfile, render, restart if it differs. That covers
every input at once, including the ones nobody has added yet, and it cannot drift — the cause no
longer has to be enumerated, only the effect observed.

An absent previous file is a fresh install: compose starts caddy on the new one moments later and
there is no old configuration to displace, so that case stays quiet.

Note the key this was found through does not exist on this branch — it is appliance-only. What is
shared is the fragile mechanism, so the fix lands here and reaches the appliance through the
routine sync, where the expose_public_ip assertion belongs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…file

Emptiness and absence were conflated: a zero-byte Caddyfile left by a crashed render read as "no
previous file", so the restart was skipped on exactly the box whose caddy is serving nothing.
File existence is now tracked separately from content.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…tart-on-any-change

fix(cli): restart caddy when the rendered Caddyfile changes, not when a listed key does
…ers-in-operator-text

fix(cli): point operator messages at published docs, not repo paths (#1024)
#1108)

A release could ship unsigned; an install could not accept one. `release.sh`
warned and published when the signing environment was absent, while the
one-click upgrade runner refuses any release with no `pithead.tar.gz.sig`. A cut
on an unconfigured box therefore produced a bundle every install in the fleet
rejects — and release assets are immutable, so the signature could never be
attached afterwards. That is how v1.18.0 had to be withdrawn. Three releases
shipped unsigned this way while `cosign.pub` was uncommitted and installs merely
warned; it is committed now, so the same mistake is a hard refusal.

- Signing is mandatory to publish (#960). Preflight aborts when `cosign.pub` is
  committed and any of {cosign, COSIGN_KEY, COSIGN_PASSWORD} is missing, naming
  the gap and why it cannot be fixed after the publish. `--unsigned` is the
  explicit, loud escape hatch. COSIGN_PASSWORD was never checked before: cosign
  would have prompted for it at stage 6b, after the images were promoted.
- The dry run rehearses the decision. It sat inside `if [ "$DRY_RUN" -eq 0 ]`,
  so the one check that exists to protect a cut could only ever print
  "signing OFF" — the failure state, unconditionally.
- The cosign flags are probed, not assumed. cosign v3 removed `--tlog-upload`,
  which both signing calls pass; a drifted box passed every other check and then
  died at stage 6b with the images already promoted.
- The pinned verifier is validated before publish (#1084). Preflight pulls
  COSIGN_IMAGE, signs a probe blob with this box's key, verifies it through the
  container, and requires a tampered blob to be refused. Since #1072 that image
  is the trust root for every install, and nothing checked it: the stack tests
  run against a fake docker, the e2e skips verification on a source checkout,
  and release-smoke runs after the assets are immutable. The round trip also
  catches a `cosign.pub` that is no longer the public half of `COSIGN_KEY`.
- The release gate stops claiming to gate `main` (#1048). `release-gate.yml`
  carried a `push: [main]` trigger behind a repo variable nobody set, so every
  merge recorded a skipped — i.e. green — run of a job that has never executed.
  It is dispatch-only now, and `releasing.md` names which gates are automated
  and which are human-run, in one table.
- `doctor` answers "can this box take an upgrade" before an upgrade does. It now
  reports whether the pinned verifier image is present and names the pre-fetch
  command; if that one fetch fails mid-upgrade the operator sees a *signature*
  failure, which reads as tampering rather than as an image the host could not
  pull. The source-checkout line says one-click does not apply there.
- The legacy cliff is documented. v1.18.1 and v1.19.0 verify with a host binary
  and an upgrade runs the installed code, so those boxes refuse until cosign is
  installed once — with the pinned v2.6.3, because v3 satisfies `command -v` and
  then fails the verify. Written up in `releasing.md` with the guards read from
  each released tag, and linked from `operations.md` where a stuck operator
  looks. Flagged in the doc as not yet driven on a live install.
- The prose is reconciled. Signing is no longer "opt-in" anywhere, because the
  consumer became fail-closed and nothing had updated the docs.

Coverage: 51 new tier-1 assertions, each mutation-proven (10 mutations run;
every one goes red on exactly the named assertions and nothing else).

Re-derived and NOT reproduced: #1108 states the upgrade runner's refusal of an
unsigned bundle has no test that would go red if the refusal were deleted.
It does — deleting it turns tests/stack/run.sh's three "missing signature"
assertions red (1787/3 against a 1790/0 baseline). No test added there.

Verified: tier-1 1790/0, dashboard 1721 passed at 97.01%, frontend, compose,
integration selftest, fakes, and every lint except lint-proto (needs a Docker
daemon this host does not have).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
cosign v3 removed the flag from `sign` and `sign-blob` together, so one probe
answers the question the loop was asking five lines to ask.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Make the cosign chain one contract, producer to consumer (#1108)
VERSION + dashboard pyproject in lockstep (a shell test fails if they diverge),
uv.lock regenerated, and the CHANGELOG section written from the seven issues
that shipped since v1.19.1.

The headline is the cosign chain (#1108 / #960 / #1084 / #1048): a release could
ship unsigned that no install would accept, the dry run could not report the real
signing decision, the pinned verifier image was never validated before publish,
and the release gate recorded green skipped runs of a job that had never run.

Carries an upgrade note for installs still on v1.18.1 or v1.19.0, which verify
with a host cosign binary and refuse until it is installed once — with v2.6.3,
because v3 satisfies the check and then fails the verification.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@VijitSingh97
VijitSingh97 merged commit c08e742 into main Aug 18, 2026
36 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant