Skip to content

Commit 40d0c5b

Browse files
committed
feat: pre-stage CHANGELOG before release-PR workflow (#168 backport)
1 parent 414d3e7 commit 40d0c5b

5 files changed

Lines changed: 192 additions & 2 deletions

File tree

.github/scripts/check_required_contexts.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,10 @@
5959
"Weekly cron + workflow_dispatch; warn-only by default with auto-"
6060
" filed tracking issue. Never appears on PR check sets."
6161
),
62+
"changelog-prestage.yml": (
63+
"workflow_dispatch only; opens its own pre-stage PR before a"
64+
" release PR is opened. Never appears on PR check sets."
65+
),
6266
}
6367

6468

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
name: Pre-stage CHANGELOG before release
2+
3+
# Triggered manually (`workflow_dispatch`) before opening a release PR.
4+
# Inserts the `## [<tag>] - <date>` heading + footer link into develop's
5+
# CHANGELOG ahead of time, and merges main into develop on the prestage
6+
# branch so develop's CHANGELOG already has the structural shape main
7+
# carries. The release PR opened afterwards is conflict-free by
8+
# construction.
9+
#
10+
# Why: release PRs commonly hit a same-line CHANGELOG conflict on the
11+
# release-branch push because git sees both develop and main editing
12+
# the region right after `## [Unreleased]`. The pre-#168 pattern was for
13+
# the operator to manually `git merge origin/main` on the release branch
14+
# every time; this workflow packages the same merge plus the heading
15+
# insertion as a reviewable PR.
16+
#
17+
# Distinct from `changelog-rollup.yml`: rollup runs *after* a release
18+
# tag is cut, bumps the version, and is auto-triggered by release.yml
19+
# success. Prestage runs *before* the tag, doesn't bump version (the
20+
# post-release rollup does), and is operator-triggered. Both share
21+
# `.github/scripts/rollup_changelog.py` — prestage uses the `--no-bump`
22+
# flag.
23+
24+
on:
25+
workflow_dispatch:
26+
inputs:
27+
tag:
28+
description: "Tag about to be released (e.g. v1.11.0)"
29+
required: true
30+
type: string
31+
prior_tag:
32+
description: "Prior release tag for the compare link (auto-resolves if empty)"
33+
required: false
34+
type: string
35+
default: ""
36+
date:
37+
description: "Release date YYYY-MM-DD (defaults to today UTC)"
38+
required: false
39+
type: string
40+
default: ""
41+
42+
permissions:
43+
contents: write
44+
pull-requests: write
45+
46+
jobs:
47+
prestage:
48+
name: Open prestage PR
49+
runs-on: ubuntu-latest
50+
steps:
51+
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
52+
with:
53+
ref: develop
54+
# Full history so `git describe --abbrev=0 --tags` can resolve
55+
# the prior tag, and the `git merge origin/main` step has a
56+
# complete merge-base.
57+
fetch-depth: 0
58+
# Prefer RELEASE_BOT_TOKEN so the prestage branch's push fires
59+
# `pull_request` workflows on the auto-PR. Falls back to
60+
# GITHUB_TOKEN when the secret isn't set — the auto-PR still
61+
# opens, but its CI doesn't run until a user pushes on top.
62+
token: ${{ secrets.RELEASE_BOT_TOKEN || secrets.GITHUB_TOKEN }}
63+
64+
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
65+
with:
66+
python-version: "3.14"
67+
68+
- name: Resolve inputs
69+
id: resolve
70+
run: |
71+
set -euo pipefail
72+
TAG="${{ inputs.tag }}"
73+
if [ -z "${TAG}" ]; then
74+
echo "::error::tag input is required"
75+
exit 1
76+
fi
77+
# Auto-resolve prior tag if not provided (mirrors changelog-rollup.yml).
78+
PRIOR="${{ inputs.prior_tag }}"
79+
if [ -z "${PRIOR}" ]; then
80+
if PRIOR=$(git describe --abbrev=0 --tags --match 'v*.*.*' "${TAG}^" 2>/dev/null); then
81+
echo "prior tag (resolved): ${PRIOR}"
82+
else
83+
# Try the most recent tag on main if `<TAG>^` doesn't resolve
84+
# (the tag isn't cut yet — that's the whole point of prestage).
85+
if PRIOR=$(git describe --abbrev=0 --tags --match 'v*.*.*' origin/main 2>/dev/null); then
86+
echo "prior tag (resolved from main): ${PRIOR}"
87+
else
88+
PRIOR=""
89+
echo "no prior tag (first release)"
90+
fi
91+
fi
92+
fi
93+
DATE="${{ inputs.date }}"
94+
if [ -z "${DATE}" ]; then
95+
DATE=$(date -u +%Y-%m-%d)
96+
fi
97+
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
98+
echo "prior=${PRIOR}" >> "$GITHUB_OUTPUT"
99+
echo "date=${DATE}" >> "$GITHUB_OUTPUT"
100+
echo "branch=chore/changelog-prestage-${TAG}" >> "$GITHUB_OUTPUT"
101+
102+
- name: Create prestage branch + merge main
103+
env:
104+
BRANCH: ${{ steps.resolve.outputs.branch }}
105+
run: |
106+
set -euo pipefail
107+
# Idempotent: if the branch already exists from a previous replay,
108+
# bail rather than force-push.
109+
if git ls-remote --exit-code --heads origin "${BRANCH}" >/dev/null 2>&1; then
110+
echo "::warning::branch ${BRANCH} already exists; skipping push to avoid clobbering an in-flight prestage PR"
111+
exit 0
112+
fi
113+
git config user.name "github-actions[bot]"
114+
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
115+
git checkout -b "${BRANCH}"
116+
# Bring main's CHANGELOG state into develop. Prefer auto-merge;
117+
# if there's a real conflict (rare in steady-state — develop and
118+
# main usually share their post-rollup history), fail fast with
119+
# a clear message so the operator can resolve manually.
120+
if ! git merge --no-edit origin/main; then
121+
echo "::error::main → develop merge hit a conflict the prestage can't auto-resolve. Resolve manually on a local branch and re-run, or open the release PR by hand and merge main into the release branch (the pre-#168 pattern)."
122+
git merge --abort
123+
exit 1
124+
fi
125+
126+
- name: Run rollup script in pre-stage mode
127+
run: |
128+
python .github/scripts/rollup_changelog.py \
129+
--tag "${{ steps.resolve.outputs.tag }}" \
130+
--prior-tag "${{ steps.resolve.outputs.prior }}" \
131+
--date "${{ steps.resolve.outputs.date }}" \
132+
--no-bump
133+
134+
- name: Open prestage PR
135+
env:
136+
# Same fallback as the checkout step (#174) so the auto-PR
137+
# fires `pull_request` workflows on creation when the secret
138+
# is provisioned; falls back to GITHUB_TOKEN otherwise.
139+
GH_TOKEN: ${{ secrets.RELEASE_BOT_TOKEN || secrets.GITHUB_TOKEN }}
140+
BRANCH: ${{ steps.resolve.outputs.branch }}
141+
TAG: ${{ steps.resolve.outputs.tag }}
142+
DATE: ${{ steps.resolve.outputs.date }}
143+
run: |
144+
set -euo pipefail
145+
git add CHANGELOG.md
146+
git commit -m "chore: pre-stage CHANGELOG for ${TAG} - ${DATE}"
147+
git push origin "${BRANCH}"
148+
gh pr create \
149+
--base develop \
150+
--head "${BRANCH}" \
151+
--title "chore: pre-stage CHANGELOG for ${TAG} - ${DATE}" \
152+
--body "$(cat <<EOF
153+
Auto-opened by [.github/workflows/changelog-prestage.yml](.github/workflows/changelog-prestage.yml) before the ${TAG} release PR.
154+
155+
## What this PR does
156+
157+
- Merges \`origin/main\` into develop (so develop's CHANGELOG has the same structural shape main carries).
158+
- Inserts \`## [${TAG#v}] - ${DATE}\` heading after \`## [Unreleased]\` in CHANGELOG.md.
159+
- Updates \`[Unreleased]: …/compare/${TAG}...HEAD\` footer link.
160+
- Adds \`[${TAG#v}]: …/compare/<prior>...${TAG}\` footer link.
161+
- **Does not** bump \`pyproject.toml\` / \`uv.lock\` — that's the post-release rollup's job.
162+
163+
## Operator next step
164+
165+
Merge this PR into develop, then open the release PR develop → main with title \`release: ${TAG}\`. The release PR will be conflict-free because develop and main now agree on the CHANGELOG's structural shape.
166+
167+
See [docs/DEVELOPMENT.md#creating-a-release](docs/DEVELOPMENT.md#creating-a-release) for the full cycle.
168+
EOF
169+
)"

docs/DEVELOPMENT.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,8 @@ Subject is **lowercase after the colon** (Title Case is rejected unless it's an
102102
| `eval-nightly.yml` | `workflow_dispatch` only by default | No |
103103
| `codeql.yml` | `workflow_dispatch` only (placeholder) | No |
104104
| `pin-freshness-audit.yml` | weekly + `workflow_dispatch` | No — async second layer of action-pinning policy |
105+
| `changelog-rollup.yml` | after `release.yml` succeeds + `workflow_dispatch` | No — opens a `chore: roll up CHANGELOG …` PR against develop |
106+
| `changelog-prestage.yml` | `workflow_dispatch` only | No — operator-triggered before opening the release PR (closes the same-line CHANGELOG conflict class) |
105107

106108
### Action-pinning policy
107109

@@ -121,6 +123,21 @@ Audited by the `Version bump check` CI job (`.github/scripts/check_version_bump.
121123

122124
The `uv.lock` self-version is hand-edited (one line); avoid `uv lock` mid-PR because it would re-resolve transitive deps and pull in unintended upgrades. The `Version bump check` gate enforces both halves.
123125

126+
### Creating a release
127+
128+
The release flow chains four workflows and one script:
129+
130+
1. **Pre-stage CHANGELOG** (`changelog-prestage.yml`, manual dispatch) — pass the new tag (e.g. `v0.3.0`); the workflow opens a `chore: pre-stage CHANGELOG …` PR against develop that merges `origin/main` into the branch and inserts the new `## [<version>] - <date>` heading + footer compare-link. Merge that PR.
131+
2. **Open the release PR**`release: vX.Y.Z` from `develop``main`. Conflict-free now that develop has main's CHANGELOG shape. Admin-merge with `gh pr merge --admin --merge` once green.
132+
3. **Tag the merge commit**`git tag vX.Y.Z && git push origin vX.Y.Z`. Triggers `release.yml`.
133+
4. **`release.yml`** builds the image, pushes to GHCR, generates the CycloneDX SBOM, publishes the GitHub Release.
134+
5. **`changelog-rollup.yml`** auto-fires on the successful release and opens a `chore: roll up CHANGELOG …` PR against develop that bumps `pyproject.toml` + `uv.lock` PATCH (so develop's `[Unreleased]` section can accumulate again).
135+
136+
The shared script is `.github/scripts/rollup_changelog.py`:
137+
138+
- `rollup_changelog.py --tag vX.Y.Z --prior-tag vA.B.C --date YYYY-MM-DD` — full rollup (CHANGELOG edits + version bump). Used by `changelog-rollup.yml` post-release.
139+
- `rollup_changelog.py … --no-bump` — CHANGELOG edits only. Used by `changelog-prestage.yml` pre-release.
140+
124141
### Testing policy
125142

126143
Audited by the `Tests required` CI job (`.github/scripts/check_tests_present.py`). `feat:` and `fix:` PRs that touch `src/` MUST also touch `tests/`. Other prefixes get a `::warning::` if `src/` is touched without tests but don't block. The 75 % coverage gate alone doesn't catch behaviour-change-without-test (a single new line on already-covered code can pass), which is why this is a separate axis.

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "harness-python-react"
3-
version = "0.2.7"
3+
version = "0.2.8"
44
description = "Production-quality LLM-driven coding harness — Python (FastAPI) backend, Vite + React + TypeScript frontend."
55
readme = "README.md"
66
requires-python = ">=3.14"

uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)