Files
cozystack/hack/select-e2e.sh
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

613 lines
35 KiB
Bash
Executable File

#!/bin/sh
# Read a list of changed files (one per line) and emit space-separated suite
# names whose Chainsaw suites under hack/e2e-chainsaw/ should run.
#
# Usage: hack/select-e2e.sh <changed-files> [<sources-dir>]
# Defaults: sources-dir = packages/core/platform/sources
#
# Output:
# - empty no E2E impact, decided by one of the rules that select
# nothing: step 1 for docs/, dashboards/ and *.md, step 2 for
# a *.disabled Chainsaw suite and for an
# examples/backups/<app>/ with no suite, and
# inert_config_pattern for repo meta and agent config
# - <suite names> selected per the PackageSource dependency graph
# - full list any path that affects all tests, OR an unrecognised
# packages/* path, OR a changed package that reaches no
# runnable suite through the graph, OR a path matching NEITHER
# the full-suite nor the inert list, OR a yq that failed to
# build the dependency graph (conservative fallbacks)
# - nothing, and the suite list itself is unavailable: find failed, or
# a non-zero hack/e2e-chainsaw holds no suite file. Unlike the
# exit yq case there is no fallback left to take — an empty list
# silently corrupts both the escalations, which would print
# it, and the backups rule, which membership-tests against
# it — so the script refuses to answer at all, and since both
# lanes run it under `bash -e` the step fails
#
# Every changed path must land in exactly one of the three selection classes by
# an explicit rule; the fourth outcome is not a classification but a refusal to
# produce one. An unclassified path escalates to the full suite rather than
# selecting nothing: both e2e lanes read an empty selection as "skip Chainsaw"
# and then post the required "E2E Tests" status green, so a silent default is a
# green gate with no suite run (#3392). When that escalation fires for a path
# that genuinely cannot affect e2e, add it to inert_config_pattern — do not
# widen the fall-through.
#
# "Empty" is not a synonym for inert_config_pattern. Several rules select
# nothing: step 1 for docs/, dashboards/ and *.md, step 2 for a *.disabled
# Chainsaw suite and for an examples/backups/<app>/ whose app has no suite, and
# the pattern itself. The property the lanes depend on is weaker than "matched
# the inert list" and it is the one to preserve: an empty selection is a
# decision some rule reached, never a path nothing looked at.
#
# Every branch that escalates names its cause on stderr, and that is a contract
# rather than a courtesy: the answer "run everything" is the same list of every
# suite whichever rule produced it, so without a reason line the only way to learn why
# a pull request ran the whole suite is to re-derive the selection by hand. Four
# of them were silent before this rule, full_suite_pattern — the commonest cause
# by a wide margin — among them, which made the usual answer the one the log
# never had. A test asserts each line, because a reason that regresses to silence
# changes no selection and so is invisible to every other test here. Deliberately
# stated without a count: the number of escalating branches moves whenever a rule
# is added, and a comment carrying a stale one reads as authoritative.
#
# The lines go to stderr, never stdout. stdout is the suite list and both lanes
# parse it, so a reason line there would be read as a suite name.
#
# One caveat worth knowing before trusting that: inert_config_pattern makes
# .github/ inert as a whole directory, and the e2e workflows are exempted by
# NAME in full_suite_pattern, which is checked first. So a workflow added under
# .github/workflows/ that runs the suite is silently inert until someone adds it
# to that list — the fall-through never sees it. The enumeration is the guard,
# which is why it carries the `rg -l test-chainsaw` reminder; treat that comment
# as load-bearing rather than decorative.
set -eu
CHANGED="${1:?missing changed-files arg}"
SOURCES_DIR="${2:-packages/core/platform/sources}"
# Anything matching this pattern triggers the full Chainsaw suite. Per-suite
# edits (hack/e2e-chainsaw/<name>/...) are matched BEFORE this so editing one
# suite doesn't escalate to the full suite; the shared _lib helpers and the
# .chainsaw.yaml config affect every suite and DO escalate (handled inline).
#
# Three groups, all "cannot be scoped to one app":
# - shipped code and its build inputs: packages/library, packages/core, the Go
# trees (api, cmd, internal, pkg), the codegen under tools/, go.mod/go.sum,
# the root Makefile, hack/*.mk (the tag/push/output flags of every image),
# hack/buildkitd.toml (the builder every image is built with), and hack/lib/
# (sourced by the hack/*.sh scripts that already escalate);
# - the e2e harness itself: hack/*.sh, hack/e2e-*.bats and hack/e2e-*.yaml.
# The bats half is prefix-matched rather than taking every hack/*.bats,
# because the root Makefile splits those two sets by exactly that prefix —
# `BATS_UNIT_FILES := $(filter-out hack/e2e-%.bats,$(wildcard hack/*.bats))`
# — so the files it keeps are the unit lane and the e2e sandbox runs none
# of them. The ones it filters out are the ones packages/core/testing's
# recipes execute inside the sandbox, and they stay here. Narrowing this
# cannot leave a bats-only pull request untested: `make unit-tests` is gated
# on the `code` output, which pull-requests.yaml computes as "any path
# outside docs/" and never from this script, so the unit files run on their
# own lane whatever the selection here is;
# - the workflows that RUN the suite — enumerated rather than matched by
# prefix, so an unrelated workflow does not burn a full run. Keep this list
# in step with `rg -l test-chainsaw .github/workflows/`.
#
# What that costs, stated rather than left to be rediscovered: only
# pull-requests.yaml is executed from the PR's own head. e2e-fork runs on
# workflow_run, which always takes the default-branch copy; e2e-tag runs on
# workflow_call from a tag push; nightly runs on a schedule. So for those
# three the suite cannot exercise the edit under review, and escalating buys
# generic regression coverage rather than coverage of the change. Measured
# over the last six months of main: 26 commits touch one of the three with
# no other escalating path, and each now pays a full Chainsaw run it would
# previously have skipped. They stay on the list so the rule remains one
# idea ("workflows that run the suite") rather than two, and because the
# alternative leaves them inert, which reads as an oversight rather than a
# decision. Reopen the trade if the full suite's flake rate makes the
# generic coverage cost more than it returns.
full_suite_pattern='^(packages/library/|packages/core/|api/|cmd/|internal/|pkg/|tools/|hack/lib/|hack/[^/]+\.sh$|hack/e2e-[^/]+\.bats$|hack/[^/]+\.mk$|hack/buildkitd\.toml$|hack/e2e-[^/]+\.ya?ml$|go\.(mod|sum)$|Makefile$|\.github/workflows/(pull-requests|e2e-fork|e2e-tag|nightly)\.yaml$)'
# Paths with no bearing on what e2e exercises. Checked AFTER full_suite_pattern,
# so a specific escalation wins over a broad directory here (.github/ is inert,
# .github/workflows/pull-requests.yaml is not). This list is the whole reason
# the fall-through at the bottom of the loop can escalate safely: the cost of
# forgetting an inert path is a wasted full run, the cost of forgetting a live
# one used to be a green gate with nothing tested.
# - examples/ demo manifests; examples/backups/<app>/,
# examples/backups/_lib/ and any file directly in
# examples/backups/ are handled above as real test harness
# before this check is reached
# - .github/ templates, CODEOWNERS, labels, renovate, linter config —
# minus the e2e workflows escalated above
# - .claude/ .gemini/ agent config, never shipped
# - img/ README assets
# - hack/testdata/ fixtures for the bats unit tests, not for e2e
# - hack/*.bats the unit lane, minus the hack/e2e-*.bats escalated above.
# The root Makefile draws the line at that prefix
# (BATS_UNIT_FILES filters hack/e2e-%.bats out of
# hack/*.bats), so these files are never executed inside
# the e2e sandbox and no Chainsaw suite can regress from one.
# They are not untested by being inert here: `make
# unit-tests` runs them, gated on pull-requests.yaml's `code`
# output, which is "any path outside docs/" and is computed
# in the workflow rather than from this script
# - hack/test_helper.bash shared setup for that same unit-only BATS lane. It
# is loaded by BATS_UNIT_FILES and by nothing in the e2e
# sandbox; enumerate it rather than making every future
# top-level *.bash file inert
# - packages/tests/ helm-unittest fixture charts. cozy-lib-tests exercises
# library/cozy-lib from the outside; changing a test OF
# cozy-lib does not change cozy-lib, no PackageSource lists
# these paths as a component, and nothing installs them. A
# change to the library itself still escalates through
# packages/library/ in full_suite_pattern
# - boilerplate.go.txt / dcgm-default-counters.csv codegen header; a CSV read
# only by check-gpu-recording-rules.bats
# - the three .gitattributes ENUMERATED, not matched by name. Every
# .gitattributes in this tree holds nothing but
# `linguist-generated` markers — GitHub's language-stats and
# diff-collapsing hint, which reaches no build, chart or test
# — and these are the three whose classification the entry
# changes; the two under api/ and internal/ are escalated by
# full_suite_pattern first, so an entry for them would be
# dead. The justification is about what these files CONTAIN,
# and the file name does not carry it: .gitattributes can also
# set `filter`, `eol`, `working-tree-encoding` and
# `export-subst`, every one of which changes what lands in the
# working tree and therefore what gets built, so a by-name
# rule would silently make a live file inert. Same trade as
# the workflow enumeration above, and the same upkeep: after
# adding or moving one, re-check with
# `git ls-files '*.gitattributes' '.gitattributes'` and
# confirm the contents are still only linguist markers. A
# .gitattributes NOT on this list is classified by whatever
# rule its path falls under — the graph, or the unclassified
# fall-through — both of which fail safe
inert_config_pattern='^(examples/|\.github/|\.claude/|\.gemini/|img/|hack/testdata/|packages/tests/|hack/[^/]+\.bats$|hack/test_helper\.bash$|hack/boilerplate\.go\.txt$|hack/dcgm-default-counters\.csv$|LICENSE$|\.gitignore$|\.pre-commit-config\.yaml$|\.coderabbit\.yaml$|packages/system/\.gitattributes$|packages/system/(backup-controller|backupstrategy-controller)/definitions/\.gitattributes$)'
# All known Chainsaw suites: every dir directly under hack/e2e-chainsaw/ in
# which Chainsaw v0.2.15 would find a Test (pkg/discovery/load.go and step.go).
# A Test is a chainsaw-test.yaml, or chainsaw-test.yml when the .yaml is absent,
# or, with neither, files named like 01-install.yaml, which Chainsaw turns into
# a Test on its own. Chainsaw walks every subdirectory of the dir it is handed
# (pkg/utils/fs/discover.go), so a Test in a top-level dir or anywhere below it
# belongs to that dir. A suite that runs but is never listed is never selected.
# A parked chainsaw-test.yaml.disabled matches none of these. A step-file name
# counts unless the entry is a directory (TryFindStepFiles), so a symlink counts
# and `-type f` would be too narrow. A chainsaw-test.y*ml counts whatever its
# type: Chainsaw stats it and fails loudly on a directory, and leaving that
# suite out would hide the failure instead.
#
# Captured before the sed/sort rather than piped straight into them: a pipeline
# carries its LAST command's status, so `$(find ... | sed | sort)` would report
# sort's success whatever find did — the same blindness handled for yq below,
# and worse here, because this list is what every escalation prints. Errors go
# to stderr rather than /dev/null for the same reason.
if ! suite_files=$(find hack/e2e-chainsaw -mindepth 2 \( ! -type d -o -name chainsaw-test.yaml -o -name chainsaw-test.yml \)); then
echo "select-e2e: find failed listing the Chainsaw suites under hack/e2e-chainsaw — nothing can be decided without that list" >&2
exit 1
fi
all_apps=$(printf '%s\n' "$suite_files" \
| sed -nE 's,^hack/e2e-chainsaw/([^/]+)/(.*/)?(chainsaw-test|[0-9]+-[^/]*)\.ya?ml$,\1,p' | sort -u)
# An empty list here is a broken enumeration — a moved directory, a wrong
# working directory — not a project without tests, and it silently corrupts
# every answer the script can give:
#
# - the escalation branches print this list, so "run everything"
# becomes a blank line, which both lanes read as "skip Chainsaw" before
# posting the required "E2E Tests" status green;
# - the examples/backups/<app>/ rule takes no escalation and still consults
# it, as a membership test. With the list empty the test fails, the app is
# not selected, and an edit to a backup harness that should run its suite
# reports nothing to run instead.
#
# Neither is distinguishable in the output from a legitimately empty selection,
# which is the failure #3392 exists to keep out. Checking once here rather than
# at the escalation branches covers both, and covers the next consumer that
# reads the list without escalating.
#
# Both lanes run this step under `bash -e`, so the non-zero exit fails the job
# instead of falling through to the empty selection.
if [ -z "$all_apps" ]; then
echo "select-e2e: found no Chainsaw suite under hack/e2e-chainsaw (no chainsaw-test.yaml, chainsaw-test.yml or numbered step file in any directory below it) — the suite enumeration is broken (wrong working directory?), refusing to decide anything from an empty suite list" >&2
exit 1
fi
# Single exit point for every escalating branch, so the invariant above has
# one consumer to reason about rather than one copy per branch.
escalate_to_full_suite() {
echo "$all_apps" | paste -sd ' ' -
exit 0
}
# PackageSource name -> Chainsaw suite name(s). Most *-application sources map
# by stripping the suffix, a source that is not an app carries its own name
# (cozystack.kuberture owns the kuberture suite), and the few that match neither
# are listed explicitly.
#
# This is the inverse of select-install.sh's suite_to_source() and has to stay
# in step with it — a suite that does not round-trip is unreachable from its own
# package, so every change to that package escalates to the full run instead of
# selecting the one suite that covers it (#3665). The round-trip is pinned by a
# test rather than left to review.
#
# Mapping optimistically is safe: a name that is not a suite is dropped by
# intersect_suites() downstream.
#
# Only names the two general arms get wrong are listed. `postgres-application`
# and `external-dns` used to sit here and no longer do: the first is what
# stripping the suffix already produces, and the second is what carrying the
# source name through already produces, so both said the rule twice and made
# this list read as longer than the set of real exceptions.
src_to_suites() {
case "$1" in
vm-instance-application) echo vminstance ;;
# The vminstance suite creates a VMDisk and asserts the DataVolume behind it
# (hack/e2e-chainsaw/vminstance/vmdisk.yaml, vmdisk-vmi.yaml), so it is the
# suite that covers this source and everything reaching it, kubevirt-cdi
# included. Without the entry the source maps to a `vm-disk` suite that does
# not exist, is dropped by intersect_suites(), and a CDI change reaches
# nothing runnable through it.
vm-disk-application) echo vminstance ;;
# Both sources own system/ingress-nginx, and the gateway suite carries the
# legacy Ingress admission regression coverage. Without these explicit
# mappings their derived names point at suites that do not exist, so an
# ingress-only package change can omit the suite that exercises it.
ingress-application|ingress-nginx) echo gateway ;;
kubernetes-application) echo "kubernetes-latest kubernetes-previous" ;;
# apps/kubernetes-nodes also belongs to computeplane-application, which has
# a suite; without this entry an edit there selects computeplane alone,
# whose pools go through the module allowlist, and drops the suites that
# create KubernetesNodes natively.
kubernetes-nodes-application) echo "kubernetes-latest kubernetes-previous computeplane" ;;
securitygroup-controller) echo securitygroup ;;
# The kafka app source covers both the app suite and the metadata-backup
# roundtrip, so an edit to packages/apps/kafka (or kafka-operator, which
# reaches this source) selects both.
kafka-application) echo "kafka kafka-metadata" ;;
# The seaweedfs suite runs an instance of its own, and the rest use
# tenant-root's. bucket drives the Bucket API against the
# tenant-root instance, the backup round-trips write to it through the
# in-cluster seaweedfs-s3 endpoint, most of them verifying its CA, and
# harbor stores its registry in a BucketClaim the seaweedfs COSI driver
# serves. etcd's round-trip is left out because CI gates it off. A test
# derives this set from the suites.
seaweedfs-application) echo "bucket clickhouse harbor kafka kafka-metadata mariadb mongodb postgres rabbitmq redis seaweedfs" ;;
# Both suites create a KeycloakClient and wait for the operator's finalizer
# to remove it. kubernetes-application has no dependsOn on keycloak-operator
# (#4680), so the graph reaches only monitoring and would drop the tenant
# Kubernetes OIDC lifecycle in kubernetes-latest.
keycloak|keycloak-operator) echo "kubernetes-latest monitoring" ;;
*-application) echo "${1%-application}" ;;
*) echo "$1" ;;
esac
}
# yq: path -> PackageSource name
build_owners_index() {
yq -rN '.metadata.name as $n | .spec.variants[]?.components[]?.path | select(. != null and . != "") | . + "\t" + $n' "$SOURCES_DIR"/*.yaml
}
# yq: PackageSource name -> sources that depend on it (reverse of dependsOn)
#
# Exclude cozystack.cozystack-engine as a propagation hub. Every *-application
# source declares `dependsOn: cozystack.cozystack-engine` purely as an INSTALL
# ORDERING edge — the app's *-rd HelmRelease must wait for the engine to
# register the ApplicationDefinition CRD before it can reconcile. That is a
# universal lifecycle dependency, NOT a behavioral one: a change to an engine
# dependency (postgres-operator, keycloak, cert-manager, ...) does not alter any
# app's runtime behavior, so it must not fan test selection out to every app.
# Without this filter, postgres-operator -> keycloak -> cozystack-engine -> EVERY
# app, defeating test-impact analysis. Dropping the engine reverse edges keeps
# the engine reachable but stops it propagating selection downstream. A change to
# the engine itself still triggers the full suite: its group reaches no suite, so
# the per-path escalation below fires on it.
#
# cozystack.cozystack-basics is the second hub of that kind and is excluded for
# the same reason. Every edge into it exists so a namespace or a platform-wide
# policy is in place before the dependent installs — kubevirt-cdi says so in its
# own source ("Depend on cozystack-basics so the target namespace exists
# first") — which is install ordering, not behavior. It is also the hub where
# treating the edge as behavioral does the most damage, and in the wrong
# direction: cozystack-basics reaches no suite today, so it escalates, but it
# sits upstream of kubevirt-cdi, so the FIRST suite that lands downstream of CDI
# silently converts the platform's namespace-and-policy package from the full
# run to that one suite. Reproduced on #3426, where enabling the site-router
# suite takes cozystack-basics from 22 suites to `site-router` alone. Dropping
# the reverse edges keeps the package reachable and stops it propagating
# selection downstream, and a change to it still runs everything through the
# per-path escalation.
build_reverse_deps() {
yq -rN '.metadata.name as $n | .spec.variants[]?.dependsOn[]? | select(. != null and . != "" and . != "cozystack.cozystack-engine" and . != "cozystack.cozystack-basics") | . + "\t" + $n' "$SOURCES_DIR"/*.yaml
}
# Each index is one yq, and `OWNERS=$(build_owners_index | sort -u)` reports
# sort's status, never yq's — so `set -eu` above cannot see a yq that failed.
# The index then comes back empty, every path falls through to escalation, and
# the run is a full suite indistinguishable in the output from "no owners
# matched". Safe in direction, invisible in nature: selection has stopped
# working and it reads as the conservatism it is supposed to be.
#
# So capture each half on its own, check its status, and say once, loudly, that
# yq is the broken component before taking the same fallback the empty index
# would have taken anyway.
broken=''
OWNERS=$(build_owners_index) || broken='the ownership index'
REVERSE=$(build_reverse_deps) || broken="${broken:+$broken and }the reverse-dependency index"
if [ -n "$broken" ]; then
echo "select-e2e: yq failed building $broken from $SOURCES_DIR/*.yaml — the dependency graph is unusable, running the full suite" >&2
escalate_to_full_suite
fi
# Sorted after the check rather than inside the pipeline above, for the reason
# the pipeline was the bug.
OWNERS=$(printf '%s\n' "$OWNERS" | sort -u)
REVERSE=$(printf '%s\n' "$REVERSE" | sort -u)
trigger_full=0
trigger_any=0
selected_groups=""
selected_apps=""
# `|| [ -n "$file" ]` is what makes the classification below total. POSIX read
# assigns the last line and then returns non-zero when the input does not end in
# a newline, so a plain `while read` silently drops it. That is the one way a
# path can reach this loop and be classified by nothing at all: the fall-through
# at the end, which exists so an unrecognised path escalates instead of
# selecting nothing, sits inside the body the drop skips. Callers building the
# list themselves rather than piping `git diff` are the ones that hit it, and
# the guard belongs here rather than in each of them.
while IFS= read -r file || [ -n "$file" ]; do
[ -z "$file" ] && continue
# 1. Skip: docs, dashboards, *.md. Checked before everything else because
# these can never matter wherever they live — a README inside
# packages/core/ must not escalate the way its templates do.
if echo "$file" | grep -qE '^(docs/|dashboards/)' || echo "$file" | grep -qE '\.md$'; then
continue
fi
# 2. Chainsaw edits. A per-suite file selects only that suite; the shared
# _lib helpers and the .chainsaw.yaml config affect every suite, so they
# escalate to the full run.
case "$file" in
hack/e2e-chainsaw/_lib/*|hack/e2e-chainsaw/.chainsaw.yaml)
echo "select-e2e: '$file' is shared by every Chainsaw suite — escalating to the full suite" >&2
trigger_full=1
continue ;;
hack/e2e-chainsaw/*/*.disabled)
# A suite parked as chainsaw-test.yaml.disabled is registered nowhere and
# executed by nothing, so an edit to it cannot regress a test. Matched
# before the per-suite rule below, which reads the suite name off the
# directory and ignores the suffix: a parked suite's directory holds no
# live suite, so that rule would escalate to the full run — the most
# expensive outcome, bought by the one file class that provably cannot
# affect anything. Inert is the classification.
continue ;;
hack/e2e-chainsaw/*/*)
# Membership-tested here rather than left to the final intersection, for
# the reason given on the hack/e2e-apps/ arm below: a directory holding no
# suite (shared material beside _lib/) must escalate whatever else the
# diff selected.
app=$(echo "$file" | sed -nE 's,^hack/e2e-chainsaw/([^/]+)/.*,\1,p')
if echo "$all_apps" | grep -Fxq "$app"; then
selected_apps="$selected_apps $app"
trigger_any=1
else
echo "select-e2e: '$file' names no runnable suite ('$app') — escalating to the full suite" >&2
trigger_full=1
fi
continue ;;
hack/e2e-apps/*)
# The pre-Chainsaw per-app BATS suites. One file per app, named after it,
# so the suite name comes off the basename exactly as the rule above takes
# it off the directory.
#
# Deliberately NOT marked inert. The files formerly kept here were wired to
# nothing after the Chainsaw migration, but inert would bake that orphan
# status into the rule and go quietly wrong the day a lane runs this layout
# again. A file named after an app selects that app's suite whether or not
# anything currently executes it.
#
# The membership test is what makes this per-path, and it is not optional.
# Adding an unmatched name to selected_apps and letting the final
# intersection drop it leaves the verdict to the REST of the diff: alone it
# empties the selection and the backstop escalates, but beside any path that
# contributed a suite the escalation vanishes and the run narrows to that
# suite with nothing on stderr. That is the merge-before-escalate shape
# #3330 removed from the graph walk, and it applies here for the same
# reason — coverage is a property of the changed path.
#
# `[^/]+` rather than `.+`: POSIX case matches `/` with `*`, so this arm
# also sees nested paths, and an unanchored capture would turn
# hack/e2e-apps/fixtures/postgres.bats into the "suite" fixtures/postgres.
# Nothing nested exists here today; whatever appears would be shared
# material for these suites the way hack/e2e-chainsaw/_lib/ is for the
# Chainsaw ones, so it escalates for the same reason rather than being
# guessed at. A non-.bats file directly in the directory lands here too and
# is treated the same way.
app=$(echo "$file" | sed -nE 's,^hack/e2e-apps/([^/]+)\.bats$,\1,p')
if [ -z "$app" ]; then
echo "select-e2e: '$file' is not a per-app suite file (hack/e2e-apps/<name>.bats), so it is shared material for all of them — escalating to the full suite" >&2
trigger_full=1
elif echo "$all_apps" | grep -Fxq "$app"; then
selected_apps="$selected_apps $app"
trigger_any=1
else
echo "select-e2e: '$file' names no runnable suite ('$app') — escalating to the full suite" >&2
trigger_full=1
fi
continue ;;
examples/backups/_lib/*)
# A helper here is shared by the backup walkthroughs, so it is material
# of the same kind as hack/e2e-chainsaw/_lib/. Matched before the per-app
# rule below, which would read _lib as an app, find no suite and select
# nothing. Escalated rather than mapped to the suites that run a
# walkthrough: that set is spread across the suites' own scripts, and a
# derivation that misses one fails open.
echo "select-e2e: '$file' is shared by every backup walkthrough — escalating to the full suite" >&2
trigger_full=1
continue ;;
examples/backups/*/*)
# A backup round-trip Test executes the example scripts under
# examples/backups/<app>/ as its harness, so an edit there must run that
# app's suite. This mapping is
# also why a round-trip Test belongs in its app's own suite dir: put it
# in a dir whose name does not match examples/backups/<app>/ and an edit
# to the harness selects a suite that cannot exercise it. A dir with no
# matching suite is a docs-only demo and stays ignored (adding it to
# selected_apps would empty the final intersection and trip the
# full-suite safety net).
app=$(echo "$file" | sed -nE 's,^examples/backups/([^/]+)/.*,\1,p')
if echo "$all_apps" | grep -Fxq "$app"; then
selected_apps="$selected_apps $app"
trigger_any=1
fi
continue ;;
examples/backups/*)
# A file beside the per-app dirs rather than in one (POSIX case lets `*`
# match `/`, so the arm above has already taken every nested path)
# belongs to no single walkthrough, so it is treated as shared by all of
# them. Otherwise it would fall to examples/ in inert_config_pattern and
# select nothing.
echo "select-e2e: '$file' belongs to no single backup walkthrough — escalating to the full suite" >&2
trigger_full=1
continue ;;
esac
# 3. Full-suite trigger
if echo "$file" | grep -qE "$full_suite_pattern"; then
echo "select-e2e: '$file' cannot be scoped to one suite (full_suite_pattern) — escalating to the full suite" >&2
trigger_full=1
continue
fi
# 4. Explicitly inert: repo meta, agent config, non-e2e workflows, fixtures.
if echo "$file" | grep -qE "$inert_config_pattern"; then
continue
fi
# 5. Component change: lookup in PackageSource graph
rel=$(echo "$file" | sed -nE 's,^packages/(apps|system|extra)/([^/]+)/.*,\1/\2,p')
if [ -n "$rel" ]; then
src=$(echo "$OWNERS" | awk -v p="$rel" -F'\t' '$1==p {print $2}')
if [ -n "$src" ]; then
# Keep this path's owning sources together as one comma-joined group. The
# unit coverage is decided over is the changed PATH, not the single
# source: a path can belong to several PackageSources, some of which
# reach no suite, and deciding per source would escalate on that half
# and run everything for every change to it.
selected_groups="$selected_groups $(echo "$src" | paste -sd , -)"
trigger_any=1
else
# Inside packages/ but no graph entry — be conservative.
echo "select-e2e: no PackageSource in $SOURCES_DIR lists '$rel' as a component path — escalating to the full suite" >&2
trigger_full=1
fi
continue
fi
# 6. Unclassified. Escalate instead of ignoring: an empty selection makes both
# e2e lanes skip Chainsaw and post "E2E Tests" green, so a path nobody has
# classified must fail safe, not fail open (#3392). Add genuinely inert
# paths to inert_config_pattern rather than relaxing this.
echo "select-e2e: unclassified path '$file' — escalating to the full suite (classify it in hack/select-e2e.sh, see #3392)" >&2
trigger_full=1
done < "$CHANGED"
if [ "$trigger_full" = 1 ]; then
escalate_to_full_suite
fi
if [ "$trigger_any" = 0 ]; then
exit 0 # nothing to run
fi
# Deduplicate a space-separated suite list; keep only names that are suites.
intersect_suites() {
echo "$1" | tr ' ' '\n' | sort -u | grep -v '^$' | while read -r app; do
if echo "$all_apps" | grep -Fxq "$app"; then
echo "$app"
fi
done | paste -sd ' ' -
}
# Transitive closure over reverse-deps from the given PackageSource names, every
# reached source mapped through src_to_suites. Emits candidate suite names, not
# yet filtered — pass the result through intersect_suites.
#
# POSIX sh has no `local`, so this scribbles on all_sources/new/final. Its one
# call site invokes it inside $( ), which contains the damage; a direct call
# would clobber the caller's variables. A test pins that there is still exactly
# one call.
resolve_suites() {
all_sources="$*"
while :; do
new=""
for s in $all_sources; do
deps=$(echo "$REVERSE" | awk -v src="$s" -F'\t' '$1==src {print $2}')
for d in $deps; do
case " $all_sources " in *" $d "*) ;; *) new="$new $d";; esac
done
done
[ -z "$new" ] && break
all_sources="$all_sources $new"
done
final=""
for s in $all_sources; do
final="$final $(src_to_suites "${s#cozystack.}")"
done
echo "$final"
}
# Escalation is a property of the changed path, not of the whole diff. A path
# that reaches no runnable suite is covered by nothing and forces the full run
# on its own account, whatever the rest of the diff selected.
#
# Reading that off an empty FINAL selection instead — which is what the old
# safety net at the bottom did, and still does as a backstop — let any other
# changed path that contributed a suite name swallow the escalation, with
# nothing in the output to record that it happened (#3330). The shape that hits
# it is ordinary rather than exotic: change a platform component, adjust the
# Chainsaw test next to it, and the component's escalation disappears behind
# that one suite.
group_suites=""
for g in $(echo "$selected_groups" | tr ' ' '\n' | sort -u); do
s=$(intersect_suites "$(resolve_suites "$(echo "$g" | tr ',' ' ')")")
if [ -z "$s" ]; then
# Say which changed path bought the full run. Every other escalation here
# names its cause on stderr, and this one is the common case rather than the
# rare one — most packages reach no suite of their own — so without a line
# "why did this pull request run everything" has no answer in the log. The
# sources are printed rather than the path because they are what the walk
# started from; the path that owns them is the one this group came from in
# the classification loop above.
echo "select-e2e: no runnable suite covers $(echo "$g" | tr ',' ' ') — escalating to the full suite" >&2
escalate_to_full_suite
fi
group_suites="$group_suites $s"
done
# Union of the graph-selected suites and the directly-selected ones from
# per-suite edits. Walking the graph once per group rather than once over all of
# them costs an extra walk per changed path and buys the escalation above; the
# answer is unchanged, because reverse reachability distributes over union, so
# the union of the per-group results is the set the single walk produced.
final_apps=$(intersect_suites "$group_suites $selected_apps")
# Backstop. Every graph path above either escalates or contributes a suite that
# exists, and every rule that selects a suite by name membership-tests it first,
# so no classification this file makes reaches here today. It stays for the next
# rule that forgets to: selecting nothing would skip E2E outright, so failing
# towards the full suite is the only safe way to be wrong. It prints the
# directly-selected names, since a rule that skipped its membership test is the
# likely way to get here; no test asserts the line.
if [ -z "$final_apps" ]; then
echo "select-e2e: no runnable suite is named by '${selected_apps# }' — escalating to the full suite" >&2
escalate_to_full_suite
fi
echo "$final_apps"