Skip to content

Commit 65ba00c

Browse files
authored
Merge pull request #278 from Vulthil/chore/release-pipeline-hardening
2 parents ab2da30 + d6ecbc9 commit 65ba00c

9 files changed

Lines changed: 180 additions & 37 deletions

.github/workflows/backport.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ jobs:
3434
fetch-depth: 0
3535

3636
- name: Create backport PRs
37-
uses: korthout/backport-action@v4
37+
uses: korthout/backport-action@2e830a1d0b8269505846ddd407a70876913ad1f8 # v4
3838
with:
3939
# AUTOMATION_TOKEN (a PAT/App token) lets the required `build` check
4040
# run on the created PR; pushes made with the default GITHUB_TOKEN do

.github/workflows/ci.yml

Lines changed: 46 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,11 @@ jobs:
1515
runs-on: ubuntu-latest
1616
permissions:
1717
contents: read
18-
packages: write
1918
steps:
2019
- uses: actions/checkout@v7
2120
with:
2221
fetch-depth: 0
22+
persist-credentials: false
2323

2424
- uses: actions/setup-dotnet@v5
2525

@@ -56,19 +56,24 @@ jobs:
5656
name: coverage-report
5757
path: ./CoverageReport
5858

59+
# Uses gh api directly instead of a third-party gist-deploy action -- one
60+
# less individual-maintainer action holding a token in its supply chain.
5961
- name: Publish coverage badge to Gist
6062
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
61-
uses: exuanbo/actions-deploy-gist@v1
62-
with:
63-
token: ${{ secrets.GIST_TOKEN }}
64-
gist_id: 2852f1400e27493b185559c76b38e9b7
65-
file_path: ./CoverageReport/badge_combined.svg
66-
file_type: binary
63+
env:
64+
GH_TOKEN: ${{ secrets.GIST_TOKEN }}
65+
run: |
66+
jq -n --rawfile content ./CoverageReport/badge_combined.svg \
67+
'{files: {"badge_combined.svg": {content: $content}}}' \
68+
| gh api --method PATCH /gists/2852f1400e27493b185559c76b38e9b7 --input -
6769
6870
# Preview (prerelease) packages. main is not a public release ref
6971
# (version.json), so every CI build carries a prerelease suffix. PRs pack
7072
# and upload a downloadable artifact for testing; only merges to main are
7173
# published to the shared feed, so transient PR commits don't pollute it.
74+
# Package validation (APICompat vs. PackageValidationBaselineVersion) also
75+
# runs here, i.e. AFTER build+test -- a baseline diff stays latent on any
76+
# commit where tests already failed and this step didn't run.
7277
- name: Pack preview
7378
if: (github.event_name == 'push' && github.ref == 'refs/heads/main') || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository)
7479
run: dotnet pack -c Release -o ./artifacts
@@ -80,8 +85,41 @@ jobs:
8085
name: nuget-packages
8186
path: ./artifacts/*
8287

83-
- name: Publish preview to GitHub Packages
88+
# Hands packages to the publish-preview job below, which carries
89+
# packages: write instead of this job -- keeps that credential out of the
90+
# job that runs untrusted PR code (dotnet build/test run arbitrary MSBuild
91+
# targets from the PR).
92+
- name: Upload preview packages
8493
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
94+
uses: actions/upload-artifact@v7
95+
with:
96+
name: preview-packages
97+
path: ./artifacts/*
98+
99+
# Split from `build` so the packages: write token is never present while
100+
# building/testing PR code. Only runs for pushes to main, never for PRs
101+
# (including same-repo ones), so a compromised dependency can't use this
102+
# token from its own PR build.
103+
publish-preview:
104+
needs: build
105+
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
106+
runs-on: ubuntu-latest
107+
permissions:
108+
contents: read
109+
packages: write
110+
steps:
111+
- uses: actions/checkout@v7
112+
with:
113+
persist-credentials: false
114+
115+
- uses: actions/setup-dotnet@v5
116+
117+
- uses: actions/download-artifact@v7
118+
with:
119+
name: preview-packages
120+
path: ./artifacts
121+
122+
- name: Publish preview to GitHub Packages
85123
run: |
86124
dotnet nuget push "./artifacts/*.nupkg" \
87125
--api-key ${{ secrets.GITHUB_TOKEN }} \

.github/workflows/claude-code-review.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ jobs:
3333

3434
- name: Run Claude Code Review
3535
id: claude-review
36-
uses: anthropics/claude-code-action@v1
36+
uses: anthropics/claude-code-action@01872ccc02bf66740207fb338a783ce028216758 # v1
3737
with:
3838
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
3939
plugin_marketplaces: 'https://github.com/anthropics/claude-code.git'

.github/workflows/claude.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ jobs:
3232

3333
- name: Run Claude Code
3434
id: claude
35-
uses: anthropics/claude-code-action@v1
35+
uses: anthropics/claude-code-action@01872ccc02bf66740207fb338a783ce028216758 # v1
3636
with:
3737
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
3838

.github/workflows/dependabot-approve-and-automerge.yml

Lines changed: 48 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,14 @@
11
name: Dependabot Auto-Approve and Auto-Merge
2+
3+
# Auto-merges Dependabot patch/minor updates, narrowed by blast radius:
4+
# github-actions bumps (CI-only, and SHA-pinned per T10) and NuGet packages
5+
# NOT referenced by any shipped src/ project still auto-merge once CI (build)
6+
# passes. NuGet's own dependency-type classification has no test-project
7+
# detection (every NuGet requirement reports as "direct:production" -- see
8+
# Dependabot::Dependency.register_production_check in dependabot-core), so the
9+
# src/ check below is done directly against src/**/*.csproj instead of trusted
10+
# from Dependabot metadata. Majors, dotnet-sdk bumps, docker bumps, and any
11+
# NuGet package referenced by src/ always require a human review and merge.
212
on: pull_request
313

414
permissions:
@@ -12,20 +22,54 @@ jobs:
1222
steps:
1323
- name: Dependabot metadata
1424
id: metadata
15-
uses: dependabot/fetch-metadata@v3.1.0
25+
uses: dependabot/fetch-metadata@25dd0e34f4fe68f24cc83900b1fe3fe149efef98 # v3.1.0
1626
with:
1727
github-token: "${{ secrets.GITHUB_TOKEN }}"
28+
29+
- uses: actions/checkout@v7
30+
with:
31+
persist-credentials: false
32+
1833
# Only auto-approve/merge patch and minor updates. A major bump can carry
1934
# breaking changes that still compile and pass tests, then ship to consumers
20-
# unnoticed -- leave majors for a human to review and merge.
21-
- name: Approve a PR
35+
# unnoticed -- leave majors for a human to review and merge. Within
36+
# patch/minor, narrow further by dependency risk (see file header).
37+
- name: Decide auto-merge eligibility
38+
id: eligibility
2239
if: steps.metadata.outputs.update-type == 'version-update:semver-patch' || steps.metadata.outputs.update-type == 'version-update:semver-minor'
40+
env:
41+
ECOSYSTEM: ${{ steps.metadata.outputs.package-ecosystem }}
42+
UPDATED_DEPENDENCIES_JSON: ${{ steps.metadata.outputs.updated-dependencies-json }}
43+
run: |
44+
case "$ECOSYSTEM" in
45+
github_actions)
46+
echo "eligible=true" >> "$GITHUB_OUTPUT"
47+
;;
48+
nuget)
49+
production_found=false
50+
while IFS= read -r name; do
51+
[ -z "$name" ] && continue
52+
if grep -rqiF "Include=\"$name\"" src/ --include="*.csproj"; then
53+
production_found=true
54+
echo "::notice::src/ references $name -- not auto-merging"
55+
fi
56+
done < <(echo "$UPDATED_DEPENDENCIES_JSON" | jq -r '.[].dependencyName' | sort -u)
57+
echo "eligible=$([ "$production_found" = true ] && echo false || echo true)" >> "$GITHUB_OUTPUT"
58+
;;
59+
*)
60+
echo "eligible=false" >> "$GITHUB_OUTPUT"
61+
echo "::notice::Not auto-merging -- $ECOSYSTEM ecosystem requires human review"
62+
;;
63+
esac
64+
65+
- name: Approve a PR
66+
if: steps.eligibility.outputs.eligible == 'true'
2367
run: gh pr review --approve "$PR_URL"
2468
env:
2569
PR_URL: ${{github.event.pull_request.html_url}}
2670
GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}}
2771
- name: Enable auto-merge for Dependabot PRs
28-
if: steps.metadata.outputs.update-type == 'version-update:semver-patch' || steps.metadata.outputs.update-type == 'version-update:semver-minor'
72+
if: steps.eligibility.outputs.eligible == 'true'
2973
run: gh pr merge --auto --merge "$PR_URL"
3074
env:
3175
PR_URL: ${{github.event.pull_request.html_url}}

.github/workflows/release.yml

Lines changed: 40 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -53,26 +53,37 @@ jobs:
5353
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
5454
echo "::notice::Publishing version $VERSION"
5555
56+
- name: Check version not already released
57+
env:
58+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
59+
run: |
60+
VERSION="${{ steps.version.outputs.version }}"
61+
if gh release view "v$VERSION" --repo "${{ github.repository }}" >/dev/null 2>&1; then
62+
echo "::error::v$VERSION is already released. Bump version.json before dispatching Release again."
63+
exit 1
64+
fi
65+
5666
- name: Generate release notes
5767
id: changelog
58-
uses: orhun/git-cliff-action@v4
68+
uses: orhun/git-cliff-action@f50e11560dce63f7c33227798f90b924471a88b5 # v4
5969
with:
6070
config: cliff.toml
6171
args: --unreleased --tag v${{ steps.version.outputs.version }} --strip header
6272
env:
6373
OUTPUT: CHANGES.md
6474

75+
# Uses gh release create directly instead of a third-party release action
76+
# -- one less individual-maintainer action holding contents: write.
6577
- name: Create GitHub Release
66-
uses: softprops/action-gh-release@v3
67-
with:
68-
name: v${{ steps.version.outputs.version }}
69-
tag_name: v${{ steps.version.outputs.version }}
70-
body_path: CHANGES.md
71-
files: |
72-
./artifacts/*.nupkg
73-
./artifacts/*.snupkg
7478
env:
7579
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
80+
run: |
81+
gh release create "v${{ steps.version.outputs.version }}" \
82+
./artifacts/*.nupkg ./artifacts/*.snupkg \
83+
--repo "${{ github.repository }}" \
84+
--title "v${{ steps.version.outputs.version }}" \
85+
--notes-file CHANGES.md \
86+
--target "${{ github.sha }}"
7687
7788
- name: Publish to GitHub Packages
7889
run: |
@@ -88,7 +99,7 @@ jobs:
8899
# https://learn.microsoft.com/nuget/nuget-org/trusted-publishing
89100
- name: NuGet login (trusted publishing)
90101
id: nuget-login
91-
uses: NuGet/login@v1
102+
uses: NuGet/login@8d196754b4036150537f80ac539e15c2f1028841 # v1
92103
with:
93104
user: ${{ secrets.NUGET_USER }}
94105

@@ -125,25 +136,37 @@ jobs:
125136
- uses: actions/checkout@v7
126137

127138
- name: Promote unshipped public API
128-
run: python3 eng/mark_api_shipped.py
139+
run: python3 eng/mark_api_shipped.py --version ${{ needs.release.outputs.version }}
140+
141+
- name: Require automation token
142+
env:
143+
AUTOMATION_TOKEN: ${{ secrets.AUTOMATION_TOKEN }}
144+
run: |
145+
if [ -z "$AUTOMATION_TOKEN" ]; then
146+
echo "::error::AUTOMATION_TOKEN is not set. The default GITHUB_TOKEN cannot open this PR: pushes made with it do not trigger the required 'build' check, so auto-merge would stall silently."
147+
exit 1
148+
fi
129149
130150
- name: Open public API promotion PR
131151
id: cpr
132-
uses: peter-evans/create-pull-request@v8
152+
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8
133153
with:
134154
# AUTOMATION_TOKEN lets the required `build` check run on the PR; with
135155
# the default GITHUB_TOKEN, pushes from a workflow do not trigger CI.
136-
token: ${{ secrets.AUTOMATION_TOKEN || secrets.GITHUB_TOKEN }}
137-
add-paths: src/**/PublicAPI.*.txt
138-
branch: chore/mark-api-shipped
156+
# Enforced by the "Require automation token" step above -- no fallback.
157+
token: ${{ secrets.AUTOMATION_TOKEN }}
158+
add-paths: |
159+
src/**/PublicAPI.*.txt
160+
Directory.Build.props
161+
branch: chore/mark-api-shipped-${{ needs.release.outputs.version }}
139162
delete-branch: true
140163
commit-message: "chore: mark public API shipped for v${{ needs.release.outputs.version }}"
141164
title: "chore: mark public API shipped for v${{ needs.release.outputs.version }}"
142165
labels: automated
143166
body: |
144-
Promotes the unshipped public API surface to shipped for **v${{ needs.release.outputs.version }}**.
167+
Promotes the unshipped public API surface to shipped for **v${{ needs.release.outputs.version }}** and bumps `PackageValidationBaselineVersion` to match.
145168
146-
Each `PublicAPI.Unshipped.txt` was folded into its sibling `PublicAPI.Shipped.txt` and reset to the `#nullable enable` header. Merge this so the next development cycle starts from a clean unshipped surface.
169+
Each `PublicAPI.Unshipped.txt` was folded into its sibling `PublicAPI.Shipped.txt` and reset to the `#nullable enable` header. Merge this so the next development cycle starts from a clean unshipped surface and package validation compares against the release that just shipped.
147170
148171
# Hands-off promotion, mirroring the dependabot auto-approve-and-merge flow.
149172
# Approve with GITHUB_TOKEN (the github-actions[bot] identity) so the review

.github/workflows/version-bump.yml

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,15 +30,25 @@ jobs:
3030
id: bump
3131
run: python3 eng/bump_version.py "${{ github.event.inputs.bump_type }}"
3232

33+
- name: Require automation token
34+
env:
35+
AUTOMATION_TOKEN: ${{ secrets.AUTOMATION_TOKEN }}
36+
run: |
37+
if [ -z "$AUTOMATION_TOKEN" ]; then
38+
echo "::error::AUTOMATION_TOKEN is not set. The default GITHUB_TOKEN cannot open this PR: pushes made with it do not trigger the required 'build' check, so an admin would have to bypass the ruleset to merge."
39+
exit 1
40+
fi
41+
3342
- name: Open version bump PR
34-
uses: peter-evans/create-pull-request@v8
43+
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8
3544
with:
3645
# A PAT or GitHub App token (AUTOMATION_TOKEN) lets the required `build`
3746
# check run on the PR. With the default GITHUB_TOKEN, pushes from this
3847
# workflow do not trigger CI, so an admin must bypass the ruleset to merge.
39-
token: ${{ secrets.AUTOMATION_TOKEN || secrets.GITHUB_TOKEN }}
48+
# Enforced by the "Require automation token" step above -- no fallback.
49+
token: ${{ secrets.AUTOMATION_TOKEN }}
4050
add-paths: version.json
41-
branch: chore/version-bump
51+
branch: chore/version-bump-${{ steps.bump.outputs.version }}
4252
delete-branch: true
4353
commit-message: "chore: bump version to ${{ steps.bump.outputs.version }}"
4454
title: "chore: bump version to ${{ steps.bump.outputs.version }}"

Directory.Build.props

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,8 @@
2222
<!-- NuGet package validation -->
2323
<PropertyGroup Condition="'$(IsPackable)' != 'false'">
2424
<EnablePackageValidation>true</EnablePackageValidation>
25-
<!-- Set this after shipping the first stable release:
26-
<PackageValidationBaselineVersion>1.0.0</PackageValidationBaselineVersion> -->
25+
<!-- Bumped by eng/mark_api_shipped.py to the version just released. -->
26+
<PackageValidationBaselineVersion>1.0.1</PackageValidationBaselineVersion>
2727
</PropertyGroup>
2828

2929
<PropertyGroup Condition="'$(IsPackable)' != 'false' and Exists('README.md')">

eng/mark_api_shipped.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,16 +13,25 @@
1313
Existing line order is preserved (the analyzer emits entries in declaration
1414
order, not sorted), so promotion adds no reordering churn. The count of
1515
promoted files is written to the GITHUB_OUTPUT "promoted" key for the caller.
16+
17+
When --version is given, this also bumps Directory.Build.props'
18+
PackageValidationBaselineVersion to that version, so the next pack validates
19+
against the release that just shipped instead of going stale.
1620
"""
1721
import argparse
1822
import glob
1923
import os
24+
import re
2025
import sys
2126

2227
NULLABLE_HEADER = "#nullable enable"
2328
REMOVED_PREFIX = "*REMOVED*"
2429
UNSHIPPED_NAME = "PublicAPI.Unshipped.txt"
2530
SHIPPED_NAME = "PublicAPI.Shipped.txt"
31+
BASELINE_FILE = "Directory.Build.props"
32+
BASELINE_PATTERN = re.compile(
33+
r"(<PackageValidationBaselineVersion>)[^<]*(</PackageValidationBaselineVersion>)"
34+
)
2635

2736

2837
def read_entries(path):
@@ -83,9 +92,25 @@ def promote(unshipped_path):
8392
return True
8493

8594

95+
def bump_validation_baseline(version, path=BASELINE_FILE):
96+
with open(path, encoding="utf-8") as handle:
97+
content = handle.read()
98+
99+
updated, count = BASELINE_PATTERN.subn(rf"\g<1>{version}\g<2>", content)
100+
if count == 0:
101+
print(f"::warning::{path}: no <PackageValidationBaselineVersion> element found; skipped.")
102+
return False
103+
104+
with open(path, "w", encoding="utf-8", newline="\n") as handle:
105+
handle.write(updated)
106+
print(f"Bumped package validation baseline: {path} -> {version}")
107+
return True
108+
109+
86110
def main():
87111
parser = argparse.ArgumentParser(description=__doc__)
88112
parser.add_argument("--root", default="src", help="Directory to scan (default: src)")
113+
parser.add_argument("--version", help="Version just released; also bumps PackageValidationBaselineVersion")
89114
args = parser.parse_args()
90115

91116
pattern = os.path.join(args.root, "**", UNSHIPPED_NAME)
@@ -95,6 +120,9 @@ def main():
95120

96121
print(f"Promoted public API in {promoted} project(s).")
97122

123+
if args.version:
124+
bump_validation_baseline(args.version)
125+
98126
output = os.environ.get("GITHUB_OUTPUT")
99127
if output:
100128
with open(output, "a", encoding="utf-8") as handle:

0 commit comments

Comments
 (0)