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
91 changes: 91 additions & 0 deletions .github/workflows/create-release-tag.yml
Original file line number Diff line number Diff line change
@@ -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"
38 changes: 32 additions & 6 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand All @@ -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}")
Comment on lines +64 to +65

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Recheck the main-commit invariant before publishing

When a repository writer directly invokes workflow_dispatch or pushes a matching version tag, this check accepts any tag/SHA whose checked-out package version matches, even if that commit would fail the new release branch's current-main check. gh workflow run --help confirms that --ref main only selects the ref containing the workflow file; it does not constrain release_sha to main. Consequently, publication can still bypass the claimed release gate, so this workflow should independently compare the supplied SHA with current main or otherwise verify that the gated workflow authorized it.

Useful? React with 👍 / 👎.

PY

- name: Test and build
Expand Down Expand Up @@ -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
Expand All @@ -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"
Expand All @@ -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
Expand Down
10 changes: 10 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
7 changes: 4 additions & 3 deletions docs/PROJECT_REVIEW.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.
Loading