ci(cutover): the wipe-day cutover's trigger on main (D332) #26
268
.gitea/workflows/cutover.yml
Normal file
268
.gitea/workflows/cutover.yml
Normal 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"
|
||||
Reference in New Issue
Block a user