Files
runicnpc-rust/.gitea/workflows/cutover.yml
wtclaude e5548472da
All checks were successful
PR Checks / plugin-checks (pull_request) Successful in 29s
fix(cutover): gate 1 asks Steam when public was set, not the manifest's date or staging (D332)
The 2026-11-05 weekend never cut over: gate 1 blocked every hourly run. Both of
its checks rested on assumptions the wipe disproved:

- The wipe build went live Thursday evening, but its depot manifest is dated
  11-04: the date DepotDownloader prints is when Facepunch built it.
- Public never names staging's manifest. Facepunch promotes a hidden `release`
  branch ("Release, before public"), and staging had moved on before the wipe.

Gate 1 now reads Steam's app info (steamcmd +app_info_print 258550, kept by
static.sh as appinfo.txt with BRANCH=public): branches.public.timeupdated must be
on the wipe day or later, and the Linux depot manifest Steam names for public
must be the one the static half downloaded. The staging static half is gone from
edge's cutover.yml; --staging-work is still accepted, since main's copy passes it
until the cutover merge carries the new wiring over.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014HJEmgsXtLR5kLfh7f9y8P
2026-11-06 04:37:50 -06:00

259 lines
14 KiB
YAML

# The wipe-day cutover (docs/runicnpc/PLAN.md stage 9c, D332): `edge` merges into `main` by itself.
#
# The org lead approves no merge on a wipe day. Rust changes on the monthly forced wipe, the first
# Thursday (D318), and RunicNPC must be proven on the public build before it is released. This job runs
# hourly from 17:00 UTC on that Thursday until Sunday 23:00 UTC and merges `edge` into `main` once five
# gates have passed, cheapest first (the decisions are pure functions in tools/drill/cutover.js, tested
# by cutover.test.js, which PR Checks runs):
#
# 1. public Steam set Rust's public branch to its build on the wipe day or later (steamcmd's app info,
# branches.public.timeupdated, which the static half keeps as appinfo.txt) AND the Linux depot
# manifest Steam names for public is the one the static half downloaded. (The first design used
# the manifest's date and staging's manifest; neither works, see tools/drill/cutover.js.)
# 2. oxide Oxide's public build has caught up (0 members behind), the swap's field list is unchanged
# and RunicNPC compiles.
# 3. carbon `rust-carbon` restarts on public Rust with edge's RunicNPC.cs: its appmanifest names the
# public manifest, and rnpc.status says framework=carbon, navmesh=ready, missing=- added=-.
# The rig is left as it was found (running stays running).
# 4. live the drill's live half (drill.js live --branch public) on `rust-staging`: every harness check
# passes, no finding.
# 5. checks `plugin-checks` is green on the cutover PR's head commit, which must be the commit the gates
# ran on. Never kept: it runs fresh, right before the merge.
#
# Each passed gate is kept in drill.json (the `cutover` key, on the `drill` branch) for that wipe, that
# public manifest and that `edge` commit; a change of any of them starts the gates again. A failed carbon or
# live attempt is not repeated for three hours for the same manifest and commit. As soon as gate 1 passes it
# opens ONE edge -> main PR (or adopts the open one: its title is never changed and only the block between
# <!-- cutover:start --> and <!-- cutover:end --> of its body is written) whose block carries a `Closes #N`
# line for every issue fixed by the PRs merged into `edge` since the last cutover (merging into `main`, the
# default branch, is what closes them). When a gate blocks it comments on that PR once a day; at Sunday
# 23:00 UTC it stops with a last comment and the org lead merges by hand, as before. The merge sends the
# head commit it checked, so Gitea refuses it if the PR moved. After the merge it waits for release.yml's
# release and comments the version on each issue that closed.
#
# Sequencing inside the job: `gates` runs 1-3 and stops in front of 4, saying live=true; the live half is
# its own step (drill.js live); `gates --after-live` records 4 and runs 5. The state is pushed to the
# `drill` branch after the first `gates` too, so a long live half that runs into the job timeout does not
# lose gate 3. A last always-run step starts `rust-carbon` again if it was running when the job began and is
# offline now (that server only, power `start` only).
#
# THE MAIN / EDGE CONTRACT. The copy of this file on `main` is the trigger and the step wiring (Gitea fires
# a schedule only from the default branch). Every step checks out `edge` and runs edge's scripts, plugin
# and harness: the gates and the decisions come from edge. The step ids and the output names (open, run,
# live, merge, pr, sha, merged) are a contract between the two: cutover.test.js reads this file and fails
# when one is used here but not written by tools/drill/cutover.js, so change both on edge and carry the
# new wiring to `main` in the same release. Because edge's code runs with RUNICNPC_TOKEN in the merge step,
# PR review into `edge` is the real gate: nothing reaches `edge` unreviewed.
#
# It shares the staging drill's concurrency group, so it never runs while the daily drill has `rust-staging`.
#
# Try it: Actions -> Wipe-day cutover -> Run workflow. A dispatch is a DRY RUN unless `dry_run` is
# explicitly false (anything else, or nothing, is dry): every gate runs and reports in the job summary (it
# does restart rust-carbon and use rust-staging); no PR is opened or edited, nothing is commented or merged,
# no state is written. `force` goes outside the wipe weekend.
#
# Secrets (Settings -> Actions -> Secrets, the repository's or the organisation's):
# PTERODACTYL_DRILL_KEY — the panel client key made for the drill (D309). Never printed.
# REGISTRY_USER / REGISTRY_TOKEN — as the drill: push the `drill` branch, open and edit the PR, comment.
# RUNICNPC_TOKEN — a personal access token locked to this repository, used by the merge step ALONE: a
# push made with the runner's own token would not start release.yml.
name: Wipe-day cutover
on:
schedule:
# Thursday from 17:00 UTC, then every hour of Friday, Saturday and Sunday. (Not one line with a day of
# the month as well: cron ORs the two.) The window step ends the runs that fall outside the weekend.
- cron: '11 17-23 * * 4'
- cron: '11 * * * 5,6,0'
workflow_dispatch:
inputs:
dry_run:
description: 'Run every gate and report; open, edit, comment and merge nothing (dry unless exactly false)'
type: boolean
default: true
force:
description: 'Work outside the wipe weekend'
type: boolean
default: false
code_ref:
description: 'Branch whose scripts and plugin a DRY run uses, to rehearse a change before it reaches edge (a real run always uses edge)'
type: string
default: 'edge'
concurrency:
group: staging-drill
cancel-in-progress: false
env:
GITEA_HOST: gitea.whitlocktech.com
REPO: RunicGateway/runicnpc-rust
jobs:
cutover:
runs-on: ubuntu-latest
# Carbon's restart and navmesh, the live half (about an hour), the PR checks: about 100 minutes, 200 at the
# worst. Room for that, so a timeout never cuts off the step that starts rust-carbon again.
timeout-minutes: 240
env:
WORK: ${{ github.workspace }}/cutover-work
# cutover.js treats a dispatch as dry unless this is exactly 'false'.
DRY_RUN_INPUT: ${{ github.event.inputs.dry_run }}
steps:
# edge, always, except a dry dispatch naming another branch (code_ref). A real run on another branch could
# not merge anyway: the merge is pinned to the commit the gates ran on, which must be edge's head.
- uses: actions/checkout@v4
with:
ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.dry_run != 'false' && github.event.inputs.code_ref || 'edge' }}
fetch-depth: 0
# The commit counts and the cut-off for the Closes list are read from git.
- name: main and the tags
run: git fetch origin main edge --tags
# 22 or later: drill.js talks to the panel's console over the global WebSocket.
- uses: actions/setup-node@v4
with:
node-version: 22
- name: The wipe weekend?
id: window
run: node tools/drill/cutover.js window ${{ github.event.inputs.force == 'true' && '--force' || '' }}
- name: Anything to merge?
id: pending
if: ${{ steps.window.outputs.open == 'true' }}
run: node tools/drill/cutover.js pending ${{ (github.event.inputs.force == 'true' || (github.event_name == 'workflow_dispatch' && github.event.inputs.dry_run != 'false')) && '--force' || '' }}
# Whether rust-carbon is running now: gate 3 restarts it and the last step puts it back if needed.
- name: Note rust-carbon
if: ${{ steps.pending.outputs.run == 'true' }}
continue-on-error: true
env:
PANEL_KEY: ${{ secrets.PTERODACTYL_DRILL_KEY }}
run: node tools/drill/cutover.js rig-note --work "$WORK"
# tools/fieldlist and the compiler (Roslyn's csc.dll ships in the SDK).
- name: .NET SDK
if: ${{ steps.pending.outputs.run == 'true' }}
run: |
set -euo pipefail
curl -sSL -o "$RUNNER_TEMP/dotnet-install.sh" https://builds.dotnet.microsoft.com/dotnet/scripts/v1/dotnet-install.sh
bash "$RUNNER_TEMP/dotnet-install.sh" --channel 9.0 --install-dir "$HOME/.dotnet" > /dev/null
echo "$HOME/.dotnet" >> "$GITHUB_PATH"
echo "DOTNET_ROOT=$HOME/.dotnet" >> "$GITHUB_ENV"
- name: The drill branch (what the cutover has passed)
if: ${{ steps.pending.outputs.run == 'true' }}
run: |
set -euo pipefail
git config user.name "runicnpc-ci"
git config user.email "ci@whitlocktech.com"
rm -rf drill-state
git worktree prune
if git ls-remote --exit-code --heads origin drill > /dev/null 2>&1; then
git fetch origin drill
git worktree add -B drill drill-state origin/drill
else
# First run: a root commit with an empty tree, so the branch inherits no code history.
ROOT="$(git commit-tree "$(git hash-object -t tree /dev/null)" -m 'chore(drill): start the drill branch')"
git worktree add -B drill drill-state "$ROOT"
fi
[ -f drill-state/drill.json ] || echo '{}' > drill-state/drill.json
cat drill-state/drill.json
# With BRANCH=public it also keeps Steam's app info (appinfo.txt) for gate 1.
- name: Static half (Rust's public build)
if: ${{ steps.pending.outputs.run == 'true' }}
env:
BRANCH: public
LC_ALL: C
DOTNET_SYSTEM_GLOBALIZATION_INVARIANT: '1'
run: bash tools/drill/static.sh
- name: Gates 1-3 (public build, Oxide, Carbon)
id: gates
if: ${{ steps.pending.outputs.run == 'true' }}
env:
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
PANEL_KEY: ${{ secrets.PTERODACTYL_DRILL_KEY }}
run: >-
node tools/drill/cutover.js gates --state drill-state/drill.json --work "$WORK"
${{ (github.event_name == 'workflow_dispatch' && github.event.inputs.dry_run != 'false') && '--dry-run' || '' }}
${{ github.event.inputs.force == 'true' && '--force' || '' }}
- name: Keep the gates passed so far on the drill branch
if: ${{ always() && steps.pending.outputs.run == 'true' && !(github.event_name == 'workflow_dispatch' && github.event.inputs.dry_run != 'false') }}
env:
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
set -euo pipefail
CI_USER="$(printf '%s' "${REGISTRY_USER}" | tr -d '\r\n')"
CI_TOKEN="$(printf '%s' "${REGISTRY_TOKEN}" | tr -d '\r\n')"
git remote set-url origin "https://${CI_USER}:${CI_TOKEN}@${GITEA_HOST}/${REPO}.git"
cd drill-state
git add drill.json
if git diff --cached --quiet; then echo "no change"; exit 0; fi
node -e "const c=require('./drill.json').cutover||{};console.log('chore(drill): cutover '+(c.wipe||'?')+' '+(c.manifest||'?')+' [skip ci]')" > ../cutover-msg.txt
git commit -q -F ../cutover-msg.txt
git push origin drill
- name: Gate 4, the drill again on live Rust (rust-staging)
id: live
if: ${{ steps.gates.outputs.live == 'true' }}
continue-on-error: true
env:
PANEL_KEY: ${{ secrets.PTERODACTYL_DRILL_KEY }}
run: node tools/drill/drill.js live --work "$WORK" --branch public
- name: Gates 4-5 (the live half, PR checks)
id: gates2
if: ${{ always() && steps.gates.outputs.live == 'true' }}
env:
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: >-
node tools/drill/cutover.js gates --after-live --state drill-state/drill.json --work "$WORK"
${{ (github.event_name == 'workflow_dispatch' && github.event.inputs.dry_run != 'false') && '--dry-run' || '' }}
${{ github.event.inputs.force == 'true' && '--force' || '' }}
# The only step that holds RUNICNPC_TOKEN. It merges the commit the gates ran on, or nothing.
- name: Merge edge into main
id: merge
if: ${{ (steps.gates2.outputs.merge == 'true' || steps.gates.outputs.merge == 'true') && !(github.event_name == 'workflow_dispatch' && github.event.inputs.dry_run != 'false') }}
env:
MERGE_TOKEN: ${{ secrets.RUNICNPC_TOKEN }}
run: node tools/drill/cutover.js merge --pr ${{ steps.gates2.outputs.pr || steps.gates.outputs.pr }} --sha ${{ steps.gates2.outputs.sha || steps.gates.outputs.sha }} --state drill-state/drill.json
- name: Record the merge and the release
if: ${{ steps.merge.outputs.merged == 'true' }}
env:
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: node tools/drill/cutover.js after --state drill-state/drill.json --pr ${{ steps.gates2.outputs.pr || steps.gates.outputs.pr }}
- name: Keep the result on the drill branch
if: ${{ always() && steps.pending.outputs.run == 'true' && !(github.event_name == 'workflow_dispatch' && github.event.inputs.dry_run != 'false') }}
env:
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
set -euo pipefail
CI_USER="$(printf '%s' "${REGISTRY_USER}" | tr -d '\r\n')"
CI_TOKEN="$(printf '%s' "${REGISTRY_TOKEN}" | tr -d '\r\n')"
git remote set-url origin "https://${CI_USER}:${CI_TOKEN}@${GITEA_HOST}/${REPO}.git"
cd drill-state
git add drill.json
if git diff --cached --quiet; then echo "no change"; exit 0; fi
node -e "const c=require('./drill.json').cutover||{};console.log('chore(drill): cutover '+(c.wipe||'?')+' '+(c.manifest||'?')+' [skip ci]')" > ../cutover-msg.txt
git commit -q -F ../cutover-msg.txt
git push origin drill
# Last: gate 3 and the live half stop and start rust-carbon; if it was running when the job began
# and is offline now, start it. That server only, power `start` only.
- name: Leave rust-carbon as found
if: ${{ always() && steps.pending.outputs.run == 'true' }}
continue-on-error: true
env:
PANEL_KEY: ${{ secrets.PTERODACTYL_DRILL_KEY }}
run: node tools/drill/cutover.js rig-restore --work "$WORK"