docs(runicnpc): the automated cutover as built, and its dry run (D332, runicnpc-rust#25, #26)

What the build added to the plan: gate 1 also needs public to name the
build staging names; gate 2 also needs the field list to match; gate 3
proves the update from the rig's appmanifest before rnpc.status; gates are
kept per wipe, public manifest and edge commit, with a 3-hour cool-down for
a failed Carbon or live attempt; the merge is a merge commit pinned with
head_commit_id; Closes since the last cutover's merge-base, leaving out PRs
main already has; dispatches dry unless dry_run is exactly false, and
code_ref for dry runs; rust-carbon started again if left stopped; 240
minutes. Then CI run 54's dry run: every gate refused for the right reason
on the 22 October public build.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
This commit is contained in:
2026-11-02 07:11:19 -06:00
parent 4224c3b0db
commit 31d7301a8e

View File

@@ -155,7 +155,7 @@ architectural or design decision is implemented.
| **D329** | **The Kits reload the first load asks for is owed until Kits has the example kits.** The reload ran on a one-second timer, and a timer dies with its plugin. In the 9d session RunicNPC was loaded again inside that second on Carbon, the reload was lost, and since `examplesWritten` was already set the examples stayed refused until Kits was reloaded by hand. Now `state.json` holds `kitsReloadOwed`: every load, and Kits coming back, clears it once Kits knows every kit owed, and otherwise asks for the reload again, at most three times, then warns with the command to run. Oxide never showed it, because loading RunicNPC recompiles and reloads Kits there (`// Requires: Kits`). The org lead chose the fix over recording it (2026-10-09). | Record it as a hot-load artifact, with the remedy in INSTALL.md. |
| **D330** | **RunicNPC's stage 9 release is v1.0.0, cut by a `feat!:` commit.** The release engine takes its bump from the non-merge commit subjects since the last tag. Every stage had landed as `feat:`, which would have cut v0.2.0. runicnpc-rust#18 (`feat!:`, with a `BREAKING CHANGE:` footer) changed only the README, and made the cutover cut v1.0.0. The API stays 6 (2026-10-09). | Accept v0.2.0 and change the plan's wording. |
| **D331** | **A kit's extra is found by its item definition when it has no held entity.** Rust's November 2026 build adds the server variable `ItemModEntity.playerOnlyEntityBeltOnly` (on by default): an item Rust marks player-only, the medical syringe among them, has a held entity only while it is in the belt. A kit puts its syringes in the main inventory, so D260's heal, which looked for an item whose held entity is a `MedicalTool`, found none and never healed (runicnpc-rust#20). Every extra (heal, melee, flamethrower, grenade, rocket) now reads the held entity when there is one and the item definition's entity prefab when there is not; holding the item moves it to the belt, where Rust makes its entity. A full belt means no heal, not an error. The org lead chose all five extras over heal alone (2026-11-02). | Fix the heal alone. |
| **D332** | **On a forced-wipe weekend a workflow merges `edge` into `main` on its own, once five gates pass** (9c, the automated cutover): Rust's public build changed on wipe day; Oxide's public release has caught up; a Carbon boot on public Rust says `missing=- added=-`; the drill's live half passes on public Rust with `edge`'s code; PR checks pass. It runs hourly from Thursday 17:00 UTC to Sunday 23:00 UTC, then leaves it to the org lead. The issues it closes are the `Closes #N` lines of the PRs merged into `edge` since the last release; it merges with a token locked to `runicnpc-rust` (`RUNICNPC_TOKEN`). RunicNPC only. The org lead chose it (2026-11-02): gate 1 is "public changed", since the daily drill tests `main` and so cannot vouch for `edge`; the schedule reaches `main` once by a `ci:` PR. | Approve the cutover by hand each wipe (D226 as it was); gate on "the staging drill passed this build"; give up Friday 23:00 UTC. |
| **D332** | **On a forced-wipe weekend a workflow merges `edge` into `main` on its own, once five gates pass** (9c, the automated cutover): Rust's public build changed on wipe day, to the build staging names; Oxide's public release has caught up; a Carbon boot on public Rust says `missing=- added=-`; the drill's live half passes on public Rust with `edge`'s code; PR checks pass. It runs hourly from Thursday 17:00 UTC to Sunday 23:00 UTC, then leaves it to the org lead. The issues it closes are the `Closes #N` lines of the PRs merged into `edge` since the last release; it merges with a token locked to `runicnpc-rust` (`RUNICNPC_TOKEN`), pinned to the `edge` commit the gates ran on. A failed Carbon or live attempt cools for 3 hours. RunicNPC only. The org lead chose it (2026-11-02): gate 1 is "public changed", since the daily drill tests `main` and so cannot vouch for `edge`; the schedule reaches `main` once by a `ci:` PR. | Approve the cutover by hand each wipe (D226 as it was); gate on "the staging drill passed this build"; give up Friday 23:00 UTC. |
**Borrowing, not copying.** NpcSpawn states no licence at all, so its source grants us nothing and is read only as a
description of *what* can be done in Rust. HumanNPC is MIT on uMod, which is GPL-compatible, but §1.2 rules out its
@@ -1805,50 +1805,77 @@ patched against patched, so only Rust's own changes show. Hurt, OnAttacked, Heal
Oxide's 31 October staging build boots on it): stage 5 47 pass, 0 fail, and the whole harness 266 pass, 0 fail,
`place` included, so cf3997b's spots off the navmesh have now run live too.
**9c, the automated cutover (D332, planned 2026-11-02).** The org lead approves no merge on a wipe day. A workflow
in `runicnpc-rust`, `.gitea/workflows/cutover.yml`, merges `edge` into `main` once Rust's update is live and the
plugin is proven on it; `release.yml` then cuts the release as on every push to `main`. The other four Rust repos
keep their cutover by hand (D313); a case for them is a plan of its own.
**9c, the automated cutover (D332, built 2026-11-02, runicnpc-rust#25 and #26).** The org lead approves no merge on
a wipe day. A workflow in `runicnpc-rust`, `.gitea/workflows/cutover.yml`, merges `edge` into `main` once Rust's
update is live and the plugin is proven on it; `release.yml` then cuts the release as on every push to `main`. The
other four Rust repos keep their cutover by hand (D313); a case for them is a plan of its own. The logic is
`tools/drill/cutover.js`, with its tests in `tools/drill/cutover.test.js` (run by pr-checks).
- **When.** Hourly, from 17:00 UTC on the forced-wipe Thursday (the first Thursday, D318) until Sunday 23:00 UTC.
Nothing is dated in the workflow: a new `wipeWeekend(now)` helper beside `nextWipe` says whether this hour is
in that span (`windowDays` cannot: on the Thursday itself `nextWipe` already answers next month). It shares the
drill's concurrency group, `staging-drill`, so it never runs while the daily drill has `rust-staging`. Nothing
to merge (`edge` already in `main`) ends it at once.
- **When.** Hourly, from 17:00 UTC on the forced-wipe Thursday (the first Thursday, D318) until Sunday 23:00 UTC,
plus one last hour after the deadline to say it gave up. Nothing is dated in the workflow: `wipeWeekend(now)`
says whether this hour is in that span (`windowDays` cannot: on the Thursday itself `nextWipe` already answers
next month). The cron fires every Thursday evening and every Friday to Sunday, and the window step ends the
runs outside the first-Thursday weekend. It shares the drill's concurrency group, `staging-drill`, so it never
runs while the daily drill has `rust-staging`. Nothing to merge (`edge` already in `main`) ends it at once.
- **Five gates, cheapest first; it merges only when all pass:**
1. **Rust's public build changed on wipe day.** The drill's static half run with `BRANCH=public`: the public
depot manifest is dated the wipe day or later. Until then it waits, quietly.
2. **Oxide has caught up.** The same static run reports Oxide's public release `0` members behind Rust's public
assemblies (D317), and RunicNPC from `edge` compiles against them.
3. **A Carbon boot on public Rust.** `rust-carbon` is restarted (its egg updates Rust and Carbon), loads `edge`'s
`RunicNPC.cs`, and `rnpc.status` must say `framework=carbon`, `navmesh=ready` and `missing=- added=-`
(D232: only Carbon's unmodified assembly makes `added` meaningful). The rig is left running.
4. **The drill again, on live Rust.** Facepunch sometimes changes or holds a build between staging and live at
the last minute, so a staging pass does not count: the drill's live half runs on `rust-staging` with public
Rust, Oxide's public build, and `edge`'s plugin and harness, and every harness check must pass with no
RunicNPC error line in Oxide's log. The drill's `branch: public` mode runs it; its report gains an outcome
the workflow reads (today a public run ends before it writes one).
5. **PR checks** on the cutover PR (`plugin-checks`), on its head commit.
- **Each passed gate is kept** for that public manifest, beside the drill's own state in `drill.json` on the
`drill` branch (`cutover: {wipe, manifest, gates, pr, merged}`), so an hourly retry never re-runs a gate that
passed, above all the hour-long gate 4. A new public manifest that weekend starts the gates again.
- **The PR.** It opens one `edge` → `main` PR (or reuses the open one) titled for the wipe and the build. Its body
lists every `Closes #N` line found in the bodies of PRs merged into `edge` since the last `v*` tag, so the issues
1. **Rust's public build changed, from staging.** The drill's static half runs twice, `BRANCH=public` and
`BRANCH=staging`: the public depot manifest must be dated the wipe day or later AND be the same manifest
staging names, so the staging build was really promoted to live (the org lead's condition: Facepunch
sometimes holds or changes it at the last minute). If Facepunch puts a newer build on staging before public
is seen, this waits to the deadline and hands back, which is safe.
2. **Oxide has caught up.** The public static run reports Oxide's public release `0` members behind Rust's public
assemblies (D317), RunicNPC from `edge` compiles against them, and the swap's field list is the same as
public Rust's.
3. **A Carbon boot on public Rust.** `rust-carbon` is restarted (its egg updates Rust and Carbon) with `edge`'s
`RunicNPC.cs`. It must first prove the update: depot 258552's manifest in `/steamapps/appmanifest_258550.acf`
equals the public manifest. Then `rnpc.status` must say `framework=carbon`, `navmesh=ready` and
`missing=- added=-` (D232: only Carbon's unmodified assembly makes `added` meaningful). The rig is left running.
4. **The drill again, on live Rust.** The drill's live half (`drill.js live --branch public`) on `rust-staging`
with public Rust, Oxide's public build and `edge`'s plugin and harness: it must run, the harness finish with
at least one pass and no fail, and no finding (an Oxide error line is one). The workflow reads `live.json`.
5. **PR checks**, fresh, on the cutover PR's head, just before the merge: never kept.
- **What is kept.** Each passed gate is kept in `drill.json` on the `drill` branch (`cutover`, beside the drill's
own keys, which each side leaves alone) for one wipe, one public manifest and one `edge` commit; any change of
the three runs every gate again, so code merged into `edge` mid-weekend is never shipped on an older proof. The
state is written after every gate, before any PR call. A failed Carbon or live attempt cools for 3 hours before
it is tried again on the same build and commit (org lead, 2026-11-02): a failing Carbon gate would otherwise
restart `rust-carbon` every hour.
- **The PR.** It opens one `edge` → `main` PR once gate 1 passes, titled for the wipe and the build (an open one,
made by hand, is adopted: its title stays, and only a marked block of its body is written). The body lists every
`Closes #N` found in the bodies of PRs merged into `edge` since the last cutover (after the committer date of
`merge-base origin/main origin/edge`, leaving out any PR whose merge commit `main` already has), so the issues
they fixed close when it merges into `main`, the default branch; a merge into `edge` closes nothing. No issue
number is written anywhere in the workflow.
- **The merge.** `POST …/pulls/{n}/merge` with `RUNICNPC_TOKEN`, a personal access token the org lead made
for this repo and locked to it, used in that one step only; a push made with the runner's own token would not start
`release.yml`. `REGISTRY_TOKEN` and `PTERODACTYL_DRILL_KEY` serve every other step, as in the drill. After the
merge it waits for the release and comments its version on each issue that closed.
- **When a gate blocks.** One comment a day on the cutover PR naming the gate and why (opened once gate 1 has
passed, so a quiet Thursday morning writes nothing). At Sunday 23:00 UTC it stops with a last comment, and the
org lead merges by hand as before.
- **Trying it.** `workflow_dispatch` with `dry_run` (every gate runs and reports; no PR, no merge, no comment) and
`force` (outside the wipe weekend). The gate decisions and the `Closes` collection are pure functions with tests
in `tools/drill/`, which pr-checks already runs.
- **First run.** Gitea fires a schedule only from the default branch, so the workflow reaches `main` once by a
`ci:` PR holding only it and its tests, merged by the org lead this week; `release.yml` cuts nothing for `ci:`,
and the field list stays on `edge`. The same commits go to `edge`. From then on no wipe needs an approval.
number is written anywhere in the workflow, and an issue number inside a gate's reason is neutralised so Gitea
never acts on it.
- **The merge.** A merge commit (not a squash: `release.yml` reads the `fix`/`feat` subjects of non-merge commits),
pinned with `head_commit_id` to the commit the gates ran on, and refused when the PR's head is not that commit.
It uses `RUNICNPC_TOKEN`, a personal access token the org lead made for this repo and locked to it, in that one
step only; a push made with the runner's own token would not start `release.yml`. `REGISTRY_TOKEN` and
`PTERODACTYL_DRILL_KEY` serve every other step, as in the drill. After the merge it waits up to 30 minutes for
the release and comments its version on each issue that closed, once.
- **When a gate blocks.** One comment a day on the cutover PR naming the gate and why, and when a cooling gate
will be tried again. At Sunday 23:00 UTC it stops with a last comment (or "every gate passed; the merge failed:
…"), and the org lead merges by hand as before.
- **The rigs.** Gate 3 touches `rust-carbon` only; gate 4 `rust-staging`, and `rust-carbon` when both 6000-map rigs
run (D309). `rust-oxide`, the demo rig, is never touched. If the job leaves `rust-carbon` stopped where it found
it running, its last step starts it again. The job may take 240 minutes (about 100 is usual).
- **The copy on `main` is the trigger.** It holds the schedule and the step wiring; every step checks out `edge`
and runs `edge`'s code, so the step outputs are a contract between the two (a test holds them equal), and the
review of PRs into `edge` is the real gate, since `edge`'s code runs with the merge token.
- **Trying it.** `workflow_dispatch` is dry unless `dry_run` is exactly `false`: every gate runs and reports to
the job summary; nothing is opened, edited, commented, merged or written. `force` works outside the weekend, and
`code_ref` lets a dry run use another branch's code (a real run always uses `edge`).
- **First run.** Gitea fires a schedule only from the default branch, so the workflow reached `main` alone by a
`ci:` PR (runicnpc-rust#26), after the feature PR into `edge` (#25); `release.yml` cuts nothing for `ci:`, and
the field list stays on `edge`. From then on no wipe needs an approval.
- **The dry run before the first wipe** (CI run 54, 2026-11-02, about 50 minutes): every gate refused, each for the
right reason. Public Rust was still the 22 October build (manifest 1632958440323253420; staging
8152502841220737368), which lacks the three fields `edge` carries for November, and gates 2, 3 and 4 each saw it
(`missing=BasePlayer.timeSinceAiming,BaseEntity.ServerPreProcessed,BaseNetworkable.ignoreNetworkLod`). The
drill's panel key wrote to, restarted and read `rust-carbon`; the live half on public Rust ran 266 pass, 0 fail;
`rust-carbon` was running before and after; nothing was opened, edited, commented or merged. Not provable before
a wipe: the pass path, and Gitea's merge with `head_commit_id` and the token's rights. Either failing hands back
at the deadline.
**9d. The player walk (D312).** Both rigs are updated first (Rust, Oxide, Carbon) and their builds stated. One
checklist, published as a page, for one session per rig. I prepare the rigs, the walk site and a linked test