From 92ff7a19d0c88d265e661ff9001541b873073983 Mon Sep 17 00:00:00 2001 From: Nikolai Emil Damm Date: Sat, 26 Sep 2026 14:31:52 +0200 Subject: [PATCH 01/18] fix(publish-dotnet-library): pin dotnet-releaser to a reviewed version The publish job installed whatever dotnet-releaser version was latest and ran it with the NuGet API key and a write-capable token. Pin it, track the same version in a Dependabot-watched tool manifest, and fail CI when an install is unpinned or drifts from the tracked version. Fixes #265 Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/dependabot.yml | 10 ++++ .github/tests/test-dotnet-tool-pins.sh | 49 +++++++++++++++++++ .../tools/dotnet/.config/dotnet-tools.json | 12 +++++ .github/workflows/ci.yaml | 4 ++ .github/workflows/publish-dotnet-library.yaml | 5 +- 5 files changed, 79 insertions(+), 1 deletion(-) create mode 100755 .github/tests/test-dotnet-tool-pins.sh create mode 100644 .github/tools/dotnet/.config/dotnet-tools.json diff --git a/.github/dependabot.yml b/.github/dependabot.yml index f5d9319a..6852e25b 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -51,3 +51,13 @@ updates: default-days: 7 ignore: - dependency-name: "*" + # The .NET tools the reusable workflows install. publish-dotnet-library.yaml runs in the + # CALLER's checkout, so it cannot read this manifest and pins the same version inline; + # this manifest exists so Dependabot proposes bumps, and + # .github/tests/test-dotnet-tool-pins.sh fails until the inline pin matches it. + - package-ecosystem: nuget + directory: /.github/tools/dotnet + schedule: + interval: daily + cooldown: + default-days: 7 diff --git a/.github/tests/test-dotnet-tool-pins.sh b/.github/tests/test-dotnet-tool-pins.sh new file mode 100755 index 00000000..31b9c630 --- /dev/null +++ b/.github/tests/test-dotnet-tool-pins.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash + +# Pins how the reusable workflows and composite actions install .NET tools (#265). +# +# 1. Every `dotnet tool install` names an explicit `--version`. An unpinned install takes +# whatever upstream released last, and publish-dotnet-library.yaml runs dotnet-releaser +# with the NuGet API key and a write-capable token. +# 2. Every tool in the Dependabot-tracked manifest (.github/tools/dotnet/.config/ +# dotnet-tools.json) is installed at exactly the manifest's version. The workflows run in +# the caller's checkout and cannot read the manifest, so the inline pin is what runs; +# this check is what makes a Dependabot bump of the manifest reach it. +# 3. dotnet-releaser itself stays in the manifest, so its bumps keep being proposed. + +set -euo pipefail + +manifest=".github/tools/dotnet/.config/dotnet-tools.json" + +fail() { + echo "FAIL: $*" >&2 + exit 1 +} + +[[ -f "$manifest" ]] || fail "missing tool manifest: $manifest" + +releaser="$(jq -r '.tools["dotnet-releaser"].version // ""' "$manifest")" +[[ -n "$releaser" ]] || fail "$manifest must track dotnet-releaser so Dependabot proposes its bumps" + +installs="$(grep -rnE 'dotnet tool install' .github/workflows actions --include='*.yaml' --include='*.yml' | + grep -vE '^[^:]+:[0-9]+:[[:space:]]*#' || true)" +[[ -n "$installs" ]] || fail "found no 'dotnet tool install' lines — refusing to pass vacuously" + +while IFS= read -r line; do + where="${line%%:*}:$(printf '%s' "$line" | cut -d: -f2)" + command="${line#*:*:}" + version="$(printf '%s\n' "$command" | sed -nE 's/.*--version[[:space:]=]+([^[:space:]]+).*/\1/p')" + [[ -n "$version" ]] || fail "$where installs a .NET tool without --version: ${command#"${command%%[![:space:]]*}"}" + [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.-]+)?$ ]] || + fail "$where pins '$version', which is not an exact version" + tool="$(printf '%s\n' "$command" | sed -nE 's/.*dotnet tool install[[:space:]]+(--global[[:space:]]+|-g[[:space:]]+)?([^[:space:]-][^[:space:]]*).*/\2/p')" + tracked="$(jq -r --arg t "$tool" '.tools[$t].version // ""' "$manifest")" + if [[ -n "$tracked" && "$tracked" != "$version" ]]; then + fail "$where installs $tool $version, but $manifest tracks $tracked — update the inline pin to $tracked" + fi +done <<<"$installs" + +grep -rqE "dotnet tool install.*dotnet-releaser.*--version[[:space:]=]+$releaser" .github/workflows || + fail "no workflow installs dotnet-releaser at the tracked version $releaser" + +echo "ok every .NET tool install is pinned, and tracked tools match $manifest" diff --git a/.github/tools/dotnet/.config/dotnet-tools.json b/.github/tools/dotnet/.config/dotnet-tools.json new file mode 100644 index 00000000..61d05392 --- /dev/null +++ b/.github/tools/dotnet/.config/dotnet-tools.json @@ -0,0 +1,12 @@ +{ + "version": 1, + "isRoot": true, + "tools": { + "dotnet-releaser": { + "version": "0.24.0", + "commands": [ + "dotnet-releaser" + ] + } + } +} diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index f388eaa2..b4548e73 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -3122,6 +3122,10 @@ jobs: shell: bash run: bash .github/tests/test-publish-caller-pin.sh + - name: 📌 Check .NET tool installs are pinned to the tracked versions + shell: bash + run: bash .github/tests/test-dotnet-tool-pins.sh + - name: 📝 Check TODO scanner ignore-input forwarding shell: bash run: bash .github/tests/test-scan-todo-ignore-input.sh diff --git a/.github/workflows/publish-dotnet-library.yaml b/.github/workflows/publish-dotnet-library.yaml index 14f33c80..756d27b8 100644 --- a/.github/workflows/publish-dotnet-library.yaml +++ b/.github/workflows/publish-dotnet-library.yaml @@ -45,7 +45,10 @@ jobs: - name: 🚚 Publish shell: bash run: | - dotnet tool install --global dotnet-releaser + # Pinned: this tool runs with the NuGet key and a write token, so an unreviewed + # upstream release must never reach them. Dependabot bumps the tracked copy in + # .github/tools/dotnet/.config/dotnet-tools.json; keep this in step with it. + dotnet tool install --global dotnet-releaser --version 0.24.0 dotnet-releaser run --nuget-token "${NUGET_API_KEY}" --github-token "${GITHUB_TOKEN}" dotnet-releaser.toml env: NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }} From c86c011343513d0c9b9f187e57b30743c077e5b8 Mon Sep 17 00:00:00 2001 From: Nikolai Emil Damm Date: Sat, 26 Sep 2026 15:13:43 +0200 Subject: [PATCH 02/18] fix(ci): pin dotnet-releaser inline without a tracked tool manifest The Dependabot-tracked dotnet-tools.json turned on GitHub's automatic NuGet dependency submission, which restores the deliberately broken .github/fixtures projects and fails. Keep the exact inline pins and the test that rejects an unpinned install; bump pins by hand after review. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/dependabot.yml | 10 ------ .github/tests/test-dotnet-tool-pins.sh | 31 ++++++------------- .../tools/dotnet/.config/dotnet-tools.json | 12 ------- .github/workflows/publish-dotnet-library.yaml | 4 +-- 4 files changed, 12 insertions(+), 45 deletions(-) delete mode 100644 .github/tools/dotnet/.config/dotnet-tools.json diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 6852e25b..f5d9319a 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -51,13 +51,3 @@ updates: default-days: 7 ignore: - dependency-name: "*" - # The .NET tools the reusable workflows install. publish-dotnet-library.yaml runs in the - # CALLER's checkout, so it cannot read this manifest and pins the same version inline; - # this manifest exists so Dependabot proposes bumps, and - # .github/tests/test-dotnet-tool-pins.sh fails until the inline pin matches it. - - package-ecosystem: nuget - directory: /.github/tools/dotnet - schedule: - interval: daily - cooldown: - default-days: 7 diff --git a/.github/tests/test-dotnet-tool-pins.sh b/.github/tests/test-dotnet-tool-pins.sh index 31b9c630..14b1c11c 100755 --- a/.github/tests/test-dotnet-tool-pins.sh +++ b/.github/tests/test-dotnet-tool-pins.sh @@ -2,29 +2,23 @@ # Pins how the reusable workflows and composite actions install .NET tools (#265). # -# 1. Every `dotnet tool install` names an explicit `--version`. An unpinned install takes +# 1. Every `dotnet tool install` names an exact `--version`. An unpinned install takes # whatever upstream released last, and publish-dotnet-library.yaml runs dotnet-releaser # with the NuGet API key and a write-capable token. -# 2. Every tool in the Dependabot-tracked manifest (.github/tools/dotnet/.config/ -# dotnet-tools.json) is installed at exactly the manifest's version. The workflows run in -# the caller's checkout and cannot read the manifest, so the inline pin is what runs; -# this check is what makes a Dependabot bump of the manifest reach it. -# 3. dotnet-releaser itself stays in the manifest, so its bumps keep being proposed. +# 2. dotnet-releaser in particular stays pinned, so removing its install line cannot make +# this check pass vacuously. +# +# The pins are bumped by hand after reviewing the upstream release. A Dependabot-tracked tool +# manifest is deliberately not used: adding one turns on GitHub's automatic NuGet dependency +# submission, which restores the deliberately broken .github/fixtures projects and fails. set -euo pipefail -manifest=".github/tools/dotnet/.config/dotnet-tools.json" - fail() { echo "FAIL: $*" >&2 exit 1 } -[[ -f "$manifest" ]] || fail "missing tool manifest: $manifest" - -releaser="$(jq -r '.tools["dotnet-releaser"].version // ""' "$manifest")" -[[ -n "$releaser" ]] || fail "$manifest must track dotnet-releaser so Dependabot proposes its bumps" - installs="$(grep -rnE 'dotnet tool install' .github/workflows actions --include='*.yaml' --include='*.yml' | grep -vE '^[^:]+:[0-9]+:[[:space:]]*#' || true)" [[ -n "$installs" ]] || fail "found no 'dotnet tool install' lines — refusing to pass vacuously" @@ -36,14 +30,9 @@ while IFS= read -r line; do [[ -n "$version" ]] || fail "$where installs a .NET tool without --version: ${command#"${command%%[![:space:]]*}"}" [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.-]+)?$ ]] || fail "$where pins '$version', which is not an exact version" - tool="$(printf '%s\n' "$command" | sed -nE 's/.*dotnet tool install[[:space:]]+(--global[[:space:]]+|-g[[:space:]]+)?([^[:space:]-][^[:space:]]*).*/\2/p')" - tracked="$(jq -r --arg t "$tool" '.tools[$t].version // ""' "$manifest")" - if [[ -n "$tracked" && "$tracked" != "$version" ]]; then - fail "$where installs $tool $version, but $manifest tracks $tracked — update the inline pin to $tracked" - fi done <<<"$installs" -grep -rqE "dotnet tool install.*dotnet-releaser.*--version[[:space:]=]+$releaser" .github/workflows || - fail "no workflow installs dotnet-releaser at the tracked version $releaser" +grep -rqE 'dotnet tool install.*dotnet-releaser.*--version[[:space:]=]+[0-9]+\.[0-9]+\.[0-9]+' .github/workflows || + fail "no workflow installs dotnet-releaser at an exact --version" -echo "ok every .NET tool install is pinned, and tracked tools match $manifest" +echo "ok every .NET tool install is pinned to an exact version" diff --git a/.github/tools/dotnet/.config/dotnet-tools.json b/.github/tools/dotnet/.config/dotnet-tools.json deleted file mode 100644 index 61d05392..00000000 --- a/.github/tools/dotnet/.config/dotnet-tools.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "version": 1, - "isRoot": true, - "tools": { - "dotnet-releaser": { - "version": "0.24.0", - "commands": [ - "dotnet-releaser" - ] - } - } -} diff --git a/.github/workflows/publish-dotnet-library.yaml b/.github/workflows/publish-dotnet-library.yaml index 756d27b8..77d37ebf 100644 --- a/.github/workflows/publish-dotnet-library.yaml +++ b/.github/workflows/publish-dotnet-library.yaml @@ -46,8 +46,8 @@ jobs: shell: bash run: | # Pinned: this tool runs with the NuGet key and a write token, so an unreviewed - # upstream release must never reach them. Dependabot bumps the tracked copy in - # .github/tools/dotnet/.config/dotnet-tools.json; keep this in step with it. + # upstream release must never reach them. Bump it by hand after reviewing the release; + # .github/tests/test-dotnet-tool-pins.sh fails on an unpinned install. dotnet tool install --global dotnet-releaser --version 0.24.0 dotnet-releaser run --nuget-token "${NUGET_API_KEY}" --github-token "${GITHUB_TOKEN}" dotnet-releaser.toml env: From 5558e6ffa384cc38575649cde177d412f86666d9 Mon Sep 17 00:00:00 2001 From: Nikolai Emil Damm Date: Sat, 26 Sep 2026 16:08:49 +0200 Subject: [PATCH 03/18] fix(ci): check each chained dotnet tool install for its own version A pinned install chained on the same line (a; b) could lend its --version to an unpinned install before it, so the guard passed while dotnet-releaser stayed unpinned. Split each run line into commands and require an exact --version on every install, and on the dotnet-releaser install itself. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Nikolai Emil Damm --- .github/tests/test-dotnet-tool-pins.sh | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/.github/tests/test-dotnet-tool-pins.sh b/.github/tests/test-dotnet-tool-pins.sh index 14b1c11c..645c261b 100755 --- a/.github/tests/test-dotnet-tool-pins.sh +++ b/.github/tests/test-dotnet-tool-pins.sh @@ -23,16 +23,25 @@ installs="$(grep -rnE 'dotnet tool install' .github/workflows actions --include= grep -vE '^[^:]+:[0-9]+:[[:space:]]*#' || true)" [[ -n "$installs" ]] || fail "found no 'dotnet tool install' lines — refusing to pass vacuously" +# One line can chain several commands (`a; b`, `a && b`). Each install command is checked on +# its own, so a pinned install cannot lend its `--version` to an unpinned one on the same line. +releaser_pinned=false while IFS= read -r line; do where="${line%%:*}:$(printf '%s' "$line" | cut -d: -f2)" command="${line#*:*:}" - version="$(printf '%s\n' "$command" | sed -nE 's/.*--version[[:space:]=]+([^[:space:]]+).*/\1/p')" - [[ -n "$version" ]] || fail "$where installs a .NET tool without --version: ${command#"${command%%[![:space:]]*}"}" - [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.-]+)?$ ]] || - fail "$where pins '$version', which is not an exact version" + while IFS= read -r segment; do + [[ "$segment" == *"dotnet tool install"* ]] || continue + segment="${segment#"${segment%%[![:space:]]*}"}" + version="$(printf '%s\n' "$segment" | sed -nE 's/.*--version[[:space:]=]+([^[:space:]]+).*/\1/p')" + [[ -n "$version" ]] || fail "$where installs a .NET tool without --version: $segment" + [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.-]+)?$ ]] || + fail "$where pins '$version', which is not an exact version" + if [[ "$where" == .github/workflows/* && "$segment" =~ (^|[[:space:]])dotnet-releaser([[:space:]]|$) ]]; then + releaser_pinned=true + fi + done < <(printf '%s\n' "$command" | sed -E 's/(;|&&|\|\||\|)/\n/g') done <<<"$installs" -grep -rqE 'dotnet tool install.*dotnet-releaser.*--version[[:space:]=]+[0-9]+\.[0-9]+\.[0-9]+' .github/workflows || - fail "no workflow installs dotnet-releaser at an exact --version" +"$releaser_pinned" || fail "no workflow installs dotnet-releaser at an exact --version" echo "ok every .NET tool install is pinned to an exact version" From 5d21709ac1e9f3fcf594815bc1b0a9a0301c6a40 Mon Sep 17 00:00:00 2001 From: Nikolai Emil Damm Date: Sat, 26 Sep 2026 16:59:51 +0200 Subject: [PATCH 04/18] fix(ci): ignore a --version that appears only in a shell comment Strip a trailing comment before checking each install, so `install dotnet-releaser # --version 0.24.0` fails the pin check. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Nikolai Emil Damm --- .github/tests/test-dotnet-tool-pins.sh | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/tests/test-dotnet-tool-pins.sh b/.github/tests/test-dotnet-tool-pins.sh index 645c261b..8d9883a3 100755 --- a/.github/tests/test-dotnet-tool-pins.sh +++ b/.github/tests/test-dotnet-tool-pins.sh @@ -29,6 +29,9 @@ releaser_pinned=false while IFS= read -r line; do where="${line%%:*}:$(printf '%s' "$line" | cut -d: -f2)" command="${line#*:*:}" + # Drop a trailing shell comment first, so a `--version` written only in a comment cannot + # satisfy the check for the install before it. + command="$(printf '%s\n' "$command" | sed -E 's/(^|[[:space:]])#.*$//')" while IFS= read -r segment; do [[ "$segment" == *"dotnet tool install"* ]] || continue segment="${segment#"${segment%%[![:space:]]*}"}" From 88ce212c1988278d3668544770af55b8bccc5143 Mon Sep 17 00:00:00 2001 From: Nikolai Emil Damm Date: Sat, 26 Sep 2026 18:00:25 +0200 Subject: [PATCH 05/18] fix(ci): strip only unquoted shell comments before checking installs A `#` inside quotes is data, so it must not hide an install that follows it on the same line. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Nikolai Emil Damm --- .github/tests/test-dotnet-tool-pins.sh | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/.github/tests/test-dotnet-tool-pins.sh b/.github/tests/test-dotnet-tool-pins.sh index 8d9883a3..4e7017c3 100755 --- a/.github/tests/test-dotnet-tool-pins.sh +++ b/.github/tests/test-dotnet-tool-pins.sh @@ -19,6 +19,26 @@ fail() { exit 1 } +# strip_comment : the text up to an unquoted `#` that starts a word, as the shell reads +# it. A `#` inside single or double quotes is data, so `echo " # x"; dotnet tool install …` +# keeps the install that follows it. +strip_comment() { + local text=$1 out="" quote="" prev=" " ch i + for ((i = 0; i < ${#text}; i++)); do + ch=${text:i:1} + if [[ -n "$quote" ]]; then + [[ "$ch" == "$quote" ]] && quote="" + elif [[ "$ch" == "'" || "$ch" == '"' ]]; then + quote=$ch + elif [[ "$ch" == "#" && "$prev" =~ [[:space:]] ]]; then + break + fi + out+=$ch + prev=$ch + done + printf '%s\n' "$out" +} + installs="$(grep -rnE 'dotnet tool install' .github/workflows actions --include='*.yaml' --include='*.yml' | grep -vE '^[^:]+:[0-9]+:[[:space:]]*#' || true)" [[ -n "$installs" ]] || fail "found no 'dotnet tool install' lines — refusing to pass vacuously" @@ -31,7 +51,7 @@ while IFS= read -r line; do command="${line#*:*:}" # Drop a trailing shell comment first, so a `--version` written only in a comment cannot # satisfy the check for the install before it. - command="$(printf '%s\n' "$command" | sed -E 's/(^|[[:space:]])#.*$//')" + command="$(strip_comment "$command")" while IFS= read -r segment; do [[ "$segment" == *"dotnet tool install"* ]] || continue segment="${segment#"${segment%%[![:space:]]*}"}" From 1736acb4d2fd83a09013bd9b251583449bfbc4fc Mon Sep 17 00:00:00 2001 From: Nikolai Emil Damm Date: Sat, 26 Sep 2026 18:59:33 +0200 Subject: [PATCH 06/18] fix(ci): allow-list the .NET tool install shape instead of parsing shell Each parser fix for chained commands, comments and quoting left another spelling that hid an unpinned install. Require every install line to be exactly one pinned install and nothing else, and keep each reported bypass as a built-in negative control. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Nikolai Emil Damm --- .github/tests/test-dotnet-tool-pins.sh | 91 ++++++++++++++------------ 1 file changed, 49 insertions(+), 42 deletions(-) diff --git a/.github/tests/test-dotnet-tool-pins.sh b/.github/tests/test-dotnet-tool-pins.sh index 4e7017c3..7c7b4ebb 100755 --- a/.github/tests/test-dotnet-tool-pins.sh +++ b/.github/tests/test-dotnet-tool-pins.sh @@ -2,12 +2,21 @@ # Pins how the reusable workflows and composite actions install .NET tools (#265). # -# 1. Every `dotnet tool install` names an exact `--version`. An unpinned install takes +# 1. Every line that installs or updates a .NET tool has exactly one allowed shape: +# +# dotnet tool install --global --version +# +# on a line of its own, with nothing before or after it. An unpinned install takes # whatever upstream released last, and publish-dotnet-library.yaml runs dotnet-releaser # with the NuGet API key and a write-capable token. # 2. dotnet-releaser in particular stays pinned, so removing its install line cannot make # this check pass vacuously. # +# The check is an allow-list, not a shell parser. Chained commands, trailing comments, +# quoting, escapes and line continuations each gave an earlier parser a way to miss an +# unpinned install; here any of them simply fails, and the fix is to put the install on its +# own line in the shape above. +# # The pins are bumped by hand after reviewing the upstream release. A Dependabot-tracked tool # manifest is deliberately not used: adding one turns on GitHub's automatic NuGet dependency # submission, which restores the deliberately broken .github/fixtures projects and fails. @@ -19,52 +28,50 @@ fail() { exit 1 } -# strip_comment : the text up to an unquoted `#` that starts a word, as the shell reads -# it. A `#` inside single or double quotes is data, so `echo " # x"; dotnet tool install …` -# keeps the install that follows it. -strip_comment() { - local text=$1 out="" quote="" prev=" " ch i - for ((i = 0; i < ${#text}; i++)); do - ch=${text:i:1} - if [[ -n "$quote" ]]; then - [[ "$ch" == "$quote" ]] && quote="" - elif [[ "$ch" == "'" || "$ch" == '"' ]]; then - quote=$ch - elif [[ "$ch" == "#" && "$prev" =~ [[:space:]] ]]; then - break - fi - out+=$ch - prev=$ch - done - printf '%s\n' "$out" -} +allowed='^[[:space:]]*dotnet tool install --global ([A-Za-z0-9._-]+) --version [0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.-]+)?[[:space:]]*$' -installs="$(grep -rnE 'dotnet tool install' .github/workflows actions --include='*.yaml' --include='*.yml' | - grep -vE '^[^:]+:[0-9]+:[[:space:]]*#' || true)" +# Every line that mentions installing or updating a .NET tool, except whole-line comments. +installs="$(grep -rnE 'dotnet[[:space:]]+tool[[:space:]]+(install|update)' .github/workflows actions \ + --include='*.yaml' --include='*.yml' | grep -vE '^[^:]+:[0-9]+:[[:space:]]*#' || true)" [[ -n "$installs" ]] || fail "found no 'dotnet tool install' lines — refusing to pass vacuously" -# One line can chain several commands (`a; b`, `a && b`). Each install command is checked on -# its own, so a pinned install cannot lend its `--version` to an unpinned one on the same line. -releaser_pinned=false -while IFS= read -r line; do - where="${line%%:*}:$(printf '%s' "$line" | cut -d: -f2)" - command="${line#*:*:}" - # Drop a trailing shell comment first, so a `--version` written only in a comment cannot - # satisfy the check for the install before it. - command="$(strip_comment "$command")" - while IFS= read -r segment; do - [[ "$segment" == *"dotnet tool install"* ]] || continue - segment="${segment#"${segment%%[![:space:]]*}"}" - version="$(printf '%s\n' "$segment" | sed -nE 's/.*--version[[:space:]=]+([^[:space:]]+).*/\1/p')" - [[ -n "$version" ]] || fail "$where installs a .NET tool without --version: $segment" - [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.-]+)?$ ]] || - fail "$where pins '$version', which is not an exact version" - if [[ "$where" == .github/workflows/* && "$segment" =~ (^|[[:space:]])dotnet-releaser([[:space:]]|$) ]]; then +# check : fail on any line outside the allowed shape, or when no workflow pins +# dotnet-releaser. +check() { + local line where command releaser_pinned=false + while IFS= read -r line; do + where="${line%%:*}:$(printf '%s' "$line" | cut -d: -f2)" + command="${line#*:*:}" + [[ "$command" =~ $allowed ]] || + fail "$where must be exactly 'dotnet tool install --global --version ' on its own line: ${command#"${command%%[![:space:]]*}"}" + if [[ "$where" == .github/workflows/* && "${BASH_REMATCH[1]}" == dotnet-releaser ]]; then releaser_pinned=true fi - done < <(printf '%s\n' "$command" | sed -E 's/(;|&&|\|\||\|)/\n/g') -done <<<"$installs" + done <<<"$1" + "$releaser_pinned" || fail "no workflow installs dotnet-releaser at an exact --version" +} + +# Negative controls: shapes that earlier versions of this check let through, each placed on +# a line after a correctly pinned releaser install so only the shape itself can fail it. +pinned='.github/workflows/x.yaml:1: dotnet tool install --global dotnet-releaser --version 0.24.0' +(check "$pinned") || fail "positive control failed: a correctly pinned install was rejected" +while IFS= read -r bad; do + if (check "$pinned +.github/workflows/x.yaml:2:$bad") 2>/dev/null; then + fail "negative control passed: $bad" + fi +done <<'EOF' + dotnet tool install --global dotnet-releaser + dotnet tool install --global dotnet-releaser; dotnet tool install --global reportgenerator --version 5.5.10 + dotnet tool install --global dotnet-releaser # --version 0.24.0 + dotnet tool install --global dotnet-releaser;# --version 0.24.0 + echo " # note"; dotnet tool install --global reportgenerator + echo "a \" # b"; dotnet tool install --global dotnet-releaser + dotnet tool install --global dotnet-releaser --version 0.24.0 \ + dotnet tool update --global dotnet-releaser + dotnet tool install --global dotnet-releaser --version latest +EOF -"$releaser_pinned" || fail "no workflow installs dotnet-releaser at an exact --version" +check "$installs" echo "ok every .NET tool install is pinned to an exact version" From 269fb59e8f1a8b67a0bb9755f79840c38923aea3 Mon Sep 17 00:00:00 2001 From: Nikolai Emil Damm Date: Sat, 26 Sep 2026 20:23:30 +0200 Subject: [PATCH 07/18] fix(ci): join backslash continuations before checking .NET tool installs --- .github/tests/test-dotnet-tool-pins.sh | 48 ++++++++++++++++++++++++-- 1 file changed, 46 insertions(+), 2 deletions(-) diff --git a/.github/tests/test-dotnet-tool-pins.sh b/.github/tests/test-dotnet-tool-pins.sh index 7c7b4ebb..17f4d44f 100755 --- a/.github/tests/test-dotnet-tool-pins.sh +++ b/.github/tests/test-dotnet-tool-pins.sh @@ -30,9 +30,43 @@ fail() { allowed='^[[:space:]]*dotnet tool install --global ([A-Za-z0-9._-]+) --version [0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.-]+)?[[:space:]]*$' +# Joins shell lines continued with trailing backslashes before matching, so split +# commands cannot bypass detection. Whole-line comments are ignored. +scan_installs() { + awk ' + FNR == 1 { buf = ""; start_line = 0 } + /^[[:space:]]*#/ { next } + /[[:space:]]*\\[[:space:]]*$/ { + sub(/[[:space:]]*\\[[:space:]]*$/, "") + if (buf == "") { start_line = FNR } + buf = buf $0 " " + next + } + { + if (buf != "") { + line = buf $0 + buf = "" + fnr = start_line + } else { + line = $0 + fnr = FNR + } + if (line ~ /dotnet[[:space:]]+tool[[:space:]]+(install|update)/) { + print (FILENAME ? FILENAME : "stdin") ":" fnr ":" line + } + } + ' "$@" +} + +target_files=() +while IFS= read -r f; do + [[ -n "$f" ]] && target_files+=("$f") +done < <(find .github/workflows actions -type f \( -name '*.yaml' -o -name '*.yml' \) 2>/dev/null | sort) + +[[ ${#target_files[@]} -gt 0 ]] || fail "found no workflow or action YAML files to scan" + # Every line that mentions installing or updating a .NET tool, except whole-line comments. -installs="$(grep -rnE 'dotnet[[:space:]]+tool[[:space:]]+(install|update)' .github/workflows actions \ - --include='*.yaml' --include='*.yml' | grep -vE '^[^:]+:[0-9]+:[[:space:]]*#' || true)" +installs="$(scan_installs "${target_files[@]}")" [[ -n "$installs" ]] || fail "found no 'dotnet tool install' lines — refusing to pass vacuously" # check : fail on any line outside the allowed shape, or when no workflow pins @@ -72,6 +106,16 @@ done <<'EOF' dotnet tool install --global dotnet-releaser --version latest EOF +# Negative control: an unpinned install split across lines with a backslash must reach +# the pin check and be rejected. +continued_scan="$(printf '%s\n' \ + ' dotnet tool install --global dotnet-releaser --version 0.24.0' \ + " dotnet tool \\" \ + ' install --global dotnet-releaser' | scan_installs)" +if (check "$continued_scan") 2>/dev/null; then + fail "negative control passed: unpinned install split across lines was not rejected" +fi + check "$installs" echo "ok every .NET tool install is pinned to an exact version" From 77573ee70db1b5ef5913033308cdc4b7c2462c70 Mon Sep 17 00:00:00 2001 From: Nikolai Emil Damm Date: Sat, 26 Sep 2026 20:32:14 +0200 Subject: [PATCH 08/18] fix(ci): scan all catalogue trees and require dotnet-releaser in publishing workflow --- .github/tests/test-dotnet-tool-pins.sh | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/.github/tests/test-dotnet-tool-pins.sh b/.github/tests/test-dotnet-tool-pins.sh index 17f4d44f..4860f853 100755 --- a/.github/tests/test-dotnet-tool-pins.sh +++ b/.github/tests/test-dotnet-tool-pins.sh @@ -61,16 +61,16 @@ scan_installs() { target_files=() while IFS= read -r f; do [[ -n "$f" ]] && target_files+=("$f") -done < <(find .github/workflows actions -type f \( -name '*.yaml' -o -name '*.yml' \) 2>/dev/null | sort) +done < <(find .github/workflows .github/actions actions .scripts scripts -type f \( -name '*.yaml' -o -name '*.yml' -o -name '*.sh' \) 2>/dev/null | sort) -[[ ${#target_files[@]} -gt 0 ]] || fail "found no workflow or action YAML files to scan" +[[ ${#target_files[@]} -gt 0 ]] || fail "found no workflow, action or script files to scan" # Every line that mentions installing or updating a .NET tool, except whole-line comments. installs="$(scan_installs "${target_files[@]}")" [[ -n "$installs" ]] || fail "found no 'dotnet tool install' lines — refusing to pass vacuously" -# check : fail on any line outside the allowed shape, or when no workflow pins -# dotnet-releaser. +# check : fail on any line outside the allowed shape, or when publish-dotnet-library.yaml +# does not pin dotnet-releaser. check() { local line where command releaser_pinned=false while IFS= read -r line; do @@ -78,20 +78,20 @@ check() { command="${line#*:*:}" [[ "$command" =~ $allowed ]] || fail "$where must be exactly 'dotnet tool install --global --version ' on its own line: ${command#"${command%%[![:space:]]*}"}" - if [[ "$where" == .github/workflows/* && "${BASH_REMATCH[1]}" == dotnet-releaser ]]; then + if [[ "$where" == .github/workflows/publish-dotnet-library.yaml:* && "${BASH_REMATCH[1]}" == dotnet-releaser ]]; then releaser_pinned=true fi done <<<"$1" - "$releaser_pinned" || fail "no workflow installs dotnet-releaser at an exact --version" + "$releaser_pinned" || fail "publish-dotnet-library.yaml does not install dotnet-releaser at an exact --version" } # Negative controls: shapes that earlier versions of this check let through, each placed on # a line after a correctly pinned releaser install so only the shape itself can fail it. -pinned='.github/workflows/x.yaml:1: dotnet tool install --global dotnet-releaser --version 0.24.0' +pinned='.github/workflows/publish-dotnet-library.yaml:1: dotnet tool install --global dotnet-releaser --version 0.24.0' (check "$pinned") || fail "positive control failed: a correctly pinned install was rejected" while IFS= read -r bad; do if (check "$pinned -.github/workflows/x.yaml:2:$bad") 2>/dev/null; then +.github/workflows/publish-dotnet-library.yaml:2:$bad") 2>/dev/null; then fail "negative control passed: $bad" fi done <<'EOF' @@ -116,6 +116,13 @@ if (check "$continued_scan") 2>/dev/null; then fail "negative control passed: unpinned install split across lines was not rejected" fi +# Negative control: dotnet-releaser pinned only in an unrelated workflow must not satisfy +# the publishing workflow requirement. +unrelated_workflow='.github/workflows/other.yaml:1: dotnet tool install --global dotnet-releaser --version 0.24.0' +if (check "$unrelated_workflow") 2>/dev/null; then + fail "negative control passed: dotnet-releaser pinned in an unrelated workflow satisfied the publishing workflow requirement" +fi + check "$installs" echo "ok every .NET tool install is pinned to an exact version" From 13a670a268b24ce6783e4c9b246e4a38feaeaf04 Mon Sep 17 00:00:00 2001 From: Nikolai Emil Damm Date: Sat, 26 Sep 2026 20:45:07 +0200 Subject: [PATCH 09/18] fix(ci): scan .github/scripts and flush continuations at comments and EOF --- .github/tests/test-dotnet-tool-pins.sh | 50 +++++++++++++++++++++++--- 1 file changed, 46 insertions(+), 4 deletions(-) diff --git a/.github/tests/test-dotnet-tool-pins.sh b/.github/tests/test-dotnet-tool-pins.sh index 4860f853..2958d5b6 100755 --- a/.github/tests/test-dotnet-tool-pins.sh +++ b/.github/tests/test-dotnet-tool-pins.sh @@ -31,11 +31,25 @@ fail() { allowed='^[[:space:]]*dotnet tool install --global ([A-Za-z0-9._-]+) --version [0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.-]+)?[[:space:]]*$' # Joins shell lines continued with trailing backslashes before matching, so split -# commands cannot bypass detection. Whole-line comments are ignored. +# commands cannot bypass detection. Whole-line comments are ignored, but any pending +# continuation buffer is flushed before skipping comments, switching files or exiting. scan_installs() { awk ' - FNR == 1 { buf = ""; start_line = 0 } - /^[[:space:]]*#/ { next } + function flush_buf() { + if (buf != "") { + if (buf ~ /dotnet[[:space:]]+tool[[:space:]]+(install|update)/) { + print (FILENAME ? FILENAME : "stdin") ":" start_line ":" buf + } + buf = "" + start_line = 0 + } + } + + FNR == 1 { flush_buf() } + /^[[:space:]]*#/ { + flush_buf() + next + } /[[:space:]]*\\[[:space:]]*$/ { sub(/[[:space:]]*\\[[:space:]]*$/, "") if (buf == "") { start_line = FNR } @@ -47,6 +61,7 @@ scan_installs() { line = buf $0 buf = "" fnr = start_line + start_line = 0 } else { line = $0 fnr = FNR @@ -55,13 +70,21 @@ scan_installs() { print (FILENAME ? FILENAME : "stdin") ":" fnr ":" line } } + END { + flush_buf() + } ' "$@" } +search_roots=() +for dir in .github/workflows .github/actions .github/scripts actions .scripts scripts; do + [[ -d "$dir" ]] && search_roots+=("$dir") +done + target_files=() while IFS= read -r f; do [[ -n "$f" ]] && target_files+=("$f") -done < <(find .github/workflows .github/actions actions .scripts scripts -type f \( -name '*.yaml' -o -name '*.yml' -o -name '*.sh' \) 2>/dev/null | sort) +done < <(find "${search_roots[@]}" -type f \( -name '*.yaml' -o -name '*.yml' -o -name '*.sh' \) 2>/dev/null | sort) [[ ${#target_files[@]} -gt 0 ]] || fail "found no workflow, action or script files to scan" @@ -116,6 +139,25 @@ if (check "$continued_scan") 2>/dev/null; then fail "negative control passed: unpinned install split across lines was not rejected" fi +# Negative control: an unpinned install continued with a backslash before an EOF comment +# must be flushed and rejected. +eof_comment_scan="$(printf '%s\n' \ + ' dotnet tool install --global dotnet-releaser --version 0.24.0' \ + " dotnet tool install --global evil-tool \\" \ + ' # end of file comment' | scan_installs)" +if (check "$eof_comment_scan") 2>/dev/null; then + fail "negative control passed: unpinned install before EOF comment was not rejected" +fi + +# Negative control: an unpinned install continued with a backslash at raw EOF must be flushed +# and rejected. +raw_eof_scan="$(printf '%s\n' \ + ' dotnet tool install --global dotnet-releaser --version 0.24.0' \ + " dotnet tool install --global evil-tool \\" | scan_installs)" +if (check "$raw_eof_scan") 2>/dev/null; then + fail "negative control passed: unpinned install at raw EOF was not rejected" +fi + # Negative control: dotnet-releaser pinned only in an unrelated workflow must not satisfy # the publishing workflow requirement. unrelated_workflow='.github/workflows/other.yaml:1: dotnet tool install --global dotnet-releaser --version 0.24.0' From 2b18ae6e204b367eb48092a4da8266c23a8a8d8d Mon Sep 17 00:00:00 2001 From: Nikolai Emil Damm Date: Sat, 26 Sep 2026 20:51:58 +0200 Subject: [PATCH 10/18] fix(ci): fold YAML run scalars and detect split tool install keywords --- .github/tests/test-dotnet-tool-pins.sh | 75 ++++++++++++++++++++++++-- 1 file changed, 70 insertions(+), 5 deletions(-) diff --git a/.github/tests/test-dotnet-tool-pins.sh b/.github/tests/test-dotnet-tool-pins.sh index 2958d5b6..907f0d8b 100755 --- a/.github/tests/test-dotnet-tool-pins.sh +++ b/.github/tests/test-dotnet-tool-pins.sh @@ -31,13 +31,15 @@ fail() { allowed='^[[:space:]]*dotnet tool install --global ([A-Za-z0-9._-]+) --version [0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.-]+)?[[:space:]]*$' # Joins shell lines continued with trailing backslashes before matching, so split -# commands cannot bypass detection. Whole-line comments are ignored, but any pending -# continuation buffer is flushed before skipping comments, switching files or exiting. +# commands cannot bypass detection. Whole-line comments and step names are ignored, +# but any pending continuation buffer is flushed before skipping comments, switching +# files or exiting. YAML folded scalars (run: > or run: >-) are folded so multiline +# commands are checked. scan_installs() { awk ' function flush_buf() { if (buf != "") { - if (buf ~ /dotnet[[:space:]]+tool[[:space:]]+(install|update)/) { + if (buf ~ /dotnet[[:space:]]+tool/ || buf ~ /^[[:space:]]*tool[[:space:]]+(install|update)([[:space:]]|$)/) { print (FILENAME ? FILENAME : "stdin") ":" start_line ":" buf } buf = "" @@ -45,11 +47,51 @@ scan_installs() { } } - FNR == 1 { flush_buf() } + function flush_folded() { + if (folded_buf != "") { + if (folded_buf ~ /dotnet[[:space:]]+tool/ || folded_buf ~ /tool[[:space:]]+(install|update)([[:space:]]|$)/) { + print (FILENAME ? FILENAME : "stdin") ":" folded_start ":" folded_buf + } + folded_buf = "" + folded_indent = 0 + in_folded = 0 + } + } + + FNR == 1 { flush_buf(); flush_folded() } /^[[:space:]]*#/ { flush_buf() next } + /^[[:space:]]*-?[[:space:]]*name:/ { + flush_buf() + next + } + + in_folded { + match($0, /^[[:space:]]*/) + cur_indent = RLENGTH + if (cur_indent >= folded_indent && NF > 0) { + folded_buf = (folded_buf == "" ? "" : folded_buf " ") $0 + sub(/^[[:space:]]*/, "", folded_buf) + next + } else if (NF == 0) { + next + } else { + flush_folded() + } + } + + /^[[:space:]]*(-[[:space:]]+)?run:[[:space:]]*>[+-]?[[:space:]]*$/ { + flush_buf() + in_folded = 1 + match($0, /^[[:space:]]*/) + folded_indent = RLENGTH + 2 + folded_start = FNR + folded_buf = "" + next + } + /[[:space:]]*\\[[:space:]]*$/ { sub(/[[:space:]]*\\[[:space:]]*$/, "") if (buf == "") { start_line = FNR } @@ -66,12 +108,13 @@ scan_installs() { line = $0 fnr = FNR } - if (line ~ /dotnet[[:space:]]+tool[[:space:]]+(install|update)/) { + if (line ~ /dotnet[[:space:]]+tool/ || line ~ /^[[:space:]]*tool[[:space:]]+(install|update)([[:space:]]|$)/) { print (FILENAME ? FILENAME : "stdin") ":" fnr ":" line } } END { flush_buf() + flush_folded() } ' "$@" } @@ -158,6 +201,28 @@ if (check "$raw_eof_scan") 2>/dev/null; then fail "negative control passed: unpinned install at raw EOF was not rejected" fi +# Negative control: an unpinned install split across lines in a YAML folded scalar (run: >) +# must be detected and rejected. +folded_scalar_scan="$(printf '%s\n' \ + ' - name: Publish' \ + ' run: >' \ + ' dotnet tool' \ + ' install --global evil-tool' | scan_installs)" +if (check "$pinned +$folded_scalar_scan") 2>/dev/null; then + fail "negative control passed: unpinned install split across folded scalar lines was not rejected" +fi + +# Negative control: an unpinned command where 'dotnet tool' is split on its own line +# must be caught and rejected. +split_keyword_scan="$(printf '%s\n' \ + ' dotnet tool install --global dotnet-releaser --version 0.24.0' \ + ' dotnet tool' \ + ' install --global evil-tool' | scan_installs)" +if (check "$split_keyword_scan") 2>/dev/null; then + fail "negative control passed: split dotnet tool command was not rejected" +fi + # Negative control: dotnet-releaser pinned only in an unrelated workflow must not satisfy # the publishing workflow requirement. unrelated_workflow='.github/workflows/other.yaml:1: dotnet tool install --global dotnet-releaser --version 0.24.0' From c8f97c9be01175774c1dbd7da86f60832aa6f153 Mon Sep 17 00:00:00 2001 From: Nikolai Emil Damm Date: Sat, 26 Sep 2026 20:59:52 +0200 Subject: [PATCH 11/18] fix(ci): inspect name: lines for tool commands and detect quoted dotnet executables --- .github/tests/test-dotnet-tool-pins.sh | 28 ++++++++++++++++++++++---- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/.github/tests/test-dotnet-tool-pins.sh b/.github/tests/test-dotnet-tool-pins.sh index 907f0d8b..95743b67 100755 --- a/.github/tests/test-dotnet-tool-pins.sh +++ b/.github/tests/test-dotnet-tool-pins.sh @@ -39,7 +39,7 @@ scan_installs() { awk ' function flush_buf() { if (buf != "") { - if (buf ~ /dotnet[[:space:]]+tool/ || buf ~ /^[[:space:]]*tool[[:space:]]+(install|update)([[:space:]]|$)/) { + if (buf ~ /["'\'']?dotnet["'\'']?[[:space:]]+["'\'']?tool["'\'']?/ || buf ~ /^[[:space:]]*tool[[:space:]]+(install|update)([[:space:]]|$)/) { print (FILENAME ? FILENAME : "stdin") ":" start_line ":" buf } buf = "" @@ -49,7 +49,7 @@ scan_installs() { function flush_folded() { if (folded_buf != "") { - if (folded_buf ~ /dotnet[[:space:]]+tool/ || folded_buf ~ /tool[[:space:]]+(install|update)([[:space:]]|$)/) { + if (folded_buf ~ /["'\'']?dotnet["'\'']?[[:space:]]+["'\'']?tool["'\'']?/ || folded_buf ~ /tool[[:space:]]+(install|update)([[:space:]]|$)/) { print (FILENAME ? FILENAME : "stdin") ":" folded_start ":" folded_buf } folded_buf = "" @@ -65,7 +65,9 @@ scan_installs() { } /^[[:space:]]*-?[[:space:]]*name:/ { flush_buf() - next + if ($0 !~ /["'\'']?dotnet["'\'']?[[:space:]]+["'\'']?tool["'\'']?/ && $0 !~ /tool[[:space:]]+(install|update)([[:space:]]|$)/) { + next + } } in_folded { @@ -108,7 +110,7 @@ scan_installs() { line = $0 fnr = FNR } - if (line ~ /dotnet[[:space:]]+tool/ || line ~ /^[[:space:]]*tool[[:space:]]+(install|update)([[:space:]]|$)/) { + if (line ~ /["'\'']?dotnet["'\'']?[[:space:]]+["'\'']?tool["'\'']?/ || line ~ /^[[:space:]]*tool[[:space:]]+(install|update)([[:space:]]|$)/) { print (FILENAME ? FILENAME : "stdin") ":" fnr ":" line } } @@ -223,6 +225,24 @@ if (check "$split_keyword_scan") 2>/dev/null; then fail "negative control passed: split dotnet tool command was not rejected" fi +# Negative control: a quoted dotnet executable form ("dotnet" tool) must reach the check +# and be rejected. +quoted_cmd_scan="$(printf '%s\n' \ + ' dotnet tool install --global dotnet-releaser --version 0.24.0' \ + ' "dotnet" tool install --global evil-tool' | scan_installs)" +if (check "$quoted_cmd_scan") 2>/dev/null; then + fail "negative control passed: quoted dotnet executable was not rejected" +fi + +# Negative control: a tool install placed after a name: command prefix must not be skipped +# as workflow metadata. +name_prefix_scan="$(printf '%s\n' \ + ' dotnet tool install --global dotnet-releaser --version 0.24.0' \ + ' name: || dotnet tool install --global evil-tool' | scan_installs)" +if (check "$name_prefix_scan") 2>/dev/null; then + fail "negative control passed: tool install after name: prefix was not rejected" +fi + # Negative control: dotnet-releaser pinned only in an unrelated workflow must not satisfy # the publishing workflow requirement. unrelated_workflow='.github/workflows/other.yaml:1: dotnet tool install --global dotnet-releaser --version 0.24.0' From 3fd795905c3ef545857ad812d0e0c5ae3e455ce6 Mon Sep 17 00:00:00 2001 From: Nikolai Emil Damm Date: Sat, 26 Sep 2026 21:07:37 +0200 Subject: [PATCH 12/18] fix(ci): normalize internal escapes and quotes when detecting tool commands --- .github/tests/test-dotnet-tool-pins.sh | 38 +++++++++++++++++++++++--- 1 file changed, 34 insertions(+), 4 deletions(-) diff --git a/.github/tests/test-dotnet-tool-pins.sh b/.github/tests/test-dotnet-tool-pins.sh index 95743b67..1b795832 100755 --- a/.github/tests/test-dotnet-tool-pins.sh +++ b/.github/tests/test-dotnet-tool-pins.sh @@ -37,9 +37,21 @@ allowed='^[[:space:]]*dotnet tool install --global ([A-Za-z0-9._-]+) --version [ # commands are checked. scan_installs() { awk ' + function norm(s, t) { + t = s + gsub(/\\/, "", t) + gsub(/["'\'']/, "", t) + return t + } + + function is_tool_cmd(s, t) { + t = norm(s) + return (t ~ /dotnet[[:space:]]+tool/ || t ~ /^[[:space:]]*tool[[:space:]]+(install|update)([[:space:]]|$)/) + } + function flush_buf() { if (buf != "") { - if (buf ~ /["'\'']?dotnet["'\'']?[[:space:]]+["'\'']?tool["'\'']?/ || buf ~ /^[[:space:]]*tool[[:space:]]+(install|update)([[:space:]]|$)/) { + if (is_tool_cmd(buf)) { print (FILENAME ? FILENAME : "stdin") ":" start_line ":" buf } buf = "" @@ -49,7 +61,7 @@ scan_installs() { function flush_folded() { if (folded_buf != "") { - if (folded_buf ~ /["'\'']?dotnet["'\'']?[[:space:]]+["'\'']?tool["'\'']?/ || folded_buf ~ /tool[[:space:]]+(install|update)([[:space:]]|$)/) { + if (is_tool_cmd(folded_buf)) { print (FILENAME ? FILENAME : "stdin") ":" folded_start ":" folded_buf } folded_buf = "" @@ -65,7 +77,7 @@ scan_installs() { } /^[[:space:]]*-?[[:space:]]*name:/ { flush_buf() - if ($0 !~ /["'\'']?dotnet["'\'']?[[:space:]]+["'\'']?tool["'\'']?/ && $0 !~ /tool[[:space:]]+(install|update)([[:space:]]|$)/) { + if (!is_tool_cmd($0)) { next } } @@ -110,7 +122,7 @@ scan_installs() { line = $0 fnr = FNR } - if (line ~ /["'\'']?dotnet["'\'']?[[:space:]]+["'\'']?tool["'\'']?/ || line ~ /^[[:space:]]*tool[[:space:]]+(install|update)([[:space:]]|$)/) { + if (is_tool_cmd(line)) { print (FILENAME ? FILENAME : "stdin") ":" fnr ":" line } } @@ -243,6 +255,24 @@ if (check "$name_prefix_scan") 2>/dev/null; then fail "negative control passed: tool install after name: prefix was not rejected" fi +# Negative control: an escaped tool subcommand (dotnet t\ool) must reach the check +# and be rejected. +escaped_tool_scan="$(printf '%s\n' \ + ' dotnet tool install --global dotnet-releaser --version 0.24.0' \ + ' dotnet t\ool install --global evil-tool' | scan_installs)" +if (check "$escaped_tool_scan") 2>/dev/null; then + fail "negative control passed: escaped tool subcommand was not rejected" +fi + +# Negative control: an escaped dotnet executable (d\otnet tool) must reach the check +# and be rejected. +escaped_dotnet_scan="$(printf '%s\n' \ + ' dotnet tool install --global dotnet-releaser --version 0.24.0' \ + ' d\otnet tool install --global evil-tool' | scan_installs)" +if (check "$escaped_dotnet_scan") 2>/dev/null; then + fail "negative control passed: escaped dotnet executable was not rejected" +fi + # Negative control: dotnet-releaser pinned only in an unrelated workflow must not satisfy # the publishing workflow requirement. unrelated_workflow='.github/workflows/other.yaml:1: dotnet tool install --global dotnet-releaser --version 0.24.0' From 0cf65919834eb2f0e65848809dc6c1f124d31b4a Mon Sep 17 00:00:00 2001 From: Nikolai Emil Damm Date: Sat, 26 Sep 2026 23:14:47 +0200 Subject: [PATCH 13/18] fix(ci): glue a token split by a backslash continuation before checking tool installs Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/tests/test-dotnet-tool-pins.sh | 27 +++++++++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/.github/tests/test-dotnet-tool-pins.sh b/.github/tests/test-dotnet-tool-pins.sh index 1b795832..7aa971e7 100755 --- a/.github/tests/test-dotnet-tool-pins.sh +++ b/.github/tests/test-dotnet-tool-pins.sh @@ -57,6 +57,7 @@ scan_installs() { buf = "" start_line = 0 } + glue = 0 } function flush_folded() { @@ -106,15 +107,25 @@ scan_installs() { next } + # A backslash directly after a character continues the same token (dot\ + net is + # dotnet), so the next line is glued on without its indentation; a backslash after + # whitespace separates tokens and is joined with a space. /[[:space:]]*\\[[:space:]]*$/ { - sub(/[[:space:]]*\\[[:space:]]*$/, "") + piece = $0 + spans = (piece ~ /[^[:space:]]\\[[:space:]]*$/) + sub(/[[:space:]]*\\[[:space:]]*$/, "", piece) + if (glue) { sub(/^[[:space:]]+/, "", piece) } if (buf == "") { start_line = FNR } - buf = buf $0 " " + buf = buf piece (spans ? "" : " ") + glue = spans next } { if (buf != "") { - line = buf $0 + piece = $0 + if (glue) { sub(/^[[:space:]]+/, "", piece) } + glue = 0 + line = buf piece buf = "" fnr = start_line start_line = 0 @@ -273,6 +284,16 @@ if (check "$escaped_dotnet_scan") 2>/dev/null; then fail "negative control passed: escaped dotnet executable was not rejected" fi +# Negative control: a continuation inside a token (dot\ + net) joins to dotnet in Bash, so it +# must reach the check and be rejected. +token_split_scan="$(printf '%s\n' \ + ' dotnet tool install --global dotnet-releaser --version 0.24.0' \ + ' dot\' \ + ' net tool install --global evil-tool' | scan_installs)" +if (check "$token_split_scan") 2>/dev/null; then + fail "negative control passed: tool command split inside a token was not rejected" +fi + # Negative control: dotnet-releaser pinned only in an unrelated workflow must not satisfy # the publishing workflow requirement. unrelated_workflow='.github/workflows/other.yaml:1: dotnet tool install --global dotnet-releaser --version 0.24.0' From f5f8de1f04ae6459bccaabf4337b520e8fc321c0 Mon Sep 17 00:00:00 2001 From: Nikolai Emil Damm Date: Sat, 26 Sep 2026 23:15:04 +0200 Subject: [PATCH 14/18] test(ci): quote the token-split control so shellcheck stays clean Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/tests/test-dotnet-tool-pins.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/tests/test-dotnet-tool-pins.sh b/.github/tests/test-dotnet-tool-pins.sh index 7aa971e7..1d1f935c 100755 --- a/.github/tests/test-dotnet-tool-pins.sh +++ b/.github/tests/test-dotnet-tool-pins.sh @@ -288,7 +288,7 @@ fi # must reach the check and be rejected. token_split_scan="$(printf '%s\n' \ ' dotnet tool install --global dotnet-releaser --version 0.24.0' \ - ' dot\' \ + " dot\\" \ ' net tool install --global evil-tool' | scan_installs)" if (check "$token_split_scan") 2>/dev/null; then fail "negative control passed: tool command split inside a token was not rejected" From ea6c1d2e8ce762435b5e2fab59cd918fd1e116ec Mon Sep 17 00:00:00 2001 From: Nikolai Emil Damm Date: Sat, 26 Sep 2026 23:15:52 +0200 Subject: [PATCH 15/18] fix(ci): recognise folded run headers with indicators or a comment Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/tests/test-dotnet-tool-pins.sh | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/.github/tests/test-dotnet-tool-pins.sh b/.github/tests/test-dotnet-tool-pins.sh index 1d1f935c..c6f70afe 100755 --- a/.github/tests/test-dotnet-tool-pins.sh +++ b/.github/tests/test-dotnet-tool-pins.sh @@ -97,7 +97,8 @@ scan_installs() { } } - /^[[:space:]]*(-[[:space:]]+)?run:[[:space:]]*>[+-]?[[:space:]]*$/ { + # A folded header may carry chomping/indentation indicators (>-, >2, >2-) and a comment. + /^[[:space:]]*(-[[:space:]]+)?run:[[:space:]]*>[1-9+-]*([[:space:]]+#.*)?[[:space:]]*$/ { flush_buf() in_folded = 1 match($0, /^[[:space:]]*/) @@ -238,6 +239,18 @@ $folded_scalar_scan") 2>/dev/null; then fail "negative control passed: unpinned install split across folded scalar lines was not rejected" fi +# Negative control: a folded header with indicators and a trailing comment still folds. +commented_folded_scan="$(printf '%s\n' \ + ' - name: Publish' \ + ' run: >- # explanation' \ + ' dotnet' \ + ' tool' \ + ' install --global evil-tool' | scan_installs)" +if (check "$pinned +$commented_folded_scan") 2>/dev/null; then + fail "negative control passed: unpinned install under a commented folded header was not rejected" +fi + # Negative control: an unpinned command where 'dotnet tool' is split on its own line # must be caught and rejected. split_keyword_scan="$(printf '%s\n' \ From d81d91f02128f11c2ef421499e2f9efa30cd325e Mon Sep 17 00:00:00 2001 From: Nikolai Emil Damm Date: Sun, 27 Sep 2026 00:11:02 +0200 Subject: [PATCH 16/18] fix(ci): honor folded scalar indentation indicators in dotnet tool pin guard --- .github/tests/test-dotnet-tool-pins.sh | 49 +++++++++++++++++++++----- 1 file changed, 41 insertions(+), 8 deletions(-) diff --git a/.github/tests/test-dotnet-tool-pins.sh b/.github/tests/test-dotnet-tool-pins.sh index c6f70afe..faabc6b1 100755 --- a/.github/tests/test-dotnet-tool-pins.sh +++ b/.github/tests/test-dotnet-tool-pins.sh @@ -65,10 +65,11 @@ scan_installs() { if (is_tool_cmd(folded_buf)) { print (FILENAME ? FILENAME : "stdin") ":" folded_start ":" folded_buf } - folded_buf = "" - folded_indent = 0 - in_folded = 0 } + folded_buf = "" + folded_indent = 0 + in_folded = 0 + base_indent = 0 } FNR == 1 { flush_buf(); flush_folded() } @@ -86,13 +87,21 @@ scan_installs() { in_folded { match($0, /^[[:space:]]*/) cur_indent = RLENGTH - if (cur_indent >= folded_indent && NF > 0) { + if (NF == 0) { + next + } + if (folded_indent == 0) { + if (cur_indent > base_indent) { + folded_indent = cur_indent + } else { + flush_folded() + } + } + if (in_folded && cur_indent >= folded_indent) { folded_buf = (folded_buf == "" ? "" : folded_buf " ") $0 sub(/^[[:space:]]*/, "", folded_buf) next - } else if (NF == 0) { - next - } else { + } else if (in_folded) { flush_folded() } } @@ -100,11 +109,22 @@ scan_installs() { # A folded header may carry chomping/indentation indicators (>-, >2, >2-) and a comment. /^[[:space:]]*(-[[:space:]]+)?run:[[:space:]]*>[1-9+-]*([[:space:]]+#.*)?[[:space:]]*$/ { flush_buf() + flush_folded() in_folded = 1 match($0, /^[[:space:]]*/) - folded_indent = RLENGTH + 2 + base_indent = RLENGTH folded_start = FNR folded_buf = "" + folded_indent = 0 + + # Parse explicit indentation indicator if present + hdr = $0 + sub(/^[[:space:]]*(-[[:space:]]+)?run:[[:space:]]*>/, "", hdr) + sub(/([[:space:]]+#.*)?$/, "", hdr) + if (match(hdr, /[1-9]/)) { + ind = substr(hdr, RSTART, 1) + 0 + folded_indent = base_indent + ind + } next } @@ -251,6 +271,19 @@ $commented_folded_scan") 2>/dev/null; then fail "negative control passed: unpinned install under a commented folded header was not rejected" fi +# Negative control: a folded header with an explicit indentation indicator (run: >1) +# whose body is indented only 1 space must be detected and rejected when unpinned. +folded_indicator_scan="$(printf '%s\n' \ + ' - name: Publish' \ + ' run: >1' \ + ' dotnet' \ + ' tool' \ + ' install --global evil-tool' | scan_installs)" +if (check "$pinned +$folded_indicator_scan") 2>/dev/null; then + fail "negative control passed: unpinned install with explicit indentation indicator >1 was not rejected" +fi + # Negative control: an unpinned command where 'dotnet tool' is split on its own line # must be caught and rejected. split_keyword_scan="$(printf '%s\n' \ From a801a493433e2a7d070acbbd2c311ef92ba72c16 Mon Sep 17 00:00:00 2001 From: Nikolai Emil Damm Date: Sun, 27 Sep 2026 00:11:42 +0200 Subject: [PATCH 17/18] fix(ci): read workflow run: scripts with a YAML parser instead of folding by hand Every run: value is now extracted with yq, which applies YAML's own folding, chomping and indentation-indicator rules, so an explicit indicator such as 'run: >1' can no longer hide an unpinned install. Unparseable files fail the check, and the folded-scalar controls now require the scan to find the install so an empty scan cannot pass as a rejection. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/tests/test-dotnet-tool-pins.sh | 165 +++++++++++++------------ 1 file changed, 87 insertions(+), 78 deletions(-) diff --git a/.github/tests/test-dotnet-tool-pins.sh b/.github/tests/test-dotnet-tool-pins.sh index c6f70afe..0908fa6b 100755 --- a/.github/tests/test-dotnet-tool-pins.sh +++ b/.github/tests/test-dotnet-tool-pins.sh @@ -29,14 +29,22 @@ fail() { } allowed='^[[:space:]]*dotnet tool install --global ([A-Za-z0-9._-]+) --version [0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.-]+)?[[:space:]]*$' - -# Joins shell lines continued with trailing backslashes before matching, so split -# commands cannot bypass detection. Whole-line comments and step names are ignored, -# but any pending continuation buffer is flushed before skipping comments, switching -# files or exiting. YAML folded scalars (run: > or run: >-) are folded so multiline -# commands are checked. +# YAML is never parsed here by hand. Every `run:` value in a workflow or action is read with +# yq, which applies YAML's own folding, chomping and indentation-indicator rules, so the text +# checked is exactly the script the runner executes. Hand-rolled folding kept missing header +# spellings (>, >-, >2-, a trailing comment, an explicit indentation indicator); a parser has +# no spellings to miss. A file yq cannot parse fails the check rather than being skipped. +command -v yq >/dev/null 2>&1 || fail "yq is required to read workflow and action run: scripts" +command -v jq >/dev/null 2>&1 || fail "jq is required to read workflow and action run: scripts" + +# scan_installs: reads shell text and prints