diff --git a/.github/workflows/build-cross-platform.yml b/.github/workflows/build-cross-platform.yml
index 3cb90435..07ba6d9c 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 e0b220c8..e7e8f150 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 8fd74661..87aed310 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 1203303a..44575a0e 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 cf6ce491..2ee44a27 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
@@ -228,3 +228,38 @@ 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 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(
+ // ...
+ 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. 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
new file mode 100644
index 00000000..0b3f74e4
--- /dev/null
+++ b/src/Fallout.Common/CI/GitHubActions/Configuration/GitHubActionsActionReference.cs
@@ -0,0 +1,103 @@
+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. 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 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;
+
+ Assert.True(value.All(x => !char.IsControl(x)),
+ $"Action reference '{value}' must be a single line without control characters");
+
+ 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 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;
+ }
+
+ 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);
+
+ 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 42d9058f..6e0088c8 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 actions/upload-artifact. Defaults to the version the generator pins; 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 2fc5b351..0f483141 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 actions/cache. Defaults to the version the generator pins; 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@v4");
+ 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 eef71ac2..80e9c274 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 actions/checkout. Defaults to the version the generator pins; 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 28956625..cc503d66 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 actions/setup-dotnet. Defaults to the version the generator pins;
+ /// 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 10237f43..902a3068 100644
--- a/src/Fallout.Common/CI/GitHubActions/GitHubActionsAttribute.cs
+++ b/src/Fallout.Common/CI/GitHubActions/GitHubActionsAttribute.cs
@@ -179,6 +179,40 @@ 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 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).
+ ///
+ /// 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
+ /// . 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
+ /// .
+ ///
+ public string SetupDotNetAction { get; set; } = GitHubActionsDefaults.SetupDotNetAction;
+
+ ///
+ /// The actions/upload-artifact reference the generated workflow uses; same forms as
+ /// .
+ ///
+ public string UploadArtifactAction { get; set; } = GitHubActionsDefaults.UploadArtifactAction;
+
public override CustomFileWriter CreateWriter(StreamWriter streamWriter)
{
return new CustomFileWriter(streamWriter, indentationFactor: 2, commentPrefix: "#");
@@ -200,6 +234,7 @@ public override ConfigurationEntity GetConfiguration(IReadOnlyCollection relev
{
var checkout = new GitHubActionsCheckoutStep
{
+ Uses = CheckoutAction,
Submodules = submodules,
Lfs = lfs,
FetchDepth = fetchDepth,
@@ -259,6 +295,7 @@ private GitHubActionsStep[] GetSteps(IReadOnlyCollection relev
var cache = CacheKeyFiles.Any()
? new GitHubActionsCacheStep
{
+ Uses = CacheAction,
IncludePatterns = CacheIncludePatterns,
ExcludePatterns = CacheExcludePatterns,
KeyFiles = CacheKeyFiles
@@ -267,6 +304,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 +322,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
@@ -313,6 +352,33 @@ private GitHubActionsStep[] GetSteps(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)
@@ -327,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
new file mode 100644
index 00000000..5c47695c
--- /dev/null
+++ b/src/Fallout.Common/CI/GitHubActions/GitHubActionsDefaults.cs
@@ -0,0 +1,24 @@
+using System;
+using System.Linq;
+
+namespace Fallout.Common.CI.GitHubActions;
+
+///
+/// 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.
+///
+/// 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.
+///
+internal static class GitHubActionsDefaults
+{
+ 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.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 00000000..0fa80f32
--- /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.Test_testName=checkout-ref_attribute=GitHubActionsAttribute.verified.txt b/tests/Fallout.Common.Specs/CI/ConfigurationGenerationSpecs.Test_testName=checkout-ref_attribute=GitHubActionsAttribute.verified.txt
index 1b086560..585cf353 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 8025557a..4715773c 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 28d6a37c..4c4edc7f 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 6d9fbad9..1dee2998 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 9a05e007..ae2d338b 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 5dee5aa3..bff516e0 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 26ba5906..48c5ca0a 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 887f44c8..e31b7716 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 5d97edd1..f1562555 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 7729b88f..c3b63642 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 247e9793..58cf1aca 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 c501a374..b1c945f9 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 cc5de92b..daf67f1a 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 f5143372..5e31f7ed 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/ConfigurationGenerationSpecs.cs b/tests/Fallout.Common.Specs/CI/ConfigurationGenerationSpecs.cs
index 9ace104a..6b88bad9 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. Pack is invoked because it produces artifacts, so 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 00000000..0353114f
--- /dev/null
+++ b/tests/Fallout.Common.Specs/CI/GitHubActionsActionReferenceSpecs.cs
@@ -0,0 +1,215 @@
+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;
+
+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");
+ }
+
+ // 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")]
+ [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);
+ }
+
+ // 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_are_passed_through(string value)
+ {
+ Resolve(value).Should().Be(value);
+ }
+
+ [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(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:")]
+ [InlineData("v8\ttrailing")]
+ public void Reference_that_would_corrupt_the_yaml_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);
+ }
+
+ // 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 6e211735..0d4052c0 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()
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 5df89c7d..a78c4993 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