diff --git a/.editorconfig b/.editorconfig index b511789..ea03058 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,4 +1,4 @@ -# Purview.BuildSdk 1.1.0 - managed file, do not edit +# Purview.BuildSdk 1.2.0 - managed file, do not edit # # This file is written by Purview.BuildSdk and is replaced whenever the SDK version changes. The version # on the first line is how the SDK recognises a file it owns: a .editorconfig without that marker is diff --git a/.github/workflows/purview-release.yml b/.github/workflows/purview-release.yml index 43e3c39..a0b865e 100644 --- a/.github/workflows/purview-release.yml +++ b/.github/workflows/purview-release.yml @@ -49,21 +49,6 @@ on: description: Where the version comes from (Version__Source). Currently PackageJson. required: false type: string - trusted-publishing: - description: >- - Use NuGet Trusted Publishing instead of a stored API key. The job exchanges a GitHub - OIDC token for a short-lived nuget.org key, so no long-lived secret is involved. The - caller must grant `id-token: write`, and nuget.org must have a Trusted Publishing policy - registered for this repository. - required: false - default: false - type: boolean - nuget-user: - description: >- - nuget.org profile name that owns the Trusted Publishing policy. This is the profile name, - not the sign-in email. Required when trusted-publishing is true. - required: false - type: string run-tests: description: Enable discovered tests (Build__RunTests). required: false @@ -97,7 +82,7 @@ on: NUGET_APIKEY: required: false # Declared because consuming repositories historically store the key under this name and the - # job forwards it as NUGET_API_KEY. A secret referenced but not declared here resolves only + # action receives it as one value. A secret referenced but not declared here resolves only # under `secrets: inherit`; declaring it makes the contract explicit either way. NUGET__APIKEY: required: false @@ -106,243 +91,42 @@ on: PURVIEW_PACKAGES_READ_TOKEN: required: false -# Deliberately no `permissions:` block: callers own their token, and the jobs below inherit it. +# This workflow is a delegator. The release implementation lives in +# actions/purview-release/action.yml, and this file exists only so callers that already reference +# it keep working. # -# A reusable workflow can never elevate its caller, so a block here grants nothing. It only adds a -# ceiling that GitHub validates against the caller *statically*, before any input is read — so a -# scope needed by one optional feature becomes mandatory for every caller. `id-token` is the trap: -# GitHub grants it `none` by default whatever the repository's default-token setting, so declaring -# `id-token: write` here failed every caller at parse time with "is only allowed 'id-token: none'", -# including callers that never asked for Trusted Publishing. +# It deliberately offers no Trusted Publishing inputs. nuget.org validates the OIDC +# `job_workflow_ref` claim against the registered policy, and inside a reusable workflow that claim +# names this repository, not the caller — the exchange fails with "No matching trust policy" +# (NuGet/NuGetGallery#11000). Trusted Publishing therefore requires calling the composite action +# from the consuming repository's own job, which is the file name a policy is registered against. # -# What the jobs need, the caller grants: `contents: write` to tag and create the release, -# `packages: write` to publish to GitHub Packages, `id-token: write` for `trusted-publishing: true`. -# See the Getting Started and Release Flow wiki pages. +# Deliberately no `permissions:` block either. Callers own their token, and a block here grants +# nothing while adding a ceiling GitHub validates against the caller statically, before any input is +# read. See the Getting Started and Release Flow wiki pages. jobs: release: name: Release runs-on: ubuntu-latest timeout-minutes: 30 - env: - GITHUB_TOKEN: ${{ github.token }} - Release__Mode: ${{ inputs.release-mode }} - Release__UploadArtifacts: ${{ inputs.upload-artifacts }} - NuGet__TrustedPublishing: ${{ inputs.trusted-publishing }} - # The repository variable is the opt-in for the additional destination. It is evaluated in the - # caller's repository, and an absent variable compares as false, so every existing repository - # keeps publishing to NuGet.org only. - NuGet__GitHubPackages__Enabled: ${{ vars.PURVIEW_PUBLISH_GITHUB_PACKAGES == 'true' }} - NuGet__GitHubPackages__Owner: ${{ github.repository_owner }} - Build__RunTests: ${{ inputs.run-tests }} - Build__RunLint: ${{ inputs.run-lint }} - Build__RunPack: ${{ inputs.run-pack }} - Build__ValidatePack: ${{ inputs.validate-pack }} steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - with: - # Eligibility is judged against the tags that already exist, so the full tag list - # has to be present before the tool evaluates anything. - fetch-depth: 0 - fetch-tags: true - - # global.json is the single source of truth for the SDK whenever it pins one; see the matching - # step in purview-build.yml. When it pins nothing, the dotnet-version input applies. - - name: Resolve the .NET SDK version - id: resolve-sdk - shell: bash - # Caller input reaches the script through env, never by interpolating an expression into the script: the - # value is substituted into the script text before bash sees it, so a crafted input would - # run as shell. - env: - INPUT_DOTNET_VERSION: ${{ inputs.dotnet-version }} - run: | - set -euo pipefail - - pinned="" - if [ -f global.json ]; then - pinned="$(jq -r '.sdk.version // empty' global.json)" - fi - - if [ -n "$pinned" ]; then - echo "global.json pins $pinned; installing that." - echo "dotnet-version=$pinned" >> "$GITHUB_OUTPUT" - else - echo "global.json pins no sdk.version; using the dotnet-version input ($INPUT_DOTNET_VERSION)." - echo "dotnet-version=$INPUT_DOTNET_VERSION" >> "$GITHUB_OUTPUT" - fi - - - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6 - with: - dotnet-version: ${{ steps.resolve-sdk.outputs.dotnet-version }} - - - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 - - - name: Forward optional inputs - shell: bash - env: - INPUT_CONFIG_PATH: ${{ inputs.config-path }} - INPUT_ELIGIBILITY_POLICY: ${{ inputs.eligibility-policy }} - INPUT_RELEASE_CHANNEL: ${{ inputs.release-channel }} - INPUT_VERSION_SOURCE: ${{ inputs.version-source }} - INPUT_TEST_FILTER: ${{ inputs.test-filter }} - run: | - set -euo pipefail - - # Only forward optional settings the caller actually provided. Setting any of these to - # the empty string would override the consuming repo's purview-build.json, because - # environment variables take precedence over the JSON config (see commit 4d72bf7). - # - # Written through a heredoc with a random delimiter, not `echo NAME=value`. A value - # containing a newline would otherwise inject arbitrary variables into every later step, - # and these values come from the calling repository. - forward() { - local name="$1" value="$2" - [ -n "$value" ] || return 0 - local delimiter - delimiter="EOF_$(openssl rand -hex 16)" - { - printf '%s<<%s\n' "$name" "$delimiter" - printf '%s\n' "$value" - printf '%s\n' "$delimiter" - } >> "$GITHUB_ENV" - } - - forward PURVIEW_BUILD_CONFIG "$INPUT_CONFIG_PATH" - forward Release__Eligibility__Policy "$INPUT_ELIGIBILITY_POLICY" - forward Release__Channel "$INPUT_RELEASE_CHANNEL" - forward Version__Source "$INPUT_VERSION_SOURCE" - forward Build__TestFilter "$INPUT_TEST_FILTER" - - # Before the tool install, so a build-version that exists only on GitHub Packages is resolvable. - # A fork pull request cannot read the private feed (no organisation secrets), so leave it on - # NuGet.org rather than registering a source every restore would be denied. - - name: Configure NuGet sources - if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork == false - uses: purview-dev/build/actions/nuget-sources@main - with: - namespace: ${{ github.repository_owner }} - token: ${{ secrets.PURVIEW_PACKAGES_READ_TOKEN || github.token }} - source-mapping: ${{ vars.PURVIEW_NUGET_SOURCE_MAPPING }} - - - name: Install shared build - shell: bash - env: - INPUT_BUILD_VERSION: ${{ inputs.build-version }} - TOOL_PATH: ${{ runner.temp }}/purview-build - run: | - set -euo pipefail - - # Quoted and passed as distinct arguments, not word-split from a string. Leaving - # build-version empty installs whatever is latest on nuget.org at job time and runs it, - # so callers should pin it. - if [ -n "$INPUT_BUILD_VERSION" ]; then - dotnet tool install Purview.Build --tool-path "$TOOL_PATH" --version "$INPUT_BUILD_VERSION" - else - echo "::warning title=Unpinned build tool::No build-version was supplied, so the latest Purview.Build on nuget.org will be installed and executed. Pin build-version for a reproducible release." - dotnet tool install Purview.Build --tool-path "$TOOL_PATH" - fi - - # The version is reported, never decided: eligibility remains the tool's evaluation below. It is - # used only to protect the just-published version from the package cleanup. - - name: Read release version - id: version - shell: bash - run: | - version="" - if [ -f package.json ]; then - version="$(jq -r '.version // ""' package.json)" - fi - echo "version=$version" >> "$GITHUB_OUTPUT" - - - name: Evaluate release eligibility - id: eligibility - shell: bash - run: | - # The tool evaluates; this workflow decides. release-explain runs no module, mutates - # nothing, and always exits 0 — the decision is in its JSON. - "${{ runner.temp }}/purview-build/purview-build" release-explain --format=json > explain.json - cat explain.json - - { - echo "verdict=$(jq -r '.verdict' explain.json)" - echo "release_mode=$(jq -r '.releaseMode' explain.json)" - echo "decided_by=$(jq -r '.decidedByRule // ""' explain.json)" - } >> "$GITHUB_OUTPUT" - - jq -r '.message' explain.json > eligibility-message.txt - - - name: Report ineligible release - if: steps.eligibility.outputs.verdict == 'Fail' - shell: bash - run: | - set -euo pipefail - - # Workflow commands are parsed out of the log line, so a message containing a newline - # and a `::` sequence would be interpreted as a command rather than printed. Escape the - # message to a single line before handing it over. - message="$(tr '\n' ' ' < eligibility-message.txt | sed 's/::/:﹕/g')" - echo "::error title=Release not eligible::${message}" - exit 1 - - - name: Skip release - if: steps.eligibility.outputs.verdict == 'Skip' - shell: bash - run: | - set -euo pipefail - message="$(tr '\n' ' ' < eligibility-message.txt | sed 's/::/:﹕/g')" - echo "::notice title=Release skipped::${message}" - - - name: Require a nuget.org profile for Trusted Publishing - if: steps.eligibility.outputs.verdict == 'Release' && inputs.trusted-publishing && inputs.nuget-user == '' - shell: bash - run: | - echo "::error title=nuget-user is required::trusted-publishing is enabled, but no nuget-user was supplied. Pass the nuget.org profile name that owns the Trusted Publishing policy." - exit 1 - - # Immediately before the push: the minted key is valid for one hour, and each OIDC token can - # be exchanged exactly once. - - name: Exchange the OIDC token for a short-lived nuget.org key - id: nuget-login - if: steps.eligibility.outputs.verdict == 'Release' && inputs.trusted-publishing - uses: NuGet/login@8d196754b4036150537f80ac539e15c2f1028841 # v1.2.0 - with: - user: ${{ inputs.nuget-user }} - - - name: Run release pipeline - if: steps.eligibility.outputs.verdict == 'Release' - shell: bash - env: - # The evaluated decision, not the raw input: a verdict that is not "Release" resolves to - # None, so the publish and release modules skip even if this step were reached. - Release__Mode: ${{ steps.eligibility.outputs.release_mode }} - # Credentials are scoped to the one step that publishes, rather than exported for the - # whole job where every action and script could read them. - # - # Trusted Publishing wins outright when enabled. The tool prefers NUGET_APIKEY over - # NUGET_API_KEY, so a repository that still holds a long-lived secret would otherwise - # shadow the short-lived key with it and keep publishing the old way while appearing to - # have migrated. Blanking it is what makes the migration real. - NUGET_APIKEY: ${{ inputs.trusted-publishing && '' || secrets.NUGET_APIKEY }} - NUGET_API_KEY: ${{ inputs.trusted-publishing && steps.nuget-login.outputs.NUGET_API_KEY || secrets.NUGET__APIKEY }} - TOOL_PATH: ${{ runner.temp }}/purview-build - run: | - set -euo pipefail - "$TOOL_PATH/purview-build" - - # 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' - 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' }} - version: ${{ steps.version.outputs.version }} - - - name: Remove temporary NuGet sources - if: always() - uses: purview-dev/build/actions/nuget-sources@main + - name: Run the release pipeline + uses: purview-dev/build/actions/purview-release@main with: - mode: remove + build-version: ${{ inputs.build-version }} + dotnet-version: ${{ inputs.dotnet-version }} + release-mode: ${{ inputs.release-mode }} + config-path: ${{ inputs.config-path }} + eligibility-policy: ${{ inputs.eligibility-policy }} + release-channel: ${{ inputs.release-channel }} + version-source: ${{ inputs.version-source }} + run-tests: ${{ inputs.run-tests }} + run-lint: ${{ inputs.run-lint }} + run-pack: ${{ inputs.run-pack }} + 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. + nuget-api-key: ${{ secrets.NUGET_APIKEY || secrets.NUGET__APIKEY }} + packages-read-token: ${{ secrets.PURVIEW_PACKAGES_READ_TOKEN }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 58b12f5..7f7c49d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -13,6 +13,10 @@ permissions: # The self-hosted release dogfoods the dual publication: the variable decides whether the extra # destination is used, and the token must be allowed to write it. packages: write + # nuget.org Trusted Publishing: the job exchanges this OIDC token for a short-lived key, so no + # long-lived publishing secret exists. GitHub grants `id-token` as `none` whatever the + # repository's default token setting, so this is never optional. + id-token: write concurrency: group: purview-build-release @@ -68,15 +72,6 @@ jobs: -p:Version=${{ steps.version.outputs.version }} -p:PackageVersion=${{ steps.version.outputs.version }} - - name: Verify NuGet API key - env: - NUGET_APIKEY: ${{ secrets.NUGET__APIKEY }} - run: | - if [ -z "$NUGET_APIKEY" ]; then - echo "The NUGET__APIKEY secret is not set. It is required to publish Purview.Build to nuget.org." >&2 - exit 1 - fi - # --source, not --add-source: the release must be produced by the tool built from this commit, # and --add-source leaves nuget.org able to satisfy the version instead. - name: Install packed tool @@ -118,10 +113,26 @@ jobs: message="$(tr '\n' ' ' < eligibility-message.txt | sed 's/::/:﹕/g')" echo "::notice title=Release skipped::${message}" + # Immediately before the push: the minted key is valid for one hour, and each OIDC token can + # be exchanged exactly once. This job is this repository's own workflow, so the claim + # nuget.org validates against the policy is `.github/workflows/release.yml` here. + - name: Exchange the OIDC token for a short-lived nuget.org key + id: nuget-login + if: steps.eligibility.outputs.verdict == 'Release' + uses: NuGet/login@8d196754b4036150537f80ac539e15c2f1028841 # v1.2.0 + with: + # The nuget.org profile name, not the sign-in email. + user: kieronlanning + - name: Run release pipeline (builds, publishes and tags itself) if: steps.eligibility.outputs.verdict == 'Release' env: - NUGET_APIKEY: ${{ secrets.NUGET__APIKEY }} + # Trusted Publishing replaces the long-lived key outright. Blanking NUGET_APIKEY is what + # makes the migration real: the tool prefers it over NUGET_API_KEY, so a leftover secret + # would otherwise keep publishing the old way while the configuration looked migrated. + NUGET_APIKEY: "" + NUGET_API_KEY: ${{ steps.nuget-login.outputs.NUGET_API_KEY }} + NuGet__TrustedPublishing: "true" Release__Mode: ${{ steps.eligibility.outputs.release_mode }} Release__UploadArtifacts: "true" NuGet__FeedUrl: https://api.nuget.org/v3/index.json diff --git a/AGENTS.md b/AGENTS.md index 01c0309..d7d4ec2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,8 +12,14 @@ This file is the primary instruction set for human and AI agents working in this - `src/tests/Build.UnitTests/` and `src/tests/Build.IntegrationTests/` — the test suites. TUnit categories come from the project-name suffix (the Purview SDK emits an assembly-level `[Category]`), so **never write a `[Category]` attribute by hand**: put a test in the project whose name carries the category you want. - `src/tests/fixtures/` — the declarative scenario matrices (`eligibility-scenarios.json`, `config-resolution-scenarios.json`) and the `release-explain` golden file. These drive both the TUnit suites and the `just` CLI matrices; a new rule or probe location means appending cases here, not writing test code. - `src/tests/scenarios/` — the shell runners behind `just release-matrix` / `just config-matrix`. -- `.github/actions/purview-build/` and `.github/workflows/` — the shared action and reusable workflows. -- `actions/nuget-sources/` and `actions/prune-github-packages/` — the one implementation of the GitHub Packages source registration (used by all shared entry points) and the mirror-retention step. They live outside `.github/` because a composite action referenced from a reusable workflow cannot be resolved from there. +- `.github/actions/purview-build/` and `.github/workflows/` — the shared build action and the reusable workflows. +- `actions/purview-release/` — the one release implementation, a composite action. It must be a + composite action rather than a reusable workflow: nuget.org validates the OIDC + `job_workflow_ref` claim against the Trusted Publishing policy, and inside a called workflow that + claim names the called repository, so a policy registered for the caller can never match + (NuGet/NuGetGallery#11000). `purview-release.yml` forwards to it and offers no Trusted Publishing + inputs for exactly this reason. +- `actions/nuget-sources/` and `actions/prune-github-packages/` — the one implementation of the GitHub Packages source registration (used by all shared entry points) and the mirror-retention step. They live outside `.github/` because a composite action referenced from a reusable workflow cannot be resolved from there; `actions/purview-release/` follows the same rule. - `.github/workflows/ci.yml` and `release.yml` — this repository's own CI/CD, which dogfoods the tool. - `docs/wiki/` — authoritative documentation: - `docs/wiki/Architecture.md` — architecture and design decisions. @@ -58,11 +64,12 @@ CI builds with `--warnaserror`. - **Eligibility ownership**: the tool *evaluates* release eligibility and reports it; the workflow *decides* and sets `Release__Mode`. The tool must never silently decline to release when asked to. `ReportEligibilityModule` is category `Build` and every failure path in it is a warning. - **Rule IDs are permanent**: never renumber or repurpose a `REL0nn`. - **This repository builds itself and never references its own released package.** The `Justfile` runs `.tools/local/Purview.Build`, published from the working tree; `ci.yml` and `release.yml` pack from source and install with `--source artifacts` (not `--add-source`, which leaves nuget.org able to satisfy an already-shipped version); `Purview.Build` is absent from `.config/dotnet-tools.json`. `SelfHostingTests` enforces all three. The reusable workflows and the composite action are the opposite case — they install the published tool for consuming repositories and must keep doing so. -- **GitHub Packages is additive and opt-in.** Consuming from `https://nuget.pkg.github.com//index.json` is automatic for every repository using the shared action/workflows; publishing to it is controlled only by the caller repository's `PURVIEW_PUBLISH_GITHUB_PACKAGES` variable, which the release workflow maps onto `NuGet:GitHubPackages:Enabled`. Never make publication automatic, never make it a replacement for NuGet.org, and never put a PAT in the shared path for publishing: publication uses `GITHUB_TOKEN` with `packages: write`. Consumption falls back to `GITHUB_TOKEN` and may use the optional, read-only organisation secret `PURVIEW_PACKAGES_READ_TOKEN` (`read:packages` only) for repositories that should read every package without per-package Actions-access grants. NuGet.org publication and `Release:Mode` semantics must not change. -- **One source-setup implementation.** `actions/nuget-sources/` is the only place that registers the organisation feed. Both reusable workflows and the composite action call it; `ci.yml` and `release.yml` call it from `./` to dogfood it. It writes the runner's user-level NuGet configuration (never a committed `nuget.config`) and stores only a macro — `%GITHUB_TOKEN%`, or `%PURVIEW_NUGET_TOKEN%` when the optional organisation read token is used. +- **GitHub Packages is additive and opt-in.** Consuming from `https://nuget.pkg.github.com//index.json` is automatic for every repository using the shared action/workflows; publishing to it is controlled only by the caller repository's `PURVIEW_PUBLISH_GITHUB_PACKAGES` variable, which the release entry points map onto `NuGet:GitHubPackages:Enabled`. Never make publication automatic, never make it a replacement for NuGet.org, and never put a PAT in the shared path for publishing: publication uses `GITHUB_TOKEN` with `packages: write`. Consumption falls back to `GITHUB_TOKEN` and may use the optional, read-only organisation secret `PURVIEW_PACKAGES_READ_TOKEN` (`read:packages` only) for repositories that should read every package without per-package Actions-access grants. NuGet.org publication and `Release:Mode` semantics must not change. +- **One source-setup implementation.** `actions/nuget-sources/` is the only place that registers the organisation feed. `purview-build.yml`, `purview-release.yml`, and both composite actions call it; `ci.yml` and `release.yml` call it from `./` to dogfood it. It writes the runner's user-level NuGet configuration (never a committed `nuget.config`) and stores only a macro — `%GITHUB_TOKEN%`, or `%PURVIEW_NUGET_TOKEN%` when the optional organisation read token is used. +- **Trusted Publishing only from a composite action.** `NuGet/login` must stay in `actions/purview-release` (and in this repository's own `release.yml`). nuget.org validates the OIDC `job_workflow_ref` claim against the policy, and inside a reusable workflow that claim names the called repository, never the caller, so the exchange fails with *"No matching trust policy"* (NuGet/NuGetGallery#11000). `purview-release.yml` therefore declares no `trusted-publishing` or `nuget-user` input: offering one would only invite a migration that cannot work. `WorkflowParityTests` enforces the placement. - **Do not add a `concurrency` block to `purview-release.yml`**: a shared group between caller and reusable workflow makes GitHub cancel the run as a deadlock. Callers own release serialization. -- **Do not remove the `release-branch` input** from `purview-release.yml`: all seven consuming repositories still pass it. -- **Do not add a `permissions:` block to `purview-release.yml`**, at workflow or job level. GitHub validates it against the caller statically, before any input is read, and a reusable workflow can never elevate its caller — so the block grants nothing and can only fail runs. Declaring `id-token: write` for the optional Trusted Publishing path broke every caller at parse time with `is only allowed 'id-token: none'`, because GitHub withholds `id-token` by default whatever the repository's default-token setting. Callers own their token: `contents: write` to tag and release, `packages: write` for GitHub Packages, `id-token: write` for Trusted Publishing. `WorkflowParityTests` enforces this, and that every documented caller block grants the full set. +- **Do not remove the `release-branch` input** from `purview-release.yml`: every consuming repository still passes it. +- **Do not add a `permissions:` block to `purview-release.yml` or any composite action.** GitHub validates a reusable workflow's block against the caller statically, before any input is read, and a reusable workflow can never elevate its caller — so the block grants nothing and can only fail runs. Declaring `id-token: write` there broke every caller at parse time with `is only allowed 'id-token: none'`, because GitHub withholds `id-token` by default whatever the repository's default-token setting. Callers own their token: `contents: write` to tag and release, `packages: write` for GitHub Packages, `id-token: write` for Trusted Publishing. `WorkflowParityTests` enforces this, and that every documented caller block grants the full set. - **Optional workflow inputs are forwarded only when non-empty.** Environment variables outrank `purview-build.json`, so forwarding an unset input as the empty string erases a consumer's configured value. `WorkflowParityTests` enforces this. - **CLI output must not be reflowed.** `CLIConsole` lifts the Spectre profile width on every write: at the default 80 columns, redirected output gets newlines inserted inside JSON strings and diagnostics, which breaks `jq` and `grep`. - **Fail fast on required configuration.** `ConfigurationPreflight` runs before the pipeline is built and reports every missing requirement at once: an existing `Build:Solution`/`Build:CSharpierPath`, the local feed path, a NuGet credential or Trusted Publishing, and `GITHUB_TOKEN`/`GITHUB_REPOSITORY_ID`. Do not move these checks back into module execution — a missing credential must never surface after the build has run, and the tool must never silently skip a requested release. diff --git a/Directory.Packages.props b/Directory.Packages.props index 8b64399..e935565 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -2,8 +2,8 @@ true 5.9.0 - 1.76.0 - 10.0.13 + 1.78.0 + 10.0.14 10.10.0 3.2.8 7.9.0 @@ -12,18 +12,18 @@ - + - + - - + + diff --git a/README.md b/README.md index cce0f09..f2d3a7c 100644 --- a/README.md +++ b/README.md @@ -12,8 +12,13 @@ Consuming repositories own configuration; they do not own pipeline source code. The same implementation is available three ways: 1. **`Purview.Build` dotnet tool** — NuGet package published to nuget.org. Run anywhere a .NET SDK exists (locally via `just`, in GitHub Actions, or another CI service). -2. **Composite action** `purview-dev/build/.github/actions/purview-build` — for repositories that want the action embedded directly in one of their own jobs. -3. **Reusable workflows** `purview-dev/build/.github/workflows/purview-build.yml` and `.../purview-release.yml` — thin `workflow_call` wrappers with structured inputs/secrets. +2. **Composite actions** `purview-dev/build/.github/actions/purview-build` (build) and + `purview-dev/build/actions/purview-release` (release) — for repositories that want the pipeline + embedded directly in one of their own jobs. Release must use the action, not a reusable workflow, + because Trusted Publishing only matches the caller's own workflow file. +3. **Reusable workflows** `purview-dev/build/.github/workflows/purview-build.yml` and + `.../purview-release.yml` — thin `workflow_call` wrappers with structured inputs/secrets. The + release workflow forwards to the action above and cannot use Trusted Publishing. ## Minimal repository setup (reusable workflow) @@ -47,16 +52,29 @@ name: Release on: push: branches: [main] +permissions: + contents: write # tag the release and create the GitHub release + packages: write # the GitHub Packages mirror, when the repository variable opts in + id-token: write # nuget.org Trusted Publishing — GitHub withholds this by default concurrency: # Serialize releases; callers own concurrency (see docs/wiki/Release-Flow.md). group: release-${{ github.ref }} cancel-in-progress: false jobs: release: - uses: purview-dev/build/.github/workflows/purview-release.yml@main - with: - release-mode: NuGet - secrets: inherit + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + # This is a composite action, not a reusable workflow. nuget.org validates the OIDC + # `job_workflow_ref` claim against the registered Trusted Publishing policy, and inside a + # called workflow that claim names the called repository rather than this one. Running the + # action in this job keeps the claim on this file, which is what the policy names. + - uses: purview-dev/build/actions/purview-release@main + with: + release-mode: NuGet + nuget-user: your-nuget-profile-name + # A composite action has no `secrets` context, so credentials are handed over explicitly. + packages-read-token: ${{ secrets.PURVIEW_PACKAGES_READ_TOKEN }} ``` For the **main-as-head / release-branch model**, point the release caller at the release branch instead: @@ -67,27 +85,34 @@ on: branches: [release] ``` -The reusable workflow checks whether `v{version}` (read from `package.json`) is already tagged and skips if so, so merging `main` into `release` releases exactly once. +The release action asks the tool to evaluate eligibility and reads the verdict: an already-released version skips (the job finishes green), and a policy violation fails the job naming the rule. Merging `main` into `release` therefore releases exactly once. + +The shared entry points install the pinned CLI version (or the latest stable when `build-version` is omitted) from nuget.org; the organisation mirror is registered first, so a pinned version that NuGet.org has not indexed yet is installed from GitHub Packages. The consuming repository adds `purview-build.json` and a root `package.json` version. It does not need a copied pipeline project or package-source credentials. -The reusable workflows install the pinned CLI version (or the latest stable when `build-version` is omitted) from nuget.org; the organisation mirror is registered first, so a pinned version that NuGet.org has not indexed yet is installed from GitHub Packages. The consuming repository adds `purview-build.json` and a root `package.json` version. It does not need a copied pipeline project or package-source credentials. +The reusable `purview-release.yml` still exists and forwards to the same action, so an older caller +keeps working. It cannot do Trusted Publishing: the OIDC claim inside a called workflow names the +called repository, never the caller, so a policy registered for the caller will not match. New +callers should use the action directly, as above. ### Publishing without a stored API key (Trusted Publishing) -Prefer this to a long-lived `NUGET__APIKEY`. The job exchanges a GitHub OIDC token for a -nuget.org key that is valid for one hour and minted for that run, so there is no publishing -secret to leak, rotate, or expire. +This is the supported path, and the only one once long-lived keys expire. The job exchanges a GitHub +OIDC token for a nuget.org key that is valid for one hour and minted for that run, so there is no +publishing secret to leak, rotate, or expire. -1. On nuget.org, under your profile → **Trusted Publishing**, register a policy naming the - repository owner and repository. -2. Grant `id-token: write` in the calling workflow — a reusable workflow can never elevate its - caller's token, so the caller must request it. GitHub grants `id-token` as `none` by default +1. On nuget.org, under your profile → **Trusted Publishing**, register a policy with the GitHub + **repository owner**, the **repository**, and the **workflow file name** (`release.yml` — the + file name only, not the `.github/workflows/` path). The policy owner must be the account that + owns the packages. Leave **Environment** blank unless the workflow uses one. +2. Grant `id-token: write` on the calling job — GitHub grants `id-token` as `none` by default however permissive the repository's default token is, so this step is never optional. -3. Pass `trusted-publishing: true` and `nuget-user` (your nuget.org **profile name**, not the - sign-in email). +3. Pass `nuget-user` (your nuget.org **profile name**, not the sign-in email) to the composite + action. ```yaml jobs: release: + runs-on: ubuntu-latest permissions: contents: write # Trusted Publishing replaces the nuget.org key, not the GitHub Packages mirror, and the @@ -95,16 +120,21 @@ jobs: # repository publishes to nuget.org and then fails on the second push. packages: write id-token: write - uses: purview-dev/build/.github/workflows/purview-release.yml@ - with: - release-mode: NuGet - build-version: "0.8.0" - trusted-publishing: true - nuget-user: your-nuget-profile-name + steps: + - uses: purview-dev/build/actions/purview-release@ + with: + release-mode: NuGet + build-version: "0.8.0" + trusted-publishing: true + nuget-user: your-nuget-profile-name ``` +The exchange only happens when the tool's eligibility evaluation returns `Release`, and it runs +immediately before the push: the minted key lives for one hour and each OIDC token can be exchanged +exactly once. + Once this is in place, remove the `NUGET__APIKEY` secret from the caller. While -`trusted-publishing` is on, the workflow deliberately blanks `NUGET_APIKEY`: the tool prefers it +`trusted-publishing` is on, the action deliberately blanks `NUGET_APIKEY`: the tool prefers it over `NUGET_API_KEY`, so a leftover long-lived secret would otherwise keep publishing the old way while the configuration looked migrated. @@ -214,7 +244,8 @@ to it is **off** until a repository opts in. ### Restoring from the organisation feed -The shared composite action and both reusable workflows register, for the duration of the job: +The shared entry points — `purview-build.yml`, `purview-release.yml`, and both composite actions — +register, for the duration of the job: ```text https://api.nuget.org/v3/index.json @@ -224,7 +255,7 @@ https://nuget.pkg.github.com//index.json They write the source into the runner's **user-level** NuGet configuration (never the repository's `nuget.config`), authenticate it with `GITHUB_TOKEN` and a `%GITHUB_TOKEN%` credential macro so no token is written to disk, and remove the source again at the end of the job. `packages: read` is -already declared by both reusable workflows. A repository that depends only on NuGet.org packages +already declared by both build workflows. A repository that depends only on NuGet.org packages keeps restoring normally: both sources stay enabled, and NuGet picks the highest version that satisfies the reference. @@ -264,7 +295,7 @@ to all workflows. If you would rather not add every consuming repository to ever types members may *create*; they do not change what a published package inherits, and Private often cannot be unchecked because the default creation type must remain allowed. -The shared workflows use that secret for the feed when it exists and fall back to `GITHUB_TOKEN` +The shared entry points use that secret for the feed when it exists and fall back to `GITHUB_TOKEN` otherwise. Publication still uses `GITHUB_TOKEN` with `packages: write`; the read token never publishes, is never written to a configuration file, and is only exported for the duration of the job. Callers of the reusable workflows already use `secrets: inherit`; composite-action users pass it as @@ -285,20 +316,22 @@ unmapped packages continue to come from NuGet.org. ### Opting a repository in to publishing Publication is controlled only by a repository-level GitHub Actions **variable**, evaluated by the -shared release workflow. Nothing in `.config/purview-build.json`, `package.json`, the `.csproj` files +shared release action. Nothing in `.config/purview-build.json`, `package.json`, the `.csproj` files or the repository's `nuget.config` needs to change. -1. **Let the release workflow write packages.** A reusable workflow can never grant more than its - caller, and it declares no permissions of its own. In the repository's +1. **Let the release workflow write packages.** Neither a reusable workflow nor a composite action can + grant more than its caller, and neither declares permissions. In the repository's `.github/workflows/release.yml`: ```yaml permissions: contents: write packages: write + id-token: write ``` - (The job also needs `contents: write` to create the tag and GitHub release.) + (`contents: write` creates the tag and GitHub release; `id-token: write` is what the Trusted + Publishing exchange needs, and GitHub withholds it by default.) 2. **Set the variable.** Repository → **Settings** → **Secrets and variables** → **Actions** → **Variables** → **New repository variable**: diff --git a/SECURITY.md b/SECURITY.md index d88fbf2..25928e5 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -42,15 +42,17 @@ Out of scope: - **Caller-controlled input never reaches a shell as source text.** It is passed through `env:`. - **Publishing credentials are scoped to the publishing step**, not exported at job level. - **Trusted Publishing is supported** (`trusted-publishing: true` plus `nuget-user`), exchanging a - GitHub OIDC token for a nuget.org key valid for one hour. Prefer it to a stored API key. + GitHub OIDC token for a nuget.org key valid for one hour. Prefer it to a stored API key. The + exchange runs in a **composite action**, never a reusable workflow: nuget.org validates the OIDC + `job_workflow_ref` claim, which inside a called workflow names the called repository rather than + the caller, so a policy registered for the caller could never match (NuGet/NuGetGallery#11000). - **The reusable workflows do not cap the caller's token.** `purview-release.yml` declares no `permissions:` block, so its jobs inherit whatever the caller granted, and the caller owns the ceiling. A block there would not restrict a permissive caller in any useful way — it is validated statically against the caller, so it can only fail runs, never reduce them — and declaring a scope GitHub withholds by default (`id-token`) fails every caller that does not use it. Grant the - release job `contents: write`, plus `packages: write` and `id-token: write` only when the - corresponding feature is on. -- **`build-version` should be pinned by every caller.** Left empty, the workflow installs and + release job `contents: write`, plus `packages: write` and `id-token: write` for NuGet publication. +- **`build-version` should be pinned by every caller.** Left empty, the action installs and executes whatever is latest on nuget.org at job time; it now emits a warning when that happens. ## A note on same-repository action references diff --git a/actions/purview-release/action.yml b/actions/purview-release/action.yml new file mode 100644 index 0000000..1fafe12 --- /dev/null +++ b/actions/purview-release/action.yml @@ -0,0 +1,329 @@ +name: Purview Release + +description: >- + Runs the release pipeline against the calling repository: evaluates release eligibility with the + pinned Purview.Build tool, exchanges a GitHub OIDC token for a short-lived nuget.org key when + Trusted Publishing is enabled, runs the pipeline, and prunes the GitHub Packages mirror. + + This is a composite action rather than a reusable workflow because nuget.org validates the OIDC + `job_workflow_ref` claim against the registered Trusted Publishing policy. Inside a reusable + workflow that claim names this repository (or any other called workflow), never the caller, so + the exchange fails with "No matching trust policy" — see NuGet/NuGetGallery#11000. A composite + action's steps run inside the caller's own job, which keeps the claim pointing at the caller's + workflow file. That is the file the policy must name. + + Callers grant `contents: write` to tag and create the release, `packages: write` for the GitHub + Packages mirror, and `id-token: write` for Trusted Publishing. A composite action cannot declare + permissions of its own. + +inputs: + build-version: + description: >- + Exact Purview.Build package version to run. When omitted, the latest stable version + from nuget.org is installed. + required: false + default: "" + dotnet-version: + description: >- + .NET SDK used by the consuming repository. Ignored when global.json pins an sdk.version, + because every dotnet command resolves against global.json and installing anything else + fails the job. + required: false + default: 10.0.x + release-mode: + description: Release__Mode passed to the pipeline (NuGet or GitHubRelease). + required: false + default: NuGet + config-path: + description: >- + Explicit purview-build.json location (PURVIEW_BUILD_CONFIG). When omitted, the tool uses + its documented probe order starting at the repository root. + required: false + default: "" + eligibility-policy: + description: >- + Release eligibility policy to evaluate (Release__Eligibility__Policy), e.g. + TrunkReservesMinor. When omitted, the repository's own configuration decides. + required: false + default: "" + release-channel: + description: Named release channel (Release__Channel), e.g. stable or preview. + required: false + default: "" + version-source: + description: Where the version comes from (Version__Source). Currently PackageJson. + required: false + default: "" + trusted-publishing: + description: >- + Use NuGet Trusted Publishing instead of a stored API key. The job exchanges a GitHub + OIDC token for a short-lived nuget.org key, so no long-lived secret is involved. The + caller must grant `id-token: write`, and nuget.org must have a Trusted Publishing policy + registered for this repository and this workflow file. + required: false + default: "false" + nuget-user: + description: >- + nuget.org profile name that owns the Trusted Publishing policy. This is the profile name, + not the sign-in email. Required when trusted-publishing is true. + required: false + default: "" + nuget-api-key: + description: >- + A long-lived nuget.org key for callers that have not migrated to Trusted Publishing. Ignored + outright when trusted-publishing is true, so a leftover secret cannot keep publishing the old + way while the configuration looks migrated. + required: false + default: "" + run-tests: + description: Enable discovered tests (Build__RunTests). + required: false + default: "true" + run-lint: + description: Enable CSharpier linting (Build__RunLint). + required: false + default: "true" + run-pack: + description: Enable packing (Build__RunPack). + required: false + default: "true" + validate-pack: + description: Enable pack validation (Build__ValidatePack). + required: false + default: "true" + test-filter: + description: Test filter (Build__TestFilter), e.g. "/*/*/*/*[Category=Unit]". + required: false + default: "" + upload-artifacts: + description: Upload artifacts folder files as GitHub release assets (Release__UploadArtifacts). + required: false + default: "false" + packages-read-token: + description: >- + Optional classic PAT with read:packages used to read the organisation GitHub Packages feed. + Defaults to the workflow token. A composite action has no `secrets` context, so a caller that + uses the organisation token has to pass it here. + required: false + default: "" + +runs: + using: composite + steps: + - name: Check out the repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + # Eligibility is judged against the tags that already exist, so the full tag list + # has to be present before the tool evaluates anything. + fetch-depth: 0 + fetch-tags: true + + # global.json is the single source of truth for the SDK whenever it pins one; see the matching + # step in purview-build.yml. When it pins nothing, the dotnet-version input applies. + - name: Resolve the .NET SDK version + id: resolve-sdk + shell: bash + # Caller input reaches the script through env, never by interpolating an expression into the script: the + # value is substituted into the script text before bash sees it, so a crafted input would + # run as shell. + env: + INPUT_DOTNET_VERSION: ${{ inputs.dotnet-version }} + run: | + set -euo pipefail + + pinned="" + if [ -f global.json ]; then + pinned="$(jq -r '.sdk.version // empty' global.json)" + fi + + if [ -n "$pinned" ]; then + echo "global.json pins $pinned; installing that." + echo "dotnet-version=$pinned" >> "$GITHUB_OUTPUT" + else + echo "global.json pins no sdk.version; using the dotnet-version input ($INPUT_DOTNET_VERSION)." + echo "dotnet-version=$INPUT_DOTNET_VERSION" >> "$GITHUB_OUTPUT" + fi + + - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6 + with: + dotnet-version: ${{ steps.resolve-sdk.outputs.dotnet-version }} + + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + + # Before the tool install, so a build-version that exists only on GitHub Packages is resolvable. + # A fork pull request cannot read the private feed (no organisation secrets), so leave it on + # NuGet.org rather than registering a source every restore would be denied. + - name: Configure NuGet sources + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork == false + uses: purview-dev/build/actions/nuget-sources@main + with: + namespace: ${{ github.repository_owner }} + token: ${{ inputs.packages-read-token || github.token }} + source-mapping: ${{ vars.PURVIEW_NUGET_SOURCE_MAPPING }} + + - name: Install shared build + shell: bash + env: + # Resolves the %GITHUB_TOKEN% macro when the organisation read token was not used. + GITHUB_TOKEN: ${{ github.token }} + INPUT_BUILD_VERSION: ${{ inputs.build-version }} + TOOL_PATH: ${{ runner.temp }}/purview-build + run: | + set -euo pipefail + + # Quoted and passed as distinct arguments, not word-split from a string. Leaving + # build-version empty installs whatever is latest on nuget.org at job time and runs it, + # so callers should pin it. + if [ -n "$INPUT_BUILD_VERSION" ]; then + dotnet tool install Purview.Build --tool-path "$TOOL_PATH" --version "$INPUT_BUILD_VERSION" + else + echo "::warning title=Unpinned build tool::No build-version was supplied, so the latest Purview.Build on nuget.org will be installed and executed. Pin build-version for a reproducible release." + dotnet tool install Purview.Build --tool-path "$TOOL_PATH" + fi + + # The version is reported, never decided: eligibility remains the tool's evaluation below. It is + # used only to protect the just-published version from the package cleanup. + - name: Read release version + id: version + shell: bash + run: | + version="" + if [ -f package.json ]; then + version="$(jq -r '.version // ""' package.json)" + fi + echo "version=$version" >> "$GITHUB_OUTPUT" + + - name: Evaluate release eligibility + id: eligibility + shell: bash + env: + # release-explain reports the decision the pipeline would take, so it needs the caller's mode. + Release__Mode: ${{ inputs.release-mode }} + run: | + set -euo pipefail + + # The tool evaluates; this action decides. release-explain runs no module, mutates + # nothing, and always exits 0 — the decision is in its JSON. + "${{ runner.temp }}/purview-build/purview-build" release-explain --format=json > explain.json + cat explain.json + + { + echo "verdict=$(jq -r '.verdict' explain.json)" + echo "release_mode=$(jq -r '.releaseMode' explain.json)" + echo "decided_by=$(jq -r '.decidedByRule // ""' explain.json)" + } >> "$GITHUB_OUTPUT" + + jq -r '.message' explain.json > eligibility-message.txt + + - name: Report ineligible release + if: steps.eligibility.outputs.verdict == 'Fail' + shell: bash + run: | + set -euo pipefail + + # Workflow commands are parsed out of the log line, so a message containing a newline + # and a `::` sequence would be interpreted as a command rather than printed. Escape the + # message to a single line before handing it over. + message="$(tr '\n' ' ' < eligibility-message.txt | sed 's/::/:﹕/g')" + echo "::error title=Release not eligible::${message}" + exit 1 + + - name: Skip release + if: steps.eligibility.outputs.verdict == 'Skip' + shell: bash + run: | + set -euo pipefail + message="$(tr '\n' ' ' < eligibility-message.txt | sed 's/::/:﹕/g')" + echo "::notice title=Release skipped::${message}" + + - name: Require a nuget.org profile for Trusted Publishing + if: steps.eligibility.outputs.verdict == 'Release' && inputs.trusted-publishing == 'true' && inputs.nuget-user == '' + shell: bash + run: | + echo "::error title=nuget-user is required::trusted-publishing is enabled, but no nuget-user was supplied. Pass the nuget.org profile name that owns the Trusted Publishing policy." + exit 1 + + # Immediately before the push: the minted key is valid for one hour, and each OIDC token can + # be exchanged exactly once. This step must live in the caller's job; a policy registered for + # the caller's workflow file will not match a key minted inside a called workflow. + - name: Exchange the OIDC token for a short-lived nuget.org key + id: nuget-login + if: steps.eligibility.outputs.verdict == 'Release' && inputs.trusted-publishing == 'true' + uses: NuGet/login@8d196754b4036150537f80ac539e15c2f1028841 # v1.2.0 + with: + user: ${{ inputs.nuget-user }} + + - name: Run release pipeline + if: steps.eligibility.outputs.verdict == 'Release' + shell: bash + env: + GITHUB_TOKEN: ${{ github.token }} + # The evaluated decision, not the raw input: a verdict that is not "Release" resolves to + # None, so the publish and release modules skip even if this step were reached. + 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__Owner: ${{ github.repository_owner }} + Build__RunTests: ${{ inputs.run-tests }} + Build__RunLint: ${{ inputs.run-lint }} + Build__RunPack: ${{ inputs.run-pack }} + Build__ValidatePack: ${{ inputs.validate-pack }} + # Optional settings are read from the environment and applied only when non-empty. Exporting + # an omitted input as the empty string would override the repository's purview-build.json, + # because environment variables outrank the JSON file. + INPUT_CONFIG_PATH: ${{ inputs.config-path }} + INPUT_ELIGIBILITY_POLICY: ${{ inputs.eligibility-policy }} + INPUT_RELEASE_CHANNEL: ${{ inputs.release-channel }} + INPUT_VERSION_SOURCE: ${{ inputs.version-source }} + INPUT_TEST_FILTER: ${{ inputs.test-filter }} + # Credentials are chosen below rather than in an expression. GitHub evaluates the empty + # string as falsy, so an and/or expression whose true-branch is `''` yields the fallback + # instead of the empty string — the leftover secret would silently keep publishing the old + # way. + INPUT_TRUSTED_PUBLISHING: ${{ inputs.trusted-publishing }} + INPUT_LEGACY_API_KEY: ${{ inputs.nuget-api-key }} + INPUT_MINTED_API_KEY: ${{ steps.nuget-login.outputs.NUGET_API_KEY }} + TOOL_PATH: ${{ runner.temp }}/purview-build + run: | + set -euo pipefail + + # Assigned from env rather than interpolated into the script: these come from the calling + # repository, and an inline expression would be substituted into the shell source text. + [ -z "$INPUT_CONFIG_PATH" ] || export PURVIEW_BUILD_CONFIG="$INPUT_CONFIG_PATH" + [ -z "$INPUT_ELIGIBILITY_POLICY" ] || export Release__Eligibility__Policy="$INPUT_ELIGIBILITY_POLICY" + [ -z "$INPUT_RELEASE_CHANNEL" ] || export Release__Channel="$INPUT_RELEASE_CHANNEL" + [ -z "$INPUT_VERSION_SOURCE" ] || export Version__Source="$INPUT_VERSION_SOURCE" + [ -z "$INPUT_TEST_FILTER" ] || export Build__TestFilter="$INPUT_TEST_FILTER" + + # Trusted Publishing wins outright when enabled. The tool prefers NUGET_APIKEY over + # NUGET_API_KEY, so a leftover long-lived secret would otherwise shadow the short-lived key + # and keep publishing the old way while the configuration looked migrated. + export NUGET_APIKEY="" + if [ "$INPUT_TRUSTED_PUBLISHING" = "true" ]; then + export NUGET_API_KEY="$INPUT_MINTED_API_KEY" + else + export NUGET_API_KEY="$INPUT_LEGACY_API_KEY" + fi + + "$TOOL_PATH/purview-build" + + # 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' + 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' }} + version: ${{ steps.version.outputs.version }} + + # A composite action cannot declare a post-step, so cleanup lives here and runs even when a + # step above failed. Hosted runners discard the user-level configuration with the VM; + # self-hosted runners do not. + - name: Remove temporary NuGet sources + if: always() + uses: purview-dev/build/actions/nuget-sources@main + with: + mode: remove diff --git a/docs/wiki/Architecture.md b/docs/wiki/Architecture.md index 19494b6..238aef4 100644 --- a/docs/wiki/Architecture.md +++ b/docs/wiki/Architecture.md @@ -9,7 +9,7 @@ The implementation is the generalized `PipelineCLI` that originated in `sourcege The repository additionally exposes: - a **composite action** (`.github/actions/purview-build`) that installs a pinned `Purview.Build` version and runs it, for repositories embedding the build in their own jobs, and -- two **reusable workflows** (`purview-build.yml`, `purview-release.yml`) that wrap that logic with structured inputs/secrets, reducing a consumer to one reusable-workflow job plus `purview-build.json`. +- a **composite action** for release (`actions/purview-release`) plus two **reusable workflows** (`purview-build.yml`, `purview-release.yml`) that wrap that logic with structured inputs/secrets, reducing a consumer to one `uses:` step plus `purview-build.json`. Release has to be the composite action: Trusted Publishing validates the caller's own workflow file, which a called workflow can never be. An MSBuild SDK remains a possible future companion for shared compile-time properties, analyzers, or package metadata. It should not own CI orchestration. diff --git a/docs/wiki/Configuration-Reference.md b/docs/wiki/Configuration-Reference.md index 4228bfe..509e3da 100644 --- a/docs/wiki/Configuration-Reference.md +++ b/docs/wiki/Configuration-Reference.md @@ -123,7 +123,7 @@ command line | --- | --- | | `-v`, `--version` | Print the tool version and exit without running the pipeline. | | `-h`, `--help`, `-?` | Print usage, options, the probe order, and the resolved configuration path (or "none found"), then exit. | -| `release-explain` | Print the release decision and exit without running any module. `--format=json` emits the stable contract the reusable workflow consumes. See [Local Development](Local-Development.md). | +| `release-explain` | Print the release decision and exit without running any module. `--format=json` emits the stable contract the release action consumes. See [Local Development](Local-Development.md). | Failures are reported the way a CLI build tool reports them: the tool prints the failing module and that module's output, then exits with code 1. Set `PURVIEW_BUILD_STACKTRACE=1` to add stack traces when diagnosing the tool itself. diff --git a/docs/wiki/Getting-Started.md b/docs/wiki/Getting-Started.md index bae33f4..f2ce541 100644 --- a/docs/wiki/Getting-Started.md +++ b/docs/wiki/Getting-Started.md @@ -1,6 +1,6 @@ # Getting Started -`Purview.Build` is consumed from a repository that owns its own configuration. The pipeline code lives here; consumers reference the composite action or one of the reusable workflows (or run the tool locally) and configure it with `purview-build.json`. +`Purview.Build` is consumed from a repository that owns its own configuration. The pipeline code lives here; consumers reference a composite action or one of the reusable workflows (or run the tool locally) and configure it with `purview-build.json`. Release uses the composite action, because Trusted Publishing validates the caller's own workflow file. ## Two independent version axes @@ -33,16 +33,27 @@ name: Release on: push: branches: [main] +permissions: + contents: write + packages: write + # nuget.org Trusted Publishing. GitHub withholds this scope by default, so the job must ask. + id-token: write concurrency: # Serialize releases; callers own concurrency (see Release Flow). group: release-${{ github.ref }} cancel-in-progress: false jobs: release: - uses: purview-dev/build/.github/workflows/purview-release.yml@main - with: - release-mode: NuGet - secrets: inherit + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + # A composite action, so the OIDC claim nuget.org validates names this workflow file — which is + # what the Trusted Publishing policy is registered against. A reusable workflow cannot do this. + - uses: purview-dev/build/actions/purview-release@main + with: + release-mode: NuGet + nuget-user: your-nuget-profile-name + packages-read-token: ${{ secrets.PURVIEW_PACKAGES_READ_TOKEN }} ``` For the **main-as-head / release-branch model**, point the release caller at the release branch instead: @@ -64,19 +75,21 @@ on: jobs: release: - uses: purview-dev/build/.github/workflows/purview-release.yml@main - with: - release-mode: NuGet - eligibility-policy: TrunkReservesMinor - secrets: inherit + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: purview-dev/build/actions/purview-release@main + with: + release-mode: NuGet + eligibility-policy: TrunkReservesMinor + nuget-user: your-nuget-profile-name ``` -The reusable release workflow asks the tool to evaluate eligibility and reads the verdict: an -already-released version skips (the job finishes green), and a policy violation fails the job naming -the rule. Merging `main` into `release` therefore releases exactly once. See -[Release Models](Release-Models.md). +The release action asks the tool to evaluate eligibility and reads the verdict: an already-released +version skips (the job finishes green), and a policy violation fails the job naming the rule. Merging +`main` into `release` therefore releases exactly once. See [Release Models](Release-Models.md). -The reusable workflows install the pinned CLI version (or the latest stable when `build-version` is omitted) from nuget.org; the organisation mirror is registered first, so a pinned version that NuGet.org has not indexed yet is installed from GitHub Packages. The consuming repository adds `purview-build.json` and a root `package.json` version. It does not need a copied pipeline project or package-source credentials. +The shared entry points install the pinned CLI version (or the latest stable when `build-version` is omitted) from nuget.org; the organisation mirror is registered first, so a pinned version that NuGet.org has not indexed yet is installed from GitHub Packages. The consuming repository adds `purview-build.json` and a root `package.json` version. It does not need a copied pipeline project or package-source credentials. ## Minimal repository setup (composite action) @@ -95,14 +108,15 @@ The action's `build-version` input is optional. When omitted, `dotnet tool insta ## GitHub Packages -Restoring from the organisation's GitHub Packages feed is **automatic**: the shared action and both -reusable workflows register it (authenticated with `GITHUB_TOKEN`) before the repository's first -restore, so nothing in the repository needs to change. +Restoring from the organisation's GitHub Packages feed is **automatic**: the shared entry points +register it (authenticated with `GITHUB_TOKEN`) before the repository's first restore, so nothing in +the repository needs to change. Publishing to it is **opt-in**, directly from the repository: -1. Add `permissions: { contents: write, packages: write }` to `.github/workflows/release.yml` - (a reusable workflow cannot elevate its caller, and declares no permissions of its own). +1. Add `permissions: { contents: write, packages: write, id-token: write }` to + `.github/workflows/release.yml` (neither a reusable workflow nor a composite action can elevate + its caller, and neither declares permissions of its own). 2. Set the repository variable `PURVIEW_PUBLISH_GITHUB_PACKAGES=true`. The next eligible release then pushes the same packages to NuGet.org and GitHub Packages. No diff --git a/docs/wiki/Home.md b/docs/wiki/Home.md index 8e6f9c3..dc95523 100644 --- a/docs/wiki/Home.md +++ b/docs/wiki/Home.md @@ -9,8 +9,13 @@ Consuming repositories own configuration (`purview-build.json`); they do not own The same implementation is available three ways: 1. **`Purview.Build` dotnet tool** — NuGet package published to nuget.org. Run anywhere a .NET SDK exists (locally via `just`, in GitHub Actions, or another CI service). -2. **Composite action** `purview-dev/build/.github/actions/purview-build` — for repositories that want the action embedded directly in one of their own jobs. -3. **Reusable workflows** `purview-dev/build/.github/workflows/purview-build.yml` and `.../purview-release.yml` — thin `workflow_call` wrappers with structured inputs/secrets. +2. **Composite actions** `purview-dev/build/.github/actions/purview-build` (build) and + `purview-dev/build/actions/purview-release` (release) — for repositories that want the pipeline + embedded directly in one of their own jobs. Release must use the action: Trusted Publishing + validates the caller's own workflow file. +3. **Reusable workflows** `purview-dev/build/.github/workflows/purview-build.yml` and + `.../purview-release.yml` — thin `workflow_call` wrappers with structured inputs/secrets. The + release workflow forwards to the action and cannot use Trusted Publishing. ## Start here diff --git a/docs/wiki/Migration-aspire-resourcekit.md b/docs/wiki/Migration-aspire-resourcekit.md index 3402c74..2e94649 100644 --- a/docs/wiki/Migration-aspire-resourcekit.md +++ b/docs/wiki/Migration-aspire-resourcekit.md @@ -17,8 +17,8 @@ This repository currently contains a vendored copy of `build/PipelineCLI`. Migra } ``` -3. Replace `pr.yml` and `release.yml` with thin callers of `purview-dev/build/.github/workflows/purview-build.yml` and `.../purview-release.yml`, passing `build-version`. Keep the `[Category=Unit]` filter in the release caller. -4. In the release caller set `release-mode: NuGet` and `secrets: inherit` (`NUGET_APIKEY` and `GITHUB_TOKEN` are read by the shared workflow). +3. Replace `pr.yml` and `release.yml` with thin callers of `purview-dev/build/.github/workflows/purview-build.yml` and `purview-dev/build/actions/purview-release` (the release entry point is a composite action), passing `build-version`. Keep the `[Category=Unit]` filter in the release caller. +4. In the release caller set `release-mode: NuGet`, grant `id-token: write`, and pass `trusted-publishing: true` plus `nuget-user`; no stored NuGet key is involved. 5. Run the PR pipeline, then delete `build/PipelineCLI` and its pipeline-only central package declarations (`ModularPipelines*`, `NuGet.Packaging/Versioning`). The old solution path and unit-test filter are preserved exactly. Other repositories migrate by changing only the JSON paths/patterns; for example `build-sdk` can list unit and integration project globs in `Build:TestPatterns`. \ No newline at end of file diff --git a/docs/wiki/Pipeline-Modules.md b/docs/wiki/Pipeline-Modules.md index 8b897ee..9b92d4f 100644 --- a/docs/wiki/Pipeline-Modules.md +++ b/docs/wiki/Pipeline-Modules.md @@ -93,7 +93,7 @@ Category `Release`. Depends on `PackModule`, `ValidatePackModule`, and `RunTests `Release:Publish` is derived from `Release:Mode` unless set explicitly, so the default skip behaviour is unchanged: it publishes when `Release:Mode=NuGet`. -Pushes every `*.nupkg` in `Build:ArtifactsFolder` with `--skip-duplicate`, to the channel's `FeedUrl` when `Release:Channel` declares one and `NuGet:FeedUrl` otherwise. When `NuGet:TrustedPublishing` is true, pushes without an API key (NuGet Trusted Publishing / OIDC federation). When `Release:DryRun` is true, logs the push it would have made and performs none. +Pushes every `*.nupkg` in `Build:ArtifactsFolder` with `--skip-duplicate`, to the channel's `FeedUrl` when `Release:Channel` declares one and `NuGet:FeedUrl` otherwise. When `NuGet:TrustedPublishing` is true the push uses the short-lived key that `NuGet/login` minted from the job's OIDC token; if none reached the pipeline the module fails with a message pointing at the login step rather than pushing unauthenticated. When `Release:DryRun` is true, logs the push it would have made and performs none. ## PublishGitHubPackagesModule diff --git a/docs/wiki/Release-Flow.md b/docs/wiki/Release-Flow.md index 8b19c64..5a625b6 100644 --- a/docs/wiki/Release-Flow.md +++ b/docs/wiki/Release-Flow.md @@ -75,7 +75,7 @@ The `release-branch` input is retained for backward compatibility only. This repository dogfoods the shared tool. CI performs restore, warnings-as-errors compilation, packing, installation from the generated package, then runs `purview-build` against this repository so the project builds and packs itself. See [Repository CI/CD](Repository-CI-CD.md). -On a push to `main`, the release workflow reads and validates the `package.json` version, skips when `v{version}` already exists, then builds and installs the tool from the current source and runs it with `Release__Mode=NuGet`, `NuGet__FeedUrl` set to nuget.org, and `Release__UploadArtifacts=true`. The tool performs the release build/pack steps, publishes the immutable package to `https://api.nuget.org/v3/index.json` using the `NUGET_APIKEY` secret, and creates `v{version}` plus a generated-notes GitHub release with the package attached — tagging itself exactly like every other purview-dev repository. When this repository's `PURVIEW_PUBLISH_GITHUB_PACKAGES` variable is `true`, the same packages also go to GitHub Packages, and older mirror versions are pruned afterwards. The tool therefore owns tagging; maintainers must not push release tags manually. +On a push to `main`, the release workflow reads and validates the `package.json` version, skips when `v{version}` already exists, then builds and installs the tool from the current source and runs it with `Release__Mode=NuGet`, `NuGet__FeedUrl` set to nuget.org, and `Release__UploadArtifacts=true`. The tool performs the release build/pack steps, publishes the immutable package to `https://api.nuget.org/v3/index.json` using a short-lived key minted from the job's GitHub OIDC token (Trusted Publishing — no long-lived publishing secret exists), and creates `v{version}` plus a generated-notes GitHub release with the package attached — tagging itself exactly like every other purview-dev repository. When this repository's `PURVIEW_PUBLISH_GITHUB_PACKAGES` variable is `true`, the same packages also go to GitHub Packages, and older mirror versions are pruned afterwards. The tool therefore owns tagging; maintainers must not push release tags manually. ## GitHub Packages @@ -102,16 +102,19 @@ PURVIEW_PUBLISH_GITHUB_PACKAGES=true To opt a repository in: -1. Grant the caller's release job the permission the publish step needs. Reusable workflows cannot - elevate a caller and `purview-release.yml` declares no permissions of its own, so add to the - repository's `.github/workflows/release.yml`: +1. Grant the caller's release job the permission the publish step needs. Neither a reusable workflow + nor a composite action can elevate a caller, and neither declares permissions of its own, so add to + the repository's `.github/workflows/release.yml`: ```yaml permissions: contents: write packages: write + id-token: write ``` + `id-token: write` is what the Trusted Publishing exchange needs; GitHub withholds it by default. + 2. Create the repository variable (Settings → Secrets and variables → Actions → Variables): `PURVIEW_PUBLISH_GITHUB_PACKAGES = true`. diff --git a/docs/wiki/Release-Models.md b/docs/wiki/Release-Models.md index d6e8bfa..0cfbafe 100644 --- a/docs/wiki/Release-Models.md +++ b/docs/wiki/Release-Models.md @@ -108,17 +108,25 @@ on: workflow_dispatch: concurrency: - # Callers own release serialization; the reusable workflow defines no concurrency group. + # Callers own release serialization; the shared entry points define no concurrency group. group: release-${{ github.ref }} cancel-in-progress: false jobs: release: - uses: purview-dev/build/.github/workflows/purview-release.yml@main - with: - release-mode: NuGet - eligibility-policy: TrunkReservesMinor - secrets: inherit + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: write + packages: write + id-token: write + steps: + - uses: purview-dev/build/actions/purview-release@main + with: + release-mode: NuGet + eligibility-policy: TrunkReservesMinor + trusted-publishing: true + nuget-user: your-nuget-profile-name ``` ## Rule evaluation diff --git a/docs/wiki/Repository-CI-CD.md b/docs/wiki/Repository-CI-CD.md index f63f209..c935480 100644 --- a/docs/wiki/Repository-CI-CD.md +++ b/docs/wiki/Repository-CI-CD.md @@ -32,16 +32,16 @@ Runs on push to `main` and is serialized by its own `concurrency` group (`purvie 2. Configure NuGet sources with the shared `./actions/nuget-sources` action (nuget.org + the organisation feed). 3. Restore and build `src/Build.slnx` with `--warnaserror`. 4. Pack the tool from source with `-p:ContinuousIntegrationBuild=true`. -5. Verify the `NUGET__APIKEY` secret is set. -6. Install the packed tool. -7. **Evaluate eligibility** by running the freshly installed `purview-build release-explain --format=json` and reading the verdict. `Skip` finishes the job green, `Fail` fails it with the rule's message, and `Release` continues. -8. **Run the release pipeline** with `Release__Mode` taken from the evaluated decision, plus `NuGet__FeedUrl=https://api.nuget.org/v3/index.json`, `Release__UploadArtifacts=true`, `Build__RunTests=false`, `Build__RunLint=false`, and `Build__ValidatePack=true`, passing `GITHUB_TOKEN` and `NUGET_APIKEY`. The workflow maps `PURVIEW_PUBLISH_GITHUB_PACKAGES` onto `NuGet__GitHubPackages__Enabled` and passes the repository owner, so setting the variable makes this repository dual-publish; leaving it unset keeps NuGet.org-only publication. +5. Install the packed tool. +6. **Evaluate eligibility** by running the freshly installed `purview-build release-explain --format=json` and reading the verdict. `Skip` finishes the job green, `Fail` fails it with the rule's message, and `Release` continues. +7. **Exchange the OIDC token for a short-lived nuget.org key** (`NuGet/login`), immediately before the push: the key lives for one hour and each token can be exchanged once. +8. **Run the release pipeline** with `Release__Mode` taken from the evaluated decision, plus `NuGet__FeedUrl=https://api.nuget.org/v3/index.json`, `Release__UploadArtifacts=true`, `Build__RunTests=false`, `Build__RunLint=false`, `Build__ValidatePack=true`, `NuGet__TrustedPublishing=true`, and `GITHUB_TOKEN`. The minted key arrives as `NUGET_API_KEY`, and `NUGET_APIKEY` is blanked so a leftover secret can never shadow it. The workflow maps `PURVIEW_PUBLISH_GITHUB_PACKAGES` onto `NuGet__GitHubPackages__Enabled` and passes the repository owner, so setting the variable makes this repository dual-publish; leaving it unset keeps NuGet.org-only publication. 9. **Prune older GitHub Packages versions** when the variable is set: keep the newest `PURVIEW_GITHUB_PACKAGES_KEEP_VERSIONS` (default 1), never the version just released. Best effort — NuGet.org keeps every version. 10. Remove the temporary NuGet sources (`if: always()`). -The workflow declares `contents: write` and `packages: write`. This repository dogfoods the eligibility evaluation as well as the pipeline: the `git rev-parse` tag check that used to live in this workflow is gone, replaced by the tool's own rules. +The workflow declares `contents: write`, `packages: write`, and `id-token: write` — the last is what the OIDC exchange needs, and GitHub withholds it by default. This repository dogfoods the eligibility evaluation as well as the pipeline: the `git rev-parse` tag check that used to live in this workflow is gone, replaced by the tool's own rules. The tool therefore publishes the immutable package to nuget.org (and, when opted in, GitHub Packages) and tags and releases itself (`v{version}` + generated-notes GitHub release with the package attached) — exactly like every other purview-dev repository. Maintainers bump the `package.json` version and merge; they do not create release tags manually. diff --git a/docs/wiki/Secrets-and-Environment-Variables.md b/docs/wiki/Secrets-and-Environment-Variables.md index 7af29b9..c7ae42a 100644 --- a/docs/wiki/Secrets-and-Environment-Variables.md +++ b/docs/wiki/Secrets-and-Environment-Variables.md @@ -10,12 +10,18 @@ Command line > environment variables > `purview-build.json` > baked-in defaults. | Secret | Where it is used | Environment-var bound alias | | --- | --- | --- | -| `NUGET_APIKEY` | NuGet push | `NuGet__NUGET_APIKEY` (binds `EnvAPIKey`); also read directly from process env `NUGET_APIKEY`/`NUGET_API_KEY` | -| `NuGet__APIKey` | NuGet push | `APIKey`. Configuration keys are case-insensitive, so the historical `NUGET__APIKEY` secret name binds here too | +| `NUGET_APIKEY` | NuGet push (legacy path) | `NuGet__NUGET_APIKEY` (binds `EnvAPIKey`); also read directly from process env `NUGET_APIKEY`/`NUGET_API_KEY` | +| `NuGet__APIKey` | NuGet push (legacy path) | `APIKey`. Configuration keys are case-insensitive, so the historical `NUGET__APIKEY` secret name binds here too | | `GITHUB_TOKEN` | GitHub release creation; GitHub Packages push | `GitHub__GITHUB_TOKEN` (binds `EnvAccessToken`); also read directly from process env `GITHUB_TOKEN` | | `LOCAL_NUGET_FEED_PATH` | Local NuGet publishing | `PublishLocalNuGet__LOCAL_NUGET_FEED_PATH` (binds `EnvLocalFeedPath`); also read directly from process env `LOCAL_NUGET_FEED_PATH` | | `PURVIEW_PACKAGES_READ_TOKEN` | Restoring from GitHub Packages across repositories | Optional organisation secret; a classic PAT with `read:packages`. Prefer it over per-package Actions-access grants. Never used for publishing. | +NuGet.org publication no longer needs a stored secret. The release action exchanges the job's GitHub +OIDC token for a nuget.org key that lives for one hour, arrives as `NUGET_API_KEY`, and is used exactly +like a stored key. No `NUGET_APIKEY` or `NUGET__APIKEY` secret should remain once every repository has +migrated; while `NuGet:TrustedPublishing` is true the action blanks `NUGET_APIKEY`, so a leftover secret +cannot shadow the minted one. + GitHub Packages uses the same `GITHUB_TOKEN`, not a separate PAT. The token needs `packages: write` to publish and `packages: read` to restore. The shared source configuration stores only the `%GITHUB_TOKEN%` macro in the user-level NuGet configuration, so no token is written to disk. @@ -30,6 +36,7 @@ Credentials and paths are checked by the startup preflight, before the pipeline | --- | --- | --- | | `LOCAL_NUGET_FEED_PATH` (or `PublishLocalNuGet:LocalFeedPath`) | `Release:Mode=LocalNuGet`, running locally | Set an absolute feed path | | `NUGET_APIKEY` / `NUGET_API_KEY` (or `NuGet:APIKey`) | `Release:Publish` is enabled and `NuGet:TrustedPublishing` is false | Provide the key, or opt into Trusted Publishing | +| `id-token: write` on the job | `NuGet:TrustedPublishing` is true | Grant it in the calling workflow; GitHub withholds the scope by default | | `GITHUB_TOKEN` | a real (non-dry-run) GitHub release is requested, or `NuGet:GitHubPackages:Enabled=true` and publication is requested | Provide a token that can create releases / write packages | | `GITHUB_REPOSITORY_ID` | a real (non-dry-run) GitHub release is requested | Supplied automatically by GitHub Actions; set it for a local run | | `NuGet:GitHubPackages:FeedUrl` | `NuGet:GitHubPackages:Enabled=true` and publication is requested | Must be an absolute `https` URL. Leave it unset: it derives from `GitHubPackages:Owner`, `GITHUB_REPOSITORY_OWNER`, then `purview-dev` | @@ -38,11 +45,11 @@ Credentials and paths are checked by the startup preflight, before the pipeline ## Optional-input forwarding -The reusable workflows (`purview-build.yml`, `purview-release.yml`) forward every **optional** input only when it is non-empty: `test-filter`, `test-projects`, `config-path`, `eligibility-policy`, `release-channel`, and `version-source`. An empty forwarded value would override a consuming repository's `purview-build.json` (env vars take precedence over JSON) and silently erase a configured value — see commit `4d72bf7`. +The shared entry points forward every **optional** input only when it is non-empty: `test-filter`, `test-projects`, `config-path`, `eligibility-policy`, `release-channel`, and `version-source`. An empty forwarded value would override a consuming repository's `purview-build.json` (env vars take precedence over JSON) and silently erase a configured value — see commit `4d72bf7`. Inputs that declare a `default:` (for example `release-mode`, `run-tests`) are never empty and are mapped directly into `env:`. -This is enforced by a test (`WorkflowParityTests`), which asserts that every `Section__Key` the workflows set resolves to a real settings property, and that no optional input is mapped straight into `env:` without an `if [ -n … ]` guard. +This is enforced by a test (`WorkflowParityTests`), which asserts that every `Section__Key` the entry points set resolves to a real settings property, and that no optional input reaches `env:` without an `if [ -n … ]` guard. The same rule is checked separately for `actions/purview-release`, whose inputs are declared at a different indentation and so are invisible to the workflow-shaped regexes. ## Configuration and diagnostics variables @@ -52,9 +59,9 @@ This is enforced by a test (`WorkflowParityTests`), which asserts that every `Se | `PURVIEW_BUILD_USER_CONFIG` | Set to `1` (or `true`) to also read machine-local user configuration. Ignored when not running locally. | | `PURVIEW_BUILD_STACKTRACE` | Set to `1` (or `true`) to include stack traces in failure reports. Unset, a failing run prints only the failing module and that module's output, then exits with code 1. | | `MODULAR_PIPELINES_DIRECTORY` | The directory containing the tool's shipped `appsettings.json`. Unrelated to `purview-build.json` discovery. | -| `PURVIEW_PUBLISH_GITHUB_PACKAGES` | Repository Actions **variable**, read only by the release workflows: `true` adds GitHub Packages as a publication destination. Absent or `false` means NuGet.org only. | -| `PURVIEW_GITHUB_PACKAGES_KEEP_VERSIONS` | Repository Actions variable read by the release workflows: the newest N versions of each package are always kept by the mirror cleanup (default `1`; `0` removes the floor). | -| `PURVIEW_GITHUB_PACKAGES_RETENTION_HOURS` | Repository Actions variable read by the release workflows: how long a mirrored version survives after publication (default `24`; `1` for an hourly window; `0` prunes by count only). | +| `PURVIEW_PUBLISH_GITHUB_PACKAGES` | Repository Actions **variable**, read only by the release entry points: `true` adds GitHub Packages as a publication destination. Absent or `false` means NuGet.org only. | +| `PURVIEW_GITHUB_PACKAGES_KEEP_VERSIONS` | Repository Actions variable read by the release entry points: the newest N versions of each package are always kept by the mirror cleanup (default `1`; `0` removes the floor). | +| `PURVIEW_GITHUB_PACKAGES_RETENTION_HOURS` | Repository Actions variable read by the release entry points: how long a mirrored version survives after publication (default `24`; `1` for an hourly window; `0` prunes by count only). | | `PURVIEW_NUGET_SOURCE_MAPPING` | Repository Actions variable read by the source setup: semicolon-separated package ID patterns pinned **only** to GitHub Packages. Mapping is always written (`*` to both sources by default); setting this also clears the HTTP cache. | | `GITHUB_REF` | Read by the eligibility rules as the evaluated ref, unless `Release:Context:Ref` overrides it. | diff --git a/global.json b/global.json index 025f0be..fcc01c7 100644 --- a/global.json +++ b/global.json @@ -1,6 +1,6 @@ { "msbuild-sdks": { - "Purview.BuildSdk": "1.1.0" + "Purview.BuildSdk": "1.2.0" }, "test": { "runner": "Microsoft.Testing.Platform" diff --git a/package.json b/package.json index 9d3d019..c8452c9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "build", - "version": "0.8.0", + "version": "0.8.1", "description": "Shared build/test/release pipeline for the purview-dev organisation, packaged as a pinned .NET tool with a GitHub composite action and reusable workflows.", "license": "MIT", "author": { diff --git a/skills/purview-build-adoption/SKILL.md b/skills/purview-build-adoption/SKILL.md index 3530561..ff5ce0e 100644 --- a/skills/purview-build-adoption/SKILL.md +++ b/skills/purview-build-adoption/SKILL.md @@ -2,8 +2,9 @@ name: purview-build-adoption description: >- Use when wiring Purview.Build into a repository - choosing the reusable workflow or the composite - action, pinning the workflow ref versus the tool version, running the pipeline locally as a dotnet - tool, and what a consuming repository has to add before anything will run. + action, pinning the workflow ref versus the tool version, authenticating nuget.org publication with + Trusted Publishing, running the pipeline locally as a dotnet tool, and what a consuming repository + has to add before anything will run. metadata: product: Purview.Build repository: purview-dev/build @@ -31,7 +32,7 @@ Get this wrong and you run new tool code through old workflow inputs. Pin both, or neither. Pinning one and floating the other is the mismatch. -## Reusable workflow — the usual choice +## Build — the reusable workflow ```yaml # .github/workflows/pr.yml @@ -45,24 +46,45 @@ jobs: secrets: inherit ``` +## Release — the composite action + +Release runs as a **composite action**, not a reusable workflow. nuget.org validates the OIDC +`job_workflow_ref` claim against the Trusted Publishing policy, and inside a called workflow that +claim names the called repository — never yours — so the exchange fails with *"No matching trust +policy"* (`NuGet/NuGetGallery#11000`). A composite action's steps run in **your** job, which keeps the +claim on your own `release.yml`, the file the policy names. + ```yaml # .github/workflows/release.yml name: Release on: push: branches: [main] +permissions: + contents: write # tag and GitHub release + packages: write # GitHub Packages mirror, when the variable opts in + id-token: write # nuget.org Trusted Publishing — GitHub withholds this by default concurrency: - # Callers own release serialisation; the reusable workflow defines no concurrency group. group: release-${{ github.ref }} cancel-in-progress: false jobs: release: - uses: purview-dev/build/.github/workflows/purview-release.yml@main - with: - release-mode: NuGet - secrets: inherit + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: purview-dev/build/actions/purview-release@main + with: + release-mode: NuGet + nuget-user: your-nuget-profile-name + # A composite action has no `secrets` context, so credentials are passed explicitly. + packages-read-token: ${{ secrets.PURVIEW_PACKAGES_READ_TOKEN }} ``` +The action checks out the repository with full tags, installs the tool, asks the tool to evaluate +eligibility, exchanges a short-lived nuget.org key when `trusted-publishing: true`, runs the +pipeline, and prunes the mirror. Pass `trusted-publishing: true` alongside `nuget-user`; the default +is the legacy API-key path. + The release caller's **trigger** is what picks your release model: | Model | Trigger | Policy | @@ -74,16 +96,24 @@ The release caller's **trigger** is what picks your release model: ```yaml jobs: release: - uses: purview-dev/build/.github/workflows/purview-release.yml@main - with: - release-mode: NuGet - eligibility-policy: TrunkReservesMinor - secrets: inherit + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: purview-dev/build/actions/purview-release@main + with: + release-mode: NuGet + eligibility-policy: TrunkReservesMinor + trusted-publishing: true + nuget-user: your-nuget-profile-name ``` -The reusable release workflow asks the tool to evaluate eligibility and reads the verdict: an -already-released version **skips** and the job finishes green; a policy violation **fails** the job -naming the rule. Merging `main` into `release` therefore releases exactly once. +The release action reads the tool's eligibility verdict: an already-released version **skips** and the +job finishes green; a policy violation **fails** the job naming the rule. Merging `main` into +`release` therefore releases exactly once. + +`purview-release.yml` still exists and forwards to the same action, so an older caller keeps working. +It cannot do Trusted Publishing and no longer accepts `trusted-publishing` or `nuget-user` — passing +them fails validation rather than 401ing mid-run. ## Composite action — when you need surrounding steps @@ -111,7 +141,8 @@ Two different defaults, and the distinction matters: | Files to change | none | `release.yml` permissions only | | Local development | unchanged | unchanged unless you opt in explicitly | -Consumption needs nothing. The shared action and both reusable workflows register +Consumption needs nothing. The shared entry points — `purview-build.yml`, `purview-release.yml`, and +both composite actions — register `https://api.nuget.org/v3/index.json` **and** `https://nuget.pkg.github.com//index.json` in the runner's user-level NuGet configuration, authenticated with `GITHUB_TOKEN` via a `%GITHUB_TOKEN%` macro, before the repository's first restore. @@ -119,20 +150,21 @@ A package that only exists on NuGet.org keeps restoring: both sources stay enabl **If you want every repository to read every package without per-package grants** (Internal does not do that on a Team plan), add one organisation secret, `PURVIEW_PACKAGES_READ_TOKEN`, a classic PAT with -`read:packages`. The shared workflows use it for the feed when present and fall back to `GITHUB_TOKEN`; -it never publishes. Callers already use `secrets: inherit`; the composite action takes it as +`read:packages`. The shared entry points use it for the feed when present and fall back to `GITHUB_TOKEN`; +it never publishes. A composite action has no `secrets` context, so it takes the token as `packages-read-token`. Visibility is independent: new packages are private, the token reads them, and changing a package to Internal is a per-package UI action (no REST endpoint). Publication is additive — the same packed artifacts go to both feeds. To opt a repository in: -1. In `.github/workflows/release.yml`, grant what the publish step needs (a reusable workflow - cannot elevate its caller, and `purview-release.yml` declares no permissions of its own): +1. In `.github/workflows/release.yml`, grant what the publish step needs (neither a reusable workflow + nor a composite action can elevate its caller, and neither declares permissions of its own): ```yaml permissions: contents: write packages: write + id-token: write ``` 2. Create the repository variable (Settings → Secrets and variables → Actions → Variables): diff --git a/skills/purview-build-configuration/SKILL.md b/skills/purview-build-configuration/SKILL.md index 01db569..d5536c0 100644 --- a/skills/purview-build-configuration/SKILL.md +++ b/skills/purview-build-configuration/SKILL.md @@ -174,7 +174,7 @@ fails immediately instead of skipping the publish or release module after the bu | --- | --- | | `-v`, `--version` | print the tool version and exit | | `-h`, `--help`, `-?` | usage, options, the probe order, and the **resolved configuration path** (or "none found") | -| `release-explain` | print the release decision and exit, running no module. `--format=json` emits the stable contract the reusable workflow consumes | +| `release-explain` | print the release decision and exit, running no module. `--format=json` emits the stable contract the release action consumes | `--help` printing the resolved path is the fastest answer to "which config is it actually reading?". diff --git a/skills/purview-build-pipeline/SKILL.md b/skills/purview-build-pipeline/SKILL.md index 3513e2e..bc98a4d 100644 --- a/skills/purview-build-pipeline/SKILL.md +++ b/skills/purview-build-pipeline/SKILL.md @@ -145,8 +145,10 @@ Credentials are a startup concern: with publishing requested and `Release:DryRun NuGet API key (when `NuGet:TrustedPublishing` is false) fails the run before any module. Pushes every `*.nupkg` with `--skip-duplicate`, to the channel's `FeedUrl` when `Release:Channel` -declares one and `NuGet:FeedUrl` otherwise. With `TrustedPublishing`, pushes without an API key. -With `Release:DryRun`, logs the push and performs none. +declares one and `NuGet:FeedUrl` otherwise. With `TrustedPublishing`, pushes with the short-lived key +`NuGet/login` minted from the job's OIDC token; if no key reached the pipeline it fails with a message +pointing at the login step rather than pushing unauthenticated. With `Release:DryRun`, logs the push +and performs none. ## GitHub Packages diff --git a/skills/purview-build-release-eligibility/SKILL.md b/skills/purview-build-release-eligibility/SKILL.md index 549adf5..c6783b7 100644 --- a/skills/purview-build-release-eligibility/SKILL.md +++ b/skills/purview-build-release-eligibility/SKILL.md @@ -30,7 +30,7 @@ Verdicts: **Release** (exit 0), **Skip** (exit 0 — the job finishes green, not ```shell dotnet purview-build release-explain -dotnet purview-build release-explain --format=json # the contract the reusable workflow consumes +dotnet purview-build release-explain --format=json # the contract the release action consumes ``` ## The rules @@ -103,17 +103,25 @@ on: workflow_dispatch: concurrency: - # Callers own release serialisation; the reusable workflow defines no concurrency group. + # Callers own release serialisation; the shared entry points define no concurrency group. group: release-${{ github.ref }} cancel-in-progress: false jobs: release: - uses: purview-dev/build/.github/workflows/purview-release.yml@main - with: - release-mode: NuGet - eligibility-policy: TrunkReservesMinor - secrets: inherit + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: write + packages: write + id-token: write + steps: + - uses: purview-dev/build/actions/purview-release@main + with: + release-mode: NuGet + eligibility-policy: TrunkReservesMinor + trusted-publishing: true + nuget-user: your-nuget-profile-name ``` ## Defining a policy diff --git a/src/tests/Build.UnitTests/NuGetSourceSetupTests.cs b/src/tests/Build.UnitTests/NuGetSourceSetupTests.cs index a3d6891..699ad18 100644 --- a/src/tests/Build.UnitTests/NuGetSourceSetupTests.cs +++ b/src/tests/Build.UnitTests/NuGetSourceSetupTests.cs @@ -17,6 +17,10 @@ public class NuGetSourceSetupTests const string PruneScript = "actions/prune-github-packages/prune.sh"; const string BuildWorkflow = ".github/workflows/purview-build.yml"; const string ReleaseWorkflow = ".github/workflows/purview-release.yml"; + + // The release implementation moved out of the reusable workflow: Trusted Publishing cannot work + // from a called workflow, so the steps live in a composite action that runs in the caller's job. + const string ReleaseAction = "actions/purview-release/action.yml"; const string SelfHostedBuild = ".github/workflows/ci.yml"; const string SelfHostedRelease = ".github/workflows/release.yml"; const string CompositeAction = ".github/actions/purview-build/action.yml"; @@ -60,7 +64,7 @@ public async Task SourceConfiguration_RunsBeforeTheToolInstall() // A build-version pinned to a version only GitHub Packages has (NuGet.org not indexed yet) is // resolved by `dotnet tool install`, so the feed must exist before that step. var build = Read(BuildWorkflow); - var release = Read(ReleaseWorkflow); + var release = Read(ReleaseAction); var composite = Read(CompositeAction); // Act & Assert @@ -68,7 +72,7 @@ public async Task SourceConfiguration_RunsBeforeTheToolInstall() var (name, content) in new[] { (BuildWorkflow, build), - (ReleaseWorkflow, release), + (ReleaseAction, release), (CompositeAction, composite), } ) @@ -90,7 +94,8 @@ public async Task SourceAction_UsesAnOrganisationReadTokenWhenProvided() var action = Read(SourceAction); var script = Read(SourceScript); var build = Read(BuildWorkflow); - var release = Read(ReleaseWorkflow); + var release = Read(ReleaseAction); + var releaseWorkflow = Read(ReleaseWorkflow); var composite = Read(CompositeAction); // Act & Assert @@ -101,7 +106,11 @@ await Assert await Assert.That(script).Contains("PURVIEW_NUGET_TOKEN=$TOKEN"); await Assert.That(script).Contains("PURVIEW_NUGET_TOKEN="); await Assert.That(build).Contains("secrets.PURVIEW_PACKAGES_READ_TOKEN || github.token"); - await Assert.That(release).Contains("secrets.PURVIEW_PACKAGES_READ_TOKEN || github.token"); + await Assert + .That(releaseWorkflow) + .Contains("PURVIEW_PACKAGES_READ_TOKEN") + .Because("a composite action has no secrets context, so the delegator has to hand the token over."); + await Assert.That(release).Contains("inputs.packages-read-token || github.token"); await Assert.That(composite).Contains("packages-read-token"); await Assert.That(composite).Contains("inputs.packages-read-token || github.token"); } @@ -161,7 +170,7 @@ public async Task EveryEntryPoint_UsesTheSameSourceAction() { // Arrange var build = Read(BuildWorkflow); - var release = Read(ReleaseWorkflow); + var release = Read(ReleaseAction); var selfBuild = Read(SelfHostedBuild); var selfRelease = Read(SelfHostedRelease); var composite = Read(CompositeAction); @@ -182,7 +191,7 @@ public async Task SourceConfiguration_RunsBeforeTheRepositoryRestore() { // Arrange var build = Read(BuildWorkflow); - var release = Read(ReleaseWorkflow); + var release = Read(ReleaseAction); var selfBuild = Read(SelfHostedBuild); var selfRelease = Read(SelfHostedRelease); @@ -191,7 +200,7 @@ public async Task SourceConfiguration_RunsBeforeTheRepositoryRestore() var (name, content, later) in new[] { (BuildWorkflow, build, "Run shared build"), - (ReleaseWorkflow, release, "Run release pipeline"), + (ReleaseAction, release, "Run release pipeline"), (SelfHostedBuild, selfBuild, "Restore"), (SelfHostedRelease, selfRelease, "Restore"), } @@ -208,7 +217,7 @@ await Assert public async Task PublishOptIn_IsEvaluatedOnlyWhereAReleaseCanHappen() { // Arrange - var release = Read(ReleaseWorkflow); + var release = Read(ReleaseAction); var selfRelease = Read(SelfHostedRelease); var build = Read(BuildWorkflow); var selfBuild = Read(SelfHostedBuild); @@ -229,15 +238,24 @@ await Assert } [Test] - public async Task ReleaseWorkflows_GrantPackageWrite() + public async Task ReleaseEntryPoints_LeaveTheTokenToTheCaller() { // Arrange + // Neither a reusable workflow nor a composite action can grant a scope. Trusted Publishing + // needs `id-token: write`, which GitHub withholds by default, so the calling job must ask for + // it. A block here would only add a ceiling validated against the caller. var release = Read(ReleaseWorkflow); + var releaseAction = Read(ReleaseAction); var selfRelease = Read(SelfHostedRelease); // Act & Assert - await Assert.That(release).Contains("packages: write"); + await Assert.That(release).DoesNotContain("packages: write"); + await Assert.That(releaseAction).DoesNotContain("permissions:"); await Assert.That(selfRelease).Contains("packages: write"); + await Assert + .That(selfRelease) + .Contains("id-token: write") + .Because("this repository's own release mints its nuget.org key from the OIDC token."); } [Test] @@ -258,7 +276,7 @@ public async Task BuildWorkflows_GrantOnlyPackageRead() public async Task Prune_OnlyRunsAfterASuccessfulReleaseWithTheOptIn() { // Arrange - var release = Read(ReleaseWorkflow); + var release = Read(ReleaseAction); var selfRelease = Read(SelfHostedRelease); var build = Read(BuildWorkflow); var composite = Read(CompositeAction); @@ -313,7 +331,7 @@ public async Task Prune_IsDisabledWhenBothRetentionPoliciesAreOff() public async Task Prune_ReadsTheRetentionWindowFromTheRepositoryVariable() { // Arrange - var release = Read(ReleaseWorkflow); + var release = Read(ReleaseAction); var selfRelease = Read(SelfHostedRelease); // Act & Assert @@ -332,7 +350,7 @@ public async Task SourceConfiguration_SkipsForkPullRequests() // A fork PR receives no organisation secrets, so the private feed would deny every restore. var build = Read(BuildWorkflow); var selfBuild = Read(SelfHostedBuild); - var release = Read(ReleaseWorkflow); + var release = Read(ReleaseAction); var composite = Read(CompositeAction); // Act & Assert diff --git a/src/tests/Build.UnitTests/WorkflowParityTests.cs b/src/tests/Build.UnitTests/WorkflowParityTests.cs index ce6ec7e..55e7379 100644 --- a/src/tests/Build.UnitTests/WorkflowParityTests.cs +++ b/src/tests/Build.UnitTests/WorkflowParityTests.cs @@ -19,6 +19,12 @@ namespace Purview.Build; /// public partial class WorkflowParityTests { + /// + /// The release implementation. It is a composite action rather than a reusable workflow because + /// Trusted Publishing cannot be satisfied from a called workflow — see the test below. + /// + const string ReleaseAction = "actions/purview-release/action.yml"; + /// /// The settings sections an environment key may address, by configuration section name. /// @@ -59,6 +65,7 @@ public partial class WorkflowParityTests ".github/workflows/purview-release.yml", ".github/workflows/release.yml", ".github/actions/purview-build/action.yml", + "actions/purview-release/action.yml", ]; public static IEnumerable> Workflows() => @@ -132,6 +139,7 @@ await Assert NonEmptyGuard(input).IsMatch(content) || EnvironmentGuard(input).IsMatch(content) || ConditionGuard(input).IsMatch(content) + || WithMapping(input).IsMatch(content) ) .IsTrue() .Because( @@ -144,12 +152,12 @@ await Assert } [Test] - public async Task ReleaseWorkflow_DelegatesTheEligibilityDecisionToTheTool() + public async Task ReleaseAction_DelegatesTheEligibilityDecisionToTheTool() { // Arrange // The gate used to be bash: read package.json, `git rev-parse` the tag, set should_release. // It now has to be the tool's evaluation, or the logic is untestable again. - var content = ReadWorkflow(".github/workflows/purview-release.yml"); + var content = ReadWorkflow(ReleaseAction); // Act var usesReleaseExplain = content.Contains("release-explain --format=json", StringComparison.Ordinal); @@ -163,7 +171,42 @@ await Assert await Assert .That(content) .Contains("steps.eligibility.outputs.release_mode") - .Because("the workflow must set Release__Mode from the evaluated decision."); + .Because("the release must set Release__Mode from the evaluated decision."); + } + + [Test] + public async Task ReleaseAction_IsTheOnlyPlaceThatMintsATrustedPublishingKey() + { + // Arrange + // nuget.org validates the OIDC `job_workflow_ref` claim against the registered policy. Inside a + // reusable workflow that claim names this repository, never the caller, so the exchange fails + // with "No matching trust policy" — NuGet/NuGetGallery#11000, still open. A composite action + // runs in the caller's job, which keeps the claim on the caller's own workflow file. + var action = ReadWorkflow(ReleaseAction); + var workflow = ReadWorkflow(".github/workflows/purview-release.yml"); + + // Act + var mintsInAction = action.Contains("NuGet/login@", StringComparison.Ordinal); + var mintsInWorkflow = workflow.Contains("NuGet/login@", StringComparison.Ordinal); + + // Assert + await Assert + .That(mintsInAction) + .IsTrue() + .Because("the composite action is the only entry point that can satisfy a Trusted Publishing policy."); + await Assert + .That(mintsInWorkflow) + .IsFalse() + .Because( + "a reusable workflow's OIDC claim names the called workflow, so a key minted there can " + + "never match a policy registered for the caller (NuGet/NuGetGallery#11000). Move the " + + "login into the composite action instead of advertising an input that always 401s." + ); + await Assert + .That(workflow) + .DoesNotContain("trusted-publishing:") + .Because("offering the input on the reusable workflow invites a migration that cannot work."); + await Assert.That(workflow).DoesNotContain("nuget-user:"); } [Test] @@ -235,6 +278,12 @@ string block // failed on the GitHub Packages push. string[] required = ["contents: write", "packages: write"]; + // A caller that grants `packages: write` is publishing to NuGet, and every NuGet publisher now + // mints its nuget.org key from the job's OIDC token. Scoping the requirement to that block + // keeps a GitHubRelease-only caller's snippet minimal: it needs neither scope. + if (block.Contains("packages: write", StringComparison.Ordinal)) + required = [.. required, "id-token: write"]; + // Act var missing = required.Where(scope => !block.Contains(scope, StringComparison.Ordinal)).ToList(); @@ -245,8 +294,8 @@ await Assert .Because( $"the caller permissions block at {file}:{line} omits {string.Join(", ", missing)}. A " + "documented release caller must grant contents: write to tag and create the " - + "release and packages: write for the GitHub Packages mirror, which a repository " - + "variable turns on without touching this snippet." + + "release, packages: write for the GitHub Packages mirror, and id-token: write for " + + "the Trusted Publishing exchange that replaced the stored nuget.org key." ); } @@ -298,17 +347,21 @@ public async Task DocumentationChecks_ActuallyFindSomethingToCheck() } [Test] - public async Task ReleaseWorkflow_DeclaresEveryNewInput() + public async Task ReleaseEntryPoints_DeclareEveryNewInput() { // Arrange - var content = ReadWorkflow(".github/workflows/purview-release.yml"); string[] expected = ["config-path", "eligibility-policy", "release-channel", "version-source"]; - // Act - var declared = expected.Where(input => content.Contains($"{input}:", StringComparison.Ordinal)); + // Act & Assert + // The delegator and the implementation must agree: an input the workflow accepts but the + // action never declares is silently dropped. + foreach (var file in new[] { ".github/workflows/purview-release.yml", ReleaseAction }) + { + var content = ReadWorkflow(file); + var declared = expected.Where(input => content.Contains($"{input}:", StringComparison.Ordinal)); - // Assert - await Assert.That(declared).IsEquivalentTo(expected); + await Assert.That(declared).IsEquivalentTo(expected).Because($"{file} must declare every release input."); + } } [Test] @@ -316,12 +369,14 @@ public async Task ParityChecks_ActuallyFindSomethingToCheck() { // Arrange // Guards the guards: a regex that silently matches nothing would make every parity - // assertion above pass vacuously. + // assertion above pass vacuously. Optional inputs are declared by the delegator; the settings + // keys are forwarded by the implementation. var content = ReadWorkflow(".github/workflows/purview-release.yml"); + var action = ReadWorkflow(ReleaseAction); // Act var optionalInputs = OptionalInputs(content); - var settingsKeys = SettingsKeys(content); + var settingsKeys = SettingsKeys(action); // Assert await Assert.That(optionalInputs).Contains("test-filter"); @@ -336,6 +391,123 @@ await Assert await Assert.That(settingsKeys).Contains("Release__Eligibility__Policy"); } + [Test] + public async Task ReleaseAction_GuardsEveryOptionalInputItForwards() + { + // Arrange + // The same hazard the workflow checks cover, in a different shape: a composite action declares + // its inputs at two spaces of indentation instead of six, so OptionalInputs cannot see them. + // An omitted input forwarded as the empty string still overrides the repository's + // purview-build.json, because environment variables outrank the file. + var content = ReadWorkflow(ReleaseAction); + + // Act + // Only inputs that become an INPUT_* shell variable can do that; an input consumed directly in + // an expression cannot reach the tool's configuration. + var forwarded = ActionInputs(content) + .Where(input => IsEmptyDefault(input.Default)) + .Select(input => input.Name) + .Where(input => content.Contains(EnvironmentName(input), StringComparison.Ordinal)) + .ToList(); + + var unguarded = forwarded.Where(input => !EnvironmentGuard(input).IsMatch(content)).ToList(); + + // Assert + await Assert.That(forwarded).IsNotEmpty().Because("the guard below would otherwise be vacuous."); + await Assert + .That(unguarded) + .IsEmpty() + .Because( + $"{ReleaseAction} forwards optional input(s) {string.Join(", ", unguarded)} without " + + "guarding them against being empty. Apply them with " + + "`[ -z \"$INPUT_\" ] || export ...` instead of exporting the empty string." + ); + } + + [Test] + public async Task ReleaseActionChecks_ActuallyFindSomethingToCheck() + { + // Arrange + // Guards the guard: a malformed inputs block would make the test above pass without reading + // anything. + var inputs = ActionInputs(ReadWorkflow(ReleaseAction)); + + // Act + var optional = inputs.Where(input => IsEmptyDefault(input.Default)).Select(input => input.Name).ToList(); + + // Assert + await Assert.That(inputs).IsNotEmpty(); + await Assert.That(optional).Contains("config-path"); + await Assert.That(optional).Contains("test-filter"); + await Assert.That(optional).DoesNotContain("run-tests").Because("run-tests declares a default."); + await Assert.That(inputs.Select(input => input.Name)).Contains("trusted-publishing"); + } + + /// + /// The inputs a composite action declares, with the raw text of each default:. + /// + /// + /// The block is read from the top-level inputs: key to the next key at column zero, which is + /// what stops runs: from being mistaken for an input. + /// + static List<(string Name, string? Default)> ActionInputs(string content) + { + var block = ActionInputsBlock().Match(content); + if (!block.Success) + return []; + + List<(string, string?)> inputs = []; + foreach (Match match in ActionInputDeclaration().Matches(block.Groups["body"].Value)) + { + var declared = ActionInputDefault().Match(match.Groups["body"].Value); + inputs.Add((match.Groups["name"].Value, declared.Success ? declared.Groups["value"].Value.Trim() : null)); + } + + return inputs; + } + + /// + /// True when an input's default is absent or an empty string, which makes it optional in practice. + /// + static bool IsEmptyDefault(string? value) => value is null || value.Trim('"', '\'').Length == 0; + + [GeneratedRegex(@"^inputs:[ \t]*$(?(?:\n(?![a-z])[^\n]*)*)", RegexOptions.Multiline)] + private static partial Regex ActionInputsBlock(); + + [GeneratedRegex( + @"^ (?[a-z][a-z0-9-]*):[ \t]*$(?(?:\n(?![ ]{0,2}[a-z])[^\n]*)*)", + RegexOptions.Multiline + )] + private static partial Regex ActionInputDeclaration(); + + [GeneratedRegex(@"^[ \t]+default:[ \t]*(?.*)$", RegexOptions.Multiline)] + private static partial Regex ActionInputDefault(); + + [Test] + public async Task ReleaseAction_BlanksTheLegacyKeyWhenTrustedPublishingIsOn() + { + // Arrange + // GitHub evaluates `''` as falsy, so the obvious `trusted && '' || legacyKey` yields the legacy + // key, not the empty string — the tool prefers NUGET_APIKEY, so a leftover secret would keep + // publishing the old way while the configuration looked migrated. The choice is made in bash + // instead. + var content = ReadWorkflow(ReleaseAction); + + // Act + var blanksLegacy = content.Contains("export NUGET_APIKEY=\"\"", StringComparison.Ordinal); + var usesFalsyExpression = content.Contains("&& '' ||", StringComparison.Ordinal); + + // Assert + await Assert.That(blanksLegacy).IsTrue().Because("a leftover long-lived key must never shadow the minted one."); + await Assert + .That(usesFalsyExpression) + .IsFalse() + .Because( + "`condition && '' || value` returns `value`, because the empty string is falsy in a " + + "GitHub expression. Select the credential in the shell script instead." + ); + } + static string ReadWorkflow(string relativePath) { var root = RepositoryRoot(); @@ -464,6 +636,21 @@ static Regex NonEmptyGuard(string input) => static Regex ConditionGuard(string input) => new(@"if:.*inputs\." + Regex.Escape(input) + @"\s*(?:==|!=)\s*''", RegexOptions.None, TimeSpan.FromSeconds(5)); + /// + /// An optional input handed straight to another action's with:. + /// + /// + /// An action input is not an environment variable, so an empty value cannot override a consuming + /// repository's configuration: the called action owns that decision and applies its own non-empty + /// guards. This is the shape a delegating workflow forwards through. + /// + static Regex WithMapping(string input) => + new( + @"^ {2,}[a-z][a-z0-9-]*:[ \t]*\$\{\{\s*inputs\." + Regex.Escape(input) + @"\s*\}\}[ \t]*$", + RegexOptions.Multiline, + TimeSpan.FromSeconds(5) + ); + static Regex EnvironmentGuard(string input) { var name = Regex.Escape(EnvironmentName(input));