Compare commits

..

6 Commits

Author SHA1 Message Date
f856ba69f7 docs(runicnpc): change both copies of cutover.yml together (the #29 conflict)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Claude-Session: https://claude.ai/code/session_014HJEmgsXtLR5kLfh7f9y8P
2026-11-06 07:00:53 -06:00
2013db016e docs(runicnpc): the cutover's gate 1 asks Steam when public was set (D397)
The 2026-11-05 weekend never cut over: gate 1 wanted the public depot manifest
dated the wipe day and equal to staging's, and neither holds. The wipe build
went live dated 11-04, and Facepunch promotes a hidden `release` branch, not
staging. D397 records the new gate 1 (steamcmd's branches.public.timeupdated on
the wipe day or later, and Steam's public manifest equal to the one downloaded),
the alternatives, and what the first weekend showed; D332 points at it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014HJEmgsXtLR5kLfh7f9y8P
2026-11-06 04:39:34 -06:00
c7fbf3291c Merge pull request 'docs(demo): the wipe prunes the images its pull replaced' (#413) from docs/ci-self-cleanup into main
Reviewed-on: #413
2026-11-04 12:09:12 +00:00
d115a577d0 docs(demo): the wipe prunes the images its pull replaced
DEMO_MODE §7.3 gains step 7, matching website's demo-wipe.yml.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-11-02 11:21:03 -06:00
ba3b9b9520 Merge pull request 'docs(runicnpc): the automated cutover as built, and its dry run (D332)' (#412) from docs/runicnpc-cutover-as-built into main
Reviewed-on: #412
2026-11-02 15:39:21 +00:00
31d7301a8e 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
2026-11-02 07:11:19 -06:00
2 changed files with 85 additions and 42 deletions

View File

@@ -155,7 +155,8 @@ 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 (how that is read: D397); 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. |
| **D397** | **The cutover's gate 1 asks Steam when the public branch was set, not the depot manifest's date or staging's manifest.** It reads steamcmd's app info for 258550 (`+app_info_print`, anonymous, kept by the public static half as `appinfo.txt`): `depots.branches.public.timeupdated` must be on the wipe day or later, UTC, and the Linux depot (258552) manifest Steam names for `public` must be the one the static half downloaded. The first gate 1 blocked the whole 2026-11-05 weekend: the wipe build went live that evening with a manifest dated 11-04 (the date is when Facepunch built it), and public never names staging's manifest (Facepunch promotes a hidden `release` branch, "Release, before public", and staging had moved on before the wipe). The later gates, the live drill above all, still prove the plugin on the build that is really live. The org lead chose it (2026-11-06). | A baseline: the public manifest the first hour of the weekend sees, passing once it changes (and is dated at most 3 days before the wipe); drop gate 1 and lean on gates 2 to 4. |
**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 +1806,90 @@ 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. **Steam set Rust's public branch on the wipe day (D397).** The public static half (`BRANCH=public`) also
runs steamcmd (`+login anonymous +app_info_update 1 +app_info_print 258550`; it fetches steamcmd and, on a
bare runner, the 32-bit `lib32gcc-s1` it needs) and keeps the output as `appinfo.txt`. The public branch's
`timeupdated` must be on the wipe day or later, UTC, and the Linux depot manifest Steam names for `public`
must be the one the static half downloaded; when the two reads disagree (a push between them), the hour
blocks and the next tries again. A steamcmd failure is the static half's `steamNote`, and the gate blocks.
Facepunch holding or changing the update at the last minute (the org lead's condition) shows as a public
branch not yet set, or set again; a hotfix is a new manifest, so every gate runs again on it.
*As first built*, gate 1 wanted the public depot manifest dated the wipe day AND equal to staging's
manifest, and blocked every hour of the 2026-11-05 weekend: the wipe build (manifest
`3647035647797862067`) went live that evening dated 11-04, a hotfix (`547616323393184824`) followed on
11-06, and staging named neither (it was `2915291852004903570`, then `4006809933087453123`). Steam's app
info shows why: Facepunch promotes a hidden `release` branch ("Release, before public") to `public`, and
`last-month` keeps the previous build.
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. **Change both copies
together, byte for byte:** `main`'s and `edge`'s were added by separate commits, so a change to `edge`'s copy
alone makes the cutover PR an add/add conflict. That happened on 2026-11-06: runicnpc-rust#28 changed `edge`'s
copy only, and the cutover PR #29 could not merge though every gate had passed, until #30 gave `main` the same
file. Mend it on `main`, not `edge`: a new `edge` commit runs every gate again.
- **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

View File

@@ -403,6 +403,8 @@ current `rust-oxide`. **Done:** the org lead created the subuser and added the s
6. Smoke check, after a bounded wait: `GET /api/v1/public/status` answers, and the Rust server
reports a live connection. A rig that has not come back fails the run, and the site still
serves with the server shown offline.
7. `docker image prune -f`, whatever the run's outcome: a pull that found a newer image leaves the
old one untagged on the demo host every cycle. Dangling images only; nothing tagged or in use.
### 7.4 What a visitor sees