docs(runicnpc): the automated cutover as built, and its dry run (D332) #412

Merged
whitlocktech merged 1 commits from docs/runicnpc-cutover-as-built into main 2026-11-02 09:39:24 -06:00

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. | | **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. | | **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. | | **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 **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 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, 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. `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 **9c, the automated cutover (D332, built 2026-11-02, runicnpc-rust#25 and #26).** The org lead approves no merge on
in `runicnpc-rust`, `.gitea/workflows/cutover.yml`, merges `edge` into `main` once Rust's update is live and the a wipe day. A workflow in `runicnpc-rust`, `.gitea/workflows/cutover.yml`, merges `edge` into `main` once Rust's
plugin is proven on it; `release.yml` then cuts the release as on every push to `main`. The other four Rust repos update is live and the plugin is proven on it; `release.yml` then cuts the release as on every push to `main`. The
keep their cutover by hand (D313); a case for them is a plan of its own. 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. - **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 plus one last hour after the deadline to say it gave up. Nothing is dated in the workflow: `wipeWeekend(now)`
in that span (`windowDays` cannot: on the Thursday itself `nextWipe` already answers next month). It shares the says whether this hour is in that span (`windowDays` cannot: on the Thursday itself `nextWipe` already answers
drill's concurrency group, `staging-drill`, so it never runs while the daily drill has `rust-staging`. Nothing next month). The cron fires every Thursday evening and every Friday to Sunday, and the window step ends the
to merge (`edge` already in `main`) ends it at once. 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:** - **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 1. **Rust's public build changed, from staging.** The drill's static half runs twice, `BRANCH=public` and
depot manifest is dated the wipe day or later. Until then it waits, quietly. `BRANCH=staging`: the public depot manifest must be dated the wipe day or later AND be the same manifest
2. **Oxide has caught up.** The same static run reports Oxide's public release `0` members behind Rust's public staging names, so the staging build was really promoted to live (the org lead's condition: Facepunch
assemblies (D317), and RunicNPC from `edge` compiles against them. sometimes holds or changes it at the last minute). If Facepunch puts a newer build on staging before public
3. **A Carbon boot on public Rust.** `rust-carbon` is restarted (its egg updates Rust and Carbon), loads `edge`'s is seen, this waits to the deadline and hands back, which is safe.
`RunicNPC.cs`, and `rnpc.status` must say `framework=carbon`, `navmesh=ready` and `missing=- added=-` 2. **Oxide has caught up.** The public static run reports Oxide's public release `0` members behind Rust's public
(D232: only Carbon's unmodified assembly makes `added` meaningful). The rig is left running. assemblies (D317), RunicNPC from `edge` compiles against them, and the swap's field list is the same as
4. **The drill again, on live Rust.** Facepunch sometimes changes or holds a build between staging and live at public Rust's.
the last minute, so a staging pass does not count: the drill's live half runs on `rust-staging` with public 3. **A Carbon boot on public Rust.** `rust-carbon` is restarted (its egg updates Rust and Carbon) with `edge`'s
Rust, Oxide's public build, and `edge`'s plugin and harness, and every harness check must pass with no `RunicNPC.cs`. It must first prove the update: depot 258552's manifest in `/steamapps/appmanifest_258550.acf`
RunicNPC error line in Oxide's log. The drill's `branch: public` mode runs it; its report gains an outcome equals the public manifest. Then `rnpc.status` must say `framework=carbon`, `navmesh=ready` and
the workflow reads (today a public run ends before it writes one). `missing=- added=-` (D232: only Carbon's unmodified assembly makes `added` meaningful). The rig is left running.
5. **PR checks** on the cutover PR (`plugin-checks`), on its head commit. 4. **The drill again, on live Rust.** The drill's live half (`drill.js live --branch public`) on `rust-staging`
- **Each passed gate is kept** for that public manifest, beside the drill's own state in `drill.json` on the with public Rust, Oxide's public build and `edge`'s plugin and harness: it must run, the harness finish with
`drill` branch (`cutover: {wipe, manifest, gates, pr, merged}`), so an hourly retry never re-runs a gate that at least one pass and no fail, and no finding (an Oxide error line is one). The workflow reads `live.json`.
passed, above all the hour-long gate 4. A new public manifest that weekend starts the gates again. 5. **PR checks**, fresh, on the cutover PR's head, just before the merge: never kept.
- **The PR.** It opens one `edge` → `main` PR (or reuses the open one) titled for the wipe and the build. Its body - **What is kept.** Each passed gate is kept in `drill.json` on the `drill` branch (`cutover`, beside the drill's
lists every `Closes #N` line found in the bodies of PRs merged into `edge` since the last `v*` tag, so the issues 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 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. number is written anywhere in the workflow, and an issue number inside a gate's reason is neutralised so Gitea
- **The merge.** `POST …/pulls/{n}/merge` with `RUNICNPC_TOKEN`, a personal access token the org lead made never acts on it.
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 - **The merge.** A merge commit (not a squash: `release.yml` reads the `fix`/`feat` subjects of non-merge commits),
`release.yml`. `REGISTRY_TOKEN` and `PTERODACTYL_DRILL_KEY` serve every other step, as in the drill. After the pinned with `head_commit_id` to the commit the gates ran on, and refused when the PR's head is not that commit.
merge it waits for the release and comments its version on each issue that closed. It uses `RUNICNPC_TOKEN`, a personal access token the org lead made for this repo and locked to it, in that one
- **When a gate blocks.** One comment a day on the cutover PR naming the gate and why (opened once gate 1 has step only; a push made with the runner's own token would not start `release.yml`. `REGISTRY_TOKEN` and
passed, so a quiet Thursday morning writes nothing). At Sunday 23:00 UTC it stops with a last comment, and the `PTERODACTYL_DRILL_KEY` serve every other step, as in the drill. After the merge it waits up to 30 minutes for
org lead merges by hand as before. the release and comments its version on each issue that closed, once.
- **Trying it.** `workflow_dispatch` with `dry_run` (every gate runs and reports; no PR, no merge, no comment) and - **When a gate blocks.** One comment a day on the cutover PR naming the gate and why, and when a cooling gate
`force` (outside the wipe weekend). The gate decisions and the `Closes` collection are pure functions with tests will be tried again. At Sunday 23:00 UTC it stops with a last comment (or "every gate passed; the merge failed:
in `tools/drill/`, which pr-checks already runs. …"), and the org lead merges by hand as before.
- **First run.** Gitea fires a schedule only from the default branch, so the workflow reaches `main` once by a - **The rigs.** Gate 3 touches `rust-carbon` only; gate 4 `rust-staging`, and `rust-carbon` when both 6000-map rigs
`ci:` PR holding only it and its tests, merged by the org lead this week; `release.yml` cuts nothing for `ci:`, run (D309). `rust-oxide`, the demo rig, is never touched. If the job leaves `rust-carbon` stopped where it found
and the field list stays on `edge`. The same commits go to `edge`. From then on no wipe needs an approval. 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 **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 checklist, published as a page, for one session per rig. I prepare the rigs, the walk site and a linked test