ceremony/actions/labels-scope/labels-scope.sh
cluade-reviewer-andresmgsl 9db8317543 fix(labels-scope): jq 1.6 cannot parse $label — the runner image ships 1.6
Found by running ceremony's own CI at 9357f09 on a real Forgejo runner
rather than reasoning about it.

`label` is a reserved word in jq's grammar (`label $out | … | break $out`),
so jq **1.6** rejects `$label` outright:

  jq: error: syntax error, unexpected label, expecting IDENT

jq 1.7 parses it, which is why this survived: GitHub's hosted ubuntu-latest
ships 1.7, and ghcr.io/catthehacker/ubuntu:act-22.04 — the image this
instance maps ubuntu-latest to — ships 1.6. So parse_labeler_config died on
a compile error before it read a byte of config, and EVERY scope derivation
on this forge failed. Renamed to $lbl in the jq program only; the bash
locals keep their names.

Also makes test/forge.test.sh hermetic. Its "github + gh passes" case
depended on gh being on the HOST's PATH, so it passed on a developer box and
failed in the runner image, which has no gh. The preflight cases now run
against stub binaries, and the missing-binary refusal gets its own arm on a
PATH carrying the shell and text tools but no clients — the condition under
test, rather than whatever the machine happens to have.

Verified in both environments: local (jq 1.7, gh present) and the runner
image (jq 1.6, no gh) — shellcheck 0, 22 files 0 failed in each.

Refs #188
2026-08-02 20:34:02 +00:00

185 lines
7.9 KiB
Bash
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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
# shellcheck source=lib/forge.sh
. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/../../lib/forge.sh"
# 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 → "label<TAB>glob" lines
# The jq variable is $lbl, not $label: **`label` is a reserved keyword in
# jq's grammar** (`label $out | ... | break $out`), and jq 1.6 refuses
# `$label` outright — "syntax error, unexpected label, expecting IDENT".
# jq 1.7 parses it, which is why this survived: GitHub's hosted
# ubuntu-latest ships 1.7, and the Forgejo runner image
# (ghcr.io/catthehacker/ubuntu:act-22.04) ships **1.6**. Measured on both,
# 2026-08-02 (#188). Every scope-label derivation on this forge failed on a
# jq compile error before the config was even read.
# 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 $lbl
| (if (.value | type) != "array" then
error("labeler config: \($lbl): rules must be a list")
else .value end)[]
| (if type != "object" then
error("labeler config: \($lbl): each rule must be a map")
else . end)
| ((keys - ["changed-files"]) as $extra
| if ($extra | length) > 0 then
error("labeler config: \($lbl): 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: \($lbl): changed-files must be a list") end)[]
| (if type != "object" then
error("labeler config: \($lbl): 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: \($lbl): 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: \($lbl): any-glob-to-any-file must be a glob or a list of globs") end)[]
| (if type != "string" then
error("labeler config: \($lbl): globs must be strings")
elif contains("\\") then
error("labeler config: \($lbl): backslash in glob \(.) — escapes are not supported (#130)")
else . end)
| [$lbl, .] | @tsv
'
}
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=$'\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() {
# See labels-reconcile's twin (#188). This action's degraded read was the
# quietest of the three: an unreadable mapping and an absent one produced
# the same "nothing to derive" no-op, so on Forgejo a PR simply got no
# scope labels and nothing said why.
# The forge is decided once, here, before anything reads the board, and
# the backend that can speak it is loaded (#188). The CEREMONY_FORGE_CLIENT
# wrapper that stood here died with the call-site port: it declared "this
# code uses gh", which stopped being true the moment every site went
# through the shim, and leaving it would have defaulted the forgejo path
# into the very client its own preflight refuses.
forge_preflight || return 1
# "" means decide from the environment; forge_select takes an explicit
# forge only in tests.
forge_select "" || return 1
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="$(forge_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="$(forge_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+=("$label"); done <<<"$labels"
run forge_labels_add "$PR_NUMBER" "${args[@]}"
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