diff --git a/.gitea/workflows/cutover.yml b/.gitea/workflows/cutover.yml new file mode 100644 index 0000000..4ec300c --- /dev/null +++ b/.gitea/workflows/cutover.yml @@ -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 +# 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 + 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"