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
68 changes: 68 additions & 0 deletions .github/workflows/tag-release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
# Tags a release the moment its version reaches main.
#
# v0.1.2 reached main on 2026-09-09 and stayed untagged for twelve days, because tagging
# was a manual step nobody owned. version.txt is the release version and main is what
# publishes, so a push to main that changes version.txt is the release event. The tag
# lands on that commit, the same place v0.1.1 and v0.1.2 point.
name: Tag release

on:
push:
branches: ["main"]
paths:
- "version.txt"
workflow_dispatch:

# Deny by default. The one job below grants itself only what it needs.
permissions: {}

# Two pushes to main in quick succession must not race each other to the same tag.
concurrency:
group: tag-release
cancel-in-progress: false

jobs:
tag:
# A manual run dispatched from any other branch would tag a commit main never
# published.
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
permissions:
contents: write # push the tag
steps:
# The tag push below authenticates with the token this checkout persists, so it
# has to stay. The job uploads no artifacts, which is the leak zizmor's
# artipacked audit guards against.
- name: Check out the repository # zizmor: ignore[artipacked]
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

- name: Tag the release unless it is tagged already
run: |
set -euo pipefail
VERSION="$(tr -d '[:space:]' < version.txt)"
# version.txt is attacker-controlled by anyone with write access, and this
# value becomes a git ref. Anything not matching semver stops here.
if ! printf '%s' "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+([-+][0-9A-Za-z.-]+)?$'; then
echo "::error file=version.txt::Not a valid semantic version"
exit 1
fi

# The release version lives in three files and no other check keeps them in
# step. A tag makes a mismatch permanent, so a mismatch fails here loudly.
PYPROJECT="$(sed -n 's/^version = "\(.*\)"$/\1/p' pyproject.toml | head -n 1)"
LOCKED="$(awk '/^name = "acs"$/ { found = 1; next } found && /^version = / { gsub(/"/, "", $3); print $3; exit }' uv.lock)"
if [ "$PYPROJECT" != "$VERSION" ] || [ "$LOCKED" != "$VERSION" ]; then
echo "::error::version.txt says ${VERSION}, pyproject.toml says ${PYPROJECT:-nothing}, uv.lock says ${LOCKED:-nothing}"
exit 1
fi

TAG="v${VERSION}"
if git ls-remote --exit-code --tags origin "refs/tags/${TAG}" >/dev/null; then
echo "${TAG} already exists. Nothing to do."
exit 0
fi
git -c user.name="github-actions[bot]" \
-c user.email="41898282+github-actions[bot]@users.noreply.github.com" \
tag -a "$TAG" "$GITHUB_SHA" -m "ACS ${TAG}"
git push origin "refs/tags/${TAG}"
echo "Tagged ${GITHUB_SHA} as ${TAG}."
5 changes: 5 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,11 @@ That is worth more to this project than a patch nobody asked for.

Project maintainers handle formal releases. Focus on contributing great features and fixes.

A release is tagged automatically. When a change to `version.txt` reaches `main`, the
`Tag release` workflow tags that commit `v<version>`. It refuses when `version.txt`,
`pyproject.toml`, and `uv.lock` disagree, so a mismatched release fails loudly instead of
shipping.

## Reporting Security Issues

**Do not file public issues for security vulnerabilities.** Use GitHub's [private vulnerability reporting](https://github.com/GenAI-Security-Project/agent-control-standard/security/advisories/new) to disclose privately. We'll acknowledge within 72 hours and coordinate a fix and disclosure timeline with you.
Expand Down
Loading