Skip to content

Commit 6462f99

Browse files
castrojoCopilot
andauthored
ci(promote): replace push-based promotion with PR gate (ublue-os#1195)
Ok I think I got it this time, I can confirm this works via testing in my personal repo. I am likely going to force this because we need the action in the LTS branch anyway. ## Agent Notes follow: ## Solution Replace with `create-lts-pr.yml`, a PR-gate workflow: - Fires on every push to `main` (and `workflow_dispatch`) - Uses `git diff --quiet` (content diff, not commit graph) to detect new content — survives 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 containing double quotes (e.g. `Revert "..."`) - 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: branch protection is the guard against direct `lts` commits Also fixes `AGENTS.md`: corrects the release schedule (cron `0 6 * * 2` = Tuesday 6am UTC, not Sunday 2am UTC) and updates all references to the old workflow. ## Testing Tested on the `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 handled safely (the `Revert "..."` case) - ✅ Force-push after squash: workflow correctly updates existing PR Assisted-by: Claude Sonnet 4.6 via GitHub Copilot Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent dd4152f commit 6462f99

3 files changed

Lines changed: 92 additions & 84 deletions

File tree

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
name: Create LTS Promotion PR
2+
3+
on:
4+
push:
5+
branches: [main]
6+
workflow_dispatch:
7+
8+
concurrency:
9+
group: create-lts-pr
10+
cancel-in-progress: true
11+
12+
permissions:
13+
contents: read
14+
pull-requests: write
15+
16+
jobs:
17+
create-pr:
18+
runs-on: ubuntu-latest
19+
steps:
20+
- name: Checkout main
21+
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
22+
with:
23+
ref: main
24+
fetch-depth: 0
25+
26+
- name: Fetch lts
27+
run: git fetch origin lts
28+
29+
- name: Check content diff
30+
id: diff
31+
run: |
32+
if git diff --quiet origin/lts origin/main; then
33+
echo "No content difference between lts and main. Nothing to promote."
34+
echo "has_diff=false" >> "$GITHUB_OUTPUT"
35+
else
36+
echo "has_diff=true" >> "$GITHUB_OUTPUT"
37+
fi
38+
39+
- name: Build commit list
40+
if: steps.diff.outputs.has_diff == 'true'
41+
id: commits
42+
run: |
43+
LIST=$(git log origin/lts..origin/main --oneline)
44+
{
45+
echo "list<<EOF"
46+
echo "$LIST"
47+
echo "EOF"
48+
} >> "$GITHUB_OUTPUT"
49+
50+
- name: Create or update promote PR
51+
if: steps.diff.outputs.has_diff == 'true'
52+
env:
53+
GH_TOKEN: ${{ github.token }}
54+
COMMIT_LIST: ${{ steps.commits.outputs.list }}
55+
run: |
56+
# Build body with printf so commit messages containing quotes are safe
57+
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}")
58+
59+
EXISTING=$(gh pr list \
60+
--base lts \
61+
--head main \
62+
--state open \
63+
--json number \
64+
--jq '.[0].number' \
65+
2>/dev/null || echo "")
66+
67+
if [ -n "$EXISTING" ]; then
68+
echo "Updating existing promote PR #${EXISTING}"
69+
printf '%s\n' "${BODY}" | gh pr edit "$EXISTING" --body-file - || true
70+
else
71+
echo "Creating new draft promote PR"
72+
printf '%s\n' "${BODY}" | gh pr create \
73+
--draft \
74+
--base lts \
75+
--head main \
76+
--title "promote: main → lts" \
77+
--body-file -
78+
fi

.github/workflows/promote-to-lts.yml

Lines changed: 0 additions & 71 deletions
This file was deleted.

AGENTS.md

Lines changed: 14 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -120,8 +120,8 @@ This section is the authoritative reference for all CI/CD behavior. Read it comp
120120
| `build-regular-hwe.yml` | Caller — builds `bluefin` with HWE kernel |
121121
| `build-dx-hwe.yml` | Caller — builds `bluefin-dx` with HWE kernel |
122122
| `reusable-build-image.yml` | Reusable workflow — all 5 callers invoke this |
123-
| `scheduled-lts-release.yml` | Dispatcher — owns the weekly Sunday production release |
124-
| `promote-to-lts.yml` | Squash-pushes `main``lts` with pre-flight divergence check (see below) |
123+
| `scheduled-lts-release.yml` | Dispatcher — owns the weekly Tuesday production release |
124+
| `create-lts-pr.yml` | Opens a draft PR from `main``lts` when content differs; maintainer squash-merges as approval gate |
125125
| `generate-release.yml` | Creates a GitHub Release when `build-gdx.yml` completes on `lts` |
126126

127127
### Two Branches, Two Tag Namespaces
@@ -137,23 +137,24 @@ This section is the authoritative reference for all CI/CD behavior. Read it comp
137137

138138
Promotion and production release are **intentionally decoupled**. There are two separate phases:
139139

140-
**Phase 1 — Promotion (manual, no publishing):**
141-
1. A maintainer triggers `promote-to-lts.yml` via `workflow_dispatch`
142-
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.
143-
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.
144-
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.
140+
**Phase 1 — Promotion (human-gated via PR):**
141+
1. Every push to `main` triggers `create-lts-pr.yml`
142+
2. The workflow checks `git diff --quiet origin/lts origin/main` (content diff, not commit graph — survives squash-merges)
143+
3. If content differs: a draft PR from `main``lts` is created (or the existing one is updated with the latest commit list)
144+
4. A maintainer reviews and **squash-merges** the PR — this is the human approval gate
145+
5. The squash-merge triggers a `push` event on `lts` — all 5 build workflows run as **validation builds** (`publish=false`). No images are published.
145146

146147
**Phase 2 — Production release (automated or manual publishing):**
147-
1. `scheduled-lts-release.yml` fires at `0 2 * * 0` (Sunday 2am UTC), OR a maintainer manually triggers it
148+
1. `scheduled-lts-release.yml` fires at `0 6 * * 2` (Tuesday 6am UTC), OR a maintainer manually triggers it
148149
2. It dispatches all 5 build workflows via `gh workflow run --ref lts`
149150
3. Those are `workflow_dispatch` events on `lts``publish=true` → production tags pushed
150151
4. After `build-gdx.yml` completes on `lts`, `generate-release.yml` creates a GitHub Release
151152

152-
**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.
153+
**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.
153154

154155
**NEVER merge `lts` into `main`.** The flow is always one-way: `main``lts`.
155156

156-
**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.
157+
**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.
157158

158159
### `publish` Input — How It Is Evaluated
159160

@@ -252,7 +253,7 @@ When touching any condition in `reusable-build-image.yml`, use this reference:
252253

253254
### `schedule:` Triggers — Ownership Rule
254255

255-
**`scheduled-lts-release.yml` is the sole owner of Sunday 2am UTC production builds.**
256+
**`scheduled-lts-release.yml` is the sole owner of Tuesday 6am UTC production builds.**
256257

257258
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.
258259

@@ -264,8 +265,8 @@ If you see `schedule:` in any of the 5 build callers, remove it entirely. Do not
264265
- `build-gdx.yml` — GPU/AI Developer Experience (`bluefin-gdx` image)
265266
- `build-regular-hwe.yml` — HWE kernel variant of `bluefin`
266267
- `build-dx-hwe.yml` — HWE kernel variant of `bluefin-dx`
267-
- `scheduled-lts-release.yml` — Weekly production release dispatcher (sole owner of Sunday builds)
268-
- `promote-to-lts.yml`Squash-pushes `main` into `lts` (with pre-flight divergence check)
268+
- `scheduled-lts-release.yml` — Weekly production release dispatcher (sole owner of Tuesday builds)
269+
- `create-lts-pr.yml`Opens a draft PR from `main` `lts` when content differs; maintainer squash-merges as approval gate
269270
- `generate-release.yml` — Creates GitHub Release after successful GDX build on `lts`
270271

271272
## Validation Scenarios

0 commit comments

Comments
 (0)