diff --git a/node_modules/.package-lock.json b/node_modules/.package-lock.json index 0762d7d67..0c3fdfa94 100644 --- a/node_modules/.package-lock.json +++ b/node_modules/.package-lock.json @@ -1,5 +1,5 @@ { - "name": "pr-716", + "name": "pr-720", "lockfileVersion": 3, "requires": true, "packages": { diff --git a/package-lock.json b/package-lock.json index 52d088070..4549ab715 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,5 +1,5 @@ { - "name": "pr-716", + "name": "pr-720", "lockfileVersion": 3, "requires": true, "packages": { diff --git a/scripts/canary-rollout.sh b/scripts/canary-rollout.sh index c422a8193..6e19e6d82 100755 --- a/scripts/canary-rollout.sh +++ b/scripts/canary-rollout.sh @@ -1458,12 +1458,98 @@ _host_release_versions() { | sed -n "s#^refs/tags/${agent}/v##p" || true } -# _autocut_bump — 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 — 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 — scan the commits that +# TOUCH the reusable between the current `next` candidate (exclusive) and main HEAD, echoing +# " ". 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 — the decoded text of at on 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 — 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 — 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 — compute the next release version: bump the highest @@ -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 `/v-next` line (a brand-new major diff --git a/scripts/lib/canary-rollout.sh b/scripts/lib/canary-rollout.sh index 5a8788078..16ee53cf9 100644 --- a/scripts/lib/canary-rollout.sh +++ b/scripts/lib/canary-rollout.sh @@ -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 — echo the semver bump level. An explicit +# 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 — 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 +# secret +# 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 — echo 1 if the change from to +# (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 — return 0 iff semver a is strictly greater than b (compared # by major, then minor, then patch). Equal is NOT greater. _semver_gt() { diff --git a/standards/canary-rings.json b/standards/canary-rings.json index c438b11b2..46e1d7331 100644 --- a/standards/canary-rings.json +++ b/standards/canary-rings.json @@ -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 /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[].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 /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-next per #657 F4); a non-breaking `feat` → minor; else patch. The optional `.agents[].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", diff --git a/tests/canary_rollout.bats b/tests/canary_rollout.bats index 75b5b901e..ef0794d85 100644 --- a/tests/canary_rollout.bats +++ b/tests/canary_rollout.bats @@ -77,6 +77,122 @@ setup() { [ "$(bump_version 2.1.0 bogus)" = "2.1.1" ] } +# ── decide_bump: signal-based classification, knob as override (#712, epic #1083) ─ +# decide_bump → major|minor|patch. +# An explicit override (patch|minor|major) always wins; else breaking→major, feat→minor, +# else patch. All 4 signal combinations plus override precedence are exercised. +@test "decide_bump: breaking beats feat → major (both signals set)" { + [ "$(decide_bump 1 1 '')" = "major" ] +} +@test "decide_bump: breaking only → major" { + [ "$(decide_bump 1 0 '')" = "major" ] +} +@test "decide_bump: feat only → minor" { + [ "$(decide_bump 0 1 '')" = "minor" ] +} +@test "decide_bump: neither signal → patch" { + [ "$(decide_bump 0 0 '')" = "patch" ] +} +@test "decide_bump: override wins over every signal combination" { + # override major + [ "$(decide_bump 0 0 major)" = "major" ] + [ "$(decide_bump 1 1 major)" = "major" ] + # override minor (even when breaking would say major) + [ "$(decide_bump 1 0 minor)" = "minor" ] + [ "$(decide_bump 0 0 minor)" = "minor" ] + # override patch (even when breaking/feat would escalate) + [ "$(decide_bump 1 1 patch)" = "patch" ] + [ "$(decide_bump 0 1 patch)" = "patch" ] +} +@test "decide_bump: an invalid override is ignored (falls back to signals)" { + [ "$(decide_bump 1 0 bogus)" = "major" ] + [ "$(decide_bump 0 1 '')" = "minor" ] +} + +# ── workflow_call_iface: parse an on.workflow_call interface into a descriptor ──── +# Pure YAML→descriptor transform: emits `input ` and `secret ` +# (sorted). Outputs are intentionally not emitted (they never drive a breaking verdict). +@test "workflow_call_iface: extracts inputs (with required flags) and secrets" { + yaml="$(cat <<'YML' +name: demo-reusable +on: + workflow_call: + inputs: + target: + required: true + type: string + dry_run: + required: false + type: boolean + note: + type: string + secrets: + APP_TOKEN: + required: true + outputs: + result: + value: ${{ jobs.x.outputs.r }} +jobs: + x: + runs-on: ubuntu-latest + steps: [] +YML +)" + run workflow_call_iface "$yaml" + [ "$status" -eq 0 ] + # required input → req 1; optional / unspecified → req 0 + [[ "$output" == *"input target 1"* ]] + [[ "$output" == *"input dry_run 0"* ]] + [[ "$output" == *"input note 0"* ]] + [[ "$output" == *"secret APP_TOKEN"* ]] + # outputs are not part of the interface descriptor + [[ "$output" != *"result"* ]] +} + +# ── interface_break: decide a breaking workflow_call interface change ───────────── +# interface_break → 1 if an input/secret was removed or renamed, +# or an input became (or was newly added as) required:true; else 0. Added-optional +# inputs and new outputs are NOT breaking. +@test "interface_break: removed input → breaking" { + old="$(printf 'input a 0\ninput b 0\n')" + new="$(printf 'input a 0\n')" + [ "$(interface_break "$old" "$new")" = "1" ] +} +@test "interface_break: renamed input (drop old name, add new) → breaking" { + old="$(printf 'input a 0\n')" + new="$(printf 'input renamed 0\n')" + [ "$(interface_break "$old" "$new")" = "1" ] +} +@test "interface_break: removed secret → breaking" { + old="$(printf 'secret TOKEN\n')" + new="$(printf '')" + [ "$(interface_break "$old" "$new")" = "1" ] +} +@test "interface_break: newly-added required input → breaking" { + old="$(printf 'input a 0\n')" + new="$(printf 'input a 0\ninput b 1\n')" + [ "$(interface_break "$old" "$new")" = "1" ] +} +@test "interface_break: optional input flipped to required → breaking" { + old="$(printf 'input a 0\n')" + new="$(printf 'input a 1\n')" + [ "$(interface_break "$old" "$new")" = "1" ] +} +@test "interface_break: added OPTIONAL input → NOT breaking" { + old="$(printf 'input a 0\n')" + new="$(printf 'input a 0\ninput b 0\n')" + [ "$(interface_break "$old" "$new")" = "0" ] +} +@test "interface_break: identical interface → NOT breaking" { + desc="$(printf 'input a 1\nsecret TOKEN\n')" + [ "$(interface_break "$desc" "$desc")" = "0" ] +} +@test "interface_break: required input relaxed to optional → NOT breaking" { + old="$(printf 'input a 1\n')" + new="$(printf 'input a 0\n')" + [ "$(interface_break "$old" "$new")" = "0" ] +} + @test "_semver_gt: compares by major, then minor, then patch" { run _semver_gt 2.1.1 2.1.0; [ "$status" -eq 0 ] run _semver_gt 2.2.0 2.1.9; [ "$status" -eq 0 ] @@ -2607,3 +2723,241 @@ GITEOF grep -q "PATCH repos/petry-projects/.github-private/git/refs/tags/dev-lead/next .*sha=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" "$GH_LOG" ! grep -q "git/refs/tags/dev-lead/v2-next" "$GH_LOG" } + +# ── autocut breaking-change detection (#712, epic #1083 pillar 2) ────────────── +# When autocut cuts a candidate it now CLASSIFIES the change: a conventional-commit +# `!`/`BREAKING CHANGE` on a commit touching the reusable, or a workflow_call interface +# break (removed/renamed/newly-required input, removed secret) auto-produces a MAJOR +# (seeding a fresh v-next per F4); a non-breaking `feat` → minor, `fix` → patch; +# any signal-fetch error fails safe to patch. The `.agents[a].autocut.bump` knob overrides. +# +# The stub feeds, in addition to the plumbing the other autocut stubs mock: the commit list +# for `commits?path=&sha=` (a JSON array, newest-first, terminated by a +# boundary commit with sha= that must be EXCLUDED) and the reusable's file CONTENT +# (base64, keyed by ref) so the interface diff can parse on.workflow_call at both refs. +# args: agent host reusable mainsha nextsha versions commits_json old_yaml new_yaml [bump] +_detect_autocut_stub() { + local agent="$1" host="$2" reusable="$3" MAINSHA="$4" NEXTSHA="$5" versions="$6" + local commits_json="$7" old_yaml="$8" new_yaml="$9" bump="${10:-}" + STUB_BIN="$(mktemp -d "$BATS_TEST_TMPDIR/stub.XXXXXX")"; export PATH="$STUB_BIN:$PATH" + 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 + printf '%s' "$commits_json" > "$STUB_BIN/commits.json" + local NEW_B64 OLD_B64 + NEW_B64="$(printf '%s' "$new_yaml" | base64 | tr -d '\n')" + OLD_B64="$(printf '%s' "$old_yaml" | base64 | tr -d '\n')" + cat > "$STUB_BIN/gh" <> "$GH_LOG"; echo "7a90000000000000000000000000000000000000" ;; + *"-X PATCH"*"git/refs/tags/"*) echo "\$*" >> "$GH_LOG"; exit 0 ;; + *"-X POST"*"git/refs"*) echo "\$*" >> "$GH_LOG"; echo "{}" ;; + *"matching-refs/tags/$agent/v"*) printf '%s' "$refs" ;; + *"git/ref/tags/$agent/v"*"-next"*) printf '\n' ;; + *"git/ref/tags/$agent/next"*) printf '%s\tcommit\n' "$NEXTSHA" ;; + *"git/ref/tags/$agent/v"*) printf '\n' ;; + *"run list"*) echo "[]" ;; + *) echo "{}" ;; +esac +GHEOF + chmod +x "$STUB_BIN/gh" + cat > "$STUB_BIN/git" < "$AUTOCUT_RINGS" + else + jq --arg a "$agent" \ + '{version, description, org_infra_repos, member_tokens, agents: {($a): .agents[$a]}}' \ + "$RINGS" > "$AUTOCUT_RINGS" + fi +} + +# A workflow_call interface with one required + one optional input and a secret. +_iface_yaml() { + cat <<'YML' +name: dev-lead-reusable +on: + workflow_call: + inputs: + target: + required: true + type: string + dry_run: + required: false + type: boolean + secrets: + APP_TOKEN: + required: true +jobs: + run: + runs-on: ubuntu-latest + steps: [] +YML +} + +@test "orchestrator: autocut detects a feat! commit → MAJOR + seeds a fresh v-next (#712)" { + local commits='[{"sha":"newcommit0000000000000000000000000000","commit":{"message":"feat!: drop the legacy dry_run input\n\nRemoves a caller-facing knob."}},{"sha":"cccccccccccccccccccccccccccccccccccccccc","commit":{"message":"chore: prior baseline"}}]' + # interface unchanged (same yaml both refs) — the major comes purely from the commit signal. + _detect_autocut_stub dev-lead petry-projects/.github-private .github/workflows/dev-lead-reusable.yml \ + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa cccccccccccccccccccccccccccccccccccccccc "2.1.0" \ + "$commits" "$(_iface_yaml)" "$(_iface_yaml)" + run env CANARY_AUTO_CUT=true CANARY_RINGS="$AUTOCUT_RINGS" bash "$ORCH" autocut + [ "$status" -eq 0 ] + # major bump v2.1.0 → v3.0.0; next seeded on the FRESH v3 line, old bare next untouched. + grep -q "git/tags .*tag=dev-lead/v3.0.0 .*object=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" "$GH_LOG" + grep -q "PATCH repos/petry-projects/.github-private/git/refs/tags/dev-lead/v3-next .*sha=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" "$GH_LOG" + ! grep -q "git/refs/tags/dev-lead/next " "$GH_LOG" +} + +@test "orchestrator: autocut detects a removed workflow_call input → MAJOR (interface break) (#712)" { + # Commits are non-breaking (fix) — the major comes from the interface diff: new yaml drops 'dry_run'. + local commits='[{"sha":"newcommit0000000000000000000000000000","commit":{"message":"fix: internal cleanup"}},{"sha":"cccccccccccccccccccccccccccccccccccccccc","commit":{"message":"chore: prior baseline"}}]' + local new_yaml + new_yaml="$(cat <<'YML' +name: dev-lead-reusable +on: + workflow_call: + inputs: + target: + required: true + type: string + secrets: + APP_TOKEN: + required: true +jobs: + run: + runs-on: ubuntu-latest + steps: [] +YML +)" + _detect_autocut_stub dev-lead petry-projects/.github-private .github/workflows/dev-lead-reusable.yml \ + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa cccccccccccccccccccccccccccccccccccccccc "2.1.0" \ + "$commits" "$(_iface_yaml)" "$new_yaml" + run env CANARY_AUTO_CUT=true CANARY_RINGS="$AUTOCUT_RINGS" bash "$ORCH" autocut + [ "$status" -eq 0 ] + grep -q "git/tags .*tag=dev-lead/v3.0.0 .*object=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" "$GH_LOG" + grep -q "PATCH repos/petry-projects/.github-private/git/refs/tags/dev-lead/v3-next .*sha=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" "$GH_LOG" +} + +@test "orchestrator: autocut detects a newly-required workflow_call input → MAJOR (interface break) (#712)" { + local commits='[{"sha":"newcommit0000000000000000000000000000","commit":{"message":"fix: tidy"}},{"sha":"cccccccccccccccccccccccccccccccccccccccc","commit":{"message":"chore: prior baseline"}}]' + # new yaml adds a brand-new REQUIRED input 'workspace' — callers not passing it break. + local new_yaml + new_yaml="$(cat <<'YML' +name: dev-lead-reusable +on: + workflow_call: + inputs: + target: + required: true + type: string + dry_run: + required: false + type: boolean + workspace: + required: true + type: string + secrets: + APP_TOKEN: + required: true +jobs: + run: + runs-on: ubuntu-latest + steps: [] +YML +)" + _detect_autocut_stub dev-lead petry-projects/.github-private .github/workflows/dev-lead-reusable.yml \ + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa cccccccccccccccccccccccccccccccccccccccc "2.1.0" \ + "$commits" "$(_iface_yaml)" "$new_yaml" + run env CANARY_AUTO_CUT=true CANARY_RINGS="$AUTOCUT_RINGS" bash "$ORCH" autocut + [ "$status" -eq 0 ] + grep -q "git/tags .*tag=dev-lead/v3.0.0 .*object=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" "$GH_LOG" +} + +@test "orchestrator: autocut detects a non-breaking feat commit → MINOR (#712)" { + # feat adds an OPTIONAL input — non-breaking → minor. + local commits='[{"sha":"newcommit0000000000000000000000000000","commit":{"message":"feat: add an optional verbose input"}},{"sha":"cccccccccccccccccccccccccccccccccccccccc","commit":{"message":"chore: prior baseline"}}]' + local new_yaml + new_yaml="$(cat <<'YML' +name: dev-lead-reusable +on: + workflow_call: + inputs: + target: + required: true + type: string + dry_run: + required: false + type: boolean + verbose: + required: false + type: boolean + secrets: + APP_TOKEN: + required: true +jobs: + run: + runs-on: ubuntu-latest + steps: [] +YML +)" + _detect_autocut_stub dev-lead petry-projects/.github-private .github/workflows/dev-lead-reusable.yml \ + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa cccccccccccccccccccccccccccccccccccccccc "2.1.0" \ + "$commits" "$(_iface_yaml)" "$new_yaml" + run env CANARY_AUTO_CUT=true CANARY_RINGS="$AUTOCUT_RINGS" bash "$ORCH" autocut + [ "$status" -eq 0 ] + # minor bump v2.1.0 → v2.2.0 + grep -q "git/tags .*tag=dev-lead/v2.2.0 .*object=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" "$GH_LOG" + ! grep -q "tag=dev-lead/v3" "$GH_LOG" +} + +@test "orchestrator: autocut detects a fix-only change → PATCH (#712)" { + local commits='[{"sha":"newcommit0000000000000000000000000000","commit":{"message":"fix: correct a log message"}},{"sha":"cccccccccccccccccccccccccccccccccccccccc","commit":{"message":"chore: prior baseline"}}]' + _detect_autocut_stub dev-lead petry-projects/.github-private .github/workflows/dev-lead-reusable.yml \ + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa cccccccccccccccccccccccccccccccccccccccc "2.1.0" \ + "$commits" "$(_iface_yaml)" "$(_iface_yaml)" + run env CANARY_AUTO_CUT=true CANARY_RINGS="$AUTOCUT_RINGS" bash "$ORCH" autocut + [ "$status" -eq 0 ] + grep -q "git/tags .*tag=dev-lead/v2.1.1 .*object=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" "$GH_LOG" +} + +@test "orchestrator: autocut fails SAFE to PATCH when the commit-signal fetch errors (#712)" { + # commits?path returns a non-array error payload → signal fetch fails → never auto-major. + local commits='{"message":"Not Found","status":"404"}' + _detect_autocut_stub dev-lead petry-projects/.github-private .github/workflows/dev-lead-reusable.yml \ + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa cccccccccccccccccccccccccccccccccccccccc "2.1.0" \ + "$commits" "$(_iface_yaml)" "$(_iface_yaml)" + run env CANARY_AUTO_CUT=true CANARY_RINGS="$AUTOCUT_RINGS" bash "$ORCH" autocut + [ "$status" -eq 0 ] + grep -q "git/tags .*tag=dev-lead/v2.1.1 .*object=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" "$GH_LOG" + ! grep -q "tag=dev-lead/v3" "$GH_LOG" +} + +@test "orchestrator: autocut knob override forces MAJOR over a patch-only diff (#712)" { + # Signals say patch (fix commit, no interface change) but the registry knob forces major. + local commits='[{"sha":"newcommit0000000000000000000000000000","commit":{"message":"fix: small tweak"}},{"sha":"cccccccccccccccccccccccccccccccccccccccc","commit":{"message":"chore: prior baseline"}}]' + _detect_autocut_stub dev-lead petry-projects/.github-private .github/workflows/dev-lead-reusable.yml \ + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa cccccccccccccccccccccccccccccccccccccccc "2.1.0" \ + "$commits" "$(_iface_yaml)" "$(_iface_yaml)" major + run env CANARY_AUTO_CUT=true CANARY_RINGS="$AUTOCUT_RINGS" bash "$ORCH" autocut + [ "$status" -eq 0 ] + grep -q "git/tags .*tag=dev-lead/v3.0.0 .*object=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" "$GH_LOG" + grep -q "PATCH repos/petry-projects/.github-private/git/refs/tags/dev-lead/v3-next .*sha=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" "$GH_LOG" +}