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
31 changes: 31 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# Two ecosystems, on purpose narrow.
#
# github-actions keeps the SHA-pinned actions in the workflows current — a pin without an update
# path just ages. Weekly, one grouped PR, so a security release in an action is not waiting a month.
#
# nuget covers the test project only. The shipping projects under src/ are deliberately excluded:
# their EF Core and provider references are floors chosen on purpose (see the csproj comments) —
# raising one raises every consumer's minimum, which is a release decision, not a dependency
# update. The test project's EF Core and provider references track those floors for the same
# reason and are ignored here; bump them by hand together with the floor. Security advisories
# still surface for everything through Dependabot security updates and NuGetAudit at build time.
version: 2
updates:
- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly
groups:
actions:
patterns: [ "*" ]

- package-ecosystem: nuget
directory: /test/EFCore.ComplexIndexes.Tests
schedule:
interval: monthly
groups:
test-dependencies:
patterns: [ "*" ]
ignore:
- dependency-name: "Microsoft.EntityFrameworkCore*"
- dependency-name: "Npgsql.EntityFrameworkCore.PostgreSQL*"
27 changes: 16 additions & 11 deletions .github/workflows/dotnet.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@
# Split into two jobs on purpose: the unit suite and the integration suite, the latter needing
# Docker for its PostgreSQL container.
#
# Actions are pinned to commit SHAs, not tags. A tag can be moved to a different commit — that is
# how the tj-actions/changed-files compromise (March 2025) reached every workflow that trusted
# `@v45` — a SHA cannot. The trailing `# vX.Y.Z` is what Dependabot reads to propose an update,
# so keep it next to the SHA (see .github/dependabot.yml).
#
# For information on GitHub Actions with .NET, see:
# https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-net

Expand Down Expand Up @@ -46,10 +51,10 @@ jobs:
os: [ ubuntu-latest ]

steps:
- uses: actions/checkout@v6
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0

- name: Setup .NET
uses: actions/setup-dotnet@v5
uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5.4.0
with:
dotnet-version: 10.0.x

Expand All @@ -72,7 +77,7 @@ jobs:
--results-directory TestResults

- name: Upload test results
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
if: always()
with:
name: test-results-${{ matrix.os }}
Expand All @@ -84,10 +89,10 @@ jobs:
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v6
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0

- name: Setup .NET
uses: actions/setup-dotnet@v5
uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5.4.0
with:
dotnet-version: 10.0.x

Expand All @@ -109,7 +114,7 @@ jobs:
--results-directory TestResults

- name: Upload test results
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
if: always()
with:
name: test-results-integration
Expand All @@ -126,10 +131,10 @@ jobs:
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v6
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0

- name: Setup .NET
uses: actions/setup-dotnet@v5
uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5.4.0
with:
dotnet-version: 10.0.x

Expand All @@ -142,10 +147,10 @@ jobs:
needs: [ test, integration ]

steps:
- uses: actions/checkout@v6
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0

- name: Setup .NET
uses: actions/setup-dotnet@v5
uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5.4.0
with:
dotnet-version: 10.0.x

Expand Down Expand Up @@ -184,7 +189,7 @@ jobs:
done

- name: Upload packages
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: nupkg
path: |
Expand Down
122 changes: 113 additions & 9 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,15 @@
# 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.
#
# Actions are pinned to commit SHAs, not tags — this is the publish path, and NuGet/login runs in
# the one job that can mint a token. A moved tag reaches every workflow trusting it; a SHA does
# not. The trailing `# vX.Y.Z` is what Dependabot reads to propose an update; keep it by the SHA.
#
# Setup, once:
# 1. GitHub → Settings → Environments → create `nuget`, and add yourself under
Expand Down Expand Up @@ -68,10 +74,10 @@ jobs:
version: ${{ steps.version.outputs.version }}

steps:
- uses: actions/checkout@v6
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0

- name: Setup .NET
uses: actions/setup-dotnet@v5
uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5.4.0
with:
dotnet-version: 10.0.x

Expand Down Expand Up @@ -163,7 +169,7 @@ jobs:
# direct (unzipped) uploads, and download v8 turns a digest mismatch into a hard failure and
# stops auto-unzipping. Neither buys anything here, and this is the publish path.
- name: Upload packages
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: nupkg-${{ steps.version.outputs.version }}
path: |
Expand All @@ -188,13 +194,13 @@ jobs:

steps:
- name: Setup .NET
uses: actions/setup-dotnet@v5
uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5.4.0
with:
dotnet-version: 10.0.x

# Publishes exactly what was verified — the packages are not rebuilt here.
- name: Download packages
uses: actions/download-artifact@v7
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
with:
name: nupkg-${{ needs.verify.outputs.version }}
path: dist
Expand All @@ -212,7 +218,7 @@ jobs:
# publishing policy, valid for one hour. The token is single-use and nothing is stored.
- name: NuGet login (OIDC)
id: login
uses: NuGet/login@v1
uses: NuGet/login@8d196754b4036150537f80ac539e15c2f1028841 # v1.2.0
with:
user: ${{ secrets.NUGET_USER }}

Expand Down Expand Up @@ -243,3 +249,101 @@ 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 the README's "What changed"
# section as the notes — the same text ChangelogConsistencyTests 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 README.md, the source of the release notes.
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0

- name: Download packages
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
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 heading up to, not including, the next H2.
notes="$(awk -v heading="## What changed in $VERSION" '
$0 == heading { found = 1; print; next }
found && /^## / { exit }
found { print }
' README.md)"

if [[ -z "$notes" ]]; then
echo "::error::README.md has no '## What changed in $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
16 changes: 13 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,10 @@ the build reports green.
`.github/workflows/dotnet.yml` runs on pushes and PRs to `main`: the unit suite, the integration
suite (Docker), then a pack job whose value is partly that packing *is* a check — a package
declaring `PackageReadmeFile` without packing the file fails with NU5019. Everything runs on
ubuntu-latest.
ubuntu-latest. Actions in both workflows are pinned to commit SHAs with the tag in a trailing
comment; `.github/dependabot.yml` keeps those pins current (weekly, grouped) and covers the test
project's NuGet references monthly — not `src/`, whose EF Core and provider floors are release
decisions.

The unit job used to fan out across ubuntu/windows/macos for the repository-convention tests, which
do real path work. Dropped in favour of reacting if it ever bites: **no shipped code touches the
Expand Down Expand Up @@ -71,11 +74,18 @@ issued only if the run matches the policy configured on nuget.org (owner + repos
name + environment). The one repository secret, `NUGET_USER`, holds the nuget.org *profile name*; it
is not a credential and is a secret only to keep it out of build logs.

The workflow is **two jobs**, and the split is the security boundary. `verify` builds, tests and
The workflow is **three jobs**, and the split is the security boundary. `verify` builds, tests and
packs with no `id-token` permission at all — nothing in it can mint a token. `publish` carries
`id-token: write`, is gated on the `nuget` environment, and only downloads and pushes what `verify`
already produced. So a reviewer is asked after the suite has passed rather than before, no token
exists until they approve, and the artifact published is the one that was tested.
exists until they approve, and the artifact published is the one that was tested. `release` runs
last with `contents: write` and no `id-token`: it creates the GitHub release if none exists (notes
taken from the README's `## What changed in <version>` section, so an empty extraction fails the
job instead of publishing a blank release) and attaches the SBOMs — their only durable home, since
workflow artifacts expire after 90 days and nuget.org has no slot for them. Only the SBOMs are
attached: nuget.org repository-signs packages on ingestion, so a `.nupkg` from there never matches
ours byte-for-byte, and attaching ours would invite a hash comparison that fails for a benign
reason. No job holds both `id-token: write` and `contents: write`.

Three things must stay in sync or the policy stops matching — by design, so fix the policy rather
than working around it: the workflow **file name** (`release.yml`), the **environment** name
Expand Down
5 changes: 3 additions & 2 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,8 +69,9 @@ that code can already run arbitrary code in your build.

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`) describing
the dependencies a consumer actually takes on.
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/EFCore.ComplexIndexes/releases)
for that version, describing the dependencies a consumer actually takes on.

## In scope

Expand Down
Loading