From fcd27a8cd9315c125cf4915467c252623a98f310 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Mon, 2 Nov 2026 22:53:02 +0000 Subject: [PATCH] fix: stop composite actions referencing vars Composite actions have no `vars` context (and no `secrets` context), so GitHub rejected actions/purview-release with "Unrecognized named-value: 'vars'" while loading the action. No consumer release could run, and actionlint does not catch it because it only parses workflow files. Take the four repository variables as inputs instead: source-mapping, github-packages, github-packages-keep-versions and github-packages-retention-hours. The calling workflow, which does have `vars`, passes them through, so the opt-in and retention semantics are unchanged. .github/actions/purview-build/action.yml had the same latent bug for PURVIEW_NUGET_SOURCE_MAPPING; it is fixed the same way. Add a test that fails when any composite action references `vars` or `secrets`, so the next manifest-level context mistake fails in CI rather than in a consumer's release. --- .github/actions/purview-build/action.yml | 8 +++- .github/workflows/purview-release.yml | 7 +++- actions/purview-release/action.yml | 34 +++++++++++++--- .../Build.UnitTests/NuGetSourceSetupTests.cs | 9 ++++- .../Build.UnitTests/WorkflowParityTests.cs | 40 +++++++++++++++++++ 5 files changed, 89 insertions(+), 9 deletions(-) diff --git a/.github/actions/purview-build/action.yml b/.github/actions/purview-build/action.yml index 01c475e..bbd8a50 100644 --- a/.github/actions/purview-build/action.yml +++ b/.github/actions/purview-build/action.yml @@ -30,6 +30,12 @@ inputs: packages without per-package "Manage Actions access" grants. required: false default: "" + source-mapping: + description: >- + Optional semicolon-separated package ID patterns pinned to GitHub Packages. Pass the repository + variable `PURVIEW_NUGET_SOURCE_MAPPING`; a composite action has no `vars` context. + required: false + default: "" runs: using: composite @@ -53,7 +59,7 @@ runs: with: namespace: ${{ github.repository_owner }} token: ${{ inputs.packages-read-token || github.token }} - source-mapping: ${{ vars.PURVIEW_NUGET_SOURCE_MAPPING }} + source-mapping: ${{ inputs.source-mapping }} - name: Install shared build shell: bash diff --git a/.github/workflows/purview-release.yml b/.github/workflows/purview-release.yml index a0b865e..4ec9d4e 100644 --- a/.github/workflows/purview-release.yml +++ b/.github/workflows/purview-release.yml @@ -127,6 +127,11 @@ jobs: validate-pack: ${{ inputs.validate-pack }} test-filter: ${{ inputs.test-filter }} upload-artifacts: ${{ inputs.upload-artifacts }} - # A composite action has no `secrets` context, so the credentials are handed over here. + # A composite action has no `secrets` or `vars` context, so credentials and repository + # variables are handed over here. nuget-api-key: ${{ secrets.NUGET_APIKEY || secrets.NUGET__APIKEY }} packages-read-token: ${{ secrets.PURVIEW_PACKAGES_READ_TOKEN }} + source-mapping: ${{ vars.PURVIEW_NUGET_SOURCE_MAPPING }} + github-packages: ${{ vars.PURVIEW_PUBLISH_GITHUB_PACKAGES == 'true' }} + github-packages-keep-versions: ${{ vars.PURVIEW_GITHUB_PACKAGES_KEEP_VERSIONS || '1' }} + github-packages-retention-hours: ${{ vars.PURVIEW_GITHUB_PACKAGES_RETENTION_HOURS || '24' }} diff --git a/actions/purview-release/action.yml b/actions/purview-release/action.yml index 1fafe12..4ced758 100644 --- a/actions/purview-release/action.yml +++ b/actions/purview-release/action.yml @@ -106,6 +106,30 @@ inputs: uses the organisation token has to pass it here. required: false default: "" + source-mapping: + description: >- + Optional semicolon-separated package ID patterns pinned to GitHub Packages. Pass the + repository variable `PURVIEW_NUGET_SOURCE_MAPPING`; a composite action has no `vars` context. + required: false + default: "" + github-packages: + description: >- + Publish the same packages to GitHub Packages as well. Pass `true` only when the repository + variable `PURVIEW_PUBLISH_GITHUB_PACKAGES` is `true`; a composite action has no `vars` context. + required: false + default: "false" + github-packages-keep-versions: + description: >- + Newest versions per package always kept by the mirror cleanup (repository variable + `PURVIEW_GITHUB_PACKAGES_KEEP_VERSIONS`). + required: false + default: "1" + github-packages-retention-hours: + description: >- + How long a mirrored version survives after publication (repository variable + `PURVIEW_GITHUB_PACKAGES_RETENTION_HOURS`). + required: false + default: "24" runs: using: composite @@ -159,7 +183,7 @@ runs: with: namespace: ${{ github.repository_owner }} token: ${{ inputs.packages-read-token || github.token }} - source-mapping: ${{ vars.PURVIEW_NUGET_SOURCE_MAPPING }} + source-mapping: ${{ inputs.source-mapping }} - name: Install shared build shell: bash @@ -263,7 +287,7 @@ runs: Release__Mode: ${{ steps.eligibility.outputs.release_mode }} Release__UploadArtifacts: ${{ inputs.upload-artifacts }} NuGet__TrustedPublishing: ${{ inputs.trusted-publishing }} - NuGet__GitHubPackages__Enabled: ${{ vars.PURVIEW_PUBLISH_GITHUB_PACKAGES == 'true' }} + NuGet__GitHubPackages__Enabled: ${{ inputs.github-packages }} NuGet__GitHubPackages__Owner: ${{ github.repository_owner }} Build__RunTests: ${{ inputs.run-tests }} Build__RunLint: ${{ inputs.run-lint }} @@ -311,12 +335,12 @@ runs: # Housekeeping on the mirror only: NuGet.org keeps every version, so this is best effort and # never fails a release. Only reached when the pipeline (both registries) succeeded. - name: Prune older GitHub Packages versions - if: steps.eligibility.outputs.verdict == 'Release' && steps.eligibility.outputs.release_mode == 'NuGet' && vars.PURVIEW_PUBLISH_GITHUB_PACKAGES == 'true' + if: steps.eligibility.outputs.verdict == 'Release' && steps.eligibility.outputs.release_mode == 'NuGet' && inputs.github-packages == 'true' uses: purview-dev/build/actions/prune-github-packages@main with: namespace: ${{ github.repository_owner }} - keep-versions: ${{ vars.PURVIEW_GITHUB_PACKAGES_KEEP_VERSIONS || '1' }} - retention-hours: ${{ vars.PURVIEW_GITHUB_PACKAGES_RETENTION_HOURS || '24' }} + keep-versions: ${{ inputs.github-packages-keep-versions }} + retention-hours: ${{ inputs.github-packages-retention-hours }} version: ${{ steps.version.outputs.version }} # A composite action cannot declare a post-step, so cleanup lives here and runs even when a diff --git a/src/tests/Build.UnitTests/NuGetSourceSetupTests.cs b/src/tests/Build.UnitTests/NuGetSourceSetupTests.cs index 699ad18..0a0a3bc 100644 --- a/src/tests/Build.UnitTests/NuGetSourceSetupTests.cs +++ b/src/tests/Build.UnitTests/NuGetSourceSetupTests.cs @@ -217,14 +217,19 @@ await Assert public async Task PublishOptIn_IsEvaluatedOnlyWhereAReleaseCanHappen() { // Arrange + // A composite action has no `vars` context, so the action takes the decision as an input and + // the delegator is what reads the repository variable. var release = Read(ReleaseAction); + var releaseWorkflow = Read(ReleaseWorkflow); var selfRelease = Read(SelfHostedRelease); var build = Read(BuildWorkflow); var selfBuild = Read(SelfHostedBuild); // Act & Assert - await Assert.That(release).Contains("vars.PURVIEW_PUBLISH_GITHUB_PACKAGES"); + await Assert.That(release).Contains("inputs.github-packages"); await Assert.That(release).Contains("NuGet__GitHubPackages__Enabled"); + await Assert.That(releaseWorkflow).Contains("vars.PURVIEW_PUBLISH_GITHUB_PACKAGES == 'true'"); + await Assert.That(releaseWorkflow).Contains("github-packages:"); await Assert.That(selfRelease).Contains("vars.PURVIEW_PUBLISH_GITHUB_PACKAGES"); await Assert.That(selfRelease).Contains("NuGet__GitHubPackages__Enabled"); await Assert @@ -283,7 +288,7 @@ public async Task Prune_OnlyRunsAfterASuccessfulReleaseWithTheOptIn() // Act & Assert await Assert.That(release).Contains("prune-github-packages"); - await Assert.That(release).Contains("vars.PURVIEW_PUBLISH_GITHUB_PACKAGES == 'true'"); + await Assert.That(release).Contains("inputs.github-packages == 'true'"); await Assert.That(selfRelease).Contains("prune-github-packages"); await Assert.That(build).DoesNotContain("prune-github-packages"); await Assert.That(composite).DoesNotContain("prune-github-packages"); diff --git a/src/tests/Build.UnitTests/WorkflowParityTests.cs b/src/tests/Build.UnitTests/WorkflowParityTests.cs index 55e7379..b14a729 100644 --- a/src/tests/Build.UnitTests/WorkflowParityTests.cs +++ b/src/tests/Build.UnitTests/WorkflowParityTests.cs @@ -508,6 +508,46 @@ await Assert ); } + [Test] + public async Task CompositeActions_UseOnlyContextsGitHubAllowsThem() + { + // Arrange + // A composite action has no `vars` or `secrets` context. Referencing either makes GitHub reject + // the manifest — "Unrecognized named-value: 'vars'" — while loading the action, before any step + // runs. actionlint only parses workflow files, so nothing else catches it, and the failure only + // appears when a consumer's release actually loads the action. + var root = RepositoryRoot(); + List actions = + [ + .. Directory.EnumerateFiles(Path.Combine(root, "actions"), "action.yml", SearchOption.AllDirectories), + .. Directory.EnumerateFiles( + Path.Combine(root, ".github", "actions"), + "action.yml", + SearchOption.AllDirectories + ), + ]; + + // Act + var offenders = actions + .Where(path => ForbiddenActionContext().IsMatch(File.ReadAllText(path))) + .Select(path => Path.GetRelativePath(root, path).Replace(Path.DirectorySeparatorChar, '/')) + .ToList(); + + // Assert + await Assert.That(actions).IsNotEmpty().Because("the guard below would otherwise be vacuous."); + await Assert + .That(offenders) + .IsEmpty() + .Because( + $"{string.Join(", ", offenders)} reference `vars` or `secrets` from a composite action. " + + "GitHub has no such context there and fails to load the manifest. Pass the value in as " + + "an input from the calling workflow." + ); + } + + [GeneratedRegex(@"\$\{\{[^}]*\b(?:vars|secrets)\.")] + private static partial Regex ForbiddenActionContext(); + static string ReadWorkflow(string relativePath) { var root = RepositoryRoot();