feat: reconciler warns on a release-shaped PR missing its release label

Bare X.Y.Z at the head where the base says otherwise, no release label,
not a draft: the sweep emits one :⚠️: per pass naming both
versions. A warning only — release is declared intent and the reconciler
never guesses intent (LABELS.md). Version read via the API, both
backends, jq not node; unreadable reads nag nobody. Plus the yq test
contract in CI and fixture tests for the guard matrix.

Part of #130.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
claude-bot-andresmgsl 2026-07-24 11:54:49 +00:00
parent 53efbfba2a
commit 16dfdb9a4f
5 changed files with 203 additions and 2 deletions

View file

@ -37,6 +37,9 @@ jobs:
# absent; in CI a skip must be a failure, or the case could
# quietly stop running (issue #3's test contract).
CEREMONY_REQUIRE_NPM: 1
# Same contract for the yq-backed labeler.yml parse cases
# (#130): yq is preinstalled on ubuntu-latest, optional locally.
CEREMONY_REQUIRE_YQ: 1
run: bash test/run.sh
# The release exercise (issue #9's scratch caller) on every PR, so the

View file

@ -175,6 +175,7 @@ set_required_bots() { # the PR author is recused by construction
# The state machine. Pure functions over these globals, set per PR:
# DRAFT true|false
# HEAD_SHA the PR's current head commit
# BASE_SHA the PR's base branch head (the release-shape guard's ref)
# REQUESTED newline-separated logins with a review currently requested
# REVIEWS_JSON JSON array of submitted (non-PENDING) reviews
# MERGEABLE MERGEABLE | CONFLICTING | UNKNOWN (GitHub's own verdict)
@ -497,6 +498,38 @@ $(configured_label_rows "$LABELS_CONF")"
has_label() { grep -qxF "$1" <<<"$LABELS"; }
release_shape_warning() { # $1 = PR, $2 = head version, $3 = base version
# The #128 incident's guard (#130): a release-shaped PR — bare X.Y.Z at
# its head where the base says something else — reaching the board with
# no `release` label is exactly the state whose merge would publish
# nothing, so the sweep says so instead of letting the merge door
# discover it. A WARNING, never a write: `release` is declared intent,
# and the reconciler does not guess intent (LABELS.md's rule for
# `blocked`/`release`). An unreadable version blocks nothing — the
# sweep must not nag on facts it did not read.
local n="$1" head_ver="$2" base_ver="$3"
[ -n "$head_ver" ] || return 0
grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$' <<<"$head_ver" || return 0
[ "$head_ver" != "$base_ver" ] || return 0
echo "::warning::labels: #$n is release-shaped (version $base_ver -> $head_ver at its head) but carries no release label — the merge door reads that label as declared intent and will refuse without it; if this is the ceremony PR, apply release (#130; the #128 incident)"
}
tree_version() { # $1 = ref → that tree's version via the API, or nothing
# Both backends, no checkout: a VERSION file first, package.json's
# version field second (jq, not node — a read needs no npm machinery).
# Every failure path prints nothing: the caller treats "could not read"
# as "not release-shaped" rather than warning on a guess.
local ref="$1" ver
ver="$(gh api "repos/$REPO/contents/VERSION?ref=$ref" --jq '.content' 2>/dev/null \
| base64 -d 2>/dev/null | tr -d '[:space:]')"
if [ -z "$ver" ]; then
ver="$(gh api "repos/$REPO/contents/package.json?ref=$ref" --jq '.content' 2>/dev/null \
| base64 -d 2>/dev/null | jq -r '.version // empty' 2>/dev/null)"
fi
[ -z "$ver" ] || printf '%s\n' "$ver"
return 0
}
reconcile_pr() { # $1 = PR number; relies on the globals set from its fetch
local n="$1" desired remove s args last_activity last_activity_epoch age
@ -581,6 +614,13 @@ reconcile_pr() { # $1 = PR number; relies on the globals set from its fetch
fi
fi
# ---- the release-shape guard (#130): a warning, never a write --------
# Drafts are exempt (the build phase is the builder's); the version
# reads cost two API calls and only on PRs missing the label.
if [ "$DRAFT" != true ] && ! has_label release; then
release_shape_warning "$n" "$(tree_version "$HEAD_SHA")" "$(tree_version "$BASE_SHA")"
fi
# ---- merge-next: cleared, never set ----------------------------------
# Queue order is INTENT — which PR should land first is a judgement about
# conflicts and dependencies that GitHub knows nothing about, so the
@ -656,6 +696,7 @@ main() {
AUTHOR="$(jq -r '.user.login' <<<"$PR_JSON")"
set_required_bots "$AUTHOR"
HEAD_SHA="$(jq -r '.head.sha' <<<"$PR_JSON")"
BASE_SHA="$(jq -r '.base.sha' <<<"$PR_JSON")"
LABELS="$(jq -r '.labels[].name' <<<"$PR_JSON")"
REQUESTED="$(jq -r '.requested_reviewers[].login' <<<"$PR_JSON")"
# PENDING reviews are unsubmitted drafts in someone's browser — not a verdict

View file

@ -45,7 +45,8 @@ run() { # every mutation goes through here — DRY_RUN=1 logs instead of doing
}
glob_to_regex() { # $1 = glob (the subset above) → anchored ERE, one line
local glob="$1" out="" c i=0 n="${#glob}"
local glob="$1" out="" c i=0 n
n="${#glob}"
while [ "$i" -lt "$n" ]; do
c="${glob:i:1}"
case "$c" in
@ -113,7 +114,7 @@ derive_labels() { # $1 = "label<TAB>glob" lines, $2 = changed files (one per
# line) → matched labels, one per line, config order, deduped
local tsv="$1" files="$2" label glob matched=$'\n'
[ -n "$files" ] || return 0
while IFS="$(printf '\t')" read -r label glob; do
while IFS=$'\t' read -r label glob; do
[ -n "$label" ] || continue
case "$matched" in *$'\n'"$label"$'\n'*) continue ;; esac
if printf '%s\n' "$files" | grep -qE -- "$(glob_to_regex "$glob")"; then

View file

@ -92,6 +92,28 @@ expect "unrelated scope labels do not affect a complete core taxonomy" "" \
scope:consumer-one
scope:consumer-two")"
# -- the release-shape guard warns, never writes (#130; the #128 incident) ----
# The caller gates on NOT has_label release and NOT draft; these fix the
# version matrix. The warning is one line per call — reconcile_pr runs once
# per PR per sweep, so "exactly one warning per sweep" is by construction.
shape_warning="$(release_shape_warning 41 2.0.0 2.0.0-dev)"
expect "bare head over a -dev base warns" yes \
"$(grep -qF '::warning::' <<<"$shape_warning" && echo yes || echo no)"
expect "...naming the PR and both versions" yes \
"$(grep -qF '#41 is release-shaped (version 2.0.0-dev -> 2.0.0' <<<"$shape_warning" && echo yes || echo no)"
expect "...and pointing at the release label, not setting it" yes \
"$(grep -qF 'apply release' <<<"$shape_warning" && echo yes || echo no)"
expect "an ordinary -dev head is silent" "" \
"$(release_shape_warning 41 2.0.1-dev 2.0.0-dev)"
expect "a bare head equal to the base is silent" "" \
"$(release_shape_warning 41 2.0.0 2.0.0)"
expect "an rc head is silent — pre-releases are not the merge door's shape" "" \
"$(release_shape_warning 41 2.0.0-rc1 2.0.0-dev)"
expect "an unreadable head version is silent — never nag on a guess" "" \
"$(release_shape_warning 41 "" 2.0.0-dev)"
expect "a bare head over an unreadable base still warns" yes \
"$(release_shape_warning 41 2.0.0 "" | grep -qF '::warning::' && echo yes || echo no)"
# -- drafts are building, whoever is requested --------------------------------
DRAFT=true HEAD_SHA=head1 REQUESTED="" REVIEWS_JSON='[]'
expect "draft PR is building" state:building "$(decide_state)"

134
test/labels-scope.test.sh Normal file
View file

@ -0,0 +1,134 @@
#!/usr/bin/env bash
set -u
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# shellcheck source=test/harness.sh
source "$ROOT/test/harness.sh"
# shellcheck source=actions/labels-scope/labels-scope.sh
source "$ROOT/actions/labels-scope/labels-scope.sh"
TMP="$(mktemp -d)"
trap 'rm -rf "$TMP"' EXIT
TAB="$(printf '\t')"
# --- glob_to_regex: the minimatch subset the family actually uses ------------
check "glob: ** crosses slashes" 0 '^lib/.*$' glob_to_regex 'lib/**'
check "glob: * stays inside a segment" 0 '^[^/]*\.md$' glob_to_regex '*.md'
check "glob: ? is one non-slash char" 0 '^doc[^/]/x$' glob_to_regex 'doc?/x'
check "glob: literal path is anchored whole" 0 '^README$' glob_to_regex 'README'
check "glob: dots are escaped, not wildcards" 0 \
'^commands/users-[^/]*\.sh$' glob_to_regex 'commands/users-*.sh'
check "glob: regex specials are literal" 0 '^a\+b\{c\}\(d\)$' glob_to_regex 'a+b{c}(d)'
# --- derive_labels: pure matching over parsed rows ---------------------------
cfg="scope:release-flow${TAB}lib/**
scope:release-flow${TAB}VERSION
scope:docs${TAB}docs/**
scope:docs${TAB}README"
check "derive: ** matches nested paths" 0 "scope:release-flow" \
derive_labels "$cfg" 'lib/deep/facts.sh'
check "derive: ** does not match the bare directory" 1 "" \
bash -c 'source "$1"; [ -n "$(derive_labels "$2" lib)" ]' _ \
"$ROOT/actions/labels-scope/labels-scope.sh" "$cfg"
check "derive: literal glob does not match a nested twin" 1 "" \
bash -c 'source "$1"; [ -n "$(derive_labels "$2" docs2/README)" ]' _ \
"$ROOT/actions/labels-scope/labels-scope.sh" "$cfg"
check "derive: one label per line, config order, deduped" 0 "" \
bash -c 'source "$1"; got="$(derive_labels "$2" "$(printf "%s\n" README VERSION lib/x docs/a.md)")"
[ "$got" = "$(printf "%s\n" scope:release-flow scope:docs)" ] || { printf "%s\n" "$got"; exit 1; }' _ \
"$ROOT/actions/labels-scope/labels-scope.sh" "$cfg"
check "derive: no files derives nothing" 0 "" derive_labels "$cfg" ""
check "derive: unmatched files derive nothing" 0 "" derive_labels "$cfg" 'src/other.c'
# --- parse_labeler_config: every spelling the governed repos use -------------
# Needs yq (preinstalled on ubuntu-latest). Locally, skip with a notice so
# the suite stays runnable in minimal environments; in CI the skip is a
# failure — ci.yml sets CEREMONY_REQUIRE_YQ so these cases can never
# quietly stop running there.
if command -v yq >/dev/null 2>&1; then
parses() { parse_labeler_config <"$1"; }
# block sequences + block glob list (ceremony's own spelling)
cat >"$TMP/block.yml" <<'EOF'
# a comment, as ceremony's own file carries
scope:labels:
- changed-files:
- any-glob-to-any-file:
- .github/workflows/labels.yml
- actions/labels-reconcile/**
EOF
check "parse: block style" 0 \
"scope:labels${TAB}actions/labels-reconcile/**" parses "$TMP/block.yml"
# quoted keys + flow glob list (box/rig's spelling)
cat >"$TMP/flow.yml" <<'EOF'
"scope:cli":
- changed-files:
- any-glob-to-any-file: ["bin/**", "test/cli.sh"]
EOF
check "parse: quoted key, flow list" 0 \
"scope:cli${TAB}bin/**" parses "$TMP/flow.yml"
# flow map inside changed-files (incubator's spelling)
cat >"$TMP/flowmap.yml" <<'EOF'
"scope:core":
- changed-files: [{any-glob-to-any-file: ["apps/core/**"]}]
EOF
check "parse: flow map entry" 0 \
"scope:core${TAB}apps/core/**" parses "$TMP/flowmap.yml"
# a single glob as a bare string
cat >"$TMP/single.yml" <<'EOF'
scope:docs:
- changed-files:
- any-glob-to-any-file: docs/**
EOF
check "parse: bare-string glob" 0 \
"scope:docs${TAB}docs/**" parses "$TMP/single.yml"
# the repo's real mapping parses, and rows keep config order
check "parse: ceremony's own labeler.yml" 0 \
"scope:labels${TAB}.github/labeler.yml" parses "$ROOT/.github/labeler.yml"
# refusals: unsupported shapes fail loudly, naming the label
cat >"$TMP/allglobs.yml" <<'EOF'
scope:x:
- changed-files:
- all-globs-to-all-files: ["a/**"]
EOF
check "parse: all-globs-to-all-files is refused" 5 \
"scope:x: unsupported matcher(s) all-globs-to-all-files" parses "$TMP/allglobs.yml"
cat >"$TMP/branch.yml" <<'EOF'
scope:x:
- head-branch: ["^feature/"]
EOF
check "parse: branch matchers are refused" 5 \
"scope:x: unsupported key(s) head-branch" parses "$TMP/branch.yml"
cat >"$TMP/toplist.yml" <<'EOF'
- scope:x
EOF
check "parse: non-map top level is refused" 5 \
"top level must be a map" parses "$TMP/toplist.yml"
cat >"$TMP/backslash.yml" <<'EOF'
scope:x:
- changed-files:
- any-glob-to-any-file: ["a\\b/**"]
EOF
check "parse: backslash escapes are refused" 5 \
"backslash in glob" parses "$TMP/backslash.yml"
elif [ -n "${CEREMONY_REQUIRE_YQ:-}" ]; then
echo "FAIL: CEREMONY_REQUIRE_YQ is set but yq is missing — the config parse cases did not run"
FAIL=$((FAIL + 1))
else
echo "SKIP: yq not found — parse_labeler_config cases not exercised"
fi
summary