mirror of
https://github.com/OPCFoundation/UA-.NETStandard.git
synced 2026-10-18 22:55:08 +08:00
731600801a
# Description `Release to nuget.org` [run 37591540644](https://github.com/OPCFoundation/UA-.NETStandard/actions/runs/37591540644) was the stable 2.0.0 promotion. It spent more than an hour in **Verify already-published package identity**. It pushed every package to both feeds and created the `2.0.0` tag, then [failed](https://github.com/OPCFoundation/UA-.NETStandard/actions/runs/37591540644/job/112693955843) in **Tag published package candidate** before the GitHub Release existed. This PR fixes both problems. ## 1. Feed gates retried every 404 PowerShell 7's `Invoke-WebRequest -MaximumRetryCount` retries every status from 400 to 599, and that includes 404. `-SkipHttpErrorCheck` stops the error from being thrown but doesn't stop the retries. Both feed gates ask once per package whether an id/version is published, and 404 is the normal "not yet" answer. Each absent package therefore cost 3 retries × 5 s, about 15 s. A stable set is never pushed automatically, so for 2.0.0 every package, `.Debug` included, was absent on **both** feeds. - `package-version-policy.ps1`: adds `Invoke-FeedRequest`. It retries only network failures, 408, 429 and 5xx, and returns every other response straight away. After the last retry it returns the final response, or rethrows the network error. - `assert-published-packages-match.ps1` and `validate-preview-package-ordering.ps1` call it instead of `Invoke-WebRequest -MaximumRetryCount 3 -RetryIntervalSec 5`. The status handling in each caller is unchanged, so the gates still fail closed: anything other than 200 or 404 still blocks. - `PackageVersionPolicyTests.GetGitHubPackagesPublishedVersionPagesResultsAndFailsClosedAsync` now dot-sources the policy script so the extracted function can find `Invoke-FeedRequest`. The `Invoke-WebRequest` stub still intercepts the call. - New stub-based tests check that `Invoke-FeedRequest` makes one request for 200/400/403/404 and retries 408/429/5xx exactly `MaximumRetryCount` times. They also check that it stops once a retry is not transient, rethrows an exhausted network failure and passes `-OutFile` with `-PassThru`. | Response | Before | After | |---|---|---| | 404 | 4 requests, 15 s | 1 request, immediate (live nuget.org: 0.58 s) | | 403 | 4 requests | 1 request | | 503 / 429 | retried 3× | retried 3× (unchanged) | | connection refused | — | retried, then rethrown | ## 2. GitHub Release body too long ``` HTTP 422: Validation Failed (https://api.github.com/repos/OPCFoundation/UA-.NETStandard/releases) body is too long (maximum is 125000 characters) ``` `gh release create --generate-notes` diffs against the latest release when no previous tag is given. Here that was `1.5.378.182`, which is on another branch, so the generated notes for 2.0.0 came to 191,048 characters. The step now calls `POST /releases/generate-notes` with `previous_tag_name` from the new `Get-PreviousReleaseTag` helper in `package-version-policy.ps1`. It picks the greatest same-major semantic version strictly lower than the tag, ignoring non-semver tags like 1.5.378.x, so a servicing 2.0.1 published after 2.1.0 still diffs against 2.0.0. For 2.0.0 that is `2.0.0-preview.2`, and the notes come to 31,408 characters (measured against the real repo; that call has no side effects). If a body still exceeds 120,000 characters, it is cut at a line boundary and a compare link is appended. The tag and release checks are idempotent, and the pushes use `--skip-duplicate` and verify identity, so re-running the promotion finishes the release. **For run 37591540644 to pass:** the dispatch runs `release.yml` from `release/2.0`, so these changes have to reach `release/2.0` before the promotion is re-run. ## Verification - All 3 scripts parse with 0 errors. The modified workflow step also parses, and the YAML loads. - Live nuget.org: absent version → 404 in 0.58 s; package index → 200. httpbin 503 with `-RetryIntervalSec 1`: retried, 503 after 4.7 s. - `dotnet test tests/Opc.Ua.Tools.Tests`: 939 passed (1 skipped) on net10.0, plus net8/9/48. ## Related Issues - No issue; found investigating run 37591540644. ## Checklist - [ ] I have signed the [CLA](https://opcfoundation.org/license/cla/ContributorLicenseAgreementv1.0.pdf) and read the [CONTRIBUTING](https://github.com/OPCFoundation/UA-.NETStandard/blob/master/CONTRIBUTING.md) doc. - [ ] I have added tests that prove my fix is effective or that my feature works and increased code coverage. - [ ] I have added all necessary documentation. - [ ] I have verified that my changes do not introduce (new) build or analyzer warnings. - [ ] I ran **all** tests locally using the **UA.slnx** solution against at least .net **framework** and .net **10**, and all passed. - [ ] I fixed **all** failing and flaky tests in the CI pipelines and **all** CodeQL warnings. - [ ] I have addressed **all** PR feedback received. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
556 lines
26 KiB
YAML
556 lines
26 KiB
YAML
name: Release to nuget.org
|
|
|
|
# Manual promotion of the exact signed package bytes produced by a successful
|
|
# nuget-publish.yml run on a canonical "release/<major>.<minor>" branch (e.g.
|
|
# release/2.0). The release environment provides the approval boundary (its
|
|
# deployment_branch_policy restricts execution to release/* branches) and the
|
|
# NuGet trusted publishing identity. The "channel" input states what is being
|
|
# promoted and must match the candidate: "stable" (the default) requires an
|
|
# exact stable release (no prerelease label or build metadata, e.g. "2.0.0");
|
|
# "preview" requires a public release-line preview (e.g. "2.0.0-preview.6").
|
|
# nuget-publish.yml only ever pushes previews to GitHub Packages, so this is
|
|
# the one path that puts a preview on nuget.org. See docs/ReleaseProcess.md
|
|
# ("Approved promotion" and "Preview release") for the full procedures,
|
|
# including how to pick a release_run_id and how to recover from a partial or
|
|
# failed promotion.
|
|
#
|
|
# One-time nuget.org trusted publishing policy:
|
|
# Repository Owner: OPCFoundation
|
|
# Repository: UA-.NETStandard
|
|
# Workflow File: release.yml
|
|
# Environment: release
|
|
|
|
on:
|
|
workflow_dispatch:
|
|
inputs:
|
|
release_run_id:
|
|
description: Successful nuget-publish.yml run ID from a release/<major>.<minor> branch
|
|
required: true
|
|
type: string
|
|
channel:
|
|
description: What the candidate is - an exact stable release, or a release-line preview (e.g. 2.0.0-preview.6)
|
|
required: false
|
|
type: choice
|
|
options:
|
|
- stable
|
|
- preview
|
|
default: stable
|
|
dry_run:
|
|
description: Validate the selected package set without publishing
|
|
required: false
|
|
type: boolean
|
|
default: false
|
|
|
|
permissions:
|
|
actions: read
|
|
contents: write
|
|
id-token: write
|
|
packages: write
|
|
|
|
concurrency:
|
|
group: nuget-release
|
|
cancel-in-progress: false
|
|
|
|
jobs:
|
|
publish:
|
|
name: Promote signed packages
|
|
runs-on: windows-2025
|
|
environment: release
|
|
|
|
steps:
|
|
# Deliberately no checkout here. This job holds the release environment
|
|
# plus packages:write and id-token:write, so no repository code may run
|
|
# before the workspace is bound to the candidate's own commit - the
|
|
# branch tip could otherwise have been changed after the candidate was
|
|
# built and would execute with those credentials. The step below
|
|
# therefore uses only the GitHub API and an inline branch-shape check,
|
|
# and the first and only checkout is at the candidate SHA.
|
|
- name: Setup .NET 10.0
|
|
uses: actions/setup-dotnet@v6
|
|
with:
|
|
dotnet-version: '10.0.401'
|
|
|
|
- name: Validate selected publish run
|
|
id: source
|
|
shell: pwsh
|
|
env:
|
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
RELEASE_RUN_ID: ${{ inputs.release_run_id }}
|
|
run: |
|
|
if ($env:RELEASE_RUN_ID -notmatch '^\d+$') {
|
|
throw 'release_run_id must be a numeric GitHub Actions run ID.'
|
|
}
|
|
|
|
$runJson = gh api "repos/$($env:GITHUB_REPOSITORY)/actions/runs/$env:RELEASE_RUN_ID"
|
|
if ($LASTEXITCODE -ne 0) {
|
|
throw "Unable to read workflow run $env:RELEASE_RUN_ID."
|
|
}
|
|
$run = $runJson | ConvertFrom-Json
|
|
|
|
$workflowJson = gh api (
|
|
"repos/$($env:GITHUB_REPOSITORY)/actions/workflows/$($run.workflow_id)")
|
|
if ($LASTEXITCODE -ne 0) {
|
|
throw "Unable to identify the workflow for run $env:RELEASE_RUN_ID."
|
|
}
|
|
$workflow = $workflowJson | ConvertFrom-Json
|
|
|
|
if ($workflow.path -cne '.github/workflows/nuget-publish.yml') {
|
|
throw "Run $env:RELEASE_RUN_ID belongs to '$($workflow.path)', not nuget-publish.yml."
|
|
}
|
|
if ($run.status -cne 'completed' -or $run.conclusion -cne 'success') {
|
|
throw (
|
|
"Run $env:RELEASE_RUN_ID is not successful: " +
|
|
"status=$($run.status), conclusion=$($run.conclusion).")
|
|
}
|
|
if (-not $run.head_branch.StartsWith(
|
|
'release/',
|
|
[StringComparison]::OrdinalIgnoreCase)) {
|
|
throw (
|
|
"Run $env:RELEASE_RUN_ID came from '$($run.head_branch)'. " +
|
|
'Only release/* branches can be promoted.')
|
|
}
|
|
# Inlined on purpose rather than dot-sourced from
|
|
# .azurepipelines/package-version-policy.ps1: nothing from the
|
|
# repository may execute before the candidate checkout below. This
|
|
# is the same shape as Test-CanonicalReleaseBranchRef, and
|
|
# PackageVersionPolicyTests pins the two together so they cannot
|
|
# drift apart.
|
|
if ("refs/heads/$($run.head_branch)" -notmatch '(?-i)^refs/heads/release/(0|[1-9]\d*)\.(0|[1-9]\d*)$') {
|
|
throw (
|
|
"Run $env:RELEASE_RUN_ID came from '$($run.head_branch)', which is not a " +
|
|
"canonical release/<major>.<minor> branch (for example release/2.0). Only " +
|
|
'that naming may be promoted; see docs/ReleaseProcess.md.')
|
|
}
|
|
if ($env:GITHUB_REF -cne "refs/heads/$($run.head_branch)") {
|
|
throw (
|
|
"This release.yml dispatch runs from '$($env:GITHUB_REF)', but the selected " +
|
|
"candidate was built from 'refs/heads/$($run.head_branch)'. Dispatch " +
|
|
'release.yml from that exact canonical release branch so the validation ' +
|
|
'policy and promotion code are source-bound to the candidate.')
|
|
}
|
|
if ($run.head_repository.full_name -cne $env:GITHUB_REPOSITORY) {
|
|
throw (
|
|
"Run $env:RELEASE_RUN_ID came from '$($run.head_repository.full_name)', " +
|
|
"not '$($env:GITHUB_REPOSITORY)'.")
|
|
}
|
|
|
|
"commit=$($run.head_sha)" | Out-File -FilePath $env:GITHUB_OUTPUT -Append
|
|
"branch=$($run.head_branch)" | Out-File -FilePath $env:GITHUB_OUTPUT -Append
|
|
"artifact=opcua-packages-$env:RELEASE_RUN_ID" |
|
|
Out-File -FilePath $env:GITHUB_OUTPUT -Append
|
|
Write-Host (
|
|
"Selected successful nuget-publish.yml run $env:RELEASE_RUN_ID " +
|
|
"for $($run.head_branch) at $($run.head_sha).")
|
|
|
|
- name: Check out the candidate commit
|
|
# First and only checkout, and deliberately after the validation step
|
|
# above: the dispatch ref pins a branch, not a commit, so checking out
|
|
# the branch tip would let a commit that landed after the candidate was
|
|
# built supply the promotion logic. Binding to the candidate's own SHA
|
|
# makes the validator, the policy scripts and the package bytes all
|
|
# describe the same source.
|
|
uses: actions/checkout@v6
|
|
with:
|
|
ref: ${{ steps.source.outputs.commit }}
|
|
|
|
- name: Download signed promotion artifact
|
|
uses: actions/download-artifact@v8
|
|
with:
|
|
github-token: ${{ secrets.GITHUB_TOKEN }}
|
|
run-id: ${{ inputs.release_run_id }}
|
|
name: ${{ steps.source.outputs.artifact }}
|
|
path: promotion
|
|
|
|
- name: Validate promotion manifest and package bytes
|
|
shell: pwsh
|
|
env:
|
|
RELEASE_RUN_ID: ${{ inputs.release_run_id }}
|
|
RELEASE_BRANCH: ${{ steps.source.outputs.branch }}
|
|
RELEASE_COMMIT: ${{ steps.source.outputs.commit }}
|
|
RELEASE_CHANNEL: ${{ inputs.channel }}
|
|
run: |
|
|
. ./.azurepipelines/package-version-policy.ps1
|
|
$manifestPath = 'promotion/release-manifest.json'
|
|
if (-not (Test-Path -LiteralPath $manifestPath)) {
|
|
throw "The selected run artifact contains no release-manifest.json."
|
|
}
|
|
$manifest = Get-Content -LiteralPath $manifestPath -Raw | ConvertFrom-Json
|
|
$expectedRef = "refs/heads/$($env:RELEASE_BRANCH)"
|
|
if (($manifest.schemaVersion -ne 1 -and $manifest.schemaVersion -ne 2) -or
|
|
$manifest.repository -cne $env:GITHUB_REPOSITORY -or
|
|
$manifest.workflow -cne '.github/workflows/nuget-publish.yml' -or
|
|
$manifest.runId -cne $env:RELEASE_RUN_ID -or
|
|
$manifest.ref -cne $expectedRef -or
|
|
$manifest.commit -cne $env:RELEASE_COMMIT) {
|
|
throw (
|
|
"The promotion manifest does not match nuget-publish.yml run " +
|
|
"$($env:RELEASE_RUN_ID) on $expectedRef.")
|
|
}
|
|
$expectedBaseVersion = if ($manifest.schemaVersion -eq 2) {
|
|
$manifest.basePackageVersion
|
|
}
|
|
else {
|
|
$manifest.packageVersion
|
|
}
|
|
|
|
# The dispatcher states which kind of release this is, and the
|
|
# candidate must be exactly that: a stable promotion can never
|
|
# publish a preview by accident, nor the reverse. A preview must be
|
|
# a public release-line build ("M.m.p-preview.N", no commit id), so
|
|
# master or feature-branch previews can never reach nuget.org. See
|
|
# docs/ReleaseProcess.md ("Approved promotion", "Preview release").
|
|
$channel = $env:RELEASE_CHANNEL
|
|
if ($channel -cne 'stable' -and $channel -cne 'preview') {
|
|
throw "channel must be 'stable' or 'preview', not '$channel'."
|
|
}
|
|
if ($channel -ceq 'stable' -and
|
|
-not (Test-StablePackageVersion -Version $expectedBaseVersion)) {
|
|
throw (
|
|
"Run $($env:RELEASE_RUN_ID) produced base version " +
|
|
"'$expectedBaseVersion', which is not an exact stable release. Only a " +
|
|
'stable candidate (no prerelease label or build metadata) may be ' +
|
|
"promoted with channel 'stable'; dispatch with channel 'preview' to " +
|
|
'publish a release-line preview.')
|
|
}
|
|
if ($channel -ceq 'preview' -and
|
|
-not (Test-PromotablePreviewPackageVersion -Version $expectedBaseVersion)) {
|
|
throw (
|
|
"Run $($env:RELEASE_RUN_ID) produced base version " +
|
|
"'$expectedBaseVersion', which is not a release-line preview. Only an " +
|
|
"'M.m.p-preview.N' candidate without a commit id may be promoted with " +
|
|
"channel 'preview'.")
|
|
}
|
|
# schemaVersion 1 manifests predate the recorded channel. They stay
|
|
# promotable as stable, but a preview must prove its channel.
|
|
if ($channel -ceq 'preview' -and $manifest.schemaVersion -ne 2) {
|
|
throw (
|
|
"The promotion manifest is schemaVersion $($manifest.schemaVersion), which " +
|
|
"records no channel. Only a schemaVersion 2 candidate may be promoted " +
|
|
"with channel 'preview'.")
|
|
}
|
|
if ($manifest.schemaVersion -eq 2 -and $manifest.channel -cne $channel) {
|
|
throw (
|
|
"The promotion manifest records channel '$($manifest.channel)', but this " +
|
|
"promotion was dispatched with channel '$channel'.")
|
|
}
|
|
if (-not (Test-CanonicalReleaseBranchForPackageVersion `
|
|
-Ref $expectedRef `
|
|
-Version $expectedBaseVersion `
|
|
-AllowPreview:($channel -ceq 'preview'))) {
|
|
throw (
|
|
"Candidate version '$expectedBaseVersion' does not match release branch " +
|
|
"'$expectedRef'. An M.m.p candidate must be promoted only from " +
|
|
'the matching release/M.m branch.')
|
|
}
|
|
|
|
$currentPath = 'promotion/current-package-set.json'
|
|
./.azurepipelines/validate-nuget-package-set.ps1 `
|
|
-PackageDirectory promotion `
|
|
-ManifestPath $currentPath `
|
|
-ExpectedVersion $expectedBaseVersion `
|
|
-RequireDebug `
|
|
-VerifySignatures
|
|
$current = Get-Content -LiteralPath $currentPath -Raw | ConvertFrom-Json
|
|
|
|
if ($current.packageCount -ne $manifest.packageCount -or
|
|
$current.symbolPackageCount -ne $manifest.symbolPackageCount -or
|
|
$current.debugPackageCount -ne $manifest.debugPackageCount) {
|
|
throw 'The downloaded package counts do not match the promotion manifest.'
|
|
}
|
|
|
|
$expectedArchives = @($manifest.archives | Sort-Object id, type, file) |
|
|
ConvertTo-Json -Depth 5 -Compress
|
|
$actualArchives = @($current.archives | Sort-Object id, type, file) |
|
|
ConvertTo-Json -Depth 5 -Compress
|
|
if ($actualArchives -cne $expectedArchives) {
|
|
throw 'The downloaded package names, identities, versions, or hashes have changed.'
|
|
}
|
|
|
|
Write-Host (
|
|
"Validated $($current.packageCount) exact package bytes at version " +
|
|
"$($current.basePackageVersion) from run $($env:RELEASE_RUN_ID).")
|
|
|
|
- name: Verify preview package ordering
|
|
# Last gate before an irreversible publication: the preview-only
|
|
# families carry the number committed in preview-version.props, which
|
|
# cannot track the growing "-preview.<height>" series that ordinary
|
|
# development publishes. Re-prove the ordering here (including on a
|
|
# dry run) rather than trusting that nothing was published between
|
|
# candidate creation and approval. See docs/ReleaseProcess.md.
|
|
shell: pwsh
|
|
env:
|
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
run: |
|
|
./.azurepipelines/validate-preview-package-ordering.ps1 `
|
|
-ManifestPath 'promotion/current-package-set.json'
|
|
|
|
- name: Verify already-published package identity
|
|
# "--skip-duplicate" below makes a failed promotion recoverable by
|
|
# re-running the same candidate, but on its own it accepts *any*
|
|
# pre-existing id/version as a successful no-op. Prove first that every
|
|
# version this candidate already occupies really is this candidate's,
|
|
# so a foreign build that claimed an immutable version can never be
|
|
# mistaken for a completed publication. Runs on a dry run too: it is
|
|
# read-only and is exactly what a rehearsal should establish.
|
|
shell: pwsh
|
|
env:
|
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
run: |
|
|
./.azurepipelines/assert-published-packages-match.ps1 `
|
|
-PackageDirectory promotion `
|
|
-ManifestPath 'promotion/current-package-set.json' `
|
|
-Feed 'nuget.org' `
|
|
-ExcludeDebugPackages
|
|
./.azurepipelines/assert-published-packages-match.ps1 `
|
|
-PackageDirectory promotion `
|
|
-ManifestPath 'promotion/current-package-set.json' `
|
|
-Feed 'GitHub Packages'
|
|
|
|
- name: NuGet login
|
|
if: ${{ !inputs.dry_run }}
|
|
id: login
|
|
uses: NuGet/login@v1
|
|
with:
|
|
user: ${{ secrets.NUGET_USER }}
|
|
|
|
- name: Push to nuget.org
|
|
if: ${{ !inputs.dry_run }}
|
|
shell: pwsh
|
|
env:
|
|
NUGET_API_KEY: ${{ steps.login.outputs.NUGET_API_KEY }}
|
|
run: |
|
|
Add-Type -AssemblyName System.IO.Compression.FileSystem
|
|
$packages = @(Get-ChildItem promotion -Filter '*.nupkg' -File |
|
|
Where-Object {
|
|
$archive = [IO.Compression.ZipFile]::OpenRead($_.FullName)
|
|
try {
|
|
$nuspec = @($archive.Entries | Where-Object {
|
|
$_.FullName -notmatch '/' -and $_.FullName.EndsWith('.nuspec')
|
|
})
|
|
if ($nuspec.Count -ne 1) {
|
|
throw "Unable to identify package '$($_.Name)'."
|
|
}
|
|
$reader = [IO.StreamReader]::new($nuspec[0].Open())
|
|
try {
|
|
[xml]$metadata = $reader.ReadToEnd()
|
|
}
|
|
finally {
|
|
$reader.Dispose()
|
|
}
|
|
|
|
$id = [string]$metadata.package.metadata.id
|
|
-not $id.EndsWith(
|
|
'.Debug',
|
|
[StringComparison]::OrdinalIgnoreCase)
|
|
}
|
|
finally {
|
|
$archive.Dispose()
|
|
}
|
|
} |
|
|
Sort-Object Name)
|
|
if ($packages.Count -eq 0) {
|
|
throw 'The candidate contains no non-Debug packages to push to nuget.org.'
|
|
}
|
|
Write-Host (
|
|
"Publishing $($packages.Count) non-Debug package(s) to nuget.org. " +
|
|
'Debug package IDs remain in the signed candidate artifact and are ' +
|
|
'published only to GitHub Packages.')
|
|
foreach ($package in $packages) {
|
|
# --skip-duplicate keeps a partially completed promotion
|
|
# re-runnable. The preflight above rejects a foreign existing
|
|
# package; the post-push verification below closes the remaining
|
|
# race where another publisher claims the version between that
|
|
# check and this push.
|
|
dotnet nuget push $package.FullName `
|
|
--source 'https://api.nuget.org/v3/index.json' `
|
|
--symbol-source 'https://api.nuget.org/v3/index.json' `
|
|
--api-key $env:NUGET_API_KEY `
|
|
--skip-duplicate
|
|
if ($LASTEXITCODE -ne 0) {
|
|
throw "nuget.org push failed for '$($package.Name)'."
|
|
}
|
|
}
|
|
./.azurepipelines/assert-published-packages-match.ps1 `
|
|
-PackageDirectory promotion `
|
|
-ManifestPath 'promotion/current-package-set.json' `
|
|
-Feed 'nuget.org' `
|
|
-ExcludeDebugPackages `
|
|
-RequirePresent
|
|
|
|
- name: Push to GitHub Packages
|
|
# nuget-publish.yml deliberately never pushes a stable candidate to
|
|
# any feed automatically (see its "Skip feed publication" step); this
|
|
# is the one place a stable release also gets mirrored to GitHub
|
|
# Packages, gated behind the same approval and dry-run switch as the
|
|
# nuget.org push above. A preview candidate was already pushed here by
|
|
# its own nuget-publish.yml run, so this loop is a verified no-op for
|
|
# it: the preflight and post-push checks prove those are its bytes.
|
|
if: ${{ !inputs.dry_run }}
|
|
shell: pwsh
|
|
env:
|
|
GITHUB_PACKAGES_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
run: |
|
|
# GitHub Packages retains the complete signed candidate, including
|
|
# .Debug package IDs. Public nuget.org releases above intentionally
|
|
# exclude them; see docs/ReleaseProcess.md.
|
|
$packages = @(Get-ChildItem promotion -Filter '*.nupkg' -File | Sort-Object Name)
|
|
foreach ($package in $packages) {
|
|
# As above, the preflight and post-push checks jointly prove that
|
|
# a duplicate is this candidate rather than a package published
|
|
# in the check-then-push window.
|
|
dotnet nuget push $package.FullName `
|
|
--source 'https://nuget.pkg.github.com/OPCFoundation/index.json' `
|
|
--api-key $env:GITHUB_PACKAGES_TOKEN `
|
|
--skip-duplicate `
|
|
--no-symbols
|
|
if ($LASTEXITCODE -ne 0) {
|
|
throw "GitHub Packages push failed for '$($package.Name)'."
|
|
}
|
|
}
|
|
./.azurepipelines/assert-published-packages-match.ps1 `
|
|
-PackageDirectory promotion `
|
|
-ManifestPath 'promotion/current-package-set.json' `
|
|
-Feed 'GitHub Packages' `
|
|
-GitHubToken $env:GITHUB_PACKAGES_TOKEN `
|
|
-RequirePresent
|
|
|
|
- name: Tag published package candidate
|
|
if: ${{ !inputs.dry_run }}
|
|
shell: pwsh
|
|
env:
|
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
RELEASE_COMMIT: ${{ steps.source.outputs.commit }}
|
|
RELEASE_CHANNEL: ${{ inputs.channel }}
|
|
run: |
|
|
$manifest = Get-Content -LiteralPath 'promotion/release-manifest.json' -Raw | ConvertFrom-Json
|
|
$tag = if ($manifest.schemaVersion -eq 2) {
|
|
[string]$manifest.basePackageVersion
|
|
}
|
|
else {
|
|
[string]$manifest.packageVersion
|
|
}
|
|
if ([string]::IsNullOrWhiteSpace($tag)) {
|
|
throw 'The promotion manifest does not contain a package version for the release tag.'
|
|
}
|
|
|
|
$tagRef = gh api "repos/$env:GITHUB_REPOSITORY/git/ref/tags/$tag" 2>&1
|
|
if ($LASTEXITCODE -eq 0) {
|
|
$tagObject = $tagRef | ConvertFrom-Json
|
|
if ($tagObject.object.type -cne 'commit' -or
|
|
$tagObject.object.sha -cne $env:RELEASE_COMMIT) {
|
|
throw "Existing tag '$tag' does not point to candidate commit $env:RELEASE_COMMIT."
|
|
}
|
|
}
|
|
elseif ($tagRef -match 'HTTP 404') {
|
|
gh api --method POST "repos/$env:GITHUB_REPOSITORY/git/refs" `
|
|
-f "ref=refs/tags/$tag" `
|
|
-f "sha=$env:RELEASE_COMMIT"
|
|
if ($LASTEXITCODE -ne 0) {
|
|
throw "Failed to create tag '$tag' for candidate commit $env:RELEASE_COMMIT."
|
|
}
|
|
}
|
|
else {
|
|
throw "Unable to determine whether tag '$tag' already exists: $tagRef"
|
|
}
|
|
|
|
$release = gh api "repos/$env:GITHUB_REPOSITORY/releases/tags/$tag" 2>&1
|
|
$isPrerelease = $env:RELEASE_CHANNEL -ceq 'preview'
|
|
if ($LASTEXITCODE -eq 0) {
|
|
$releaseObject = $release | ConvertFrom-Json
|
|
if ($releaseObject.draft -or
|
|
$releaseObject.target_commitish -cne $env:RELEASE_COMMIT -or
|
|
[bool]$releaseObject.prerelease -ne $isPrerelease) {
|
|
throw "Existing GitHub Release '$tag' does not match the promoted candidate."
|
|
}
|
|
}
|
|
elseif ($release -match 'HTTP 404') {
|
|
# GitHub generates notes against the latest release when no
|
|
# previous tag is given. That is a 1.5.378 maintenance release
|
|
# on another branch, so the 2.0.0 notes listed every commit
|
|
# since the branches split and exceeded the 125000-character
|
|
# body limit (HTTP 422). Start from the greatest lower release
|
|
# of the same major (see Get-PreviousReleaseTag) and cap the
|
|
# body regardless.
|
|
. ./.azurepipelines/package-version-policy.ps1
|
|
$releaseTags = @(gh api --paginate "repos/$env:GITHUB_REPOSITORY/releases?per_page=100" `
|
|
--jq '.[] | select(.draft | not) | .tag_name')
|
|
if ($LASTEXITCODE -ne 0) {
|
|
throw 'Unable to list existing GitHub Releases.'
|
|
}
|
|
$previousTag = Get-PreviousReleaseTag -Tag $tag -ReleaseTags $releaseTags
|
|
|
|
$notesRequest = @('--method', 'POST', "repos/$env:GITHUB_REPOSITORY/releases/generate-notes",
|
|
'-f', "tag_name=$tag", '-f', "target_commitish=$env:RELEASE_COMMIT")
|
|
if ($previousTag) {
|
|
$notesRequest += @('-f', "previous_tag_name=$previousTag")
|
|
}
|
|
$notes = gh api @notesRequest | ConvertFrom-Json
|
|
if ($LASTEXITCODE -ne 0) {
|
|
throw "Failed to generate release notes for '$tag'."
|
|
}
|
|
|
|
$body = [string]$notes.body
|
|
$maxBodyLength = 120000
|
|
if ($body.Length -gt $maxBodyLength) {
|
|
$compare = if ($previousTag) {
|
|
"https://github.com/$env:GITHUB_REPOSITORY/compare/$previousTag...$tag"
|
|
}
|
|
else {
|
|
"https://github.com/$env:GITHUB_REPOSITORY/commits/$tag"
|
|
}
|
|
$cut = $body.LastIndexOf("`n", $maxBodyLength)
|
|
$body = $body.Substring(0, [Math]::Max($cut, 0)) +
|
|
"`n`n_The generated notes were truncated to fit the GitHub Release size limit. Full changelog: $($compare)_`n"
|
|
}
|
|
$notesFile = Join-Path $env:RUNNER_TEMP 'release-notes.md'
|
|
Set-Content -LiteralPath $notesFile -Value $body -Encoding utf8NoBOM
|
|
|
|
$arguments = @('release', 'create', $tag, '--target', $env:RELEASE_COMMIT,
|
|
'--title', [string]$notes.name, '--notes-file', $notesFile)
|
|
if ($isPrerelease) {
|
|
$arguments += '--prerelease'
|
|
}
|
|
gh @arguments
|
|
if ($LASTEXITCODE -ne 0) {
|
|
throw "Failed to create GitHub Release '$tag'."
|
|
}
|
|
}
|
|
else {
|
|
throw "Unable to determine whether GitHub Release '$tag' already exists: $release"
|
|
}
|
|
|
|
- name: Summary
|
|
if: always()
|
|
shell: pwsh
|
|
env:
|
|
RELEASE_RUN_ID: ${{ inputs.release_run_id }}
|
|
RELEASE_BRANCH: ${{ steps.source.outputs.branch }}
|
|
RELEASE_CHANNEL: ${{ inputs.channel }}
|
|
RELEASE_DRY_RUN: ${{ inputs.dry_run }}
|
|
run: |
|
|
$manifestPath = 'promotion/release-manifest.json'
|
|
if (Test-Path -LiteralPath $manifestPath) {
|
|
$manifest = Get-Content -LiteralPath $manifestPath -Raw | ConvertFrom-Json
|
|
$version = if ($manifest.schemaVersion -eq 2) {
|
|
$manifest.basePackageVersion
|
|
}
|
|
else {
|
|
$manifest.packageVersion
|
|
}
|
|
$count = $manifest.packageCount
|
|
}
|
|
else {
|
|
$version = 'unavailable'
|
|
$count = 'unavailable'
|
|
}
|
|
@"
|
|
### nuget.org release
|
|
|
|
- Source run: ``$($env:RELEASE_RUN_ID)``
|
|
- Source branch: ``$($env:RELEASE_BRANCH)``
|
|
- Channel: ``$($env:RELEASE_CHANNEL)``
|
|
- Version: ``$version``
|
|
- Packages: ``$count``
|
|
- Dry run: ``$($env:RELEASE_DRY_RUN)``
|
|
"@ | Add-Content -LiteralPath $env:GITHUB_STEP_SUMMARY
|