diff --git a/.github/workflows/self-labels.yml b/.github/workflows/self-labels.yml index 9c94d39..53ba9ea 100644 --- a/.github/workflows/self-labels.yml +++ b/.github/workflows/self-labels.yml @@ -13,6 +13,8 @@ on: types: [opened, reopened, ready_for_review, converted_to_draft, synchronize, labeled, unlabeled] permissions: contents: read + checks: read # mergeability/check-rollup read for PR state + statuses: read # commit-status rollup read for PR state issues: write pull-requests: write jobs: diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d859e5..f9814a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ so entries say what changed, cite the issue, and stop. ## Unreleased +- `labels-reconcile` — grant callers private-repo check reads and warn when an entire PR sweep is blind (#95). - `labels-reconcile` — the bootstrap now retires the six GitHub defaults `LABELS.md` publishes as deleted, tolerating both an already-absent label and a refused delete (#93). - `issueflow-reconcile` — a triage-authored issue arrival stands down with exit 0 instead of killing the run before the sweep (#91). - FLEET.md — the assignee's `attention` wake: one role-independent trigger ahead of every per-role list, one acked session per demand; a spec on paper until `duty.sh` polls it (#86). diff --git a/actions/labels-reconcile/labels-reconcile.sh b/actions/labels-reconcile/labels-reconcile.sh index 8b34374..f84ed2d 100755 --- a/actions/labels-reconcile/labels-reconcile.sh +++ b/actions/labels-reconcile/labels-reconcile.sh @@ -60,6 +60,12 @@ run() { # every mutation goes through here — DRY_RUN=1 logs instead of doing if [ -n "${DRY_RUN:-}" ]; then log "DRY_RUN: $*"; else "$@"; fi } +blind_sweep_warning() { # $1 = unreadable PRs, $2 = all open PRs + if [ "$2" -gt 0 ] && [ "$1" -eq "$2" ]; then + echo "::warning::labels: every open PR was unreadable; grant checks: read and statuses: read in the caller (private repos do not imply them)" + fi +} + load_config() { # $1 = consumer labels.conf; panel is mandatory, scopes optional local conf="$1" line panel_seen=false [ -f "$conf" ] || { @@ -592,9 +598,13 @@ main() { REPO_LABELS="$(gh label list -R "$REPO" --limit 200 --json name --jq '.[].name' 2>/dev/null || echo "")" [ -z "$REPO_LABELS" ] && log "WARNING: could not read the label set — applying labels unfiltered" - local n - for n in $(gh pr list -R "$REPO" --state open --limit 100 --json number --jq '.[].number'); do - ( + local n output status total=0 unreadable=0 + while IFS= read -r n; do + [ -n "$n" ] || continue + total=$((total + 1)) + status=0 + output="$( + ( PR_JSON="$(gh api "repos/$REPO/pulls/$n")" DRAFT="$(jq -r '.draft' <<<"$PR_JSON")" AUTHOR="$(jq -r '.user.login' <<<"$PR_JSON")" @@ -623,8 +633,16 @@ main() { exit 0 fi reconcile_pr "$n" - ) || log "#$n: reconcile failed — continuing with the remaining PRs" - done + ) 2>&1 + )" || status=$? + [ -n "$output" ] && printf '%s\n' "$output" + if grep -qxF "labels: #$n: could not read mergeability/checks — left alone this pass" <<<"$output"; then + unreadable=$((unreadable + 1)) + elif [ "$status" -ne 0 ]; then + log "#$n: reconcile failed — continuing with the remaining PRs" + fi + done < <(gh pr list -R "$REPO" --state open --limit 100 --json number --jq '.[].number') + blind_sweep_warning "$unreadable" "$total" log "reconciled." } diff --git a/docs/CONSUMERS.md b/docs/CONSUMERS.md index 38077fd..5df1893 100644 --- a/docs/CONSUMERS.md +++ b/docs/CONSUMERS.md @@ -256,6 +256,8 @@ on: types: [opened, labeled, unlabeled, assigned, unassigned, closed] permissions: contents: read + checks: read # mergeability/check-rollup read for PR state + statuses: read # commit-status rollup read for PR state issues: write pull-requests: write jobs: @@ -263,6 +265,11 @@ jobs: uses: heavy-duty/ceremony/.github/workflows/labels.yml@ ``` +Naming any permission sets every unnamed permission to `none`. Public +repositories allow check data to be read regardless, but a private consumer +needs both explicit reads above; without them the failure appears as an empty +`state:*` axis on the board rather than a red workflow run. + The `issues:` trigger is **unreleased** and is not in `0.1.0`. A consumer pinned to `0.1.0` omits it. Add it only when bumping every ceremony reference to the first tag carrying ceremony#32; never mix refs to adopt it early. diff --git a/test/labels-reconcile.test.sh b/test/labels-reconcile.test.sh index 8001ccc..724c800 100755 --- a/test/labels-reconcile.test.sh +++ b/test/labels-reconcile.test.sh @@ -38,6 +38,18 @@ rev() { # $1=login $2=state $3=commit $4=body $5=submitted_at → one review obj reviews() { jq -s '.' <<<"$*"; } # collect review objects into an array +# -- a sweep-wide read failure is visible without changing any PR ------------ +warning="$(blind_sweep_warning 3 3)" +expect "a wholly blind sweep warns" \ + "::warning::labels: every open PR was unreadable; grant checks: read and statuses: read in the caller (private repos do not imply them)" \ + "$warning" +expect "the blind warning names checks: read" named \ + "$(grep -qF "checks: read" <<<"$warning" && echo named || echo missing)" +expect "the blind warning names statuses: read" named \ + "$(grep -qF "statuses: read" <<<"$warning" && echo named || echo missing)" +expect "a partially blind sweep does not warn" "" "$(blind_sweep_warning 1 3)" +expect "a sweep with no open PRs does not warn" "" "$(blind_sweep_warning 0 0)" + # -- drafts are building, whoever is requested -------------------------------- DRAFT=true HEAD_SHA=head1 REQUESTED="" REVIEWS_JSON='[]' expect "draft PR is building" state:building "$(decide_state)" @@ -592,6 +604,40 @@ expect "exactly one nudge across both sweeps" \ expect "no label edit across both sweeps names the ruling flag" \ no "$(grep -q 'needs-ruling' "$RTMP/edits" 2>/dev/null && echo yes || echo no)" +# -- the sweep wiring observes the existing per-PR skip without writing ------- +blind_main_probe() { + ( + GITHUB_EVENT_NAME=schedule + REPO=owner/repo + LABELS_CONF=.github/labels.conf + gh() { + if [ "$1" = label ] && [ "$2" = list ]; then + printf 'state:building\nstate:addressing\n' + elif [ "$1" = pr ] && [ "$2" = list ]; then + printf '101\n102\n' + elif [ "$1" = pr ] && [ "$2" = view ]; then + printf '{}\n' + elif [ "$1" = api ] && [[ "$*" = *"/reviews"* ]]; then + return 0 + elif [ "$1" = api ]; then + jq -n --arg n "${*: -1}" \ + '{draft:false,user:{login:"author"},head:{sha:"head"},labels:[],requested_reviewers:[],created_at:"2026-07-23T00:00:00Z"}' + elif [ "$1" = issue ] && [ "$2" = edit ]; then + printf 'MUTATION: %s\n' "$*" + fi + } + main + ) +} + +blind_main="$(blind_main_probe)" +expect "a wholly blind main sweep emits one actionable annotation" 1 \ + "$(grep -c '^::warning::.*checks: read.*statuses: read' <<<"$blind_main")" +expect "a wholly blind main sweep leaves every PR untouched" no \ + "$(grep -q '^MUTATION:' <<<"$blind_main" && echo yes || echo no)" +expect "the existing per-PR skip still runs for every blind PR" 2 \ + "$(grep -c 'could not read mergeability/checks — left alone this pass' <<<"$blind_main")" + # --------------------------------------------------------------------------- # bootstrap_labels retires the GitHub defaults (#93). LABELS.md published # them as deleted at bootstrap; nothing deleted them — incubator's first @@ -754,6 +800,5 @@ for ev in schedule pull_request_target; do expect "...and deletes nothing" \ no "$(grep -q '^delete ' "$EXEC/record" && echo yes || echo no)" done - printf 'labels-reconcile tests: %d passed, %d failed\n' "$pass" "$fail" [ "$fail" -eq 0 ]