diff --git a/.github/workflows/release-dryrun.yml b/.github/workflows/release-dryrun.yml new file mode 100644 index 0000000..a1f59a7 --- /dev/null +++ b/.github/workflows/release-dryrun.yml @@ -0,0 +1,64 @@ +name: release-dryrun + +# Continuously rehearses the SIDE-EFFECT-FREE portion of the release pipeline — build, pack, and embed the +# SBOM for the two published projects — on every push to main and every pull request. release.yml itself +# runs only on a tag or a manual dispatch, so its packaging path is otherwise exercised for the first time +# in production, on a tag, once; this catches packaging/SBOM regressions in ordinary CI instead. +# +# It deliberately stops before every step that has a side effect: +# * no provenance attestation — that writes a permanent public record (Sigstore/Rekor + the attestation +# store); it is kept to the manual `release.yml` dispatch dry-run and to +# real releases, where it runs once and on purpose; +# * no NuGet login / push — nuget.org has no "dry-run push", so the actual publish stays release-only; +# * no GitHub Release — no tag or release is ever created here. +# Least privilege follows from that: this workflow only reads the repository. + +on: + push: + branches: + - main + pull_request: + branches: + - main + workflow_dispatch: + +# Cancel superseded runs on the same branch / PR. +concurrency: + group: release-dryrun-${{ github.ref }} + cancel-in-progress: true + +# Read-only: no publishing, no OIDC, no attestation — so none of release.yml's write scopes are needed. +permissions: + contents: read + +env: + DOTNET_NOLOGO: 'true' + DOTNET_CLI_TELEMETRY_OPTOUT: 'true' + DOTNET_SKIP_FIRST_TIME_EXPERIENCE: 'true' + # A throwaway version: nothing is published, so the exact value is irrelevant — it only has to be a valid + # SemVer the pack accepts. (release.yml's version comes from the tag and is validated there.) + DRYRUN_VERSION: '0.0.0-dryrun' + +jobs: + pack: + name: Dry-run pack (build + SBOM, no publish) + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + + - name: Setup .NET + uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5 + with: + # The release SDK — the same one release.yml packs with, so this rehearses the real pack path. + dotnet-version: '10.0.x' + + - name: Build + run: dotnet build FirstClassErrors.sln -c Release -p:Version="$DRYRUN_VERSION" + + # The SAME script release.yml packs with — it packs the two published projects with their SBOM and + # then asserts the SBOM is actually embedded. Sharing it is the point: this rehearsal cannot drift + # from the real release, because there is only one definition of "pack the release artifacts". + # (The unit/integration tests run in ci.yml; this job's unique contribution is the packaging.) + - name: Pack with SBOM (build artifacts, no publish) + run: tools/packaging/pack.sh "$DRYRUN_VERSION" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index de84e22..d0696fa 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -8,8 +8,20 @@ on: workflow_dispatch: inputs: version: - description: 'Package version to publish, without the leading "v" (e.g. 1.2.3)' + description: 'Package version, without the leading "v" (e.g. 1.2.3; a dry run can use any SemVer, e.g. 0.0.0-dry.1)' required: true + # release.yml is the only workflow no CI run exercises before a real tag: version resolution, + # pack, SBOM generation, OIDC and the attestation permissions only ever execute in production + # conditions. Three defects were found here that were invisible from the repository (the + # +metadata regex, the nuget.org re-signing mismatch, the dispatch-tag target). Dry run makes + # all of it testable on demand: everything runs up to AND INCLUDING the attestation — which + # deliberately stays in, since OIDC/permission failures are exactly what it rehearses — while + # NuGet login, the push and the GitHub Release are skipped. Defaults to true so an accidental + # dispatch publishes nothing; publishing requires explicitly unticking it. + dry_run: + description: 'Dry run: stop after pack + attestation; skip NuGet push and the GitHub Release' + type: boolean + default: true # Never publish the same ref twice concurrently. concurrency: @@ -89,14 +101,13 @@ jobs: # bundled inside the main package, and the CLI / worker / samples are not published. # GenerateSBOM activates Microsoft.Sbom.Targets (referenced by both packable projects): each # package embeds its SPDX inventory at _manifest/spdx_2.2/manifest.spdx.json. Deliberately - # passed only HERE, not hardcoded in the csproj, so local packs and the floor-check job's - # throwaway pack stay fast and SBOM-free — only published artifacts carry one. + # tools/packaging/pack.sh is the single source of truth for producing the packages (packed projects, + # flags, embedded SBOM, and the SBOM-present check). The release-dryrun workflow calls the same script, + # so the automatic rehearsal can never drift from the real release. - name: Pack env: VERSION: ${{ steps.version.outputs.version }} - run: | - dotnet pack FirstClassErrors/FirstClassErrors.csproj -c Release --no-build -p:Version="$VERSION" -p:GenerateSBOM=true -o artifacts - dotnet pack FirstClassErrors.Testing/FirstClassErrors.Testing.csproj -c Release --no-build -p:Version="$VERSION" -p:GenerateSBOM=true -o artifacts + run: tools/packaging/pack.sh "$VERSION" - name: Upload packages as build artifacts uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 @@ -127,13 +138,18 @@ jobs: # Requires a trusted publishing policy on nuget.org (owner Reefact / repo first-class-errors / # workflow release.yml) for each published package, and the NUGET_USER secret set to the nuget.org # account username (profile name, not the email address). + # Everything from here on PUBLISHES and is skipped on a dry run. A tag push always publishes + # (inputs.* is empty on push events, so the event check short-circuits first); a manual dispatch + # publishes only with dry_run explicitly unticked. - name: NuGet login (OIDC) + if: github.event_name == 'push' || inputs.dry_run == false id: nuget-login uses: NuGet/login@8d196754b4036150537f80ac539e15c2f1028841 # v1 with: user: ${{ secrets.NUGET_USER }} - name: Push to NuGet + if: github.event_name == 'push' || inputs.dry_run == false env: NUGET_API_KEY: ${{ steps.nuget-login.outputs.NUGET_API_KEY }} run: | @@ -152,6 +168,7 @@ jobs: # even when dispatched from a hotfix branch or an older SHA. The `|| upload --clobber` fallback # keeps a re-run idempotent if the release already exists. - name: Publish GitHub Release with attested packages + if: github.event_name == 'push' || inputs.dry_run == false env: GH_TOKEN: ${{ github.token }} VERSION: ${{ steps.version.outputs.version }} diff --git a/maintainers/ReleaseDryRun.en.md b/maintainers/ReleaseDryRun.en.md new file mode 100644 index 0000000..7b77185 --- /dev/null +++ b/maintainers/ReleaseDryRun.en.md @@ -0,0 +1,94 @@ +# Release dry run (manual) + +> Maintainer / operational documentation. This is **not** part of the library's +> user documentation under `doc/`. + +## What it is + +The `release` workflow (`.github/workflows/release.yml`) publishes the NuGet +packages. It normally runs **only when a version tag is pushed** (`v1.2.3`), so +its whole pipeline — version parsing, build, test, pack, SBOM generation, OIDC, +provenance attestation, and the publish steps — otherwise runs *for the first +time in production, on a tag, once*. + +The **manual dry run** lets you run that same pipeline **on demand**, all the way +through the provenance attestation, **without publishing anything**. It is a +rehearsal: you confirm the release machinery is healthy before it matters. + +## What it does — and does not do + +| Step | Real release (tag push) | Dry run | +| --- | --- | --- | +| Resolve & validate version | ✅ | ✅ | +| Restore, build, test | ✅ | ✅ | +| Pack the two published projects | ✅ | ✅ | +| Embed the SPDX SBOM | ✅ | ✅ | +| Upload packages as workflow artifacts | ✅ | ✅ | +| **Sign the provenance attestation** | ✅ | ✅ (see *Impacts*) | +| Log in to NuGet (OIDC) | ✅ | ⛔ skipped | +| **Push to nuget.org** | ✅ | ⛔ skipped | +| **Create the GitHub Release** | ✅ | ⛔ skipped | + +The three publish steps are gated on +`github.event_name == 'push' || inputs.dry_run == false`, so: + +- a **tag push always publishes** — the normal release path is untouched; +- a **manual run publishes only if you explicitly untick `dry_run`**. + +## How to run it + +1. On GitHub, open the **Actions** tab. +2. In the left sidebar, select the **release** workflow. +3. Click **Run workflow** (top right). +4. Fill in the inputs: + - **version** — any valid SemVer; use an obviously fake one such as + `0.0.0-dry.1` (nothing is published, so the value only has to be valid). + - **dry run** — **already ticked by default**. Leave it ticked. +5. Click **Run workflow** and watch the run. + +If the run is green through *Attest build provenance*, the release pipeline is +healthy. + +## Impacts + +A dry run is *almost* free of side effects, with one exception to be aware of: + +- **It creates a real provenance attestation.** The `Attest build provenance` + step runs in a dry run (on purpose — OIDC and attestation-permission failures + are exactly what it is there to catch). That attestation is written to the + repository's attestation store and to the public Sigstore transparency log — + it is **permanent and public**, and references the throwaway version. This is + harmless but not nothing, so: + - use a clearly fake version (`0.0.0-dry.N`) so a throwaway attestation is + never mistaken for a real release; + - run the manual dry run deliberately (before a real release, or after + changing `release.yml`), not casually in a loop. +- **Nothing is published.** No package reaches nuget.org, and no GitHub Release + or git tag is created. +- **The packed `.nupkg` / `.snupkg` are uploaded as workflow-run artifacts**, + which you can download from the run page to inspect, and which expire on the + repository's normal artifact retention. + +## When to use it + +- Before cutting an important release, as a final smoke test of the pipeline. +- After changing `release.yml`, the packable `.csproj` files, or the packaging + configuration (`Directory.Build.props`), since those changes are otherwise + unverified until a real tag. + +## Related: the automatic dry run + +For the **side-effect-free** part of the pipeline — build, pack, and SBOM +embedding — there is nothing to trigger by hand: the `release-dryrun` workflow +(`.github/workflows/release-dryrun.yml`) runs it automatically on **every pull +request and push to `main`**, and fails if the SBOM stops being embedded. It has +no attestation and no publish, so it runs continuously with no side effects. + +Use the **manual** dry run documented here when you additionally want to rehearse +the **attestation / OIDC** path that the automatic one deliberately leaves out. + +## What neither dry run can test + +The actual **push to nuget.org** and the **repository-signed bytes** nuget.org +serves cannot be exercised without publishing — nuget.org has no "dry-run push". +That final link is only ever validated by a real release. diff --git a/maintainers/ReleaseDryRun.fr.md b/maintainers/ReleaseDryRun.fr.md new file mode 100644 index 0000000..9850eda --- /dev/null +++ b/maintainers/ReleaseDryRun.fr.md @@ -0,0 +1,102 @@ +# Répétition de release à blanc (« dry run » manuel) + +> Documentation technique / mainteneur. Elle ne fait **pas** partie de la +> documentation utilisateur de la librairie, sous `doc/`. + +## De quoi s'agit-il + +Le workflow `release` (`.github/workflows/release.yml`) publie les packages +NuGet. Il ne s'exécute normalement **que lorsqu'un tag de version est poussé** +(`v1.2.3`) : tout son pipeline — analyse de la version, build, tests, pack, +génération du SBOM, OIDC, attestation de provenance et étapes de publication — +ne tourne donc *pour la première fois qu'en production, sur un tag, une seule +fois*. + +Le **dry run manuel** permet de lancer ce même pipeline **à la demande**, +jusqu'à l'attestation de provenance incluse, **sans rien publier**. C'est une +répétition : on vérifie que la machinerie de release est saine avant que ça ne +compte. + +## Ce qu'il fait — et ne fait pas + +| Étape | Release réelle (push de tag) | Dry run | +| --- | --- | --- | +| Résoudre & valider la version | ✅ | ✅ | +| Restore, build, tests | ✅ | ✅ | +| Packer les deux projets publiés | ✅ | ✅ | +| Embarquer le SBOM SPDX | ✅ | ✅ | +| Uploader les packages en artefacts de run | ✅ | ✅ | +| **Signer l'attestation de provenance** | ✅ | ✅ (voir *Impacts*) | +| Login NuGet (OIDC) | ✅ | ⛔ sauté | +| **Push vers nuget.org** | ✅ | ⛔ sauté | +| **Créer la GitHub Release** | ✅ | ⛔ sauté | + +Les trois étapes de publication sont conditionnées par +`github.event_name == 'push' || inputs.dry_run == false`, donc : + +- un **push de tag publie toujours** — le chemin de release normal est + inchangé ; +- un **lancement manuel ne publie que si tu décoches explicitement `dry_run`**. + +## Comment le lancer + +1. Sur GitHub, ouvre l'onglet **Actions**. +2. Dans la barre latérale de gauche, sélectionne le workflow **release**. +3. Clique **Run workflow** (en haut à droite). +4. Renseigne les entrées : + - **version** — n'importe quel SemVer valide ; utilise une version + manifestement factice comme `0.0.0-dry.1` (rien n'est publié, la valeur + doit seulement être valide). + - **dry run** — **déjà cochée par défaut**. Laisse-la cochée. +5. Clique **Run workflow** et observe le run. + +Si le run est vert jusqu'à *Attest build provenance*, le pipeline de release +est sain. + +## Impacts + +Un dry run est *presque* sans effet de bord, avec une exception à connaître : + +- **Il crée une vraie attestation de provenance.** L'étape `Attest build + provenance` s'exécute pendant un dry run (volontairement — les échecs d'OIDC + ou de permission d'attestation sont précisément ce qu'elle sert à détecter). + Cette attestation est écrite dans le magasin d'attestations du dépôt et dans + le journal de transparence public Sigstore : elle est **permanente et + publique**, et référence la version jetable. C'est inoffensif mais pas rien, + donc : + - utilise une version clairement factice (`0.0.0-dry.N`) pour qu'une + attestation jetable ne soit jamais confondue avec une vraie release ; + - lance le dry run manuel de façon délibérée (avant une vraie release, ou + après avoir modifié `release.yml`), pas en boucle par réflexe. +- **Rien n'est publié.** Aucun package n'atteint nuget.org, et aucune GitHub + Release ni aucun tag Git n'est créé. +- **Les `.nupkg` / `.snupkg` produits sont uploadés en artefacts de run**, que + tu peux télécharger depuis la page du run pour les inspecter, et qui expirent + selon la rétention d'artefacts normale du dépôt. + +## Quand l'utiliser + +- Avant de sortir une release importante, comme test de fumée final du + pipeline. +- Après avoir modifié `release.yml`, les `.csproj` empaquetés ou la + configuration de packaging (`Directory.Build.props`), puisque ces changements + restent sinon non vérifiés jusqu'à un vrai tag. + +## En rapport : le dry run automatique + +Pour la partie **sans effet de bord** du pipeline — build, pack et +embarquement du SBOM — il n'y a rien à déclencher à la main : le workflow +`release-dryrun` (`.github/workflows/release-dryrun.yml`) l'exécute +automatiquement à **chaque pull request et push sur `main`**, et échoue si le +SBOM cesse d'être embarqué. Il n'a ni attestation ni publication, donc il tourne +en continu sans effet de bord. + +Utilise le dry run **manuel** documenté ici quand tu veux en plus répéter le +chemin **attestation / OIDC** que l'automatique laisse volontairement de côté. + +## Ce qu'aucun dry run ne peut tester + +Le **push réel vers nuget.org** et les **octets re-signés par le dépôt** que +nuget.org sert ne peuvent pas être exercés sans publier — nuget.org n'a pas de +« push à blanc ». Ce dernier maillon n'est jamais validé que par une vraie +release. diff --git a/tools/packaging/pack.sh b/tools/packaging/pack.sh new file mode 100755 index 0000000..967eac9 --- /dev/null +++ b/tools/packaging/pack.sh @@ -0,0 +1,45 @@ +#!/bin/sh +# Single source of truth for producing the published NuGet packages. +# +# Both the real release (.github/workflows/release.yml) and the automatic +# rehearsal (.github/workflows/release-dryrun.yml) call this, so the dry run can +# never silently drift from the release it is meant to mirror: the set of packed +# projects, the pack flags, the embedded SBOM and the "is the SBOM actually +# there?" check all live here, once. +# +# It assumes the solution has already been built in Release (it packs with +# --no-build). It writes the .nupkg / .snupkg into ./artifacts. +# +# Usage: tools/packaging/pack.sh +# is any valid SemVer (a real release passes the tag version; the +# dry run passes a throwaway like 0.0.0-dryrun). + +set -eu + +if [ "$#" -ne 1 ] || [ -z "$1" ]; then + echo "usage: tools/packaging/pack.sh " >&2 + exit 2 +fi +version="$1" + +# The two projects that carry NuGet identity. GenerateSBOM embeds an SPDX SBOM +# (_manifest/spdx_2.2/manifest.spdx.json) inside each package; it is passed here, +# not hardcoded in the csproj, so local and floor-check packs stay SBOM-free. +for project in \ + FirstClassErrors/FirstClassErrors.csproj \ + FirstClassErrors.Testing/FirstClassErrors.Testing.csproj +do + dotnet pack "$project" -c Release --no-build -p:Version="$version" -p:GenerateSBOM=true -o artifacts +done + +# Positive proof, not just a green pack: a pack that silently stopped embedding +# the manifest (a GenerateSBOM / Microsoft.Sbom.Targets regression) would +# otherwise pass unnoticed. Assert the SPDX file is present in every package. +for package in artifacts/*.nupkg; do + if unzip -l "$package" | grep -q '_manifest/spdx_2.2/manifest.spdx.json'; then + echo "ok: SBOM present in $package" + else + echo "error: SBOM manifest missing from $package" >&2 + exit 1 + fi +done