2026-07-24 11:51:48 +00:00
|
|
|
|
#!/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
|
|
|
|
|
|
|
feat(forge): refuse loudly when the client cannot speak the forge
The preflight half of #188, landed first so it stands alone: the forge is
decided once, before any sweep, and a client that cannot speak it exits
non-zero with a named reason.
Measured against forgejo.heavyduty.builders at 84bb1a4 — two of the three
actions reported SUCCESS having read nothing:
labels-scope exit 0 "no .github/labeler.yml" (the file is HTTP 200)
labels-reconcile exit 0 "reconciled." (zero PRs enumerated)
issueflow-reconcile exit 1 "unexpected end of JSON input"
labels-reconcile's blind-sweep warning (#96) could not fire: it counts
unreadable PRs against a list `gh pr list` never produced, and a process
substitution's failure does not trip set -e, so total stayed 0. Installing
gh makes it worse, silencing the one loud failure.
Detection is measured, not inferred from docs: a real forgejo-runner v6.3.1
job (probe task 278) shows Forgejo populating the whole GITHUB_* namespace,
so GITHUB_ACTIONS proves nothing. GITHUB_API_URL's shape, GITEA_ACTIONS and
GITHUB_SERVER_URL do. The same probe shows the runner image carries neither
gh nor stoke, which is what makes the forgejo backend REST.
Tests declare CEREMONY_FORGE at the forge boundary rather than stubbing gh
and staying silent about the forge — the boundary move term 5 asks for.
Refs #188
2026-08-02 18:29:08 +00:00
|
|
|
|
# shellcheck source=lib/forge.sh
|
|
|
|
|
|
. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/../../lib/forge.sh"
|
|
|
|
|
|
|
2026-07-24 11:51:48 +00:00
|
|
|
|
# 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
|
2026-07-24 11:54:49 +00:00
|
|
|
|
local glob="$1" out="" c i=0 n
|
|
|
|
|
|
n="${#glob}"
|
2026-07-24 11:51:48 +00:00
|
|
|
|
while [ "$i" -lt "$n" ]; do
|
|
|
|
|
|
c="${glob:i:1}"
|
|
|
|
|
|
case "$c" in
|
|
|
|
|
|
\*)
|
|
|
|
|
|
if [ "${glob:i:2}" = '**' ]; then
|
|
|
|
|
|
out="$out.*"
|
|
|
|
|
|
i=$((i + 2))
|
|
|
|
|
|
continue
|
|
|
|
|
|
fi
|
2026-07-24 11:57:10 +00:00
|
|
|
|
out="${out}[^/]*"
|
2026-07-24 11:51:48 +00:00
|
|
|
|
;;
|
2026-07-24 11:57:10 +00:00
|
|
|
|
\?) out="${out}[^/]" ;;
|
2026-07-24 11:51:48 +00:00
|
|
|
|
[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
|
|
|
|
|
|
# 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 = "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
|
2026-07-24 11:54:49 +00:00
|
|
|
|
while IFS=$'\t' read -r label glob; do
|
2026-07-24 11:51:48 +00:00
|
|
|
|
[ -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() {
|
feat(forge): refuse loudly when the client cannot speak the forge
The preflight half of #188, landed first so it stands alone: the forge is
decided once, before any sweep, and a client that cannot speak it exits
non-zero with a named reason.
Measured against forgejo.heavyduty.builders at 84bb1a4 — two of the three
actions reported SUCCESS having read nothing:
labels-scope exit 0 "no .github/labeler.yml" (the file is HTTP 200)
labels-reconcile exit 0 "reconciled." (zero PRs enumerated)
issueflow-reconcile exit 1 "unexpected end of JSON input"
labels-reconcile's blind-sweep warning (#96) could not fire: it counts
unreadable PRs against a list `gh pr list` never produced, and a process
substitution's failure does not trip set -e, so total stayed 0. Installing
gh makes it worse, silencing the one loud failure.
Detection is measured, not inferred from docs: a real forgejo-runner v6.3.1
job (probe task 278) shows Forgejo populating the whole GITHUB_* namespace,
so GITHUB_ACTIONS proves nothing. GITHUB_API_URL's shape, GITEA_ACTIONS and
GITHUB_SERVER_URL do. The same probe shows the runner image carries neither
gh nor stoke, which is what makes the forgejo backend REST.
Tests declare CEREMONY_FORGE at the forge boundary rather than stubbing gh
and staying silent about the forge — the boundary move term 5 asks for.
Refs #188
2026-08-02 18:29:08 +00:00
|
|
|
|
# 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.
|
feat(forge): port every reconciler call site onto the shim
Term 1 completed. All 52 runtime gh call sites in the three reconcilers and
lib/ruling.sh now go through forge_* verbs; the three remaining matches in
labels-reconcile are prose in comments. lib/facts.sh is deliberately
untouched — it is the release door, and the ruling keeps release.yml out of
this issue.
The CEREMONY_FORGE_CLIENT:-gh wrappers die here, in the same commit as the
sites they described, so the tree is never in a state where the declaration
lies. main() now runs forge_preflight then forge_select "".
Two sites needed judgment rather than substitution:
- labels-scope's write is forge_labels_add, a genuine additive POST on
both backends, NOT forge_issue_edit --add-label. ceremony#128 turns on
that write not being a read-modify-PUT: the labeler action computed
(labels-at-job-start union derived) and PUT the whole set, silently
dropping a label applied while the job ran. Routing it through a generic
edit verb would have quietly reopened that.
- the human-review request is forge_request_reviewer. Contrary to my
earlier reading, POST /pulls/{n}/requested_reviewers DOES exist on
Forgejo — 422 naming the reviewer's access without it, 201 with it. The
earlier 404 was a GET, which the endpoint does not serve, plus a
username that did not exist.
Test churn, all of it the term-5 boundary move:
- the suites select the github backend, so their existing gh() stubs stay
the boundary and keep intercepting;
- stubs strip the paging the shim injects, so fixtures stay keyed on the
logical endpoint (inlined in the PATH stub, which is a standalone
executable and cannot see a shell function);
- fixtures renamed off the per_page suffix for the same reason;
- recorded-mutation assertions now match the verb, not the raw gh line;
- gh() stubs carry SC2317: they are reached through the backend now, so
shellcheck can no longer see the call path.
Refs #188
2026-08-02 19:43:58 +00:00
|
|
|
|
# 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
|
feat(forge): refuse loudly when the client cannot speak the forge
The preflight half of #188, landed first so it stands alone: the forge is
decided once, before any sweep, and a client that cannot speak it exits
non-zero with a named reason.
Measured against forgejo.heavyduty.builders at 84bb1a4 — two of the three
actions reported SUCCESS having read nothing:
labels-scope exit 0 "no .github/labeler.yml" (the file is HTTP 200)
labels-reconcile exit 0 "reconciled." (zero PRs enumerated)
issueflow-reconcile exit 1 "unexpected end of JSON input"
labels-reconcile's blind-sweep warning (#96) could not fire: it counts
unreadable PRs against a list `gh pr list` never produced, and a process
substitution's failure does not trip set -e, so total stayed 0. Installing
gh makes it worse, silencing the one loud failure.
Detection is measured, not inferred from docs: a real forgejo-runner v6.3.1
job (probe task 278) shows Forgejo populating the whole GITHUB_* namespace,
so GITHUB_ACTIONS proves nothing. GITHUB_API_URL's shape, GITEA_ACTIONS and
GITHUB_SERVER_URL do. The same probe shows the runner image carries neither
gh nor stoke, which is what makes the forgejo backend REST.
Tests declare CEREMONY_FORGE at the forge boundary rather than stubbing gh
and staying silent about the forge — the boundary move term 5 asks for.
Refs #188
2026-08-02 18:29:08 +00:00
|
|
|
|
|
2026-07-24 11:51:48 +00:00
|
|
|
|
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.
|
feat(forge): port every reconciler call site onto the shim
Term 1 completed. All 52 runtime gh call sites in the three reconcilers and
lib/ruling.sh now go through forge_* verbs; the three remaining matches in
labels-reconcile are prose in comments. lib/facts.sh is deliberately
untouched — it is the release door, and the ruling keeps release.yml out of
this issue.
The CEREMONY_FORGE_CLIENT:-gh wrappers die here, in the same commit as the
sites they described, so the tree is never in a state where the declaration
lies. main() now runs forge_preflight then forge_select "".
Two sites needed judgment rather than substitution:
- labels-scope's write is forge_labels_add, a genuine additive POST on
both backends, NOT forge_issue_edit --add-label. ceremony#128 turns on
that write not being a read-modify-PUT: the labeler action computed
(labels-at-job-start union derived) and PUT the whole set, silently
dropping a label applied while the job ran. Routing it through a generic
edit verb would have quietly reopened that.
- the human-review request is forge_request_reviewer. Contrary to my
earlier reading, POST /pulls/{n}/requested_reviewers DOES exist on
Forgejo — 422 naming the reviewer's access without it, 201 with it. The
earlier 404 was a GET, which the endpoint does not serve, plus a
username that did not exist.
Test churn, all of it the term-5 boundary move:
- the suites select the github backend, so their existing gh() stubs stay
the boundary and keep intercepting;
- stubs strip the paging the shim injects, so fixtures stay keyed on the
logical endpoint (inlined in the PATH stub, which is a standalone
executable and cannot see a shell function);
- fixtures renamed off the per_page suffix for the same reason;
- recorded-mutation assertions now match the verb, not the raw gh line;
- gh() stubs carry SC2317: they are reached through the backend now, so
shellcheck can no longer see the call path.
Refs #188
2026-08-02 19:43:58 +00:00
|
|
|
|
if ! config="$(forge_api "repos/$REPO/contents/$CONFIG_PATH?ref=$CONFIG_REF" \
|
2026-07-24 11:51:48 +00:00
|
|
|
|
--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")"
|
feat(forge): port every reconciler call site onto the shim
Term 1 completed. All 52 runtime gh call sites in the three reconcilers and
lib/ruling.sh now go through forge_* verbs; the three remaining matches in
labels-reconcile are prose in comments. lib/facts.sh is deliberately
untouched — it is the release door, and the ruling keeps release.yml out of
this issue.
The CEREMONY_FORGE_CLIENT:-gh wrappers die here, in the same commit as the
sites they described, so the tree is never in a state where the declaration
lies. main() now runs forge_preflight then forge_select "".
Two sites needed judgment rather than substitution:
- labels-scope's write is forge_labels_add, a genuine additive POST on
both backends, NOT forge_issue_edit --add-label. ceremony#128 turns on
that write not being a read-modify-PUT: the labeler action computed
(labels-at-job-start union derived) and PUT the whole set, silently
dropping a label applied while the job ran. Routing it through a generic
edit verb would have quietly reopened that.
- the human-review request is forge_request_reviewer. Contrary to my
earlier reading, POST /pulls/{n}/requested_reviewers DOES exist on
Forgejo — 422 naming the reviewer's access without it, 201 with it. The
earlier 404 was a GET, which the endpoint does not serve, plus a
username that did not exist.
Test churn, all of it the term-5 boundary move:
- the suites select the github backend, so their existing gh() stubs stay
the boundary and keep intercepting;
- stubs strip the paging the shim injects, so fixtures stay keyed on the
logical endpoint (inlined in the PATH stub, which is a standalone
executable and cannot see a shell function);
- fixtures renamed off the per_page suffix for the same reason;
- recorded-mutation assertions now match the verb, not the raw gh line;
- gh() stubs carry SC2317: they are reached through the backend now, so
shellcheck can no longer see the call path.
Refs #188
2026-08-02 19:43:58 +00:00
|
|
|
|
files="$(forge_api --paginate "repos/$REPO/pulls/$PR_NUMBER/files" --jq '.[].filename')"
|
2026-07-24 11:51:48 +00:00
|
|
|
|
labels="$(derive_labels "$tsv" "$files")"
|
|
|
|
|
|
|
|
|
|
|
|
if [ -z "$labels" ]; then
|
|
|
|
|
|
log "#$PR_NUMBER: no scope labels derived"
|
|
|
|
|
|
return 0
|
|
|
|
|
|
fi
|
|
|
|
|
|
local args=()
|
feat(forge): port every reconciler call site onto the shim
Term 1 completed. All 52 runtime gh call sites in the three reconcilers and
lib/ruling.sh now go through forge_* verbs; the three remaining matches in
labels-reconcile are prose in comments. lib/facts.sh is deliberately
untouched — it is the release door, and the ruling keeps release.yml out of
this issue.
The CEREMONY_FORGE_CLIENT:-gh wrappers die here, in the same commit as the
sites they described, so the tree is never in a state where the declaration
lies. main() now runs forge_preflight then forge_select "".
Two sites needed judgment rather than substitution:
- labels-scope's write is forge_labels_add, a genuine additive POST on
both backends, NOT forge_issue_edit --add-label. ceremony#128 turns on
that write not being a read-modify-PUT: the labeler action computed
(labels-at-job-start union derived) and PUT the whole set, silently
dropping a label applied while the job ran. Routing it through a generic
edit verb would have quietly reopened that.
- the human-review request is forge_request_reviewer. Contrary to my
earlier reading, POST /pulls/{n}/requested_reviewers DOES exist on
Forgejo — 422 naming the reviewer's access without it, 201 with it. The
earlier 404 was a GET, which the endpoint does not serve, plus a
username that did not exist.
Test churn, all of it the term-5 boundary move:
- the suites select the github backend, so their existing gh() stubs stay
the boundary and keep intercepting;
- stubs strip the paging the shim injects, so fixtures stay keyed on the
logical endpoint (inlined in the PATH stub, which is a standalone
executable and cannot see a shell function);
- fixtures renamed off the per_page suffix for the same reason;
- recorded-mutation assertions now match the verb, not the raw gh line;
- gh() stubs carry SC2317: they are reached through the backend now, so
shellcheck can no longer see the call path.
Refs #188
2026-08-02 19:43:58 +00:00
|
|
|
|
while IFS= read -r label; do args+=("$label"); done <<<"$labels"
|
|
|
|
|
|
run forge_labels_add "$PR_NUMBER" "${args[@]}"
|
2026-07-24 11:51:48 +00:00
|
|
|
|
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
|