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..2ee44a275 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 000000000..b8dd7f26a
--- /dev/null
+++ b/src/Fallout.Common/CI/GitHubActions/Configuration/GitHubActionsActionReference.cs
@@ -0,0 +1,119 @@
+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 a user-supplied action override into the uses: value to emit. The consumer-facing
+ /// contract — the accepted forms and what each means — is documented on the public properties that feed
+ /// this, and its siblings; this is the
+ /// implementation behind them.
+ ///
+ /// A trailing # comment is split off and set aside before classifying, so it never sways the
+ /// decision. What remains is a complete reference when it contains an @ (emitted verbatim),
+ /// otherwise a bare ref appended to 's owner/repo — with a
+ /// leading @ required to mark a slash-bearing value as a ref rather than an owner/repo.
+ /// Local (./path) and container (docker://) references pass through untouched.
+ ///
+ /// The owner/repo@ref the generator pins, used both as the fallback
+ /// and as the action a bare ref is appended to.
+ /// The override to resolve; null or whitespace yields .
+ /// Names what carries — a property and its workflow — so a
+ /// rejected reference points at the declaration to fix. Prefixed onto every message.
+ /// Thrown via when is
+ /// not a reference the generator can emit.
+ public static string Resolve(string defaultAction, string value, string origin)
+ {
+ if (value.IsNullOrWhiteSpace())
+ return defaultAction;
+
+ Assert.True(value.All(x => !char.IsControl(x)),
+ $"{origin}: action reference '{value}' must be a single line without control characters");
+
+ var (reference, comment) = SplitComment(value.Trim());
+
+ Assert.True(!reference.IsNullOrWhiteSpace(),
+ $"{origin}: 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)),
+ $"{origin}: 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 = ResolveMarketplaceReference(defaultAction, reference, origin);
+ }
+
+ return comment == null ? reference : $"{reference} {comment}";
+ }
+
+ ///
+ /// Resolves a comment-free reference to an action on GitHub — the owner/repo@ref case and the
+ /// bare-ref case — rejecting anything that names no ref or is ambiguous between the two. Local and
+ /// container references never reach here; they carry no ref to resolve.
+ ///
+ private static string ResolveMarketplaceReference(string defaultAction, string reference, string origin)
+ {
+ // 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('/'),
+ $"{origin}: action reference '{reference}' must name the action as 'owner/repo@ref'");
+ Assert.True(!gitReference.IsNullOrWhiteSpace() && !gitReference.Contains('@'),
+ $"{origin}: action reference '{reference}' must name exactly one version or commit after the '@'");
+
+ return reference;
+ }
+
+ Assert.True(!reference.IsNullOrWhiteSpace() && !reference.Contains('@'),
+ $"{origin}: action reference '{reference}' must name exactly one version or commit");
+ Assert.True(isExplicitBareRef || !reference.Contains('/'),
+ $"{origin}: 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}";
+ }
+
+ ///
+ /// Takes the owner/repo half of a pinned default, which is what a bare ref gets appended to.
+ ///
+ private static string GetActionName(string defaultAction)
+ {
+ var separatorIndex = defaultAction.IndexOf('@');
+ return separatorIndex > 0 ? defaultAction.Substring(startIndex: 0, separatorIndex) : defaultAction;
+ }
+
+ ///
+ /// Splits a trailing YAML comment off the reference, so neither classification nor validation can be
+ /// swayed by a / or @ that only appears inside it. A # opens a 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 42d9058fa..a42b50279 100644
--- a/src/Fallout.Common/CI/GitHubActions/Configuration/GitHubActionsArtifactStep.cs
+++ b/src/Fallout.Common/CI/GitHubActions/Configuration/GitHubActionsArtifactStep.cs
@@ -6,6 +6,18 @@ namespace Fallout.Common.CI.GitHubActions.Configuration;
public class GitHubActionsArtifactStep : GitHubActionsStep
{
+ ///
+ /// 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
+ {
+ get;
+ set => field = GitHubActionsActionReference.Resolve(
+ GitHubActionsDefaults.UploadArtifactAction, value, $"{nameof(GitHubActionsArtifactStep)}.{nameof(Uses)}");
+ } = GitHubActionsDefaults.UploadArtifactAction;
+
public string Name { get; set; }
public string Path { get; set; }
public string Condition { get; set; }
@@ -13,7 +25,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 2fc5b3517..0b43aa770 100644
--- a/src/Fallout.Common/CI/GitHubActions/Configuration/GitHubActionsCacheStep.cs
+++ b/src/Fallout.Common/CI/GitHubActions/Configuration/GitHubActionsCacheStep.cs
@@ -8,6 +8,18 @@ namespace Fallout.Common.CI.GitHubActions.Configuration;
// https://github.com/actions/cache
public class GitHubActionsCacheStep : GitHubActionsStep
{
+ ///
+ /// 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
+ {
+ get;
+ set => field = GitHubActionsActionReference.Resolve(
+ GitHubActionsDefaults.CacheAction, value, $"{nameof(GitHubActionsCacheStep)}.{nameof(Uses)}");
+ } = GitHubActionsDefaults.CacheAction;
+
public string[] IncludePatterns { get; set; }
public string[] ExcludePatterns { get; set; }
public string[] KeyFiles { get; set; }
@@ -17,7 +29,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 eef71ac2d..1ddfc22ea 100644
--- a/src/Fallout.Common/CI/GitHubActions/Configuration/GitHubActionsCheckoutStep.cs
+++ b/src/Fallout.Common/CI/GitHubActions/Configuration/GitHubActionsCheckoutStep.cs
@@ -6,6 +6,18 @@ namespace Fallout.Common.CI.GitHubActions.Configuration;
public class GitHubActionsCheckoutStep : GitHubActionsStep
{
+ ///
+ /// 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
+ {
+ get;
+ set => field = GitHubActionsActionReference.Resolve(
+ GitHubActionsDefaults.CheckoutAction, value, $"{nameof(GitHubActionsCheckoutStep)}.{nameof(Uses)}");
+ } = GitHubActionsDefaults.CheckoutAction;
+
public GitHubActionsSubmodules? Submodules { get; set; }
public bool? Lfs { get; set; }
public uint? FetchDepth { get; set; }
@@ -28,7 +40,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..0167df4be 100644
--- a/src/Fallout.Common/CI/GitHubActions/Configuration/GitHubActionsRunStep.cs
+++ b/src/Fallout.Common/CI/GitHubActions/Configuration/GitHubActionsRunStep.cs
@@ -6,6 +6,19 @@ namespace Fallout.Common.CI.GitHubActions.Configuration;
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 actions/setup-dotnet. Defaults to the version the generator pins;
+ /// setting null or whitespace restores it.
+ ///
+ public string SetupDotNetAction
+ {
+ get;
+ set => field = GitHubActionsActionReference.Resolve(
+ GitHubActionsDefaults.SetupDotNetAction, value, $"{nameof(GitHubActionsRunStep)}.{nameof(SetupDotNetAction)}");
+ } = GitHubActionsDefaults.SetupDotNetAction;
+
public string[] InvokedTargets { get; set; }
public Dictionary Imports { get; set; }
@@ -14,7 +27,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..e3c355c34 100644
--- a/src/Fallout.Common/CI/GitHubActions/GitHubActionsAttribute.cs
+++ b/src/Fallout.Common/CI/GitHubActions/GitHubActionsAttribute.cs
@@ -179,6 +179,43 @@ 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 that itself contains a / (a branch such as releases/v1) reads exactly like an
+ /// owner/repo, so it needs a leading @ — "@releases/v1"; the bare slash form is
+ /// otherwise rejected as ambiguous rather than guessed at;
+ /// - a complete reference (one that contains an @), emitted verbatim —
+ /// "my-org/checkout@v9", or "actions/checkout@11bd7190… # v7.1.0" to pin a commit with the
+ /// version as a trailing comment.
+ ///
+ /// A trailing # comment is allowed on either form and is split off before the value is
+ /// classified, so a / or @ inside it changes nothing. A malformed reference is rejected
+ /// when the workflow is generated. 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 +237,7 @@ public override ConfigurationEntity GetConfiguration(IReadOnlyCollection relev
{
var checkout = new GitHubActionsCheckoutStep
{
+ Uses = CheckoutAction,
Submodules = submodules,
Lfs = lfs,
FetchDepth = fetchDepth,
@@ -259,6 +298,7 @@ private GitHubActionsStep[] GetSteps(IReadOnlyCollection relev
var cache = CacheKeyFiles.Any()
? new GitHubActionsCacheStep
{
+ Uses = CacheAction,
IncludePatterns = CacheIncludePatterns,
ExcludePatterns = CacheExcludePatterns,
KeyFiles = CacheKeyFiles
@@ -267,6 +307,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 +325,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 +355,24 @@ 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; the discarded results are re-resolved by the step
+ // setters, which is cheap and keeps the steps the single owner of what they emit.
+ 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)
+ })
+ {
+ GitHubActionsActionReference.Resolve(defaultAction, value, $"'{property}' in workflow '{name}'");
+ }
+ }
+
private void ValidateCustomSteps(GitHubActionsStepPipeline pipeline)
{
foreach (var step in pipeline.AllInserts)
@@ -327,6 +387,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 000000000..5c47695c1
--- /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 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.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/ConfigurationGenerationSpecs.cs b/tests/Fallout.Common.Specs/CI/ConfigurationGenerationSpecs.cs
index 9ace104ac..6b88bad9c 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 000000000..dd5e145a8
--- /dev/null
+++ b/tests/Fallout.Common.Specs/CI/GitHubActionsActionReferenceSpecs.cs
@@ -0,0 +1,248 @@
+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";
+
+ // Stands in for what a real caller passes: the property carrying the value, and the workflow it's on.
+ private const string Origin = "'CheckoutAction' in workflow 'test'";
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ [InlineData(" ")]
+ public void Unset_value_falls_back_to_the_default(string value)
+ {
+ var reference = GitHubActionsActionReference.Resolve(GitHubActionsDefaults.CheckoutAction, value, Origin);
+
+ reference.Should().Be(GitHubActionsDefaults.CheckoutAction);
+ }
+
+ [Theory]
+ [InlineData("v8")]
+ [InlineData("@v8")]
+ [InlineData(" v8 ")]
+ public void Bare_ref_is_appended_to_the_default_action_name(string value)
+ {
+ var reference = GitHubActionsActionReference.Resolve(GitHubActionsDefaults.CheckoutAction, value, Origin);
+
+ reference.Should().Be("actions/checkout@v8");
+ }
+
+ [Fact]
+ public void Bare_sha_with_a_trailing_comment_keeps_the_comment()
+ {
+ var reference = GitHubActionsActionReference.Resolve(
+ GitHubActionsDefaults.CheckoutAction, $"{Sha} # v7.1.0", Origin);
+
+ reference.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()
+ {
+ var value = $"{Sha} # https://github.com/actions/checkout/releases/tag/v7.1.0";
+
+ var reference = GitHubActionsActionReference.Resolve(GitHubActionsDefaults.CheckoutAction, value, Origin);
+
+ reference.Should().Be($"actions/checkout@{value}");
+ }
+
+ [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)
+ {
+ var reference = GitHubActionsActionReference.Resolve(GitHubActionsDefaults.CheckoutAction, value, Origin);
+
+ reference.Should().Be(value);
+ }
+
+ [Fact]
+ public void Complete_reference_with_a_sha_pin_is_emitted_verbatim()
+ {
+ var value = $"actions/checkout@{Sha} # v7.1.0";
+
+ var reference = GitHubActionsActionReference.Resolve(GitHubActionsDefaults.CheckoutAction, value, Origin);
+
+ reference.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)
+ {
+ var reference = GitHubActionsActionReference.Resolve(GitHubActionsDefaults.CheckoutAction, value, Origin);
+
+ reference.Should().Be(expected);
+ }
+
+ [Theory]
+ [InlineData("./.github/actions/checkout")]
+ [InlineData("docker://alpine:3.20")]
+ public void Local_and_container_references_are_passed_through(string value)
+ {
+ var reference = GitHubActionsActionReference.Resolve(GitHubActionsDefaults.CheckoutAction, value, Origin);
+
+ reference.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 = () => GitHubActionsActionReference.Resolve(GitHubActionsDefaults.CheckoutAction, value, Origin);
+
+ 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 = () => GitHubActionsActionReference.Resolve(GitHubActionsDefaults.CheckoutAction, value, Origin);
+
+ 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 = () => GitHubActionsActionReference.Resolve(GitHubActionsDefaults.CheckoutAction, value, Origin);
+
+ act.Should().Throw();
+ }
+
+ // A rejected reference names the property and workflow that carry it, so the message points at the
+ // declaration to fix rather than just the offending string.
+ [Fact]
+ public void Rejection_message_names_the_origin_of_the_value()
+ {
+ var act = () => GitHubActionsActionReference.Resolve(
+ GitHubActionsDefaults.CheckoutAction, "releases/v1", "'CheckoutAction' in workflow 'build'");
+
+ act.Should().Throw()
+ .WithMessage("*'CheckoutAction' in workflow 'build'*");
+ }
+
+ [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 = () => GenerateWorkflow(attribute =>
+ {
+ attribute.CacheKeyFiles = new string[0];
+ attribute.CacheAction = "actions/cache";
+ });
+
+ act.Should().Throw().WithMessage($"*{nameof(GitHubActionsAttribute.CacheAction)}*");
+ }
+
+ [Fact]
+ public void Artifact_override_is_validated_even_when_artifacts_are_disabled()
+ {
+ var act = () => GenerateWorkflow(attribute =>
+ {
+ attribute.PublishArtifacts = false;
+ attribute.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 = () => GenerateWorkflow(attribute => attribute.CheckoutAction = "releases/v1");
+
+ act.Should().Throw().WithMessage("*'test'*");
+ }
+
+ [Fact]
+ public void Well_formed_overrides_do_not_throw()
+ {
+ var act = () => GenerateWorkflow(attribute =>
+ {
+ attribute.CheckoutAction = "@releases/v1";
+ attribute.CacheAction = "v4";
+ attribute.SetupDotNetAction = "actions/setup-dotnet@v7";
+ attribute.UploadArtifactAction = $"my-org/upload-artifact@{Sha} # v7.1.0";
+ });
+
+ act.Should().NotThrow();
+ }
+
+ ///
+ /// Runs the generator over a minimal push-triggered workflow named test, after
+ /// has set the properties under test. Generation is what triggers
+ /// validation, so this is how a spec observes an override being rejected — or accepted.
+ ///
+ private static void GenerateWorkflow(Action configureAttribute)
+ {
+ 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) }
+ };
+ configureAttribute(attribute);
+ ((ConfigurationAttributeBase)attribute).Build = build;
+
+ attribute.GetConfiguration(relevantTargets);
+ }
+}
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()
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