From d3d513d8677c57101799cd4b09d2936bc7a69bbf Mon Sep 17 00:00:00 2001 From: "Jorge O. Castro" Date: Tue, 17 Mar 2026 18:09:47 -0400 Subject: [PATCH] ci(promote): replace push-based promotion with PR gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The existing promote-to-lts.yml has a fatal self-deadlock bug: after the first successful squash-push, commit S lives on lts but not in main's graph. Every subsequent pre-flight check (rev-list origin/lts ^origin/main) sees S and permanently fails. Manual git surgery required after every promotion. Replace with create-lts-pr.yml, a PR-gate workflow: - Fires on every push to main (and on workflow_dispatch) - Uses git diff --quiet (content diff, not commit graph) to detect new content and survive squash-merges without false positives - Passes the commit list as a COMMIT_LIST env var and uses printf to build the PR body, safely handling commit messages that contain double quotes - Auto-creates a draft PR from main → lts, or updates the existing one - Maintainer squash-merges the PR as the human approval gate - No pre-flight check needed: branch protection is the guard Also update AGENTS.md to reflect the new workflow and correct the release schedule (cron 0 6 * * 2 = Tuesday 6am UTC, not Sunday 2am UTC). Tested on castrojo/bluefin-lts fork: - Workflow fires on push and creates draft PR correctly - PR body auto-updates on subsequent pushes without creating duplicate PRs - Commit messages with double quotes (e.g. Revert "...") handled safely Assisted-by: Claude Sonnet 4.6 via GitHub Copilot Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/create-lts-pr.yml | 78 ++++++++++++++++++++++++++++ .github/workflows/promote-to-lts.yml | 71 ------------------------- AGENTS.md | 27 +++++----- 3 files changed, 92 insertions(+), 84 deletions(-) create mode 100644 .github/workflows/create-lts-pr.yml delete mode 100644 .github/workflows/promote-to-lts.yml diff --git a/.github/workflows/create-lts-pr.yml b/.github/workflows/create-lts-pr.yml new file mode 100644 index 00000000..9daf5d4a --- /dev/null +++ b/.github/workflows/create-lts-pr.yml @@ -0,0 +1,78 @@ +name: Create LTS Promotion PR + +on: + push: + branches: [main] + workflow_dispatch: + +concurrency: + group: create-lts-pr + cancel-in-progress: true + +permissions: + contents: read + pull-requests: write + +jobs: + create-pr: + runs-on: ubuntu-latest + steps: + - name: Checkout main + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + ref: main + fetch-depth: 0 + + - name: Fetch lts + run: git fetch origin lts + + - name: Check content diff + id: diff + run: | + if git diff --quiet origin/lts origin/main; then + echo "No content difference between lts and main. Nothing to promote." + echo "has_diff=false" >> "$GITHUB_OUTPUT" + else + echo "has_diff=true" >> "$GITHUB_OUTPUT" + fi + + - name: Build commit list + if: steps.diff.outputs.has_diff == 'true' + id: commits + run: | + LIST=$(git log origin/lts..origin/main --oneline) + { + echo "list<> "$GITHUB_OUTPUT" + + - name: Create or update promote PR + if: steps.diff.outputs.has_diff == 'true' + env: + GH_TOKEN: ${{ github.token }} + COMMIT_LIST: ${{ steps.commits.outputs.list }} + run: | + # Build body with printf so commit messages containing quotes are safe + BODY=$(printf '## Commits pending promotion to `lts`\n\n%s\n\n---\n_Squash-merge this PR to promote. The PR body updates automatically as `main` advances._\n' "${COMMIT_LIST}") + + EXISTING=$(gh pr list \ + --base lts \ + --head main \ + --state open \ + --json number \ + --jq '.[0].number' \ + 2>/dev/null || echo "") + + if [ -n "$EXISTING" ]; then + echo "Updating existing promote PR #${EXISTING}" + printf '%s\n' "${BODY}" | gh pr edit "$EXISTING" --body-file - || true + else + echo "Creating new draft promote PR" + printf '%s\n' "${BODY}" | gh pr create \ + --draft \ + --base lts \ + --head main \ + --title "promote: main → lts" \ + --body-file - + fi diff --git a/.github/workflows/promote-to-lts.yml b/.github/workflows/promote-to-lts.yml deleted file mode 100644 index 8fb2c503..00000000 --- a/.github/workflows/promote-to-lts.yml +++ /dev/null @@ -1,71 +0,0 @@ -name: Promote Main to LTS - -on: - workflow_dispatch: - inputs: - commit_title: - description: 'Commit title for the squash promotion commit' - required: false - default: 'promote: main to lts' - commit_body: - description: 'Commit body (optional)' - required: false - default: | - Squash promotion of tested changes from `main` to `lts`. - -permissions: - contents: write - -jobs: - promote: - runs-on: ubuntu-latest - steps: - - name: Checkout lts - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - with: - ref: lts - fetch-depth: 0 - token: ${{ github.token }} - - - name: Fetch main - run: git fetch origin main - - - name: Pre-flight check - run: | - UNIQUE=$(git rev-list origin/lts ^origin/main --count) - if [ "$UNIQUE" -gt 0 ]; then - echo "ERROR: lts has $UNIQUE commit(s) that are not in main:" - git log --oneline origin/lts ^origin/main - echo "" - echo "All changes must land in main before promoting to lts." - echo "Land the above commits in main first, then re-run this workflow." - exit 1 - fi - echo "Pre-flight passed: lts has no commits outside of main." - - - name: Configure Git - run: | - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - - - name: Squash merge main into lts - id: squash - run: | - git merge --squash origin/main - if git diff --cached --quiet; then - echo "No changes to promote: origin/main is already fully merged into lts." - echo "has_changes=false" >> "$GITHUB_OUTPUT" - else - echo "has_changes=true" >> "$GITHUB_OUTPUT" - fi - - - name: Commit promotion - if: steps.squash.outputs.has_changes == 'true' - run: | - git commit \ - -m "${{ inputs.commit_title }}" \ - -m "${{ inputs.commit_body }}" - - - name: Push to lts - if: steps.squash.outputs.has_changes == 'true' - run: git push origin lts diff --git a/AGENTS.md b/AGENTS.md index 58207ff0..e0e718b0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -120,8 +120,8 @@ This section is the authoritative reference for all CI/CD behavior. Read it comp | `build-regular-hwe.yml` | Caller — builds `bluefin` with HWE kernel | | `build-dx-hwe.yml` | Caller — builds `bluefin-dx` with HWE kernel | | `reusable-build-image.yml` | Reusable workflow — all 5 callers invoke this | -| `scheduled-lts-release.yml` | Dispatcher — owns the weekly Sunday production release | -| `promote-to-lts.yml` | Squash-pushes `main` → `lts` with pre-flight divergence check (see below) | +| `scheduled-lts-release.yml` | Dispatcher — owns the weekly Tuesday production release | +| `create-lts-pr.yml` | Opens a draft PR from `main` → `lts` when content differs; maintainer squash-merges as approval gate | | `generate-release.yml` | Creates a GitHub Release when `build-gdx.yml` completes on `lts` | ### Two Branches, Two Tag Namespaces @@ -137,23 +137,24 @@ This section is the authoritative reference for all CI/CD behavior. Read it comp Promotion and production release are **intentionally decoupled**. There are two separate phases: -**Phase 1 — Promotion (manual, no publishing):** -1. A maintainer triggers `promote-to-lts.yml` via `workflow_dispatch` -2. The workflow runs a **pre-flight check**: fails immediately if `lts` has any commits not reachable from `main`, printing those commits with instructions to land them in `main` first. -3. The workflow performs a **squash merge** (`git merge --squash origin/main`) and pushes one clean commit to `lts`. There is no PR. Triggering `workflow_dispatch` is the human approval step. -4. The push triggers a `push` event on `lts` — all 5 build workflows run as **validation builds** (`publish=false`). No images are published. This confirms the promoted code builds cleanly on `lts` before the next production release. +**Phase 1 — Promotion (human-gated via PR):** +1. Every push to `main` triggers `create-lts-pr.yml` +2. The workflow checks `git diff --quiet origin/lts origin/main` (content diff, not commit graph — survives squash-merges) +3. If content differs: a draft PR from `main` → `lts` is created (or the existing one is updated with the latest commit list) +4. A maintainer reviews and **squash-merges** the PR — this is the human approval gate +5. The squash-merge triggers a `push` event on `lts` — all 5 build workflows run as **validation builds** (`publish=false`). No images are published. **Phase 2 — Production release (automated or manual publishing):** -1. `scheduled-lts-release.yml` fires at `0 2 * * 0` (Sunday 2am UTC), OR a maintainer manually triggers it +1. `scheduled-lts-release.yml` fires at `0 6 * * 2` (Tuesday 6am UTC), OR a maintainer manually triggers it 2. It dispatches all 5 build workflows via `gh workflow run --ref lts` 3. Those are `workflow_dispatch` events on `lts` → `publish=true` → production tags pushed 4. After `build-gdx.yml` completes on `lts`, `generate-release.yml` creates a GitHub Release -**Why `promote-to-lts.yml` exists:** Automated tools (the old Pull app, AI agents) cannot distinguish merge direction — when they see `lts` is behind `main`, they attempt to "sync" and sometimes merge `lts` → `main`, polluting `main` with old production commits. The workflow enforces the correct direction by always targeting `lts` as the base. +**Why `create-lts-pr.yml` exists:** Automated tools (the old Pull app, AI agents) cannot distinguish merge direction — when they see `lts` is behind `main`, they attempt to "sync" and sometimes merge `lts` → `main`, polluting `main` with old production commits. The PR-gate workflow enforces the correct direction: `main` → `lts` only, with a human squash-merge as the approval step. **NEVER merge `lts` into `main`.** The flow is always one-way: `main` → `lts`. -**NEVER commit directly to `lts`.** All changes — including CI hotfixes — must land in `main` first. Direct commits to `lts` create divergence that causes the pre-flight check to fail and blocks future promotions. +**NEVER commit directly to `lts`.** All changes — including CI hotfixes — must land in `main` first. Direct commits to `lts` will appear as phantom content in the PR diff and confuse reviewers. ### `publish` Input — How It Is Evaluated @@ -252,7 +253,7 @@ When touching any condition in `reusable-build-image.yml`, use this reference: ### `schedule:` Triggers — Ownership Rule -**`scheduled-lts-release.yml` is the sole owner of Sunday 2am UTC production builds.** +**`scheduled-lts-release.yml` is the sole owner of Tuesday 6am UTC production builds.** The 5 build caller workflows (`build-regular.yml`, `build-dx.yml`, `build-gdx.yml`, `build-regular-hwe.yml`, `build-dx-hwe.yml`) must NOT have `schedule:` triggers. Any `schedule:` event on those workflows fires on `main` (the default branch), evaluates `publish=false`, publishes nothing, and wastes runner time. @@ -264,8 +265,8 @@ If you see `schedule:` in any of the 5 build callers, remove it entirely. Do not - `build-gdx.yml` — GPU/AI Developer Experience (`bluefin-gdx` image) - `build-regular-hwe.yml` — HWE kernel variant of `bluefin` - `build-dx-hwe.yml` — HWE kernel variant of `bluefin-dx` -- `scheduled-lts-release.yml` — Weekly production release dispatcher (sole owner of Sunday builds) -- `promote-to-lts.yml` — Squash-pushes `main` into `lts` (with pre-flight divergence check) +- `scheduled-lts-release.yml` — Weekly production release dispatcher (sole owner of Tuesday builds) +- `create-lts-pr.yml` — Opens a draft PR from `main` → `lts` when content differs; maintainer squash-merges as approval gate - `generate-release.yml` — Creates GitHub Release after successful GDX build on `lts` ## Validation Scenarios