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
110 changes: 107 additions & 3 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,11 @@
# 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.
#
# Two jobs on purpose: everything that can fail runs unattended, and only the push waits behind
# the environment's approval gate. A reviewer is asked once the full suite has already passed,
# and no OIDC token is minted until they approve.
# 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.
#
# Three names must agree with the nuget.org policy, or it silently stops matching: this file's
# *name* (release.yml), the environment (`nuget`), and the repository owner/name.
Expand Down Expand Up @@ -218,3 +220,105 @@ jobs:
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 this version's section of
# CHANGELOG.md as the notes — the same section ChangelogConventionTests already requires for the
# shipped version, 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@v6

- name: Download packages
uses: actions/download-artifact@v7
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 }}
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: from its `## [x.y.z] — date` heading up to, not
# including, the next H2. The heading's brackets are a reference-style link whose
# target is this very release page, so they are dropped rather than left to render as
# literal brackets.
notes="$(awk -v version="$VERSION" '
BEGIN { heading = "^## \\[" version "\\]" }
$0 ~ heading { found = 1; sub(/^## \[[^]]*\]/, "## " version); print; next }
found && /^## / { exit }
found { print }
' CHANGELOG.md)"

if [[ -z "$notes" ]]; then
echo "::error::CHANGELOG.md has no '## [$VERSION]' 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

flags=()
if [[ "$VERSION" == *-* ]]; then
flags+=(--prerelease)
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1031,7 +1031,7 @@ All three layers run in CI on every push and pull request — the badge at the t

Bug reports and pull requests are welcome — [CONTRIBUTING.md](CONTRIBUTING.md) covers the setup and the quality bar this package holds itself to. Security reports go privately through [SECURITY.md](SECURITY.md).

Packages are published through GitHub Actions Trusted Publishing, carry Source Link metadata and symbol packages, and ship a CycloneDX SBOM per release.
Packages are published through GitHub Actions Trusted Publishing, carry Source Link metadata and symbol packages, and ship a CycloneDX SBOM per package, attached to each GitHub release.

A substantial portion of this codebase was written with AI assistance, under maintainer direction and review. [CONTRIBUTING.md](CONTRIBUTING.md#ai-assisted-development) explains what that means in practice, and how every change is verified before it ships.

Expand Down
8 changes: 5 additions & 3 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,9 +75,11 @@ through, so there is no documented "you asked for it" escape hatch here.

Published packages carry Source Link metadata (repository and commit) and symbol packages, and are
published through GitHub Actions Trusted Publishing — no long-lived credential exists that could
publish on this project's behalf. Each release also ships a CycloneDX SBOM (`*.cdx.json`). Nothing
in this repository is referenced with `PrivateAssets=all`, so those SBOMs list exactly the
dependencies a consumer receives, and a convention test fails the build if that stops being true.
publish on this project's behalf. Each release also carries a CycloneDX SBOM per package
(`*.cdx.json`), attached to the [GitHub release](https://github.com/CaffeinatedCoder/CodoMetis.ValueRanges/releases)
for that version. Nothing in this repository is referenced with `PrivateAssets=all`, so those SBOMs
list exactly the dependencies a consumer receives, and a convention test fails the build if that
stops being true.

## In scope

Expand Down
Loading