diff --git a/.github/labeler.yml b/.github/labeler.yml index 9271aa4..1859654 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -1,9 +1,11 @@ # Path → scope:* mapping for the labels workflow's scope job -# (actions/labeler@v5; additive only — the reusable workflow keeps -# sync-labels off, so a hand-applied scope survives the machine). The scope +# (actions/labels-scope since #130: it reads this mapping at the BASE ref +# and its only write is an additive POST, so a label applied while the job +# runs survives the machine — its predecessor, actions/labeler@v5, PUT the +# whole set and clobbered ceremony#128's `release` mid-job). The scope # taxonomy itself lives in .github/labels.conf; LABELS.md carries the table # these globs implement. Scopes locate, they do not alert — a path that maps -# to nothing is fine (labeler is advisory), so these rows chase the big +# to nothing is fine (the mapping is advisory), so these rows chase the big # surfaces, not every file. scope:release-flow: - changed-files: @@ -41,9 +43,11 @@ scope:labels: - .github/labeler.yml - .github/labels.conf - actions/labels-reconcile/** + - actions/labels-scope/** - LABELS.md - test/labels.test.sh - test/labels-reconcile.test.sh + - test/labels-scope.test.sh scope:docs: - changed-files: - any-glob-to-any-file: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ff863ef..37a6882 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.github/workflows/labels.yml b/.github/workflows/labels.yml index 0cea584..71a7155 100644 --- a/.github/workflows/labels.yml +++ b/.github/workflows/labels.yml @@ -5,8 +5,9 @@ name: labels # The caller uses pull_request_target, not pull_request: every PR in this # family arrives from a fork, where pull_request runs with a READ-ONLY token # and cannot label anything. _target is safe in this workflow because no PR -# code is ever checked out or executed — labeler reads changed paths via the -# API, and reconcile checks out the BASE branch only. Keep it that way. +# code is ever checked out or executed — scope reads changed paths and the +# path mapping via the API and checks out only the ceremony implementation, +# and reconcile checks out the BASE branch only. Keep it that way. # # There is no pull_request_review_target, so a review landing cannot wake this # workflow directly — and the */15 cron is advisory: GitHub deprioritises @@ -28,7 +29,7 @@ env: jobs: scope: - # Not on labeled/unlabeled: those events change no paths, so labeler has + # Not on labeled/unlabeled: those events change no paths, so scope has # nothing new to derive — and label churn is precisely what they are. if: >- github.event_name == 'pull_request_target' && @@ -39,11 +40,41 @@ jobs: group: labels-scope-${{ github.event.pull_request.number }} cancel-in-progress: true steps: - - uses: actions/labeler@v5 + # actions/labeler@v5 held this seat until #130. Even with + # sync-labels: false it wrote the WHOLE label set — PUT of + # (labels-fetched-at-job-start ∪ derived) — so a label applied while + # the job ran was silently removed: ceremony#128 lost its `release`, + # the merge door's declared-intent read, two seconds after the + # builder set it. v6/v7 write the same way, so the step was replaced + # rather than repinned. labels-scope reads the consumer's + # .github/labeler.yml and the changed paths via the API, and its + # only write is an additive POST of the derived scopes: a label + # applied mid-job survives by construction. + # + # Still no PR code: both checkouts below fetch the ceremony + # implementation only. The dogfood checkout rides github.sha — the + # base-branch commit the workflow file itself came from, so the + # script and workflow can never skew — and doubles as the #11 + # bootstrap: ceremony's own labels must work before any release tag + # exists for the pinned checkout to fetch. + - uses: actions/checkout@v4 + if: github.repository == 'heavy-duty/ceremony' with: - # labeler reads the consumer's .github/labeler.yml via the API - # additive only — a hand-applied scope must survive the machine - sync-labels: false + repository: ${{ github.repository }} + ref: ${{ github.sha }} + - uses: actions/checkout@v4 + if: github.repository != 'heavy-duty/ceremony' + with: + repository: heavy-duty/ceremony + ref: ${{ env.CEREMONY_SELF_REF }} + - uses: ./actions/labels-scope + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + PR_NUMBER: ${{ github.event.pull_request.number }} + # the BASE branch commit — a PR must not label itself by editing + # the mapping it is judged by + CONFIG_REF: ${{ github.sha }} reconcile: runs-on: ubuntu-latest @@ -55,9 +86,9 @@ jobs: cancel-in-progress: false steps: # pull_request_target is required for fork PR write permission. It is - # safe here because no PR code is ever checked out or executed: labeler - # reads paths via the API, and reconcile checks out the BASE branch only. - # Keep it that way. + # safe here because no PR code is ever checked out or executed: + # labels-scope reads the mapping and changed paths via the API, and + # reconcile checks out the BASE branch only. Keep it that way. - uses: actions/checkout@v4 with: repository: ${{ github.repository }} diff --git a/LABELS.md b/LABELS.md index fc5830b..2db8ba5 100644 --- a/LABELS.md +++ b/LABELS.md @@ -170,8 +170,10 @@ re-arms mentions, but only the writer can declare that a move is owed (#83). ## Scope — which surface? (PRs and issues, any number) All scopes share one calm color, `#C5DEF5` — scopes locate, states alert. The -set is per-repo: PRs get theirs from changed paths via actions/labeler, issues -get theirs from triage. This file never enumerates a set — it is mirrored +set is per-repo: PRs get theirs from changed paths via the labels workflow's +scope job — an additive write only, so a label applied by hand or by an agent +while the machine runs always survives it (#130) — and issues get theirs from +triage. This file never enumerates a set — it is mirrored byte-identically into every governed repo, and any list it carried would be true in one repo and false in the rest (#104). The set for the repo you are standing in lives in the two places that are true wherever you read them: its diff --git a/actions/labels-reconcile/labels-reconcile.sh b/actions/labels-reconcile/labels-reconcile.sh index d02e6aa..be14777 100755 --- a/actions/labels-reconcile/labels-reconcile.sh +++ b/actions/labels-reconcile/labels-reconcile.sh @@ -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:-unreadable} -> $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 diff --git a/actions/labels-scope/action.yml b/actions/labels-scope/action.yml new file mode 100644 index 0000000..2e47cd6 --- /dev/null +++ b/actions/labels-scope/action.yml @@ -0,0 +1,8 @@ +name: Derive scope labels +description: Additively apply path-derived scope:* labels to a PR — the only write is POST (issue #130) +runs: + using: composite + steps: + - name: derive and add scope labels + shell: bash + run: bash "$GITHUB_ACTION_PATH/labels-scope.sh" diff --git a/actions/labels-scope/labels-scope.sh b/actions/labels-scope/labels-scope.sh new file mode 100644 index 0000000..b8a4544 --- /dev/null +++ b/actions/labels-scope/labels-scope.sh @@ -0,0 +1,159 @@ +#!/usr/bin/env bash +if [ "${BASH_SOURCE[0]}" = "$0" ]; then + set -euo pipefail +else + # Fixture tests source the pure functions and deliberately inspect failures. + set -u +fi + +# labels-scope.sh — the additive half of the labels automation: derive +# scope:* labels from a PR's changed paths and ADD them, touching nothing +# else. This seat belonged to actions/labeler@v5 until #130: even under +# `sync-labels: false`, labeler computes (labels-fetched-at-job-start ∪ +# derived) and writes it back with `PUT /issues/{n}/labels` +# (src/labeler.ts: api.setLabels — a full replace), so any label applied +# between its read and its write is silently removed. On ceremony#128 the +# builder's `release` — the merge door's declared-intent read — landed in +# that window and vanished two seconds later; v6 and v7 write the same +# way, so the fix is this replacement, not a newer pin. +# +# The only write here is `POST /issues/{n}/labels`: GitHub adds the named +# labels, ignores ones already present, and removes nothing. A label +# applied while this runs survives by construction. +# +# The path mapping stays in the consumer's .github/labeler.yml, read via +# the API at CONFIG_REF — the base branch, never the PR head, so a PR +# cannot label itself by editing the mapping. The accepted shape is the +# one every governed repo uses: +# +# scope:name: +# - changed-files: +# - any-glob-to-any-file: ["glob", ...] +# +# in any YAML spelling (block or flow; a glob list may be a single +# string). Anything else — all-globs-to-all-files, branch matchers, +# negations, backslash escapes — is refused loudly rather than +# half-honoured: this parser exists to make one write additive, not to +# reimplement minimatch. Globs support `**` (crosses `/`), `*` and `?` +# (do not); a leading dot is not special; the whole path must match +# (`README` matches README, never docs/README). + +log() { printf 'labels-scope: %s\n' "$*"; } + +run() { # every mutation goes through here — DRY_RUN=1 logs instead of doing + if [ -n "${DRY_RUN:-}" ]; then log "DRY_RUN: $*"; else "$@"; fi +} + +glob_to_regex() { # $1 = glob (the subset above) → anchored ERE, one line + local glob="$1" out="" c i=0 n + n="${#glob}" + while [ "$i" -lt "$n" ]; do + c="${glob:i:1}" + case "$c" in + \*) + if [ "${glob:i:2}" = '**' ]; then + out="$out.*" + i=$((i + 2)) + continue + fi + out="${out}[^/]*" + ;; + \?) out="${out}[^/]" ;; + [a-zA-Z0-9_/-]) out="$out$c" ;; + *) out="$out\\$c" ;; # every other byte is literal — ., +, {, (, … + esac + i=$((i + 1)) + done + printf '^%s$\n' "$out" +} + +parse_labeler_config() { # labeler.yml on stdin → "labelglob" lines + # yq only normalizes YAML to JSON; the shape contract is enforced in jq, + # where an unsupported key is a loud error naming the label it sits under. + yq -o=json '.' - | jq -r ' + if type != "object" then + error("labeler config: top level must be a map of label -> rules") + else . end + | to_entries[] + | .key as $label + | (if (.value | type) != "array" then + error("labeler config: \($label): rules must be a list") + else .value end)[] + | (if type != "object" then + error("labeler config: \($label): each rule must be a map") + else . end) + | ((keys - ["changed-files"]) as $extra + | if ($extra | length) > 0 then + error("labeler config: \($label): unsupported key(s) \($extra | join(", ")) — the scope job accepts changed-files/any-glob-to-any-file only (#130)") + else . end) + | .["changed-files"] + | (if type == "object" then [.] + elif type == "array" then . + else error("labeler config: \($label): changed-files must be a list") end)[] + | (if type != "object" then + error("labeler config: \($label): each changed-files entry must be a map") + else . end) + | ((keys - ["any-glob-to-any-file"]) as $extra + | if ($extra | length) > 0 then + error("labeler config: \($label): unsupported matcher(s) \($extra | join(", ")) — the scope job accepts any-glob-to-any-file only (#130)") + else . end) + | .["any-glob-to-any-file"] + | (if type == "string" then [.] + elif type == "array" then . + else error("labeler config: \($label): any-glob-to-any-file must be a glob or a list of globs") end)[] + | (if type != "string" then + error("labeler config: \($label): globs must be strings") + elif contains("\\") then + error("labeler config: \($label): backslash in glob \(.) — escapes are not supported (#130)") + else . end) + | [$label, .] | @tsv + ' +} + +derive_labels() { # $1 = "labelglob" 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=$'\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 + matched="$matched$label"$'\n' + printf '%s\n' "$label" + fi + done <<<"$tsv" +} + +main() { + REPO="${REPO:?set REPO to owner/name}" + PR_NUMBER="${PR_NUMBER:?set PR_NUMBER to the pull request number}" + CONFIG_REF="${CONFIG_REF:?set CONFIG_REF to the base commit the mapping is read at}" + CONFIG_PATH="${CONFIG_PATH:-.github/labeler.yml}" + + local config tsv files labels + # No mapping is a consumer that has not adopted scope labels — an + # advisory no-op, not a red run (scopes locate, they do not alert). A + # mapping that EXISTS but does not parse still fails loudly below. + if ! config="$(gh api "repos/$REPO/contents/$CONFIG_PATH?ref=$CONFIG_REF" \ + --jq '.content' 2>/dev/null | base64 -d)" || [ -z "$config" ]; then + log "no $CONFIG_PATH at $CONFIG_REF — nothing to derive" + return 0 + fi + tsv="$(parse_labeler_config <<<"$config")" + files="$(gh api --paginate "repos/$REPO/pulls/$PR_NUMBER/files" --jq '.[].filename')" + labels="$(derive_labels "$tsv" "$files")" + + if [ -z "$labels" ]; then + log "#$PR_NUMBER: no scope labels derived" + return 0 + fi + local args=() + while IFS= read -r label; do args+=(-f "labels[]=$label"); done <<<"$labels" + run gh api "repos/$REPO/issues/$PR_NUMBER/labels" "${args[@]}" --silent + log "#$PR_NUMBER: scopes -> $(paste -sd, <<<"$labels") (additive POST; already-present names are no-ops)" +} + +# sourced by test/labels-scope.test.sh for the fixture tests; executed in CI +if [ "${BASH_SOURCE[0]}" = "$0" ]; then + main "$@" +fi diff --git a/changelog.d/130.md b/changelog.d/130.md new file mode 100644 index 0000000..a2d35aa --- /dev/null +++ b/changelog.d/130.md @@ -0,0 +1 @@ +- `actions/labels-scope` replaces `actions/labeler@v5` in the labels workflow's scope job: labeler wrote the whole label set (`PUT`) even under `sync-labels: false`, silently removing any label applied while it ran — #128 lost its `release` that way — so the scope job now derives from the same `.github/labeler.yml` mapping (the `changed-files`/`any-glob-to-any-file` shape, block or flow; anything else refuses loudly) and its only write is an additive `POST`. The reconcile sweep also warns — never sets — when a non-draft PR is release-shaped (bare version differing from its base) but carries no `release` label (#130). diff --git a/docs/CONSUMERS.md b/docs/CONSUMERS.md index 257417a..f1017c1 100644 --- a/docs/CONSUMERS.md +++ b/docs/CONSUMERS.md @@ -276,6 +276,21 @@ never writes it). The consumer keeps its path mapping in `.github/labeler.yml` and its review panel plus scope taxonomy in `.github/labels.conf`. +**Additive means additive** (unreleased — #130): the scope job's only label +write is `POST /issues/{n}/labels`, which adds the derived scopes and removes +nothing, so a label applied while the job runs survives it. Earlier tags used +`actions/labeler@v5`, which — even under `sync-labels: false` — replaces the +whole label set and silently drops a label written mid-job (ceremony#128 lost +its `release` that way). With the same pin bump, `.github/labeler.yml` keeps +its format but the accepted shape becomes exactly the one this guide has +always shown: label → `changed-files` → `any-glob-to-any-file`, block or flow +style, globs over `**`, `*` and `?` (`**` crosses `/`, the others do not; the +whole path must match). Any other labeler key — `all-globs-to-all-files`, +branch matchers, negations — fails the run loudly instead of being +half-honoured. The reconcile sweep also warns (never sets) when a non-draft +PR carries a bare `X.Y.Z` version differing from its base but no `release` +label — the merge door would refuse that merge, and the sweep says so first. + The complete caller is: ```yaml diff --git a/test/labels-reconcile.test.sh b/test/labels-reconcile.test.sh index 4cb1c14..f31e0cc 100755 --- a/test/labels-reconcile.test.sh +++ b/test/labels-reconcile.test.sh @@ -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)" diff --git a/test/labels-scope.test.sh b/test/labels-scope.test.sh new file mode 100644 index 0000000..d34687f --- /dev/null +++ b/test/labels-scope.test.sh @@ -0,0 +1,146 @@ +#!/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' +# shellcheck disable=SC2016 # expansion belongs to the nested bash +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" +# shellcheck disable=SC2016 # expansion belongs to the nested bash +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" +# shellcheck disable=SC2016 # expansion belongs to the nested bash +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" + + # the real mapping covers this implementation's own surface (#133 round): + # a PR touching only labels-scope must still derive scope:labels, like + # the neighboring labels-reconcile rows already did + real_rows="$(parses "$ROOT/.github/labeler.yml")" + check "derive: the real mapping labels a labels-scope-only change" 0 \ + "scope:labels" derive_labels "$real_rows" 'actions/labels-scope/labels-scope.sh' + check "derive: the real mapping labels this test file" 0 \ + "scope:labels" derive_labels "$real_rows" 'test/labels-scope.test.sh' + + # 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