diff --git a/.github/workflows/labels.yml b/.github/workflows/labels.yml index 0cea584..034b74e 100644 --- a/.github/workflows/labels.yml +++ b/.github/workflows/labels.yml @@ -30,10 +30,16 @@ jobs: scope: # Not on labeled/unlabeled: those events change no paths, so labeler has # nothing new to derive — and label churn is precisely what they are. + # review_requested/review_request_removed likewise change no paths — they + # exist to wake reconcile (#137) — and running labeler on them widens + # exactly the window #130 documents, where a label written during a + # scope run is clobbered. if: >- github.event_name == 'pull_request_target' && github.event.action != 'labeled' && - github.event.action != 'unlabeled' + github.event.action != 'unlabeled' && + github.event.action != 'review_requested' && + github.event.action != 'review_request_removed' runs-on: ubuntu-latest concurrency: group: labels-scope-${{ github.event.pull_request.number }} diff --git a/.github/workflows/self-labels.yml b/.github/workflows/self-labels.yml index 53ba9ea..e9b8dac 100644 --- a/.github/workflows/self-labels.yml +++ b/.github/workflows/self-labels.yml @@ -10,7 +10,11 @@ on: issues: types: [opened, edited, assigned, unassigned, labeled, unlabeled, closed, reopened] pull_request_target: - types: [opened, reopened, ready_for_review, converted_to_draft, synchronize, labeled, unlabeled] + # review_requested/review_request_removed wake the sweep that clears (or + # restores) blocker:unrequested — without them the one event that makes + # the label false could not clear it, and a quiet repo wore the red flag + # until the advisory cron (#137). + types: [opened, reopened, ready_for_review, converted_to_draft, synchronize, labeled, unlabeled, review_requested, review_request_removed] permissions: contents: read checks: read # mergeability/check-rollup read for PR state diff --git a/changelog.d/134.md b/changelog.d/134.md deleted file mode 100644 index d9d334c..0000000 --- a/changelog.d/134.md +++ /dev/null @@ -1 +0,0 @@ -- `lib/facts.sh` — a repository's first push to `main` (a root commit with no first parent) now reads `base_ver=(none)` and lets decide's table govern, instead of dying at exit 128 before establishing a fact; the no-base path skips the base fetch and `git show`, and an unresolvable head still fails loudly (#134). diff --git a/changelog.d/137.md b/changelog.d/137.md new file mode 100644 index 0000000..f4980d4 --- /dev/null +++ b/changelog.d/137.md @@ -0,0 +1,10 @@ +### Fixed + +- `blocker:unrequested` now clears the moment the panel is asked: the labels + caller (and the `docs/CONSUMERS.md` stub) listens on `review_requested` and + `review_request_removed`, so the one event that falsifies the label — or + makes it true again — wakes the reconcile sweep instead of waiting for an + unrelated push or the advisory cron. The `scope` job skips both events: + they change no paths, and running the labeler on them widens the #130 + clobber window. Adopting the new triggers is a stub edit riding the pin + bump to the first tag carrying this change (#137). diff --git a/docs/CONSUMERS.md b/docs/CONSUMERS.md index 5ad6ee8..92cec78 100644 --- a/docs/CONSUMERS.md +++ b/docs/CONSUMERS.md @@ -284,8 +284,12 @@ on: schedule: [{cron: "*/15 * * * *"}] # advisory; the handoff label is the real wake workflow_dispatch: # bootstraps missing labels on a fresh repo pull_request_target: - types: [opened, reopened, ready_for_review, converted_to_draft, synchronize, labeled, unlabeled] - # Unreleased — not in 0.1.0; add only with the first tag carrying ceremony#32. + # review_requested/review_request_removed are unreleased — not in 0.2.0; + # add them with the pin bump to the first tag carrying ceremony#137. They + # wake the sweep that clears blocker:unrequested when the panel is asked. + types: [opened, reopened, ready_for_review, converted_to_draft, synchronize, labeled, unlabeled, review_requested, review_request_removed] + # Available at 0.2.0 and later (the first tag carrying ceremony#32); a + # consumer pinned to 0.1.0 omits this block. issues: types: [opened, labeled, unlabeled, assigned, unassigned, closed] permissions: @@ -304,15 +308,21 @@ 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. +The `issues:` trigger is available at `0.2.0` and later — `0.2.0` is the +first tag carrying ceremony#32. A consumer pinned to `0.1.0` omits it. Adopt +it only by bumping every ceremony reference to `0.2.0` or later; never mix +refs to adopt it early. `pull_request_target` is intentional: fork PRs need the base repository's token to write labels. The reusable workflow executes no PR code. It checks out only the consumer's base branch and the pinned ceremony implementation. -The #52 ruling invariants ride exactly these triggers — the caller above is -unchanged since #18, so adopting them is a pin bump, not a stub edit. +The #52 ruling invariants ride exactly these triggers — but the caller above +is no longer the #18 shape, so adopting current triggers is a stub edit, not +a bare pin bump. The pending edit is `review_requested` and +`review_request_removed` (#137): the wake that clears `blocker:unrequested` +the moment the panel is asked, without which a quiet repo wears that flag +until the advisory cron. Make that edit with the pin bump to the first tag +carrying ceremony#137 — never before it and never through mixed refs. `.github/labels.conf` has one mandatory panel setting, one mandatory `triage-actors` setting, and then zero or more scope rows: @@ -324,11 +334,11 @@ scope:cli|C5DEF5|The command-line surface scope:docs|C5DEF5|Documentation ``` -The mandatory `triage-actors=` setting is also **unreleased** and is not -accepted by `0.1.0`. At that tag the file contains `panel=` plus scope rows -only; adding `triage-actors=` is a parse failure, not an ignored setting. Add -it at the same pin bump as the `issues:` trigger, to the first tag carrying -ceremony#32 — never before it and never through mixed refs. +The mandatory `triage-actors=` setting is likewise accepted at `0.2.0` and +later, and not by `0.1.0`. At that tag the file contains `panel=` plus scope +rows only; adding `triage-actors=` is a parse failure, not an ignored setting. +Add it at the same pin bump as the `issues:` trigger — `0.2.0` or later — +never before it and never through mixed refs. Both actor lists are whitespace-separated. `triage-actors` names the identities allowed to mint issues without the sweep applying `needs-triage`. Label rows use exactly diff --git a/lib/facts.sh b/lib/facts.sh index add259b..e667524 100644 --- a/lib/facts.sh +++ b/lib/facts.sh @@ -44,50 +44,31 @@ ver="$(version_read "$VERSION_SOURCE")" # event.before is all-zeros on a branch-create push, and absent outside push # events; the pushed head's first parent is main the instant before, either # way (#1 constraint 10; cast's `*[!0]*` test — "contains a non-zero char"). -# One branch-create has no instant before: the repository's FIRST push to -# main, whose head is a root commit — both 0.2.0 drills died here at exit -# 128, the first release-flow event either scratch consumer ever saw (#134). -# The parent count is read as a fact (`rev-list --parents` prints the head -# alone for a root commit) rather than inferred from a failed rev-parse, so -# an unresolvable MERGE_SHA still dies loudly instead of masquerading as -# "(none)". base_sha="${EVENT_BEFORE:-}" case "$base_sha" in *[!0]*) ;; - *) - parents="$(git rev-list --parents -n 1 "$MERGE_SHA")" - case "$parents" in - *" "*) base_sha="$(git rev-parse "$MERGE_SHA^1")" ;; - *) base_sha="" ;; # a root commit: no base tree exists at all - esac - ;; + *) base_sha="$(git rev-parse "$MERGE_SHA^1")" ;; esac # Belt-and-braces (cast's precedent): the workflow's fetch-depth: 2 resolves # the first parent, but event.before can predate it when pushes raced. If # the fetch still cannot produce it, the git show below is the loud failure. -# Skipped entirely when there is no base: with an empty rev the fetch is -# meaningless and `git show ":$src"` would read the INDEX — reporting the -# head's own version as the base, a wrong fact worse than any crash (#134). -if [ -n "$base_sha" ]; then - git cat-file -e "$base_sha" 2>/dev/null \ - || git fetch --depth=1 origin "$base_sha" >&2 \ - || true -fi +git cat-file -e "$base_sha" 2>/dev/null \ + || git fetch --depth=1 origin "$base_sha" >&2 \ + || true base_dir="$(mktemp -d)" trap 'rm -rf "$base_dir"' EXIT -if [ -n "$base_sha" ] && git show "$base_sha:$src" >"$base_dir/$src" 2>/dev/null; then +if git show "$base_sha:$src" >"$base_dir/$src" 2>/dev/null; then base_ver="$(version_read "$VERSION_SOURCE" "$base_dir")" else - # No base tree (a root commit — the repository's first push, the 0.2.0 - # drills' wall, #134), or a base tree with no version source in it: the - # merge that ADDS the version machinery (a consumer's adoption PR, a - # greenfield repo's first caller). "(none)" is not a version, so decide - # sees a changed version and the table still governs: a -dev head is work - # (row 2, the guided bootstrap path), a bare head still demands the - # merged release label (rows 5–6). Nothing releases silently either way. + # The base tree has no version source at all: the merge that ADDS the + # version machinery (a consumer's adoption PR, a greenfield repo's first + # caller). "(none)" is not a version, so decide sees a changed version + # and the table still governs: a -dev head is work (row 2, the guided + # bootstrap path), a bare head still demands the merged release label + # (rows 5–6). Nothing releases silently either way. base_ver="(none)" fi diff --git a/test/facts.test.sh b/test/facts.test.sh index 32bea4a..83cc16d 100644 --- a/test/facts.test.sh +++ b/test/facts.test.sh @@ -146,42 +146,6 @@ adoptb_head="$(commit adoption-bare VERSION 0.1.0)" check "absent-at-base with a bare head still asks for the label" 0 "labeled=yes" \ facts_in adoption-bare VERSION_SOURCE=file MERGE_SHA="$adoptb_head" EVENT_BEFORE="$adoptb_base" GH_STUB=labeled-yes -# --- a root commit: no base tree at all (the repository's first push) -------- - -# The first push to main IS a branch-create push (event.before all-zeros) -# whose head has no first parent — there is no base, and the honest fact is -# "(none)", not an exit-128 death at rev-parse. Both 0.2.0 drills hit the -# death independently (#134). The -dev row consults no API (stub default), -# which also asserts the no-base path runs no base fetch/show at all. -repo greenfield -green_head="$(commit greenfield VERSION 0.1.0-dev)" - -check "root commit, all-zeros event.before: base_ver=(none)" 0 "base_ver=(none)" \ - facts_in greenfield VERSION_SOURCE=file MERGE_SHA="$green_head" EVENT_BEFORE="$ZEROS" -check "root commit, empty event.before: base_ver=(none)" 0 "base_ver=(none)" \ - facts_in greenfield VERSION_SOURCE=file MERGE_SHA="$green_head" EVENT_BEFORE= - -repo greenfield-bare -greenb_head="$(commit greenfield-bare VERSION 0.1.0)" -check "bare root commit still establishes labeled, so decide can refuse" 0 "labeled=no" \ - facts_in greenfield-bare VERSION_SOURCE=file MERGE_SHA="$greenb_head" EVENT_BEFORE="$ZEROS" GH_STUB=labeled-no - -# The D2 pin: "no first parent" is detected, never inferred from a failed -# command — an unresolvable MERGE_SHA is a loud death, not "(none)". A -# `|| true` around the fallback would pass every case above and fail here. -BAD_SHA="1111111111111111111111111111111111111111" -check "an unresolvable MERGE_SHA still dies loudly" 128 "bad object" \ - facts_in greenfield VERSION_SOURCE=file MERGE_SHA="$BAD_SHA" EVENT_BEFORE="$ZEROS" -bad_out="$(facts_in greenfield VERSION_SOURCE=file MERGE_SHA="$BAD_SHA" EVENT_BEFORE="$ZEROS" 2>&1)" -if printf '%s' "$bad_out" | grep -qF "base_ver=(none)"; then - echo "FAIL: an unresolvable MERGE_SHA must not be reported as base_ver=(none)" - printf '%s\n' "$bad_out" | sed 's/^/ /' - FAIL=$((FAIL + 1)) -else - echo "ok: an unresolvable MERGE_SHA is not reported as base_ver=(none)" - PASS=$((PASS + 1)) -fi - # --- the package-json backend ------------------------------------------------ repo pkg diff --git a/test/labels.test.sh b/test/labels.test.sh index 6d35a3a..e571ca2 100755 --- a/test/labels.test.sh +++ b/test/labels.test.sh @@ -65,4 +65,41 @@ check "PR author is recused from the required panel" 0 "one three" printf '%s\n' check "LABELS.md enumerates no repo's scope labels" 1 "0" \ grep -c 'scope:[a-z0-9]' "$ROOT/LABELS.md" +# The caller's pull_request_target list and the CONSUMERS.md stub's must be +# the same list. review_requested/review_request_removed are the wake that +# clears blocker:unrequested — the label sat false for as long as a quiet +# repo stayed quiet because the one event that falsifies it was never +# listed (#137). The stub is prose, so nothing but this row keeps the two +# lists from drifting: a type in one file only is a wake that fires at home +# and nowhere in the fleet, or the reverse. +pr_target_types() { # $1 = file → its pull_request_target types line, unindented + awk '/pull_request_target:/{f=1; next} f && /types: /{sub(/^ */,""); print; exit}' "$1" +} +types_in_sync() { # $1 = caller, $2 = stub → 0 when both lists exist and match + local a b + a="$(pr_target_types "$1")" b="$(pr_target_types "$2")" + [ -n "$a" ] && [ "$a" = "$b" ] +} +CALLER="$ROOT/.github/workflows/self-labels.yml" +STUB="$ROOT/docs/CONSUMERS.md" +check "caller and stub pull_request_target lists are identical" 0 "" \ + types_in_sync "$CALLER" "$STUB" +# shellcheck disable=SC2016 # expansion belongs to the nested bash +check "the caller lists both review-request wakes" 0 "" bash -c \ + 'awk "/pull_request_target:/{f=1; next} f && /types: /{print; exit}" "$1" | + grep -F review_requested | grep -qF review_request_removed' _ "$CALLER" +# the failing cases: drop a type from either file, or reorder one list only, +# and the identity row above goes red — exercised here on mutated copies +mut_caller="$TMP/mut-caller.yml" mut_stub="$TMP/mut-stub.md" +sed 's/, review_request_removed//' "$CALLER" >"$mut_caller" +check "a type dropped from the caller goes red" 1 "" \ + types_in_sync "$mut_caller" "$STUB" +sed 's/, review_request_removed//' "$STUB" >"$mut_stub" +check "a type dropped from the stub goes red" 1 "" \ + types_in_sync "$CALLER" "$mut_stub" +sed 's/review_requested, review_request_removed/review_request_removed, review_requested/' \ + "$STUB" >"$mut_stub" +check "a reorder in one list only goes red" 1 "" \ + types_in_sync "$CALLER" "$mut_stub" + summary diff --git a/test/release-chain.test.sh b/test/release-chain.test.sh index 8733425..a83348b 100644 --- a/test/release-chain.test.sh +++ b/test/release-chain.test.sh @@ -69,14 +69,12 @@ git -C "$TMP/repo" add VERSION CHANGELOG.md git -C "$TMP/repo" commit -qm "release: 0.7.0" MERGE_SHA="$(git -C "$TMP/repo" rev-parse HEAD)" -# chain [repo_dir] [stub_dir] — facts, then -# decide fed from facts' output lines, then the notes extraction, printing -# each stage's result. The optional dirs default to the main fixture; the -# greenfield cases below (#134) bring their own repo and stub. +# chain — facts, then decide fed from facts' +# output lines, then the notes extraction, printing each stage's result. chain() { ( - cd "${3:-$TMP/repo}" || exit 1 - facts_out="$(env PATH="${4:-$TMP/stub}:$PATH" GITHUB_REPOSITORY=fixture/fixture \ + cd "$TMP/repo" || exit 1 + facts_out="$(env PATH="$TMP/stub:$PATH" GITHUB_REPOSITORY=fixture/fixture \ GH_TOKEN=stub VERSION_SOURCE=file MERGE_SHA="$1" EVENT_BEFORE="$2" \ bash "$FACTS")" || exit 1 printf '%s\n' "$facts_out" @@ -152,51 +150,4 @@ check "the post-release bump decides ceremony=no" 0 "ceremony=no" \ check "an ordinary -dev merge decides ceremony=no" 0 "ceremony=no" \ chain "$WORK2_SHA" "$BUMP_SHA" -# --- the repository's first push to main: a root commit, no base (#134) ------ - -# event.before is all-zeros and the head has no first parent. facts reads -# base_ver=(none) instead of dying at rev-parse, and decide's table governs -# from there: the guided bootstrap (-dev first commit, what CONSUMERS.md -# tells a new repo to write) is a green NOTICE no-op — the doctrine's -# promise held at the exact moment a consumer adopts the ceremony. -ZEROS="0000000000000000000000000000000000000000" - -git init -q "$TMP/greenfield" -git -C "$TMP/greenfield" config user.email fixture@example.invalid -git -C "$TMP/greenfield" config user.name fixture -printf '0.1.0-dev\n' >"$TMP/greenfield/VERSION" -git -C "$TMP/greenfield" add VERSION -git -C "$TMP/greenfield" commit -qm "root: adopt the ceremony at 0.1.0-dev" -GREEN_SHA="$(git -C "$TMP/greenfield" rev-parse HEAD)" - -check "a greenfield -dev root commit decides ceremony=no, not a red run" 0 "ceremony=no" \ - chain "$GREEN_SHA" "$ZEROS" "$TMP/greenfield" -check "the greenfield no-op is a NOTICE" 0 "NOTICE:" \ - chain "$GREEN_SHA" "$ZEROS" "$TMP/greenfield" - -# A bare first commit with no merged release-labeled PR must still refuse — -# the reason the crash could not be `|| true`-ed away: a greenfield adoption -# must never become a silent release. This stub answers the labeled query -# with false. -mkdir -p "$TMP/stub-unlabeled" -cat >"$TMP/stub-unlabeled/gh" <<'EOF' -#!/usr/bin/env bash -if [ "$1" = api ]; then echo false; exit 0; fi -echo "gh stub: unexpected call: gh $*" >&2 -exit 97 -EOF -chmod +x "$TMP/stub-unlabeled/gh" - -git init -q "$TMP/greenfield-bare" -git -C "$TMP/greenfield-bare" config user.email fixture@example.invalid -git -C "$TMP/greenfield-bare" config user.name fixture -printf '0.1.0\n' >"$TMP/greenfield-bare/VERSION" -git -C "$TMP/greenfield-bare" add VERSION -git -C "$TMP/greenfield-bare" commit -qm "root: bare 0.1.0, nobody declared a release" -GREENB_SHA="$(git -C "$TMP/greenfield-bare" rev-parse HEAD)" - -check "a bare unlabeled root commit still refuses, creating nothing" 1 \ - "no merged, release-labeled PR" \ - chain "$GREENB_SHA" "$ZEROS" "$TMP/greenfield-bare" "$TMP/stub-unlabeled" - summary