#!/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 ] 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 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"