# 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 # and 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"