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
1 change: 1 addition & 0 deletions .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ jobs:
tests/test_verify_auth_scopes.bats \
tests/test_initiative_driver.bats \
tests/test_cut_release.bats \
tests/test_cut_release_cross_repo.bats \
tests/test_gitleaks_config.bats \
tests/test_initiative_planner.bats \
tests/test_initiative_planner_redispatch.bats \
Expand Down
54 changes: 40 additions & 14 deletions scripts/cut-release.sh
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,11 @@
# 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.
# targets; moving `<agent>/v<MAJOR>-<tier>` channel tags are what callers pin to.
#
# Channels are major-scoped (#1184, epic #657): a breaking (major) release gets
# its own channel line, so it cannot silently reach a consumer that has not
# opted into that major. See docs/initiatives/agentic-release-strategy.md §5.2.
#
# Usage:
# cut-release.sh <agent> <version> [--ref <ref>] [--channel <name>]
Expand All @@ -17,19 +21,24 @@
# --ref commit/ref to tag (default: main). Resolved against the agent's
# HOST repo via the API for EVERY agent (an `origin/` prefix is
# stripped — `origin/main` means the host's `main`).
# --channel also move <agent>/<name> (e.g. stable) to the new release
# --promote move <agent>/<channel> to the EXISTING <agent>/vX.Y.Z release
# instead of cutting a new one (ring-to-ring promotion). Requires
# --channel; errors if the release tag does not already exist;
# --ref is ignored (the release's own commit is authoritative).
# --channel also move the major-scoped channel to the new release. Accepts a
# bare tier (stable | next | ring0 | ring1) — resolved to
# <agent>/v<MAJOR-of-version>-<tier> — OR a fully-qualified
# v<M>-<tier> (e.g. v1-ring0), which pins that major as-is.
# --promote move the channel to the EXISTING <agent>/vX.Y.Z release instead
# of cutting a new one (ring-to-ring promotion within the release's
# major). Requires --channel; errors if the release tag does not
# already exist; --ref is ignored (the release's own commit is
# authoritative).
# --push publish the created/moved tags (default: print 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
# cut-release.sh agent-shield 2.1.0 --channel next --push # cross-repo → .github
# cut-release.sh pr-review 1.1.0 --channel stable --push # moves pr-review/v1-stable
# cut-release.sh dev-lead 2.0.0 --channel stable --dry-run # plans dev-lead/v2-stable
# cut-release.sh dev-lead 1.4.0 --channel v1-ring0 --push # explicit v1-ring0 (pins v1)
# cut-release.sh agent-shield 2.1.0 --channel next --push # cross-repo → .github; agent-shield/v2-next
#
# ONE consistent path for every agent (#1076). All tag operations — resolve,
# create, and channel-move — go through `gh api` against the agent's HOST repo
Expand All @@ -43,8 +52,8 @@
# docs/release/versioning.md "Cross-repo reusables".
#
# The pure helpers (valid_agent, cross_repo_agent, agent_host_repo, this_repo,
# validate_version, release_ref, channel_ref, strip_origin) are defined at the
# top level so tests can `source` this file without executing it (see the
# validate_version, release_ref, major_of, channel_ref, strip_origin) 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
Expand Down Expand Up @@ -109,8 +118,25 @@ validate_version() {
# 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"; }
# major_of <version> — echo the MAJOR component of a MAJOR.MINOR.PATCH version.
major_of() { printf '%s\n' "${1%%.*}"; }

# channel_ref <agent> <version> <channel> — echo the major-scoped channel tag
# name (#1184, epic #657). Channels are scoped by major so a breaking (major)
# release cannot silently reach a consumer that has not opted into that major:
# - a bare tier (stable | next | ring0 | ring1) is prefixed with the version's
# major → channel_ref dev-lead 2.0.0 stable = dev-lead/v2-stable
# - a fully-qualified v<M>-<tier> passes through unchanged; its OWN major wins,
# so an explicit --channel v1-ring0 pins v1 regardless of <version> →
# channel_ref dev-lead 2.0.0 v1-ring0 = dev-lead/v1-ring0
channel_ref() {
local agent="$1" version="$2" channel="$3"
if [[ "$channel" =~ ^v[0-9]+- ]]; then
printf '%s/%s\n' "$agent" "$channel"
else
printf '%s/v%s-%s\n' "$agent" "$(major_of "$version")" "$channel"
fi
}

# strip_origin <ref> — normalize a local-style ref to a remote-repo ref name for
# the API path: `origin/main` → `main`, a bare ref/SHA passes through.
Expand Down Expand Up @@ -210,7 +236,7 @@ main() {
local rel chan sha pushrefs
rel="$(release_ref "$agent" "$version")"
pushrefs=("$rel")
if [ -n "$channel" ]; then chan="$(channel_ref "$agent" "$channel")"; pushrefs+=("$chan"); fi
if [ -n "$channel" ]; then chan="$(channel_ref "$agent" "$version" "$channel")"; pushrefs+=("$chan"); fi

# ── --promote: move an existing release's channel; never cut a new tag ──
# A ring-to-ring promotion (e.g. ring0 → ring1 of an already-cut vX.Y.Z). The
Expand Down
71 changes: 54 additions & 17 deletions tests/test_cut_release.bats
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
#!/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.
# (valid_agent, validate_version, release_ref, major_of, channel_ref). The
# git-backed flow in main() is not exercised here.
#
# Run with: bats tests/test_cut_release.bats

Expand Down Expand Up @@ -262,29 +262,66 @@ setup() {
[ "$output" = "dependabot-automerge/v2.1.3" ]
}

@test "channel_ref: agent-scoped channel name" {
run channel_ref "pr-review" "stable"
[ "$output" = "pr-review/stable" ]
# ---------------------------------------------------------------------------
# major_of — extract the MAJOR component of a MAJOR.MINOR.PATCH version (#1184)
# ---------------------------------------------------------------------------

@test "major_of: 1.2.3 is 1" {
run major_of "1.2.3"
[ "$output" = "1" ]
}

@test "major_of: 2.0.0 is 2" {
run major_of "2.0.0"
[ "$output" = "2" ]
}

@test "major_of: 12.4.37 is 12" {
run major_of "12.4.37"
[ "$output" = "12" ]
}

# ---------------------------------------------------------------------------
# channel_ref — major-scoped channel tag names (#1184, epic #657)
# A bare tier is prefixed with v<major(version)>-; a fully-qualified
# v<M>-<tier> passes through unchanged (its own major wins).
# ---------------------------------------------------------------------------

@test "channel_ref: bare tier is prefixed with the version's major" {
run channel_ref "pr-review" "1.0.0" "stable"
[ "$output" = "pr-review/v1-stable" ]
}

@test "channel_ref: dev-lead 2.0.0 stable -> dev-lead/v2-stable" {
run channel_ref "dev-lead" "2.0.0" "stable"
[ "$output" = "dev-lead/v2-stable" ]
}

@test "channel_ref: next channel is major-scoped" {
run channel_ref "dev-lead" "1.4.2" "next"
[ "$output" = "dev-lead/v1-next" ]
}

@test "channel_ref: next channel" {
run channel_ref "dev-lead" "next"
[ "$output" = "dev-lead/next" ]
@test "channel_ref: feature-ideation ring0 channel is major-scoped" {
run channel_ref "feature-ideation" "3.1.0" "ring0"
[ "$output" = "feature-ideation/v3-ring0" ]
}

@test "channel_ref: feature-ideation ring0 channel" {
run channel_ref "feature-ideation" "ring0"
[ "$output" = "feature-ideation/ring0" ]
@test "channel_ref: explicit v<M>-<tier> passes through unchanged" {
run channel_ref "dev-lead" "2.0.0" "v1-ring0"
[ "$output" = "dev-lead/v1-ring0" ]
}

@test "channel_ref: pr-review-mention next channel" {
run channel_ref "pr-review-mention" "next"
[ "$output" = "pr-review-mention/next" ]
@test "channel_ref: explicit v<M>-<tier> wins over the version's major" {
run channel_ref "pr-review" "9.9.9" "v1-stable"
[ "$output" = "pr-review/v1-stable" ]
}

@test "channel_ref: auto-rebase stable channel" {
run channel_ref "auto-rebase" "stable"
[ "$output" = "auto-rebase/stable" ]
@test "channel_ref: major bump is reflected in the prefix (1.x -> v1-, 2.x -> v2-)" {
run channel_ref "dev-lead" "1.7.2" "stable"
[ "$output" = "dev-lead/v1-stable" ]
run channel_ref "dev-lead" "2.0.0" "stable"
[ "$output" = "dev-lead/v2-stable" ]
}

# ---------------------------------------------------------------------------
Expand Down
47 changes: 44 additions & 3 deletions tests/test_cut_release_cross_repo.bats
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ STUB
run env GH_TOKEN=x bash "$SCRIPT" agent-shield 2.1.0 --channel next --push
[ "$status" -eq 0 ]
echo "$output" | grep -q 'created agent-shield/v2.1.0 on petry-projects/.github'
echo "$output" | grep -q 'moved agent-shield/next'
echo "$output" | grep -q 'moved agent-shield/v2-next'
echo "$output" | grep -q 'published to petry-projects/.github'
# annotated tag object + ref creation both went to petry-projects/.github
grep -qE 'POST repos/petry-projects/\.github/git/tags' "$GH_CALLS"
Expand Down Expand Up @@ -89,7 +89,7 @@ STUB
run env GH_TOKEN=x bash "$SCRIPT" dev-lead 1.5.4 --channel next --push
[ "$status" -eq 0 ]
echo "$output" | grep -q 'created dev-lead/v1.5.4 on petry-projects/.github-private'
echo "$output" | grep -q 'moved dev-lead/next'
echo "$output" | grep -q 'moved dev-lead/v1-next'
# the create + channel move both went through the API on the this-repo host
grep -qE 'POST repos/petry-projects/\.github-private/git/tags' "$GH_CALLS"
grep -qE '(POST|PATCH) repos/petry-projects/\.github-private/git/refs' "$GH_CALLS"
Expand All @@ -113,7 +113,7 @@ STUB
@test "cross-repo --promote moves the channel to the existing release without cutting a new tag" {
GH_TAG_EXISTS=1 run env GH_TOKEN=x GH_TAG_EXISTS=1 bash "$SCRIPT" agent-shield 2.1.0 --channel ring1 --promote --push
[ "$status" -eq 0 ]
echo "$output" | grep -q 'moved agent-shield/ring1'
echo "$output" | grep -q 'moved agent-shield/v2-ring1'
# the immutable release tag object is never (re)created
! grep -qE 'POST repos/petry-projects/\.github/git/tags' "$GH_CALLS"
# the channel ref WAS moved
Expand All @@ -126,3 +126,44 @@ STUB
echo "$output" | grep -q 'dry-run'
! grep -qE '(PATCH|POST) .*git/refs' "$GH_CALLS"
}

# ── major-scoped channels (#1184, epic #657) ──────────────────────────────────
# --channel <tier> derives the major from <version>; a fully-qualified
# v<M>-<tier> passes through; the immutable vX.Y.Z release tag is unchanged.

@test "major-scoped: --channel stable derives v2- from version 2.0.0 (dry-run)" {
run env GH_TOKEN=x bash "$SCRIPT" dev-lead 2.0.0 --channel stable --dry-run
[ "$status" -eq 0 ]
echo "$output" | grep -q 'channel tag: dev-lead/v2-stable -> dev-lead/v2.0.0'
# not the old bare-tier form
! echo "$output" | grep -qE 'channel tag: dev-lead/stable '
}

@test "major-scoped: explicit --channel v1-ring0 passes through unchanged (dry-run)" {
run env GH_TOKEN=x bash "$SCRIPT" dev-lead 2.0.0 --channel v1-ring0 --dry-run
[ "$status" -eq 0 ]
echo "$output" | grep -q 'channel tag: dev-lead/v1-ring0 -> dev-lead/v2.0.0'
}

@test "major-scoped: bump of major is reflected in the channel prefix (1.x -> v1-)" {
run env GH_TOKEN=x bash "$SCRIPT" dev-lead 1.7.2 --channel stable --dry-run
[ "$status" -eq 0 ]
echo "$output" | grep -q 'channel tag: dev-lead/v1-stable -> dev-lead/v1.7.2'
}

@test "major-scoped: release tag <agent>/v2.0.0 is still created on --push" {
run env GH_TOKEN=x bash "$SCRIPT" dev-lead 2.0.0 --channel stable --push
[ "$status" -eq 0 ]
echo "$output" | grep -q 'created dev-lead/v2.0.0 on petry-projects/.github-private'
echo "$output" | grep -q 'moved dev-lead/v2-stable'
# the immutable release tag ref is the un-prefixed vX.Y.Z form
grep -qE 'POST repos/petry-projects/\.github-private/git/refs .*refs/tags/dev-lead/v2.0.0' "$GH_CALLS"
# the channel ref that moved is the major-scoped v2-stable
grep -qE '(POST|PATCH) repos/petry-projects/\.github-private/git/refs.*dev-lead/v2-stable' "$GH_CALLS"
}

@test "major-scoped: --promote moves the major-scoped channel of the release's major" {
GH_TAG_EXISTS=1 run env GH_TOKEN=x GH_TAG_EXISTS=1 bash "$SCRIPT" dev-lead 2.3.1 --channel ring1 --promote --push
[ "$status" -eq 0 ]
echo "$output" | grep -q 'moved dev-lead/v2-ring1'
}
Loading