| 1 | name: Tag release |
| 2 | |
| 3 | # After a prepare-release PR merges to main, create the vX.Y.Z tag and |
| 4 | # dispatch release.yml (GITHUB_TOKEN tag pushes do not start other workflows). |
| 5 | |
| 6 | on: |
| 7 | push: |
| 8 | branches: |
| 9 | - main |
| 10 | |
| 11 | permissions: {} |
| 12 | |
| 13 | jobs: |
| 14 | tag: |
| 15 | runs-on: ubuntu-latest |
| 16 | # Only act on the release-prep commit shape produced by prepare-release.yml |
| 17 | # (or an equivalent manual chore(release) commit). Quote the expression — |
| 18 | # a bare `chore(release):` colon is invalid YAML and fails the whole |
| 19 | # workflow before any job runs. Use contains (not startsWith) so merge |
| 20 | # commits whose subject is "Merge pull request #N …" still match when the |
| 21 | # PR title is in the body. |
| 22 | if: "contains(github.event.head_commit.message, 'chore(release): bump version to ')" |
| 23 | permissions: |
| 24 | contents: write |
| 25 | actions: write |
| 26 | pull-requests: read |
| 27 | steps: |
| 28 | - name: Checkout |
| 29 | uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 |
| 30 | with: |
| 31 | fetch-depth: 0 |
| 32 | persist-credentials: false |
| 33 | |
| 34 | - name: Create annotated tag and dispatch Release |
| 35 | env: |
| 36 | GH_TOKEN: ${{ github.token }} |
| 37 | HEAD_MSG: ${{ github.event.head_commit.message }} |
| 38 | HEAD_SHA: ${{ github.sha }} |
| 39 | REPOSITORY: ${{ github.repository }} |
| 40 | DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} |
| 41 | run: | |
| 42 | set -euo pipefail |
| 43 | # Scan every line — merge commits put the chore(release) title in the body. |
| 44 | VERSION="$(printf '%s\n' "${HEAD_MSG}" | sed -n 's/^chore(release): bump version to \([0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*\).*/\1/p' | head -n1)" |
| 45 | if [ -z "${VERSION}" ]; then |
| 46 | echo "Could not parse version from commit message: ${HEAD_MSG}" |
| 47 | exit 1 |
| 48 | fi |
| 49 | TAG="v${VERSION}" |
| 50 | |
| 51 | PY_VERSION="$(python3 -c "import tomllib; print(tomllib.load(open('pyproject.toml','rb'))['project']['version'])")" |
| 52 | if [ "${PY_VERSION}" != "${VERSION}" ]; then |
| 53 | echo "Commit message version (${VERSION}) does not match pyproject.toml (${PY_VERSION})" |
| 54 | exit 1 |
| 55 | fi |
| 56 | |
| 57 | # Require the merged PR to carry the repository-controlled `release` |
| 58 | # label so a matching title alone cannot mint a tag. |
| 59 | PR_NUMBER="$(gh api "repos/${REPOSITORY}/commits/${HEAD_SHA}/pulls" \ |
| 60 | --jq 'map(select(.base.ref == env.DEFAULT_BRANCH)) | .[0].number // empty')" |
| 61 | if [ -z "${PR_NUMBER}" ]; then |
| 62 | echo "No PR found for ${HEAD_SHA} into ${DEFAULT_BRANCH} — refusing to tag." |
| 63 | exit 1 |
| 64 | fi |
| 65 | if ! gh api "repos/${REPOSITORY}/issues/${PR_NUMBER}/labels" \ |
| 66 | --jq '.[].name' | grep -qx 'release'; then |
| 67 | echo "PR #${PR_NUMBER} lacks the 'release' label — refusing to tag." |
| 68 | exit 1 |
| 69 | fi |
| 70 | |
| 71 | if git rev-parse -q --verify "refs/tags/${TAG}" >/dev/null; then |
| 72 | echo "Tag ${TAG} already exists locally — nothing to do." |
| 73 | exit 0 |
| 74 | fi |
| 75 | if git ls-remote --exit-code --tags origin "refs/tags/${TAG}" >/dev/null 2>&1; then |
| 76 | echo "Tag ${TAG} already exists on origin — nothing to do." |
| 77 | exit 0 |
| 78 | fi |
| 79 | |
| 80 | git config user.name "github-actions[bot]" |
| 81 | git config user.email "41898282+github-actions[bot]@users.noreply.github.com" |
| 82 | git remote set-url origin "https://x-access-token:${GH_TOKEN}@github.com/${REPOSITORY}.git" |
| 83 | git tag -a "${TAG}" -m "Release ${TAG}" |
| 84 | git push origin "refs/tags/${TAG}" |
| 85 | echo "Created and pushed ${TAG}" |
| 86 | |
| 87 | # GITHUB_TOKEN tag pushes do not trigger release.yml; dispatch it. |
| 88 | gh workflow run release.yml \ |
| 89 | --ref "${DEFAULT_BRANCH}" \ |
| 90 | -f "tag=${TAG}" |
| 91 | echo "Dispatched release.yml for ${TAG}" |
| 92 |