From 5d75e914b1aa6c26e8ee59158bc48edf7d741774 Mon Sep 17 00:00:00 2001 From: Nikolai Emil Damm Date: Thu, 22 Oct 2026 09:20:55 +0200 Subject: [PATCH 1/3] fix(ci)!: retire catalogue-only .NET workflow --- .../actions/test-dotnet-fixture/action.yaml | 76 +++++++++ .github/agent-guides/catalogue.md | 5 + .../ci-selection/dotnet_workflow_test.go | 9 +- .github/scripts/ci-selection/inventory.json | 8 +- .github/scripts/ci-selection/main.go | 22 +-- .../test-catalogue-credentials-controls.sh | 2 +- .github/tests/test-ci-selection.sh | 9 +- .github/tests/test-dotnet-helper-checkout.sh | 4 +- .github/tests/test-dotnet-native-fixtures.sh | 89 +++++++++++ .github/workflows/ci.yaml | 118 ++++++-------- .github/workflows/run-dotnet-tests.yaml | 145 ------------------ README.md | 38 ----- actions/run-dotnet-tests/README.md | 36 +++-- 13 files changed, 267 insertions(+), 294 deletions(-) create mode 100644 .github/actions/test-dotnet-fixture/action.yaml create mode 100644 .github/tests/test-dotnet-native-fixtures.sh delete mode 100644 .github/workflows/run-dotnet-tests.yaml diff --git a/.github/actions/test-dotnet-fixture/action.yaml b/.github/actions/test-dotnet-fixture/action.yaml new file mode 100644 index 00000000..e489b4b3 --- /dev/null +++ b/.github/actions/test-dotnet-fixture/action.yaml @@ -0,0 +1,76 @@ +name: Test catalogue .NET fixture +description: Exercise the shared action and preserve authenticated package source state. +inputs: + working-directory: + description: Catalogue fixture directory. + required: true + github-token: + description: Explicit ephemeral token; callers must exclude fork and bot pull requests. + required: false + default: "" +runs: + using: composite + steps: + # Match an external consumer: helper projects and coverage must not pollute it. + - name: Checkout the tested action only + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: ${{ job.workflow_repository }} + ref: ${{ job.workflow_sha }} + path: .devantler-tech-actions + sparse-checkout: /actions/run-dotnet-tests/ + sparse-checkout-cone-mode: false + persist-credentials: false + + - name: Snapshot authenticated GitHub Packages source + id: github-source-before + if: ${{ inputs.github-token != '' }} + working-directory: ${{ inputs.working-directory }} + shell: bash + run: | + set -euo pipefail + source_listing="$(dotnet nuget list source --format Detailed)" + github_source_state="$( + awk ' + tolower($0) ~ /^[[:space:]]*[0-9]+\.[[:space:]]+github[[:space:]]+\[(enabled|disabled)\][[:space:]]*$/ { + status = $0 + getline + sub(/^[[:space:]]+/, "") + print status "|" $0 + exit + } + ' <<<"$source_listing" + )" + echo "state=$github_source_state" >>"$GITHUB_OUTPUT" + + - name: Test .NET solution or project + uses: ./.devantler-tech-actions/actions/run-dotnet-tests + with: + github-token: ${{ inputs.github-token }} + working-directory: ${{ inputs.working-directory }} + + - name: Verify authenticated GitHub Packages source state is preserved + if: ${{ inputs.github-token != '' }} + working-directory: ${{ inputs.working-directory }} + env: + BEFORE_STATE: ${{ steps.github-source-before.outputs.state }} + shell: bash + run: | + set -euo pipefail + source_listing="$(dotnet nuget list source --format Detailed)" + after_state="$( + awk ' + tolower($0) ~ /^[[:space:]]*[0-9]+\.[[:space:]]+github[[:space:]]+\[(enabled|disabled)\][[:space:]]*$/ { + status = $0 + getline + sub(/^[[:space:]]+/, "") + print status "|" $0 + exit + } + ' <<<"$source_listing" + )" + if [[ "$after_state" != "$BEFORE_STATE" ]]; then + echo "::error::Authenticated restore changed the caller's GitHub Packages source state" + exit 1 + fi + echo "Authenticated GitHub Packages source state was preserved" diff --git a/.github/agent-guides/catalogue.md b/.github/agent-guides/catalogue.md index 92c653d1..5467d00d 100644 --- a/.github/agent-guides/catalogue.md +++ b/.github/agent-guides/catalogue.md @@ -91,6 +91,11 @@ One component may reference another **in this repository**. GitHub resolves thes - **Reusable workflow → a sibling action:** a bare `./actions/` does **NOT** work (a reusable workflow resolves `./` against the *caller's* checkout). Use the **same-commit self-checkout pattern**: a step checks out `${{ job.workflow_repository }}` at `${{ job.workflow_sha }}` (the reusable workflow file's own repository + exact commit) into `path: .devantler-tech-actions`, and the next step calls `uses: ./.devantler-tech-actions/actions/`. This is **zero-lag by construction** (the action always runs from the same commit as the workflow calling it) and satisfies consumer `sha_pinning_required` policies (they see only the pinned `actions/checkout` plus a local `./` path). Place the self-checkout immediately before the step that needs it, and remove it (`rm -rf .devantler-tech-actions`) before any later step that commits or scans the whole workspace (see `update-agent-skills.yaml`). **Never** use a remote self-reference — a tag pin is rejected by consumer `sha_pinning_required` policies (proven live 2026-07-03: platform's `Update Agent Skills`/`TODOs` scheduled runs failed on `devantler-tech/actions/@v8.0.0`; org direction is SHA-pinning everywhere, #67), and a SHA self-pin can only ever name a *prior* release (a commit cannot embed its own SHA — an unacceptable one-release lag). - **Composite action → a sibling composite action:** no zero-lag self-reference exists (`uses:` takes no expressions; `github.action_*` is unreliable when the action is invoked via a local path). **Inline the underlying pinned step(s)** instead of referencing the sibling wrapper — see `actions/run-dotnet-tests/action.yaml`, which inlines `upload-coverage`'s single `actions/upload-code-coverage` step — and keep the inlined inputs in sync with the wrapper. +The catalogue's private `.github/actions/test-dotnet-fixture` helper is only called by this +repository's native CI jobs, not exported as a consumer interface. Its known catalogue workflow +identity supplies the same-commit sparse action checkout used to test consumer-root isolation. +Keep the credential, source-state, three-OS and selector refusal controls when changing it. + Everything is SHA-pinned by convention and gated by `zizmor` (`unpinned-uses` policy `"*": hash-pin` — no by-owner allowances) and CodeQL's `actions/unpinned-tag`. ### Releases diff --git a/.github/scripts/ci-selection/dotnet_workflow_test.go b/.github/scripts/ci-selection/dotnet_workflow_test.go index e31de505..5a0901f4 100644 --- a/.github/scripts/ci-selection/dotnet_workflow_test.go +++ b/.github/scripts/ci-selection/dotnet_workflow_test.go @@ -9,7 +9,7 @@ import ( "testing" ) -const dotnetSubject = ".github/workflows/run-dotnet-tests.yaml" +const dotnetSubject = ".github/actions/test-dotnet-fixture/action.yaml" var dotnetSubjects = []string{"test-run-dotnet-tests-gate-lockstep", "test-run-dotnet-tests-mtp", "test-run-dotnet-tests-workflow", "test-run-dotnet-tests-workflow-authenticated"} @@ -24,9 +24,6 @@ func dotnetSelectionFixture(t *testing.T) (inventory, workflow) { for _, id := range dotnetSubjects { i.Jobs[id] = []string{dotnetSubject} j := job{If: selectionGuard(id), Needs: []any{"select-ci-tests"}} - if id == "test-run-dotnet-tests-workflow" || id == "test-run-dotnet-tests-workflow-authenticated" { - j.Uses = "./" + dotnetSubject - } if id == "test-run-dotnet-tests-workflow-authenticated" { j.If = "${{ trusted_same_repository_pull_request }}" j.Needs = nil @@ -151,7 +148,7 @@ func TestDotnetWorkflowGitStatusRetainsFullFallback(t *testing.T) { t.Fatal(err) } } - git("add", "--", ".github/workflows") + git("add", "--", ".github/actions/test-dotnet-fixture") git("commit", "-qm", "workflow change") paths, err := changedPaths(dir, base, git("rev-parse", "HEAD")) if err != nil { @@ -202,7 +199,7 @@ if [[ "$1" == diff && "$2" == --raw ]]; then "$CI_REAL_GIT" "$@" exit 17 fi - printf ':100644 100644 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb M\0.github/workflows/run-dotnet-tests.yaml' + printf ':100644 100644 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb M\0.github/actions/test-dotnet-fixture/action.yaml' exit 0 fi exec "$CI_REAL_GIT" "$@" diff --git a/.github/scripts/ci-selection/inventory.json b/.github/scripts/ci-selection/inventory.json index c260d1e9..6626e857 100644 --- a/.github/scripts/ci-selection/inventory.json +++ b/.github/scripts/ci-selection/inventory.json @@ -80,8 +80,8 @@ "test-run-dotnet-tests-preserves-source-mapping": ["actions/run-dotnet-tests/", "actions/upload-coverage/"], "test-run-dotnet-tests-missing-working-directory": ["actions/run-dotnet-tests/", "actions/upload-coverage/"], "test-run-dotnet-tests-blocks": ["actions/run-dotnet-tests/", "actions/upload-coverage/"], - "test-run-dotnet-tests-mtp": ["actions/run-dotnet-tests/", "actions/upload-coverage/", ".github/workflows/run-dotnet-tests.yaml"], - "test-run-dotnet-tests-gate-lockstep": ["actions/run-dotnet-tests/", "actions/upload-coverage/", ".github/workflows/run-dotnet-tests.yaml"], + "test-run-dotnet-tests-mtp": ["actions/run-dotnet-tests/", "actions/upload-coverage/", ".github/actions/test-dotnet-fixture/action.yaml"], + "test-run-dotnet-tests-gate-lockstep": ["actions/run-dotnet-tests/", "actions/upload-coverage/", ".github/actions/test-dotnet-fixture/action.yaml"], "test-run-dotnet-tests-coverage-inline-lockstep": ["actions/run-dotnet-tests/", "actions/upload-coverage/"], "test-setup-agent-skills-inline": ["actions/setup-agent-skills/"], "test-setup-agent-skills-pinned": ["actions/setup-agent-skills/"], @@ -115,8 +115,8 @@ "test-validate-go-project-apply-signed-fixes": ["actions/upload-coverage/"], "test-validate-go-project-test-default-branch": ["actions/upload-coverage/"], "test-validate-go-project-maintenance-default-branch": ["actions/upload-coverage/"], - "test-run-dotnet-tests-workflow": ["actions/run-dotnet-tests/", "actions/upload-coverage/", ".github/workflows/run-dotnet-tests.yaml"], - "test-run-dotnet-tests-workflow-authenticated": ["actions/run-dotnet-tests/", "actions/upload-coverage/", ".github/workflows/run-dotnet-tests.yaml"], + "test-run-dotnet-tests-workflow": ["actions/run-dotnet-tests/", "actions/upload-coverage/", ".github/actions/test-dotnet-fixture/action.yaml"], + "test-run-dotnet-tests-workflow-authenticated": ["actions/run-dotnet-tests/", "actions/upload-coverage/", ".github/actions/test-dotnet-fixture/action.yaml"], "test-enable-auto-merge": [], "test-enable-auto-merge-actor-trust": [], "test-enable-auto-merge-queued": [], diff --git a/.github/scripts/ci-selection/main.go b/.github/scripts/ci-selection/main.go index 34ee123f..d0561038 100644 --- a/.github/scripts/ci-selection/main.go +++ b/.github/scripts/ci-selection/main.go @@ -18,18 +18,18 @@ import ( const scheduling = "github.event_name != 'merge_group' && !startsWith(github.event.head_commit.message, 'chore(main): release ')" -const dotnetWorkflow = ".github/workflows/run-dotnet-tests.yaml" +const dotnetFixtureAction = ".github/actions/test-dotnet-fixture/action.yaml" const goCacheObserver = "test-go-cache-native-observer" const goCacheFixture = "test-validate-go-postgresql-fixture" var ordinarySelectiveFiles = map[string]bool{ - dotnetWorkflow: true, + dotnetFixtureAction: true, } -// These include inline hosted and credential controls, not just reusable callers. +// Native fixture jobs and their hosted/credential controls share this local action. var dotnetOwners = map[string]bool{ - "test-run-dotnet-tests-gate-lockstep": false, - "test-run-dotnet-tests-mtp": false, + "test-run-dotnet-tests-gate-lockstep": true, + "test-run-dotnet-tests-mtp": true, "test-run-dotnet-tests-workflow": true, "test-run-dotnet-tests-workflow-authenticated": true, } @@ -83,7 +83,7 @@ func validateInventory(i inventory, w workflow) ([]string, error) { seen[id] = true for _, p := range paths { if !regexp.MustCompile(`^actions/[a-z0-9-]+/$`).MatchString(p) && - (p != dotnetWorkflow || !hasDotnetOwner(id)) { + (p != dotnetFixtureAction || !hasDotnetOwner(id)) { return nil, fmt.Errorf("invalid selective owner %q", p) } } @@ -158,19 +158,19 @@ func hasDotnetOwner(id string) bool { func validateDotnetOwners(i inventory, w workflow) error { for id, j := range w.Jobs { - if j.Uses == "./"+dotnetWorkflow && !dotnetOwners[id] { - return fmt.Errorf("unreviewed .NET workflow caller %s", id) + if j.Uses == "./"+dotnetFixtureAction || j.Uses == "./.github/workflows/run-dotnet-tests.yaml" { + return fmt.Errorf("retired or non-native .NET workflow caller %s", id) } } - for id, caller := range dotnetOwners { + for id := range dotnetOwners { j, ok := w.Jobs[id] count := 0 for _, p := range i.Jobs[id] { - if p == dotnetWorkflow { + if p == dotnetFixtureAction { count++ } } - if !ok || count != 1 || caller && j.Uses != "./"+dotnetWorkflow || !caller && j.Uses != "" { + if !ok || count != 1 || j.Uses != "" { return fmt.Errorf("incomplete or unbound .NET workflow owner %s", id) } } diff --git a/.github/tests/test-catalogue-credentials-controls.sh b/.github/tests/test-catalogue-credentials-controls.sh index 4ab13c39..694d2f17 100644 --- a/.github/tests/test-catalogue-credentials-controls.sh +++ b/.github/tests/test-catalogue-credentials-controls.sh @@ -57,7 +57,7 @@ restored skills writer .github/workflows/update-agent-skills.yaml del(.jobs["upd removed skipped dependency .github/workflows/update-agent-skills.yaml del(.jobs["open-skill-pull-requests"].needs) 1 write authority always restores skipped writer .github/workflows/update-agent-skills.yaml .jobs["open-skill-pull-requests"].if="${{ always() }}" 1 write authority restored auto merge .github/workflows/enable-auto-merge.yaml .jobs["disarm-untrusted-update"].if="${{ always() }}" 1 write authority -restored test package writer .github/workflows/run-dotnet-tests.yaml .jobs.test.permissions.packages="write" 1 write authority +restored test package writer .github/workflows/ci.yaml .jobs["test-run-dotnet-tests-workflow"].permissions.packages="write" 1 write authority restored application writer .github/workflows/publish-app.yaml del(.jobs.publish.if) 1 write authority restored manifest writer .github/workflows/publish-manifests.yaml del(.jobs["publish-manifests"].if) 1 write authority unknown condition on new writer .github/workflows/ci.yaml .jobs.new={"if":"${{ vars.MAYBE }}",permissions:{issues:"write"},steps:[{run:"echo fixture"}]} 1 write authority diff --git a/.github/tests/test-ci-selection.sh b/.github/tests/test-ci-selection.sh index 8f9bf166..b885c9d3 100644 --- a/.github/tests/test-ci-selection.sh +++ b/.github/tests/test-ci-selection.sh @@ -142,12 +142,13 @@ git init -q "$dotnet" git -C "$dotnet" config user.name 'CI fixture' git -C "$dotnet" config user.email 'fixture@example.invalid' git -C "$dotnet" config commit.gpgsign false -printf 'base\n' > "$dotnet/.github/workflows/run-dotnet-tests.yaml" -git -C "$dotnet" add -- .github/workflows/run-dotnet-tests.yaml +mkdir -p "$dotnet/.github/actions/test-dotnet-fixture" +printf 'base\n' > "$dotnet/.github/actions/test-dotnet-fixture/action.yaml" +git -C "$dotnet" add -- .github/actions/test-dotnet-fixture/action.yaml git -C "$dotnet" commit -qm 'test: dotnet baseline' base="$(git -C "$dotnet" rev-parse HEAD)" -printf 'changed\n' > "$dotnet/.github/workflows/run-dotnet-tests.yaml" -git -C "$dotnet" add -- .github/workflows/run-dotnet-tests.yaml +printf 'changed\n' > "$dotnet/.github/actions/test-dotnet-fixture/action.yaml" +git -C "$dotnet" add -- .github/actions/test-dotnet-fixture/action.yaml git -C "$dotnet" commit -qm 'test: dotnet change' head="$(git -C "$dotnet" rev-parse HEAD)" : > "$work/output" diff --git a/.github/tests/test-dotnet-helper-checkout.sh b/.github/tests/test-dotnet-helper-checkout.sh index a06f8105..4524bdc2 100644 --- a/.github/tests/test-dotnet-helper-checkout.sh +++ b/.github/tests/test-dotnet-helper-checkout.sh @@ -3,14 +3,14 @@ # test command from a consumer root. A full helper checkout must reproduce #258. set -euo pipefail root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" -workflow="${1:-$root/.github/workflows/run-dotnet-tests.yaml}" +workflow="${1:-$root/.github/actions/test-dotnet-fixture/action.yaml}" mode="${2:-offline}" work="$(mktemp -d)" trap 'rm -rf "$work"' EXIT fail() { echo "FAIL: $*" >&2; exit 1; } [[ "$mode" == offline || "$mode" == hosted ]] || fail 'unknown test mode' -checkout="$(yq -o=json '.jobs.test.steps[] | select(.with.path == ".devantler-tech-actions") | .with' "$workflow")" +checkout="$(yq -o=json '.runs.steps[] | select(.with.path == ".devantler-tech-actions") | .with' "$workflow")" [[ -n "$checkout" ]] || fail 'helper checkout is missing' patterns="$(jq -r '."sparse-checkout" // empty' <<<"$checkout")" cone="$(jq -r 'if has("sparse-checkout-cone-mode") then .["sparse-checkout-cone-mode"] else true end' <<<"$checkout")" diff --git a/.github/tests/test-dotnet-native-fixtures.sh b/.github/tests/test-dotnet-native-fixtures.sh new file mode 100644 index 00000000..b2562f5f --- /dev/null +++ b/.github/tests/test-dotnet-native-fixtures.sh @@ -0,0 +1,89 @@ +#!/usr/bin/env bash +# Bind the retired wrapper's complete fixture coverage to native catalogue jobs. +set -euo pipefail +root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +[[ ! -e "$root/.github/workflows/run-dotnet-tests.yaml" ]] || { + echo 'FAIL: singleton .NET workflow is still exported' >&2; exit 1; +} +ci="$root/.github/workflows/ci.yaml" +action="$root/.github/actions/test-dotnet-fixture/action.yaml" +work=$(mktemp -d) +trap 'rm -rf "$work"' EXIT +# Read parsed workflow data, never evaluate candidate expressions as shell commands. +check_ci() { + jq -e ' + def compact: gsub("[[:space:]]";""); + .jobs as $jobs | + ["test-run-dotnet-tests-workflow", "test-run-dotnet-tests-workflow-authenticated"] | + all(. as $id | $jobs[$id] | + .uses == null and .["runs-on"] == "${{ matrix.os }}" and + .["continue-on-error"]==null and + .strategy.matrix.os == ["ubuntu-latest","windows-latest","macos-latest"] and + .permissions == {contents:"read",packages:"read","code-quality":"write"} and + ([.steps[] | select((.uses // "") | startswith("actions/checkout@"))] | length==1 and all(.with["persist-credentials"]==false)) and + ([.steps[] | select(.uses=="./.github/actions/test-dotnet-fixture")] | length==1 and all(.if==null and .["continue-on-error"]==null and .with["working-directory"]==".github/fixtures/run-dotnet-tests" and + (.with["github-token"] | compact) == (if $id=="test-run-dotnet-tests-workflow" then + "${{ github.event_name != '\''pull_request'\'' && secrets.GITHUB_TOKEN || '\'''\'' }}" + else + "${{ (github.event_name != '\''pull_request'\'' || (github.event.pull_request.head.repo.full_name == github.repository && !endsWith(github.event.pull_request.user.login, '\''[bot]'\''))) && secrets.GITHUB_TOKEN || '\'''\'' }}" + end | compact))) + ) + ' "$1" >/dev/null +} +# Source-state checks must surround the actual action and use the saved snapshot. +check_action() { + jq -e ' + .runs.using=="composite" and + (.runs.steps|length)==4 and + .runs.steps[0].with.repository=="${{ job.workflow_repository }}" and + .runs.steps[0].with.ref=="${{ job.workflow_sha }}" and + .runs.steps[0].with.path==".devantler-tech-actions" and + .runs.steps[0].with["sparse-checkout"]=="/actions/run-dotnet-tests/" and + .runs.steps[0].with["sparse-checkout-cone-mode"]==false and + .runs.steps[0].with["persist-credentials"]==false and + .runs.steps[1].id=="github-source-before" and + .runs.steps[1].if=="${{ inputs.github-token != '\'''\'' }}" and + .runs.steps[2].uses=="./.devantler-tech-actions/actions/run-dotnet-tests" and + .runs.steps[2].if==null and .runs.steps[2].with["github-token"]=="${{ inputs.github-token }}" and + .runs.steps[3].if=="${{ inputs.github-token != '\'''\'' }}" and + .runs.steps[3].env.BEFORE_STATE=="${{ steps.github-source-before.outputs.state }}" and + (.runs.steps[3].run|contains("if [[ \"$after_state\" != \"$BEFORE_STATE\" ]]; then")) and + all(.runs.steps[]; .["continue-on-error"]==null) + ' "$1" >/dev/null +} +yq -o=json . "$ci" > "$work/ci.json" +yq -o=json . "$action" > "$work/action.json" +check_ci "$work/ci.json" || { echo 'FAIL: native OS, token or fixture binding changed' >&2; exit 1; } +check_action "$work/action.json" || { echo 'FAIL: source-state comparison or real action binding changed' >&2; exit 1; } +controls=0 +for id in test-run-dotnet-tests-workflow test-run-dotnet-tests-workflow-authenticated; do + for mutation in \ + '.strategy.matrix.os=["ubuntu-latest"]' \ + '.permissions.packages="write"' \ + '.["continue-on-error"]=true' \ + '.steps[1].with["persist-credentials"]=true' \ + ".steps[-1].with[\"github-token\"]=\"\${{ secrets.GITHUB_TOKEN }}\"" \ + '.steps[-1].if="false"' \ + '.steps[-1]["continue-on-error"]=true' \ + '.steps[-1].uses="./another-action"'; do + jq --arg id "$id" ".jobs[\$id] |= ($mutation)" "$work/ci.json" > "$work/broken.json" + if check_ci "$work/broken.json"; then echo "FAIL: accepted native fixture mutation $id" >&2; exit 1; fi + controls=$((controls+1)) + done +done +for mutation in \ + '.runs.steps[0].with.ref="main"' \ + 'del(.runs.steps[0].with["sparse-checkout"])' \ + '.runs.steps[0].with["persist-credentials"]=true' \ + '.runs.steps[1].if="false"' \ + '.runs.steps[2].if="false"' \ + '.runs.steps[2].uses="./another-action"' \ + ".runs.steps[2].with[\"github-token\"]=\"\${{ github.token }}\"" \ + '.runs.steps[3].run="echo unchanged"' \ + '.runs.steps[3].env.BEFORE_STATE=""' \ + '.runs.steps[3]["continue-on-error"]=true'; do + jq "$mutation" "$work/action.json" > "$work/broken.json" + if check_action "$work/broken.json"; then echo 'FAIL: accepted source-state or token regression' >&2; exit 1; fi + controls=$((controls+1)) +done +echo "PASS: both native .NET fixture jobs and $controls refusal controls" diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 5702860e..9b53cbef 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -1256,7 +1256,7 @@ jobs: - name: 🧪 Exercise consumer-root testing with the workflow helper checkout if: runner.os == 'Linux' shell: bash - run: bash .github/tests/test-dotnet-helper-checkout.sh .github/workflows/run-dotnet-tests.yaml hosted + run: bash .github/tests/test-dotnet-helper-checkout.sh .github/actions/test-dotnet-fixture/action.yaml hosted test-run-dotnet-tests-preserves-source-mapping: needs: [select-ci-tests] @@ -3099,62 +3099,9 @@ jobs: fi echo "Credential-free tests cannot reach the token-bearing coverage upload ✅" - - name: 🔒 Assert the authenticated opt-in rejects fork and bot pull requests + - name: 🔒 Assert native fixture OS, credential and source-state boundaries shell: bash - env: - WORKFLOW: .github/workflows/run-dotnet-tests.yaml - run: | - set -euo pipefail - token_expr="$(yq -r '.jobs.test.steps[] | select(.name == "🧪 Test .NET solution or project") | .with.github-token' "$WORKFLOW")" - for required_guard in \ - "inputs.enable-github-packages" \ - "github.event.pull_request.head.repo.full_name" \ - "github.repository" \ - "github.event.pull_request.user.login" \ - "[bot]"; do - if [[ "$token_expr" != *"$required_guard"* ]]; then - echo "::error::$WORKFLOW authenticated pull-request path is missing the '$required_guard' trust guard" - exit 1 - fi - done - if [[ "$token_expr" == *"github.actor"* ]]; then - echo "::error::$WORKFLOW must trust the immutable pull-request author, not the actor who triggered this event" - exit 1 - fi - - for source_step in \ - "🔎 Snapshot authenticated GitHub Packages source" \ - "🔒 Verify authenticated GitHub Packages source state is preserved"; do - source_if="$(SOURCE_STEP="$source_step" yq -r '.jobs.test.steps[] | select(.name == strenv(SOURCE_STEP)) | .if' "$WORKFLOW")" - for required_guard in \ - "inputs.enable-github-packages" \ - "github.event.pull_request.head.repo.full_name" \ - "github.repository" \ - "github.event.pull_request.user.login" \ - "[bot]"; do - if [[ "$source_if" != *"$required_guard"* ]]; then - echo "::error::$WORKFLOW '$source_step' is missing the '$required_guard' trust guard" - exit 1 - fi - done - if [[ "$source_if" == *"github.actor"* ]]; then - echo "::error::$WORKFLOW '$source_step' must trust the pull-request author, not the event actor" - exit 1 - fi - done - - verify_run="$(yq -r '.jobs.test.steps[] | select(.name == "🔒 Verify authenticated GitHub Packages source state is preserved") | .run' "$WORKFLOW")" - verify_before_state="$(yq -r '.jobs.test.steps[] | select(.name == "🔒 Verify authenticated GitHub Packages source state is preserved") | .env.BEFORE_STATE' "$WORKFLOW")" - expected_comparison="if [[ \"\$after_state\" != \"\$BEFORE_STATE\" ]]; then" - if [[ "$verify_run" != *'after_state'* || "$verify_run" != *"$expected_comparison"* ]]; then - echo "::error::$WORKFLOW authenticated-source assertion must compare the post-action state with its snapshot" - exit 1 - fi - if [[ "$verify_before_state" != *"steps.github-source-before.outputs.state"* ]]; then - echo "::error::$WORKFLOW authenticated-source assertion must consume the pre-action snapshot" - exit 1 - fi - echo "Only an explicit same-repository non-bot pull request can receive the ephemeral restore token, and its source state is preserved ✅" + run: bash .github/tests/test-dotnet-native-fixtures.sh # run-dotnet-tests INLINES upload-coverage's single pinned step (a composite action # cannot `uses:` its own repo at the same commit), so nothing structural ties the two @@ -3967,15 +3914,28 @@ jobs: needs: [select-ci-tests] if: "${{ github.event_name != 'merge_group' && !startsWith(github.event.head_commit.message, 'chore(main): release ') && contains(fromJSON(needs.select-ci-tests.outputs.selected), 'test-run-dotnet-tests-workflow') }}" name: "[Test] Run .NET Tests" - uses: ./.github/workflows/run-dotnet-tests.yaml - # Fixture-targeted: exercise the workflow against the known-good .NET fixture - # (this repo is not itself a .NET project). - with: - working-directory: .github/fixtures/run-dotnet-tests + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] permissions: contents: read packages: read - code-quality: write # callers must grant this for the coverage upload (GitHub Code Quality) + code-quality: write + steps: + - name: Harden runner + uses: step-security/harden-runner@ccd8616d44fd3846e67624a50d5aad6d37bf2d25 # v2.22.1 + with: + egress-policy: audit + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Test credential-free pull-request fixture + uses: ./.github/actions/test-dotnet-fixture + with: + working-directory: .github/fixtures/run-dotnet-tests + github-token: ${{ github.event_name != 'pull_request' && secrets.GITHUB_TOKEN || '' }} test-run-dotnet-tests-workflow-authenticated: if: >- @@ -3987,15 +3947,37 @@ jobs: github.event.pull_request.user.login == 'devantler' }} name: "[Test] Run .NET Tests - Authenticated Packages" - uses: ./.github/workflows/run-dotnet-tests.yaml - # Exercise the opt-in state only for the maintainer-authored code from this repository. - with: - working-directory: .github/fixtures/run-dotnet-tests - enable-github-packages: true + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] permissions: contents: read packages: read - code-quality: write # callers must grant this for the coverage upload (GitHub Code Quality) + code-quality: write + steps: + - name: Harden runner + uses: step-security/harden-runner@ccd8616d44fd3846e67624a50d5aad6d37bf2d25 # v2.22.1 + with: + egress-policy: audit + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Test explicitly trusted authenticated fixture + uses: ./.github/actions/test-dotnet-fixture + with: + working-directory: .github/fixtures/run-dotnet-tests + github-token: >- + ${{ + ( + github.event_name != 'pull_request' || + ( + github.event.pull_request.head.repo.full_name == github.repository && + !endsWith(github.event.pull_request.user.login, '[bot]') + ) + ) && secrets.GITHUB_TOKEN || '' + }} test-enable-auto-merge: if: "${{ github.event_name != 'merge_group' && !startsWith(github.event.head_commit.message, 'chore(main): release ') }}" diff --git a/.github/workflows/run-dotnet-tests.yaml b/.github/workflows/run-dotnet-tests.yaml deleted file mode 100644 index d056ebe8..00000000 --- a/.github/workflows/run-dotnet-tests.yaml +++ /dev/null @@ -1,145 +0,0 @@ -name: 🧪 Run .NET Tests - -permissions: {} - -on: - workflow_call: - inputs: - enable-github-packages: - required: false - type: boolean - default: false - description: "Allow authenticated package restore and Code Quality upload for trusted same-repository pull-request code." - working-directory: - required: false - type: string - default: "" - description: "Directory containing the .NET solution/project to test. Empty = repo root (the normal consumer case); the CI self-test sets it to a fixture so this workflow can be exercised in a repo that is not itself a .NET project." - ### Required Workflow Triggers ### - pull_request: - merge_group: - ################################## - -jobs: - test: - permissions: - contents: read - packages: read - # Required for the GitHub Code Quality coverage upload. Callers of this reusable workflow - # must also grant `code-quality: write`, or the run fails at startup. - code-quality: write - strategy: - matrix: - os: [ubuntu-latest, windows-latest, macos-latest] - runs-on: ${{ matrix.os }} - # Run for consumer calls (their repo) and for the fixture-targeted CI self-test - # (working-directory set). Skip a bare standalone fire in THIS repo, which has no - # .NET project at its root. - if: ${{ inputs.working-directory != '' || github.repository != 'devantler-tech/.github' }} - steps: - - name: 🛡️ Harden runner - uses: step-security/harden-runner@ccd8616d44fd3846e67624a50d5aad6d37bf2d25 # v2.22.1 - with: - egress-policy: audit - - - name: 📑 Checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - # Self-reference: check out THIS workflow's own repo at the exact commit it is running - # from (job.workflow_repository / job.workflow_sha), then call the sibling action by - # local path. Same-commit resolution — no release lag, no tag refs; consumer SHA-pinning - # policies see only the pinned actions/checkout plus a local `./` path. - - name: 📥 Checkout devantler-tech/.github (this workflow's commit) - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - repository: ${{ job.workflow_repository }} - ref: ${{ job.workflow_sha }} - path: .devantler-tech-actions - # Consumer-root scans must never see the catalogue's projects or coverage fixtures. - sparse-checkout: /actions/run-dotnet-tests/ - sparse-checkout-cone-mode: false - persist-credentials: false - - - name: 🔎 Snapshot authenticated GitHub Packages source - id: github-source-before - if: >- - ${{ - github.event_name != 'pull_request' || - ( - inputs.enable-github-packages && - github.event.pull_request.head.repo.full_name == github.repository && - !endsWith(github.event.pull_request.user.login, '[bot]') - ) - }} - working-directory: ${{ inputs.working-directory || '.' }} - shell: bash - run: | - set -euo pipefail - source_listing="$(dotnet nuget list source --format Detailed)" - github_source_state="$( - awk ' - tolower($0) ~ /^[[:space:]]*[0-9]+\.[[:space:]]+github[[:space:]]+\[(enabled|disabled)\][[:space:]]*$/ { - status = $0 - getline - sub(/^[[:space:]]+/, "") - print status "|" $0 - exit - } - ' <<<"$source_listing" - )" - echo "state=$github_source_state" >>"$GITHUB_OUTPUT" - - - name: 🧪 Test .NET solution or project - uses: ./.devantler-tech-actions/actions/run-dotnet-tests - with: - # Pull requests are credential-free by default. A trusted workflow_call caller may - # opt in only for same-repository, non-bot code. Fork and bot pull requests cannot - # recover the token by changing the input in their workflow definition. - github-token: >- - ${{ - ( - github.event_name != 'pull_request' || - ( - inputs.enable-github-packages && - github.event.pull_request.head.repo.full_name == github.repository && - !endsWith(github.event.pull_request.user.login, '[bot]') - ) - ) && secrets.GITHUB_TOKEN || '' - }} - working-directory: ${{ inputs.working-directory || '.' }} - - - name: 🔒 Verify authenticated GitHub Packages source state is preserved - if: >- - ${{ - github.event_name != 'pull_request' || - ( - inputs.enable-github-packages && - github.event.pull_request.head.repo.full_name == github.repository && - !endsWith(github.event.pull_request.user.login, '[bot]') - ) - }} - working-directory: ${{ inputs.working-directory || '.' }} - env: - BEFORE_STATE: ${{ steps.github-source-before.outputs.state }} - shell: bash - run: | - set -euo pipefail - source_listing="$(dotnet nuget list source --format Detailed)" - after_state="$( - awk ' - tolower($0) ~ /^[[:space:]]*[0-9]+\.[[:space:]]+github[[:space:]]+\[(enabled|disabled)\][[:space:]]*$/ { - status = $0 - getline - sub(/^[[:space:]]+/, "") - print status "|" $0 - exit - } - ' <<<"$source_listing" - )" - if [[ "$after_state" != "$BEFORE_STATE" ]]; then - echo "::error::Authenticated restore changed the caller's GitHub Packages source state" - exit 1 - fi - echo "Authenticated GitHub Packages source state was preserved ✅" diff --git a/README.md b/README.md index a074d2f3..7334cc57 100644 --- a/README.md +++ b/README.md @@ -115,7 +115,6 @@ single-product pipeline. | [Lint](.github/workflows/lint.yaml) | Run MegaLinter with isolated signed-fix handling | | [Publish app](.github/workflows/publish-app.yaml) | Build, sign and publish an application image | | [Publish manifests](.github/workflows/publish-manifests.yaml) | Build, sign and publish a manifest bundle | -| [Run .NET tests](.github/workflows/run-dotnet-tests.yaml) | Compatibility wrapper while the template completes native enforcement | | [Scan TODO comments](.github/workflows/scan-for-todo-comments.yaml) | Turn actionable TODOs into tracked work | | [Scan workflow vulnerabilities](.github/workflows/scan-for-workflow-vulnerabilities.yaml) | Analyze workflow security; also an organization PR gate | | [Template sync](.github/workflows/template-sync.yaml) | Open updates from an upstream template | @@ -716,43 +715,6 @@ Explicit recovery runs on the original semantic-version tag and source revision. -### 🧪 Run .NET Tests - -
-Purpose, interface and usage - -[.github/workflows/run-dotnet-tests.yaml](.github/workflows/run-dotnet-tests.yaml) is a workflow used to test .NET solutions or projects across multiple operating systems. On authenticated runs, coverage is merged into a single Cobertura report and uploaded to **GitHub Code Quality** (native PR coverage). - -#### Usage - -```yaml -jobs: - dotnet-test: - uses: devantler-tech/.github/.github/workflows/run-dotnet-tests.yaml@ # vX.Y.Z - permissions: - contents: read - packages: read - code-quality: write # required for GitHub Code Quality coverage upload -``` - -> **Note:** The calling workflow must grant `code-quality: write` (otherwise the run fails at startup). Coverage requires the repo's **Code Quality** to be enabled (_Settings → Code quality_) and is skipped on credential-free pull-request runs. - -#### Secrets and Inputs - -| Key | Type | Default | Required | Description | -|----------------------------|-----------------|---------|----------|-----------------------------------------------------------------------------| -| `enable-github-packages` | Input (boolean) | `false` | No | Use `GITHUB_TOKEN` for private packages and Code Quality on trusted same-repository non-bot pull-request code | -| `working-directory` | Input (string) | `""` | No | Directory containing the .NET solution or project | - -Pull-request runs are credential-free by default. A trusted same-repository human-authored -pull request that needs private GitHub Packages can set `enable-github-packages: true`. -The workflow ignores that input for fork and bot pull requests, so their code cannot opt -itself back into the token-bearing path. Merge-group and direct non-pull-request runs -authenticate with the automatic `GITHUB_TOKEN` when the calling job grants `packages: read`. -The same explicit token boundary keeps the Code Quality uploader out of credential-free runs. - -
- ### 📝 Scan for TODO Comments
diff --git a/actions/run-dotnet-tests/README.md b/actions/run-dotnet-tests/README.md index 5685f695..33fc7ce2 100644 --- a/actions/run-dotnet-tests/README.md +++ b/actions/run-dotnet-tests/README.md @@ -8,7 +8,7 @@ Test .NET solutions or projects across multiple platforms with code coverage rep | Name | Description | Required | Default | |------|-------------|----------|---------| -| `github-token` | GitHub token for authenticated GHCR package restore and Code Quality upload. Omit it when testing untrusted pull-request code. | ❌ | `""` | +| `github-token` | GitHub token for authenticated GitHub Packages restore and Code Quality upload. Omit it when testing untrusted pull-request code. | ❌ | `""` | | `working-directory` | Directory to run the tests in | ❌ | `.` | ## Usage @@ -45,28 +45,34 @@ GitHub Packages. Authentication is scoped to that restore step, so subsequent te cannot read the credential from their process environment or the user NuGet configuration. Credential-free runs also skip the token-bearing GitHub Code Quality upload. -## Reusable workflow +## Repository-owned CI -The reusable workflow checks out only this action in its helper directory. Catalogue test -projects and sample coverage reports are excluded, so testing from the consumer repository's -root detects coverage support and merges reports from the consumer's own files. - -The reusable workflow is credential-free on pull requests by default. Trusted same-repository -human-authored pull requests that need private GitHub Packages can opt in explicitly: +Put the job in your repository's own CI workflow and call this action. The action runs on +the current runner; a matrix supplies multiple operating systems. No shared .NET test workflow +is exported. This complete job keeps pull-request tests credential-free: ```yaml jobs: test: - uses: devantler-tech/.github/.github/workflows/run-dotnet-tests.yaml@ # vX.Y.Z - with: - enable-github-packages: true + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] permissions: contents: read packages: read code-quality: write + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: devantler-tech/.github/actions/run-dotnet-tests@ # vX.Y.Z + with: + github-token: ${{ github.event_name != 'pull_request' && secrets.GITHUB_TOKEN || '' }} ``` -The workflow honors `enable-github-packages` only when the pull request head is in the -same repository and the pull request author is not a bot. Fork and bot pull requests remain -credential-free even if they set the input. Merge-group and direct non-pull-request runs -retain authenticated restore and Code Quality upload without the opt-in. +Omit `github-token` entirely when authenticated restore and coverage upload are unnecessary. +Any explicit authenticated pull-request path belongs to the calling repository: require its +own opt-in, a same-repository head and a non-bot pull-request author. Do not trust `github.actor` +or let fork and bot code opt itself into credentials. The action does not decide whether its +caller is trusted. From f4150b7d4c0e7c3ee825428598d509f4f7c32994 Mon Sep 17 00:00:00 2001 From: Nikolai Emil Damm Date: Thu, 22 Oct 2026 09:41:52 +0200 Subject: [PATCH 2/3] fix(ci): keep native fixture contracts outside smoke selection --- .github/tests/test-ci-catalogue-scope.sh | 4 ++++ .github/workflows/ci.yaml | 8 ++++---- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/.github/tests/test-ci-catalogue-scope.sh b/.github/tests/test-ci-catalogue-scope.sh index 9d50a07a..64d90691 100644 --- a/.github/tests/test-ci-catalogue-scope.sh +++ b/.github/tests/test-ci-catalogue-scope.sh @@ -160,6 +160,10 @@ yq -o=json -I=0 '.jobs."test-validate-retired-repo-links".needs' "${ci}" | jq -e '.catalogue_optional | index("test-validate-retired-repo-links") != null' "${repo_root}/.github/scripts/ci-selection/inventory.json" >/dev/null || fail 'deployment-only selection retains unrelated smoke job' [[ "$(yq -r '.jobs."validate-manifests".needs' "${ci}")" == null ]] || fail 'manifest checks became conditional on scope' [[ "$(yq -r '.jobs."lint-ci-coverage-parity".needs' "${ci}")" == null ]] || fail 'security contract checks became conditional on scope' +yq -o=json '.jobs."lint-ci-coverage-parity".steps' "${ci}" | + jq -e '[.[] | select(.run=="bash .github/tests/test-dotnet-native-fixtures.sh")] | + length==1 and all(.if==null and .["continue-on-error"]==null)' >/dev/null || + fail 'native .NET fixture boundaries must run outside optional smoke selection' [[ "$(yq -r '.jobs."test-dependency-review-comments".needs' "${ci}")" == null ]] || fail 'credential-boundary replay became conditional on scope' [[ "$(yq -r '.jobs."test-dependency-review-comments".if' "${ci}")" != *'outputs.catalogue'* ]] || fail 'credential-boundary replay ignores its unconditional contract' yq -o=json -I=0 '.jobs' "${ci}" | jq -e --slurpfile inventory "${repo_root}/.github/scripts/ci-selection/inventory.json" ' diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 9b53cbef..c1b5e878 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -3099,10 +3099,6 @@ jobs: fi echo "Credential-free tests cannot reach the token-bearing coverage upload ✅" - - name: 🔒 Assert native fixture OS, credential and source-state boundaries - shell: bash - run: bash .github/tests/test-dotnet-native-fixtures.sh - # run-dotnet-tests INLINES upload-coverage's single pinned step (a composite action # cannot `uses:` its own repo at the same commit), so nothing structural ties the two # definitions together. This guard treats actions/upload-coverage/action.yaml as the source of @@ -3398,6 +3394,10 @@ jobs: shell: bash run: bash .github/tests/test-ci-catalogue-scope.sh + - name: 🔒 Assert native fixture OS, credential and source-state boundaries + shell: bash + run: bash .github/tests/test-dotnet-native-fixtures.sh + - name: Set up the declared validator Go toolchain uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: From 714021bb947af3ffb5b656bab592795f9a93e10b Mon Sep 17 00:00:00 2001 From: Nikolai Emil Damm Date: Thu, 22 Oct 2026 16:10:22 +0200 Subject: [PATCH 3/3] fix(ci): bind native fixtures to immutable action sources --- .../actions/test-dotnet-fixture/action.yaml | 26 +++--- .github/agent-guides/catalogue.md | 31 ++++++- .github/tests/catalogue-credentials/main.go | 10 ++- .../self_repository_test.go | 80 +++++++++++++++++++ .github/tests/test-dotnet-helper-checkout.sh | 38 +++++---- .github/tests/test-dotnet-native-fixtures.sh | 30 ++++--- .github/workflows/ci.yaml | 4 +- AGENTS.md | 2 +- 8 files changed, 177 insertions(+), 44 deletions(-) create mode 100644 .github/tests/catalogue-credentials/self_repository_test.go diff --git a/.github/actions/test-dotnet-fixture/action.yaml b/.github/actions/test-dotnet-fixture/action.yaml index e489b4b3..cabb22b3 100644 --- a/.github/actions/test-dotnet-fixture/action.yaml +++ b/.github/actions/test-dotnet-fixture/action.yaml @@ -11,16 +11,20 @@ inputs: runs: using: composite steps: - # Match an external consumer: helper projects and coverage must not pollute it. - - name: Checkout the tested action only - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - repository: ${{ job.workflow_repository }} - ref: ${{ job.workflow_sha }} - path: .devantler-tech-actions - sparse-checkout: /actions/run-dotnet-tests/ - sparse-checkout-cone-mode: false - persist-credentials: false + - name: Keep the native action source outside the consumer workspace + shell: bash + env: + FIXTURE_ACTION_SOURCE: ${{ github.action_path }} + FIXTURE_WORKSPACE: ${{ github.workspace }} + run: | + set -euo pipefail + fixture_source_dir="$(cd "$FIXTURE_ACTION_SOURCE" && pwd -P)" + fixture_workspace_dir="$(cd "$FIXTURE_WORKSPACE" && pwd -P)" + [[ "$fixture_source_dir" != "$fixture_workspace_dir" && + "$fixture_source_dir" != "$fixture_workspace_dir/"* ]] || { + echo '::error::Native action source entered the consumer workspace' + exit 1 + } - name: Snapshot authenticated GitHub Packages source id: github-source-before @@ -44,7 +48,7 @@ runs: echo "state=$github_source_state" >>"$GITHUB_OUTPUT" - name: Test .NET solution or project - uses: ./.devantler-tech-actions/actions/run-dotnet-tests + uses: $/actions/run-dotnet-tests with: github-token: ${{ inputs.github-token }} working-directory: ${{ inputs.working-directory }} diff --git a/.github/agent-guides/catalogue.md b/.github/agent-guides/catalogue.md index 0fa4240b..cd453159 100644 --- a/.github/agent-guides/catalogue.md +++ b/.github/agent-guides/catalogue.md @@ -73,7 +73,17 @@ read-only projection. Preserve every declared input/secret table and the fail-cl One component may reference another **in this repository**. GitHub resolves these differently — follow the matching rule: -- **CI job → local action** (e.g. a `test-` job): `uses: ./actions/` after `actions/checkout`. Works (the repository is checked out). +- **Native same-repository references:** `uses: $/actions/` (or + `$/.github/workflows/.yaml`) binds to the repository and commit of the + **file containing that reference**, independently of the consumer's workspace. + A nested composite therefore keeps its own pinned source, not the caller workflow's + potentially newer revision. No `@ref` suffix or source checkout is needed. GitHub.com + runners must be at least 2.336.0; see the [GitHub announcement](https://github.blog/changelog/2026-07-30-reference-same-repository-actions-with-self-repository-syntax/). + The private .NET fixture helper uses this form in native CI and nested composition. + Preserve the credential graph's defining-file revision and refusal controls. +- **CI job → workspace action:** existing `uses: ./actions/` references require + `actions/checkout`. They resolve from workspace bytes, not the defining file; the + broader native-reference migration and its cross-repository proof are tracked in #271. - **Reusable workflow → co-located reusable workflow:** normally `uses: ./.github/workflows/.yaml` (same repository, same commit). An organization-required workflow is injected into a consumer repository, where that path resolves against the consumer @@ -88,12 +98,25 @@ One component may reference another **in this repository**. GitHub resolves thes is recorded in the test. `test-required-workflow-no-local-calls-blocks.sh` proves each unsafe reference is rejected (#484). - **Composite action → shared script:** invoke via `${{ github.action_path }}/../../.scripts/…` (resolves at the calling ref, no pin). See `setup-agent-skills` / `update-agent-skills`. -- **Reusable workflow → a sibling action:** a bare `./actions/` does **NOT** work (a reusable workflow resolves `./` against the *caller's* checkout). Use the **same-commit self-checkout pattern**: a step checks out `${{ job.workflow_repository }}` at `${{ job.workflow_sha }}` (the reusable workflow file's own repository + exact commit) into `path: .devantler-tech-actions`, and the next step calls `uses: ./.devantler-tech-actions/actions/`. This is **zero-lag by construction** (the action always runs from the same commit as the workflow calling it) and satisfies consumer `sha_pinning_required` policies (they see only the pinned `actions/checkout` plus a local `./` path). Place the self-checkout immediately before the step that needs it, and remove it (`rm -rf .devantler-tech-actions`) before any later step that commits or scans the whole workspace (see `update-agent-skills.yaml`). **Never** use a remote self-reference — a tag pin is rejected by consumer `sha_pinning_required` policies (proven live 2026-07-03: platform's `Update Agent Skills`/`TODOs` scheduled runs failed on `devantler-tech/actions/@v8.0.0`; org direction is SHA-pinning everywhere, #67), and a SHA self-pin can only ever name a *prior* release (a commit cannot embed its own SHA — an unacceptable one-release lag). -- **Composite action → a sibling composite action:** no zero-lag self-reference exists (`uses:` takes no expressions; `github.action_*` is unreliable when the action is invoked via a local path). **Inline the underlying pinned step(s)** instead of referencing the sibling wrapper — see `actions/run-dotnet-tests/action.yaml`, which inlines `upload-coverage`'s single `actions/upload-code-coverage` step — and keep the inlined inputs in sync with the wrapper. +- **Reusable workflow → a sibling action:** native `$/actions/` uses the workflow's + own source. Existing same-commit self-checkouts bind `${{ job.workflow_repository }}` + and `${{ job.workflow_sha }}` into `.devantler-tech-actions` before a workspace-relative + call. Keep their exact identity and cleanup until their #271 migration is proven. + A tag self-reference is not SHA-pinned; a remote SHA self-pin necessarily names an + earlier commit, so neither substitutes for exact same-commit composition. +- **Composite action → a sibling composite action:** native `$/actions/` keeps + the defining composite's repository and exact commit, without workspace lookup. + Existing intentionally inlined external steps retain their reviewed inputs. +- **Actions from another reviewed source:** `./` is still needed when the job deliberately + checks out a *different* product's immutable reviewed base (for example product admission). + Do not replace those paths with `$/`: that would select the runner workflow's own + repository instead of the product validator. Check the source identity, not just spelling. The catalogue's private `.github/actions/test-dotnet-fixture` helper is only called by this repository's native CI jobs, not exported as a consumer interface. Its known catalogue workflow -identity supplies the same-commit sparse action checkout used to test consumer-root isolation. +identity supplies native same-commit actions outside the consumer workspace. Its +isolation test deliberately places complete catalogue source, including unrelated projects +and coverage, in a separate action cache and proves an in-workspace copy still fails. Keep the credential, source-state, three-OS and selector refusal controls when changing it. Everything is SHA-pinned by convention and gated by `zizmor` (`unpinned-uses` policy `"*": hash-pin` — no by-owner allowances) and CodeQL's `actions/unpinned-tag`. diff --git a/.github/tests/catalogue-credentials/main.go b/.github/tests/catalogue-credentials/main.go index 72e36ca7..77977bc5 100644 --- a/.github/tests/catalogue-credentials/main.go +++ b/.github/tests/catalogue-credentials/main.go @@ -402,8 +402,16 @@ func (a *auditor) immutableEntry(path, revision string) (sourceEntry, error) { return entry, nil } -// reference binds first-party sources to local same-commit bytes or an exact owned SHA. +// reference binds sources to the defining file's commit or an exact owned SHA. func reference(ref string, revision string, action bool) (string, string, bool, error) { + if strings.HasPrefix(ref, "$/") { + // Native self references are independent of workspace checkout state. + // GitHub forbids an explicit ref suffix; never treat it as an opaque action. + if strings.Contains(ref, "@") { + return "", "", false, fmt.Errorf("UNKNOWN self reference with an explicit ref") + } + ref = "./" + strings.TrimPrefix(ref, "$/") + } if strings.HasPrefix(ref, "./") { p, e := safePath(ref) if action { diff --git a/.github/tests/catalogue-credentials/self_repository_test.go b/.github/tests/catalogue-credentials/self_repository_test.go new file mode 100644 index 00000000..491f13fc --- /dev/null +++ b/.github/tests/catalogue-credentials/self_repository_test.go @@ -0,0 +1,80 @@ +package main + +import ( + "strings" + "testing" +) + +// TestSelfRepositoryReferencesBindTheDefiningFile ignores unrelated workspace checkouts. +func TestSelfRepositoryReferencesBindTheDefiningFile(t *testing.T) { + revision := "1111111111111111111111111111111111111111" + c := context{"__workflow_revision": "2222222222222222222222222222222222222222"} + bindings := map[string]binding{".": {revision: "3333333333333333333333333333333333333333", valid: true}} + p, r, owned, err := actionReference("$/actions/fixture", revision, object{}, c, bindings) + if err != nil || !owned || p != "actions/fixture/action.yaml" || r != revision { + t.Fatalf("self action lost defining-file identity: %q %q %v %v", p, r, owned, err) + } + p, r, owned, err = reference("$/.github/workflows/callee.yaml", revision, false) + if err != nil || !owned || p != ".github/workflows/callee.yaml" || r != revision { + t.Fatalf("self workflow lost defining-file identity: %q %q %v %v", p, r, owned, err) + } +} + +// TestSelfRepositoryReferencesRejectAliases forbids mutable suffixes and path escapes. +func TestSelfRepositoryReferencesRejectAliases(t *testing.T) { + for _, ref := range []string{"$/", "$/../outside", "$//absolute", "$/actions/fixture@main", "$/actions/fixture#fragment", "$/actions/%2e%2e/fixture"} { + if _, _, _, err := reference(ref, "", true); err == nil { + t.Fatalf("accepted ambiguous self reference %q", ref) + } + } +} + +// TestSelfRepositoryNestedAuthority traverses both actions even without a workspace checkout. +func TestSelfRepositoryNestedAuthority(t *testing.T) { + for _, unsafe := range []bool{false, true} { + leaf := "runs: {using: composite, steps: [{run: echo fixture, shell: bash}]}\n" + if unsafe { + leaf = "runs: {using: composite, steps: [{run: echo fixture, shell: bash, env: {EXTRA: '${{ secrets.TOKEN }}'}}]}\n" + } + files := map[string]string{ + ".github/workflows/ci.yaml": "permissions: {}\njobs:\n read:\n permissions: {contents: read}\n steps:\n - uses: $/actions/wrapper\n", + "actions/wrapper/action.yaml": "runs: {using: composite, steps: [{uses: '$/actions/inner'}]}\n", + "actions/inner/action.yaml": leaf, + } + err := Audit(fixture(t, files)) + if !unsafe && err != nil { + t.Fatalf("safe native self graph rejected: %v", err) + } + if unsafe && (err == nil || !strings.Contains(err.Error(), "external secret")) { + t.Fatalf("native self leaf was not audited: %v", err) + } + } +} + +// TestImmutableCompositeSelfSource cannot substitute the caller workflow's newer leaf. +func TestImmutableCompositeSelfSource(t *testing.T) { + const revision = "1111111111111111111111111111111111111111" + root := fixture(t, map[string]string{ + ".github/workflows/ci.yaml": "permissions: {}\njobs:\n read:\n permissions: {contents: read}\n steps:\n - uses: devantler-tech/.github/actions/wrapper@" + revision + "\n", + "actions/inner/action.yaml": "runs: {using: composite, steps: [{run: echo safe, shell: bash}]}\n", + }) + wrapper, err := decode([]byte("runs: {using: composite, steps: [{uses: '$/actions/inner'}]}\n")) + if err != nil { + t.Fatal(err) + } + inner, err := decode([]byte("runs: {using: composite, steps: [{run: echo fixture, shell: bash, env: {EXTRA: '${{ secrets.TOKEN }}'}}]}\n")) + if err != nil { + t.Fatal(err) + } + a := auditor{root: root, active: map[string]bool{}, sources: map[string]object{ + revision + ":actions/wrapper/action.yaml": wrapper, + revision + ":actions/inner/action.yaml": inner, + }, trees: map[string]map[string]sourceEntry{revision: { + "actions/wrapper/action.yaml": {Mode: "100644", Type: "blob", SHA: revision}, + "actions/inner/action.yaml": {Mode: "100644", Type: "blob", SHA: revision}, + }}} + c := context{"github.repository": "devantler-tech/.github", "github.event_name": "pull_request", "github.token": builtinToken{}, "secrets.github_token": builtinToken{}} + if err := a.workflow(".github/workflows/ci.yaml", "", c, nil, nil, nil); err == nil || !strings.Contains(err.Error(), "external secret") { + t.Fatalf("immutable composite self leaf escaped audit: %v", err) + } +} diff --git a/.github/tests/test-dotnet-helper-checkout.sh b/.github/tests/test-dotnet-helper-checkout.sh index 4524bdc2..e5459a6f 100644 --- a/.github/tests/test-dotnet-helper-checkout.sh +++ b/.github/tests/test-dotnet-helper-checkout.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash -# Exercise the workflow's real checkout settings with Git, then run the action's -# test command from a consumer root. A full helper checkout must reproduce #258. +# Exercise native action-cache isolation, then run the action's real test command +# from a consumer root. An in-workspace helper must still reproduce #258. set -euo pipefail root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" workflow="${1:-$root/.github/actions/test-dotnet-fixture/action.yaml}" @@ -10,10 +10,9 @@ trap 'rm -rf "$work"' EXIT fail() { echo "FAIL: $*" >&2; exit 1; } [[ "$mode" == offline || "$mode" == hosted ]] || fail 'unknown test mode' -checkout="$(yq -o=json '.runs.steps[] | select(.with.path == ".devantler-tech-actions") | .with' "$workflow")" -[[ -n "$checkout" ]] || fail 'helper checkout is missing' -patterns="$(jq -r '."sparse-checkout" // empty' <<<"$checkout")" -cone="$(jq -r 'if has("sparse-checkout-cone-mode") then .["sparse-checkout-cone-mode"] else true end' <<<"$checkout")" +yq -o=json . "$workflow" | jq -e ' + [.runs.steps[] | select(.uses != null) | .uses] == ["$/actions/run-dotnet-tests"] +' >/dev/null || fail 'native same-commit action reference is missing' yq -er '.runs.steps[] | select(.name == "🧪 Test") | .run' "$root/actions/run-dotnet-tests/action.yaml" >"$work/test.sh" yq -er '.runs.steps[] | select(.name == "🔀 Merge coverage into a single Cobertura report") | .run' \ "$root/actions/run-dotnet-tests/action.yaml" >"$work/merge.sh" @@ -31,14 +30,15 @@ for fixture in run-dotnet-tests-mtp-no-coverage run-dotnet-tests; do consumer="$work/$fixture" mkdir -p "$consumer" cp -R "$root/.github/fixtures/$fixture/." "$consumer/" - helper="$consumer/.devantler-tech-actions" + helper="$work/native-action-cache/$fixture" + mkdir -p "$(dirname "$helper")" git clone --quiet --no-checkout --shared "$root" "$helper" - if [[ -n "$patterns" ]]; then - if [[ "$cone" == true ]]; then sparse_mode=--cone; else sparse_mode=--no-cone; fi - printf '%s\n' "$patterns" | git -C "$helper" sparse-checkout set "$sparse_mode" --stdin - fi git -C "$helper" checkout --quiet --detach HEAD [[ -f "$helper/actions/run-dotnet-tests/action.yaml" ]] || fail 'needed action was omitted' + # Full source deliberately includes both kinds of scan input. The passing + # consumer proves placement, not sparse omission, prevents contamination. + [[ -f "$helper/.github/fixtures/run-dotnet-tests/src/Example/Example.csproj" ]] || fail 'project contamination control is absent' + [[ -f "$helper/.github/fixtures/upload-coverage/coverage.cobertura.xml" ]] || fail 'coverage contamination control is absent' if [[ "$fixture" == run-dotnet-tests-mtp-no-coverage ]]; then if [[ "$mode" == hosted ]]; then @@ -58,7 +58,17 @@ for fixture in run-dotnet-tests-mtp-no-coverage run-dotnet-tests; do echo 'PASS: real consumer coverage merges without catalogue coverage' fi - contamination="$(find "$helper" -type f \( -name '*.csproj' -o -name '*.props' -o -name '*.targets' -o -name '*.cobertura.xml' \) -print)" - [[ -z "$contamination" ]] || fail 'helper checkout contains unrelated projects or coverage reports' - echo "PASS: $fixture helper contains no scan inputs" + echo "PASS: $fixture action-cache scan inputs remain outside the consumer" done + +# Reproduce the original root-consumer failure using the identical action +# command, even in hosted mode. This guards against a vacuous isolation pass. +polluted="$work/polluted-consumer" +mkdir -p "$polluted" +cp -R "$root/.github/fixtures/run-dotnet-tests-mtp-no-coverage/." "$polluted/" +git clone --quiet --shared "$root" "$polluted/.devantler-tech-actions" +if (cd "$polluted" && PATH="$work/bin:$PATH" bash -eo pipefail "$work/test.sh") >"$work/contamination.log" 2>&1; then + fail 'in-workspace catalogue source did not reproduce unsupported coverage' +fi +grep -q 'unexpected coverage arguments:' "$work/contamination.log" || fail 'negative control failed for an unrelated reason' +echo 'PASS: in-workspace helper reproduces the root-consumer regression' diff --git a/.github/tests/test-dotnet-native-fixtures.sh b/.github/tests/test-dotnet-native-fixtures.sh index b2562f5f..93f24668 100644 --- a/.github/tests/test-dotnet-native-fixtures.sh +++ b/.github/tests/test-dotnet-native-fixtures.sh @@ -21,7 +21,7 @@ check_ci() { .strategy.matrix.os == ["ubuntu-latest","windows-latest","macos-latest"] and .permissions == {contents:"read",packages:"read","code-quality":"write"} and ([.steps[] | select((.uses // "") | startswith("actions/checkout@"))] | length==1 and all(.with["persist-credentials"]==false)) and - ([.steps[] | select(.uses=="./.github/actions/test-dotnet-fixture")] | length==1 and all(.if==null and .["continue-on-error"]==null and .with["working-directory"]==".github/fixtures/run-dotnet-tests" and + ([.steps[] | select(.uses=="$/.github/actions/test-dotnet-fixture")] | length==1 and all(.if==null and .["continue-on-error"]==null and .with["working-directory"]==".github/fixtures/run-dotnet-tests" and (.with["github-token"] | compact) == (if $id=="test-run-dotnet-tests-workflow" then "${{ github.event_name != '\''pull_request'\'' && secrets.GITHUB_TOKEN || '\'''\'' }}" else @@ -35,15 +35,12 @@ check_action() { jq -e ' .runs.using=="composite" and (.runs.steps|length)==4 and - .runs.steps[0].with.repository=="${{ job.workflow_repository }}" and - .runs.steps[0].with.ref=="${{ job.workflow_sha }}" and - .runs.steps[0].with.path==".devantler-tech-actions" and - .runs.steps[0].with["sparse-checkout"]=="/actions/run-dotnet-tests/" and - .runs.steps[0].with["sparse-checkout-cone-mode"]==false and - .runs.steps[0].with["persist-credentials"]==false and + .runs.steps[0].uses==null and .runs.steps[0].if==null and + .runs.steps[0].env=={FIXTURE_ACTION_SOURCE:"${{ github.action_path }}",FIXTURE_WORKSPACE:"${{ github.workspace }}"} and + (.runs.steps[0].run|contains("fixture_source_dir")) and .runs.steps[1].id=="github-source-before" and .runs.steps[1].if=="${{ inputs.github-token != '\'''\'' }}" and - .runs.steps[2].uses=="./.devantler-tech-actions/actions/run-dotnet-tests" and + .runs.steps[2].uses=="$/actions/run-dotnet-tests" and .runs.steps[2].if==null and .runs.steps[2].with["github-token"]=="${{ inputs.github-token }}" and .runs.steps[3].if=="${{ inputs.github-token != '\'''\'' }}" and .runs.steps[3].env.BEFORE_STATE=="${{ steps.github-source-before.outputs.state }}" and @@ -72,9 +69,9 @@ for id in test-run-dotnet-tests-workflow test-run-dotnet-tests-workflow-authenti done done for mutation in \ - '.runs.steps[0].with.ref="main"' \ - 'del(.runs.steps[0].with["sparse-checkout"])' \ - '.runs.steps[0].with["persist-credentials"]=true' \ + '.runs.steps[0].run="echo skipped"' \ + '.runs.steps[0].if="false"' \ + '.runs.steps[0].env.FIXTURE_ACTION_SOURCE="candidate"' \ '.runs.steps[1].if="false"' \ '.runs.steps[2].if="false"' \ '.runs.steps[2].uses="./another-action"' \ @@ -86,4 +83,15 @@ for mutation in \ if check_action "$work/broken.json"; then echo 'FAIL: accepted source-state or token regression' >&2; exit 1; fi controls=$((controls+1)) done +# Execute the actual isolation guard with a separate native action cache and +# reject the former workspace-relative source placement, not just its spelling. +mkdir -p "$work/action-cache" "$work/consumer/actions/inside" +yq -r '.runs.steps[0].run' "$action" > "$work/isolation.sh" +FIXTURE_ACTION_SOURCE="$work/action-cache" FIXTURE_WORKSPACE="$work/consumer" \ + bash "$work/isolation.sh" +if FIXTURE_ACTION_SOURCE="$work/consumer/actions/inside" FIXTURE_WORKSPACE="$work/consumer" \ + bash "$work/isolation.sh" > "$work/isolation-failure" 2>&1; then + echo 'FAIL: native action source may pollute the consumer workspace' >&2; exit 1; +fi +grep -q 'Native action source entered the consumer workspace' "$work/isolation-failure" echo "PASS: both native .NET fixture jobs and $controls refusal controls" diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index c1b5e878..ed0246b4 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -3932,7 +3932,7 @@ jobs: with: persist-credentials: false - name: Test credential-free pull-request fixture - uses: ./.github/actions/test-dotnet-fixture + uses: $/.github/actions/test-dotnet-fixture with: working-directory: .github/fixtures/run-dotnet-tests github-token: ${{ github.event_name != 'pull_request' && secrets.GITHUB_TOKEN || '' }} @@ -3965,7 +3965,7 @@ jobs: with: persist-credentials: false - name: Test explicitly trusted authenticated fixture - uses: ./.github/actions/test-dotnet-fixture + uses: $/.github/actions/test-dotnet-fixture with: working-directory: .github/fixtures/run-dotnet-tests github-token: >- diff --git a/AGENTS.md b/AGENTS.md index 86b0316b..fd987046 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -325,7 +325,7 @@ The auto-merge workflow's own allow-lists (`TRUSTED_BOT_AUTHORS`, `TRUSTED_TRIGG **Blast radius first:** a change to a composite action / reusable workflow affects **every consumer repo**. Prefer additive, backward-compatible changes; call out any breaking input/output change prominently in the PR body and treat it as a deliberate decision (keep an alias where feasible). -**Validate before any PR:** `actionlint` on every changed workflow/action (else a thorough YAML parse); confirm `uses:` refs resolve and are pinned/aligned; check `inputs`/`outputs`/`shell:` are declared; for reusable workflows keep `on: workflow_call` inputs/secrets backward-compatible. No app build here — YAML correctness + pinning is the gate. Keep all remote actions pinned to full-length commit SHAs; first-party self-references resolve by local path after the same-commit self-checkout (never a remote ref — see the self-reference rule above). Never weaken a security control to pass a check. +**Validate before any PR:** `actionlint` on every changed workflow/action (else a thorough YAML parse); confirm `uses:` refs resolve and are pinned/aligned; check `inputs`/`outputs`/`shell:` are declared; for reusable workflows keep `on: workflow_call` inputs/secrets backward-compatible. No app build here — YAML correctness + pinning is the gate. Keep all remote actions pinned to full-length commit SHAs. Native `$/` self-references bind to the defining file's repository and exact commit; existing workspace-relative calls retain their same-commit checkout until the wider #271 migration is proven. Deliberately checked-out product-base validators must retain that different source identity. Follow the catalogue guide's self-reference rules; never weaken a security control to pass a check. Read the [tested invariants](.github/agent-guides/invariants.md) before changing their implementation or self-tests.