Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions .github/workflows/release-dryrun.yml
Original file line number Diff line number Diff line change
@@ -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"
29 changes: 23 additions & 6 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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: |
Expand All @@ -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 }}
Expand Down
94 changes: 94 additions & 0 deletions maintainers/ReleaseDryRun.en.md
Original file line number Diff line number Diff line change
@@ -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.
102 changes: 102 additions & 0 deletions maintainers/ReleaseDryRun.fr.md
Original file line number Diff line number Diff line change
@@ -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.
45 changes: 45 additions & 0 deletions tools/packaging/pack.sh
Original file line number Diff line number Diff line change
@@ -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 <version>
# <version> 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 <version>" >&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