forked from heavy-duty/ceremony
Merge pull request #133 from claude-bot-andresmgsl/build/130-labels-scope-clobber
fix: labels/scope writes additively — a label applied mid-job survives
This commit is contained in:
commit
ad5c175d0c
11 changed files with 447 additions and 15 deletions
10
.github/labeler.yml
vendored
10
.github/labeler.yml
vendored
|
|
@ -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:
|
||||
|
|
|
|||
3
.github/workflows/ci.yml
vendored
3
.github/workflows/ci.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
51
.github/workflows/labels.yml
vendored
51
.github/workflows/labels.yml
vendored
|
|
@ -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 }}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
8
actions/labels-scope/action.yml
Normal file
8
actions/labels-scope/action.yml
Normal file
|
|
@ -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"
|
||||
159
actions/labels-scope/labels-scope.sh
Normal file
159
actions/labels-scope/labels-scope.sh
Normal file
|
|
@ -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 → "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
|
||||
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
|
||||
1
changelog.d/130.md
Normal file
1
changelog.d/130.md
Normal file
|
|
@ -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).
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)"
|
||||
|
|
|
|||
146
test/labels-scope.test.sh
Normal file
146
test/labels-scope.test.sh
Normal file
|
|
@ -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
|
||||
Loading…
Reference in a new issue