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
2 changes: 1 addition & 1 deletion node_modules/.package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

98 changes: 92 additions & 6 deletions scripts/canary-rollout.sh
Original file line number Diff line number Diff line change
Expand Up @@ -1458,12 +1458,98 @@ _host_release_versions() {
| sed -n "s#^refs/tags/${agent}/v##p" || true
}

# _autocut_bump <agent> — the configured bump level for the agent (registry knob
# .agents[a].autocut.bump), defaulting to patch. Anything but minor/major → patch.
_autocut_bump() {
# _autocut_bump_override <agent> — the EXPLICIT bump override for the agent (registry knob
# .agents[a].autocut.bump), or empty when unset/invalid. Since #712 the knob is an OVERRIDE
# over signal-based detection (not the sole source), so an absent/invalid knob yields empty
# (→ detection decides) rather than defaulting to patch.
_autocut_bump_override() {
local b
b="$(_jq -r --arg a "$1" '.agents[$a]?.autocut?.bump // "patch"')"
case "$b" in major|minor|patch) echo "$b" ;; *) echo "patch" ;; esac
b="$(_jq -r --arg a "$1" '.agents[$a]?.autocut?.bump // ""')"
case "$b" in major|minor|patch) echo "$b" ;; *) echo "" ;; esac
}

# _autocut_commit_signals <host> <reusable> <next_commit> <mainsha> — scan the commits that
# TOUCH the reusable between the current `next` candidate (exclusive) and main HEAD, echoing
# "<breaking 0|1> <feat 0|1>". breaking=1 iff any such commit is a conventional-commit `!`
# (`type(scope)!:`) or carries a `BREAKING CHANGE:`/`BREAKING-CHANGE:` footer; feat=1 iff any
# such commit subject is `feat:`/`feat(scope):` (non-breaking). Returns non-zero on any
# fetch/parse error so the caller can fail safe to patch (never auto-major on missing data).
_autocut_commit_signals() {
local host="$1" reusable="$2" next_commit="$3" mainsha="$4" json out
json="$(gh api "repos/$host/commits?path=$reusable&sha=$mainsha&per_page=100" 2>/dev/null)" || return 1
[ -z "$json" ] && return 1
out="$(jq -r --arg stop "$next_commit" '
if type != "array" then error("not an array") else . end
| (map(.sha)) as $shas
| ([ range(0; length) | select($shas[.] == $stop) ] | first) as $idx
| if $idx == null then error("boundary sha not found in page") else .[0:$idx] end
| map(.commit.message // "")
| {
b: any(.[]; test("^\\w+(\\([^)]*\\))?!:") or test("(^|\\n)BREAKING[ -]CHANGE:")),
f: any(.[]; test("^feat(\\([^)]*\\))?:"))
}
| "\(if .b then 1 else 0 end) \(if .f then 1 else 0 end)"
' <<< "$json" 2>/dev/null)" || return 1
[ -z "$out" ] && return 1
printf '%s\n' "$out"
}

# _gh_file_content <repo> <path> <ref> — the decoded text of <path> at <ref> on <repo> via the
# contents API (base64 → text). Non-zero on any fetch/decode error.
_gh_file_content() {
local raw
raw="$(gh api "repos/$1/contents/$2?ref=$3" --jq '.content // empty' 2>/dev/null)" || return 1
[ -z "$raw" ] && return 1
printf '%s' "$raw" | tr -d '\n' | base64 -d 2>/dev/null || return 1
}

# _autocut_iface_break <host> <reusable> <next_commit> <mainsha> — echo 1 if the reusable's
# `on.workflow_call` interface has a BREAKING change (removed/renamed input|secret, or a
# newly-required input) between the `next` candidate and main HEAD, else 0. Non-zero return on
# any fetch error so the caller does not escalate to major on missing interface data.
_autocut_iface_break() {
local host="$1" reusable="$2" old_ref="$3" new_ref="$4" old_c new_c
old_c="$(_gh_file_content "$host" "$reusable" "$old_ref")" || return 1
new_c="$(_gh_file_content "$host" "$reusable" "$new_ref")" || return 1
interface_break "$(workflow_call_iface "$old_c")" "$(workflow_call_iface "$new_c")"
}

# _autocut_detect_bump <agent> <host> <reusable> <next_commit> <mainsha> — the release bump for
# an autocut, echoed on stdout (a `::notice::` recording the level + driving signal goes to
# stderr so it does not pollute the captured value). The `.agents[a].autocut.bump` knob wins as
# an explicit override (even over a failed signal fetch); otherwise the level is DETECTED from
# the commit signals and the workflow_call interface diff via decide_bump, failing safe to
# patch on any signal-fetch error (never auto-major on missing data — a real breaking change
# can still be forced with the knob).
_autocut_detect_bump() {
local agent="$1" host="$2" reusable="$3" next_commit="$4" mainsha="$5"
local override; override="$(_autocut_bump_override "$agent")"
if [ -n "$override" ]; then
echo "::notice::autocut $agent: bump=$override (registry override .agents[$agent].autocut.bump)" >&2
echo "$override"; return 0
fi
local breaking=0 feat=0 driver="" sigs iface
if sigs="$(_autocut_commit_signals "$host" "$reusable" "$next_commit" "$mainsha")"; then
read -r breaking feat <<< "$sigs"
else
echo "::notice::autocut $agent: commit-signal fetch failed — bump=patch (fail-safe)" >&2
echo "patch"; return 0
fi
[ "$breaking" = 1 ] && driver="conventional-commit breaking change"
# An interface break escalates to major; a fetch error here is non-fatal (keep commit signals)
# and never invents a major from missing data.
if iface="$(_autocut_iface_break "$host" "$reusable" "$next_commit" "$mainsha")"; then
if [ "$iface" = 1 ]; then breaking=1; driver="workflow_call interface break"; fi
fi
local bump; bump="$(decide_bump "$breaking" "$feat" "")"
if [ -z "$driver" ]; then
case "$bump" in
minor) driver="feat commit" ;;
*) driver="no breaking/feat signal" ;;
esac
fi
echo "::notice::autocut $agent: detected bump=$bump ($driver)" >&2
echo "$bump"
}

# _next_release_version <agent> <bump> — compute the next release version: bump the highest
Expand Down Expand Up @@ -1508,7 +1594,7 @@ _autocut_agent() {
echo "autocut $agent: reusable unchanged on $host (next candidate up to date) — no cut."
return 0
fi
bump="$(_autocut_bump "$agent")"
bump="$(_autocut_detect_bump "$agent" "$host" "$reusable" "$next_commit" "$mainsha")"
newver="$(_next_release_version "$agent" "$bump")"
# Seed/advance the correct `next` line on the major dimension (major-scoped-channels epic
# #657, F4): a MAJOR bump opens a FRESH `<agent>/v<newmajor>-next` line (a brand-new major
Expand Down
89 changes: 89 additions & 0 deletions scripts/lib/canary-rollout.sh
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,95 @@ bump_version() {
esac
}

# ── breaking-change classification (autocut major-awareness, #712, epic #1083) ──
# The autocut front end decides the release bump from SIGNALS (a breaking change → major,
# a non-breaking feat → minor, else patch), with the manual `.agents[a].autocut.bump` knob
# kept as an explicit override. These pure cores make that decision unit-testable; the
# orchestrator gathers the raw signals (commit messages, workflow_call interface) and feeds
# them in.

# decide_bump <breaking:0|1> <feat:0|1> <override> — echo the semver bump level. An explicit
# <override> in {major,minor,patch} ALWAYS wins (back-compat / manual force); otherwise a
# breaking signal → major, a feat signal → minor, and neither → patch.
decide_bump() {
local breaking="${1:-0}" feat="${2:-0}" override="${3:-}"
case "$override" in major|minor|patch) echo "$override"; return 0 ;; esac
if [ "$breaking" = 1 ]; then echo major; return 0; fi
if [ "$feat" = 1 ]; then echo minor; return 0; fi
echo patch
}

# workflow_call_iface <yaml_text> — parse a reusable workflow's `on.workflow_call` block into
# a normalized, sorted interface descriptor (one item per line), for a set-comparison diff:
# input <name> <required 0|1>
# secret <name>
# Outputs are intentionally omitted: a new output is not breaking and a removed output is out
# of scope for #712, so they never drive the verdict. Pure: a deterministic awk transform over
# the text (no gh/git/network I/O), tolerant of comments and standard 2-space GitHub indent.
workflow_call_iface() {
tr -d '\r' <<< "$1" | awk '
function ind(s){ match(s, /^ */); return RLENGTH }
{ raw = $0 }
raw ~ /^[[:space:]]*($|#)/ { next } # blank / comment
{ i = ind(raw) }
raw ~ /^[[:space:]]*workflow_call:[[:space:]]*(#.*)?$/ { inwc=1; wci=i; sec=""; seci=-1; keyi=-1; next }
(inwc && i <= wci) { inwc=0 } # left workflow_call block
!inwc { next }
raw ~ /^[[:space:]]*inputs:[[:space:]]*(#.*)?$/ { sec="input"; seci=i; keyi=-1; next }
raw ~ /^[[:space:]]*secrets:[[:space:]]*(#.*)?$/ { sec="secret"; seci=i; keyi=-1; next }
raw ~ /^[[:space:]]*outputs:[[:space:]]*(#.*)?$/ { sec="output"; seci=i; keyi=-1; next }
(sec != "" && i <= seci) { sec="" } # left the section
sec == "" { next }
{
if (keyi == -1) keyi = i
if (i == keyi) {
name = raw; sub(/^[[:space:]]*/, "", name); sub(/:.*/, "", name)
cur = name
if (sec == "input") { inp[cur]=1; if (!(cur in req)) req[cur]=0 }
else if (sec == "secret") { seclist[cur]=1 }
} else if (i > keyi && sec == "input") {
if (raw ~ /^[[:space:]]*required:[[:space:]]*true[[:space:]]*(#.*)?$/) req[cur]=1
}
}
END {
for (n in inp) print "input " n " " (req[n] ? 1 : 0)
for (n in seclist) print "secret " n
}
' | sort
}

# interface_break <old_desc> <new_desc> — echo 1 if the change from <old_desc> to <new_desc>
# (both workflow_call_iface descriptors) is a BREAKING interface change, else 0. Breaking iff:
# - an input or secret present in OLD is absent in NEW (removed or renamed), OR
# - an input is required:true in NEW but was not required:true in OLD (newly-added-required
# or optional→required flip).
# Added-optional inputs and relaxing required→optional are NOT breaking. Pure: bash only.
interface_break() {
local old="$1" new="$2" kind name req key
local -A new_has=() old_has=() new_input_req=() old_input_req=()
while read -r kind name req || [ -n "$kind" ]; do
[ -z "$kind" ] && continue
kind="${kind%$'\r'}"; name="${name%$'\r'}"; req="${req%$'\r'}"
new_has["$kind/$name"]=1
[ "$kind" = input ] && new_input_req["$name"]="${req:-0}"
done <<< "$new"
while read -r kind name req || [ -n "$kind" ]; do
[ -z "$kind" ] && continue
kind="${kind%$'\r'}"; name="${name%$'\r'}"; req="${req%$'\r'}"
old_has["$kind/$name"]=1
[ "$kind" = input ] && old_input_req["$name"]="${req:-0}"
done <<< "$old"
for key in "${!old_has[@]}"; do
if [ -z "${new_has[$key]:-}" ]; then echo 1; return 0; fi # removed / renamed
done
for name in "${!new_input_req[@]}"; do
if [ "${new_input_req[$name]}" = 1 ] && [ "${old_input_req[$name]:-x}" != 1 ]; then
echo 1; return 0 # newly-required input
fi
done
echo 0
}

# _semver_gt <a> <b> — return 0 iff semver a is strictly greater than b (compared
# by major, then minor, then patch). Equal is NOT greater.
_semver_gt() {
Expand Down
2 changes: 1 addition & 1 deletion standards/canary-rings.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
"$org_infra": "org_infra_repos minus the host (the host is already in `next`)",
"*": "every other consumer not named in an earlier ring"
},
"_autocut_note": "autocut (#1069): `canary-rollout.sh autocut` (gated by org var CANARY_AUTO_CUT) auto-cuts a new immutable <agent>/vX.Y.Z + moves `next` when a reusable's blob on its host main HEAD differs from the current `next` candidate. Version bump defaults to patch; override per agent with the optional `.agents[<a>].autocut.bump` knob (patch|minor|major).",
"_autocut_note": "autocut (#1069): `canary-rollout.sh autocut` (gated by org var CANARY_AUTO_CUT) auto-cuts a new immutable <agent>/vX.Y.Z + moves `next` when a reusable's blob on its host main HEAD differs from the current `next` candidate. Since #712 (epic #1083 pillar 2) the bump is DETECTED from the change: a conventional-commit `!`/`BREAKING CHANGE` or a `workflow_call` interface break (removed/renamed input|secret, or a newly-required input) → major (seeding a fresh v<newMAJOR>-next per #657 F4); a non-breaking `feat` → minor; else patch. The optional `.agents[<a>].autocut.bump` knob (patch|minor|major) is now an OVERRIDE that forces a level (not the sole source); it fails safe to patch on any signal-fetch error.",
"agents": {
"dev-lead": {
"host": "petry-projects/.github-private",
Expand Down
Loading
Loading