diff --git a/.github/workflows/labels.yml b/.github/workflows/labels.yml index 0c69c8a..bf512bd 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 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..538652b --- /dev/null +++ b/actions/labels-scope/labels-scope.sh @@ -0,0 +1,158 @@ +#!/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="${#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="$(printf '\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