Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 80 additions & 0 deletions .github/actions/test-dotnet-fixture/action.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
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:
- 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
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: $/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"
34 changes: 31 additions & 3 deletions .github/agent-guides/catalogue.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,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-<action>` job): `uses: ./actions/<action>` after `actions/checkout`. Works (the repository is checked out).
- **Native same-repository references:** `uses: $/actions/<action>` (or
`$/.github/workflows/<workflow>.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/<action>` 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/<x>.yaml` (same repository, same commit). An organization-required
workflow is injected into a consumer repository, where that path resolves against the consumer
Expand All @@ -102,8 +112,26 @@ 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/<action>` 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/<action>`. 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/<x>@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/<action>` 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/<action>` 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 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`.

Expand Down
9 changes: 3 additions & 6 deletions .github/scripts/ci-selection/dotnet_workflow_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"}

Expand All @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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" "$@"
Expand Down
8 changes: 4 additions & 4 deletions .github/scripts/ci-selection/inventory.json
Original file line number Diff line number Diff line change
Expand Up @@ -82,8 +82,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/"],
Expand Down Expand Up @@ -117,8 +117,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": [],
Expand Down
22 changes: 11 additions & 11 deletions .github/scripts/ci-selection/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
Expand Down Expand Up @@ -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)
}
}
Expand Down Expand Up @@ -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)
}
}
Expand Down
10 changes: 9 additions & 1 deletion .github/tests/catalogue-credentials/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
80 changes: 80 additions & 0 deletions .github/tests/catalogue-credentials/self_repository_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading
Loading