Repository navigation
395 lines (350 loc) · 17.7 KB
/
Copy pathrelease.yml
File metadata and controls
395 lines (350 loc) · 17.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
# Publish to NuGet when a v* tag is pushed.
#
# Directory.Build.props is the single source of truth for the version — the tag is checked
# against it rather than driving it, so a mistyped tag fails before anything is pushed.
# The packages themselves are discovered from the pack output, so a future satellite package
# is picked up without editing this file.
#
# git tag v5.0.2 && git push origin v5.0.2
#
# Publishing uses Trusted Publishing (OIDC): no long-lived API key exists in this repository.
# GitHub signs a short-lived token proving which workflow in which repository is running,
# nuget.org validates it against a policy you configure there, and issues an API key valid for
# one hour. A compromise of this repository's secrets yields nothing that can publish.
#
# First the full CI (#111): the `ci` job calls dotnet.yml, so every lane a PR runs — the SQL Server
# one included — runs again on the tagged commit, and its `CI` gate fails on a lane that failed, was
# skipped or was cancelled. `verify` needs it, so nothing is packed, approved or published until it
# passes. A tag on a commit whose push run failed, or one no PR ever checked, stops here.
#
# Then three jobs on purpose: everything that can fail runs unattended, only the push waits behind
# the environment's approval gate, and the GitHub release is created afterwards. A reviewer is
# asked once the full suite has already passed, and no OIDC token is minted until they approve.
# The job that can mint a token cannot write to the repository, and the job that can write to
# the repository cannot mint a token.
#
# Actions are pinned to commit SHAs, not tags — this is the publish path, and NuGet/login runs in
# the one job that can mint a token. A moved tag reaches every workflow trusting it; a SHA does
# not. The trailing `# vX.Y.Z` is what Dependabot reads to propose an update; keep it by the SHA.
#
# Setup, once:
# 1. GitHub → Settings → Environments → create `nuget`, and add yourself under
# "Required reviewers" (without a reviewer the environment adds no gate).
# 2. nuget.org → your account → Trusted Publishing → add a policy naming this repository
# owner, this repository, the workflow *file name* (release.yml, not a path), and the
# environment `nuget`. The environment must match: the OIDC token carries it as a claim,
# and a policy that does not expect it will not match.
# 3. Add a NUGET_USER secret holding your nuget.org *profile name* — not the email address.
# This is not a credential; it is a secret only to keep it out of build logs. A repository
# secret is fine, or scope it to the `nuget` environment.
name: Release
on:
push:
tags: [ "v*" ]
workflow_dispatch:
inputs:
dry_run:
description: "Build, test and pack, but do not push to NuGet"
type: boolean
default: true
permissions:
contents: read
defaults:
run:
shell: bash
concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false
env:
DOTNET_NOLOGO: true
DOTNET_CLI_TELEMETRY_OPTOUT: true
DOTNET_SKIP_FIRST_TIME_EXPERIENCE: true
NUGET_SOURCE: https://api.nuget.org/v3/index.json
jobs:
# Every lane of dotnet.yml, gated by its `CI` job. A reusable workflow rather than a copy, so a lane
# added there gates releases without an edit here. Read-only, like every job before `publish`.
# ReleaseWiringConventionTests holds that `publish` cannot run unless this job succeeded.
ci:
name: CI
uses: ./.github/workflows/dotnet.yml
permissions:
contents: read
verify:
name: Verify and pack
runs-on: ubuntu-latest
needs: ci
permissions:
contents: read # deliberately no id-token here: nothing in this job may mint a token
outputs:
version: ${{ steps.version.outputs.version }}
steps:
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
- name: Setup .NET
uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5.4.0
with:
global-json-file: global.json
# MSBuild evaluation rather than parsing the XML, so the value is exactly what the build
# will stamp onto the packages.
- name: Resolve version
id: version
run: |
version="$(dotnet msbuild src/EFCore.ComplexIndexes/EFCore.ComplexIndexes.csproj -getProperty:Version)"
if [[ -z "$version" ]]; then
echo "::error::Could not evaluate the Version property."
exit 1
fi
echo "version=$version" >> "$GITHUB_OUTPUT"
echo "Version: $version"
- name: Verify the tag matches the package version
if: startsWith(github.ref, 'refs/tags/v')
run: |
tag="${GITHUB_REF_NAME#v}"
version="${{ steps.version.outputs.version }}"
if [[ "$tag" != "$version" ]]; then
echo "::error::Tag v$tag does not match Directory.Build.props ($version) — bump the version and re-tag rather than publishing a version nothing documents."
exit 1
fi
- name: Build
run: dotnet build -c Release
# The full suite gates the release, which also means ChangelogConsistencyTests runs:
# a version with no changelog entry in the root or package CHANGELOG.md fails here.
# CI=true keeps the integration layer mandatory.
- name: Test
env:
CI: true
run: dotnet test -c Release --no-build
- name: Pack
run: dotnet pack -c Release --no-build -o dist
# Installs the packages just packed into a throwaway project outside this repository and runs a
# real `dotnet ef migrations add`. Passing dist means the artifacts published are the artifacts
# tested, rather than a fresh pack that happens to be built from the same commit.
- name: Consumer smoke test
run: ./test/consumer-smoke-test.sh dist
# One CycloneDX SBOM per package, describing what a consumer actually takes on.
#
# --exclude-filter drops Microsoft.EntityFrameworkCore.Design and its whole subtree. That
# reference is PrivateAssets=all, so it never reaches consumers — leaving it in would list
# ~45 MSBuild/Roslyn components for a package whose nuspec declares one, and hand every
# consumer a pile of vulnerability alerts for code they do not ship. PackagingConventionTests
# asserts this filter still covers every PrivateAssets=all reference.
#
# --include-project-references keeps the core package listed as a dependency of the
# satellites, matching their nuspec.
- name: Generate SBOMs
run: |
dotnet tool restore
for directory in src/*/; do
id="$(basename "$directory")"
echo "==> $id"
dotnet cyclonedx "$directory$id.csproj" \
--output dist \
--filename "$id.${{ steps.version.outputs.version }}.cdx.json" \
--output-format Json \
--set-type Library \
--set-nuget-purl \
--set-name "$id" \
--set-version "${{ steps.version.outputs.version }}" \
--include-project-references \
--exclude-filter Microsoft.EntityFrameworkCore.Design
done
- name: Collect packages
run: |
shopt -s nullglob
packages=(dist/*."${{ steps.version.outputs.version }}".nupkg)
if [[ ${#packages[@]} -eq 0 ]]; then
echo "::error::No *.${{ steps.version.outputs.version }}.nupkg found in dist/."
exit 1
fi
printf 'Packages (%d):\n' "${#packages[@]}"
printf ' %s\n' "${packages[@]##*/}"
# upload-artifact v6 and download-artifact v7 are the majors that actually switched to the
# Node 24 runtime; v5/v6 respectively only had preliminary support and still ran on Node 20,
# which the runner now warns about. Deliberately not the newest majors: upload v7 adds ESM and
# direct (unzipped) uploads, and download v8 turns a digest mismatch into a hard failure and
# stops auto-unzipping. Neither buys anything here, and this is the publish path.
- name: Upload packages
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: nupkg-${{ steps.version.outputs.version }}
path: |
dist/*.nupkg
dist/*.snupkg
dist/*.cdx.json
publish:
name: Publish
runs-on: ubuntu-latest
needs: verify
# Skipped entirely on a dry run, so no approval is requested and no token is minted.
if: ${{ github.event_name == 'push' || !inputs.dry_run }}
# The approval gate. Must match the environment named in the nuget.org policy.
environment: nuget
permissions:
contents: read
id-token: write # lets GitHub mint the OIDC token nuget.org validates
# No checkout, so no global.json: this job only pushes what `verify` built, and any 10.0 SDK
# pushes alike.
steps:
- name: Setup .NET
uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5.4.0
with:
dotnet-version: 10.0.x
# Publishes exactly what was verified — the packages are not rebuilt here.
- name: Download packages
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
with:
name: nupkg-${{ needs.verify.outputs.version }}
path: dist
- name: Verify the publisher identity is configured
env:
NUGET_USER: ${{ secrets.NUGET_USER }}
run: |
if [[ -z "${NUGET_USER:-}" ]]; then
echo "::error::NUGET_USER secret is not set — it must be your nuget.org profile name, not the email address."
exit 1
fi
# Exchanges this run's OIDC token for an API key that nuget.org issues against the trusted
# publishing policy, valid for one hour. The token is single-use and nothing is stored.
- name: NuGet login (OIDC)
id: login
uses: NuGet/login@8d196754b4036150537f80ac539e15c2f1028841 # v1.2.0
with:
user: ${{ secrets.NUGET_USER }}
# Only .nupkg is globbed — dotnet nuget push uploads the matching .snupkg alongside it
# automatically, so symbols need no separate step.
#
# --skip-duplicate makes a re-run after a partial failure safe: already-published
# packages are reported and skipped instead of failing the job. The temporary key expires
# an hour after login, so a much-delayed re-run needs a fresh workflow run.
- name: Push to NuGet
env:
NUGET_API_KEY: ${{ steps.login.outputs.NUGET_API_KEY }}
run: |
for package in dist/*."${{ needs.verify.outputs.version }}".nupkg; do
echo "==> Pushing $(basename "$package")"
dotnet nuget push "$package" \
--api-key "$NUGET_API_KEY" \
--source "$NUGET_SOURCE" \
--skip-duplicate
done
- name: Summary
run: |
{
echo "### Released ${{ needs.verify.outputs.version }}"
echo
for package in dist/*."${{ needs.verify.outputs.version }}".nupkg; do
echo "- \`$(basename "$package")\`"
done
} >> "$GITHUB_STEP_SUMMARY"
# Gives the SBOMs a permanent home. Workflow artifacts expire after 90 days and nuget.org has no
# slot for an SBOM, so without this job the "each release ships an SBOM" statement in SECURITY.md
# holds for one quarter. Runs after the push, so what is attached describes what is on nuget.org.
#
# Only the SBOMs are attached, deliberately. nuget.org repository-signs every package on
# ingestion, so a .nupkg downloaded from there differs byte-for-byte from the one built here;
# attaching ours would invite a hash comparison that fails for a benign reason. nuget.org is the
# immutable store for the packages; the SBOM has no other home.
#
# The release itself is created here when it does not exist yet, with the root CHANGELOG.md's
# section for this version as the notes — the same text ChangelogConsistencyTests already requires
# for the shipped version, down to the heading style it asserts, so an empty extraction is a
# workflow bug and fails the job rather than publishing a blank release. A release created by hand
# before the tag was pushed is left as written; only the assets are added. Either way the packages
# are already on nuget.org: a failure here is loud and costs nothing but a manual upload.
#
# Separate from `publish` on purpose: that job holds id-token: write, this one holds
# contents: write, and no job holds both.
release:
name: GitHub release
runs-on: ubuntu-latest
needs: [ verify, publish ]
# Tag pushes only. A manual non-dry run from a branch has no tag to release against.
if: startsWith(github.ref, 'refs/tags/v')
permissions:
contents: write # create the release and upload assets; deliberately no id-token
steps:
# The checkout is for CHANGELOG.md, the source of the release notes.
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
- name: Download packages
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
with:
name: nupkg-${{ needs.verify.outputs.version }}
path: dist
- name: Create the release if it does not exist
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
VERSION: ${{ needs.verify.outputs.version }}
REPO_URL: ${{ github.server_url }}/${{ github.repository }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
tag="$GITHUB_REF_NAME"
# "Not found" is the one failure that means "create it"; anything else (auth, API) must
# not be mistaken for it, or a release that exists gets a second create attempt.
if gh release view "$tag" --json tagName >/dev/null 2>view.err; then
echo "Release $tag already exists; leaving its notes as written."
exit 0
elif ! grep -q "release not found" view.err; then
cat view.err
echo "::error::Could not determine whether release $tag exists."
exit 1
fi
# The section for this version: the lines after its `## [x.y.z] — date` heading up to,
# not including, the next H2, from the first line with text. The heading is left out, as
# in CodoMetis.ValueRanges: the release's title already names the version. The section is
# terse by construction (CONTRIBUTING.md, Changelog; ChangelogConsistencyTests). index()
# rather than a regex, so the dots in the version match only dots.
notes="$(awk -v heading="## [$VERSION] " '
index($0, heading) == 1 { found = 1; next }
found && /^## / { exit }
found && (started || NF) { started = 1; print }
' CHANGELOG.md)"
# The changelog links the docs at main, which moves on after the tag (#151). The release
# links them at the tag, as the packages' release notes do (src/Directory.Build.targets).
# The pattern is quoted so it matches literally; the replacement is not, because bash 3.2
# would keep the quotes in it, and it holds no character bash 5.2 would substitute.
for kind in blob tree; do
main="$REPO_URL/$kind/main/"
frozen="$REPO_URL/$kind/v$VERSION/"
notes="${notes//"$main"/$frozen}"
done
if [[ -z "$notes" ]]; then
echo "::error::CHANGELOG.md has no '## [$VERSION] — yyyy-mm-dd' section to use as release notes."
exit 1
fi
{
printf '%s\n\n' "$notes"
printf 'Published to nuget.org through Trusted Publishing by [this run](%s). ' "$RUN_URL"
printf 'The CycloneDX SBOM for each package is attached below.\n'
} > release-notes.md
# "Latest" is decided here rather than left to the API's default, which marks every new
# release Latest. Two lines are supported from 11.0.0 on (CONTRIBUTING.md, "Branches and
# releases"), so this is the normal case, not an accident: a 10.x tag pushed once 11.0.0
# exists is older than Latest and must not take it, or the repository's front page would
# point every visitor at the LTS line. A preview (`11.0.0-preview.N`) is a prerelease and
# never Latest. With no tag, `gh release view` shows the current Latest release, which is
# never a prerelease; none yet means this one is.
flags=()
if [[ "$VERSION" == *-* ]]; then
echo "Publishing $tag as a prerelease, not Latest."
flags+=(--prerelease --latest=false)
else
latest="$(gh release view --json tagName --jq .tagName 2>/dev/null || true)"
if [[ -n "$latest" && "$(printf '%s\n' "${latest#v}" "$VERSION" | sort -V | tail -n1)" != "$VERSION" ]]; then
echo "Release $latest is newer; publishing $tag without marking it Latest."
flags+=(--latest=false)
fi
fi
gh release create "$tag" --verify-tag --title "$tag" --notes-file release-notes.md "${flags[@]}"
- name: Attach the SBOMs
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
VERSION: ${{ needs.verify.outputs.version }}
run: |
shopt -s nullglob
sboms=(dist/*."$VERSION".cdx.json)
if [[ ${#sboms[@]} -eq 0 ]]; then
echo "::error::No *.$VERSION.cdx.json in the downloaded artifact — the SBOM step in verify did not run, or the artifact glob changed."
exit 1
fi
gh release upload "$GITHUB_REF_NAME" "${sboms[@]}" --clobber