forked from heavy-duty/ceremony
Merge pull request #200 from claude-bot-andresmgsl/build/199-labels-sweep-cadence
labels: reduce full-board sweep trigger frequency (#199)
This commit is contained in:
commit
c4b49cd8d3
6 changed files with 214 additions and 44 deletions
13
.github/workflows/labels.yml
vendored
13
.github/workflows/labels.yml
vendored
|
|
@ -10,11 +10,14 @@ name: labels
|
||||||
# and reconcile checks out the BASE branch only. Keep it that way.
|
# 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
|
# There is no pull_request_review_target, so a review landing cannot wake this
|
||||||
# workflow directly — and the */15 cron is advisory: GitHub deprioritises
|
# workflow directly — which is why the caller's cron is load-bearing, not a
|
||||||
# short intervals hard enough that a quiet repo goes hours between ticks. The
|
# safety net (#199 relaxed it from */15 to hourly, but did NOT drop it). The
|
||||||
# handoff wakes the sweep itself: the author sets state:needs-human, and the
|
# cron is the sweep's only discovery path for every transition no subscribed
|
||||||
# caller's `labeled` event confirms or corrects that optimistic write within
|
# event carries: a verdict landing, blocker:ci-red set/cleared, a
|
||||||
# seconds. The cron stays as the last resort for a forgotten handoff.
|
# blocker:conflict when another PR merges under this one, and the time-based
|
||||||
|
# stale / 48h claim-reclaim. Where an event IS subscribed the wake is direct —
|
||||||
|
# the handoff sets state:needs-human and the caller's `labeled` event confirms
|
||||||
|
# or corrects that optimistic write within seconds.
|
||||||
#
|
#
|
||||||
# This cannot loop: reconciler writes use GITHUB_TOKEN, and GitHub does not
|
# This cannot loop: reconciler writes use GITHUB_TOKEN, and GitHub does not
|
||||||
# create workflow runs from GITHUB_TOKEN-triggered events. Agent writes use a
|
# create workflow runs from GITHUB_TOKEN-triggered events. Agent writes use a
|
||||||
|
|
|
||||||
29
.github/workflows/self-labels.yml
vendored
29
.github/workflows/self-labels.yml
vendored
|
|
@ -5,11 +5,36 @@ name: labels
|
||||||
# unpinned — correct only for the repo that IS the source). Consumers write:
|
# unpinned — correct only for the repo that IS the source). Consumers write:
|
||||||
# uses: heavy-duty/ceremony/.github/workflows/labels.yml@<pinned-tag>
|
# uses: heavy-duty/ceremony/.github/workflows/labels.yml@<pinned-tag>
|
||||||
on:
|
on:
|
||||||
schedule: [{cron: "*/15 * * * *"}] # advisory; the handoff label is the real wake
|
# Hourly, not */15 (#199): the cron is the sweep's ONLY wake for the four
|
||||||
|
# transition classes no subscribed event carries — a review verdict landing
|
||||||
|
# (there is no pull_request_review trigger here), blocker:ci-red set or cleared
|
||||||
|
# (no check_suite/check_run/workflow_run), a blocker:conflict when ANOTHER PR
|
||||||
|
# merges under this one, and the time-based stale / 48h claim-reclaim. The
|
||||||
|
# events below carry the rest in seconds. So hourly trades ≤1h of latency on
|
||||||
|
# those four for dropping */15's four full-board sweeps an hour at GitHub's
|
||||||
|
# 1-minute billing floor — 52% of one private consumer's runs. Do NOT delete
|
||||||
|
# this cron — it is the discovery path for those four, not a forgotten-handoff net.
|
||||||
|
schedule: [{cron: "0 * * * *"}]
|
||||||
workflow_dispatch: # bootstraps missing labels on a fresh repo
|
workflow_dispatch: # bootstraps missing labels on a fresh repo
|
||||||
|
# Narrowed (#199) to the actions that carry a queue-state change the hourly
|
||||||
|
# cron cannot wait one cadence for — dropping only labeled/unlabeled/assigned/
|
||||||
|
# unassigned, which feed validation and the 48h claim clock (caught within one
|
||||||
|
# cadence) and were the dominant issues-churn source. Kept: `opened` (the
|
||||||
|
# mint→needs-triage check, issueflow's opened-only path), `closed` (the
|
||||||
|
# blocker-closes→ready self-heal, crew#96/#98), `edited` (a body rewrite of the
|
||||||
|
# `Blocked by #N` declaration the sweep parses — issueflow-reconcile.sh:179),
|
||||||
|
# `reopened` (a closed issue re-entering the queue wearing labels derived when
|
||||||
|
# it closed). The must-fail in #199 is exactly "a queue-state transition waits
|
||||||
|
# on the schedule when an event could have carried it", so edited/reopened stay
|
||||||
|
# on events. The PR handoff wake is pull_request_target:labeled, NOT issues, so
|
||||||
|
# this does not touch the handoff.
|
||||||
issues:
|
issues:
|
||||||
types: [opened, edited, assigned, unassigned, labeled, unlabeled, closed, reopened]
|
types: [opened, closed, edited, reopened]
|
||||||
pull_request_target:
|
pull_request_target:
|
||||||
|
# Every PR arrives from a fork, so these carry the head/draft/review facts
|
||||||
|
# the sweep derives state:* from. labeled/unlabeled are the handoff wake —
|
||||||
|
# the author's optimistic state:needs-human write, confirmed or corrected
|
||||||
|
# here in seconds (#11); synchronize re-derives on every push;
|
||||||
# review_requested/review_request_removed wake the sweep that clears (or
|
# review_requested/review_request_removed wake the sweep that clears (or
|
||||||
# restores) blocker:unrequested — without them the one event that makes
|
# 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
|
# the label false could not clear it, and a quiet repo wore the red flag
|
||||||
|
|
|
||||||
17
changelog.d/199.md
Normal file
17
changelog.d/199.md
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- `labels` — the reconcile cron relaxes from `*/15` to hourly (#199), cutting a
|
||||||
|
private consumer's schedule-triggered full-board sweeps ~4× at GitHub's
|
||||||
|
1-minute billing floor.
|
||||||
|
- `labels` — the hourly cron is the sweep's only wake for transitions no
|
||||||
|
subscribed event carries — a verdict landing, blocker:ci-red, a
|
||||||
|
blocker:conflict when another PR merges, the time-based stale/reclaim — so it
|
||||||
|
bounds their latency to ≤1h, delaying no event-carried transition (#199).
|
||||||
|
- `labels` — the caller's `issues:` trigger narrows to
|
||||||
|
`[opened, closed, edited, reopened]` (#199), the actions that carry a
|
||||||
|
queue-state change the cron cannot wait a cadence for. The churn/validation
|
||||||
|
actions — labeled/unlabeled/assigned/unassigned — come off; the PR handoff
|
||||||
|
wake is unaffected.
|
||||||
|
- `labels` — each caller trigger now carries a comment saying why it is
|
||||||
|
subscribed, and reconcile keeps `cancel-in-progress: false` (#199) —
|
||||||
|
cancelling a sweep mid-board is the race that guard exists to prevent.
|
||||||
|
|
@ -296,20 +296,34 @@ The complete caller is:
|
||||||
```yaml
|
```yaml
|
||||||
name: labels
|
name: labels
|
||||||
on:
|
on:
|
||||||
schedule: [{cron: "*/15 * * * *"}] # advisory; the handoff label is the real wake
|
# Hourly, not */15 (#199): the cron is the sweep's only wake for the
|
||||||
|
# transitions no subscribed event carries — a review verdict landing (no
|
||||||
|
# pull_request_review trigger), blocker:ci-red set/cleared, a blocker:conflict
|
||||||
|
# when another PR merges under this one, and the time-based stale / 48h
|
||||||
|
# claim-reclaim. Events below carry the rest in seconds. Hourly trades ≤1h of
|
||||||
|
# latency on those four for dropping */15's four sweeps an hour at GitHub's
|
||||||
|
# 1-minute floor. Keep the cron — it is the discovery path, not a safety net.
|
||||||
|
schedule: [{cron: "0 * * * *"}]
|
||||||
workflow_dispatch: # bootstraps missing labels on a fresh repo
|
workflow_dispatch: # bootstraps missing labels on a fresh repo
|
||||||
pull_request_target:
|
pull_request_target:
|
||||||
# review_requested/review_request_removed are unreleased — not in 0.2.0;
|
# Fork PRs; these carry the head/draft/review facts state:* derives from.
|
||||||
# add them with the pin bump to the first tag carrying ceremony#137. They
|
# labeled/unlabeled are the handoff wake (state:needs-human confirmed here);
|
||||||
# wake the sweep that clears blocker:unrequested when the panel is asked.
|
# synchronize re-derives on every push. review_requested/review_request_removed
|
||||||
|
# (shipped in 0.3.0, ceremony#137) 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]
|
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
|
# Available at 0.2.0 and later (the first tag carrying ceremony#32); a
|
||||||
# consumer pinned to 0.1.0 omits this block.
|
# consumer pinned to 0.1.0 omits this block.
|
||||||
issues:
|
issues:
|
||||||
# edited/reopened are unreleased — not in 0.2.0; add them with the pin
|
# Narrowed (#199) to the actions carrying a queue-state change the hourly
|
||||||
# bump to the first tag carrying ceremony#144, the same bump as the
|
# cron cannot wait one cadence for: opened → the mint→needs-triage check,
|
||||||
# pull_request_target additions above.
|
# closed → the blocker-closes→ready self-heal, edited → a body rewrite of the
|
||||||
types: [opened, edited, assigned, unassigned, labeled, unlabeled, closed, reopened]
|
# `Blocked by #N` declaration the sweep parses, reopened → a closed issue
|
||||||
|
# re-entering the queue. Dropped: labeled/unlabeled/assigned/unassigned —
|
||||||
|
# validation + the 48h claim clock, caught within one cadence, and
|
||||||
|
# labeled/unlabeled were the issues-churn source. The handoff wake is
|
||||||
|
# pull_request_target:labeled, not issues, so this leaves it intact.
|
||||||
|
types: [opened, closed, edited, reopened]
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
checks: read # mergeability/check-rollup read for PR state
|
checks: read # mergeability/check-rollup read for PR state
|
||||||
|
|
@ -330,23 +344,32 @@ needs all three explicit reads above; without them the failure appears as an emp
|
||||||
The `issues:` trigger is available at `0.2.0` and later — `0.2.0` is the
|
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
|
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
|
it only by bumping every ceremony reference to `0.2.0` or later; never mix
|
||||||
refs to adopt it early. At `0.2.0` the type list stops at `closed`: `edited`
|
refs to adopt it early. The type list has grown then narrowed across tags:
|
||||||
and `reopened` are newer, part of the pending stub edit below.
|
`0.2.0` (ceremony#32) shipped `[opened, labeled, unlabeled, assigned,
|
||||||
|
unassigned, closed]`; `0.3.0` (ceremony#144) added `edited` and `reopened`;
|
||||||
|
ceremony#199 narrows it to `[opened, closed, edited, reopened]` and relaxes the
|
||||||
|
cron to hourly, so a consumer picks up the smaller trigger surface at the pin
|
||||||
|
bump to the first tag carrying ceremony#199. The narrowing drops
|
||||||
|
`labeled`/`unlabeled`/`assigned`/`unassigned` — validation and the 48h claim
|
||||||
|
clock, which the hourly cron catches within one cadence, and `labeled`/
|
||||||
|
`unlabeled` were the issues-churn source — while **keeping** #144's `edited`/
|
||||||
|
`reopened`: those carry a queue-state change an event uniquely carries (a body
|
||||||
|
rewrite of `Blocked by #N`, and a closed issue re-entering the queue), so the
|
||||||
|
must-fail in ceremony#199 keeps them on events. `opened` drives the
|
||||||
|
mint→`needs-triage` check and `closed` the blocker-closes→`ready` self-heal;
|
||||||
|
the stub and ceremony's own caller stay byte-for-byte identical, the parity
|
||||||
|
#144 established.
|
||||||
|
|
||||||
`pull_request_target` is intentional: fork PRs need the base repository's
|
`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
|
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.
|
out only the consumer's base branch and the pinned ceremony implementation.
|
||||||
The #52 ruling invariants ride exactly these triggers — but the caller above
|
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
|
is no longer the #18 shape, so adopting current triggers is a stub edit, not
|
||||||
a bare pin bump. The pending edits are `review_requested` and
|
a bare pin bump. `review_requested` and `review_request_removed` on
|
||||||
`review_request_removed` on `pull_request_target:` (#137) — the wake that
|
`pull_request_target:` shipped in `0.3.0` (ceremony#137) — the wake that
|
||||||
clears `blocker:unrequested` the moment the panel is asked, without which a
|
clears `blocker:unrequested` the moment the panel is asked, without which a
|
||||||
quiet repo wears that flag until the advisory cron — and `edited` and
|
quiet repo wears that flag until the backstop cron; a consumer picks them up
|
||||||
`reopened` on `issues:` (#144): the wakes for a body edit rewriting the
|
by pinning `0.3.0` or later, never through mixed refs.
|
||||||
`Blocked by #N` declaration the reconcile sweep parses, and for a closed
|
|
||||||
issue re-entering the queue wearing labels derived when it closed. Make both
|
|
||||||
edits together, with the pin bump to the first tag carrying ceremony#137 and
|
|
||||||
ceremony#144 — never before it and never through mixed refs.
|
|
||||||
|
|
||||||
`.github/labels.conf` has one mandatory panel setting, one mandatory
|
`.github/labels.conf` has one mandatory panel setting, one mandatory
|
||||||
`triage-actors` setting, and then zero or more scope rows:
|
`triage-actors` setting, and then zero or more scope rows:
|
||||||
|
|
|
||||||
98
test/labels-triggers.test.sh
Normal file
98
test/labels-triggers.test.sh
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -u
|
||||||
|
|
||||||
|
# The labels TRIGGER SURFACE is a cost lever (#199): a full-board sweep is
|
||||||
|
# billed a 1-minute minimum every time a trigger fires, so how OFTEN it fires
|
||||||
|
# is what exhausted the fleet's shared Actions allotment. These assertions
|
||||||
|
# pin the reductions #199 made and the guard it must not trade away — none of
|
||||||
|
# them touch the reconciler's LOGIC, which its own fixtures cover.
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
# shellcheck source=test/harness.sh
|
||||||
|
source "$ROOT/test/harness.sh"
|
||||||
|
|
||||||
|
REUSABLE="$ROOT/.github/workflows/labels.yml"
|
||||||
|
SELF="$ROOT/.github/workflows/self-labels.yml"
|
||||||
|
STUB="$ROOT/docs/CONSUMERS.md" # the published caller stub, a fenced yaml block
|
||||||
|
|
||||||
|
# The `cancel-in-progress:` value of a named top-level job, read from the first
|
||||||
|
# such line inside that job's block. Job keys sit at two-space indent.
|
||||||
|
job_cancel_in_progress() { # $1 = file, $2 = job name
|
||||||
|
awk -v job="^ $2:\$" '
|
||||||
|
$0 ~ job { f = 1; next }
|
||||||
|
f && /^ [a-z]/ { exit } # next job — stop before leaking into it
|
||||||
|
f && /cancel-in-progress:/ { sub(/.*cancel-in-progress:[[:space:]]*/, ""); print; exit }
|
||||||
|
' "$1"
|
||||||
|
}
|
||||||
|
|
||||||
|
# The `types:` list of a trigger key (issues:, pull_request_target:), read from
|
||||||
|
# the first `types:` line after the bare key. The key is bare (nothing after
|
||||||
|
# the colon) so it never collides with `issues: write` in the permissions block.
|
||||||
|
trigger_types() { # $1 = file, $2 = trigger key
|
||||||
|
awk -v key="^ $2:\$" '
|
||||||
|
$0 ~ key { f = 1; next }
|
||||||
|
f && /^ types:/ { sub(/^ types:[[:space:]]*/, ""); print; exit }
|
||||||
|
f && /^ [a-z]/ { exit }
|
||||||
|
' "$1"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---- the guard the cost fix must never trade away (#199 test plan must-fail) --
|
||||||
|
# cancel-in-progress: true on reconcile kills a sweep mid-board, the exact race
|
||||||
|
# the shared concurrency group exists to prevent. It WOULD cut run count — by
|
||||||
|
# trading correctness for minutes — so it stays false, forever.
|
||||||
|
check "reconcile serializes, never cancels mid-board" 0 "false" \
|
||||||
|
job_cancel_in_progress "$REUSABLE" reconcile
|
||||||
|
# shellcheck disable=SC2016 # the awk program runs in the nested bash, not here
|
||||||
|
check "reconcile is never cancel-in-progress: true" 1 "" \
|
||||||
|
bash -c 'job_cancel_in_progress() {
|
||||||
|
awk -v job="^ reconcile:\$" "\$0 ~ job{f=1;next} f&&/^ [a-z]/{exit} f&&/cancel-in-progress:/{sub(/.*cancel-in-progress:[[:space:]]*/,\"\");print;exit}" "$1"
|
||||||
|
}; [ "$(job_cancel_in_progress "$1")" = true ]' _ "$REUSABLE"
|
||||||
|
# scope MAY cancel — it is per-PR and additive, so a superseded run is waste,
|
||||||
|
# not a lost sweep. This asserts the must-fail above is scoped to reconcile.
|
||||||
|
check "scope stays cancel-in-progress: true (per-PR, additive)" 0 "true" \
|
||||||
|
job_cancel_in_progress "$REUSABLE" scope
|
||||||
|
|
||||||
|
# ---- the cron is a backstop, relaxed to hourly (#199 candidate 1) -----------
|
||||||
|
# Scope the */15 assertion to the cron LINE — the prose comments cite */15 by
|
||||||
|
# name to explain the change, and must not re-red their own documentation.
|
||||||
|
check "self caller cron is hourly" 0 '0 * * * *' grep -F 'cron:' "$SELF"
|
||||||
|
# shellcheck disable=SC2016 # $1 expands in the nested bash, not here
|
||||||
|
check "self caller cron line no longer fires */15" 1 "" \
|
||||||
|
bash -c 'grep -F "cron:" "$1" | grep -qF "*/15"' _ "$SELF"
|
||||||
|
check "stub cron is hourly" 0 '0 * * * *' grep -F 'cron:' "$STUB"
|
||||||
|
# shellcheck disable=SC2016 # $1 expands in the nested bash, not here
|
||||||
|
check "stub cron line no longer fires */15" 1 "" \
|
||||||
|
bash -c 'grep -F "cron:" "$1" | grep -qF "*/15"' _ "$STUB"
|
||||||
|
|
||||||
|
# ---- issues: is narrowed to the queue-state-changing actions (#199) ----------
|
||||||
|
# Kept because each carries a queue-state change an event uniquely carries, so
|
||||||
|
# dropping it would trip #199's must-fail (a transition waiting on the schedule
|
||||||
|
# when an event could have carried it): opened → mint→needs-triage; closed →
|
||||||
|
# blocker-closes→ready self-heal; edited → a body rewrite of the `Blocked by #N`
|
||||||
|
# declaration the sweep parses; reopened → a closed issue re-entering the queue.
|
||||||
|
# (labels.test.sh owns the exact-list and caller<->stub parity assertions.)
|
||||||
|
for keep in opened closed edited reopened; do
|
||||||
|
# shellcheck disable=SC2016 # the awk program runs in the nested bash, not here
|
||||||
|
check "self caller issues surface keeps '$keep'" 0 "" \
|
||||||
|
bash -c 'trigger_types() {
|
||||||
|
awk -v key="^ issues:\$" "\$0 ~ key{f=1;next} f&&/^ types:/{sub(/^ types:[[:space:]]*/,\"\");print;exit} f&&/^ [a-z]/{exit}" "$1"
|
||||||
|
}; trigger_types "$1" | grep -qw "$2"' _ "$SELF" "$keep"
|
||||||
|
done
|
||||||
|
# The churn actions must not reappear on the issues surface without a fresh why.
|
||||||
|
# labeled/unlabeled were the dominant issues-churn source; assigned/unassigned
|
||||||
|
# only feed validation and the 48h claim clock, caught within one cadence.
|
||||||
|
for churn in labeled unlabeled assigned unassigned; do
|
||||||
|
# shellcheck disable=SC2016 # the awk program runs in the nested bash, not here
|
||||||
|
check "self caller issues surface drops '$churn'" 1 "" \
|
||||||
|
bash -c 'trigger_types() {
|
||||||
|
awk -v key="^ issues:\$" "\$0 ~ key{f=1;next} f&&/^ types:/{sub(/^ types:[[:space:]]*/,\"\");print;exit} f&&/^ [a-z]/{exit}" "$1"
|
||||||
|
}; trigger_types "$1" | grep -qw "$2"' _ "$SELF" "$churn"
|
||||||
|
done
|
||||||
|
|
||||||
|
# ---- the PR handoff wake is NOT collateral of the issues narrowing ----------
|
||||||
|
# The handoff (state:needs-human, confirmed by the caller's labeled event) rides
|
||||||
|
# pull_request_target, not issues. A future edit that strips it there re-reds.
|
||||||
|
check "pull_request_target keeps the labeled handoff wake" 0 "labeled" \
|
||||||
|
trigger_types "$SELF" pull_request_target
|
||||||
|
|
||||||
|
summary
|
||||||
|
|
@ -69,15 +69,16 @@ check "LABELS.md enumerates no repo's scope labels" 1 "0" \
|
||||||
# same lists. review_requested/review_request_removed are the wake that
|
# same lists. review_requested/review_request_removed are the wake that
|
||||||
# clears blocker:unrequested — the label sat false for as long as a quiet
|
# 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
|
# repo stayed quiet because the one event that falsifies it was never
|
||||||
# listed (#137). edited/reopened are the wakes for the two events that
|
# listed (#137). The issues list narrowed to [opened, closed, edited, reopened]
|
||||||
# falsify issue labels silently — an edited body rewrites the `Blocked by
|
# (#199): each carries a queue-state change the hourly cron cannot wait one
|
||||||
# #N` declaration the reconcile sweep parses, and a reopened issue
|
# cadence for — opened drives mint→needs-triage, closed the blocker-closes→ready
|
||||||
# re-enters the queue wearing labels derived at close; PR #32 widened the
|
# self-heal, edited a body rewrite of the `Blocked by #N` line the sweep parses,
|
||||||
# caller by both and the stub never followed (#144). The stub is prose, so
|
# reopened a closed issue re-entering the queue — while the churn/validation
|
||||||
# nothing but these rows keeps the lists from drifting: a type in one file
|
# actions (labeled/unlabeled/assigned/unassigned) came off. The stub is prose, so nothing but these rows
|
||||||
# only is a wake that fires at home and nowhere in the fleet, or the
|
# keeps the lists from drifting: a type in one file only is a wake that fires
|
||||||
# reverse. The NF guard keeps `issues: write` under permissions: from
|
# at home and nowhere in the fleet, or the reverse — the drift #144 caught.
|
||||||
# matching the issues: trigger key.
|
# The NF guard keeps `issues: write` under permissions: from matching the
|
||||||
|
# issues: trigger key.
|
||||||
event_types() { # $1 = file, $2 = trigger key → that trigger's types line, unindented
|
event_types() { # $1 = file, $2 = trigger key → that trigger's types line, unindented
|
||||||
awk -v key="$2:" '$1 == key && NF == 1 {f=1; next} f && /types: /{sub(/^ */,""); print; exit}' "$1"
|
awk -v key="$2:" '$1 == key && NF == 1 {f=1; next} f && /types: /{sub(/^ */,""); print; exit}' "$1"
|
||||||
}
|
}
|
||||||
|
|
@ -88,17 +89,20 @@ types_in_sync() { # $1 = trigger key, $2 = caller, $3 = stub → 0 when both lis
|
||||||
}
|
}
|
||||||
CALLER="$ROOT/.github/workflows/self-labels.yml"
|
CALLER="$ROOT/.github/workflows/self-labels.yml"
|
||||||
STUB="$ROOT/docs/CONSUMERS.md"
|
STUB="$ROOT/docs/CONSUMERS.md"
|
||||||
|
# event_types anchors on the bare trigger key (NF == 1), so it reads the real
|
||||||
|
# types line even though the #199 comments name pull_request_target: and
|
||||||
|
# issues: in prose above the keys — an inline /pull_request_target:/ scan would
|
||||||
|
# latch onto the first mention and read the wrong list.
|
||||||
|
pr_has_both_review_wakes() {
|
||||||
|
event_types "$CALLER" pull_request_target | grep -F review_requested | grep -qF review_request_removed
|
||||||
|
}
|
||||||
check "caller and stub pull_request_target lists are identical" 0 "" \
|
check "caller and stub pull_request_target lists are identical" 0 "" \
|
||||||
types_in_sync pull_request_target "$CALLER" "$STUB"
|
types_in_sync pull_request_target "$CALLER" "$STUB"
|
||||||
# shellcheck disable=SC2016 # expansion belongs to the nested bash
|
check "the caller lists both review-request wakes" 0 "" pr_has_both_review_wakes
|
||||||
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"
|
|
||||||
check "caller and stub issues lists are identical" 0 "" \
|
check "caller and stub issues lists are identical" 0 "" \
|
||||||
types_in_sync issues "$CALLER" "$STUB"
|
types_in_sync issues "$CALLER" "$STUB"
|
||||||
check "the caller still lists all eight issue types" 0 \
|
check "the caller lists exactly the queue-state-changing issue types" 0 \
|
||||||
"types: [opened, edited, assigned, unassigned, labeled, unlabeled, closed, reopened]" \
|
"types: [opened, closed, edited, reopened]" event_types "$CALLER" issues
|
||||||
event_types "$CALLER" issues
|
|
||||||
# the failing cases: drop a type from either file, or reorder one list only,
|
# the failing cases: drop a type from either file, or reorder one list only,
|
||||||
# and the identity rows above go red — exercised here on mutated copies
|
# and the identity rows above go red — exercised here on mutated copies
|
||||||
mut_caller="$TMP/mut-caller.yml" mut_stub="$TMP/mut-stub.md"
|
mut_caller="$TMP/mut-caller.yml" mut_stub="$TMP/mut-stub.md"
|
||||||
|
|
@ -112,13 +116,13 @@ sed 's/review_requested, review_request_removed/review_request_removed, review_r
|
||||||
"$STUB" >"$mut_stub"
|
"$STUB" >"$mut_stub"
|
||||||
check "a reorder in one list only goes red" 1 "" \
|
check "a reorder in one list only goes red" 1 "" \
|
||||||
types_in_sync pull_request_target "$CALLER" "$mut_stub"
|
types_in_sync pull_request_target "$CALLER" "$mut_stub"
|
||||||
sed 's/, edited//' "$CALLER" >"$mut_caller"
|
sed 's/, closed//' "$CALLER" >"$mut_caller"
|
||||||
check "an issue type dropped from the caller goes red" 1 "" \
|
check "an issue type dropped from the caller goes red" 1 "" \
|
||||||
types_in_sync issues "$mut_caller" "$STUB"
|
types_in_sync issues "$mut_caller" "$STUB"
|
||||||
sed 's/, edited//' "$STUB" >"$mut_stub"
|
sed 's/, closed//' "$STUB" >"$mut_stub"
|
||||||
check "an issue type dropped from the stub goes red" 1 "" \
|
check "an issue type dropped from the stub goes red" 1 "" \
|
||||||
types_in_sync issues "$CALLER" "$mut_stub"
|
types_in_sync issues "$CALLER" "$mut_stub"
|
||||||
sed 's/closed, reopened/reopened, closed/' "$STUB" >"$mut_stub"
|
sed 's/opened, closed/closed, opened/' "$STUB" >"$mut_stub"
|
||||||
check "an issue-list reorder in one file only goes red" 1 "" \
|
check "an issue-list reorder in one file only goes red" 1 "" \
|
||||||
types_in_sync issues "$CALLER" "$mut_stub"
|
types_in_sync issues "$CALLER" "$mut_stub"
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue