Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
4 changes: 2 additions & 2 deletions .github/workflows/canary-rollout.yml
Original file line number Diff line number Diff line change
Expand Up @@ -157,8 +157,8 @@ jobs:
DRY_RUN: ${{ github.event_name == 'schedule' && 'false' || github.event.inputs.dry_run }}
RING: ${{ github.event.inputs.ring }}
TO: ${{ github.event.inputs.to }}
# cut-release.sh (cross-repo tag moves on petry-projects/.github) and the
# gate's cross-repo run-history reads both authenticate with the App token.
# The inline autocut cut (create+move tags on each agent's host) and the gate's
# cross-repo run-history reads both authenticate with the App token.
GH_TOKEN: ${{ steps.app-token.outputs.token }}
# One TSV line per real promotion (agent, ring, sha, owning-repo) so the deployment
# step records a deployment for EVERY move in a promote-all run, on the right host.
Expand Down
55 changes: 45 additions & 10 deletions scripts/canary-rollout.sh
Original file line number Diff line number Diff line change
Expand Up @@ -48,9 +48,12 @@ source "${_HERE}/lib/canary-rollout.sh"
DEFAULT_RINGS="$(cd "${_HERE}/.." && pwd)/standards/canary-rings.json"
CANARY_RINGS="${CANARY_RINGS:-$DEFAULT_RINGS}"

# CUT_RELEASE — the cut-release.sh invoked by `autocut` to cut a new candidate. A
# sibling of this script by default; overridable so tests can stub the cut (#1069).
CUT_RELEASE="${CUT_RELEASE:-${_HERE}/cut-release.sh}"
# The `autocut` front end (#1069) cuts new candidates INLINE via the App-token gh-api path
# (_gh_create_annotated_tag + _gh_move_tag against the agent's registry host) — it no longer
# shells out to a sibling cut-release.sh. That coupling broke when the engine relocated to
# .github (#613): cut-release.sh stayed in .github-private as the human/runbook CLI and does
# not travel with this checkout. Cutting inline keeps the engine self-contained and writes to
# the correct host for every agent (incl. dev-lead, hosted in .github-private).

# THIS_REPO — the repo this checkout belongs to. Agents hosted HERE (dev-lead, pr-review)
# keep their channel/release tags in this checkout and resolve them via local git. A
Expand Down Expand Up @@ -120,6 +123,26 @@ _gh_move_tag() {
-f ref="refs/tags/$tag" -f sha="$sha" >/dev/null 2>&1
}

# _gh_create_annotated_tag <repo> <tag> <sha> <message> — create the immutable annotated
# tag object <tag> pointing at commit <sha> on <repo> and publish its ref, via the GitHub
# API with the release-manager App token (mirrors cut-release.sh's gh_create_annotated_tag).
# This is the CUT primitive for `autocut` (#1069): once the engine relocated to .github the
# sibling cut-release.sh no longer travels with it, so the cut runs inline through the same
# App-token path used for every other tag write — no dependency on a script left behind in
# another repo, and it writes to the agent's registry HOST (correct for the this-repo
# dev-lead agent whose reusable lives in .github-private, #613 relocation). Returns non-zero
# on API failure so the caller can degrade best-effort.
_gh_create_annotated_tag() {
[ $# -lt 4 ] && return 1
local repo="$1" tag="$2" sha="$3" message="$4" obj
obj="$(gh api -X POST "repos/$repo/git/tags" \
-f tag="$tag" -f message="$message" -f object="$sha" -f type=commit \
--jq '.sha' 2>/dev/null)" || return 1
[ -z "$obj" ] && return 1
Comment thread
don-petry marked this conversation as resolved.
Resolved
gh api -X POST "repos/$repo/git/refs" \
-f ref="refs/tags/$tag" -f sha="$obj" >/dev/null 2>&1
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

# channel_commit <agent> <channel> — commit the channel tag <agent>/<channel> resolves to
# (empty if the tag does not exist). Agents hosted in THIS repo resolve against the local
# checkout; a cross-repo agent's channel tags live on ITS host, so they are resolved there
Expand Down Expand Up @@ -810,9 +833,11 @@ cmd_sync_issues() {
# tick — gated by CANARY_AUTO_CUT — for each registered agent it compares the reusable
# blob at the host's default-branch HEAD against the blob at the current `next` candidate;
# if they differ it cuts a new immutable <agent>/vX.Y.Z (patch bump by default) and moves
# `next` onto it via cut-release.sh, seeding the candidate into the existing soak/promote
# pipeline. Detection is done here in .github-private via the App token (which reads every
# host), so no cross-repo Actions plumbing is needed. Best-effort: never fails the run.
# `next` onto it INLINE via the App-token gh-api path (_gh_create_annotated_tag + _gh_move_tag),
# seeding the candidate into the existing soak/promote pipeline. Detection AND the cut both run
# against the agent's registry host via the App token (which reads/writes every host), so no
# cross-repo Actions plumbing — and no sibling cut-release.sh — is needed. Best-effort: never
# fails the run.
#
# Scope/limitation (v1): detection is on the reusable FILE blob only (the registry
# `reusable` path). A change to a shared library the reusable sources — without the
Expand Down Expand Up @@ -891,13 +916,23 @@ _autocut_agent() {
bump="$(_autocut_bump "$agent")"
newver="$(_next_release_version "$agent" "$bump")"
echo "autocut $agent: reusable changed on $host ($defbranch ${mainsha:0:12}) vs next ${next_commit:0:12} — cutting v$newver (bump=$bump), moving next."
local relver="$agent/v$newver"
if [ "$dry" = true ]; then
echo "[DRY-RUN] would: cut-release.sh $agent $newver --ref $mainsha --channel next --push"
echo "[DRY-RUN] would: cut $relver at ${mainsha:0:12} on $host + move $agent/next (gh-api, App token)"
return 0
fi
bash "$CUT_RELEASE" "$agent" "$newver" --ref "$mainsha" --channel next --push \
|| { echo "::warning::autocut $agent: cut-release failed for v$newver (best-effort, continuing)"; return 0; }
echo "autocut $agent: cut v$newver from ${mainsha:0:12} and moved next."
# Cut inline (no sibling cut-release.sh, #613): create the immutable annotated release tag,
# then advance `next` onto the same commit — both on the agent's HOST via the App token. If
# the release tag already exists (e.g. a retry after a partial move), skip the create and just
# re-point next so the operation stays idempotent.
if [ -n "$(_gh_tag_commit "$host" "$relver")" ]; then
echo "autocut $agent: release $relver already exists on $host — re-pointing next only."
elif ! _gh_create_annotated_tag "$host" "$relver" "$mainsha" "$agent release v$newver"; then
Comment thread
don-petry marked this conversation as resolved.
Resolved
echo "::warning::autocut $agent: could not create $relver on $host (best-effort, continuing)"; return 0
fi
Comment thread
don-petry marked this conversation as resolved.
Resolved
_gh_move_tag "$host" "$agent/next" "$mainsha" \
|| { echo "::warning::autocut $agent: could not move $agent/next on $host (best-effort, continuing)"; return 0; }
echo "autocut $agent: cut v$newver from ${mainsha:0:12} and moved next (on $host)."
}

# cmd_autocut [--dry-run] — the scheduled front end. Gated by CANARY_AUTO_CUT (the single
Expand Down
57 changes: 31 additions & 26 deletions tests/canary_rollout.bats
Original file line number Diff line number Diff line change
Expand Up @@ -828,20 +828,16 @@ GHEOF
# The front end of the canary pipeline: at each scheduled tick (gated by CANARY_AUTO_CUT), for
# each registered agent compare the reusable blob at the host's main HEAD against the blob at the
# current `next` candidate; if they differ, cut a new immutable vX.Y.Z (patch bump default) and
# move `next` onto it via cut-release.sh. The stub feeds: default_branch, main HEAD, the two blob
# SHAs, the `next` commit (git for a this-repo agent, gh api for a cross-repo one), the existing
# release-tag versions (matching-refs), and a cut-release.sh stand-in (CUT_RELEASE) that logs args.
# move `next` onto it INLINE via the App-token gh-api path (create annotated tag + move ref) —
# no sibling cut-release.sh (#613). The stub feeds: default_branch, main HEAD, the two blob SHAs,
# the `next` commit (git for a this-repo agent, gh api for a cross-repo one), the existing
# release-tag versions (matching-refs), and LOGS every mutating gh-api call (tag/ref writes) to
# GH_LOG so a test can assert the cut hit the right host without a cut-release.sh stand-in.
_autocut_stub() {
# args: agent host reusable main_blob next_blob mainsha nextsha versions_ws [bump]
local agent="$1" host="$2" reusable="$3" MAIN_BLOB="$4" NEXT_BLOB="$5" MAINSHA="$6" NEXTSHA="$7" versions="$8" bump="${9:-}"
STUB_BIN="$(mktemp -d "$BATS_TEST_TMPDIR/stub.XXXXXX")"; export PATH="$STUB_BIN:$PATH"
export CUT_LOG="$STUB_BIN/cut.log"; : > "$CUT_LOG"
export CUT_RELEASE="$STUB_BIN/cut-release"
cat > "$CUT_RELEASE" <<CUTEOF
#!/usr/bin/env bash
echo "\$*" >> "$CUT_LOG"
CUTEOF
chmod +x "$CUT_RELEASE"
export GH_LOG="$STUB_BIN/gh-writes.log"; : > "$GH_LOG"
local refs="" v
for v in $versions; do refs+="refs/tags/$agent/v$v"$'\n'; done
cat > "$STUB_BIN/gh" <<GHEOF
Expand All @@ -850,9 +846,14 @@ case "\$*" in
*".default_branch"*) echo "main" ;;
*"contents/"*"ref=$MAINSHA"*) echo "$MAIN_BLOB" ;;
*"contents/"*"ref=$NEXTSHA"*) echo "$NEXT_BLOB" ;;
# inline cut (mutating writes) — log to GH_LOG and simulate success:
*"-X POST"*"git/tags"*) echo "\$*" >> "$GH_LOG"; echo "7a90000000000000000000000000000000000000" ;; # create annotated tag object
*"-X PATCH"*"git/refs/tags/$agent/next"*) echo "\$*" >> "$GH_LOG"; exit 0 ;; # move next (force PATCH)
*"-X POST"*"git/refs"*) echo "\$*" >> "$GH_LOG"; echo "{}" ;; # publish a ref
*"/commits/"*) echo "$MAINSHA" ;;
*"matching-refs/tags/$agent/v"*) printf '%s' "$refs" ;;
*"git/ref/tags/$agent/next"*) printf '%s\tcommit\n' "$NEXTSHA" ;;
*"git/ref/tags/$agent/v"*) echo "" ;; # release-tag existence probe (_gh_tag_commit) — not yet cut
*"run list"*) echo "[]" ;;
*) echo "{}" ;;
esac
Expand Down Expand Up @@ -881,58 +882,62 @@ GITEOF
@test "orchestrator: autocut is a no-op when CANARY_AUTO_CUT is not 'true' (kill-switch off)" {
_autocut_stub dev-lead petry-projects/.github-private .github/workflows/dev-lead-reusable.yml \
blobMAIN blobNEXT aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa cccccccccccccccccccccccccccccccccccccccc "2.1.0"
run env CANARY_RINGS="$AUTOCUT_RINGS" CUT_RELEASE="$CUT_RELEASE" bash "$ORCH" autocut
run env CANARY_RINGS="$AUTOCUT_RINGS" bash "$ORCH" autocut
[ "$status" -eq 0 ]
[[ "$output" == *"DISABLED"* ]]
[ ! -s "$CUT_LOG" ] # nothing cut when the kill-switch is off
[ ! -s "$GH_LOG" ] # nothing cut when the kill-switch is off
}

@test "orchestrator: autocut cuts a patch-bumped version + moves next when the reusable blob differs on main" {
_autocut_stub dev-lead petry-projects/.github-private .github/workflows/dev-lead-reusable.yml \
blobMAIN blobNEXT aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa cccccccccccccccccccccccccccccccccccccccc "2.1.0"
run env CANARY_AUTO_CUT=true CANARY_RINGS="$AUTOCUT_RINGS" CUT_RELEASE="$CUT_RELEASE" bash "$ORCH" autocut
run env CANARY_AUTO_CUT=true CANARY_RINGS="$AUTOCUT_RINGS" bash "$ORCH" autocut
[ "$status" -eq 0 ]
# Highest existing tag is v2.1.0 → patch bump → v2.1.1, cut from main HEAD, channel next, pushed.
grep -q "dev-lead 2.1.1 --ref aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa --channel next --push" "$CUT_LOG"
# Highest existing tag is v2.1.0 → patch bump → v2.1.1, annotated tag cut from main HEAD on the
# HOST repo (.github-private for dev-lead), then `next` force-moved onto the same commit — all gh-api.
grep -q "repos/petry-projects/.github-private/git/tags .*tag=dev-lead/v2.1.1 .*object=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" "$GH_LOG"
grep -q "PATCH repos/petry-projects/.github-private/git/refs/tags/dev-lead/next .*sha=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" "$GH_LOG"
}

@test "orchestrator: autocut is idempotent — identical blob on main and next is a clean no-op" {
_autocut_stub dev-lead petry-projects/.github-private .github/workflows/dev-lead-reusable.yml \
sameBLOB sameBLOB aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa cccccccccccccccccccccccccccccccccccccccc "2.1.0"
run env CANARY_AUTO_CUT=true CANARY_RINGS="$AUTOCUT_RINGS" CUT_RELEASE="$CUT_RELEASE" bash "$ORCH" autocut
run env CANARY_AUTO_CUT=true CANARY_RINGS="$AUTOCUT_RINGS" bash "$ORCH" autocut
[ "$status" -eq 0 ]
[[ "$output" == *"no cut"* ]]
[ ! -s "$CUT_LOG" ] # nothing cut when the blob is unchanged
[ ! -s "$GH_LOG" ] # nothing cut when the blob is unchanged
}

@test "orchestrator: autocut --dry-run prints the intended cut without invoking cut-release --push" {
@test "orchestrator: autocut --dry-run prints the intended cut without writing any tag" {
_autocut_stub dev-lead petry-projects/.github-private .github/workflows/dev-lead-reusable.yml \
blobMAIN blobNEXT aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa cccccccccccccccccccccccccccccccccccccccc "2.1.0"
run env CANARY_AUTO_CUT=true CANARY_RINGS="$AUTOCUT_RINGS" CUT_RELEASE="$CUT_RELEASE" bash "$ORCH" autocut --dry-run
run env CANARY_AUTO_CUT=true CANARY_RINGS="$AUTOCUT_RINGS" bash "$ORCH" autocut --dry-run
[ "$status" -eq 0 ]
[[ "$output" == *"DRY-RUN"* ]]
[[ "$output" == *"2.1.1"* ]]
[ ! -s "$CUT_LOG" ] # dry-run never pushes a real cut
[ ! -s "$GH_LOG" ] # dry-run never writes a real cut
}

@test "orchestrator: autocut honors the registry autocut.bump override (minor)" {
_autocut_stub dev-lead petry-projects/.github-private .github/workflows/dev-lead-reusable.yml \
blobMAIN blobNEXT aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa cccccccccccccccccccccccccccccccccccccccc "2.1.0" minor
run env CANARY_AUTO_CUT=true CANARY_RINGS="$AUTOCUT_RINGS" CUT_RELEASE="$CUT_RELEASE" bash "$ORCH" autocut
run env CANARY_AUTO_CUT=true CANARY_RINGS="$AUTOCUT_RINGS" bash "$ORCH" autocut
[ "$status" -eq 0 ]
# minor bump of v2.1.0 → v2.2.0
grep -q "dev-lead 2.2.0 --ref aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa --channel next --push" "$CUT_LOG"
grep -q "repos/petry-projects/.github-private/git/tags .*tag=dev-lead/v2.2.0 .*object=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" "$GH_LOG"
}

@test "orchestrator: autocut is cross-repo aware — cuts v2.1.1 for auto-rebase from the host main HEAD (#1069)" {
@test "orchestrator: autocut is cross-repo aware — cuts v2.1.1 for auto-rebase on the host repo (#1069)" {
# auto-rebase is hosted in petry-projects/.github; its next candidate + release tags live there,
# so the next commit is resolved via gh api (not local git) and the cut is cross-repo.
# so the next commit is resolved via gh api (not local git) and BOTH the tag create and the
# next move are written to that host — not GITHUB_REPOSITORY.
_autocut_stub auto-rebase petry-projects/.github .github/workflows/auto-rebase-reusable.yml \
ece45480ece45480ece45480ece45480ece45480 2763750027637500276375002763750027637500 \
ece45480ece45480ece45480ece45480ece45480 2763750027637500276375002763750027637500 "2.1.0"
run env CANARY_AUTO_CUT=true CANARY_RINGS="$AUTOCUT_RINGS" CUT_RELEASE="$CUT_RELEASE" bash "$ORCH" autocut
run env CANARY_AUTO_CUT=true CANARY_RINGS="$AUTOCUT_RINGS" bash "$ORCH" autocut
[ "$status" -eq 0 ]
grep -q "auto-rebase 2.1.1 --ref ece45480ece45480ece45480ece45480ece45480 --channel next --push" "$CUT_LOG"
grep -q "repos/petry-projects/.github/git/tags .*tag=auto-rebase/v2.1.1 .*object=ece45480ece45480ece45480ece45480ece45480" "$GH_LOG"
grep -q "PATCH repos/petry-projects/.github/git/refs/tags/auto-rebase/next .*sha=ece45480ece45480ece45480ece45480ece45480" "$GH_LOG"
}

# ── set_difference (pure set-diff core for drift detection, #1082) ─────────────
Expand Down
Loading