diff --git a/.github/workflows/sync-main-to-staging.yml b/.github/workflows/sync-main-to-staging.yml new file mode 100644 index 0000000..3083f73 --- /dev/null +++ b/.github/workflows/sync-main-to-staging.yml @@ -0,0 +1,118 @@ +# Reusable workflow: merge `main` back into `staging` after ANY push to main. +# +# `release-unfreeze.yml` already syncs main into staging when the weekly release +# PR merges. That covers the release path and nothing else. A commit that reaches +# main any other way — a hotfix PR, a revert, a direct merge — fires no unfreeze +# (the wrapper's guard requires the merged PR's head to be `staging`), so it sits +# on main until the NEXT weekly release drags it across, up to a week later. +# +# That gap is not cosmetic on squash-merge repos. While main is ahead, the next +# release PR's diff is computed against a main that staging does not contain, so +# the release either reverts the missing commit or has to be reconciled by hand. +# This workflow closes the window: main flows into staging within a minute of +# landing, whatever route it took. +# +# Idempotent by construction: it exits 0 when staging already contains main, so +# it is safe to run on every push, and safe to run alongside the unfreeze sync. +# +# Ordering against release-unfreeze: merging the release PR fires BOTH this +# (push to main) and the unfreeze (pull_request closed). Both push staging with +# the same App, so the CALLER must put both workflows in one `concurrency` group +# to serialise them; the loser then finds staging already current and no-ops. +# The push also retries once on a non-fast-forward, which covers a staging that +# moved between fetch and push for any other reason. +# +# The push uses the `mindsdb-release-train` App token. The App is the ruleset +# bypass actor on staging, so it lands even while staging is frozen — which is +# what you want: a hotfix on main belongs in the release candidate too. +# +# Called by a per-repo wrapper, e.g. +# auth/.github/workflows/sync-main-to-staging.yml +# +# Requires: vars.RELEASE_APP_CLIENT_ID + secrets.RELEASE_APP_PRIVATE_KEY +# (org-provisioned; secret reaches here via `secrets: inherit`). + +name: Sync main to staging + +on: + workflow_call: + inputs: + staging-branch: + description: "Branch to sync into" + type: string + default: staging + base-branch: + description: "Production branch to sync from" + type: string + default: main + runs-on: + description: "Runner label" + type: string + default: ubuntu-latest + +permissions: + contents: read + +jobs: + sync: + runs-on: ${{ inputs.runs-on }} + steps: + - name: Mint release-train App token + id: app-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + client-id: ${{ vars.RELEASE_APP_CLIENT_ID }} + private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} + + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + fetch-depth: 0 + token: ${{ steps.app-token.outputs.token }} + + - name: Sync base branch into staging + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + APP_SLUG: ${{ steps.app-token.outputs.app-slug }} + STAGING: ${{ inputs.staging-branch }} + BASE: ${{ inputs.base-branch }} + run: | + set -euo pipefail + + # Attribute the merge commit to the App's bot identity. The username is + # URL-encoded ('[' -> %5B, ']' -> %5D) for the users API lookup. + APP_USER_ID=$(gh api "/users/${APP_SLUG}%5Bbot%5D" --jq '.id') + git config user.name "${APP_SLUG}[bot]" + git config user.email "${APP_USER_ID}+${APP_SLUG}[bot]@users.noreply.github.com" + + # One attempt, then one retry: the retry exists for the case where + # staging moved after the fetch (a concurrent sync, or an ordinary push + # landing while staging is unfrozen), which shows up as a rejected + # non-fast-forward rather than an error worth failing the run over. + for attempt in 1 2; do + git fetch origin "${BASE}" "${STAGING}" + git checkout -B "${STAGING}" "origin/${STAGING}" + + if git merge-base --is-ancestor "origin/${BASE}" HEAD; then + echo "${STAGING} already contains ${BASE} — nothing to sync." + exit 0 + fi + + git merge --no-ff "origin/${BASE}" \ + -m "Sync ${BASE} into ${STAGING}" + + if git push origin "HEAD:${STAGING}"; then + echo "Pushed ${BASE} -> ${STAGING} on attempt ${attempt}." + { + echo "## Synced \`${BASE}\` into \`${STAGING}\`" + echo "" + echo "\`${STAGING}\` now contains every commit on \`${BASE}\`." + } >> "$GITHUB_STEP_SUMMARY" + exit 0 + fi + + echo "Push rejected on attempt ${attempt} — ${STAGING} moved underneath us." + git merge --abort 2>/dev/null || true + done + + echo "::error::Could not fast-forward ${STAGING} after 2 attempts. ${STAGING} is moving faster than this workflow can merge, or the App lost its bypass on ${STAGING}." + exit 1 diff --git a/README.md b/README.md index c272a9c..3e3c926 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ Use an action from this repo in your workflow like this: ## Release-train reusable workflows -Three reusable workflows automate the weekly `staging → main` release cycle. +Four reusable workflows automate the weekly `staging → main` release cycle. They live in `.github/workflows/` and are called from ~25-line per-repo wrappers (same pattern as `stale-deploy-label.yml`): @@ -24,6 +24,19 @@ They live in `.github/workflows/` and are called from ~25-line per-repo wrappers | `release-freeze.yml` | `Staging Freeze` | Activates the `staging-freeze` ruleset to lock staging (skips if staging == main) | | `release-pr.yml` | `Create staging to main release PR` | Opens the `staging → main` PR (idempotent) | | `release-unfreeze.yml` | `Staging Unfreeze` | Disables the ruleset when the release PR merges, then syncs `main` back into `staging` | +| `sync-main-to-staging.yml` | `Sync main to staging` | Merges `main` into `staging` after **any** push to main, not just the release merge | + +`release-unfreeze.yml` syncs main back only on the release path, because its wrapper's guard requires the merged PR's head branch to be `staging`. A commit that reaches main any other way (a hotfix PR, a revert, a direct merge) fires nothing, and on a squash-merge repo that leaves the next release PR diffed against a `main` that `staging` does not contain. `sync-main-to-staging.yml` closes that window on `push: main`. + +Both push `staging` as the release-train App, and merging the release PR fires both, so a caller that installs both **must put them in the same `concurrency` group**: + +```yaml +concurrency: + group: sync-main-to-staging + cancel-in-progress: false +``` + +The sync is idempotent (it exits 0 when `staging` already contains `main`), so whichever run loses the race no-ops. The chain is event-driven: `Staging Freeze` finishing fires the release-PR workflow via `workflow_run`; merging that PR fires `Staging Unfreeze`. The @@ -71,6 +84,25 @@ only and a called workflow can never hold more than its caller grants. Without it the lookup is refused and the job stays silent (it never fails the run), so a missing recovery message is the symptom to look for. +### Scoping staging alerts to the freeze window + +A staging failure is not the same event all week. Once the freeze window is open, `staging` is the release candidate and a red pipeline blocks the release. Before it, `staging` is the integration branch and the same red is routine, so paging the channel for it is what teaches people to scroll past the channel. + +`freeze-scoped: true` on a **staging** caller keeps the red `:rotating_light:` alert while the `staging-freeze` ruleset is `active`, and drops it to a muted grey `:warning:` notice outside the window: + +```yaml + with: + env-name: "staging build+deploy" + status: ${{ contains(needs.*.result, 'failure') && 'failed' || 'recovered' }} + freeze-scoped: true +``` + +Add `outside-freeze: silent` to post nothing at all outside the window instead. That trades the noise for a blind spot: a staging pipeline can then sit red unnoticed until the next freeze opens on a branch that no longer builds, so `notice` is the default. + +Freeze state is read from the `staging-freeze` ruleset itself, not inferred from workflow history — a freeze that skipped itself because staging had nothing unreleased still concludes `success`, and history cannot tell that apart from a real freeze. Reading rulesets needs admin, so this reuses the same App that toggles them (`vars.RELEASE_APP_CLIENT_ID` + `secrets.RELEASE_APP_PRIVATE_KEY`); no extra `permissions:` on the caller. If the App token or the ruleset lookup fails, it escalates to the red alert rather than downgrading, so a lookup problem can never silence a real release-blocking failure. + +Leave `freeze-scoped` off for prod and freeze/unfreeze callers: a failure there is always worth interrupting for. + For a freeze/unfreeze wrapper, keep it failure-only: `needs:` its single job, `if: failure()`, the default `status: failed`, no `permissions:` block (the prior-run lookup only runs in recovered mode), and label it accordingly