diff --git a/.github/scripts/check_required_contexts.py b/.github/scripts/check_required_contexts.py index b78327a..a59036c 100644 --- a/.github/scripts/check_required_contexts.py +++ b/.github/scripts/check_required_contexts.py @@ -59,6 +59,10 @@ "Weekly cron + workflow_dispatch; warn-only by default with auto-" " filed tracking issue. Never appears on PR check sets." ), + "changelog-prestage.yml": ( + "workflow_dispatch only; opens its own pre-stage PR before a" + " release PR is opened. Never appears on PR check sets." + ), } diff --git a/.github/workflows/changelog-prestage.yml b/.github/workflows/changelog-prestage.yml new file mode 100644 index 0000000..aa90d41 --- /dev/null +++ b/.github/workflows/changelog-prestage.yml @@ -0,0 +1,169 @@ +name: Pre-stage CHANGELOG before release + +# Triggered manually (`workflow_dispatch`) before opening a release PR. +# Inserts the `## [] - ` heading + footer link into develop's +# CHANGELOG ahead of time, and merges main into develop on the prestage +# branch so develop's CHANGELOG already has the structural shape main +# carries. The release PR opened afterwards is conflict-free by +# construction. +# +# Why: release PRs commonly hit a same-line CHANGELOG conflict on the +# release-branch push because git sees both develop and main editing +# the region right after `## [Unreleased]`. The pre-#168 pattern was for +# the operator to manually `git merge origin/main` on the release branch +# every time; this workflow packages the same merge plus the heading +# insertion as a reviewable PR. +# +# Distinct from `changelog-rollup.yml`: rollup runs *after* a release +# tag is cut, bumps the version, and is auto-triggered by release.yml +# success. Prestage runs *before* the tag, doesn't bump version (the +# post-release rollup does), and is operator-triggered. Both share +# `.github/scripts/rollup_changelog.py` — prestage uses the `--no-bump` +# flag. + +on: + workflow_dispatch: + inputs: + tag: + description: "Tag about to be released (e.g. v1.11.0)" + required: true + type: string + prior_tag: + description: "Prior release tag for the compare link (auto-resolves if empty)" + required: false + type: string + default: "" + date: + description: "Release date YYYY-MM-DD (defaults to today UTC)" + required: false + type: string + default: "" + +permissions: + contents: write + pull-requests: write + +jobs: + prestage: + name: Open prestage PR + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + ref: develop + # Full history so `git describe --abbrev=0 --tags` can resolve + # the prior tag, and the `git merge origin/main` step has a + # complete merge-base. + fetch-depth: 0 + # Prefer RELEASE_BOT_TOKEN so the prestage branch's push fires + # `pull_request` workflows on the auto-PR. Falls back to + # GITHUB_TOKEN when the secret isn't set — the auto-PR still + # opens, but its CI doesn't run until a user pushes on top. + token: ${{ secrets.RELEASE_BOT_TOKEN || secrets.GITHUB_TOKEN }} + + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: "3.14" + + - name: Resolve inputs + id: resolve + run: | + set -euo pipefail + TAG="${{ inputs.tag }}" + if [ -z "${TAG}" ]; then + echo "::error::tag input is required" + exit 1 + fi + # Auto-resolve prior tag if not provided (mirrors changelog-rollup.yml). + PRIOR="${{ inputs.prior_tag }}" + if [ -z "${PRIOR}" ]; then + if PRIOR=$(git describe --abbrev=0 --tags --match 'v*.*.*' "${TAG}^" 2>/dev/null); then + echo "prior tag (resolved): ${PRIOR}" + else + # Try the most recent tag on main if `^` doesn't resolve + # (the tag isn't cut yet — that's the whole point of prestage). + if PRIOR=$(git describe --abbrev=0 --tags --match 'v*.*.*' origin/main 2>/dev/null); then + echo "prior tag (resolved from main): ${PRIOR}" + else + PRIOR="" + echo "no prior tag (first release)" + fi + fi + fi + DATE="${{ inputs.date }}" + if [ -z "${DATE}" ]; then + DATE=$(date -u +%Y-%m-%d) + fi + echo "tag=${TAG}" >> "$GITHUB_OUTPUT" + echo "prior=${PRIOR}" >> "$GITHUB_OUTPUT" + echo "date=${DATE}" >> "$GITHUB_OUTPUT" + echo "branch=chore/changelog-prestage-${TAG}" >> "$GITHUB_OUTPUT" + + - name: Create prestage branch + merge main + env: + BRANCH: ${{ steps.resolve.outputs.branch }} + run: | + set -euo pipefail + # Idempotent: if the branch already exists from a previous replay, + # bail rather than force-push. + if git ls-remote --exit-code --heads origin "${BRANCH}" >/dev/null 2>&1; then + echo "::warning::branch ${BRANCH} already exists; skipping push to avoid clobbering an in-flight prestage PR" + exit 0 + fi + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git checkout -b "${BRANCH}" + # Bring main's CHANGELOG state into develop. Prefer auto-merge; + # if there's a real conflict (rare in steady-state — develop and + # main usually share their post-rollup history), fail fast with + # a clear message so the operator can resolve manually. + if ! git merge --no-edit origin/main; then + 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)." + git merge --abort + exit 1 + fi + + - name: Run rollup script in pre-stage mode + run: | + python .github/scripts/rollup_changelog.py \ + --tag "${{ steps.resolve.outputs.tag }}" \ + --prior-tag "${{ steps.resolve.outputs.prior }}" \ + --date "${{ steps.resolve.outputs.date }}" \ + --no-bump + + - name: Open prestage PR + env: + # Same fallback as the checkout step (#174) so the auto-PR + # fires `pull_request` workflows on creation when the secret + # is provisioned; falls back to GITHUB_TOKEN otherwise. + GH_TOKEN: ${{ secrets.RELEASE_BOT_TOKEN || secrets.GITHUB_TOKEN }} + BRANCH: ${{ steps.resolve.outputs.branch }} + TAG: ${{ steps.resolve.outputs.tag }} + DATE: ${{ steps.resolve.outputs.date }} + run: | + set -euo pipefail + git add CHANGELOG.md + git commit -m "chore: pre-stage CHANGELOG for ${TAG} - ${DATE}" + git push origin "${BRANCH}" + gh pr create \ + --base develop \ + --head "${BRANCH}" \ + --title "chore: pre-stage CHANGELOG for ${TAG} - ${DATE}" \ + --body "$(cat <...${TAG}\` footer link. + - **Does not** bump \`pyproject.toml\` / \`uv.lock\` — that's the post-release rollup's job. + + ## Operator next step + + 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. + + See [docs/DEVELOPMENT.md#creating-a-release](docs/DEVELOPMENT.md#creating-a-release) for the full cycle. + EOF + )" diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 20e11cf..db7bb90 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -102,6 +102,8 @@ Subject is **lowercase after the colon** (Title Case is rejected unless it's an | `eval-nightly.yml` | `workflow_dispatch` only by default | No | | `codeql.yml` | `workflow_dispatch` only (placeholder) | No | | `pin-freshness-audit.yml` | weekly + `workflow_dispatch` | No — async second layer of action-pinning policy | +| `changelog-rollup.yml` | after `release.yml` succeeds + `workflow_dispatch` | No — opens a `chore: roll up CHANGELOG …` PR against develop | +| `changelog-prestage.yml` | `workflow_dispatch` only | No — operator-triggered before opening the release PR (closes the same-line CHANGELOG conflict class) | ### Action-pinning policy @@ -121,6 +123,21 @@ Audited by the `Version bump check` CI job (`.github/scripts/check_version_bump. 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. +### Creating a release + +The release flow chains four workflows and one script: + +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 `## [] - ` heading + footer compare-link. Merge that PR. +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. +3. **Tag the merge commit** — `git tag vX.Y.Z && git push origin vX.Y.Z`. Triggers `release.yml`. +4. **`release.yml`** builds the image, pushes to GHCR, generates the CycloneDX SBOM, publishes the GitHub Release. +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). + +The shared script is `.github/scripts/rollup_changelog.py`: + +- `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. +- `rollup_changelog.py … --no-bump` — CHANGELOG edits only. Used by `changelog-prestage.yml` pre-release. + ### Testing policy 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. diff --git a/pyproject.toml b/pyproject.toml index 3e23ecf..58ab761 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "harness-python-react" -version = "0.2.7" +version = "0.2.8" description = "Production-quality LLM-driven coding harness — Python (FastAPI) backend, Vite + React + TypeScript frontend." readme = "README.md" requires-python = ">=3.14" diff --git a/uv.lock b/uv.lock index fa4a87c..9eb1613 100644 --- a/uv.lock +++ b/uv.lock @@ -328,7 +328,7 @@ wheels = [ [[package]] name = "harness-python-react" -version = "0.2.7" +version = "0.2.8" source = { virtual = "." } dependencies = [ { name = "fastapi" },