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