diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 78e33e27..1c20f698 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -56,6 +56,8 @@ jobs: run: bash tests/e2e.sh - name: Verify canonical descriptile policy repair run: bash tests/retired-descriptile-policy-test.sh + - name: Verify the template-drift recipes + run: bash tests/template-drift-recipes-test.sh - name: Verify star-list enrollment discovery run: bash tests/enroll-discovery-test.sh - name: Verify the fleet report-back driver (envelope contract) diff --git a/campaigns/TEMPLATE-DRIFT-CAMPAIGN.adoc b/campaigns/TEMPLATE-DRIFT-CAMPAIGN.adoc new file mode 100644 index 00000000..8cb402a6 --- /dev/null +++ b/campaigns/TEMPLATE-DRIFT-CAMPAIGN.adoc @@ -0,0 +1,117 @@ +// SPDX-License-Identifier: CC-BY-SA-4.0 += Template Drift Campaign: 2026-11-10 +:toc: +:toclevels: 2 + +== Purpose + +A repository gets a copy of the estate template (`hyperpolymath/rsr-template-repo`) +when it is made. Later repairs in the template do not reach that copy. The +same CI failures then show in each older repository, although the template +has the repair. + +This campaign has 3 recipes for 3 such failures. Each recipe makes the change +that the template already has. The worked example is `hyperpolymath/system-et` +(pull requests 90 and 92, 2026-11-10), where each repair was first made by +hand. + +== Status + +*The recipes are not dispatched.* The fleet dispatch is under quarantine +(`docs/AUTOMATION-QUARANTINE.adoc`), and this change does not touch the +interlock. There is no manifest in this campaign, because a manifest needs an +estate scan, and the quarantine document forbids a scan as a workaround. +No rule in `AGENTIC.a2ml` assigns these recipes; that grant is the owner's. + +== Recipes + +[cols="2,2,3,3"] +|=== +| Recipe id | Script | Target | Repair + +| `recipe-workflow-linter-spdx-rule` +| `fix-workflow-linter-spdx-rule.sh` +| `workflow-linter.yml` with the rule `head -1 "$file" \| grep -q "^# SPDX-License-Identifier:"`. + The tool `gh actions-lock` writes its comment on line 1, so each lock run + makes each managed workflow fail the lint. +| The rule of the template: the licence line can be at any position in the + leading comment block. + +| `recipe-sonarqube-token-guard` +| `fix-sonarqube-token-guard.sh` +| `sonarqube.yml` that scans with `secrets.SONAR_TOKEN` and has no `if:` line. + With no secret, the scan fails on each run. +| The guard of the template (2026-07-28): the first step tests the token. With + no token the job says that the scan is not configured and passes. The guard + step is the text of the template after `rsr-template-repo` pull request 321 + (2026-11-10): the token is in the environment of the guard step and of the + scan step only, and the guard step sets `shell: bash`. + +| `recipe-workflow-split-comment-keys` +| `fix-split-comment-keys.sh` +| A workflow where the lock sweep of 2026-08 split a comment line such as + `# steps:` into a line `#` and a line `steps:` at column 1. GitHub refuses + the file ("Unexpected value 'steps'"). +| The 2 lines become 1 comment line again. +|=== + +Each recipe has the predicate `--check `. It prints the number of +targets and changes nothing. Each recipe is idempotent, does not follow a +symlink, and edits the working tree only: it makes no commit. + +A recipe that finds a target with an unknown shape does not change the file +and exits 1. The run then does not read as a repair. The split-key recipe +examines each workflow before the first write: one workflow that it cannot +repair refuses the repository, and no file is changed. + +== Evidence + +`tests/template-drift-recipes-test.sh` runs in the `e2e` workflow. + +Each recipe was also run on the files of `system-et` at `main` (`04a8b6e`): + +* SPDX rule: the step is identical to the step of the template (`c5a1da7`). +* Token guard: the job is identical to the job in pull request 92 + (`9f529ef`). The guard step is identical to the guard step of the template + branch (`rsr-template-repo` pull request 321, `806dac0`). +* Split keys: 19 lines restored. The 19 lines are the lines that the file had + before the sweep. The file is identical to the repair in pull request 90, + except for the order of the first 2 comment lines, which a different commit + of that pull request changed. + +A second repository (`anytype`) has the first 2 targets and not the third. + +== Not in this campaign + +*Descriptile files in a retired directory* (`.machine_readable/6a2/` or +directly in `.machine_readable/`). Hypatia reports these as critical. No +recipe moves the files: they include `AGENTIC.a2ml`, which holds the +permissions of the bots, and the quarantine document says that a job must not +relocate directive files. A person moves the files. After that, +`recipe-retired-descriptile-policy` repairs the references. + +*Reusable-workflow callers that do not start.* `fix-stale-reusable-pin.sh` +and `fix-lock-self-sha-refs.sh` already cover these. + +== Limits + +* Not verified: that a later run of `gh actions-lock` does not split the + restored comment lines again. The template avoids the problem with an + inactive file (`e2e.yml.template`). +* The token guard recipe edits only the shape of the old template. It does + not rewrite a workflow that a person changed. +* The number of repositories with each target is not known. No estate scan + was made. + +== Use by a person + +Count the targets in one checkout. This reads only: + +[source,bash] +---- +bash scripts/fix-split-comment-keys.sh --check /absolute/path/to/repo +---- + +A person can run one recipe on one checkout and review the diff before a +commit. That is a manual repair. It is not a fleet dispatch, and it does not +show that other repositories consent. diff --git a/scripts/fix-script-registry.json b/scripts/fix-script-registry.json index 8f35a496..6892bae9 100644 --- a/scripts/fix-script-registry.json +++ b/scripts/fix-script-registry.json @@ -127,7 +127,10 @@ "workflow_audit/known_broken_reusable_revision": "fix-stale-reusable-pin.sh", "PIN-003": "fix-stale-reusable-pin.sh", "PI006": "fix-lock-self-sha-refs.sh", - "pin_integrity/self_sha_lock_record": "fix-lock-self-sha-refs.sh" + "pin_integrity/self_sha_lock_record": "fix-lock-self-sha-refs.sh", + "recipe-workflow-linter-spdx-rule": "fix-workflow-linter-spdx-rule.sh", + "recipe-sonarqube-token-guard": "fix-sonarqube-token-guard.sh", + "recipe-workflow-split-comment-keys": "fix-split-comment-keys.sh" } } } diff --git a/scripts/fix-sonarqube-token-guard.sh b/scripts/fix-sonarqube-token-guard.sh new file mode 100755 index 00000000..339fe551 --- /dev/null +++ b/scripts/fix-sonarqube-token-guard.sh @@ -0,0 +1,183 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# +# fix-sonarqube-token-guard.sh: add the SONAR_TOKEN guard to sonarqube.yml +# +# Older copies of the estate template run the SonarQube scan with no test of +# the token. In a repository with no SONAR_TOKEN secret, the scan fails on +# each run ("Not authorized or organization not found"). That red check is not +# a code-quality signal: the scan did not run. +# +# The current template (rsr-template-repo, since 2026-07-28) has a guard: the +# first step tests the token. With no token, the job says that the scan is not +# configured and passes. With a token, the scan runs, with no further edit. +# This recipe adds that guard. +# +# The guard step is the text of the template after pull request 321 of +# rsr-template-repo (2026-11-10, owner decisions: the template first, then +# this recipe). The token is in the environment of the guard step only. The +# scan step keeps its own entry. No other step, for example the checkout, can +# then read the secret. The guard step sets `shell: bash`. +# +# Target: .github/workflows/sonarqube.yml that uses a SonarSource scan action +# with `secrets.SONAR_TOKEN` and has no `if:` line. A file with an `if:` line +# is not a target, because a person can have written a different guard. +# +# The recipe edits only the old template: the `jobs:` section must be the text +# of the old template, line for line. Only these can differ: the reference of +# the 2 actions, the name of the scan action (`sonarqube-scan-action` or the +# older `sonarcloud-github-action`, which reads the same token from the +# environment), the version of the Ubuntu runner +# (`ubuntu-latest` or `ubuntu-.`), the value of the timeout, +# and the comment on `fetch-depth`. Before `jobs:`, the workflow can have only +# the top-level keys of the old template: `name`, `on`, `concurrency` and +# `permissions`. A target with any other difference is not changed, and the +# recipe exits 1 so that the run does not read as a repair. +# +# Idempotent: a file that has the guard is not a target. +# +# Usage: fix-sonarqube-token-guard.sh [finding-json] +# fix-sonarqube-token-guard.sh --check +# outcome-predicate: --check + +set -euo pipefail + +REL=".github/workflows/sonarqube.yml" +ME="fix-sonarqube-token-guard" + +# Print the path of the workflow when it is a regular file inside the repository. +# A missing file, a symlink, or a path that leaves the repository is no target. +target_file() { + local repo="$1" root + root=$(realpath -e -- "$repo") || return 1 + [[ -f "$root/$REL" && ! -L "$root/$REL" ]] || return 1 + [[ "$(realpath -e -- "$root/$REL")" == "$root/$REL" ]] || return 1 + printf '%s\n' "$root/$REL" +} + +# Print 1 when the workflow scans with secrets.SONAR_TOKEN and has no `if:` +# line, else 0. +count_targets() { + local file + file=$(target_file "$1") || { echo 0; return 0; } + if grep -qE '^[[:space:]]*(- )?uses:[[:space:]]*SonarSource/(sonarqube-scan-action|sonarcloud-github-action)@' "$file" && + grep -qF 'secrets.SONAR_TOKEN' "$file" && + ! grep -qE '^[[:space:]]*(- )?if:' "$file"; then + echo 1 + else + echo 0 + fi +} + +# The `jobs:` section of the old template, in a neutral form: no action +# reference, no runner version, no timeout value, no comment on `fetch-depth`. +OLD_JOBS=$(cat <<'YAML' +jobs: + sonarqube: + name: SonarQube + runs-on: ubuntu-VERSION + timeout-minutes: N + steps: + - name: Checkout + uses: actions/checkout@REF + with: + fetch-depth: 0 + - name: SonarQube Scan + uses: SonarSource/sonarqube-scan-action@REF + env: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} +YAML +) + +# Print the `jobs:` section of a workflow in the neutral form. +neutral_jobs() { + sed -n '/^jobs:[[:space:]]*$/,$p' "$1" | sed -E \ + -e 's/[[:space:]]+$//' \ + -e 's#^( +uses:[[:space:]]*SonarSource/)sonarcloud-github-action@#\1sonarqube-scan-action@#' \ + -e 's/^( +uses:[[:space:]]*[^@[:space:]]+)@.*$/\1@REF/' \ + -e 's/^( runs-on:[[:space:]]*ubuntu)-(latest|[0-9]+\.[0-9]+)$/\1-VERSION/' \ + -e 's/^( timeout-minutes:[[:space:]]*)[0-9]+$/\1N/' \ + -e 's/^( fetch-depth: 0)[[:space:]]+#.*$/\1/' +} + +# Succeed when each top-level key before `jobs:` is a key of the old template. +# This is an allow list: a key in any other form, for example `defaults:` or +# `"defaults":` (a default shell), is refused. +known_header() { + awk ' + /^jobs:[[:space:]]*$/ { exit } + /^[[:space:]#]/ || /^$/ || /^(name|on|concurrency|permissions):/ { next } + { bad = 1; exit } + END { exit bad } + ' "$1" +} + +# Succeed when the workflow IS the old template: the known header, and then +# the `jobs:` section. This is an exact comparison: a workflow with one more +# step, another runner, another step name or another place for the token is +# not the old template, and it is refused. +known_shape() { + known_header "$1" && [[ "$(neutral_jobs "$1")" == "$OLD_JOBS" ]] +} + +if [[ "${1:-}" == "--check" ]]; then + [[ -d "${2:-}" ]] || { echo "--check: not a directory: ${2:-}" >&2; exit 2; } + count_targets "$2" + exit 0 +fi + +REPO_PATH="${1:?Usage: $0 [finding-json]}" +[[ -d "$REPO_PATH" ]] || { echo "$ME: not a directory: $REPO_PATH" >&2; exit 2; } + +if [[ "$(count_targets "$REPO_PATH")" == 0 ]]; then + echo "$ME: no unguarded scan in $REL (nothing to do)" + exit 0 +fi + +FILE=$(target_file "$REPO_PATH") +if ! known_shape "$FILE"; then + echo "$ME: $REL does not have the shape of the old template; not changed" >&2 + exit 1 +fi + +TMP=$(mktemp) +trap 'rm -f "$TMP"' EXIT + +GUARD=$(cat <<'YAML' + steps: + - name: Report configuration state + id: cfg + # A job-level `if:` cannot read a secret. This step reads the token + # and gives the result to the steps after it. The token is in the + # environment of this step and of the scan step only. + env: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + # The script of this step is Bash. `shell: bash` says so in the step: + # a default shell or a runner with a different shell cannot change it. + shell: bash + run: | + if [ -z "${SONAR_TOKEN}" ]; then + echo "configured=false" >> "$GITHUB_OUTPUT" + echo "::notice::SONAR_TOKEN is not configured for this repository - skipping the CI-based scan." + echo "SonarCloud automatic analysis (check: 'SonarCloud Code Analysis') is unaffected." + echo "To enable this scan: Settings > Secrets and variables > Actions > new secret SONAR_TOKEN." + else + echo "configured=true" >> "$GITHUB_OUTPUT" + echo "SONAR_TOKEN present - running the CI-based scan." + fi +YAML +) + +GUARD="$GUARD" awk ' + !in_steps && /^ steps:[[:space:]]*$/ { print ENVIRON["GUARD"]; in_steps = 1; next } + in_steps && /^ - name: / { print; print " if: steps.cfg.outputs.configured == \047true\047"; next } + { print } +' "$FILE" > "$TMP" +[[ -s "$TMP" ]] || { echo "$ME: the edit gave no text; $REL is not changed" >&2; exit 1; } +cat "$TMP" > "$FILE" + +if [[ "$(count_targets "$REPO_PATH")" != 0 ]]; then + echo "$ME: $REL is still a target after the edit" >&2 + exit 1 +fi +echo "$ME: added the token guard to $REL" diff --git a/scripts/fix-split-comment-keys.sh b/scripts/fix-split-comment-keys.sh new file mode 100755 index 00000000..7673bb51 --- /dev/null +++ b/scripts/fix-split-comment-keys.sh @@ -0,0 +1,153 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# +# fix-split-comment-keys.sh: restore comment lines that a lock run split in two +# +# The estate sweep that adopted the Actions lock file (2026-08) ran +# gh actions-lock on each workflow. In a job that is commented out, the tool +# changed a comment line that holds only a key, for example +# +# # steps: +# +# into 2 lines: a line with only "#", and the key at column 1: +# +# # +# steps: +# +# GitHub then refuses the workflow file ("Unexpected value 'steps'"), and no +# job of that file runs. The template example file e2e.yml has many such +# blocks (steps:, with:, strategy:). +# +# Target: in .github/workflows/*.yml and *.yaml, a line with only "#", +# followed by a key at column 1 that has no value and that is not a legal +# top-level key of a workflow, followed by a comment line. A key that is legal +# at the top level (name, run-name, on, permissions, env, defaults, +# concurrency, jobs) is not a target, because the line can be correct. +# +# Repair: the 2 lines become 1 comment line again. The key gets the indentation +# of the next comment line minus 2 spaces (the key is the parent of that line). +# If the next line cannot give an indentation, the file is not changed, and +# the recipe exits 1 so that the run does not read as a repair. +# +# Not verified: that a later lock run does not split these lines again. +# +# Idempotent: a file with no such pair is not changed. +# +# Usage: fix-split-comment-keys.sh [finding-json] +# fix-split-comment-keys.sh --check +# outcome-predicate: --check + +set -euo pipefail + +ME="fix-split-comment-keys" + +# Shared awk functions: the definition of a split pair. +PAIR_LIB=$(cat <<'AWK' +function is_key(l) { + return l ~ /^[A-Za-z_][A-Za-z0-9_-]*:[[:space:]]*$/ && + l !~ /^(name|run-name|on|permissions|env|defaults|concurrency|jobs):/ +} +function is_pair(i) { + return i + 2 <= NR && line[i] == "#" && is_key(line[i + 1]) && line[i + 2] ~ /^#/ +} +# Number of spaces for the restored key, or -1 when the next line gives none. +function key_indent(i, rest, first) { + rest = substr(line[i + 2], 2) + first = match(rest, /[^ ]/) + return (first >= 4) ? first - 3 : -1 +} +{ line[NR] = $0 } +AWK +) + +# Print each workflow file that is a regular file inside the repository. +# A symlink, or a workflow directory that leaves the repository, is no target. +list_files() { + local repo="$1" root dir file + root=$(realpath -e -- "$repo") || return 0 + dir="$root/.github/workflows" + [[ -d "$dir" && "$(realpath -e -- "$dir")" == "$dir" ]] || return 0 + for file in "$dir"/*.yml "$dir"/*.yaml; do + [[ -f "$file" && ! -L "$file" ]] && printf '%s\n' "$file" + done + return 0 +} + +# Print the number of split pairs in one file. +count_file() { + awk "$PAIR_LIB"' + END { for (i = 1; i <= NR; i++) if (is_pair(i)) n++; print n + 0 } + ' "$1" +} + +# Print the number of split pairs in the repository. +count_targets() { + local file total=0 + while IFS= read -r file; do + total=$((total + $(count_file "$file"))) + done < <(list_files "$1") + echo "$total" +} + +if [[ "${1:-}" == "--check" ]]; then + [[ -d "${2:-}" ]] || { echo "--check: not a directory: ${2:-}" >&2; exit 2; } + count_targets "$2" + exit 0 +fi + +REPO_PATH="${1:?Usage: $0 [finding-json]}" +[[ -d "$REPO_PATH" ]] || { echo "$ME: not a directory: $REPO_PATH" >&2; exit 2; } + +TMP=$(mktemp) +trap 'rm -f "$TMP"' EXIT +FAILED=0 +REPAIRED=0 + +# Validate each file before the first write: a repository is repaired fully or +# not at all. The runner does not undo the edits of a recipe that fails. +while IFS= read -r FILE; do + awk "$PAIR_LIB"' + END { for (i = 1; i <= NR; i++) if (is_pair(i) && key_indent(i) < 0) exit 1 } + ' "$FILE" || { + echo "$ME: ${FILE##*/} has a split key with no usable next line; no file is changed" >&2 + exit 1 + } +done < <(list_files "$REPO_PATH") + +while IFS= read -r FILE; do + PAIRS=$(count_file "$FILE") + [[ "$PAIRS" == 0 ]] && continue + # Validate each pair first, then write: a file is repaired fully or not at all. + if awk "$PAIR_LIB"' + END { + for (i = 1; i <= NR; i++) if (is_pair(i) && key_indent(i) < 0) exit 1 + for (i = 1; i <= NR; i++) { + if (is_pair(i)) { + key = line[i + 1]; sub(/[[:space:]]*$/, "", key) + pad = ""; for (s = key_indent(i); s > 0; s--) pad = pad " " + print "#" pad key + i++ + } else { + print line[i] + } + } + } + ' "$FILE" > "$TMP" && [[ -s "$TMP" ]]; then + cat "$TMP" > "$FILE" + REPAIRED=$((REPAIRED + PAIRS)) + echo "$ME: restored $PAIRS comment line(s) in ${FILE##*/}" + else + FAILED=1 + echo "$ME: ${FILE##*/} has a split key with no usable next line; not changed" >&2 + fi +done < <(list_files "$REPO_PATH") + +if [[ "$FAILED" == 1 || "$(count_targets "$REPO_PATH")" != 0 ]]; then + echo "$ME: split comment keys remain in $REPO_PATH" >&2 + exit 1 +fi +if [[ "$REPAIRED" == 0 ]]; then + echo "$ME: no split comment key in .github/workflows (nothing to do)" +else + echo "$ME: restored $REPAIRED comment line(s) in total" +fi diff --git a/scripts/fix-workflow-linter-spdx-rule.sh b/scripts/fix-workflow-linter-spdx-rule.sh new file mode 100755 index 00000000..f5b21188 --- /dev/null +++ b/scripts/fix-workflow-linter-spdx-rule.sh @@ -0,0 +1,91 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# +# fix-workflow-linter-spdx-rule.sh: accept the SPDX line in the leading comment block +# +# Older copies of the estate template have this rule in +# .github/workflows/workflow-linter.yml: +# +# if ! head -1 "$file" | grep -q "^# SPDX-License-Identifier:"; then +# +# The tool gh actions-lock writes its own comment on line 1 of each workflow +# that it manages. Each lock run thus makes every managed workflow fail the +# lint ("missing SPDX header"). The current template (rsr-template-repo) +# accepts the licence line at any position in the leading comment block. +# This recipe replaces the old rule with the rule of the template. +# +# Only the exact old rule is a target. A linter with a different rule (for +# example `head -5`) is not changed. +# +# Idempotent: a file that has the new rule is not changed. +# +# Usage: fix-workflow-linter-spdx-rule.sh [finding-json] +# fix-workflow-linter-spdx-rule.sh --check +# outcome-predicate: --check + +set -euo pipefail + +REL=".github/workflows/workflow-linter.yml" +OLD_RULE='^[[:space:]]*if ! head -1 "\$[A-Za-z_][A-Za-z0-9_]*" \| grep -q "\^# SPDX-License-Identifier:"; then[[:space:]]*$' +NEW_RULE='/^# SPDX-License-Identifier:/ { found=1 } /^[^#[:space:]]/ { exit } END { exit !found }' + +# Print the path of the linter when it is a regular file inside the repository. +# A missing file, a symlink, or a path that leaves the repository is no target. +target_file() { + local repo="$1" root + root=$(realpath -e -- "$repo") || return 1 + [[ -f "$root/$REL" && ! -L "$root/$REL" ]] || return 1 + [[ "$(realpath -e -- "$root/$REL")" == "$root/$REL" ]] || return 1 + printf '%s\n' "$root/$REL" +} + +# Print the number of old-rule lines in the repository (0 when no target file). +count_targets() { + local file + file=$(target_file "$1") || { echo 0; return 0; } + grep -cE -- "$OLD_RULE" "$file" || true +} + +if [[ "${1:-}" == "--check" ]]; then + [[ -d "${2:-}" ]] || { echo "--check: not a directory: ${2:-}" >&2; exit 2; } + count_targets "$2" + exit 0 +fi + +REPO_PATH="${1:?Usage: $0 [finding-json]}" +[[ -d "$REPO_PATH" ]] || { echo "fix-workflow-linter-spdx-rule: not a directory: $REPO_PATH" >&2; exit 2; } + +if [[ "$(count_targets "$REPO_PATH")" == 0 ]]; then + echo "fix-workflow-linter-spdx-rule: no old SPDX rule in $REL (nothing to do)" + exit 0 +fi + +FILE=$(target_file "$REPO_PATH") +TMP=$(mktemp) +trap 'rm -f "$TMP"' EXIT + +PROGRAM=$(cat <<'AWK' +/^[[:space:]]*if ! head -1 "\$[A-Za-z_][A-Za-z0-9_]*" \| grep -q "\^# SPDX-License-Identifier:"; then[[:space:]]*$/ { + indent = $0; sub(/if ! head.*$/, "", indent) + name = $0; sub(/^[^$]*\$/, "", name); sub(/".*$/, "", name) + print indent "# actions-lock may prepend its own comment. Require the licence" + print indent "# in the leading comment block, before the workflow body." + print indent "if ! awk '" ENVIRON["NEW_RULE"] "' \"$" name "\"; then" + next +} +/echo "Add '# SPDX-License-Identifier: [A-Za-z0-9.+-]+' as first line"/ { + sub(/ as first line"/, " to the leading comment block\"") +} +{ print } +AWK +) + +NEW_RULE="$NEW_RULE" awk "$PROGRAM" "$FILE" > "$TMP" +[[ -s "$TMP" ]] || { echo "fix-workflow-linter-spdx-rule: the edit gave no text; $REL is not changed" >&2; exit 1; } +cat "$TMP" > "$FILE" + +if [[ "$(count_targets "$REPO_PATH")" != 0 ]]; then + echo "fix-workflow-linter-spdx-rule: the old rule is still in $REL" >&2 + exit 1 +fi +echo "fix-workflow-linter-spdx-rule: replaced the SPDX rule in $REL" diff --git a/tests/template-drift-recipes-test.sh b/tests/template-drift-recipes-test.sh new file mode 100644 index 00000000..6224a0e5 --- /dev/null +++ b/tests/template-drift-recipes-test.sh @@ -0,0 +1,358 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Contract test of the 3 template-drift recipes +# (campaigns/TEMPLATE-DRIFT-CAMPAIGN.adoc). For each recipe: the predicate +# finds the old shape, the repair gives the exact expected text, a second run +# changes nothing, and a file that is not a target is not changed. +set -euo pipefail +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +TMP=$(mktemp -d) +trap 'rm -rf "$TMP"' EXIT + +fail() { echo "FAIL: $*" >&2; exit 1; } + +# new_repo : make an empty fixture repository and print its path. +new_repo() { mkdir -p "$TMP/$1/.github/workflows"; printf '%s\n' "$TMP/$1"; } + +# expect_count : the predicate prints that number. +expect_count() { + local got + got=$(bash "$ROOT/scripts/$1" --check "$2") + [[ "$got" == "$3" ]] || fail "$1 --check ${2##*/}: expected $3, got $got" +} + +# expect_same