commit 49603a62a2cacad03c299bb1fd5ca65103e0680e
parent 97a92b16df4db8346cd5d878039a83d94a9c5b0c
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Tue, 6 Oct 2026 10:51:23 -0400
plans: release 18 — S2's round-2 cleanups; the changelog drops the hub-regression bullet (in no release) and names the private-only channels
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
2 files changed, 19 insertions(+), 5 deletions(-)
diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md
@@ -2,8 +2,7 @@
## [Unreleased]
- **Deploys are pinned and checked live.** wrangler is an exact dependency of the workspace (4.147.0), so a deploy runs the version installed with the code instead of whatever `pnpm dlx` fetched that day, and every deploy names its branch: production is `--branch main`, never taken from the checkout it ran in (where a "production" deploy from a feature branch used to land as a preview). The publish stages' deploy (release 18) refuses before wrangler runs when there is no Cloudflare credential at all — "set CLOUDFLARE_API_TOKEN in .env" — and says "REFUSED by Cloudflare — the API token was not accepted" when Cloudflare rejects one; it refuses a production deploy of a build made from a branch other than `main`. After each deploy it reads `corpus.json` at the site's address twice, as a visitor would and cache-busted, and records the verdict: ok, stale-edge (the deployment is right, Cloudflare's edge still serves an older copy), mismatch, or unreachable. A verdict short of ok is a warning in the log; the deploy itself succeeded. What each target last shipped, where, and how it read is kept in `deployed.json` beside its build.
-- **The hub builds again.** Since 2026-10-05 every hub build was refused as "still carries a site's data (reports, m)": the export app's own report and moment pages are part of every build, the hub's included. A hub build is now refused only for report data a site's build wrote — a report index, a report's page, citations, exports or history, a moment — and still for any other site data.
-- **Withdrawn X posts ship tombstones.** While X posts are private, a public site's build no longer just leaves an X channel's posts out: at every path they were served from it ships an empty stand-in — the channel's posts manifest with no pages, and an empty page for each page the channel has — served uncached. The hub, which carries no posts, ships the same for every X channel a public site carries, with an empty posts manifest; a channel only private sites carry is never named on the hub. Leaving a path out of a deploy does not take it off Cloudflare's edge, which kept serving a withdrawn copy for up to a week; a changed object at the same path replaces it. The hub's deploy reads each of those paths back.
+- **Withdrawn X posts ship tombstones.** While X posts are private, a public site's build no longer just leaves an X channel's posts out: at every path they were served from it ships an empty stand-in — the channel's posts manifest with no pages, and an empty page for each page the channel has — served uncached. The hub, which carries no posts, ships the same for every X channel a public site carries, with an empty posts manifest; a channel only on a private site, or on no site, is never named on the hub. Leaving a path out of a deploy does not take it off Cloudflare's edge, which kept serving a withdrawn copy for up to a week; a changed object at the same path replaces it. The hub's deploy reads each of those paths back.
- **Publishing is stages, from the command line: `archilyzer publish`.** `publish index` updates the index — the LMDB index, the stats datasets and the chart templates, in one child process with an 8 GB heap — and writes an index stamp (`export/.export-index/stamp.json`) naming, for each site, a signature of everything that site's build reads. `publish build <id|all>` builds a site from that index (no data phase of its own) into its own bundle, `export/.export-builds/<id>/out`, and stamps it (`built.json`); a site whose bundle already matches the index is a no-op unless `--force`. `publish deploy <id|all> [--preview <branch>] [--to local]` ships that bundle — to Cloudflare Pages, or with `--to local` into the directory the docker `site` service serves — and records the deploy (`deployed.json`); deploying the same build again is a no-op unless `--force`. `all` passes over private sites and, to Pages, sites with no Pages project; any other site it cannot deploy is a failure, said after the rest are tried. `publish hub [--deploy]` and `publish homepage [--deploy]` do the same for the hub (`_hub/out`) and the homepage. A stage whose input is not there says so and exits 3: "update the index first", "no build of jeralyzer — archilyzer publish build jeralyzer". Production refuses a bundle built on a branch other than `main`, or with no branch recorded (a detached checkout; an image sets `ARCHILYZER_BRANCH`) — a preview of it is fine. Exit codes: 0 done or nothing to do, 1 failed, 2 usage, 3 precondition not met, 130 cancelled.
- **One publish at a time on a machine.** Every stage takes `export/.export-builds/.publish.lock`; a second one — an `archilyzer publish` beside the editor, say — waits for it, saying once whom it waits for, and Ctrl-C ends the wait. A lock left by a process that is gone is taken over. A cancelled stage takes the whole process tree it started with it (`next build`'s workers, wrangler, docker).
- **`export/out` is now a link to the bundle built last.** Each site, and the hub, keeps its own bundle, so building one site no longer replaces another's; `export/out` points at whichever was built most recently, so `serve out` and anything else that read it keeps working.
diff --git a/plans/release-18.md b/plans/release-18.md
@@ -395,8 +395,10 @@ export 6910), one Opus implementer, beside S1 (stage core) and S5's image half.
from `dockerSiteStagingDir` (`<id>/.r2-staging`), a preview logging `PREVIEW_SHARES_ARCHIVES_NOTICE`; the pinned
wrangler with the classifier on its stream; the live check; and LAST the record, written atomically (temp +
rename) into `deployed.json` (`production` / `local` / `previews[branch]`, other slots kept). A refusal or failure
- throws `DeployStageError` (`exitCode` 1 refused/failed, 2 a request it cannot run — a bad branch name, local with a
- preview, the hub locally, a kind and target that do not match — 3 precondition not met, 130 cancelled) whose message
+ throws `DeployStageError` (`exitCode` 1 failed or refused at the credential/destination step, 2 a request it cannot
+ run — a bad branch name, local with a preview, the hub locally, a kind and target that do not match — 3 precondition
+ not met: no build, a private site, no Pages project, a production build not of main, a bundle guard — the codes
+ `needs()` gives the same refusals; 130 cancelled) whose message
is the line the log already ends on, word for word, and `deployed.json` is untouched. `deploy-hub` and
`deploy-homepage` take only `_hub` / `_homepage`, and a site deploy refuses either. Wrangler's own lines reach the log
through the same `log()`, each ending in a newline. A local destination that holds the checkout, the corpus, the
@@ -656,13 +658,26 @@ written; `stage deploy-site smoke --preview smoke` 0 through the stage row (fake
is the unit tests'; the same again: no-op (fresh); `E2E_FAKE_WRANGLER_AUTH_FAIL=1`: exit 1, `[deploy] REFUSED by
Cloudflare — the API token was not accepted` once, then `[stage] deploy-site smoke: FAILED (exit 1)`, no record;
`stage deploy-hub _hub --preview smoke` 0 (`previews.smoke` in `_hub/deployed.json`); `stage deploy-homepage smoke`
-refused by S1's argv parser ("the target is _homepage", exit 1); `publish deploy smoke --to local` 0 (195 files into
+refused by S1's argv parser ("the target is _homepage", exit 2 — the step's 1 was `pnpm exec`'s); `publish deploy smoke --to local` 0 (195 files into
the scratch `siteout`, `local` recorded with `liveCheck: null`).
| Run | At | Specs | Result |
|---|---|---|---|
| 5 (editor) | `f1541070` | the five + `build`'s neighbours: `deploy-page`, `site-publish-preview`, `sites-homepage`, `build`, `site-scope`, `cut-release` | not run: the export webServer timed out (120 s) — the smoke, run while this one waited 16 min in the queue, had composed the hub into this worktree's `export/public` (no `summaries/`). `export/public` restored (`git clean -X` + the fixture links), then run 6 |
| 6 (editor) | `f1541070` | the same six | **35 passed**, 0 failed, 2.3 min (no queue wait) |
+
+**The hub fix is a record, not a changelog line:** the regression (main `5c09cd7b`, 2026-10-05) is in no release, so
+no user ever saw it.
+
+#### Round-2 review cleanups (review SHIP, `$T/s2-review-2.md`)
+
+| # | Fix | Commit |
+|---|---|---|
+| L1 | `localDestProblem` resolves symlinks (realpath of the destination's nearest existing ancestor, and of every protected root) and refuses a destination INSIDE the builds dir, `export/` (so `export/out`, the link to the last bundle) or the corpus, as well as one containing them or the checkout; tests: `ARCHILYZER_SITE_OUT=<export>/out` refused with the bundle untouched, and a directory inside each of the three refused and not made | `c5f9100f` |
+| L2 | The deploy body's exit codes agree with `needs()`: a private site, no Pages project (site or hub), a production build not of main, and the bundle guards (incl. the homepage's source gate) exit 3, not 1 | `c5f9100f` |
+| L3 | `deployStage.ts`'s header: S1 has landed; `builtAfter` is `needs()`'s; the exit codes per step | `c5f9100f` |
+| L4 | `stageRun.test.ts` proves the local copy by files the destination did not have (`corpus.json`, `site.json`) and the replaced `index.html` | `c5f9100f` |
+| L7 | The smoke row: the mismatched homepage deploy exits 2; the changelog's tombstone bullet says "a channel only on a private site, or on no site"; the "hub builds again" changelog bullet removed (the statement above stays) | `c5f9100f` |
### Slice S1, as shipped — the stage contract, the stamps, the lock, per-target bundles and the CLI (2026-10-06)
Branch `r18/stage-core` off `ce66f2d3` (the plan commit on `r18/integration`), worktree `~/Projects/r18-stage-core`