From 99edc43b378ce7336892830a2a38bb50ae534a39 Mon Sep 17 00:00:00 2001 From: Marko Lahma Date: Thu, 23 Jul 2026 18:27:51 +0300 Subject: [PATCH 1/3] Bump the generated cache step to actions/cache@v6 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The generator's cache pin sat at v4 while #533 moved checkout, setup-dotnet, and upload-artifact to their current majors — cache was never in that PR's scope. v6.1.0 is current; v4 is two majors behind. - v5.0.0 moved to the node24 runtime and requires a self-hosted runner >= 2.327.1 - v6.0.0 updated dependencies and migrated to ESM The `path:` and `key:` inputs the generator emits are unchanged across both, so the emitted step needs no other edits. Covers the generated workflows, the hand-written publish-packages-* ones, the 14 Verify snapshots with a cache step, and the docs sample. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01Vn8JQZRwdoovPuctD21Wjx --- .github/workflows/build-cross-platform.yml | 4 ++-- .github/workflows/build.yml | 2 +- .github/workflows/publish-packages-preview.yml | 2 +- .github/workflows/publish-packages-release.yml | 2 +- docs/05-cicd/github-actions.md | 2 +- .../GitHubActions/Configuration/GitHubActionsCacheStep.cs | 2 +- ...eckout-ref_attribute=GitHubActionsAttribute.verified.txt | 2 +- ...-with-only_attribute=GitHubActionsAttribute.verified.txt | 2 +- ...ith-sparse_attribute=GitHubActionsAttribute.verified.txt | 2 +- ...ckout-with_attribute=GitHubActionsAttribute.verified.txt | 2 +- ...ermissions_attribute=GitHubActionsAttribute.verified.txt | 2 +- ...ault-shell_attribute=GitHubActionsAttribute.verified.txt | 2 +- ...d-triggers_attribute=GitHubActionsAttribute.verified.txt | 6 +++--- ...ut-scoping_attribute=GitHubActionsAttribute.verified.txt | 2 +- ...plus-typed_attribute=GitHubActionsAttribute.verified.txt | 2 +- ...ped-inputs_attribute=GitHubActionsAttribute.verified.txt | 2 +- ...ermissions_attribute=GitHubActionsAttribute.verified.txt | 2 +- ...=env-block_attribute=GitHubActionsAttribute.verified.txt | 2 +- ...-on-labels_attribute=GitHubActionsAttribute.verified.txt | 2 +- ...e-triggers_attribute=GitHubActionsAttribute.verified.txt | 6 +++--- ...nSpecs.Rich_injection_renders_expected_yaml.verified.txt | 2 +- 21 files changed, 26 insertions(+), 26 deletions(-) diff --git a/.github/workflows/build-cross-platform.yml b/.github/workflows/build-cross-platform.yml index 3cb90435c..07ba6d9cb 100644 --- a/.github/workflows/build-cross-platform.yml +++ b/.github/workflows/build-cross-platform.yml @@ -42,7 +42,7 @@ jobs: with: fetch-depth: 0 - name: 'Cache: .fallout/temp, ~/.nuget/packages' - uses: actions/cache@v4 + uses: actions/cache@v6 with: path: | .fallout/temp @@ -64,7 +64,7 @@ jobs: with: fetch-depth: 0 - name: 'Cache: .fallout/temp, ~/.nuget/packages' - uses: actions/cache@v4 + uses: actions/cache@v6 with: path: | .fallout/temp diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e0b220c8c..e7e8f150b 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -42,7 +42,7 @@ jobs: repository: ${{ github.event.pull_request.head.repo.full_name || github.repository }} ref: ${{ github.head_ref }} - name: 'Cache: .fallout/temp, ~/.nuget/packages' - uses: actions/cache@v4 + uses: actions/cache@v6 with: path: | .fallout/temp diff --git a/.github/workflows/publish-packages-preview.yml b/.github/workflows/publish-packages-preview.yml index 8fd746611..87aed3103 100644 --- a/.github/workflows/publish-packages-preview.yml +++ b/.github/workflows/publish-packages-preview.yml @@ -53,7 +53,7 @@ jobs: with: fetch-depth: 0 # Nerdbank.GitVersioning needs full history - name: 'Cache: .fallout/temp, ~/.nuget/packages' - uses: actions/cache@v4 + uses: actions/cache@v6 with: path: | .fallout/temp diff --git a/.github/workflows/publish-packages-release.yml b/.github/workflows/publish-packages-release.yml index 1203303ac..44575a0e3 100644 --- a/.github/workflows/publish-packages-release.yml +++ b/.github/workflows/publish-packages-release.yml @@ -102,7 +102,7 @@ jobs: ref: ${{ inputs.tag || github.ref }} fetch-depth: 0 # Nerdbank.GitVersioning needs full history - name: 'Cache: .fallout/temp, ~/.nuget/packages' - uses: actions/cache@v4 + uses: actions/cache@v6 with: path: | .fallout/temp diff --git a/docs/05-cicd/github-actions.md b/docs/05-cicd/github-actions.md index cf6ce4913..09ac18ec4 100644 --- a/docs/05-cicd/github-actions.md +++ b/docs/05-cicd/github-actions.md @@ -208,7 +208,7 @@ By default, the generated workflow file will include a [caching step](https://gi ```yaml title=".github/workflows/continuous.yml" - name: 'Cache: .fallout/temp, ~/.nuget/packages' - uses: actions/cache@v4 + uses: actions/cache@v6 with: path: | .fallout/temp diff --git a/src/Fallout.Common/CI/GitHubActions/Configuration/GitHubActionsCacheStep.cs b/src/Fallout.Common/CI/GitHubActions/Configuration/GitHubActionsCacheStep.cs index 2fc5b3517..ad0754ff5 100644 --- a/src/Fallout.Common/CI/GitHubActions/Configuration/GitHubActionsCacheStep.cs +++ b/src/Fallout.Common/CI/GitHubActions/Configuration/GitHubActionsCacheStep.cs @@ -17,7 +17,7 @@ public override void Write(CustomFileWriter writer) writer.WriteLine("- name: " + $"Cache: {IncludePatterns.JoinCommaSpace()}".SingleQuoteYaml()); using (writer.Indent()) { - writer.WriteLine("uses: actions/cache@v4"); + writer.WriteLine("uses: actions/cache@v6"); writer.WriteLine("with:"); using (writer.Indent()) { diff --git a/tests/Fallout.Common.Specs/CI/ConfigurationGenerationSpecs.Test_testName=checkout-ref_attribute=GitHubActionsAttribute.verified.txt b/tests/Fallout.Common.Specs/CI/ConfigurationGenerationSpecs.Test_testName=checkout-ref_attribute=GitHubActionsAttribute.verified.txt index 1b0865604..585cf3532 100644 --- a/tests/Fallout.Common.Specs/CI/ConfigurationGenerationSpecs.Test_testName=checkout-ref_attribute=GitHubActionsAttribute.verified.txt +++ b/tests/Fallout.Common.Specs/CI/ConfigurationGenerationSpecs.Test_testName=checkout-ref_attribute=GitHubActionsAttribute.verified.txt @@ -31,7 +31,7 @@ jobs: repository: ${{ github.event.pull_request.head.repo.full_name || github.repository }} ref: ${{ github.head_ref }} - name: 'Cache: .fallout/temp, ~/.nuget/packages' - uses: actions/cache@v4 + uses: actions/cache@v6 with: path: | .fallout/temp diff --git a/tests/Fallout.Common.Specs/CI/ConfigurationGenerationSpecs.Test_testName=checkout-with-only_attribute=GitHubActionsAttribute.verified.txt b/tests/Fallout.Common.Specs/CI/ConfigurationGenerationSpecs.Test_testName=checkout-with-only_attribute=GitHubActionsAttribute.verified.txt index 8025557af..4715773cd 100644 --- a/tests/Fallout.Common.Specs/CI/ConfigurationGenerationSpecs.Test_testName=checkout-with-only_attribute=GitHubActionsAttribute.verified.txt +++ b/tests/Fallout.Common.Specs/CI/ConfigurationGenerationSpecs.Test_testName=checkout-with-only_attribute=GitHubActionsAttribute.verified.txt @@ -27,7 +27,7 @@ jobs: with: path: src - name: 'Cache: .fallout/temp, ~/.nuget/packages' - uses: actions/cache@v4 + uses: actions/cache@v6 with: path: | .fallout/temp diff --git a/tests/Fallout.Common.Specs/CI/ConfigurationGenerationSpecs.Test_testName=checkout-with-sparse_attribute=GitHubActionsAttribute.verified.txt b/tests/Fallout.Common.Specs/CI/ConfigurationGenerationSpecs.Test_testName=checkout-with-sparse_attribute=GitHubActionsAttribute.verified.txt index 28d6a37c1..4c4edc7fb 100644 --- a/tests/Fallout.Common.Specs/CI/ConfigurationGenerationSpecs.Test_testName=checkout-with-sparse_attribute=GitHubActionsAttribute.verified.txt +++ b/tests/Fallout.Common.Specs/CI/ConfigurationGenerationSpecs.Test_testName=checkout-with-sparse_attribute=GitHubActionsAttribute.verified.txt @@ -29,7 +29,7 @@ jobs: src build - name: 'Cache: .fallout/temp, ~/.nuget/packages' - uses: actions/cache@v4 + uses: actions/cache@v6 with: path: | .fallout/temp diff --git a/tests/Fallout.Common.Specs/CI/ConfigurationGenerationSpecs.Test_testName=checkout-with_attribute=GitHubActionsAttribute.verified.txt b/tests/Fallout.Common.Specs/CI/ConfigurationGenerationSpecs.Test_testName=checkout-with_attribute=GitHubActionsAttribute.verified.txt index 6d9fbad93..1dee29980 100644 --- a/tests/Fallout.Common.Specs/CI/ConfigurationGenerationSpecs.Test_testName=checkout-with_attribute=GitHubActionsAttribute.verified.txt +++ b/tests/Fallout.Common.Specs/CI/ConfigurationGenerationSpecs.Test_testName=checkout-with_attribute=GitHubActionsAttribute.verified.txt @@ -30,7 +30,7 @@ jobs: path: src persist-credentials: false - name: 'Cache: .fallout/temp, ~/.nuget/packages' - uses: actions/cache@v4 + uses: actions/cache@v6 with: path: | .fallout/temp diff --git a/tests/Fallout.Common.Specs/CI/ConfigurationGenerationSpecs.Test_testName=default-shell-with-permissions_attribute=GitHubActionsAttribute.verified.txt b/tests/Fallout.Common.Specs/CI/ConfigurationGenerationSpecs.Test_testName=default-shell-with-permissions_attribute=GitHubActionsAttribute.verified.txt index 9a05e007d..ae2d338bb 100644 --- a/tests/Fallout.Common.Specs/CI/ConfigurationGenerationSpecs.Test_testName=default-shell-with-permissions_attribute=GitHubActionsAttribute.verified.txt +++ b/tests/Fallout.Common.Specs/CI/ConfigurationGenerationSpecs.Test_testName=default-shell-with-permissions_attribute=GitHubActionsAttribute.verified.txt @@ -37,7 +37,7 @@ jobs: steps: - uses: actions/checkout@v7 - name: 'Cache: .fallout/temp, ~/.nuget/packages' - uses: actions/cache@v4 + uses: actions/cache@v6 with: path: | .fallout/temp diff --git a/tests/Fallout.Common.Specs/CI/ConfigurationGenerationSpecs.Test_testName=default-shell_attribute=GitHubActionsAttribute.verified.txt b/tests/Fallout.Common.Specs/CI/ConfigurationGenerationSpecs.Test_testName=default-shell_attribute=GitHubActionsAttribute.verified.txt index 5dee5aa3b..bff516e0b 100644 --- a/tests/Fallout.Common.Specs/CI/ConfigurationGenerationSpecs.Test_testName=default-shell_attribute=GitHubActionsAttribute.verified.txt +++ b/tests/Fallout.Common.Specs/CI/ConfigurationGenerationSpecs.Test_testName=default-shell_attribute=GitHubActionsAttribute.verified.txt @@ -29,7 +29,7 @@ jobs: steps: - uses: actions/checkout@v7 - name: 'Cache: .fallout/temp, ~/.nuget/packages' - uses: actions/cache@v4 + uses: actions/cache@v6 with: path: | .fallout/temp diff --git a/tests/Fallout.Common.Specs/CI/ConfigurationGenerationSpecs.Test_testName=detailed-triggers_attribute=GitHubActionsAttribute.verified.txt b/tests/Fallout.Common.Specs/CI/ConfigurationGenerationSpecs.Test_testName=detailed-triggers_attribute=GitHubActionsAttribute.verified.txt index 26ba5906d..48c5ca0ad 100644 --- a/tests/Fallout.Common.Specs/CI/ConfigurationGenerationSpecs.Test_testName=detailed-triggers_attribute=GitHubActionsAttribute.verified.txt +++ b/tests/Fallout.Common.Specs/CI/ConfigurationGenerationSpecs.Test_testName=detailed-triggers_attribute=GitHubActionsAttribute.verified.txt @@ -68,7 +68,7 @@ jobs: progress: false filter: tree:0 - name: 'Cache: .fallout/temp, ~/.nuget/packages' - uses: actions/cache@v4 + uses: actions/cache@v6 with: path: | .fallout/temp @@ -122,7 +122,7 @@ jobs: progress: false filter: tree:0 - name: 'Cache: .fallout/temp, ~/.nuget/packages' - uses: actions/cache@v4 + uses: actions/cache@v6 with: path: | .fallout/temp @@ -176,7 +176,7 @@ jobs: progress: false filter: tree:0 - name: 'Cache: .fallout/temp, ~/.nuget/packages' - uses: actions/cache@v4 + uses: actions/cache@v6 with: path: | .fallout/temp diff --git a/tests/Fallout.Common.Specs/CI/ConfigurationGenerationSpecs.Test_testName=dispatch-input-scoping_attribute=GitHubActionsAttribute.verified.txt b/tests/Fallout.Common.Specs/CI/ConfigurationGenerationSpecs.Test_testName=dispatch-input-scoping_attribute=GitHubActionsAttribute.verified.txt index 887f44c8c..e31b77161 100644 --- a/tests/Fallout.Common.Specs/CI/ConfigurationGenerationSpecs.Test_testName=dispatch-input-scoping_attribute=GitHubActionsAttribute.verified.txt +++ b/tests/Fallout.Common.Specs/CI/ConfigurationGenerationSpecs.Test_testName=dispatch-input-scoping_attribute=GitHubActionsAttribute.verified.txt @@ -30,7 +30,7 @@ jobs: steps: - uses: actions/checkout@v7 - name: 'Cache: .fallout/temp, ~/.nuget/packages' - uses: actions/cache@v4 + uses: actions/cache@v6 with: path: | .fallout/temp diff --git a/tests/Fallout.Common.Specs/CI/ConfigurationGenerationSpecs.Test_testName=dispatch-legacy-plus-typed_attribute=GitHubActionsAttribute.verified.txt b/tests/Fallout.Common.Specs/CI/ConfigurationGenerationSpecs.Test_testName=dispatch-legacy-plus-typed_attribute=GitHubActionsAttribute.verified.txt index 5d97edd17..f1562555a 100644 --- a/tests/Fallout.Common.Specs/CI/ConfigurationGenerationSpecs.Test_testName=dispatch-legacy-plus-typed_attribute=GitHubActionsAttribute.verified.txt +++ b/tests/Fallout.Common.Specs/CI/ConfigurationGenerationSpecs.Test_testName=dispatch-legacy-plus-typed_attribute=GitHubActionsAttribute.verified.txt @@ -38,7 +38,7 @@ jobs: steps: - uses: actions/checkout@v7 - name: 'Cache: .fallout/temp, ~/.nuget/packages' - uses: actions/cache@v4 + uses: actions/cache@v6 with: path: | .fallout/temp diff --git a/tests/Fallout.Common.Specs/CI/ConfigurationGenerationSpecs.Test_testName=dispatch-typed-inputs_attribute=GitHubActionsAttribute.verified.txt b/tests/Fallout.Common.Specs/CI/ConfigurationGenerationSpecs.Test_testName=dispatch-typed-inputs_attribute=GitHubActionsAttribute.verified.txt index 7729b88f6..c3b636424 100644 --- a/tests/Fallout.Common.Specs/CI/ConfigurationGenerationSpecs.Test_testName=dispatch-typed-inputs_attribute=GitHubActionsAttribute.verified.txt +++ b/tests/Fallout.Common.Specs/CI/ConfigurationGenerationSpecs.Test_testName=dispatch-typed-inputs_attribute=GitHubActionsAttribute.verified.txt @@ -53,7 +53,7 @@ jobs: steps: - uses: actions/checkout@v7 - name: 'Cache: .fallout/temp, ~/.nuget/packages' - uses: actions/cache@v4 + uses: actions/cache@v6 with: path: | .fallout/temp diff --git a/tests/Fallout.Common.Specs/CI/ConfigurationGenerationSpecs.Test_testName=env-block-with-permissions_attribute=GitHubActionsAttribute.verified.txt b/tests/Fallout.Common.Specs/CI/ConfigurationGenerationSpecs.Test_testName=env-block-with-permissions_attribute=GitHubActionsAttribute.verified.txt index 247e97930..58cf1aca6 100644 --- a/tests/Fallout.Common.Specs/CI/ConfigurationGenerationSpecs.Test_testName=env-block-with-permissions_attribute=GitHubActionsAttribute.verified.txt +++ b/tests/Fallout.Common.Specs/CI/ConfigurationGenerationSpecs.Test_testName=env-block-with-permissions_attribute=GitHubActionsAttribute.verified.txt @@ -37,7 +37,7 @@ jobs: steps: - uses: actions/checkout@v7 - name: 'Cache: .fallout/temp, ~/.nuget/packages' - uses: actions/cache@v4 + uses: actions/cache@v6 with: path: | .fallout/temp diff --git a/tests/Fallout.Common.Specs/CI/ConfigurationGenerationSpecs.Test_testName=env-block_attribute=GitHubActionsAttribute.verified.txt b/tests/Fallout.Common.Specs/CI/ConfigurationGenerationSpecs.Test_testName=env-block_attribute=GitHubActionsAttribute.verified.txt index c501a3746..b1c945f96 100644 --- a/tests/Fallout.Common.Specs/CI/ConfigurationGenerationSpecs.Test_testName=env-block_attribute=GitHubActionsAttribute.verified.txt +++ b/tests/Fallout.Common.Specs/CI/ConfigurationGenerationSpecs.Test_testName=env-block_attribute=GitHubActionsAttribute.verified.txt @@ -31,7 +31,7 @@ jobs: steps: - uses: actions/checkout@v7 - name: 'Cache: .fallout/temp, ~/.nuget/packages' - uses: actions/cache@v4 + uses: actions/cache@v6 with: path: | .fallout/temp diff --git a/tests/Fallout.Common.Specs/CI/ConfigurationGenerationSpecs.Test_testName=runs-on-labels_attribute=GitHubActionsAttribute.verified.txt b/tests/Fallout.Common.Specs/CI/ConfigurationGenerationSpecs.Test_testName=runs-on-labels_attribute=GitHubActionsAttribute.verified.txt index cc5de92ba..daf67f1a9 100644 --- a/tests/Fallout.Common.Specs/CI/ConfigurationGenerationSpecs.Test_testName=runs-on-labels_attribute=GitHubActionsAttribute.verified.txt +++ b/tests/Fallout.Common.Specs/CI/ConfigurationGenerationSpecs.Test_testName=runs-on-labels_attribute=GitHubActionsAttribute.verified.txt @@ -25,7 +25,7 @@ jobs: steps: - uses: actions/checkout@v7 - name: 'Cache: .fallout/temp, ~/.nuget/packages' - uses: actions/cache@v4 + uses: actions/cache@v6 with: path: | .fallout/temp diff --git a/tests/Fallout.Common.Specs/CI/ConfigurationGenerationSpecs.Test_testName=simple-triggers_attribute=GitHubActionsAttribute.verified.txt b/tests/Fallout.Common.Specs/CI/ConfigurationGenerationSpecs.Test_testName=simple-triggers_attribute=GitHubActionsAttribute.verified.txt index f5143372e..5e31f7edb 100644 --- a/tests/Fallout.Common.Specs/CI/ConfigurationGenerationSpecs.Test_testName=simple-triggers_attribute=GitHubActionsAttribute.verified.txt +++ b/tests/Fallout.Common.Specs/CI/ConfigurationGenerationSpecs.Test_testName=simple-triggers_attribute=GitHubActionsAttribute.verified.txt @@ -29,7 +29,7 @@ jobs: steps: - uses: actions/checkout@v7 - name: 'Cache: .fallout/temp, ~/.nuget/packages' - uses: actions/cache@v4 + uses: actions/cache@v6 with: path: | .fallout/temp @@ -67,7 +67,7 @@ jobs: steps: - uses: actions/checkout@v7 - name: 'Cache: .fallout/temp, ~/.nuget/packages' - uses: actions/cache@v4 + uses: actions/cache@v6 with: path: | .fallout/temp @@ -105,7 +105,7 @@ jobs: steps: - uses: actions/checkout@v7 - name: 'Cache: .fallout/temp, ~/.nuget/packages' - uses: actions/cache@v4 + uses: actions/cache@v6 with: path: | .fallout/temp diff --git a/tests/Fallout.Common.Specs/CI/GitHubActionsStepInjectionSpecs.Rich_injection_renders_expected_yaml.verified.txt b/tests/Fallout.Common.Specs/CI/GitHubActionsStepInjectionSpecs.Rich_injection_renders_expected_yaml.verified.txt index 5df89c7df..a78c49935 100644 --- a/tests/Fallout.Common.Specs/CI/GitHubActionsStepInjectionSpecs.Rich_injection_renders_expected_yaml.verified.txt +++ b/tests/Fallout.Common.Specs/CI/GitHubActionsStepInjectionSpecs.Rich_injection_renders_expected_yaml.verified.txt @@ -29,7 +29,7 @@ jobs: with: node-version: 20 - name: 'Cache: .fallout/temp, ~/.nuget/packages' - uses: actions/cache@v4 + uses: actions/cache@v6 with: path: | .fallout/temp From 13c20b3f317eb3a54f0ec92340563d04907bfa53 Mon Sep 17 00:00:00 2001 From: Marko Lahma Date: Thu, 23 Jul 2026 18:33:15 +0300 Subject: [PATCH 2/3] Make the generated action references configurable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The generator hard-coded the four actions it emits, so a consumer who wanted a newer major — or a SHA pin — had to subclass GitHubActionsAttribute and reimplement step emission. Two repos migrating off NUKE carried custom step classes for exactly that (#533). Each emitting step now carries a normalizing property, forwarded from four new attribute properties: CheckoutAction, CacheAction, SetupDotNetAction, UploadArtifactAction A value without a '/' is a bare ref appended to the default action name, so "v8" emits actions/checkout@v8. Anything else is a complete reference emitted verbatim — a fork, or a commit pin whose version rides along as a trailing YAML comment. Both forms take that comment, which is what makes the SHA-pinning idiom readable. Null or whitespace restores the default. GitHubActionsDefaults exposes every pinned version as a const — name, version, and the composed reference — so the values are referenceable from an attribute argument and there is one place to bump them. Resolution lives in the step setters rather than the attribute, so subclasses and hand-built steps get the same shorthand and validation. Additive: with no property set the emitted YAML is byte-identical, which the untouched Verify snapshots assert. Closes #534 Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01Vn8JQZRwdoovPuctD21Wjx --- docs/05-cicd/github-actions.md | 28 +++++ .../GitHubActionsActionReference.cs | 46 +++++++ .../GitHubActionsArtifactStep.cs | 15 ++- .../Configuration/GitHubActionsCacheStep.cs | 15 ++- .../GitHubActionsCheckoutStep.cs | 15 ++- .../Configuration/GitHubActionsRunStep.cs | 16 ++- .../GitHubActions/GitHubActionsAttribute.cs | 36 ++++++ .../CI/GitHubActions/GitHubActionsDefaults.cs | 35 ++++++ ...ribute=GitHubActionsAttribute.verified.txt | 51 ++++++++ .../CI/ConfigurationGenerationSpecs.cs | 17 +++ .../CI/GitHubActionsActionReferenceSpecs.cs | 112 ++++++++++++++++++ 11 files changed, 382 insertions(+), 4 deletions(-) create mode 100644 src/Fallout.Common/CI/GitHubActions/Configuration/GitHubActionsActionReference.cs create mode 100644 src/Fallout.Common/CI/GitHubActions/GitHubActionsDefaults.cs create mode 100644 tests/Fallout.Common.Specs/CI/ConfigurationGenerationSpecs.Test_testName=action-overrides_attribute=GitHubActionsAttribute.verified.txt create mode 100644 tests/Fallout.Common.Specs/CI/GitHubActionsActionReferenceSpecs.cs diff --git a/docs/05-cicd/github-actions.md b/docs/05-cicd/github-actions.md index 09ac18ec4..475202d1e 100644 --- a/docs/05-cicd/github-actions.md +++ b/docs/05-cicd/github-actions.md @@ -228,3 +228,31 @@ You can customize the caching step by overwriting the following properties: CacheExcludePatterns = new string[0])] class Build : FalloutBuild { /* ... */ } ``` + +### Pinning Action Versions + +The generated workflow references four marketplace actions. Each one is overridable, so a new action release never has to wait on a Fallout release: + +| Property | Action | Default | +| --- | --- | --- | +| `CheckoutAction` | [`actions/checkout`](https://github.com/actions/checkout) | `v7` | +| `CacheAction` | [`actions/cache`](https://github.com/actions/cache) | `v6` | +| `SetupDotNetAction` | [`actions/setup-dotnet`](https://github.com/actions/setup-dotnet) | `v6` | +| `UploadArtifactAction` | [`actions/upload-artifact`](https://github.com/actions/upload-artifact) | `v7` | + +A value without a `/` is a bare ref and gets appended to the default action; anything else is a complete reference and is emitted as-is: + +```csharp title="Build.cs" +[GitHubActions( + // ... + CheckoutAction = "v8", // actions/checkout@v8 + CacheAction = "0c907a75c2c80ebcb7f088228285e798b750cf8f # v4.2.4", // SHA pin, version as comment + UploadArtifactAction = "my-org/upload-artifact@v7")] // fork or drop-in replacement +class Build : FalloutBuild { /* ... */ } +``` + +Both forms take a trailing `# comment`, which is what makes the [SHA-pinning](https://docs.github.com/en/actions/security-for-github-actions/security-guides/security-hardening-for-github-actions#using-third-party-actions) idiom above readable. Every default is also exposed as a `const` on `GitHubActionsDefaults` — `GitHubActionsDefaults.CheckoutAction`, `CheckoutActionName`, and `CheckoutActionVersion`. + +:::note +`actions/cache@v5` and later run on the node24 runtime and require a self-hosted runner of at least `2.327.1`. Set `CacheAction = "v4"` if your runners are older. +::: diff --git a/src/Fallout.Common/CI/GitHubActions/Configuration/GitHubActionsActionReference.cs b/src/Fallout.Common/CI/GitHubActions/Configuration/GitHubActionsActionReference.cs new file mode 100644 index 000000000..601fc6f48 --- /dev/null +++ b/src/Fallout.Common/CI/GitHubActions/Configuration/GitHubActionsActionReference.cs @@ -0,0 +1,46 @@ +using System; +using System.Linq; +using Fallout.Common.Utilities; + +namespace Fallout.Common.CI.GitHubActions.Configuration; + +/// +/// Resolves a user-supplied action override against the version the generator pins by default. +/// +internal static class GitHubActionsActionReference +{ + /// + /// Resolves into the uses: value to emit: + /// + /// null or whitespace — falls back to . + /// contains a / — a complete reference, emitted verbatim (e.g. my-org/checkout@v9, + /// or actions/checkout@11bd7190… # v7.1.0 for a SHA pin). + /// otherwise — a bare ref appended to 's owner/repo, so + /// v8 becomes actions/checkout@v8. A leading @ is optional. A trailing + /// # comment rides along, which is what makes 11bd7190… # v7.1.0 work as shorthand. + /// + /// + public static string Resolve(string defaultAction, string value) + { + if (value.IsNullOrWhiteSpace()) + return defaultAction; + + var reference = value.Trim(); + Assert.True(!reference.Contains('\n') && !reference.Contains('\r'), + $"Action reference '{value}' must be a single line"); + + if (!reference.Contains('/')) + { + var separatorIndex = defaultAction.IndexOf('@'); + var name = separatorIndex > 0 ? defaultAction.Substring(startIndex: 0, separatorIndex) : defaultAction; + reference = $"{name}@{reference.TrimStart('@')}"; + } + + // Local (./path) and container (docker://) references carry no ref; everything else must name one, + // since GitHub rejects a bare owner/repo. + Assert.True(reference.Contains('@') || reference.StartsWith("./") || reference.StartsWith("docker://"), + $"Action reference '{value}' must name a version or commit — expected 'owner/repo@ref' or a bare ref appended to '{defaultAction}'"); + + return reference; + } +} diff --git a/src/Fallout.Common/CI/GitHubActions/Configuration/GitHubActionsArtifactStep.cs b/src/Fallout.Common/CI/GitHubActions/Configuration/GitHubActionsArtifactStep.cs index 42d9058fa..c7844135d 100644 --- a/src/Fallout.Common/CI/GitHubActions/Configuration/GitHubActionsArtifactStep.cs +++ b/src/Fallout.Common/CI/GitHubActions/Configuration/GitHubActionsArtifactStep.cs @@ -6,6 +6,19 @@ namespace Fallout.Common.CI.GitHubActions.Configuration; public class GitHubActionsArtifactStep : GitHubActionsStep { + private string uses = GitHubActionsDefaults.UploadArtifactAction; + + /// + /// The upload-artifact action to reference. Accepts a complete owner/repo@ref or a bare ref that + /// gets appended to . Defaults to + /// ; setting null or whitespace restores it. + /// + public string Uses + { + set => uses = GitHubActionsActionReference.Resolve(GitHubActionsDefaults.UploadArtifactAction, value); + get => uses; + } + public string Name { get; set; } public string Path { get; set; } public string Condition { get; set; } @@ -13,7 +26,7 @@ public class GitHubActionsArtifactStep : GitHubActionsStep public override void Write(CustomFileWriter writer) { writer.WriteLine("- name: " + $"Publish: {Name}".SingleQuoteYaml()); - writer.WriteLine(" uses: actions/upload-artifact@v7"); + writer.WriteLine($" uses: {Uses}"); using (writer.Indent()) { diff --git a/src/Fallout.Common/CI/GitHubActions/Configuration/GitHubActionsCacheStep.cs b/src/Fallout.Common/CI/GitHubActions/Configuration/GitHubActionsCacheStep.cs index ad0754ff5..7502f7a10 100644 --- a/src/Fallout.Common/CI/GitHubActions/Configuration/GitHubActionsCacheStep.cs +++ b/src/Fallout.Common/CI/GitHubActions/Configuration/GitHubActionsCacheStep.cs @@ -8,6 +8,19 @@ namespace Fallout.Common.CI.GitHubActions.Configuration; // https://github.com/actions/cache public class GitHubActionsCacheStep : GitHubActionsStep { + private string uses = GitHubActionsDefaults.CacheAction; + + /// + /// The cache action to reference. Accepts a complete owner/repo@ref or a bare ref that gets + /// appended to . Defaults to + /// ; setting null or whitespace restores it. + /// + public string Uses + { + set => uses = GitHubActionsActionReference.Resolve(GitHubActionsDefaults.CacheAction, value); + get => uses; + } + public string[] IncludePatterns { get; set; } public string[] ExcludePatterns { get; set; } public string[] KeyFiles { get; set; } @@ -17,7 +30,7 @@ public override void Write(CustomFileWriter writer) writer.WriteLine("- name: " + $"Cache: {IncludePatterns.JoinCommaSpace()}".SingleQuoteYaml()); using (writer.Indent()) { - writer.WriteLine("uses: actions/cache@v6"); + writer.WriteLine($"uses: {Uses}"); writer.WriteLine("with:"); using (writer.Indent()) { diff --git a/src/Fallout.Common/CI/GitHubActions/Configuration/GitHubActionsCheckoutStep.cs b/src/Fallout.Common/CI/GitHubActions/Configuration/GitHubActionsCheckoutStep.cs index eef71ac2d..745410f56 100644 --- a/src/Fallout.Common/CI/GitHubActions/Configuration/GitHubActionsCheckoutStep.cs +++ b/src/Fallout.Common/CI/GitHubActions/Configuration/GitHubActionsCheckoutStep.cs @@ -6,6 +6,19 @@ namespace Fallout.Common.CI.GitHubActions.Configuration; public class GitHubActionsCheckoutStep : GitHubActionsStep { + private string uses = GitHubActionsDefaults.CheckoutAction; + + /// + /// The checkout action to reference. Accepts a complete owner/repo@ref or a bare ref that gets + /// appended to . Defaults to + /// ; setting null or whitespace restores it. + /// + public string Uses + { + set => uses = GitHubActionsActionReference.Resolve(GitHubActionsDefaults.CheckoutAction, value); + get => uses; + } + public GitHubActionsSubmodules? Submodules { get; set; } public bool? Lfs { get; set; } public uint? FetchDepth { get; set; } @@ -28,7 +41,7 @@ public class GitHubActionsCheckoutStep : GitHubActionsStep public override void Write(CustomFileWriter writer) { - writer.WriteLine("- uses: actions/checkout@v7"); + writer.WriteLine($"- uses: {Uses}"); if (Submodules.HasValue || Lfs.HasValue || FetchDepth.HasValue || Progress.HasValue || !Filter.IsNullOrWhiteSpace() || !Ref.IsNullOrWhiteSpace() || CheckoutWith.Length > 0) diff --git a/src/Fallout.Common/CI/GitHubActions/Configuration/GitHubActionsRunStep.cs b/src/Fallout.Common/CI/GitHubActions/Configuration/GitHubActionsRunStep.cs index 289566258..3cd80bbdf 100644 --- a/src/Fallout.Common/CI/GitHubActions/Configuration/GitHubActionsRunStep.cs +++ b/src/Fallout.Common/CI/GitHubActions/Configuration/GitHubActionsRunStep.cs @@ -6,6 +6,20 @@ namespace Fallout.Common.CI.GitHubActions.Configuration; public class GitHubActionsRunStep : GitHubActionsStep { + private string setupDotNetAction = GitHubActionsDefaults.SetupDotNetAction; + + /// + /// The SDK-setup action to reference — this step emits the setup, the tool restore, and the build run, + /// so only the first of the three is configurable. Accepts a complete owner/repo@ref or a bare + /// ref that gets appended to . Defaults to + /// ; setting null or whitespace restores it. + /// + public string SetupDotNetAction + { + set => setupDotNetAction = GitHubActionsActionReference.Resolve(GitHubActionsDefaults.SetupDotNetAction, value); + get => setupDotNetAction; + } + public string[] InvokedTargets { get; set; } public Dictionary Imports { get; set; } @@ -14,7 +28,7 @@ public override void Write(CustomFileWriter writer) writer.WriteLine("- name: 'Setup: .NET SDK'"); using (writer.Indent()) { - writer.WriteLine("uses: actions/setup-dotnet@v6"); + writer.WriteLine($"uses: {SetupDotNetAction}"); writer.WriteLine("with:"); using (writer.Indent()) { diff --git a/src/Fallout.Common/CI/GitHubActions/GitHubActionsAttribute.cs b/src/Fallout.Common/CI/GitHubActions/GitHubActionsAttribute.cs index 10237f43a..0e4472c24 100644 --- a/src/Fallout.Common/CI/GitHubActions/GitHubActionsAttribute.cs +++ b/src/Fallout.Common/CI/GitHubActions/GitHubActionsAttribute.cs @@ -179,6 +179,38 @@ public string CheckoutRef /// public string[] CheckoutWith { get; set; } = new string[0]; + /// + /// The actions/checkout reference the generated workflow uses, so a newer action release doesn't + /// have to wait on a Fallout release. Two forms are accepted: + /// + /// a bare ref, appended to actions/checkout"v8" emits actions/checkout@v8; + /// a complete reference, emitted verbatim — "my-org/checkout@v9", or + /// "actions/checkout@11bd7190… # v7.1.0" to pin a commit with the version as a trailing comment + /// (the bare-ref form takes a # comment too). + /// + /// Defaults to . + /// + public string CheckoutAction { get; set; } = GitHubActionsDefaults.CheckoutAction; + + /// + /// The actions/cache reference the generated workflow uses; same forms as + /// . Defaults to — set + /// "v4" to stay on the last major that runs on node20, for self-hosted runners older than 2.327.1. + /// + public string CacheAction { get; set; } = GitHubActionsDefaults.CacheAction; + + /// + /// The actions/setup-dotnet reference the generated workflow uses; same forms as + /// . Defaults to . + /// + public string SetupDotNetAction { get; set; } = GitHubActionsDefaults.SetupDotNetAction; + + /// + /// The actions/upload-artifact reference the generated workflow uses; same forms as + /// . Defaults to . + /// + public string UploadArtifactAction { get; set; } = GitHubActionsDefaults.UploadArtifactAction; + public override CustomFileWriter CreateWriter(StreamWriter streamWriter) { return new CustomFileWriter(streamWriter, indentationFactor: 2, commentPrefix: "#"); @@ -247,6 +279,7 @@ private GitHubActionsStep[] GetSteps(IReadOnlyCollection relev { var checkout = new GitHubActionsCheckoutStep { + Uses = CheckoutAction, Submodules = submodules, Lfs = lfs, FetchDepth = fetchDepth, @@ -259,6 +292,7 @@ private GitHubActionsStep[] GetSteps(IReadOnlyCollection relev var cache = CacheKeyFiles.Any() ? new GitHubActionsCacheStep { + Uses = CacheAction, IncludePatterns = CacheIncludePatterns, ExcludePatterns = CacheExcludePatterns, KeyFiles = CacheKeyFiles @@ -267,6 +301,7 @@ private GitHubActionsStep[] GetSteps(IReadOnlyCollection relev var run = new GitHubActionsRunStep { + SetupDotNetAction = SetupDotNetAction, InvokedTargets = InvokedTargets, Imports = GetImports().ToDictionary(x => x.Key, x => x.Value) }; @@ -284,6 +319,7 @@ private GitHubActionsStep[] GetSteps(IReadOnlyCollection relev foreach (var artifact in artifactPaths) artifacts.Add(new GitHubActionsArtifactStep { + Uses = UploadArtifactAction, Name = artifact.ToString().TrimStart(artifact.Parent.ToString()).TrimStart('/', '\\'), Path = Build.RootDirectory.GetUnixRelativePathTo(artifact), Condition = PublishCondition diff --git a/src/Fallout.Common/CI/GitHubActions/GitHubActionsDefaults.cs b/src/Fallout.Common/CI/GitHubActions/GitHubActionsDefaults.cs new file mode 100644 index 000000000..dd5a39bf7 --- /dev/null +++ b/src/Fallout.Common/CI/GitHubActions/GitHubActionsDefaults.cs @@ -0,0 +1,35 @@ +using System; +using System.Linq; + +namespace Fallout.Common.CI.GitHubActions; + +/// +/// The marketplace actions the workflow generator emits, and the versions it pins them to. Every entry is a +/// const, so it can be referenced from an attribute argument — e.g. to pin one action while leaving +/// the rest on their defaults. +/// +/// Each action exposes three constants: the *Name (owner/repo), the *Version (the ref +/// after the @), and the composed *Action that the generator actually emits. Override any of +/// them per workflow via , +/// , , +/// and — so a newer action release never has to +/// wait on a Fallout release. +/// +public static class GitHubActionsDefaults +{ + public const string CheckoutActionName = "actions/checkout"; + public const string CheckoutActionVersion = "v7"; + public const string CheckoutAction = CheckoutActionName + "@" + CheckoutActionVersion; + + public const string CacheActionName = "actions/cache"; + public const string CacheActionVersion = "v6"; + public const string CacheAction = CacheActionName + "@" + CacheActionVersion; + + public const string SetupDotNetActionName = "actions/setup-dotnet"; + public const string SetupDotNetActionVersion = "v6"; + public const string SetupDotNetAction = SetupDotNetActionName + "@" + SetupDotNetActionVersion; + + public const string UploadArtifactActionName = "actions/upload-artifact"; + public const string UploadArtifactActionVersion = "v7"; + public const string UploadArtifactAction = UploadArtifactActionName + "@" + UploadArtifactActionVersion; +} diff --git a/tests/Fallout.Common.Specs/CI/ConfigurationGenerationSpecs.Test_testName=action-overrides_attribute=GitHubActionsAttribute.verified.txt b/tests/Fallout.Common.Specs/CI/ConfigurationGenerationSpecs.Test_testName=action-overrides_attribute=GitHubActionsAttribute.verified.txt new file mode 100644 index 000000000..0fa80f327 --- /dev/null +++ b/tests/Fallout.Common.Specs/CI/ConfigurationGenerationSpecs.Test_testName=action-overrides_attribute=GitHubActionsAttribute.verified.txt @@ -0,0 +1,51 @@ +# ------------------------------------------------------------------------------ +# +# +# This code was generated. +# +# - To turn off auto-generation set: +# +# [TestGitHubActions (AutoGenerate = false)] +# +# - To trigger manual generation invoke: +# +# fallout --generate-configuration GitHubActions_test --host GitHubActions +# +# +# ------------------------------------------------------------------------------ + +name: test + +on: [push] + +jobs: + ubuntu-latest: + name: ubuntu-latest + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v8 + - name: 'Cache: .fallout/temp, ~/.nuget/packages' + uses: actions/cache@0c907a75c2c80ebcb7f088228285e798b750cf8f # v4.2.4 + with: + path: | + .fallout/temp + ~/.nuget/packages + key: ${{ runner.os }}-${{ hashFiles('**/global.json', '**/*.csproj', '**/Directory.Packages.props') }} + - name: 'Setup: .NET SDK' + uses: actions/setup-dotnet@v7 + with: + global-json-file: global.json + - name: 'Restore: dotnet tools' + run: dotnet tool restore + - name: 'Run: Pack' + run: dotnet fallout Pack + - name: 'Publish: src' + uses: my-org/upload-artifact@v7 + with: + name: src + path: src + - name: 'Publish: packages' + uses: my-org/upload-artifact@v7 + with: + name: packages + path: output/packages diff --git a/tests/Fallout.Common.Specs/CI/ConfigurationGenerationSpecs.cs b/tests/Fallout.Common.Specs/CI/ConfigurationGenerationSpecs.cs index 9ace104ac..8ae420de7 100644 --- a/tests/Fallout.Common.Specs/CI/ConfigurationGenerationSpecs.cs +++ b/tests/Fallout.Common.Specs/CI/ConfigurationGenerationSpecs.cs @@ -357,6 +357,23 @@ public class TestBuild : FalloutBuild } ); + // Every emitted action is overridable, in each accepted form: a bare ref appended to the default + // action name, a complete reference, a SHA pin carrying its version as a trailing comment, and a + // fork. Publish is invoked so the run produces artifacts and the upload step actually renders. + yield return + ( + "action-overrides", + new TestGitHubActionsAttribute(GitHubActionsImage.UbuntuLatest) + { + On = new[] { GitHubActionsTrigger.Push }, + InvokedTargets = new[] { nameof(Pack) }, + CheckoutAction = "v8", + SetupDotNetAction = "actions/setup-dotnet@v7", + CacheAction = "0c907a75c2c80ebcb7f088228285e798b750cf8f # v4.2.4", + UploadArtifactAction = "my-org/upload-artifact@v7" + } + ); + yield return ( null, diff --git a/tests/Fallout.Common.Specs/CI/GitHubActionsActionReferenceSpecs.cs b/tests/Fallout.Common.Specs/CI/GitHubActionsActionReferenceSpecs.cs new file mode 100644 index 000000000..2553c46fa --- /dev/null +++ b/tests/Fallout.Common.Specs/CI/GitHubActionsActionReferenceSpecs.cs @@ -0,0 +1,112 @@ +using System; +using Fallout.Common.CI.GitHubActions; +using Fallout.Common.CI.GitHubActions.Configuration; +using FluentAssertions; +using Xunit; + +namespace Fallout.Common.Specs.CI; + +public class GitHubActionsActionReferenceSpecs +{ + private const string Sha = "11bd7190b47010a048f0e0e5c8ea9e6b2e0b5d3a"; + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void Unset_value_falls_back_to_the_default(string value) + { + Resolve(value).Should().Be(GitHubActionsDefaults.CheckoutAction); + } + + [Theory] + [InlineData("v8")] + [InlineData("@v8")] + [InlineData(" v8 ")] + public void Bare_ref_is_appended_to_the_default_action_name(string value) + { + Resolve(value).Should().Be("actions/checkout@v8"); + } + + [Fact] + public void Bare_sha_with_a_trailing_comment_keeps_the_comment() + { + Resolve($"{Sha} # v7.1.0").Should().Be($"actions/checkout@{Sha} # v7.1.0"); + } + + [Theory] + [InlineData("actions/checkout@v8")] + [InlineData("my-org/checkout@v9")] + [InlineData("my-org/actions/checkout@v9")] + public void Complete_reference_is_emitted_verbatim(string value) + { + Resolve(value).Should().Be(value); + } + + [Fact] + public void Complete_reference_with_a_sha_pin_is_emitted_verbatim() + { + var value = $"actions/checkout@{Sha} # v7.1.0"; + + Resolve(value).Should().Be(value); + } + + [Theory] + [InlineData("./.github/actions/checkout")] + [InlineData("docker://alpine:3.20")] + public void Local_and_container_references_need_no_ref(string value) + { + Resolve(value).Should().Be(value); + } + + [Fact] + public void Reference_without_a_ref_throws() + { + var act = () => Resolve("actions/checkout"); + + act.Should().Throw(); + } + + [Theory] + [InlineData("actions/checkout@v8\nmalicious: true")] + [InlineData("v8\r with:")] + public void Multi_line_reference_throws(string value) + { + var act = () => Resolve(value); + + act.Should().Throw(); + } + + [Fact] + public void Each_step_resolves_against_its_own_default() + { + new GitHubActionsCheckoutStep { Uses = "v8" }.Uses.Should().Be("actions/checkout@v8"); + new GitHubActionsCacheStep { Uses = "v4" }.Uses.Should().Be("actions/cache@v4"); + new GitHubActionsArtifactStep { Uses = "v8" }.Uses.Should().Be("actions/upload-artifact@v8"); + new GitHubActionsRunStep { SetupDotNetAction = "v7" }.SetupDotNetAction.Should().Be("actions/setup-dotnet@v7"); + } + + [Fact] + public void Steps_default_to_the_pinned_versions() + { + new GitHubActionsCheckoutStep().Uses.Should().Be(GitHubActionsDefaults.CheckoutAction); + new GitHubActionsCacheStep().Uses.Should().Be(GitHubActionsDefaults.CacheAction); + new GitHubActionsArtifactStep().Uses.Should().Be(GitHubActionsDefaults.UploadArtifactAction); + new GitHubActionsRunStep().SetupDotNetAction.Should().Be(GitHubActionsDefaults.SetupDotNetAction); + } + + [Fact] + public void Resetting_a_step_to_null_restores_its_default() + { + var step = new GitHubActionsCheckoutStep { Uses = "v8" }; + + step.Uses = null; + + step.Uses.Should().Be(GitHubActionsDefaults.CheckoutAction); + } + + private static string Resolve(string value) + { + return GitHubActionsActionReference.Resolve(GitHubActionsDefaults.CheckoutAction, value); + } +} From 7fc9a9781ddb2296aac045e630d997f67520b07b Mon Sep 17 00:00:00 2001 From: Marko Lahma Date: Thu, 23 Jul 2026 19:05:22 +0300 Subject: [PATCH 3/3] Harden action-reference resolution and shrink its public surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-up on the configurable action references. Resolution was classifying the override with a bare Contains('/') over the whole string, so a slash anywhere — in a branch ref like releases/v1, or in a URL inside the trailing comment — misread the value and then failed the ref-presence check. That check was itself a substring Contains('@'), which an '@' in a comment satisfied, letting a reference with no version through to the workflow file. And with only a CR/LF guard, a value carrying ': ' was emitted into the unquoted uses: scalar and corrupted the whole document. Resolution now splits the trailing comment off first and classifies on what remains: an '@' means a complete reference, otherwise it is a bare ref. A ref containing a '/' reads exactly like an owner/repo, so it takes a leading '@' to disambiguate — '@releases/v1' — and the ambiguous form is rejected with a message naming both fixes rather than guessed at. The reference token must carry no whitespace, which is what keeps a stray key out of the YAML. The four overrides are now resolved up-front in GetConfiguration. Cache and artifact steps are conditional, so their overrides were previously unvalidated and silently ignored whenever caching or publishing was off — the typo only surfaced later, attributed to the unrelated edit that re-enabled the step. Errors name the property and the workflow. GitHubActionsDefaults is internal, and keeps only the four composed constants. The properties take a plain string, so nothing a consumer writes needs the type; the split *Name/*Version constants had no callers outside XML docs. This also stops the transition-shim generator mirroring it into Nuke.* as an empty class — it forwards static methods, never consts — which would have given mid-migration consumers CS0117 on every member, and CS0104 on the bare name. A custom step's Uses is emitted verbatim and has no default to resolve against, so it now must name its action in full. Without that, the shorthand documented for the built-in steps silently produced an unusable 'uses: v8', and a multi-line value could inject arbitrary keys into the generated workflow. Also makes the local/container prefix checks ordinal — as a culture-sensitive comparison the guard's outcome depended on the machine's globalization mode. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01Vn8JQZRwdoovPuctD21Wjx --- docs/05-cicd/github-actions.md | 11 +- .../GitHubActionsActionReference.cs | 93 +++++++++++--- .../GitHubActionsArtifactStep.cs | 4 +- .../Configuration/GitHubActionsCacheStep.cs | 4 +- .../GitHubActionsCheckoutStep.cs | 4 +- .../Configuration/GitHubActionsRunStep.cs | 4 +- .../GitHubActions/GitHubActionsAttribute.cs | 53 +++++++- .../CI/GitHubActions/GitHubActionsDefaults.cs | 39 +++--- .../CI/ConfigurationGenerationSpecs.cs | 2 +- .../CI/GitHubActionsActionReferenceSpecs.cs | 113 +++++++++++++++++- .../GitHubActionsCustomStepValidationSpecs.cs | 11 ++ 11 files changed, 273 insertions(+), 65 deletions(-) diff --git a/docs/05-cicd/github-actions.md b/docs/05-cicd/github-actions.md index 475202d1e..2ee44a275 100644 --- a/docs/05-cicd/github-actions.md +++ b/docs/05-cicd/github-actions.md @@ -240,7 +240,7 @@ The generated workflow references four marketplace actions. Each one is overrida | `SetupDotNetAction` | [`actions/setup-dotnet`](https://github.com/actions/setup-dotnet) | `v6` | | `UploadArtifactAction` | [`actions/upload-artifact`](https://github.com/actions/upload-artifact) | `v7` | -A value without a `/` is a bare ref and gets appended to the default action; anything else is a complete reference and is emitted as-is: +A value containing an `@` is a complete reference and is emitted as-is; anything else is a bare ref and gets appended to the default action: ```csharp title="Build.cs" [GitHubActions( @@ -251,7 +251,14 @@ A value without a `/` is a bare ref and gets appended to the default action; any class Build : FalloutBuild { /* ... */ } ``` -Both forms take a trailing `# comment`, which is what makes the [SHA-pinning](https://docs.github.com/en/actions/security-for-github-actions/security-guides/security-hardening-for-github-actions#using-third-party-actions) idiom above readable. Every default is also exposed as a `const` on `GitHubActionsDefaults` — `GitHubActionsDefaults.CheckoutAction`, `CheckoutActionName`, and `CheckoutActionVersion`. +Both forms take a trailing `# comment`, which is what makes the [SHA-pinning](https://docs.github.com/en/actions/security-for-github-actions/security-guides/security-hardening-for-github-actions#using-third-party-actions) idiom above readable. The comment is split off before the value is classified, so a slash or an `@` inside it changes nothing. + +A ref that itself contains a `/` — a branch like `releases/v1` — reads exactly like an `owner/repo`, so it needs a leading `@` to disambiguate: + +```csharp +CheckoutAction = "@releases/v1" // actions/checkout@releases/v1 +CheckoutAction = "releases/v1" // error: ambiguous, names neither form +``` :::note `actions/cache@v5` and later run on the node24 runtime and require a self-hosted runner of at least `2.327.1`. Set `CacheAction = "v4"` if your runners are older. diff --git a/src/Fallout.Common/CI/GitHubActions/Configuration/GitHubActionsActionReference.cs b/src/Fallout.Common/CI/GitHubActions/Configuration/GitHubActionsActionReference.cs index 601fc6f48..0b3f74e4a 100644 --- a/src/Fallout.Common/CI/GitHubActions/Configuration/GitHubActionsActionReference.cs +++ b/src/Fallout.Common/CI/GitHubActions/Configuration/GitHubActionsActionReference.cs @@ -10,37 +10,94 @@ namespace Fallout.Common.CI.GitHubActions.Configuration; internal static class GitHubActionsActionReference { /// - /// Resolves into the uses: value to emit: + /// Resolves into the uses: value to emit. A trailing # comment is + /// split off first and never taken into account when classifying, so a commit pin commented with a + /// version or a URL behaves like any other reference. What remains is then read as: /// /// null or whitespace — falls back to . - /// contains a / — a complete reference, emitted verbatim (e.g. my-org/checkout@v9, - /// or actions/checkout@11bd7190… # v7.1.0 for a SHA pin). - /// otherwise — a bare ref appended to 's owner/repo, so - /// v8 becomes actions/checkout@v8. A leading @ is optional. A trailing - /// # comment rides along, which is what makes 11bd7190… # v7.1.0 work as shorthand. + /// contains an @ — a complete reference, emitted verbatim (my-org/checkout@v9, + /// actions/checkout@11bd7190…). + /// starts with @, or contains no / — a bare ref appended to + /// 's owner/repo, so v8 becomes actions/checkout@v8 + /// and @releases/v1 becomes actions/checkout@releases/v1. + /// contains a / but no @ — ambiguous between the two (a fork missing its version + /// reads exactly like a branch ref), and rejected rather than guessed at. /// + /// Local (./path) and container (docker://) references are passed through as-is. /// public static string Resolve(string defaultAction, string value) { if (value.IsNullOrWhiteSpace()) return defaultAction; - var reference = value.Trim(); - Assert.True(!reference.Contains('\n') && !reference.Contains('\r'), - $"Action reference '{value}' must be a single line"); + Assert.True(value.All(x => !char.IsControl(x)), + $"Action reference '{value}' must be a single line without control characters"); - if (!reference.Contains('/')) + var (reference, comment) = SplitComment(value.Trim()); + + Assert.True(!reference.IsNullOrWhiteSpace(), + $"Action reference '{value}' is only a comment and names no action"); + // The reference is emitted into an unquoted YAML scalar, where whitespace would let a second key + // (': ') or a stray token through and corrupt the whole workflow file. + Assert.True(reference.All(x => !char.IsWhiteSpace(x)), + $"Action reference '{reference}' must not contain whitespace — expected 'owner/repo@ref' or a bare ref"); + + if (!reference.StartsWith("./", StringComparison.Ordinal) && + !reference.StartsWith("docker://", StringComparison.Ordinal)) + { + reference = ResolveActionReference(defaultAction, reference); + } + + return comment == null ? reference : $"{reference} {comment}"; + } + + private static string ResolveActionReference(string defaultAction, string reference) + { + // A leading '@' marks a bare ref explicitly — the only way to say that a slash-bearing value like + // 'releases/v1' is a branch rather than an owner/repo. + var isExplicitBareRef = reference.StartsWith("@", StringComparison.Ordinal); + if (isExplicitBareRef) + reference = reference.Substring(startIndex: 1); + + var separatorIndex = reference.IndexOf('@'); + if (!isExplicitBareRef && separatorIndex >= 0) { - var separatorIndex = defaultAction.IndexOf('@'); - var name = separatorIndex > 0 ? defaultAction.Substring(startIndex: 0, separatorIndex) : defaultAction; - reference = $"{name}@{reference.TrimStart('@')}"; + var name = reference.Substring(startIndex: 0, separatorIndex); + var gitReference = reference.Substring(separatorIndex + 1); + + Assert.True(name.Contains('/'), + $"Action reference '{reference}' must name the action as 'owner/repo@ref'"); + Assert.True(!gitReference.IsNullOrWhiteSpace() && !gitReference.Contains('@'), + $"Action reference '{reference}' must name exactly one version or commit after the '@'"); + + return reference; } - // Local (./path) and container (docker://) references carry no ref; everything else must name one, - // since GitHub rejects a bare owner/repo. - Assert.True(reference.Contains('@') || reference.StartsWith("./") || reference.StartsWith("docker://"), - $"Action reference '{value}' must name a version or commit — expected 'owner/repo@ref' or a bare ref appended to '{defaultAction}'"); + Assert.True(!reference.IsNullOrWhiteSpace() && !reference.Contains('@'), + $"Action reference '{reference}' must name exactly one version or commit"); + Assert.True(isExplicitBareRef || !reference.Contains('/'), + $"Action reference '{reference}' is ambiguous — write 'owner/repo@ref' for a complete reference, " + + $"or '@{reference}' to use it as a ref on '{GetActionName(defaultAction)}'"); + + return $"{GetActionName(defaultAction)}@{reference}"; + } + + private static string GetActionName(string defaultAction) + { + var separatorIndex = defaultAction.IndexOf('@'); + return separatorIndex > 0 ? defaultAction.Substring(startIndex: 0, separatorIndex) : defaultAction; + } + + // A '#' opens a YAML comment only at the start of the scalar or after whitespace; anywhere else it is + // an ordinary character and stays part of the reference. + private static (string Reference, string Comment) SplitComment(string value) + { + if (value.StartsWith("#", StringComparison.Ordinal)) + return (string.Empty, value); - return reference; + var commentIndex = value.IndexOf(" #", StringComparison.Ordinal); + return commentIndex < 0 + ? (value, null) + : (value.Substring(startIndex: 0, commentIndex).TrimEnd(), value.Substring(commentIndex + 1)); } } diff --git a/src/Fallout.Common/CI/GitHubActions/Configuration/GitHubActionsArtifactStep.cs b/src/Fallout.Common/CI/GitHubActions/Configuration/GitHubActionsArtifactStep.cs index c7844135d..6e0088c80 100644 --- a/src/Fallout.Common/CI/GitHubActions/Configuration/GitHubActionsArtifactStep.cs +++ b/src/Fallout.Common/CI/GitHubActions/Configuration/GitHubActionsArtifactStep.cs @@ -10,8 +10,8 @@ public class GitHubActionsArtifactStep : GitHubActionsStep /// /// The upload-artifact action to reference. Accepts a complete owner/repo@ref or a bare ref that - /// gets appended to . Defaults to - /// ; setting null or whitespace restores it. + /// gets appended to actions/upload-artifact. Defaults to the version the generator pins; setting + /// null or whitespace restores it. /// public string Uses { diff --git a/src/Fallout.Common/CI/GitHubActions/Configuration/GitHubActionsCacheStep.cs b/src/Fallout.Common/CI/GitHubActions/Configuration/GitHubActionsCacheStep.cs index 7502f7a10..0f483141c 100644 --- a/src/Fallout.Common/CI/GitHubActions/Configuration/GitHubActionsCacheStep.cs +++ b/src/Fallout.Common/CI/GitHubActions/Configuration/GitHubActionsCacheStep.cs @@ -12,8 +12,8 @@ public class GitHubActionsCacheStep : GitHubActionsStep /// /// The cache action to reference. Accepts a complete owner/repo@ref or a bare ref that gets - /// appended to . Defaults to - /// ; setting null or whitespace restores it. + /// appended to actions/cache. Defaults to the version the generator pins; setting null or + /// whitespace restores it. /// public string Uses { diff --git a/src/Fallout.Common/CI/GitHubActions/Configuration/GitHubActionsCheckoutStep.cs b/src/Fallout.Common/CI/GitHubActions/Configuration/GitHubActionsCheckoutStep.cs index 745410f56..80e9c274b 100644 --- a/src/Fallout.Common/CI/GitHubActions/Configuration/GitHubActionsCheckoutStep.cs +++ b/src/Fallout.Common/CI/GitHubActions/Configuration/GitHubActionsCheckoutStep.cs @@ -10,8 +10,8 @@ public class GitHubActionsCheckoutStep : GitHubActionsStep /// /// The checkout action to reference. Accepts a complete owner/repo@ref or a bare ref that gets - /// appended to . Defaults to - /// ; setting null or whitespace restores it. + /// appended to actions/checkout. Defaults to the version the generator pins; setting null or + /// whitespace restores it. /// public string Uses { diff --git a/src/Fallout.Common/CI/GitHubActions/Configuration/GitHubActionsRunStep.cs b/src/Fallout.Common/CI/GitHubActions/Configuration/GitHubActionsRunStep.cs index 3cd80bbdf..cc503d666 100644 --- a/src/Fallout.Common/CI/GitHubActions/Configuration/GitHubActionsRunStep.cs +++ b/src/Fallout.Common/CI/GitHubActions/Configuration/GitHubActionsRunStep.cs @@ -11,8 +11,8 @@ public class GitHubActionsRunStep : GitHubActionsStep /// /// The SDK-setup action to reference — this step emits the setup, the tool restore, and the build run, /// so only the first of the three is configurable. Accepts a complete owner/repo@ref or a bare - /// ref that gets appended to . Defaults to - /// ; setting null or whitespace restores it. + /// ref that gets appended to actions/setup-dotnet. Defaults to the version the generator pins; + /// setting null or whitespace restores it. /// public string SetupDotNetAction { diff --git a/src/Fallout.Common/CI/GitHubActions/GitHubActionsAttribute.cs b/src/Fallout.Common/CI/GitHubActions/GitHubActionsAttribute.cs index 0e4472c24..902a30687 100644 --- a/src/Fallout.Common/CI/GitHubActions/GitHubActionsAttribute.cs +++ b/src/Fallout.Common/CI/GitHubActions/GitHubActionsAttribute.cs @@ -183,31 +183,33 @@ public string CheckoutRef /// The actions/checkout reference the generated workflow uses, so a newer action release doesn't /// have to wait on a Fallout release. Two forms are accepted: /// - /// a bare ref, appended to actions/checkout"v8" emits actions/checkout@v8; + /// a bare ref, appended to actions/checkout"v8" emits actions/checkout@v8. + /// A ref containing a / needs a leading @ to say it isn't an owner/repo — + /// "@releases/v1"; /// a complete reference, emitted verbatim — "my-org/checkout@v9", or /// "actions/checkout@11bd7190… # v7.1.0" to pin a commit with the version as a trailing comment /// (the bare-ref form takes a # comment too). /// - /// Defaults to . + /// Null or whitespace restores the pinned default. /// public string CheckoutAction { get; set; } = GitHubActionsDefaults.CheckoutAction; /// /// The actions/cache reference the generated workflow uses; same forms as - /// . Defaults to — set - /// "v4" to stay on the last major that runs on node20, for self-hosted runners older than 2.327.1. + /// . Set "v4" to stay on the last major that runs on node20, for + /// self-hosted runners older than 2.327.1. /// public string CacheAction { get; set; } = GitHubActionsDefaults.CacheAction; /// /// The actions/setup-dotnet reference the generated workflow uses; same forms as - /// . Defaults to . + /// . /// public string SetupDotNetAction { get; set; } = GitHubActionsDefaults.SetupDotNetAction; /// /// The actions/upload-artifact reference the generated workflow uses; same forms as - /// . Defaults to . + /// . /// public string UploadArtifactAction { get; set; } = GitHubActionsDefaults.UploadArtifactAction; @@ -232,6 +234,7 @@ public override ConfigurationEntity GetConfiguration(IReadOnlyCollection relev return steps.ToArray(); } + // The cache and artifact steps are conditional, so their overrides would otherwise go unvalidated — + // and silently ignored — whenever caching or artifact publishing is off. Resolving all four here means + // a malformed reference fails at generation time, next to the property that carries it. + private void ValidateActionReferences() + { + foreach (var (property, value, defaultAction) in new[] + { + (nameof(CheckoutAction), CheckoutAction, GitHubActionsDefaults.CheckoutAction), + (nameof(CacheAction), CacheAction, GitHubActionsDefaults.CacheAction), + (nameof(SetupDotNetAction), SetupDotNetAction, GitHubActionsDefaults.SetupDotNetAction), + (nameof(UploadArtifactAction), UploadArtifactAction, GitHubActionsDefaults.UploadArtifactAction) + }) + { + try + { + GitHubActionsActionReference.Resolve(defaultAction, value); + } + catch (ArgumentException exception) + { + // Rethrown rather than wrapped by Assert.Fail so the type matches every other validation + // on this attribute, with the property and workflow prepended to locate the bad value. + throw new ArgumentException( + $"'{property}' in workflow '{name}' is invalid: {exception.Message}", exception); + } + } + } + private void ValidateCustomSteps(GitHubActionsStepPipeline pipeline) { foreach (var step in pipeline.AllInserts) @@ -363,6 +393,17 @@ private void ValidateCustomSteps(GitHubActionsStepPipeline pipeline) $"Custom step '{id}' in workflow '{name}' sets '{nameof(GitHubActionsCustomStep.With)}' but no '{nameof(GitHubActionsCustomStep.Uses)}'; 'with:' is only valid on a 'uses:' step"); Assert.True(step.Shell.IsNullOrWhiteSpace() || !hasUses, $"Custom step '{id}' in workflow '{name}' sets '{nameof(GitHubActionsCustomStep.Shell)}' on a 'uses:' step; shell applies only to run steps"); + + // A custom step's Uses is emitted verbatim into an unquoted YAML scalar, and — unlike the + // built-in steps' same-named property — carries no default to resolve a bare ref against. The + // whitespace half keeps a stray token or an injected key out of the workflow file; the '/' half + // turns the shorthand the built-in steps accept into an error rather than a broken 'uses: v8'. + if (hasUses) + { + var uses = step.Uses.Trim(); + Assert.True(uses.All(x => !char.IsWhiteSpace(x) && !char.IsControl(x)) && uses.Contains('/'), + $"Custom step '{id}' in workflow '{name}' must name its action in full — '{nameof(GitHubActionsCustomStep.Uses)}' takes 'owner/repo@ref', './local-action', or 'docker://image', never a bare ref"); + } } } diff --git a/src/Fallout.Common/CI/GitHubActions/GitHubActionsDefaults.cs b/src/Fallout.Common/CI/GitHubActions/GitHubActionsDefaults.cs index dd5a39bf7..5c47695c1 100644 --- a/src/Fallout.Common/CI/GitHubActions/GitHubActionsDefaults.cs +++ b/src/Fallout.Common/CI/GitHubActions/GitHubActionsDefaults.cs @@ -4,32 +4,21 @@ namespace Fallout.Common.CI.GitHubActions; /// -/// The marketplace actions the workflow generator emits, and the versions it pins them to. Every entry is a -/// const, so it can be referenced from an attribute argument — e.g. to pin one action while leaving -/// the rest on their defaults. +/// The marketplace actions the workflow generator emits, and the versions it pins them to — the single +/// place to bump one. Override any of them per workflow via +/// , , +/// , and +/// , so a newer action release never has to wait +/// on a Fallout release. /// -/// Each action exposes three constants: the *Name (owner/repo), the *Version (the ref -/// after the @), and the composed *Action that the generator actually emits. Override any of -/// them per workflow via , -/// , , -/// and — so a newer action release never has to -/// wait on a Fallout release. +/// Deliberately internal: those properties take a plain string, so nothing a consumer writes needs to name +/// this type. Keeping it out of the public surface also keeps it out of the Nuke.* transition shims, +/// which mirror public types but cannot carry const members. /// -public static class GitHubActionsDefaults +internal static class GitHubActionsDefaults { - public const string CheckoutActionName = "actions/checkout"; - public const string CheckoutActionVersion = "v7"; - public const string CheckoutAction = CheckoutActionName + "@" + CheckoutActionVersion; - - public const string CacheActionName = "actions/cache"; - public const string CacheActionVersion = "v6"; - public const string CacheAction = CacheActionName + "@" + CacheActionVersion; - - public const string SetupDotNetActionName = "actions/setup-dotnet"; - public const string SetupDotNetActionVersion = "v6"; - public const string SetupDotNetAction = SetupDotNetActionName + "@" + SetupDotNetActionVersion; - - public const string UploadArtifactActionName = "actions/upload-artifact"; - public const string UploadArtifactActionVersion = "v7"; - public const string UploadArtifactAction = UploadArtifactActionName + "@" + UploadArtifactActionVersion; + public const string CheckoutAction = "actions/checkout@v7"; + public const string CacheAction = "actions/cache@v6"; + public const string SetupDotNetAction = "actions/setup-dotnet@v6"; + public const string UploadArtifactAction = "actions/upload-artifact@v7"; } diff --git a/tests/Fallout.Common.Specs/CI/ConfigurationGenerationSpecs.cs b/tests/Fallout.Common.Specs/CI/ConfigurationGenerationSpecs.cs index 8ae420de7..6b88bad9c 100644 --- a/tests/Fallout.Common.Specs/CI/ConfigurationGenerationSpecs.cs +++ b/tests/Fallout.Common.Specs/CI/ConfigurationGenerationSpecs.cs @@ -359,7 +359,7 @@ public class TestBuild : FalloutBuild // Every emitted action is overridable, in each accepted form: a bare ref appended to the default // action name, a complete reference, a SHA pin carrying its version as a trailing comment, and a - // fork. Publish is invoked so the run produces artifacts and the upload step actually renders. + // fork. Pack is invoked because it produces artifacts, so the upload step actually renders. yield return ( "action-overrides", diff --git a/tests/Fallout.Common.Specs/CI/GitHubActionsActionReferenceSpecs.cs b/tests/Fallout.Common.Specs/CI/GitHubActionsActionReferenceSpecs.cs index 2553c46fa..0353114f9 100644 --- a/tests/Fallout.Common.Specs/CI/GitHubActionsActionReferenceSpecs.cs +++ b/tests/Fallout.Common.Specs/CI/GitHubActionsActionReferenceSpecs.cs @@ -1,6 +1,8 @@ using System; +using Fallout.Common.CI; using Fallout.Common.CI.GitHubActions; using Fallout.Common.CI.GitHubActions.Configuration; +using Fallout.Common.Execution; using FluentAssertions; using Xunit; @@ -34,6 +36,15 @@ public void Bare_sha_with_a_trailing_comment_keeps_the_comment() Resolve($"{Sha} # v7.1.0").Should().Be($"actions/checkout@{Sha} # v7.1.0"); } + // A comment is split off before the reference is classified, so a slash or an '@' inside it can't be + // mistaken for an owner/repo or a version. + [Fact] + public void Comment_is_not_taken_into_account_when_classifying() + { + Resolve($"{Sha} # https://github.com/actions/checkout/releases/tag/v7.1.0") + .Should().Be($"actions/checkout@{Sha} # https://github.com/actions/checkout/releases/tag/v7.1.0"); + } + [Theory] [InlineData("actions/checkout@v8")] [InlineData("my-org/checkout@v9")] @@ -51,26 +62,52 @@ public void Complete_reference_with_a_sha_pin_is_emitted_verbatim() Resolve(value).Should().Be(value); } + // A slash-bearing ref reads exactly like an owner/repo, so it needs the explicit '@' marker. + [Theory] + [InlineData("@releases/v1", "actions/checkout@releases/v1")] + [InlineData("@feature/node24", "actions/checkout@feature/node24")] + public void Explicit_bare_ref_may_contain_a_slash(string value, string expected) + { + Resolve(value).Should().Be(expected); + } + [Theory] [InlineData("./.github/actions/checkout")] [InlineData("docker://alpine:3.20")] - public void Local_and_container_references_need_no_ref(string value) + public void Local_and_container_references_are_passed_through(string value) { Resolve(value).Should().Be(value); } - [Fact] - public void Reference_without_a_ref_throws() + [Theory] + [InlineData("actions/checkout")] // a complete reference missing its version + [InlineData("releases/v1")] // a bare ref that reads like an owner/repo + public void Slash_without_a_ref_is_rejected_as_ambiguous(string value) { - var act = () => Resolve("actions/checkout"); + var act = () => Resolve(value); + + act.Should().Throw(); + } + + [Theory] + [InlineData("my-org/checkout # pinned by @ops-team")] // the '@' lives in the comment, not the ref + [InlineData("# v7.1.0")] // comment only, no reference at all + [InlineData("actions/checkout@")] // '@' with nothing after it + [InlineData("@")] + public void Reference_without_a_usable_ref_throws(string value) + { + var act = () => Resolve(value); act.Should().Throw(); } + // Emitted into an unquoted YAML scalar, so a second ': ' would corrupt the whole workflow file. [Theory] + [InlineData("actions/cache@v6 with: enableCrossOsArchive")] [InlineData("actions/checkout@v8\nmalicious: true")] [InlineData("v8\r with:")] - public void Multi_line_reference_throws(string value) + [InlineData("v8\ttrailing")] + public void Reference_that_would_corrupt_the_yaml_throws(string value) { var act = () => Resolve(value); @@ -105,6 +142,72 @@ public void Resetting_a_step_to_null_restores_its_default() step.Uses.Should().Be(GitHubActionsDefaults.CheckoutAction); } + // The cache and artifact steps are conditional, so their overrides must still be validated when the + // step isn't emitted — otherwise the typo only surfaces when caching or publishing is re-enabled. + [Fact] + public void Cache_override_is_validated_even_when_no_cache_step_is_emitted() + { + var act = () => Generate(x => + { + x.CacheKeyFiles = new string[0]; + x.CacheAction = "actions/cache"; + }); + + act.Should().Throw().WithMessage($"*{nameof(GitHubActionsAttribute.CacheAction)}*"); + } + + [Fact] + public void Artifact_override_is_validated_even_when_artifacts_are_disabled() + { + var act = () => Generate(x => + { + x.PublishArtifacts = false; + x.UploadArtifactAction = "my-org/upload-artifact"; + }); + + act.Should().Throw() + .WithMessage($"*{nameof(GitHubActionsAttribute.UploadArtifactAction)}*"); + } + + // The failing property and workflow are named, so the message points at the edit that broke it. + [Fact] + public void Validation_message_names_the_workflow() + { + var act = () => Generate(x => x.CheckoutAction = "releases/v1"); + + act.Should().Throw().WithMessage("*'test'*"); + } + + [Fact] + public void Well_formed_overrides_do_not_throw() + { + var act = () => Generate(x => + { + x.CheckoutAction = "@releases/v1"; + x.CacheAction = "v4"; + x.SetupDotNetAction = "actions/setup-dotnet@v7"; + x.UploadArtifactAction = $"my-org/upload-artifact@{Sha} # v7.1.0"; + }); + + act.Should().NotThrow(); + } + + private static void Generate(Action configure) + { + var build = new ConfigurationGenerationSpecs.TestBuild(); + var relevantTargets = ExecutableTargetFactory.CreateAll(build, x => x.Compile); + + var attribute = new TestGitHubActionsAttribute(GitHubActionsImage.UbuntuLatest) + { + On = new[] { GitHubActionsTrigger.Push }, + InvokedTargets = new[] { nameof(ConfigurationGenerationSpecs.TestBuild.Test) } + }; + configure(attribute); + ((ConfigurationAttributeBase)attribute).Build = build; + + attribute.GetConfiguration(relevantTargets); + } + private static string Resolve(string value) { return GitHubActionsActionReference.Resolve(GitHubActionsDefaults.CheckoutAction, value); diff --git a/tests/Fallout.Common.Specs/CI/GitHubActionsCustomStepValidationSpecs.cs b/tests/Fallout.Common.Specs/CI/GitHubActionsCustomStepValidationSpecs.cs index 6e211735d..0d4052c0d 100644 --- a/tests/Fallout.Common.Specs/CI/GitHubActionsCustomStepValidationSpecs.cs +++ b/tests/Fallout.Common.Specs/CI/GitHubActionsCustomStepValidationSpecs.cs @@ -83,6 +83,17 @@ public void Well_formed_run_step_does_not_throw() => Generate(new GitHubActionsCustomStep { Run = new[] { "echo hi" }, Shell = "pwsh" }) .Should().NotThrow(); + // A custom step's Uses has no default to resolve a bare ref against, so the shorthand the built-in + // steps accept must fail loudly here instead of emitting an unusable 'uses: v8'. The same guard keeps + // a multi-line value from injecting extra keys into the generated workflow. + [Theory] + [InlineData("v8")] + [InlineData("a/b@v1 with: x")] + [InlineData("a/b@v1\n with:\n x: y")] + public void Uses_that_is_not_a_complete_reference_throws(string uses) + => Generate(new GitHubActionsCustomStep { Uses = uses }) + .Should().Throw(); + // Collections are publicly settable, so a caller can null them. That must be treated as empty, not crash. [Fact] public void Null_collections_on_a_uses_step_do_not_throw()