From 3955f0f08ca6104a3880c3f30d2f4a9296c225a7 Mon Sep 17 00:00:00 2001 From: Andrew Kent Date: Fri, 22 May 2026 11:28:53 -0600 Subject: [PATCH] release gating for c# sdk --- ...blish-release-from-tag.yml => release.yml} | 213 ++++++++++++------ CONTRIBUTING.md | 115 ++++++++++ scripts/release.sh | 19 ++ scripts/verify-release-config.sh | 4 +- 4 files changed, 283 insertions(+), 68 deletions(-) rename .github/workflows/{publish-release-from-tag.yml => release.yml} (54%) create mode 100644 CONTRIBUTING.md diff --git a/.github/workflows/publish-release-from-tag.yml b/.github/workflows/release.yml similarity index 54% rename from .github/workflows/publish-release-from-tag.yml rename to .github/workflows/release.yml index c90fc71..2b08d1e 100644 --- a/.github/workflows/publish-release-from-tag.yml +++ b/.github/workflows/release.yml @@ -1,99 +1,174 @@ -# This workflow is triggered when a new tag is pushed to main. -# It can also be run manually to re-publish a release in case it failed for some reason. -name: Publish Release From Tag +# Drives a release end-to-end from GitHub Actions in a single workflow. +# +# Click "Run workflow", enter a version like v1.2.3 and the full 40-char +# commit SHA to release, and this will: +# 1. Validate the version (semver) and the SHA. +# 2. Verify the SHA is reachable from origin/main. +# 3. Run the full CI gate (format, build, test) on the pinned SHA. +# 4. Create and push the annotated tag vX.Y.Z pointing at the SHA +# (using GITHUB_TOKEN). +# 5. Check out the tag and re-run the CI gate at the tag. +# 6. Pack all NuGet packages with the released version. +# 7. Create the GitHub Release and upload the .nupkg / .snupkg files. +# 8. Publish to NuGet.org via OIDC trusted publishing. +# +# The releaser must supply an explicit commit SHA (not a branch name) so +# that commits which land on main during the environment approval gate +# are NOT silently included in the release. +# +# Re-publishing a failed release: re-run this workflow with the same +# version (and any valid SHA on main -- it is ignored once the tag +# exists). The tag-creation step is skipped, GitHub Release asset +# uploads use --clobber, and dotnet nuget push uses --skip-duplicate, so +# the workflow is safe to re-run. +# +# Environment gating: +# +# - Stable releases (e.g. v1.2.3) run in the protected `release` +# GitHub Environment, which requires reviewer approval before any +# tag is pushed or any artifact is published. +# - Prereleases (any version containing `-`, e.g. v1.2.3-beta.1) run +# in the `release-prerelease` Environment, which holds the same +# publish secrets but does NOT require reviewer approval. This +# keeps iteration on prereleases fast. +# +# Both environments must be configured in repo settings (Settings -> +# Environments) with the NuGet publish secrets (NUGET_USER). Only the +# `release` environment should have required reviewers. +name: Release on: - push: - tags: - - 'v*' workflow_dispatch: inputs: - tag: - description: 'Tag to publish (e.g., v1.0.0)' + version: + description: 'Version to release (e.g., v1.2.3)' + required: true + type: string + sha: + description: 'Full 40-char commit SHA to tag. Required so the releaser controls exactly what ships, even if main advances during the approval gate. Ignored if the tag already exists.' required: true type: string permissions: contents: write - id-token: write # Required for OIDC trusted publishing + id-token: write # Required for NuGet OIDC trusted publishing jobs: - validate-and-publish: - name: Validate Tag and Publish Release + release: + name: Release # we want to run ubuntu-latest but we'll pin to a specific version so workflow is reproducable runs-on: ubuntu-24.04 + # Gate stable releases behind the protected `release` GitHub + # Environment (required reviewers). Prereleases -- any semver with + # a `-` suffix, e.g. v1.2.3-beta.1 -- run in `release-prerelease`, + # which holds the same publish secrets but has no approval gate so + # iteration on prereleases stays fast. + # + # The validation step below enforces the semver shape + # vX.Y.Z(-prerelease)?, so this `contains` check is safe: stable + # versions never contain `-`, prereleases always do. + environment: ${{ contains(inputs.version, '-') && 'release-prerelease' || 'release' }} steps: - - name: Checkout code - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - with: - fetch-depth: 0 - - - name: Determine tag - id: determine-tag + - name: Validate inputs run: | - if [[ "${{ github.event_name }}" == "push" ]]; then - TAG_NAME="${{ github.ref_name }}" - else - TAG_NAME="${{ inputs.tag }}" - fi - echo "tag=$TAG_NAME" >> $GITHUB_OUTPUT - echo "Using tag: $TAG_NAME" - - - name: Validate tag format - run: | - TAG="${{ steps.determine-tag.outputs.tag }}" - - # Check if tag starts with 'v' - if [[ ! "$TAG" =~ ^v ]]; then - echo "Error: Tag '$TAG' must start with 'v'" + V="${{ inputs.version }}" + if [[ ! "$V" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.-]+)?$ ]]; then + echo "Error: version must be semver (e.g. v1.2.3 or v1.2.3-beta.1)" >&2 exit 1 fi - - # Extract version without 'v' prefix - VERSION="${TAG#v}" - - # Check if version is valid semver (x.y.z or x.y.z-prerelease) - if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.-]+)?$ ]]; then - echo "Error: Tag '$TAG' is not valid semver format (vx.y.z or vx.y.z-prerelease)" + SHA="${{ inputs.sha }}" + if [[ ! "$SHA" =~ ^[0-9a-f]{40}$ ]]; then + echo "Error: sha must be a full 40-character lowercase commit SHA. Got: '$SHA'" >&2 + echo "Tip: copy the SHA from the commit page on GitHub (use the 'Copy full SHA' button)." >&2 exit 1 fi - echo "Tag '$TAG' is valid" - echo "version=$VERSION" >> $GITHUB_OUTPUT - id: validate-tag + - name: Checkout + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + ref: ${{ inputs.sha }} + fetch-depth: 0 - - name: Verify tag exists + - name: Verify SHA is reachable from main run: | - TAG="${{ steps.determine-tag.outputs.tag }}" - if ! git tag -l | grep -q "^$TAG$"; then - echo "Error: Tag '$TAG' does not exist" + SHA="${{ inputs.sha }}" + git fetch origin main --quiet + if ! git merge-base --is-ancestor "$SHA" origin/main; then + echo "Error: commit $SHA is not an ancestor of origin/main." >&2 + echo "Releases must be cut from commits that have landed on main." >&2 exit 1 fi - echo "Tag '$TAG' exists" + echo "Commit $SHA is reachable from origin/main." - - name: Checkout tag + - name: Determine whether tag already exists + id: tag-state run: | - git checkout ${{ steps.determine-tag.outputs.tag }} + TAG="${{ inputs.version }}" + git fetch --tags --quiet + if git rev-parse -q --verify "refs/tags/$TAG" >/dev/null; then + echo "exists=true" >> "$GITHUB_OUTPUT" + echo "Tag '$TAG' already exists; will publish from the existing tag." + elif git ls-remote --tags origin | grep -q "refs/tags/${TAG}$"; then + echo "exists=true" >> "$GITHUB_OUTPUT" + echo "Tag '$TAG' exists on origin but not locally; fetching." + git fetch origin "refs/tags/$TAG:refs/tags/$TAG" + else + echo "exists=false" >> "$GITHUB_OUTPUT" + echo "Tag '$TAG' does not exist yet; will create at $SHA." + fi - name: Set up .NET 8.0 uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1 with: dotnet-version: '8.0.x' - - name: Restore dependencies + - name: Restore dependencies (pre-tag, on chosen ref) + if: steps.tag-state.outputs.exists == 'false' run: dotnet restore - - name: Check code formatting + - name: Check code formatting (pre-tag, on chosen ref) + if: steps.tag-state.outputs.exists == 'false' run: dotnet format --verify-no-changes - - name: Run CI (build and test) + - name: Run CI (pre-tag, on chosen ref) + if: steps.tag-state.outputs.exists == 'false' + run: | + dotnet build --no-restore --configuration Release + dotnet test --no-build --configuration Release --verbosity normal + + - name: Configure git identity + if: steps.tag-state.outputs.exists == 'false' + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + - name: Create and push tag + if: steps.tag-state.outputs.exists == 'false' + run: | + TAG="${{ inputs.version }}" + SHA="${{ inputs.sha }}" + git tag -a "$TAG" -m "Release $TAG" "$SHA" + git push origin "$TAG" + + - name: Checkout tag + run: git checkout "${{ inputs.version }}" + + - name: Restore dependencies (at tag) + run: dotnet restore + + - name: Check code formatting (at tag) + run: dotnet format --verify-no-changes + + - name: Run CI (at tag) run: | dotnet build --no-restore --configuration Release dotnet test --no-build --configuration Release --verbosity normal - name: Pack NuGet packages run: | - VERSION="${{ steps.validate-tag.outputs.version }}" + TAG="${{ inputs.version }}" + # Strip 'v' prefix to get the actual package version + VERSION="${TAG#v}" dotnet pack src/Braintrust.Sdk/Braintrust.Sdk.csproj \ --configuration Release \ --no-build \ @@ -128,7 +203,8 @@ jobs: - name: Find built artifacts id: find-artifacts run: | - VERSION="${{ steps.validate-tag.outputs.version }}" + TAG="${{ inputs.version }}" + VERSION="${TAG#v}" # Find the built NuGet packages NUPKG=$(find ./artifacts -name "Braintrust.Sdk.${VERSION}.nupkg" | head -1) @@ -185,16 +261,22 @@ jobs: echo " AzureOpenAI symbols package: $AZUREOPENAI_SNUPKG" fi - - name: Create GitHub Release + - name: Create or update GitHub Release + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - TAG="${{ steps.determine-tag.outputs.tag }}" + TAG="${{ inputs.version }}" - # Create the release - gh release create "$TAG" \ - --generate-notes \ - --title "Release $TAG" + # Create the release if it doesn't already exist (re-publish path). + if ! gh release view "$TAG" >/dev/null 2>&1; then + gh release create "$TAG" \ + --generate-notes \ + --title "Release $TAG" + else + echo "Release '$TAG' already exists; will upload (clobber) assets." + fi - # Upload artifacts if they exist + # Upload artifacts if they exist, clobbering any partial uploads from a prior run. for artifact in \ "${{ steps.find-artifacts.outputs.nupkg }}" \ "${{ steps.find-artifacts.outputs.snupkg }}" \ @@ -207,11 +289,9 @@ jobs: "${{ steps.find-artifacts.outputs.azureopenai_nupkg }}" \ "${{ steps.find-artifacts.outputs.azureopenai_snupkg }}"; do if [[ -n "$artifact" && -f "$artifact" ]]; then - gh release upload "$TAG" "$artifact" + gh release upload "$TAG" "$artifact" --clobber fi done - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: NuGet login (OIDC trusted publishing) uses: NuGet/login@d22cc5f58ff5b88bf9bd452535b4335137e24544 # v1.1.0 @@ -241,7 +321,8 @@ jobs: - name: Wait for NuGet.org indexing run: | - VERSION="${{ steps.validate-tag.outputs.version }}" + TAG="${{ inputs.version }}" + VERSION="${TAG#v}" echo "Packages published! It may take a few minutes to appear on NuGet.org" echo "Check status at:" echo " https://www.nuget.org/packages/Braintrust.Sdk/$VERSION" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..3e6625b --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,115 @@ +# Contributing + +## Releasing + +Releases of the Braintrust .NET SDK are cut end-to-end from a single +gated GitHub Actions workflow. Release managers do **not** push tags +from a local checkout. + +### How to cut a release + +1. Pick the commit you want to release. Copy its full 40-character SHA + from the GitHub commit page (use the "Copy full SHA" button). +2. Go to **Actions → Release → Run workflow**. +3. Fill in: + - `version` — semver tag like `v1.2.3` (or `v1.2.3-beta.1` for a + prerelease). + - `sha` — the full 40-char commit SHA from step 1. +4. Click **Run workflow**. The job will pause on the protected + `release` environment until a required reviewer approves it. +5. After approval, the workflow validates the inputs, verifies the SHA + is reachable from `origin/main`, runs CI on the pinned SHA, creates + and pushes the annotated tag `vX.Y.Z` pointing at that SHA, checks + out the tag and re-runs CI, packs all NuGet packages, creates the + GitHub Release with the `.nupkg`/`.snupkg` files attached, and + publishes to NuGet.org via OIDC trusted publishing. + +### Why the SHA is required (and not just a branch name) + +The workflow pauses at the environment approval gate. During that +pause, new commits can land on `main`. If we tagged "whatever `main` +is right now" at publish time, those just-landed commits would be +silently included in the release. + +Requiring the releaser to pin an explicit commit SHA at dispatch time +makes the released contents reviewable: what gets approved is exactly +what ships, regardless of how long the approval takes. + +### Approval gate and secrets + +The release job runs in one of two GitHub Environments depending on +whether the version is a stable release or a prerelease: + +- **`release`** — used for stable versions (e.g. `v1.2.3`). + Configured in **Settings → Environments → release** with **required + reviewers** (release managers) and the NuGet publish secrets + (`NUGET_USER`, plus anything else the publish step relies on). +- **`release-prerelease`** — used for prereleases (any semver with a + `-` suffix, e.g. `v1.2.3-beta.1`). Configured in **Settings → + Environments → release-prerelease** with the **same publish + secrets** but **no required reviewers**, so prerelease iteration is + fast. + +The workflow picks the environment dynamically from the `version` +input via `contains(inputs.version, '-')`. The input validation step +enforces the semver shape `vX.Y.Z(-prerelease)?`, so the check is +safe: stable versions never contain `-`, prereleases always do. + +Secrets are scoped to each environment, so they are only accessible to +jobs that have entered that environment. + +If you are cutting a stable release and forget to leave off the +prerelease suffix, the workflow will silently take the ungated path. +Double-check the version before you click "Run workflow". + +### Re-publishing a failed release + +If a release fails partway through (e.g. NuGet flaked on one of the +packages), re-run the `Release` workflow with the **same** `version`. +Any valid SHA on `main` may be passed for `sha`; it is ignored once +the tag already exists. + +On re-run, the workflow: + +- Detects the existing tag and skips the tag-creation step. +- Re-runs CI at the tag. +- Re-packs all NuGet packages. +- Updates the existing GitHub Release and re-uploads assets with + `--clobber` so partial uploads from the prior run are replaced. +- Re-pushes to NuGet.org with `--skip-duplicate`, so packages that + already made it through on the previous attempt are not treated as + failures. + +### No source version constant to bump + +Package versions are passed into `dotnet pack` at build time via +`-p:PackageVersion="$VERSION"`. There is no version constant in source +to bump as part of preparing a release — the release workflow derives +everything from the `version` input. + +### Adding a new package + +If you add a new project under `src/` that should ship as a NuGet +package, `scripts/verify-release-config.sh` (run in CI) will fail +until you also: + +1. Add the project to `Braintrust.Sdk.sln`. +2. Add a `dotnet pack` invocation for it in + `.github/workflows/release.yml` (the "Pack NuGet packages" step). +3. Add a `find ./artifacts -name ".${VERSION}.nupkg"` entry + in the "Find built artifacts" step, and corresponding `.snupkg` + lookup if the project produces symbols. +4. Reference the new `nupkg`/`snupkg` outputs in the "Create or update + GitHub Release" step (uploads) and the "Publish to NuGet.org" step + (push loop). +5. Add a status URL line in the "Wait for NuGet.org indexing" step. + +### Local fallback + +`scripts/release.sh` is retained as a local fallback for testing tag +creation (e.g. `./scripts/release.sh v1.2.3 --skip-push` to make the +tag locally without pushing). It does **not** publish to NuGet and is +not the canonical release path. The `push: tags: v*` workflow trigger +has been removed, so pushing a tag from this script will no longer +trigger any release automation — only the gated `Release` workflow +publishes artifacts. diff --git a/scripts/release.sh b/scripts/release.sh index efa2bb4..0be8781 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -1,4 +1,23 @@ #!/usr/bin/env bash +# +# Local fallback for cutting a release tag. +# +# The CANONICAL release path is the gated GitHub Actions workflow: +# Actions -> Release -> Run workflow +# That workflow runs in the protected `release` environment, which +# requires reviewer approval and holds the NuGet publish secrets. See +# CONTRIBUTING.md ("Releasing") for the end-to-end flow. +# +# This script is kept as a local fallback for testing tag creation +# (e.g. with --skip-push) and for emergencies where the Actions UI is +# not usable. It does NOT publish to NuGet.org and does NOT create a +# GitHub Release; it only validates, runs `dotnet test`, and pushes a +# vX.Y.Z tag. +# +# Note that with the new gated release workflow the `push: tags: v*` +# trigger has been REMOVED. Pushing a tag with this script will no +# longer publish anything on its own -- a release manager still has to +# run the `Release` workflow from the Actions UI to ship the artifacts. set -euo pipefail diff --git a/scripts/verify-release-config.sh b/scripts/verify-release-config.sh index c869015..a4cd088 100755 --- a/scripts/verify-release-config.sh +++ b/scripts/verify-release-config.sh @@ -8,7 +8,7 @@ set -euo pipefail REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" -RELEASE_WORKFLOW="$REPO_ROOT/.github/workflows/publish-release-from-tag.yml" +RELEASE_WORKFLOW="$REPO_ROOT/.github/workflows/release.yml" SOLUTION_FILE="$REPO_ROOT/Braintrust.Sdk.sln" errors=0 @@ -81,7 +81,7 @@ echo "" if [[ $errors -gt 0 ]]; then echo "FAILED: Found $errors error(s). New src/ projects must be added to:" echo " 1. The solution file (Braintrust.Sdk.sln)" - echo " 2. The release workflow (.github/workflows/publish-release-from-tag.yml):" + echo " 2. The release workflow (.github/workflows/release.yml):" echo " - 'Pack NuGet packages' step (dotnet pack)" echo " - 'Find built artifacts' step" echo " - 'Create GitHub Release' step (upload)"