Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/scripts/check_required_contexts.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."
),
}


Expand Down
169 changes: 169 additions & 0 deletions .github/workflows/changelog-prestage.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
name: Pre-stage CHANGELOG before release

# Triggered manually (`workflow_dispatch`) before opening a release PR.
# Inserts the `## [<tag>] - <date>` 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 `<TAG>^` 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 <<EOF
Auto-opened by [.github/workflows/changelog-prestage.yml](.github/workflows/changelog-prestage.yml) before the ${TAG} release PR.

## What this PR does

- Merges \`origin/main\` into develop (so develop's CHANGELOG has the same structural shape main carries).
- Inserts \`## [${TAG#v}] - ${DATE}\` heading after \`## [Unreleased]\` in CHANGELOG.md.
- Updates \`[Unreleased]: …/compare/${TAG}...HEAD\` footer link.
- Adds \`[${TAG#v}]: …/compare/<prior>...${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
)"
17 changes: 17 additions & 0 deletions docs/DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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 `## [<version>] - <date>` 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.
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading