Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .editorconfig
Original file line number Diff line number Diff line change
@@ -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
Expand Down
276 changes: 30 additions & 246 deletions .github/workflows/purview-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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 }}
31 changes: 21 additions & 10 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading