Repository navigation
feat(scripts): 3 template-drift recipes (SPDX lint rule, SonarQube token guard, split comment keys) #658
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat(scripts): 3 template-drift recipes (SPDX lint rule, SonarQube token guard, split comment keys) #658
Changes from all commits
bc34fea
e21b05f
1addea2
b2ea6c9
03417d0
851030e
a7b7d51
6874fe2
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 <repo-path>`. 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. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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-<number>.<number>`), 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 <repo-path> [finding-json] | ||
| # fix-sonarqube-token-guard.sh --check <repo-path> | ||
| # 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 | ||
|
hyperpolymath marked this conversation as resolved.
Resolved
|
||
| 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 \ | ||
|
Check warning on line 94 in scripts/fix-sonarqube-token-guard.sh
|
||
| -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" | ||
|
Check warning on line 112 in scripts/fix-sonarqube-token-guard.sh
|
||
| } | ||
|
|
||
| # 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" ]] | ||
|
Check warning on line 120 in scripts/fix-sonarqube-token-guard.sh
|
||
| } | ||
|
|
||
| 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 <repo-path> [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 | ||
|
hyperpolymath marked this conversation as resolved.
Resolved
|
||
| # 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 | ||
|
hyperpolymath marked this conversation as resolved.
Resolved
|
||
| 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 } | ||
|
hyperpolymath marked this conversation as resolved.
Resolved
|
||
| { 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" | ||
Uh oh!
There was an error while loading. Please reload this page.