feat: add scripts/release-tag automation helper (#50)
Automate the documented release-tag checklist (#48) without bypassing safety gates. scripts/release-tag: - Requires a SemVer tag (vMAJOR.MINOR.PATCH); validates before any git/network. - Fetch/prune first, then refuses: dirty worktree, non-master branch, local master != remote master, HEAD not on remote master, and an existing local or remote tag of the same name. - Runs the full suite by default; --skip-tests is an explicit opt-out that warns. - Creates an ANNOTATED tag (git tag -a), never lightweight. - Safe by default: no push unless --push; --dry-run prints planned actions and changes nothing. Supports --notes-file <path> for the annotation message. - Prints: commit, tag, tests_run, tag_created, tag_pushed. - Env injection points for testing/CI: RELEASE_TAG_REMOTE, RELEASE_TAG_TEST_CMD. tests/test_release_tag.py (14 cases): valid SemVer dry-run; invalid version; dirty worktree; non-master; master/remote mismatch; existing tag; missing notes-file; annotated-not-lightweight; no-push-without-flag; push-only-with-flag; notes-file message; --skip-tests warns; default runs tests (fail blocks tag, pass tags). Each test builds a throwaway repo with a LOCAL bare remote (cloned, not pushed) and stubs the test command — no network, no real tags, no pushing from the project repo. Docs: reference scripts/release-tag from the runbook, SKILL, and the release-tag template (script preferred; manual steps are the fallback). Full suite 305 passed / 0 failures; bash -n clean; git diff --check clean; no secrets. Closes #50. Refs #48. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Executable
+139
@@ -0,0 +1,139 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# release-tag — create an annotated release tag safely from remote master.
|
||||
# Enforces the documented tagging policy (see docs/llm-workflow-runbooks.md and
|
||||
# skills/llm-project-workflow/SKILL.md). Never pushes unless --push is given.
|
||||
#
|
||||
# Test/CI injection points (env):
|
||||
# RELEASE_TAG_REMOTE git remote name (default: prgs)
|
||||
# RELEASE_TAG_TEST_CMD test command run before tagging
|
||||
# (default: ./venv/bin/python -m pytest tests/ -q)
|
||||
|
||||
REMOTE="${RELEASE_TAG_REMOTE:-prgs}"
|
||||
TEST_CMD="${RELEASE_TAG_TEST_CMD:-./venv/bin/python -m pytest tests/ -q}"
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
usage: scripts/release-tag [--dry-run] [--skip-tests] [--push]
|
||||
[--notes-file <path>] <vMAJOR.MINOR.PATCH>
|
||||
|
||||
Create an annotated release tag from remote master, only when the tree/branch
|
||||
are clean and tests pass. Safe by default: no push unless --push; --dry-run
|
||||
changes nothing.
|
||||
|
||||
Options:
|
||||
--dry-run Print planned actions; create/push nothing.
|
||||
--skip-tests Skip the test suite (explicit opt-out; prints a warning).
|
||||
--push Push the tag to the remote after creating it.
|
||||
--notes-file <path> Use this file's contents as the annotated-tag message.
|
||||
--help Show this help.
|
||||
|
||||
Examples:
|
||||
scripts/release-tag --dry-run v0.4.0
|
||||
scripts/release-tag v0.4.0 --notes-file /tmp/release-notes.md
|
||||
scripts/release-tag v0.4.0 --notes-file /tmp/release-notes.md --push
|
||||
EOF
|
||||
}
|
||||
|
||||
fail() { printf 'release-tag: %s\n' "$1" >&2; exit "${2:-1}"; }
|
||||
|
||||
dry_run=0
|
||||
skip_tests=0
|
||||
push=0
|
||||
notes_file=""
|
||||
version=""
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--dry-run) dry_run=1 ;;
|
||||
--skip-tests) skip_tests=1 ;;
|
||||
--push) push=1 ;;
|
||||
--notes-file) shift; notes_file="${1:-}"; [[ -n "$notes_file" ]] || fail "--notes-file needs a path" 2 ;;
|
||||
--help) usage; exit 0 ;;
|
||||
-*) usage >&2; exit 2 ;;
|
||||
*) if [[ -z "$version" ]]; then version="$1"; else usage >&2; exit 2; fi ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
[[ -n "$version" ]] || { usage >&2; exit 2; }
|
||||
|
||||
# 1. SemVer validation (before any git/network work).
|
||||
if [[ ! "$version" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
fail "invalid version '$version' (expected vMAJOR.MINOR.PATCH, e.g. v0.4.0)" 2
|
||||
fi
|
||||
|
||||
if [[ -n "$notes_file" && ! -f "$notes_file" ]]; then
|
||||
fail "notes file not found: $notes_file" 2
|
||||
fi
|
||||
|
||||
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
repo_root="$(cd "$script_dir/.." && pwd)"
|
||||
git_c=(git -C "$repo_root")
|
||||
|
||||
# 2. Fetch/prune first.
|
||||
"${git_c[@]}" fetch "$REMOTE" --prune
|
||||
|
||||
# 3. Must be on master.
|
||||
current_branch="$("${git_c[@]}" symbolic-ref --quiet --short HEAD || echo DETACHED)"
|
||||
[[ "$current_branch" == "master" ]] || fail "not on master (on '$current_branch'); tag only from master"
|
||||
|
||||
# 4. Clean worktree.
|
||||
[[ -z "$("${git_c[@]}" status --porcelain)" ]] || fail "worktree is dirty; commit/stash before tagging"
|
||||
|
||||
# 5. Local master must equal remote master.
|
||||
local_sha="$("${git_c[@]}" rev-parse master)"
|
||||
remote_sha="$("${git_c[@]}" rev-parse "$REMOTE/master")"
|
||||
[[ "$local_sha" == "$remote_sha" ]] || fail "local master ($local_sha) != $REMOTE/master ($remote_sha)"
|
||||
|
||||
# 6. HEAD must be that same commit (present on remote master).
|
||||
head_sha="$("${git_c[@]}" rev-parse HEAD)"
|
||||
[[ "$head_sha" == "$remote_sha" ]] || fail "HEAD ($head_sha) is not $REMOTE/master; tag only commits on remote master"
|
||||
|
||||
# 7. Tag must not already exist locally or on the remote.
|
||||
if "${git_c[@]}" rev-parse -q --verify "refs/tags/$version" >/dev/null 2>&1; then
|
||||
fail "tag $version already exists locally"
|
||||
fi
|
||||
if "${git_c[@]}" ls-remote --tags "$REMOTE" "refs/tags/$version" | grep -q .; then
|
||||
fail "tag $version already exists on $REMOTE"
|
||||
fi
|
||||
|
||||
# Annotation message: notes file, or a minimal default.
|
||||
if [[ -n "$notes_file" ]]; then
|
||||
notes_arg=(-F "$notes_file")
|
||||
else
|
||||
notes_arg=(-m "$version")
|
||||
fi
|
||||
|
||||
# Tests (default on; explicit --skip-tests warns). Not executed in dry-run.
|
||||
tests_run="no"
|
||||
if [[ "$skip_tests" -eq 1 ]]; then
|
||||
printf 'release-tag: WARNING --skip-tests set; NOT running the test suite before tagging.\n' >&2
|
||||
elif [[ "$dry_run" -eq 1 ]]; then
|
||||
printf 'release-tag: [dry-run] would run tests: %s\n' "$TEST_CMD"
|
||||
else
|
||||
tests_run="yes"
|
||||
( cd "$repo_root" && eval "$TEST_CMD" ) || fail "tests failed; refusing to tag"
|
||||
fi
|
||||
|
||||
# Create (and optionally push) the annotated tag.
|
||||
tag_created="no"
|
||||
tag_pushed="no"
|
||||
if [[ "$dry_run" -eq 1 ]]; then
|
||||
printf 'release-tag: [dry-run] would create annotated tag %s at %s\n' "$version" "$head_sha"
|
||||
[[ "$push" -eq 1 ]] && printf 'release-tag: [dry-run] would push %s to %s\n' "$version" "$REMOTE"
|
||||
else
|
||||
"${git_c[@]}" tag -a "$version" "$head_sha" "${notes_arg[@]}"
|
||||
tag_created="yes"
|
||||
if [[ "$push" -eq 1 ]]; then
|
||||
"${git_c[@]}" push "$REMOTE" "$version"
|
||||
tag_pushed="yes"
|
||||
fi
|
||||
fi
|
||||
|
||||
printf 'commit: %s\n' "$head_sha"
|
||||
printf 'tag: %s\n' "$version"
|
||||
printf 'tests_run: %s\n' "$tests_run"
|
||||
printf 'tag_created: %s\n' "$tag_created"
|
||||
printf 'tag_pushed: %s\n' "$tag_pushed"
|
||||
Reference in New Issue
Block a user