ci(cutover): the wipe-day cutover's trigger on main (D332)
All checks were successful
PR Checks / plugin-checks (pull_request) Successful in 11s

Gitea fires a schedule only from the default branch, so the cutover's
workflow file reaches main once, alone. It is the same file as on edge
(the trigger and the step wiring); every step checks out edge and runs
edge's scripts, so nothing else of edge comes with it: the field list stays
on edge until the cutover merges it. A ci: commit cuts no release.

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:09:11 -06:00
parent 27bafedb03
commit 567b7671ad

View File

@@ -0,0 +1,268 @@
# 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 Rust's public build is dated the wipe day or later AND names the same depot manifest as
# Rust's current STAGING build (Facepunch promotes staging to public, so the two agree once the
# cut over from staging to live went through). The static half runs twice, BRANCH=public and
# BRANCH=staging; only the staging run's manifest is used.
# 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
STAGING_WORK: ${{ github.workspace }}/cutover-staging
# 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
- 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
# Only this run's manifest is used (gate 1): the build Rust has on staging now.
- name: Static half (Rust's staging build, for its manifest)
if: ${{ steps.pending.outputs.run == 'true' }}
env:
BRANCH: staging
WORK: ${{ github.workspace }}/cutover-staging
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" --staging-work "$STAGING_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" --staging-work "$STAGING_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"