Files
cozystack/hack/issue-triage-contract.bats
Myasnikov Daniil cc942fed68 test(tests): run unit suites with Bats and retain POSIX coverage
The shell translator rewrites test bodies and loses Bats reporting.
The unit lane needs complete TAP results and JUnit reports without
changing how live-cluster suites run.

Sourced production helpers still need a dash pass, and unit test bodies
need nounset without exporting shell options into subprocesses.

Assisted-by: LLM
Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com>
2026-10-07 11:04:00 +03:00

434 lines
21 KiB
Bash

#!/usr/bin/env bats
# Contract for the issue triage labeler. Like promote-gate-contract.bats and
# release-freeze-contract.bats, these tests pin executable/structural workflow
# lines rather than prose: a guard demoted to a comment must never satisfy the
# contract. The workflow's own comments name every API and constant asserted
# below, so the JS block is filtered on `//` as well as `#`.
#
# No PR lane can exercise this workflow. It runs on `issues`, on a daily cron
# and on workflow_dispatch, so the first real execution is a sweep over every
# open issue in the repository, writing a label to each. The lines pinned here
# are the ones that bound that blast radius: the skip that keeps pull requests
# out of it, the skip that keeps an already-triaged issue out of it, the pacing
# and the per-run cap that keep the sweep inside GitHub's secondary rate limits,
# the pinned action, and the token scopes. Each is a single deletion away from a
# repository-wide mess that nothing reports — drop the pull-request skip and
# every open PR is told it needs triage; drop the pacing and the sweep is
# throttled and dies half done.
#
# The rate-limit tests assert the invariant (stay under GitHub's published
# ceilings) rather than today's constants, so tuning the pacing within the safe
# band does not need a test edit, while leaving it does go red.
#
# Known ceiling of the approach: these are structural greps, so they prove a
# guard is present and executable, not that it is reachable. Wrapping the block
# around a guard in `if (false)` satisfies every pin here, because each matches
# the guard's own line and nothing above it; editing the pinned line itself does
# not, so `if (false && issue.pull_request)` goes red. Behavioural coverage of the script
# would need a JS runtime, and `checks` runs where none is guaranteed, so a
# harness would go silently green wherever node is missing.
load test_helper
REPO_ROOT="$(cd "$(dirname "${BATS_TEST_FILENAME:-$0}")/.." && pwd)"
WF="$REPO_ROOT/.github/workflows/issue-triage.yaml"
STALE="$REPO_ROOT/.github/workflows/stale.yaml"
LABELS="$REPO_ROOT/.github/labels.yml"
# GitHub's documented secondary rate limits for content-generating requests.
# https://docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api
WRITES_PER_MINUTE=80
WRITES_PER_HOUR=500
# Strip YAML comments. POSIX `grep` only: the unit-test runner has no ripgrep.
# grep exits 1 when nothing is selected (legitimate for an all-comment block)
# and 2 on a real error. Do not read the `rc` check below as what catches that,
# here or in script_lines: neither helper is ever the last stage of a pipeline
# in this file, so ordinary (non-pipefail) pipe semantics discard whatever it
# returns, and `hack/cozytest.sh`'s translator appends `return 0` to every line
# that is exactly `}`, both helpers' closing braces included. What catches a
# real grep error is the OUTPUT, and only for one class of pin: the error
# empties the stream, so a pin requiring something to be present in it fails on
# that. A pin demanding an absence stays blind, because an emptied stream
# produces the same count of zero the pin was written to assert. The
# `[ -n "$block" ]` guard at the top of a test is not that check either: it
# runs on the raw awk extraction, upstream of both helpers, and cannot see
# either of them fail.
code_lines() {
local rc=0
grep -v '^[[:space:]]*#' || rc=$?
[ "$rc" -le 1 ]
}
# Strip JavaScript comments. The labeling logic lives in a github-script
# `script: |` block whose comments discuss the pull_request skip, the cap and
# the pacing by name, so filtering on `#` alone would let a comment satisfy a
# pin.
script_lines() {
local rc=0
grep -v '^[[:space:]]*//' || rc=$?
[ "$rc" -le 1 ]
}
# Body of one job, up to the next top-level job key.
job_block() {
awk -v job=" $1:" '
$0 == job { inside = 1; next }
/^ [a-z0-9_-]+:$/ { inside = 0 }
inside' "$2"
}
# Integer on the right of the first `=` on the first matching line. Callers
# assert the plain-literal form first, because this strips non-digits rather
# than evaluating: `= 60 * 1000;` would read as 601000 and quietly satisfy a
# bound it never computed.
number_after_eq() {
awk -F'=' 'NR == 1 { gsub(/[^0-9]/, "", $2); print $2 }'
}
# The declaration this file can read: one integer literal, no arithmetic.
literal_const() { # <name> <block>
printf '%s\n' "$2" | script_lines | grep -qE "const $1 = [0-9]+;[[:space:]]*$"
}
# The accepted-signal list, one label per line.
signal_labels() {
script_lines < "$WF" |
awk '/const ACCEPTED_SIGNAL_LABELS = \[/ { inside = 1; next } /\];/ { inside = 0 } inside' |
sed -n "s/^[[:space:]]*'\([^']*\)'.*/\1/p"
}
# Minutes past midnight for a 5-field cron, from its first two fields.
cron_minutes() {
sed "s/^[^']*'//; s/'.*$//" | awk 'NR == 1 { print $2 * 60 + $1 }'
}
@test "the workflow under contract exists" {
[ -f "$WF" ]
}
# ── what the sweep must not touch ────────────────────────────────────────────
# listForRepo returns pull requests alongside issues, and the sweep re-reads
# every open issue every day. Both skips below are the difference between a
# no-op pass and a repository-wide mislabeling that no run reports as an error.
@test "pull requests are skipped by the classifier" {
block="$(job_block triage "$WF")"
[ -n "$block" ]
# Losing this tells every open pull request it needs triage, on the first
# sweep, with a green job.
printf '%s\n' "$block" | script_lines | grep -qF 'if (issue.pull_request) return null'
}
@test "an issue that already carries a triage label is left alone" {
block="$(job_block triage "$WF")"
[ -n "$block" ]
# The sweep is additive and never removes a label, so without this skip a
# daily pass re-adds a second, contradictory triage/* label to issues a
# maintainer has already classified.
printf '%s\n' "$block" | script_lines | grep -qF "labels.some((n) => n.startsWith('triage/'))"
}
# ── staying inside GitHub's secondary rate limits ────────────────────────────
# The sweep is the only bulk writer in the tree. The action's client carries no
# throttling plugin, its retry default is off, and a throttled write answers
# 403, which is exempt from retry anyway: an unpaced sweep does not slow down
# when it is throttled, it fails the job partway and leaves the backlog half
# labeled.
@test "writes are paced under the per-minute ceiling" {
block="$(job_block triage "$WF")"
[ -n "$block" ]
# The wait has to be executed, not merely declared.
printf '%s\n' "$block" | script_lines | grep -qF 'await sleep(WRITE_INTERVAL_MS)'
literal_const WRITE_INTERVAL_MS "$block"
interval="$(printf '%s\n' "$block" | script_lines | grep -F 'const WRITE_INTERVAL_MS' | number_after_eq)"
[ -n "$interval" ]
[ "$interval" -gt 0 ]
# 60000 / interval is the resulting writes-per-minute. Strictly under, for
# the same reason the hourly cap below is: the event-driven writes share this
# ceiling, so a sweep sized exactly to it leaves them nothing.
rate="$(awk -v i="$interval" 'BEGIN { print int(60000 / i) }')"
[ "$rate" -lt "$WRITES_PER_MINUTE" ]
}
@test "a single run cannot exceed the hourly ceiling" {
block="$(job_block triage "$WF")"
[ -n "$block" ]
# The cap has to gate the loop, not just exist as a constant.
printf '%s\n' "$block" | script_lines | grep -qF 'if (writes >= MAX_WRITES_PER_RUN)'
literal_const MAX_WRITES_PER_RUN "$block"
cap="$(printf '%s\n' "$block" | script_lines | grep -F 'const MAX_WRITES_PER_RUN' | number_after_eq)"
[ -n "$cap" ]
[ "$cap" -gt 0 ]
# Strictly under: a run sized exactly to the ceiling leaves no room for the
# event-driven writes that share the same hourly budget.
[ "$cap" -lt "$WRITES_PER_HOUR" ]
}
@test "a hung run cannot hold the queue for the default six hours" {
block="$(job_block triage "$WF")"
[ -n "$block" ]
# Every run of this workflow shares one concurrency group, so a call that
# hangs blocks each issue filed behind it until the job times out. A capped
# run paces roughly seven minutes, so the bound is generous and still far
# under the 360-minute default.
minutes="$(printf '%s\n' "$block" | code_lines | grep -F 'timeout-minutes:' | awk -F: 'NR == 1 { gsub(/[^0-9]/, "", $2); print $2 }')"
[ -n "$minutes" ]
[ "$minutes" -gt 0 ]
[ "$minutes" -lt 60 ]
}
@test "the manual entry point defaults to dry-run" {
# Bounded by indentation: nothing below the input is a key at this level, so
# a stop condition matching the next sibling key would run to end of file and
# call the rest of the workflow part of the block.
block="$(code_lines < "$WF" | awk '
/^ dry_run:$/ { inside = 1; next }
inside && !/^ / { exit }
inside')"
[ -n "$block" ]
# workflow_dispatch is the only way a human starts a repository-wide write,
# and this is the whole of what stands between clicking Run and one. Every
# other guard of that class is pinned above; this one is a single word away
# from writing to every open issue on the first click, which is exactly the
# shape the rest of this file exists to catch.
default="$(printf '%s\n' "$block" | awk '/^[[:space:]]*default:/ { print $2; exit }')"
[ "$default" = "true" ]
# The declared default is what the form shows. It is deliberately not what
# makes this safe: the derivation keys on the event and treats an absent or
# unrecognised input as a dry run, which is why it is pinned separately.
# Pinning only the default would leave `const dryRun = false;` green.
wf="$(job_block triage "$WF")"
printf '%s\n' "$wf" | script_lines | grep -qF "context.eventName !== 'schedule' &&"
printf '%s\n' "$wf" | script_lines | grep -qF "String(context.payload.inputs?.dry_run ?? 'true') !== 'false'"
}
@test "a failed write neither aborts the sweep nor passes unnoticed" {
block="$(job_block triage "$WF")"
[ -n "$block" ]
# Without the catch, one issue transferred or deleted mid-sweep costs the run
# the rest of the backlog and its summary line with it.
printf '%s\n' "$block" | script_lines | grep -qF 'catch (err)'
# A refusal is the other kind of failure and wants the opposite reaction: 403
# is what both a lost `issues: write` and the secondary rate limit answer, 429
# is the other throttle shape. The status is what separates them from the
# benign 404 of a vanished issue, so the discrimination has to be on status.
printf '%s\n' "$block" | script_lines | grep -qE "err\.status === 403.*err\.status === 429|err\.status === 429.*err\.status === 403"
# And one refusal has to be enough to go red. Counting instead would make the
# alarm dead in the regime the sweep lives in once the backlog is drained,
# where a whole run writes to only a handful of issues.
printf '%s\n' "$block" | script_lines | grep -qF 'if (refused > 0)'
printf '%s\n' "$block" | script_lines | grep -qF 'core.setFailed'
# And the run has to stop at the first refusal rather than carry on. A
# refusal applies to every write left, so continuing repeats a rejected
# request up to the cap, which is what gets an integration banned and what
# burns the repository's hourly budget on a permission regression.
# -A10 rather than -A5: the warning between the two is prose and gains a
# line whenever it is reworded, which would fail this on a no-op edit.
printf '%s\n' "$block" | script_lines | grep -A10 -F 'refused += 1;' | grep -qF 'break;'
}
@test "the sweep reports what it did in a job summary" {
block="$(job_block triage "$WF")"
[ -n "$block" ]
# A sweep over the backlog logs a line per issue, so the summary is the only
# place a reader gets the outcome without paging through it. `write()` is
# async: unawaited, the promise can go unflushed and the step ends green with
# an empty panel, which is indistinguishable from a run that did nothing.
printf '%s\n' "$block" | script_lines | grep -qF 'await core.summary'
printf '%s\n' "$block" | script_lines | grep -qF '.write();'
# Every outcome a run can end on has to reach it, at zero as much as at ten:
# reporting the writes and dropping the rest is what makes a run that stopped
# early read as one that got to the end. The refusal itself is pinned by the
# failure test above; what is pinned here is that the table accounts for it,
# because otherwise the rows silently fail to sum to the listing.
printf '%s\n' "$block" | script_lines | grep -qF '${deferred}'
printf '%s\n' "$block" | script_lines | grep -qF '${unexamined}'
printf '%s\n' "$block" | script_lines | grep -qF '${refused}'
}
# ── supply chain and token scope ─────────────────────────────────────────────
@test "every action is pinned to a digest" {
# zizmor gates this tree-wide, but this file is the one that fails on the
# workflow that has issues: write, so it pins its own.
# Asserted as "every `uses:` is a digest" rather than "no tag matches a
# tag-shaped pattern", because every such pattern has a gap: `@main` is
# neither `@v7` nor `@1.2.3`, and a branch is the most mutable of the three.
# Counting digests rather than naming the one action keeps a second, properly
# pinned step from going red for no reason. A repo-local `./.github/actions/`
# step would need an exemption here; it is not the same class of risk.
total="$(code_lines < "$WF" | grep -cE '^[[:space:]]*(-[[:space:]]*)?uses:' || true)"
pinned="$(code_lines < "$WF" | grep -cE '^[[:space:]]*(-[[:space:]]*)?uses:.*@[0-9a-f]{40}([[:space:]]|$)' || true)"
[ "${total:-0}" -gt 0 ]
[ "${total:-0}" -eq "${pinned:-0}" ]
}
@test "the token is read-only except for one job that writes issues" {
# A job-level block replaces the top-level one rather than extending it, so
# the two assertions below are the whole permission surface.
code_lines < "$WF" | grep -qF 'contents: read'
# Anchored to the YAML key, not matched as a substring. `code_lines` strips
# `#` comments only, and `issues: write` also appears inside the script block
# in a `//` comment and in a `core.setFailed` string, so a substring match is
# satisfied by prose and stays green when the job's one scope is swapped for
# `contents: write`. The count below does not catch that either: the swapped
# line is still exactly one `write`.
printf '%s\n' "$(job_block triage "$WF")" | code_lines |
grep -qE '^[[:space:]]+issues:[[:space:]]*write[[:space:]]*(#.*)?$'
# Nothing else is writable. This is what keeps a labeling workflow from
# quietly acquiring contents: write or pull-requests: write later. The
# trailing-comment form is matched too, because a permission gets added
# with its justification on the same line, which is exactly the shape an
# assertion anchored at `write$` would wave through.
count="$(code_lines < "$WF" | grep -cE '^[[:space:]]+[a-z-]+:[[:space:]]*write[[:space:]]*(#.*)?$' || true)"
[ "${count:-0}" -eq 1 ]
}
# ── interaction with the rest of the tree ────────────────────────────────────
@test "runs are serialised and never cancelled" {
# A sweep works from a listing taken when it started. Letting a second run
# overlap it lets the two read the same issue differently and leave it
# holding two contradictory labels; cancelling one mid-sweep leaves the
# backlog half labeled with a green-looking cancelled run.
block="$(code_lines < "$WF" | awk '/^concurrency:/ { inside = 1; next } /^[a-z]/ { inside = 0 } inside')"
[ -n "$block" ]
printf '%s\n' "$block" | grep -qF 'cancel-in-progress: false'
}
@test "no accepted-signal label widens the stale exemption set" {
[ -f "$STALE" ]
# `triage/accepted` is itself on exempt-issue-labels, so awarding it for a
# label that is NOT on that list quietly grants a permanent reprieve the
# stale policy withheld — which is what treating every `priority/*` tier as
# a signal did, `priority/backlog` being the tier stale.yaml leaves reapable.
# An assignee is deliberately not covered here: it is not a label, and its
# exemption is the one the workflow argues for out loud.
exempt="$(awk '
/^[[:space:]]*exempt-issue-labels:/ { inside = 1; next }
/^[[:space:]]*[a-z-]+:/ { inside = 0 }
inside' "$STALE")"
[ -n "$exempt" ]
# One entry per line, so the containment check below can be exact. The value
# is a folded scalar: comma-separated entries wrapped across several indented
# lines, each line carrying a trailing comma. Matching a signal label as a
# substring of that block would pass one that is merely a prefix of an exempt
# entry — `priority/important` against `priority/important-soon` — and that
# prefix is a label the stale policy never exempted.
exempt_labels="$(printf '%s\n' "$exempt" | tr ',' '\n' |
sed 's/^[[:space:]]*//; s/[[:space:]]*$//' | grep -v '^$')"
[ -n "$exempt_labels" ]
signals="$(signal_labels)"
# Non-vacuous: an empty list, or a classifier that stopped reading it, must
# not let this pass by having nothing to check. One, not a higher floor —
# a floor above the stated purpose fails on a legitimate removal that keeps
# the list non-empty, which is a test edit with no contract behind it. This
# branch shortened the list once already.
count="$(printf '%s\n' "$signals" | grep -c . || true)"
[ "${count:-0}" -ge 1 ]
printf '%s\n' "$(job_block triage "$WF")" | script_lines | grep -qF 'ACCEPTED_SIGNAL_LABELS.includes(n)'
for label in $signals; do
printf '%s\n' "$exempt_labels" | grep -qxF "$label"
done
}
@test "no lifecycle label sits in the accepted-signal list" {
# Membership of stale.yaml's exempt list is necessary for a signal but not
# sufficient, and the test above can only check the necessary half. A
# `lifecycle/*` label states what the stale bot may do with an issue and
# nothing about whether anybody reviewed it, so `lifecycle/frozen` is exempt
# and still not a signal: freezing an untriaged issue to keep the bot off it
# is exactly the case where reading it as accepted is false.
#
# `priority/backlog` is excluded for a different reason and needs no assertion
# of its own, because it is not on the exempt list and the test above already
# rejects it. That is a coincidence of the stale policy rather than a decision
# taken here, and it stops covering the tier the moment somebody exempts it.
signals="$(signal_labels)"
# Guards the assertion below against passing on an empty list, which is what
# a broken extraction and a compliant workflow would otherwise look like.
[ -n "$signals" ]
# Capture grep's status instead of writing `! grep -q ...`, which cannot fail
# a test here. Two mechanisms, and the second is the one that carries this
# case: `set -e` ignores a pipeline led by `!`, so such an assertion mid-body
# never aborts, and `hack/cozytest.sh`'s translator appends `return 0` to
# every line that is exactly `}`, so one written as the body's last statement
# does not decide the status either. Under real bats the trailing form does
# go red, which is the only position where the two runners differ at all: a
# negated assertion with anything after it is green under both. So the rule
# is not "bats is the honest runner", it is that no position is safe. Every
# negative assertion in this file takes this shape, and a mutation ladder
# proving otherwise has to run the runner CI uses.
rc=0
printf '%s\n' "$signals" | grep -q '^lifecycle/' || rc=$?
[ "$rc" -ne 0 ]
}
@test "the sweep runs after the stale bot, not before it" {
[ -f "$STALE" ]
# Labeling bumps updated_at, which the stale bot reads as activity. Running
# first would hand every issue it is about to label a fresh clock in the same
# morning, so the sweep goes second and lets that day's stale pass complete
# on the state it was scheduled against.
# `-e`, because the pattern opens with a dash and grep would read it as one.
sweep="$(code_lines < "$WF" | grep -F -e '- cron:' | cron_minutes)"
stale="$(code_lines < "$STALE" | grep -F -e '- cron:' | cron_minutes)"
[ -n "$sweep" ] && [ -n "$stale" ]
[ "$sweep" -gt "$stale" ]
}
@test "both labels the workflow applies are declared in the repository" {
[ -f "$LABELS" ]
# addLabels creates a label that does not exist, with a default colour and no
# description, so a typo here is invisible until someone notices two triage
# labels with almost the same name.
#
# Whole-line match, for the same reason the exempt-list check above splits its
# block one entry per line: a substring match accepts a truncation, and a
# truncation is exactly what a typo looks like. `name: triage/needs-triag`
# occurs inside `- name: triage/needs-triage`, so `grep -F` alone reports the
# misspelled label as declared and the sweep then creates it on first write.
# `-e`, because the whole-line pattern opens with the list dash and grep would
# otherwise read it as an option, the same reason the cron check above needs it.
#
# The trailing comment is stripped before the match rather than allowed by the
# pattern, so the check stays a fixed-string one: the label is interpolated,
# and a regex would give whatever it contains meaning it does not have. A
# declaration carrying its justification inline is the shape the permission
# count above already tolerates, and there is no contract against it here.
for label in $(script_lines < "$WF" | grep -E "const (NEEDS_TRIAGE|ACCEPTED) = " | sed "s/^[^']*'//; s/'.*$//"); do
code_lines < "$LABELS" | sed 's/[[:space:]]*#.*$//; s/[[:space:]]*$//' |
grep -qxF -e "- name: $label"
done
}