Files
twenty/.github/workflows/ci-merge-queue.yaml
Paul Rastoin bb4e427196 ci: run upgrade mutation guard in the merge queue against main (#23216)
Follow-up to #23215 (merged). Rebased on `main`.

## Why

#23215 fixes an instance of a class of bug: an upgrade command whose
version is chosen at `generate:instance-command` time from
`TWENTY_CURRENT_VERSION`, then left behind when `main` bumps the version
before the PR merges (base-drift). The command ships one minor early and
instances already on the newer version skip it forever.

The existing `server-previous-version-upgrade-mutation-guard` in
`ci-server.yaml` runs on `pull_request`, so it validates against the
PR's base. When the base is stale (main moved after the branch was cut),
the guard reads the branch's own `TWENTY_CURRENT_VERSION` and the check
passes even though the command is now a version behind main. That is
exactly how the original bug slipped through.

## What

The version-directory and append-only-timestamp validation is extracted
into a shared composite action,
`.github/actions/upgrade-mutation-guard`, diffed against a
caller-supplied `base_sha`. It is called from two places:

- **`ci-server.yaml`** (PR-level guard, `base = pull_request.base.sha`)
for fast feedback. The job keeps its existing name/check. This replaces
~290 lines of inline shell.
- **`ci-merge-queue.yaml`** (new, `merge_group`-triggered, `base =
merge_group.base_sha`). GitHub builds each merge-queue candidate on top
of the current tip of `main`, so the checks read
`TWENTY_CURRENT_VERSION` and the existing per-directory timestamps from
main's real state at merge time.

Because the candidate is rebased onto main, base-drift is caught by
construction: the same validation simply runs where the base is
guaranteed current. No origin/main comparison hack; the logic now lives
in one place.

## Bypass semantics

The guard has two independent checks, and they are treated differently
on purpose:

- **Version-directory check** keeps its
`ci:allow-previous-version-upgrade-mutation` bypass, a deliberate,
reviewed escape hatch for legitimately touching a previous-version
directory. The PR-level guard reads the label directly; the merge-queue
guard resolves it from the queued PR (the `merge_group` event carries no
labels) and passes it to the composite action, which skips only the
version-directory step.
- **Timestamp / append-only check has no bypass.** The old
`ci:allow-upgrade-command-timestamp-exception` label is removed. A fake
or out-of-order timestamp rewinds the upgrade cursor and re-hides
already-applied columns, so there is no "allowed" version of it: the
timestamp just has to be configured correctly (real epoch millis,
strictly greater than every existing command in the same version
directory). If a blocking existing max is itself a fabricated future
timestamp, re-slot that command to its real merge epoch rather than
reaching for a bypass.

Preventing previous-version mutation is the guard's primary purpose. In
the merge queue the guard job always runs and skips only the
version-directory step when the bypass label is set, so it reports a
real success/failure (the required check never resolves to a skipped
state, and a label-lookup failure fails closed) and the timestamp check
always runs.

## Requires a settings change (not in this diff)

Enabling the merge queue and marking the check required are
branch-protection settings, not file changes. After merge, an admin
needs to:

1. Enable the merge queue for `main` in branch protection.
2. Add `CI - Merge Queue / upgrade-mutation-guard` to the merge queue's
required checks.

## Notes

- Composite action, not a `workflow_call` reusable workflow,
deliberately: converting the `ci-server.yaml` job to a reusable-workflow
call would rename its status check to
`server-previous-version-upgrade-mutation-guard / ` and break that
required-check mapping in branch protection. A composite action dedups
the logic while keeping both callers' check names intact.

---------

Co-authored-by: Paul Rastoin <paul.rastoin@gmail.com>
2026-07-24 14:37:45 +02:00

61 lines
2.4 KiB
YAML

name: CI - Merge Queue
# Runs when a PR is queued to merge. GitHub builds the merge candidate on top of
# the current tip of main, so these checks validate against main's real state at
# merge time rather than the PR's (possibly stale) base. This is what closes the
# upgrade-command base-drift race: a command generated against an old version,
# then left behind when main bumps, is caught here even if the PR-level guard
# passed on a stale base.
on:
merge_group:
permissions:
contents: read
pull-requests: read
jobs:
# merge_group carries no PR labels, so resolve them from the queued PR (its
# number is in the merge-queue ref) and pass the version-mutation bypass to the
# guard as an input, which skips only the version-directory step. The job
# always runs and reports a real success/failure, so the required check never
# resolves to a skipped state and a label-lookup failure fails closed. The
# timestamp / append-only check has no bypass and always runs: violating it
# rewinds the upgrade cursor.
upgrade-mutation-guard:
timeout-minutes: 5
runs-on: ubuntu-latest
steps:
- name: Checkout merge candidate
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
with:
fetch-depth: 2
- name: Resolve version-mutation bypass label from the queued PR
id: labels
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
HEAD_REF: ${{ github.event.merge_group.head_ref }}
run: |
set -euo pipefail
PR_NUMBER=$(echo "$HEAD_REF" | sed -n -E 's|.*/pr-([0-9]+)-[0-9a-f]+$|\1|p')
skip=false
if [ -n "$PR_NUMBER" ]; then
LABELS=$(gh pr view "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" --json labels --jq '.labels[].name')
echo "Queued PR #$PR_NUMBER labels:"
echo "$LABELS"
if echo "$LABELS" | grep -qxF 'ci:allow-previous-version-upgrade-mutation'; then
skip=true
fi
else
echo "Could not parse a PR number from '$HEAD_REF'; enforcing strictly."
fi
echo "skip=$skip" >> "$GITHUB_OUTPUT"
- name: Validate upgrade command mutations against main
uses: ./.github/actions/upgrade-mutation-guard
with:
base_sha: ${{ github.event.merge_group.base_sha }}
allow_previous_version_mutation: ${{ steps.labels.outputs.skip }}