Skip to content

publish

publish #9

Workflow file for this run

# Publishing through trusted publishing: no API key is stored anywhere.
#
# nuget.org issues a temporary API key (valid for 1 hour) in exchange for the OIDC token GitHub
# mints for this workflow. The exchange is only allowed because the repository, workflow and
# owner below match a trusted publishing policy on nuget.org:
#
# Repository Owner: MPCoreDeveloper
# Repository: SharpCorePDF
# Workflow File: publish.yml (the file name only, not .github/workflows/publish.yml)
# Environment: (leave empty, unless this job declares one and the policy names it)
#
# The policy also needs at least one scope, and every scope needs at least one subject in its
# "Glob Patterns and Packages" box (one glob or exact package id per line, e.g. SharpCorePDF.*).
# Only *, letters, digits, underscore, dot and dash are accepted on a line, and the temporary API
# key is scoped to exactly the subjects listed there.
#
# Both lines are needed, not just the glob: SharpCorePDF.* covers the four layers, but the entry
# point's id has no dot after the product name, so SharpCorePDF.* does not match it. A box holding
# only the glob publishes four packages and refuses the fifth with 403 Forbidden.
#
# The policy is owned by a nuget.org user or organization, and it applies to every package that
# owner owns, so the account named for the policy owns what this workflow publishes.
#
# Nothing is published without a policy in place, and nothing is published from a manual run
# unless the "publish" input is set to true.
#
# Nothing is published before the SonarCloud quality gate passes for the commit being published either.
# The analysis itself runs in ci.yml; the sonar-gate job below reads its verdict from SonarCloud, and it
# runs before the build and before the OIDC exchange, so a red gate costs this workflow nothing.
name: publish
on:
workflow_dispatch:
inputs:
publish:
description: Push the packages to nuget.org (a trusted publishing policy must exist)
type: boolean
default: false
push:
tags:
- 'v*'
permissions:
contents: read
jobs:
# ci.yml runs the analysis, and `sonar.qualitygate.wait` makes it fail when the gate fails, so a green
# CI run is a necessary condition for a release rather than a sufficient one: the verdict belongs to
# the project on SonarCloud, not to that run. It can change after the run (a rule newly enabled, a
# finding on a file), the snapshot the run uploaded can be replaced by Automatic Analysis' one, which
# imports no coverage and so leaves the coverage condition unjudged, and a tag can point at a commit
# whose analysis is not the newest on main. So the verdict is asked for here, about the commit that is
# about to be published, at a point where nothing has been packed or pushed.
sonar-gate:
name: Check the SonarCloud quality gate
runs-on: ubuntu-latest
steps:
- name: Read the gate for the commit being published
shell: pwsh
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
COMMIT: ${{ github.sha }}
run: |
$ErrorActionPreference = 'Stop'
$project = 'MPCoreDeveloper_SharpCorePDF'
$api = 'https://sonarcloud.io/api'
$dashboard = "https://sonarcloud.io/dashboard?id=$project&branch=main"
if ([string]::IsNullOrWhiteSpace($env:SONAR_TOKEN)) {
throw "SONAR_TOKEN is not set, so the gate cannot be read. A release is not published on the assumption that the gate is green: add the secret (README -> Continuous integration)."
}
$headers = @{ Authorization = "Bearer $env:SONAR_TOKEN" }
function Read-Api([string] $path) { Invoke-RestMethod -Uri "$api/$path" -Headers $headers }
# 1. The analysis method. Automatic Analysis is a second analyser on SonarCloud's side, and the
# snapshot it uploads carries no coverage report, so a gate read while it is on may be a gate
# that never judged coverage. ci.yml refuses to scan while it is on; this refuses to publish.
# Like ci.yml, the read is best-effort: an analysis-only token answers 401/403 here, and the
# coverage condition below is what catches the consequence.
try {
$method = Read-Api "settings/values?component=$project&keys=sonar.autoscan.enabled"
$autoscan = @($method.settings | Where-Object { $_.key -eq 'sonar.autoscan.enabled' -and $_.value -eq 'true' })
if ($autoscan.Count -gt 0) {
throw "Automatic Analysis is on for $project, so the newest snapshot of main is not necessarily the one CI uploaded and carries no coverage. Turn it off in Administration -> Analysis Method: https://sonarcloud.io/project/analysis_method?id=$project"
}
}
catch [System.Net.Http.HttpRequestException] {
Write-Host "::notice::The analysis method could not be read ($($_.Exception.Message)); the coverage condition below still has to be present."
}
# 2. Whether the snapshot describes the commit being published. A tag can point at any commit,
# including one that no analysis of main has seen, and the gate would then be somebody else's.
$analyses = @((Read-Api "project_analyses/search?project=$project&branch=main&ps=1").analyses)
if ($analyses.Count -eq 0) {
throw "SonarCloud holds no analysis of main for $project, so there is no gate to publish against. $dashboard"
}
if ($analyses[0].revision -ne $env:COMMIT) {
throw "The newest analysis of main is of commit $($analyses[0].revision) ($($analyses[0].date)), not of $env:COMMIT, which is the commit being published. Tag the commit that CI analysed last, then publish from a run of this workflow. $dashboard"
}
# 3. The gate itself, condition by condition, so a red run says which number was the problem.
$gate = (Read-Api "qualitygates/project_status?projectKey=$project&branch=main").projectStatus
$conditions = @($gate.conditions)
foreach ($condition in $conditions) {
$state = if ($condition.status -eq 'OK') { 'ok ' } else { 'FAIL' }
Write-Host ("{0} {1} ({2} {3}, measured {4})" -f $state, $condition.metricKey, $condition.comparator, $condition.errorThreshold, $condition.actualValue)
}
# The condition the gate exists for, and the one a snapshot without a coverage report silently
# drops. A gate that answered without it answered a different question, so its answer is not
# taken as a pass here.
if (@($conditions | Where-Object { $_.metricKey -eq 'new_coverage' }).Count -eq 0) {
throw "The gate reports no new_coverage condition, so the analysis it judged carried no coverage report -- which is what Automatic Analysis produces. Run ci.yml on main and check that the report was uploaded. $dashboard"
}
if ($gate.status -ne 'OK') {
$failing = @($conditions | Where-Object { $_.status -ne 'OK' })
throw "The quality gate is $($gate.status) for $env:COMMIT: $($failing.Count) of $($conditions.Count) conditions fail. A release is not published on a red gate. $dashboard"
}
Write-Host "The quality gate is OK for $env:COMMIT, with $($conditions.Count) conditions and coverage among them: $dashboard"
publish:
runs-on: ubuntu-latest
needs: sonar-gate
# Optional: gate publishing behind a protected environment (create it in repository settings,
# add required reviewers, then set the policy's Environment field to the same name).
# environment: release
permissions:
contents: read
id-token: write # lets this job ask GitHub for the OIDC token nuget.org exchanges for a key
steps:
- name: Check out
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
# The .NET 11 SDK is not on the hosted images yet. The "11.0.x" form takes the latest patch on
# that channel, prereleases included, so it follows the RC to GA on its own once .NET 11 ships.
# The .NET 10 SDK is installed with it because the SDK layout is what carries a net10.0 host
# pack and runtime: with the 11.0 SDK alone, the solution's RID-less net10.0 leg dies with
# MSB3030 (obj/Release/net10.0/apphost not found) and the net10.0 test run has nothing to start
# on. global.json pins no SDK version, so the newest SDK still does the building.
- name: Install the .NET SDKs
uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6
with:
dotnet-version: |
11.0.x
10.0.x
- name: Build
run: dotnet build ./SharpCorePDF.slnx -c Release
# Both legs, per README: the RID-less net10.0 leg of some SDK builds skips the app host that
# the test project needs, so the net10.0 run names a runtime.
- name: Test on net11.0
run: dotnet test ./tests/SharpCorePDF.Tests/SharpCorePDF.Tests.csproj -c Release -p:TargetFramework=net11.0
- name: Test on net10.0
run: dotnet test ./tests/SharpCorePDF.Tests/SharpCorePDF.Tests.csproj -c Release -p:TargetFramework=net10.0 -r linux-x64
- name: Pack
run: dotnet pack ./SharpCorePDF.slnx -c Release -o ./artifacts
# Guards the promise recorded in src/Directory.Build.props: every package ships both legs, and
# a tagged run may only publish the version the tag names. One package is checked differently:
# SharpCorePDF is the entry point and carries no assemblies of its own, so what has to hold is
# that it depends on all four layers. Every package, that one included, must carry the README
# nuget.org will render as its page.
- name: Check the packages before they go anywhere
shell: pwsh
run: |
$failures = @()
$packages = @(Get-ChildItem ./artifacts/*.nupkg)
if ($packages.Count -eq 0) { throw 'nothing was packed' }
$version = (Select-String -Path ./src/Directory.Build.props -Pattern '<Version>(.*?)</Version>').Matches[0].Groups[1].Value
$entryPoint = 'SharpCorePDF'
$layers = @(
'SharpCorePDF.Domain'
'SharpCorePDF.Application'
'SharpCorePDF.Infrastructure'
'SharpCorePDF.DependencyInjection'
)
if ('${{ github.ref_type }}' -eq 'tag') {
$tag = '${{ github.ref_name }}' -replace '^v', ''
if ($tag -ne $version) { $failures += "tag ${{ github.ref_name }} does not match <Version> $version" }
}
foreach ($package in $packages) {
$zip = [System.IO.Compression.ZipFile]::OpenRead($package.FullName)
try {
$entries = @($zip.Entries | ForEach-Object { $_.FullName })
$nuspecEntry = $zip.Entries | Where-Object { $_.FullName -like '*.nuspec' } | Select-Object -First 1
$reader = New-Object System.IO.StreamReader($nuspecEntry.Open())
[xml]$nuspec = $reader.ReadToEnd()
$reader.Dispose()
$id = $nuspec.package.metadata.id
if ($id -eq $entryPoint) {
$dependencies = @(
$nuspec.package.metadata.dependencies.group |
ForEach-Object { $_.dependency } |
ForEach-Object { $_.id } |
Sort-Object -Unique
)
foreach ($layer in $layers) {
if ($dependencies -notcontains $layer) { $failures += "$id does not depend on $layer" }
}
}
else {
foreach ($leg in 'lib/net10.0', 'lib/net11.0') {
if (-not ($entries | Where-Object { $_ -like "$leg/*.dll" })) {
$failures += "$($package.Name) carries no $leg assembly"
}
}
}
if (-not ($entries -contains 'README.md')) { $failures += "$id carries no README.md" }
Write-Host "$id -> $($entries -join ', ')"
}
finally {
$zip.Dispose()
}
}
if ($failures.Count -gt 0) {
$failures | ForEach-Object { Write-Host "::error::$_" }
throw "$($failures.Count) problem(s) with the packages"
}
# A manual run without publish: true stops here. Pushing into a folder feed needs no key, and
# it proves the packages survive a real feed handshake. The folder has to be named by absolute
# path: dotnet nuget push reads a --source value that is not rooted as the name of a source in
# nuget.config, so "./localfeed" is refused with "The specified source './localfeed' is invalid".
- name: Handshake against a local feed (dry run)
if: ${{ github.event_name == 'workflow_dispatch' && !inputs.publish }}
run: |
feed="$RUNNER_TEMP/scp-feed"
mkdir -p "$feed"
dotnet nuget push ./artifacts/*.nupkg --source "$feed"
ls -l "$feed"
# Exchanges this workflow's OIDC token for a temporary API key (valid for 1 hour) that
# nuget.org matched against the trusted publishing policy. The username is the nuget.org
# account the policy is owned by. The action is pinned to the commit its v1 tag points at,
# because a tag can be moved and this step is handed the token that mints an API key.
- name: NuGet login (OIDC to temporary API key)
if: ${{ github.event_name == 'push' || inputs.publish }}
uses: NuGet/login@8d196754b4036150537f80ac539e15c2f1028841 # v1.2.0
id: login
with:
user: ${{ secrets.NUGET_USERNAME }}
- name: Push to nuget.org
if: ${{ github.event_name == 'push' || inputs.publish }}
run: |
dotnet nuget push ./artifacts/*.nupkg \
--source https://api.nuget.org/v3/index.json \
--api-key "${{ steps.login.outputs.NUGET_API_KEY }}" \
--skip-duplicate