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
14 changes: 12 additions & 2 deletions .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ jobs:
- name: shellcheck
# --severity=warning: SC2016 (info) fires on printf '`%s`' where backticks
# are literal markdown, not shell expressions — suppress info-level noise.
run: shellcheck --severity=warning scripts/fleet_monitor.sh scripts/fleet_report.sh scripts/token_report.sh scripts/lib/model-pricing.sh
run: shellcheck --severity=warning scripts/fleet_monitor.sh scripts/fleet_report.sh scripts/token_report.sh scripts/lib/model-pricing.sh scripts/cut-release.sh

bats:
runs-on: ubuntu-latest
Expand All @@ -34,7 +34,17 @@ jobs:
run: sudo apt-get install -y bats

- name: Run tests
run: bats tests/fleet_report.bats tests/token_report.bats tests/model_pricing.bats tests/test_review_cycle.bats tests/test_ci_status.bats tests/test_push_protection.bats tests/test_verify_auth_scopes.bats tests/test_initiative_driver.bats
run: |
bats \
tests/fleet_report.bats \
tests/token_report.bats \
tests/model_pricing.bats \
tests/test_review_cycle.bats \
tests/test_ci_status.bats \
tests/test_push_protection.bats \
tests/test_verify_auth_scopes.bats \
tests/test_initiative_driver.bats \
tests/test_cut_release.bats

validate-agent-profiles:
runs-on: ubuntu-latest
Expand Down
89 changes: 89 additions & 0 deletions docs/release/versioning.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
# Agent versioning & release channels

Status: **active** (Phase 1 of the [Safe Release Strategy](../initiatives/agentic-release-strategy.md)
initiative, epic #495). Implements issue #496.

This defines how the **dev-lead** and **pr-review** agents are versioned and how callers select a
version. It is the foundation the rest of the initiative (rings, promotion, rollback) builds on.

## What is versioned

A "release" of an agent is the reusable workflow **plus the scripts it executes** — they move
together, so a version is a single repo commit that contains a known-good combination:

| Agent | Reusable workflow | Key scripts (non-exhaustive) |
|---|---|---|
| `pr-review` | `.github/workflows/pr-review.yml` | `scripts/review-one-pr.sh`, `scripts/review-batch.sh`, `scripts/post-pr-review.sh`, `scripts/engine.sh`, `scripts/lib/*` |
| `dev-lead` | `.github/workflows/dev-lead-reusable.yml` | `scripts/dev-lead-*.sh`, `scripts/engine.sh`, `scripts/lib/*` |

Because both agents live in one repo, a release tag points at a whole-repo commit; the tag *name*
scopes it to one agent so the two can be released and promoted independently.

## Tag scheme

Two kinds of tag, per agent:

| Kind | Format | Mutable? | Purpose |
|---|---|---|---|
| **Immutable release** | `<agent>/vMAJOR.MINOR.PATCH` | No (annotated, never moved) | Audit trail + rollback target |
| **Channel** | `<agent>/<channel>` | Yes (moved on promotion) | What callers pin to |

Channels (Phase 1 defines `stable`; Phase 2 adds `next` and per-ring channels):

- `<agent>/stable` — the production channel (blue). Callers in production pin here.
- `<agent>/next` — the candidate channel (green). *(Phase 2, #499.)*
- `<agent>/ring1`, … — per-ring channels for staged promotion. *(Phase 2, #499/#500.)*

Examples: `pr-review/v1.0.0`, `pr-review/stable`, `dev-lead/v1.0.0`, `dev-lead/stable`.

### Semantic versioning

- **MAJOR** — breaking change to the caller contract (workflow inputs/secrets, required permissions,
the merge-gate behavior a consumer relies on).
- **MINOR** — backward-compatible capability (new safety check, new optional input).
- **PATCH** — backward-compatible fix (bug fix, prompt tweak, resilience hardening).

## How callers select a version (no per-caller churn)

GitHub does **not** allow an expression in a `uses:` ref, so version selection is **a moving channel
tag**, not a variable. Each caller pins **once** to a channel and is never edited again; promotion is a
central move of the channel tag (see the initiative doc §5.1):

```yaml
# A consumer / self-host caller pins once to the per-agent channel:
uses: petry-projects/.github-private/.github/workflows/pr-review.yml@pr-review/stable
uses: petry-projects/.github-private/.github/workflows/dev-lead-reusable.yml@dev-lead/stable
```

> Shorthand: the initiative doc writes `@stable`; the concrete tag is the **per-agent** channel
> (`pr-review/stable`, `dev-lead/stable`) so the agents promote independently.

## The v1.0.0 baseline

The first release was cut from the production `main` at the time of issue #496:

- `pr-review/v1.0.0`, `dev-lead/v1.0.0` — immutable baselines.
- `pr-review/stable`, `dev-lead/stable` — channels pointing at v1.0.0.

`v1.0.0` is **"what is in production today,"** the current rollback floor — *not* a certified-perfect
version. The health-gated promotion added in Phase 2 (#501) is what makes *future* promotions to
`stable` genuinely validated before they become production.

## Cutting / moving tags

Use `scripts/cut-release.sh` (tested in `tests/test_cut_release.bats`) rather than ad-hoc `git tag`:

```bash
# Cut an immutable release for an agent at a ref (default ref: origin/main):
scripts/cut-release.sh pr-review 1.1.0 --push

# Cut a release AND advance that agent's stable channel to it (a promotion):
scripts/cut-release.sh pr-review 1.1.0 --channel stable --push

# Preview without touching anything:
scripts/cut-release.sh pr-review 1.1.0 --channel stable --dry-run
```

The promote/rollback **runbook** (when to move `stable`, how to roll back) is issue #498; the automated,
health-gated promotion workflow is issue #501. Tag-protection so only the promotion workflow may move a
channel tag is issue #505.
132 changes: 132 additions & 0 deletions scripts/cut-release.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
#!/usr/bin/env bash
#
# cut-release.sh — cut an immutable agent release tag and (optionally) advance a
# channel tag to it. The GitHub-native primitive behind the Release Strategy
# initiative (epic #495): immutable `<agent>/vX.Y.Z` tags are audit/rollback
# targets; moving `<agent>/<channel>` tags are what callers pin to.
#
# Usage:
# cut-release.sh <agent> <version> [--ref <ref>] [--channel <name>]
# [--push] [--dry-run]
#
# <agent> pr-review | dev-lead
# <version> semantic version without the leading v, e.g. 1.2.0
# --ref commit/ref to tag (default: origin/main)
# --channel also move <agent>/<name> (e.g. stable) to the new release
# --push push the created/moved tags to origin (default: local only)
# --dry-run print what would happen; touch nothing
#
# Examples:
# cut-release.sh pr-review 1.1.0 --push
# cut-release.sh pr-review 1.1.0 --channel stable --push
# cut-release.sh dev-lead 2.0.0 --channel stable --dry-run
#
# The pure helpers (valid_agent, validate_version, release_ref, channel_ref) are
# defined at the top level so tests can `source` this file without executing it
# (see the source-guard at the bottom and tests/test_cut_release.bats).
#
set -euo pipefail

# ── pure helpers (sourced by tests; no side effects) ─────────────────────────

# valid_agent <agent> — return 0 iff agent is a known agent name.
valid_agent() {
case "$1" in
pr-review | dev-lead) return 0 ;;
*) return 1 ;;
esac
}

# validate_version <version> — return 0 iff strictly MAJOR.MINOR.PATCH numerics.
validate_version() {
[[ "$1" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]
}

# release_ref <agent> <version> — echo the immutable release tag name.
release_ref() { printf '%s/v%s\n' "$1" "$2"; }

# channel_ref <agent> <channel> — echo the channel tag name.
channel_ref() { printf '%s/%s\n' "$1" "$2"; }

# ── main ─────────────────────────────────────────────────────────────────────

main() {
local agent="" version="" ref="origin/main" channel="" do_push=false dry=false

if [ "$#" -lt 2 ]; then
echo "::error::usage: cut-release.sh <agent> <version> [--ref <ref>] [--channel <name>] [--push] [--dry-run]" >&2
return 2
fi
agent="$1"
version="$2"
shift 2
while [ "$#" -gt 0 ]; do
case "$1" in
--ref)
[ "$#" -lt 2 ] && { echo "::error::--ref requires an argument" >&2; return 2; }
ref="$2"; shift 2 ;;
--channel)
[ "$#" -lt 2 ] && { echo "::error::--channel requires an argument" >&2; return 2; }
channel="$2"; shift 2 ;;
--push) do_push=true; shift ;;
--dry-run) dry=true; shift ;;
*) echo "::error::unknown argument: $1" >&2; return 2 ;;
esac
done

if ! valid_agent "$agent"; then
echo "::error::unknown agent '$agent' (expected: pr-review | dev-lead)" >&2
return 2
fi
if ! validate_version "$version"; then
echo "::error::invalid version '$version' (expected MAJOR.MINOR.PATCH, e.g. 1.2.0)" >&2
return 2
fi

local rel chan sha
rel="$(release_ref "$agent" "$version")"
if ! sha="$(git rev-parse --verify "$ref^{commit}")"; then
echo "::error::ref '$ref' could not be resolved — verify the ref exists and the remote has been fetched" >&2
return 1
fi

echo "agent=$agent version=$version ref=$ref ($sha)"
echo "release tag: $rel"
[ -n "$channel" ] && { chan="$(channel_ref "$agent" "$channel")"; echo "channel tag: $chan -> $rel"; }

if [ "$dry" = true ]; then
echo "(dry-run) no tags created or pushed."
return 0
fi

if git rev-parse -q --verify "refs/tags/$rel" >/dev/null; then
echo "::error::release tag '$rel' already exists — immutable tags are never overwritten." >&2
return 1
fi
git tag -a "$rel" "$sha" -m "$agent release v$version"
echo "created $rel"

local pushrefs=("$rel")
if [ -n "$channel" ]; then
git tag -f "$chan" "$sha"
echo "moved $chan -> $sha"
pushrefs+=("$chan")
fi

if [ "$do_push" = true ]; then
# Push the immutable release tag without --force to prevent accidental remote overwrites
git push origin "$rel"
if [ -n "$channel" ]; then
# --force is required to move the mutable channel tag
git push --force origin "$chan"
fi
echo "pushed: ${pushrefs[*]}"
Comment on lines +116 to +123

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Using git push --force on the entire pushrefs array (which contains both the immutable release tag $rel and the mutable channel tag $chan) is risky. If the local repository is out of sync or the release tag was deleted locally but still exists on the remote, a force push will overwrite the remote immutable release tag. This violates the core design principle that immutable tags must never be overwritten.

To prevent this, we should push the immutable release tag $rel without --force, and only use --force when pushing the mutable channel tag $chan.

Suggested change
if [ "$do_push" = true ]; then
# --force needed for the (movable) channel tag; release tag is new so it is a no-op there.
git push --force origin "${pushrefs[@]}"
echo "pushed: ${pushrefs[*]}"
if [ "$do_push" = true ]; then
# Push the immutable release tag without --force to prevent accidental remote overwrites
git push origin "$rel"
if [ -n "$channel" ]; then
# --force is required to move the mutable channel tag
git push --force origin "$chan"
fi
echo "pushed: ${pushrefs[*]}"

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Applied the exact suggestion in scripts/cut-release.sh lines 116–123: replaced the [ -n "$channel" ] && git push --force ... one-liner with an explicit if [ -n "$channel" ] block and added the two comments explaining why the immutable release tag is pushed without --force and why the channel tag requires it.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified: the fix is in place at scripts/cut-release.sh lines 116–123. The immutable release tag $rel is pushed without --force (line 118), and --force is used only for the mutable channel tag $chan inside the [ -n "$channel" ] guard (line 121), exactly matching the suggestion. ShellCheck passes cleanly on the file.

else
echo "local only — re-run with --push to publish: ${pushrefs[*]}"
fi
}

# Run main only when executed directly, so tests can source the helpers.
if [ "${BASH_SOURCE[0]:-$0}" = "$0" ]; then
main "$@"
fi
93 changes: 93 additions & 0 deletions tests/test_cut_release.bats
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
#!/usr/bin/env bats
# Unit tests for the pure helpers in scripts/cut-release.sh
# (valid_agent, validate_version, release_ref, channel_ref). The git-backed
# flow in main() is not exercised here.
#
# Run with: bats tests/test_cut_release.bats

setup() {
# Sourcing must NOT run main() (source-guard); it only defines the helpers.
source "$(dirname "$BATS_TEST_FILENAME")/../scripts/cut-release.sh"
}

# ---------------------------------------------------------------------------
# valid_agent
# ---------------------------------------------------------------------------

@test "valid_agent: pr-review is accepted" {
run valid_agent "pr-review"
[ "$status" -eq 0 ]
}

@test "valid_agent: dev-lead is accepted" {
run valid_agent "dev-lead"
[ "$status" -eq 0 ]
}

@test "valid_agent: unknown agent is rejected" {
run valid_agent "ci-failure-analyst"
[ "$status" -ne 0 ]
}

# ---------------------------------------------------------------------------
# validate_version — strict MAJOR.MINOR.PATCH
# ---------------------------------------------------------------------------

@test "validate_version: 1.0.0 is valid" {
run validate_version "1.0.0"
[ "$status" -eq 0 ]
}

@test "validate_version: 12.4.37 is valid" {
run validate_version "12.4.37"
[ "$status" -eq 0 ]
}

@test "validate_version: leading v is rejected" {
run validate_version "v1.0.0"
[ "$status" -ne 0 ]
}

@test "validate_version: two-part version is rejected" {
run validate_version "1.2"
[ "$status" -ne 0 ]
}

@test "validate_version: four-part version is rejected" {
run validate_version "1.2.3.4"
[ "$status" -ne 0 ]
}

@test "validate_version: non-numeric is rejected" {
run validate_version "1.2.x"
[ "$status" -ne 0 ]
}

@test "validate_version: empty is rejected" {
run validate_version ""
[ "$status" -ne 0 ]
}

# ---------------------------------------------------------------------------
# release_ref / channel_ref — tag-name formatting
# ---------------------------------------------------------------------------

@test "release_ref: prepends agent and v" {
run release_ref "pr-review" "1.0.0"
[ "$output" = "pr-review/v1.0.0" ]
}

@test "release_ref: dev-lead variant" {
run release_ref "dev-lead" "2.3.1"
[ "$output" = "dev-lead/v2.3.1" ]
}

@test "channel_ref: agent-scoped channel name" {
run channel_ref "pr-review" "stable"
[ "$output" = "pr-review/stable" ]
}

@test "channel_ref: next channel" {
run channel_ref "dev-lead" "next"
[ "$output" = "dev-lead/next" ]
}
Loading