All checks were successful
PR Checks / plugin-checks (pull_request) Successful in 21s
runicnpc-rust#28 changed edge's copy of cutover.yml (gate 1 reads Steam; the
staging static half is gone). main's copy was added by its own commit (#26), so
the two now conflict (add/add) and the cutover PR #29 cannot merge, though all
its gates passed. This makes main's copy byte-for-byte edge's. It changes no
commit on edge, so the gates kept for edge 761a9b67 stand and the next hourly
run only re-checks the PR and merges.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014HJEmgsXtLR5kLfh7f9y8P
259 lines
14 KiB
YAML
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"
|