Files
cozystack/hack/label-workflows-contract.bats
Aleksei Sviridkin be55f6eb2b test(ci): load the shared bats helper in the labeler contract test
Every unit bats file under hack/ must load test_helper so it runs with
nounset, and hack/bats-strict-setup.bats fails the suite for a file
that does not.

Assisted-by: LLM
Signed-off-by: Aleksei Sviridkin <f@lex.la>
2026-10-11 18:42:11 +03:00

243 lines
9.4 KiB
Bash

#!/usr/bin/env bats
# Contract for the two label-writing workflows: pr-labeler.yaml (which labels
# it may apply and from what input) and stale.yaml (which labels drive the
# auto-close policy and who is exempt). Every label name either workflow can
# write or match must resolve against .github/labels.yml — a mistyped name is
# not an error at runtime, it is a rule that silently stops matching.
#
# These tests intentionally pin executable/structural lines, not prose: a
# mapping demoted to a YAML `#` or JavaScript `//` comment must never satisfy
# the contract. See hack/promote-gate-contract.bats for the pattern.
load test_helper
REPO_ROOT="$(cd "$(dirname "${BATS_TEST_FILENAME:-$0}")/.." && pwd)"
PR_LABELER="$REPO_ROOT/.github/workflows/pr-labeler.yaml"
STALE="$REPO_ROOT/.github/workflows/stale.yaml"
LABELS="$REPO_ROOT/.github/labels.yml"
DOCS="$REPO_ROOT/docs/agents/contributing.md"
# Comment-stripping filters, one per language in these files. grep exits 1 on
# an empty selection (legitimate for an empty block) and 2 on a real error, so
# only the latter propagates.
code_lines() {
local rc=0
grep -v '^[[:space:]]*#' || rc=$?
[ "$rc" -le 1 ]
}
script_lines() {
local rc=0
grep -v '^[[:space:]]*//' || rc=$?
[ "$rc" -le 1 ]
}
# Body of a `const <name> = {` ... `};` object literal inside the
# github-script block. Comments are stripped BEFORE block detection so a
# commented-out `// const typeToKind = {` can never open the block.
js_object_block() {
script_lines < "$2" | awk -v name="const $1 = {" '
index($0, name) { inside = 1; next }
inside && /^[[:space:]]*};[[:space:]]*$/ { exit }
inside'
}
# The type -> kind/* table under "Type → `kind/*`" in docs/agents/contributing.md,
# as "<type> <label>" lines. A row listing several types yields one line per
# type; a "(no kind label)" row yields "-" as the label, so a type documented
# as unmapped is distinguishable from one the table does not mention.
doc_type_table() {
awk '
/^\*\*Type .*kind\/\*.*:\*\*/ { inside = 1; next }
inside && /^\|/ {
started = 1
if ($0 ~ /^\|[[:space:]]*type[[:space:]]*\|/ || $0 ~ /^\|[[:space:]-]*\|[[:space:]-]*\|/) next
line = $0
sub(/^\|[[:space:]]*/, "", line)
sub(/[[:space:]]*\|[[:space:]]*$/, "", line)
split(line, cell, /[[:space:]]*\|[[:space:]]*/)
label = cell[2]
if (label == "(no kind label)") label = "-"
n = split(cell[1], types, /[[:space:]]*,[[:space:]]*/)
for (i = 1; i <= n; i++) print types[i], label
next
}
inside && started { exit }
' "$DOCS"
}
# The executable typeToKind mappings in pr-labeler.yaml, as "<type> <label>"
# lines. Comment-stripped by js_object_block, so a mapping demoted to a
# `//` comment is not listed.
workflow_type_table() {
js_object_block typeToKind "$PR_LABELER" \
| sed -nE "s/^[[:space:]]*([A-Za-z]+):[[:space:]]*'([^']*)',?[[:space:]]*$/\1 \2/p"
}
# Every label defined in .github/labels.yml, one per line. Parses the value
# after `- name: ` verbatim (labels may contain spaces, e.g. "help wanted"),
# stripping optional quotes and trailing inline comments.
defined_labels() {
code_lines < "$LABELS" | awk '/^- name: / {
sub(/^- name: /, "")
sub(/[[:space:]]+#.*$/, "")
sub(/[[:space:]]+$/, "")
gsub(/^"|"$/, "")
print
}'
}
# `key: value` inputs of the stale action; whole-line and trailing inline
# comments are both stripped from the value.
stale_input() {
code_lines < "$STALE" | awk -v key=" $1: " '
index($0, key) == 1 {
sub(key, "")
sub(/[[:space:]]+#.*$/, "")
sub(/[[:space:]]+$/, "")
print
exit
}'
}
# A `>-` folded list input of the stale action, flattened to one label per line.
stale_exempt_list() {
code_lines < "$STALE" | awk -v key=" $1: >-" '
$0 == key { inside = 1; next }
inside && !/^ / { exit }
inside { print }' | tr -d ' ' | tr ',' '\n' | grep -v '^$'
}
assert_labels_defined() {
# $1: newline-separated label names. Fails naming the missing ones.
# No process substitution here: hack/cozytest.sh runs these files in plain
# sh, which lacks it. (`local` is fine — dash/ash/bash all support it, and
# the other contract files in hack/ already use it.)
local all missing
all="$(defined_labels)"
missing="$(printf '%s\n' "$1" | sort -u | while read -r l; do
[ -n "$l" ] || continue
printf '%s\n' "$all" | grep -qxF "$l" || printf '%s\n' "$l"
done)"
if [ -n "$missing" ]; then
echo "labels not defined in .github/labels.yml: $missing" >&2
return 1
fi
}
@test "every label pr-labeler can apply resolves against labels.yml" {
kind_labels="$(js_object_block typeToKind "$PR_LABELER" | grep -o "'kind/[^']*'" | tr -d "'")"
[ -n "$kind_labels" ]
assert_labels_defined "$kind_labels"
area_labels="$(js_object_block scopeToArea "$PR_LABELER" | grep -o "'area/[^']*'" | tr -d "'")"
[ -n "$area_labels" ]
assert_labels_defined "$area_labels"
# Labels added outside the two tables: backport prefix, breaking-change
# footer, and the no-area fallback.
literal_labels="$(code_lines < "$PR_LABELER" | script_lines \
| grep -o "toAdd.add('[^']*')" | sed "s/toAdd.add('//; s/')//" || true)"
for l in area/release kind/breaking-change area/uncategorized; do
printf '%s\n' "$literal_labels" | grep -qxF "$l"
done
assert_labels_defined "$literal_labels"
}
@test "pr-labeler's type mappings are the table in docs/agents/contributing.md" {
documented="$(doc_type_table)"
[ -n "$documented" ]
mapped="$(workflow_type_table)"
[ -n "$mapped" ]
# Mapped rows: the same set in both files, in both directions, so a row
# added, dropped, retyped or demoted to a `//` comment on either side fails.
expected="$(printf '%s\n' "$documented" | grep -v ' -$' | sort)"
actual="$(printf '%s\n' "$mapped" | sort)"
if [ "$expected" != "$actual" ]; then
printf 'docs table:\n%s\nworkflow typeToKind:\n%s\n' "$expected" "$actual" >&2
return 1
fi
# Rows the table documents as unmapped must have no key in typeToKind.
unmapped="$(printf '%s\n' "$documented" | awk '$2 == "-" { print $1 }')"
[ -n "$unmapped" ]
for t in $unmapped; do
count="$(printf '%s\n' "$actual" | grep -c "^$t " || true)"
[ "${count:-0}" -eq 0 ]
done
}
@test "pr-labeler subscribes to opened/reopened/synchronize and not edited" {
count="$(code_lines < "$PR_LABELER" | grep -cF ' types: [opened, reopened, synchronize]' || true)"
[ "${count:-0}" -eq 1 ]
# 'edited' is deliberately omitted (see the comment above the trigger); it
# must not creep back in as an executable line — in either the flow-sequence
# or block-sequence spelling. Scan the whole trigger section (before jobs:).
count="$(code_lines < "$PR_LABELER" | awk '/^jobs:/ { exit } { print }' | grep -cw 'edited' || true)"
[ "${count:-0}" -eq 0 ]
}
@test "pr-labeler is additive only and falls back to area/uncategorized" {
lines="$(code_lines < "$PR_LABELER" | script_lines)"
count="$(printf '%s\n' "$lines" | grep -cF "toAdd.add('area/uncategorized')" || true)"
[ "${count:-0}" -eq 1 ]
# Only labels not already present are sent, and only via addLabels: a
# labeler that starts removing or replacing labels is a different tool.
count="$(printf '%s\n' "$lines" | grep -cF '!existing.has(l)' || true)"
[ "${count:-0}" -eq 1 ]
count="$(printf '%s\n' "$lines" | grep -cF 'addLabels' || true)"
[ "${count:-0}" -eq 1 ]
count="$(printf '%s\n' "$lines" | grep -cE 'removeLabel|setLabels' || true)"
[ "${count:-0}" -eq 0 ]
}
@test "every label stale.yaml writes or matches resolves against labels.yml" {
for key in stale-issue-label stale-pr-label close-issue-label close-pr-label; do
value="$(stale_input "$key")"
[ -n "$value" ]
assert_labels_defined "$value"
done
assert_labels_defined "$(stale_exempt_list exempt-issue-labels)"
assert_labels_defined "$(stale_exempt_list exempt-pr-labels)"
}
@test "stale policy pins: label choices, exempt list sizes, day counts, write cap" {
[ "$(stale_input stale-issue-label)" = "lifecycle/stale" ]
[ "$(stale_input stale-pr-label)" = "lifecycle/stale" ]
[ "$(stale_input close-issue-label)" = "lifecycle/rotten" ]
[ "$(stale_input close-pr-label)" = "lifecycle/rotten" ]
count="$(stale_exempt_list exempt-issue-labels | wc -l | tr -d ' ')"
[ "$count" -eq 12 ]
count="$(stale_exempt_list exempt-pr-labels | wc -l | tr -d ' ')"
[ "$count" -eq 4 ]
# The renewal path: frozen exempts both; an accepted issue or a held PR is
# deliberate, not stale.
stale_exempt_list exempt-issue-labels | grep -qxF 'lifecycle/frozen'
stale_exempt_list exempt-issue-labels | grep -qxF 'triage/accepted'
stale_exempt_list exempt-pr-labels | grep -qxF 'lifecycle/frozen'
stale_exempt_list exempt-pr-labels | grep -qxF 'do-not-merge/hold'
[ "$(stale_input days-before-stale)" = "60" ]
[ "$(stale_input days-before-close)" = "14" ]
[ "$(stale_input operations-per-run)" = "100" ]
[ "$(stale_input remove-stale-when-updated)" = "true" ]
}
@test "stale runs only on a daily schedule and manual dispatch" {
triggers="$(code_lines < "$STALE" | awk '/^on:/ { inside = 1; next }
inside && /^[^[:space:]]/ { exit }
inside')"
[ -n "$triggers" ]
printf '%s\n' "$triggers" | grep -qF " - cron: '37 4 * * *'"
printf '%s\n' "$triggers" | grep -qE '^ workflow_dispatch:'
# No event trigger may run the write policy on user activity.
count="$(printf '%s\n' "$triggers" | grep -cE '^ (push|pull_request|pull_request_target|issues|issue_comment):' || true)"
[ "${count:-0}" -eq 0 ]
}