fix(cd): document the upgrade_policy half of CD, and run the hook tests in CI #31

Merged
forgejo-admin merged 1 commit from wire-cd-backup-gate into main 2026-08-02 03:42:33 +00:00

What I found

The brief for this work assumed lunacycle might have no on_backup hook, which would have made arming auto_upgrade reckless. It has one, and it workscelilo backup list lunacycle on celilo-mgr shows three completed backups, the most recent a 1.1 MB snapshot of module version 1.0.4 at schema v11. The build half is also already real: 8 green release.yml runs, and Version PR #30 currently open with 1.0.5.

So CD is genuinely wired in both directions. The gap is narrower and easier to miss.

The actual trap

manifest.yml and release.yml both told the operator to set one key:

sudo celilo module config set lunacycle auto_upgrade true

That arms the poll. It says nothing about whether the upgrade backs the database up first — that's upgrade_policy, and unset it defaults to by-semver, which backs up on minor/major only.

Every lunacycle release to date has been a patch (see CHANGELOG.md). So an operator following the documented instruction would have armed unattended upgrades of a data-bearing app on the fast path: no pre-deploy backup, and celilo has no rollback. The on_backup hook would sit there, tested and working, and never be called. The declared posture reads "protected"; the realistic release cadence routes around it.

Both keys are now documented together — in manifest.yml, release.yml, and .changeset/README.md — with why the second one is the load-bearing one.

The hook tests had never run in CI

test:unit is vitest run with include: ['tests/unit/**']. The celilo hooks live in celilo/scripts/, deliberately outside the root workspace. So all 57 hook tests — including backup.test.ts and restore.test.ts, which cover the hook the entire safe-upgrade path depends on — ran only on developer laptops.

Added test:hooks + a step in pr-validate.yml. Wiring it up immediately caught that celilo/scripts/bun.lock was stale (pinned @celilo/capabilities at ^0.4.2 against package.json's ^0.5.0), which a frozen install rejects. Regenerated — lockfile metadata only, the installed tree was already correct (Checked 7 installs across 8 packages (no changes)).

New guard, watched to fail

celilo/scripts/manifest-cd.test.ts guards the declaration celilo depends on silently. The gate in celilo is:

needsPreUpgradeBackup = posture === 'safe' && Boolean(targetManifest.hooks?.on_backup)

Dropping on_backup does not fail and does not refuse — celilo takes an else if (posture === 'safe') branch that logs a warning and deploys anyway. Verified by deleting the hook from manifest.yml:

(fail) manifest CD safety declarations > declares the on_backup hook
(fail) manifest CD safety declarations > on_backup script exists on disk
 3 pass, 2 fail

then restored, back to 5 pass.

Removed the auto_upgrade declaration

It existed to work around celilo module config set rejecting undeclared keys. celilo 0.16.0 (#516) made auto_upgrade and upgrade_policy framework keys settable on any module, and celilo-mgr runs 0.16.2 — verified. Keeping it leaves a second, module-specific source of truth for a framework concern, and declaring only one of the two keys is part of what made auto_upgrade look like "the switch".

Verification

gate result
bun run lint 0 errors (10 pre-existing warnings)
bun run typecheck clean
bun run test:unit 58 pass, 3 todo
bun run test:hooks 62 pass (was: never run in CI)
celilo module check 9 ok, 2 warn, 0 fail

No application behaviour changes.

Still needs an operator decision

This PR does not turn CD on — deliberately. Arming it is two commands on celilo-mgr, and they should be run together:

sudo celilo module config set lunacycle auto_upgrade   true
sudo celilo module config set lunacycle upgrade_policy always-safe

Worth weighing first: lunacycle's only e2e suite (e2e-nightly.yml) has run exactly once and failed — 11 failed, 12 did not run, 6 passed, every failure a seedDatabase() UNIQUE-constraint error. release.yml deliberately does not gate on it. Filed separately as #32.

## What I found The brief for this work assumed lunacycle might have no `on_backup` hook, which would have made arming `auto_upgrade` reckless. **It has one, and it works** — `celilo backup list lunacycle` on celilo-mgr shows three completed backups, the most recent a 1.1 MB snapshot of module version 1.0.4 at schema v11. The build half is also already real: 8 green `release.yml` runs, and Version PR #30 currently open with 1.0.5. So CD is genuinely wired in both directions. The gap is narrower and easier to miss. ## The actual trap `manifest.yml` and `release.yml` both told the operator to set **one** key: ``` sudo celilo module config set lunacycle auto_upgrade true ``` That arms the poll. It says nothing about whether the upgrade backs the database up first — that's `upgrade_policy`, and unset it defaults to `by-semver`, which backs up on **minor/major only**. Every lunacycle release to date has been a **patch** (see `CHANGELOG.md`). So an operator following the documented instruction would have armed unattended upgrades of a data-bearing app on the *fast* path: no pre-deploy backup, and celilo has no rollback. The `on_backup` hook would sit there, tested and working, and never be called. The declared posture reads "protected"; the realistic release cadence routes around it. Both keys are now documented together — in `manifest.yml`, `release.yml`, and `.changeset/README.md` — with why the second one is the load-bearing one. ## The hook tests had never run in CI `test:unit` is `vitest run` with `include: ['tests/unit/**']`. The celilo hooks live in `celilo/scripts/`, deliberately outside the root workspace. So all 57 hook tests — including `backup.test.ts` and `restore.test.ts`, which cover the hook the entire safe-upgrade path depends on — ran only on developer laptops. Added `test:hooks` + a step in `pr-validate.yml`. Wiring it up immediately caught that `celilo/scripts/bun.lock` was stale (pinned `@celilo/capabilities` at `^0.4.2` against `package.json`'s `^0.5.0`), which a frozen install rejects. Regenerated — lockfile metadata only, the installed tree was already correct (`Checked 7 installs across 8 packages (no changes)`). ## New guard, watched to fail `celilo/scripts/manifest-cd.test.ts` guards the declaration celilo depends on *silently*. The gate in celilo is: ```ts needsPreUpgradeBackup = posture === 'safe' && Boolean(targetManifest.hooks?.on_backup) ``` Dropping `on_backup` does not fail and does not refuse — celilo takes an `else if (posture === 'safe')` branch that logs a warning and deploys anyway. Verified by deleting the hook from `manifest.yml`: ``` (fail) manifest CD safety declarations > declares the on_backup hook (fail) manifest CD safety declarations > on_backup script exists on disk 3 pass, 2 fail ``` then restored, back to 5 pass. ## Removed the `auto_upgrade` declaration It existed to work around `celilo module config set` rejecting undeclared keys. celilo 0.16.0 (#516) made `auto_upgrade` and `upgrade_policy` framework keys settable on any module, and celilo-mgr runs **0.16.2** — verified. Keeping it leaves a second, module-specific source of truth for a framework concern, and declaring only one of the two keys is part of what made `auto_upgrade` look like "the switch". ## Verification | gate | result | |---|---| | `bun run lint` | 0 errors (10 pre-existing warnings) | | `bun run typecheck` | clean | | `bun run test:unit` | 58 pass, 3 todo | | `bun run test:hooks` | **62 pass** (was: never run in CI) | | `celilo module check` | 9 ok, 2 warn, **0 fail** | No application behaviour changes. ## Still needs an operator decision This PR does **not** turn CD on — deliberately. Arming it is two commands on celilo-mgr, and they should be run together: ``` sudo celilo module config set lunacycle auto_upgrade true sudo celilo module config set lunacycle upgrade_policy always-safe ``` Worth weighing first: lunacycle's only e2e suite (`e2e-nightly.yml`) has run exactly once and **failed** — 11 failed, 12 did not run, 6 passed, every failure a `seedDatabase()` UNIQUE-constraint error. `release.yml` deliberately does not gate on it. Filed separately as #32.
fix(cd): document the upgrade_policy half of CD, and run the hook tests in CI
All checks were successful
pr-validate / validate (pull_request) Successful in 16s
f3f57ac8c9
lunacycle turned out to be wired for registry-poll CD in both directions
already: release.yml publishes unattended (8 green runs, Version PR #30
open with 1.0.5), and the on_backup hook is real — three completed
backups on celilo-mgr, the most recent a 1.1 MB snapshot at schema v11.
What was missing was the part that makes the backup happen on the path
CD actually takes.

manifest.yml and release.yml both told the operator to set one key:

  sudo celilo module config set lunacycle auto_upgrade true

That arms the poll but says nothing about whether the upgrade backs the
database up first. That is upgrade_policy, and unset it means by-semver
— back up on minor/major only. Every lunacycle release to date has been
a patch, so an operator following the documented instruction would have
armed unattended upgrades of a data-bearing app on the fast path: no
pre-deploy backup, and celilo has no rollback. The declared posture
would read "protected" while the realistic release cadence routed around
it. Both keys are now documented together, in manifest.yml, release.yml
and .changeset/README.md, with why the second one is the load-bearing
one.

The hook tests that cover all of this had never run in CI. test:unit is
`vitest run` with include: ['tests/unit/**'], and the celilo hooks live
in celilo/scripts/, deliberately outside the root workspace. So
backup.test.ts and restore.test.ts — the tests for the hook the entire
safe-upgrade path depends on — ran only on developer laptops. Added a
test:hooks script and a pr-validate.yml step. Wiring it up immediately
caught that celilo/scripts/bun.lock was stale (pinned
@celilo/capabilities ^0.4.2 against package.json's ^0.5.0), which a
frozen install rejects; regenerated it, lockfile metadata only, the
installed tree was already correct.

Added manifest-cd.test.ts to guard the declaration celilo depends on
silently. The gate is

  needsPreUpgradeBackup = posture === 'safe' && Boolean(hooks?.on_backup)

so dropping on_backup does not fail and does not refuse — celilo takes
an `else if (posture === 'safe')` branch that logs a warning and deploys
anyway. Verified the guard by deleting the hook from manifest.yml: 2 of
5 assertions go red, then restored.

Removed the auto_upgrade variable declaration. It existed to work around
`celilo module config set` rejecting undeclared keys; celilo 0.16.0
(#516) made auto_upgrade and upgrade_policy framework keys settable on
any module, and celilo-mgr runs 0.16.2. Keeping it would leave a
second, module-specific source of truth for a framework concern — and
declaring only one of the two keys is what made auto_upgrade look like
"the switch" in the first place.

No application behaviour changes. Gates: lint 0 errors, tsc clean,
test:unit 58 pass, test:hooks 62 pass, `celilo module check` 0 fail.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign in to join this conversation.
No description provided.