From c397d0e60f7e0f63aa69f8458521d1057186c4d9 Mon Sep 17 00:00:00 2001 From: rocklambros Date: Mon, 21 Sep 2026 10:14:48 -0600 Subject: [PATCH 1/2] Tag each release automatically when its version reaches main v0.1.2 reached `main` with #59 on 2026-09-09 and stayed untagged until today, when it was tagged by hand on `7743826`, the commit that first carried it. Tagging was a manual step with no owner, so nothing noticed the gap. `Tag release` makes it automatic. A push to `main` that changes `version.txt` is the release event, and the workflow tags that commit `v` as an annotated tag, which matches how v0.1.1 and v0.1.2 are shaped. It exits quietly when the tag already exists, so reruns and unrelated pushes are safe. A manual dispatch from any branch other than `main` is skipped, since it would tag a commit `main` never published. It also refuses to tag when `version.txt`, `pyproject.toml`, and `uv.lock` disagree. The release version lives in those three files and no other check keeps them in step. A tag is the point where a mismatch becomes permanent, so the run fails with all three values in the error. `version.txt` is attacker-controlled by anyone with write access and becomes a git ref here, so it passes the same semver check `sync_version.yml` uses before it reaches `git tag`. No `${{ }}` expression appears inside `run:`. The job holds `contents: write` and nothing else, and the only action is `actions/checkout` at the SHA every other workflow pins. The workflow lives on `main` only after the next promotion, so the first release it tags is the next version bump that reaches `main` after that. Tested against a local bare remote with the step's own script, extracted from the YAML. An existing version exits 0 with "already exists". A new version in all three files creates an annotated tag and a rerun is a no-op. A stale `uv.lock` fails with the three values. An invalid version fails before touching git. `actionlint` is clean and `uv run pytest` gives 278 passed, 1 skipped. CONTRIBUTING.md's Release Process section now says tagging is automatic. Signed-off-by: Rock Lambros --- .github/workflows/tag-release.yml | 65 +++++++++++++++++++++++++++++++ CONTRIBUTING.md | 5 +++ 2 files changed, 70 insertions(+) create mode 100644 .github/workflows/tag-release.yml diff --git a/.github/workflows/tag-release.yml b/.github/workflows/tag-release.yml new file mode 100644 index 0000000..35babb1 --- /dev/null +++ b/.github/workflows/tag-release.yml @@ -0,0 +1,65 @@ +# 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: + - name: Check out the repository + 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}." diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1476c8c..c8e6b63 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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`. 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. From ad91aa00217dce850582d1beaafd944700ef52fe Mon Sep 17 00:00:00 2001 From: rocklambros Date: Mon, 21 Sep 2026 10:41:42 -0600 Subject: [PATCH 2/2] Record why the tag job keeps its checkout credentials zizmor's artipacked audit flags a checkout that leaves the job token in .git/config. This job pushes the tag with that token and uploads no artifacts, which is the leak the audit guards against, so the finding is suppressed inline with the reason. Signed-off-by: rocklambros --- .github/workflows/tag-release.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/tag-release.yml b/.github/workflows/tag-release.yml index 35babb1..17d580b 100644 --- a/.github/workflows/tag-release.yml +++ b/.github/workflows/tag-release.yml @@ -30,7 +30,10 @@ jobs: permissions: contents: write # push the tag steps: - - name: Check out the repository + # 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