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/release.sh
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@ repo_root="$(git rev-parse --show-toplevel)"
cd "$repo_root"

if [[ "$bump_type" == "publish" ]]; then
if [[ -f .github/release-policy.json ]]; then
echo "ERROR: CI owns tagging for this repository; follow the release workflow run." >&2
exit 1
fi
if [[ "$(git branch --show-current)" != "main" ]] || [[ -n "$(git status --porcelain)" ]]; then
echo "ERROR: publish requires a clean main checkout." >&2
exit 1
Expand Down
6 changes: 6 additions & 0 deletions .github/scripts/tests/release.sh
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,9 @@ if bash "$script" publish; then
echo 'Publishing an existing tag should fail' >&2
exit 1
fi
mkdir -p .github
printf '{}\n' > .github/release-policy.json
if bash "$script" publish; then
echo 'Manual publication with a CI policy should fail' >&2
exit 1
fi
94 changes: 94 additions & 0 deletions .github/workflows/release-tag.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
# Shared implementation is pinned separately so no caller code runs with App secrets.
name: Validate and tag release

on:
workflow_call:
inputs:
app-id:
description: Release GitHub App ID
type: string
required: true
secrets:
release-app-private-key:
required: true
outputs:
tag:
description: Release tag, empty for a push without release intent
value: ${{ jobs.validate.outputs.tag }}

permissions:
contents: read

jobs:
validate:
runs-on: ubuntu-latest
outputs:
release: ${{ steps.intent.outputs.release }}
tag: ${{ steps.intent.outputs.tag }}
commit: ${{ steps.intent.outputs.commit }}
steps:
- name: Require a main push
env:
EVENT: ${{ github.event_name }}
REF: ${{ github.ref }}
DELETED: ${{ github.event.deleted }}
run: |
test "$EVENT" = push
test "$REF" = refs/heads/main
test "$DELETED" = false
- uses: actions/checkout@v5
with:
ref: ${{ github.sha }}
fetch-depth: 0
persist-credentials: false
path: caller
- uses: actions/checkout@v5
with:
repository: BehindTheMusicTree/github-workflows
ref: f304059fd7e9a9d0743a9e6a76ae11ea6e12f121
persist-credentials: false
path: foundation
- name: Validate the exact push
id: intent
working-directory: caller
env:
BEFORE: ${{ github.event.before }}
AFTER: ${{ github.sha }}
POLICY: .github/release-policy.json
run: |
python3 ../foundation/release/validate-release.py "$BEFORE" "$AFTER" "$POLICY" > "$RUNNER_TEMP/release-intent.json"
jq -r '"release=\(.release)", "tag=\(.tag // "")", "commit=\(.commit // "")"' "$RUNNER_TEMP/release-intent.json" >> "$GITHUB_OUTPUT"

tag:
needs: validate
if: needs.validate.outputs.release == 'true'
runs-on: ubuntu-latest
# Each event is independent: a global concurrency group can drop pending bumps.
steps:
- uses: actions/checkout@v5
with:
repository: BehindTheMusicTree/github-workflows
ref: f304059fd7e9a9d0743a9e6a76ae11ea6e12f121
persist-credentials: false
- name: Require App configuration
env:
APP_ID: ${{ inputs.app-id }}
APP_PRIVATE_KEY: ${{ secrets.release-app-private-key }}
run: |
test -n "$APP_ID" || { echo 'Release App ID missing' >&2; exit 1; }
test -n "$APP_PRIVATE_KEY" || { echo 'Release App private key missing' >&2; exit 1; }
- name: Create repository-scoped App token
id: app
uses: actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349 # v2
with:
app-id: ${{ inputs.app-id }}
private-key: ${{ secrets.release-app-private-key }}
owner: ${{ github.repository_owner }}
repositories: ${{ github.event.repository.name }}
permission-contents: write
- name: Create or verify immutable annotated tag
env:
RELEASE_APP_TOKEN: ${{ steps.app.outputs.token }}
TAG: ${{ needs.validate.outputs.tag }}
COMMIT: ${{ needs.validate.outputs.commit }}
run: python3 release/create-tag.py "$TAG" "$COMMIT"
12 changes: 12 additions & 0 deletions .github/workflows/test-actions.yml
Original file line number Diff line number Diff line change
Expand Up @@ -48,3 +48,15 @@ jobs:
- uses: actions/checkout@v5
- name: Verify prepare and publish
run: bash .github/scripts/tests/release.sh

release-foundation-tests:
name: release foundation tests
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v5
- name: Verify validation, collisions and retries without remote writes
run: python3 -m unittest discover -s release -p 'test_*.py'
- name: Lint release shell scripts
run: shellcheck .github/scripts/release.sh .github/scripts/tests/release.sh
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@
.local/
.github/pr-descriptions/
.claude/worktrees/
__pycache__/
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,10 @@ All contributors (including maintainers) should update `CHANGELOG.md` when creat

## [Unreleased]

### Added

- Add a reusable release validator and atomic App-based annotated tagging workflow, with explicit version contracts, migration safeguards and retry tests.

## [5.0.1] - 2026-11-02


Expand Down
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -392,3 +392,25 @@ See [CHANGELOG.md](CHANGELOG.md) for a detailed history of changes, including ve
## License

[Specify your license here]

## Release validation and tagging

The reusable [release-tag workflow](.github/workflows/release-tag.yml) validates a push to `main` and creates an annotated tag on that event's exact commit. It does not build packages, create GitHub Releases, or deploy. Existing tag-triggered publishers keep their permissions, OIDC identities and workflow filenames.

An adopter commits `.github/release-policy.json` using the [schema](release/release-policy.schema.json) and [example policy](examples/release-policy.json). The contract declares one canonical version, all mirrors, a tag prefix, a changelog and expected publication outputs. JSON and TOML sources use dot-separated keys; text files contain a version. Versions must be stable SemVer without leading zeroes. The validator enforces repository-relative paths and rejects duplicate sources. The changelog must contain exactly one `## [VERSION]` section with content beyond headings and HTML comments.

Use the [caller example](examples/release-tag.yml), replacing its placeholder with a full **merged and reviewed commit SHA**. Configure `RELEASE_APP_ID` and the explicitly named `RELEASE_APP_PRIVATE_KEY` secret. Install the App on the adopting repository with Contents write permission; select only adopting repositories for organization secrets. Do not pass registry credentials or `secrets: inherit`.

Before enabling a caller, configure separate tag rulesets: creation permits the release App; update/deletion protection has no App bypass. The App must have no bypass of `main` protection. PR checks must cover the version and publisher changes before merge. If a repository also requires checks after merge, add those jobs in the caller's push workflow and make the reusable-workflow job depend on their successful completion with `needs`. Those checks must operate on `github.sha`, including reruns after a later push; a check of the latest branch tip is insufficient.

Activation and changes to the contract must land in a separate push from a version bump. Activation validates the current version, mirrors and changelog but never tags it, including an existing version with no tag. A subsequent push with no bump also produces no tag. For an established contract, a bump must increase the version and leave the policy unchanged; all mirrors and its changelog section must match. The version comparison uses the push's before/after commits, never the current branch tip. Force pushes and newly created branch events fail closed.

Prepare version and changelog in a PR using the project's own tools. In this repository, `.github/scripts/release.sh major|minor|patch` prepares a commit on a feature branch. Its legacy `publish` command remains available to repositories without the CI policy; once `.github/release-policy.json` exists, manual publication is refused. This foundation PR does not activate automatic tagging for github-workflows itself.

Validation runs without App secrets and checks ancestry against fetched `origin/main`. A separate runner checks out only this repository's shared implementation at an immutable SHA, checks configuration, then requests an App token scoped to the caller repository and Contents write. The App-token action revokes it in its post step. No caller script or build runs in that job. Maintainers changing the Python implementation must commit it first and update **both** helper checkout SHAs in the reusable workflow to that reviewed snapshot. The outer workflow is independently pinned by each caller.

Each run creates an annotated tag object, then atomically creates its ref. An existing lightweight or annotated tag that dereferences to the same commit is a successful retry; a tag on another commit fails. Concurrent creation is accepted only after resolving the winner to the same SHA. API failures propagate. There is no global concurrency group that can discard a pending bump. A failed API response after ref creation can be retried with the same push event; never move a tag or bump again to recover.

`outputs` records the publisher contract; this tagger does not assert that those artifacts are published. For a partial publication, rerun the existing tag publisher after checking each required artifact. In a multi-package project the publisher must skip an already published identical version and distinguish registry errors from an absent package. GitHub Release creation must have one owner and wait for every required package. Do not replace a missing publisher run with an arbitrary workflow dispatch.

Run `python3 -m unittest discover -s release -p 'test_*.py'`, `bash .github/scripts/tests/release.sh`, `shellcheck .github/scripts/release.sh .github/scripts/tests/release.sh`, and `actionlint`. Tests simulate tagging and API errors without credentials or remote writes. Python 3.11+ is required. Repository-specific publisher recovery and a live App run are adoption checks, beyond the local foundation tests.
8 changes: 8 additions & 0 deletions examples/release-policy.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"release_branch": "main",
"version": { "path": "VERSION", "format": "text" },
"mirrors": [],
"tag_prefix": "v",
"changelog": "CHANGELOG.md",
"outputs": { "github_release": true, "packages": [] }
}
15 changes: 15 additions & 0 deletions examples/release-tag.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# Place at .github/workflows/release-tag.yml in an adopting repository.
name: Release tag
on:
push:
branches: [main]
permissions:
contents: read
jobs:
release:
# Replace with the full merged, reviewed github-workflows commit SHA.
uses: BehindTheMusicTree/github-workflows/.github/workflows/release-tag.yml@REPLACE_WITH_MERGED_COMMIT_SHA
with:
app-id: ${{ vars.RELEASE_APP_ID }}
secrets:
release-app-private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}
109 changes: 109 additions & 0 deletions release/create-tag.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
#!/usr/bin/env python3
"""Create an immutable annotated release tag; never build or publish artifacts."""

import json
import os
import re
import sys
from urllib.error import HTTPError
from urllib.parse import quote
from urllib.request import Request, urlopen


class APIError(ValueError):
def __init__(self, status):
super().__init__(f"GitHub API request failed (HTTP {status})")
self.status = status


class GitHub:
def __init__(self, repository, token, api_url):
if not re.fullmatch(r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+", repository):
raise ValueError("invalid repository")
if not token:
raise ValueError("release App token missing")
if not api_url.startswith("https://"):
raise ValueError("GitHub API URL must use HTTPS")
self.base = f"{api_url.rstrip('/')}/repos/{repository}"
self.token = token

def request(self, method, path, data=None):
request = Request(
self.base + path,
data=json.dumps(data).encode() if data is not None else None,
headers={
"Authorization": f"Bearer {self.token}",
"Accept": "application/vnd.github+json",
"Content-Type": "application/json",
"X-GitHub-Api-Version": "2022-11-28",
},
method=method,
)
try:
with urlopen(request, timeout=30) as response:
return json.load(response)
except HTTPError as exc:
raise APIError(exc.code) from None


def existing_commit(api, tag):
try:
obj = api.request("GET", "/git/ref/tags/" + quote(tag, safe=""))["object"]
except APIError as exc:
if exc.status == 404:
return None
raise
seen = set()
while obj["type"] == "tag":
sha = obj["sha"]
if sha in seen or len(seen) >= 20 or not re.fullmatch(r"[0-9a-f]{40}", sha):
raise ValueError("invalid annotated tag chain")
seen.add(sha)
obj = api.request("GET", "/git/tags/" + sha)["object"]
if obj["type"] != "commit" or not re.fullmatch(r"[0-9a-f]{40}", obj["sha"]):
raise ValueError("release tag must resolve to a commit")
return obj["sha"]


def create_tag(api, tag, commit):
if not re.fullmatch(r"[A-Za-z0-9_-]+(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)", tag):
raise ValueError("invalid stable release tag")
if not re.fullmatch(r"[0-9a-f]{40}", commit) or commit == "0" * 40:
raise ValueError("invalid commit SHA")
current = existing_commit(api, tag)
if current is not None:
if current != commit:
raise ValueError("release tag collision: existing tag targets another commit")
return "existing"
annotated = api.request("POST", "/git/tags", {
"tag": tag, "message": f"Release {tag}", "object": commit, "type": "commit",
})
sha = annotated["sha"]
if not re.fullmatch(r"[0-9a-f]{40}", sha):
raise ValueError("invalid tag object SHA returned by GitHub")
try:
api.request("POST", "/git/refs", {"ref": "refs/tags/" + tag, "sha": sha})
except APIError as exc:
# Another run may win the atomic ref creation. Only the same commit is a retry.
if exc.status != 422:
raise
current = existing_commit(api, tag)
if current is None:
raise exc
if current != commit:
raise ValueError("release tag collision: concurrent tag targets another commit") from None
return "existing"
if existing_commit(api, tag) != commit:
raise ValueError("created tag did not resolve to the expected commit")
return "created"


if __name__ == "__main__":
try:
if len(sys.argv) != 3:
raise ValueError("usage: create-tag.py TAG COMMIT")
api = GitHub(os.environ["GITHUB_REPOSITORY"], os.environ["RELEASE_APP_TOKEN"], os.environ["GITHUB_API_URL"])
sys.stdout.write(create_tag(api, *sys.argv[1:]) + "\n")
except (ValueError, KeyError, TypeError, OSError) as exc:
sys.stderr.write(f"release tag creation failed: {exc}\n")
sys.exit(1)
Loading
Loading