diff --git a/.github/workflows/create-release-tag.yml b/.github/workflows/create-release-tag.yml new file mode 100644 index 0000000..78e634d --- /dev/null +++ b/.github/workflows/create-release-tag.yml @@ -0,0 +1,91 @@ +name: Create release tag + +on: + push: + branches: + - "release/v*" + +concurrency: + group: release-tag-${{ github.ref }} + cancel-in-progress: false + +permissions: {} + +jobs: + tag: + runs-on: ubuntu-latest + permissions: + actions: write + contents: write + steps: + - name: Checkout the candidate + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Validate release branch, version, and main commit + id: validate + shell: bash + env: + RELEASE_BRANCH: ${{ github.ref_name }} + RELEASE_SHA: ${{ github.sha }} + run: | + set -euo pipefail + release_tag=$(python - "$RELEASE_BRANCH" <<'PY' + import pathlib + import re + import sys + import tomllib + + branch = sys.argv[1] + match = re.fullmatch(r"release/(v[0-9]+\.[0-9]+\.[0-9]+)", branch) + if match is None: + raise SystemExit(f"invalid release branch: {branch!r}") + tag = match.group(1) + version = tomllib.loads(pathlib.Path("pyproject.toml").read_text())["project"]["version"] + if tag != f"v{version}": + raise SystemExit(f"branch tag {tag!r} does not match package version v{version}") + print(tag) + PY + ) + git fetch --no-tags origin main + main_sha=$(git rev-parse FETCH_HEAD) + if [[ "$RELEASE_SHA" != "$main_sha" ]]; then + echo "Release branch must point to current main: $RELEASE_SHA != $main_sha" >&2 + exit 1 + fi + existing_tag=$(git ls-remote origin "refs/tags/$release_tag" | awk '{print $1}') + if [[ -n "$existing_tag" && "$existing_tag" != "$RELEASE_SHA" ]]; then + echo "Tag $release_tag already points to $existing_tag, not $RELEASE_SHA" >&2 + exit 1 + fi + echo "tag=$release_tag" >> "$GITHUB_OUTPUT" + if [[ "$existing_tag" == "$RELEASE_SHA" ]]; then + echo "tag_exists=true" >> "$GITHUB_OUTPUT" + else + echo "tag_exists=false" >> "$GITHUB_OUTPUT" + fi + + - name: Create the immutable version tag + if: steps.validate.outputs.tag_exists != 'true' + shell: bash + env: + RELEASE_SHA: ${{ github.sha }} + RELEASE_TAG: ${{ steps.validate.outputs.tag }} + run: | + set -euo pipefail + git tag "$RELEASE_TAG" "$RELEASE_SHA" + git push origin "refs/tags/$RELEASE_TAG" + + - name: Dispatch the verified publish workflow + shell: bash + env: + GH_TOKEN: ${{ github.token }} + RELEASE_SHA: ${{ github.sha }} + RELEASE_TAG: ${{ steps.validate.outputs.tag }} + run: | + set -euo pipefail + gh workflow run publish.yml \ + --ref main \ + --field release_tag="$RELEASE_TAG" \ + --field release_sha="$RELEASE_SHA" diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 79384b6..74ac952 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -4,6 +4,22 @@ on: push: tags: - "v[0-9]+.[0-9]+.[0-9]+" + workflow_dispatch: + inputs: + release_tag: + description: Existing immutable version tag to publish. + required: true + type: string + release_sha: + description: Exact commit SHA referenced by the version tag. + required: true + type: string + +permissions: {} + +env: + RELEASE_TAG: ${{ inputs.release_tag || github.ref_name }} + RELEASE_SHA: ${{ inputs.release_sha || github.sha }} jobs: verify-release: @@ -15,6 +31,7 @@ jobs: uses: actions/checkout@v6 with: fetch-depth: 0 + ref: ${{ inputs.release_sha || github.sha }} - name: Setup Python uses: actions/setup-python@v6 @@ -23,18 +40,29 @@ jobs: - name: Validate tag and package version shell: bash - env: - RELEASE_TAG: ${{ github.ref_name }} run: | - python - "$RELEASE_TAG" <<'PY' + python - "$RELEASE_TAG" "$RELEASE_SHA" <<'PY' import pathlib + import re + import subprocess import sys import tomllib expected = tomllib.loads(pathlib.Path("pyproject.toml").read_text())["project"]["version"] actual = sys.argv[1] + release_sha = sys.argv[2] + if re.fullmatch(r"[0-9a-f]{40}", release_sha) is None: + raise SystemExit(f"invalid release SHA: {release_sha!r}") if actual != f"v{expected}": raise SystemExit(f"tag {actual!r} does not match package version v{expected}") + head_sha = subprocess.check_output(["git", "rev-parse", "HEAD"], text=True).strip() + if head_sha != release_sha: + raise SystemExit(f"checked-out commit {head_sha} does not match {release_sha}") + remote_tag = subprocess.check_output( + ["git", "ls-remote", "origin", f"refs/tags/{actual}"], text=True + ).split() + if not remote_tag or remote_tag[0] != release_sha: + raise SystemExit(f"tag {actual!r} does not resolve to {release_sha}") PY - name: Test and build @@ -80,6 +108,7 @@ jobs: uses: actions/checkout@v6 with: fetch-depth: 0 + ref: ${{ inputs.release_sha || github.sha }} - name: Download verified distributions uses: actions/download-artifact@v4 @@ -89,8 +118,6 @@ jobs: - name: Advance the stable v0 tag shell: bash - env: - RELEASE_SHA: ${{ github.sha }} run: | set -euo pipefail git tag --force v0 "$RELEASE_SHA" @@ -102,7 +129,6 @@ jobs: shell: bash env: GH_TOKEN: ${{ github.token }} - RELEASE_TAG: ${{ github.ref_name }} run: | set -euo pipefail if gh release view "$RELEASE_TAG" >/dev/null 2>&1; then diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f45b212..c29418d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -24,3 +24,13 @@ python -m pytest -q boundver fingerprints are deterministic and content-addressed. Prefer explicit, strict behavior over implicit fallback to avoid surprising CI behavior. + +## Maintainer releases + +After the version, changelog, and v2 lockfiles are updated, merge the release PR +only after its full CI matrix passes. Then create a branch named +`release/vX.Y.Z` at the tested `main` commit. The release-tag workflow verifies +that the branch points to current `main` and matches `pyproject.toml` before it +creates the immutable version tag. The tag-triggered publish workflow retests +and builds the package, publishes the verified artifacts to PyPI, creates the +GitHub Release, and advances the stable major tag. diff --git a/docs/PROJECT_REVIEW.md b/docs/PROJECT_REVIEW.md index 4bd8a12..b41b08c 100644 --- a/docs/PROJECT_REVIEW.md +++ b/docs/PROJECT_REVIEW.md @@ -113,6 +113,7 @@ folding them invisibly into the broader baseline items. | BV-070 | Medium | Build/runtime floor | The earlier Python 3.8 support claim conflicted with the setuptools build-backend floor. `pyproject.toml`, CI matrices, README, maintained guides, and `CHANGELOG.md` now consistently declare Python 3.9+. External matrix execution remains a release gate. | Resolved | | BV-071 | Medium | Atomic writes | Direct lock/config replacement could leave truncated JSON if writing failed. `_write_text_atomic` writes and fsyncs a sibling temporary file before `os.replace`, and generate, verify-update, migration, init, add, and remove route mutations through it. | Resolved | | BV-072 | Medium | Documentation lifecycle | The historical implementation plan described a retired Action interface and linked deleted design/CI files, while a provider docstring repeated one stale link. The obsolete plan is retired and the provider points to the maintained custom-provider guide. | Resolved | +| BV-073 | Medium | Release initiation | Tag creation had no repository-enforced link to the tested `main` commit. `.github/workflows/create-release-tag.yml` accepts only `release/vX.Y.Z`, requires the branch SHA to equal current `main`, matches the version to `pyproject.toml`, rejects a conflicting immutable tag, and explicitly dispatches publication with the pinned tag/SHA. This uses GitHub's documented `workflow_dispatch` exception to `GITHUB_TOKEN` recursion suppression. | Resolved | ## Verification index @@ -163,9 +164,9 @@ semantic or backward compatibility. - [x] Every finding above is marked resolved with a verification reference. - [x] Unit and integration suites pass without environment shims (834 tests). -- [ ] Supported Python versions pass in external CI. -- [ ] Root Action passes its external workflow test with safe input handling. +- [x] Supported Python versions pass in external CI (Python 3.9–3.14 on Linux and Windows). +- [x] Root Action passes its external workflow test with safe input handling. - [x] Wheel and sdist inspection plus installed-package smoke tests pass for the exact local release commit. - [ ] Tag exactly matches `pyproject.toml` and `boundver.__version__`. -- [ ] GitHub branch/PR checks pass before the release tag is created. +- [x] GitHub branch/PR and post-merge `main` checks pass before the release tag is created. - [ ] GitHub Release, Marketplace tag, and PyPI publication all point to the same commit.