diff --git a/.githooks/pre-push b/.githooks/pre-push index 99cd66f..793c69d 100755 --- a/.githooks/pre-push +++ b/.githooks/pre-push @@ -3,11 +3,16 @@ # branch + pull request. This mirrors the server-side branch protection on # binlecode/actop and fails fast locally before a push is rejected by GitHub. # +# Also nudges when a v* tag is being pushed directly (bare `git push origin +# vX.Y.Z`) — use scripts/tag_release.sh instead; it creates the required +# GitHub Release object alongside the tag. +# # Enabled via: git config core.hooksPath .githooks set -euo pipefail protected_branch="main" remote="${1:-}" +warn_tag=0 while read -r local_ref local_sha remote_ref remote_sha; do if [[ "$remote_ref" == "refs/heads/${protected_branch}" ]]; then @@ -19,6 +24,19 @@ while read -r local_ref local_sha remote_ref remote_sha; do echo "" >&2 exit 1 fi + if [[ "$remote_ref" =~ ^refs/tags/v ]]; then + warn_tag=1 + fi done +if [[ $warn_tag -eq 1 ]]; then + echo "" >&2 + echo "WARNING: you are pushing a v* tag directly." >&2 + echo "Use scripts/tag_release.sh instead — it creates the required GitHub Release" >&2 + echo "object alongside the tag. A bare tag push creates no release and leaves the" >&2 + echo "project's /releases page incomplete." >&2 + echo "" >&2 + echo "Push anyway? Proceeding…" >&2 +fi + exit 0 diff --git a/.github/workflows/guard-release.yml b/.github/workflows/guard-release.yml new file mode 100644 index 0000000..63f7aca --- /dev/null +++ b/.github/workflows/guard-release.yml @@ -0,0 +1,54 @@ +name: guard-release +run-name: "guard-release | ${{ github.ref_name }}" + +# Post-push integrity guard: every tag push triggers a scan of all v* tags. +# If any tag lacks a GitHub Release object the job fails — catching the +# "silent link breakage" pattern where tags are pushed but release creation +# never happens (e.g. bare `git tag` + push without `tag_release.sh`). + +on: + push: + tags: + - "v*" + +permissions: + contents: read + +jobs: + guard: + runs-on: ubuntu-latest + steps: + - name: Check all v* tags have a release object + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + run: | + set -euo pipefail + + TAGS="$(gh api "repos/${GH_REPO}/git/refs/tags" --jq '.[].ref | ltrimstr("refs/tags/")' | grep '^v' | sort -V)" + RELEASES="$(gh api "repos/${GH_REPO}/releases" --jq '.[].tag_name' | sort -V | uniq)" + + missing=() + while IFS= read -r tag; do + [[ -z "$tag" ]] && continue + # Normalise: strip ^{} deref suffix and skip non-annotated deref lines. + tag="${tag%^\{\}}" + [[ "$tag" =~ \^\{ ]] && continue + if ! printf '%s\n' "$RELEASES" | grep -qxF "$tag"; then + missing+=("$tag") + fi + done <<< "$TAGS" + + if [[ ${#missing[@]} -gt 0 ]]; then + echo "" >&2 + echo "GUARD FAILED: ${#missing[@]} tag(s) have no GitHub Release object:" >&2 + printf ' - %s\n' "${missing[@]}" >&2 + echo "" >&2 + echo "Every v* tag must have a corresponding release. Create them with:" >&2 + echo " scripts/tag_release.sh" >&2 + echo "Or backfill an existing tag:" >&2 + echo " gh release create --title \"actop \" --notes-from-tag" >&2 + exit 1 + fi + + echo "All $(echo "$TAGS" | wc -l | tr -d ' ') v* tags have a release object." diff --git a/CLAUDE.md b/CLAUDE.md index 1c9c9cf..cfc0523 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -15,7 +15,7 @@ This file is the single source of truth for repository guidelines, used by Claud - Do not run `python`, `pip`, or `pytest` from the global environment for this repo. ## Build, Test, and Development Commands -- `git config core.hooksPath .githooks`: **run once per clone** to activate the local hooks (`pre-commit` secret redaction + `pre-push` `main` guard). Fresh clones have no hooks until this is set. +- `git config core.hooksPath .githooks`: **run once per clone** to activate the local hooks (`pre-commit` secret redaction + `pre-push` `main` guard + `pre-push` bare-tag nudge). Fresh clones have no hooks until this is set. - `.venv/bin/python -m pip install -e ".[dev]"`: install editable + dev deps (ruff). - `.venv/bin/python -m actop.actop --help`: validate CLI parsing and flags. - `.venv/bin/python -m actop.actop --interval 2 --avg 30`: run the tool locally. @@ -47,7 +47,7 @@ This file is the single source of truth for repository guidelines, used by Claud ## Release Process -`main` is **PR-only** (branch protection + `.githooks/pre-commit` redaction check and `.githooks/pre-push` guard; run `git config core.hooksPath .githooks` once). The Homebrew formula lives in the separate tap repo `binlecode/homebrew-actop` (not this repo); CI syncs it on tag and publishes to PyPI via OIDC. CI/CD mechanics are owned by the workflow files themselves (`.github/workflows/main-ci.yml`, `release-formula.yml`, `publish-pypi.yml`) — each carries its one-time setup inline. +`main` is **PR-only** (branch protection + `.githooks/pre-commit` redaction check and `.githooks/pre-push` guard; run `git config core.hooksPath .githooks` once). The Homebrew formula lives in the separate tap repo `binlecode/homebrew-actop` (not this repo); CI syncs it on tag and publishes to PyPI via OIDC. CI/CD mechanics are owned by the workflow files themselves (`.github/workflows/main-ci.yml`, `release-formula.yml`, `publish-pypi.yml`, `guard-release.yml`) — each carries its one-time setup inline. `guard-release.yml` is the post-push integrity gate: every tag push triggers a scan that verifies every `v*` tag has a corresponding GitHub Release object, catching the "silent link breakage" that caused the v0.8.7–v1.6.6 release-object gap. **RULE — cutting a release REQUIRES a GitHub Release, not just a tag.** A release is cut with `scripts/tag_release.sh`, which pushes the git tag **and** creates the GitHub Release for it via `gh release create` (release notes pulled from the matching `CHANGELOG.md` section). A bare `git tag` + push is **not** a release: it never surfaces on the `/releases` page or as `Latest`, and leaves the Releases history silently behind the code. Never cut a release by hand-tagging alone — if the `gh release create` step fails, finish it manually (see playbooks below) before calling the release cut done. diff --git a/README.md b/README.md index f944f41..75a0cdf 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,7 @@ [![Python versions](https://img.shields.io/pypi/pyversions/actop.svg)](https://pypi.org/project/actop/) [![License: MIT](https://img.shields.io/github/license/binlecode/actop.svg)](LICENSE) [![CI](https://github.com/binlecode/actop/actions/workflows/main-ci.yml/badge.svg)](https://github.com/binlecode/actop/actions/workflows/main-ci.yml) +[![Website](https://img.shields.io/badge/website-actop.pages.dev-58a6ff)](https://actop.pages.dev) **Watch your Apple Silicon Mac the way it actually works — and profile your own workloads from Python.**