diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 0000000..5002b71
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,54 @@
+# Publishes the Writeback packages to nuget.org with Trusted Publishing (no stored API key).
+# Trigger: push a tag such as v0.1.0. One-time setup is described in docs/RELEASING.md.
+name: release
+
+on:
+ push:
+ tags: ["v*"]
+
+jobs:
+ publish:
+ runs-on: ubuntu-latest
+ environment: nuget
+ permissions:
+ contents: read
+ id-token: write # lets the job request a GitHub OIDC token for nuget.org
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: |
+ 8.0.x
+ 10.0.x
+
+ - name: Check that the tag matches the project version
+ run: |
+ tag="${GITHUB_REF_NAME#v}"
+ version=$(sed -n 's:.*\(.*\).*:\1:p' Directory.Build.props)
+ if [ "$tag" != "$version" ]; then
+ echo "::error::Tag $GITHUB_REF_NAME does not match $version in Directory.Build.props"
+ exit 1
+ fi
+
+ - run: dotnet build Writeback.slnx -c Release
+ - run: dotnet test --project tests/Writeback.Tests -c Release --no-build
+ - run: dotnet pack Writeback.slnx -c Release --no-build -o artifacts
+
+ - name: NuGet login (OIDC -> short-lived API key)
+ uses: NuGet/login@v1
+ id: login
+ with:
+ user: ${{ secrets.NUGET_USER }} # nuget.org profile name, not the email address
+
+ - name: Push to nuget.org
+ run: >
+ dotnet nuget push "artifacts/*.nupkg"
+ --api-key "${{ steps.login.outputs.NUGET_API_KEY }}"
+ --source https://api.nuget.org/v3/index.json
+ --skip-duplicate
+
+ - uses: actions/upload-artifact@v4
+ with:
+ name: packages
+ path: artifacts/
diff --git a/README.md b/README.md
index 0058670..751ece8 100644
--- a/README.md
+++ b/README.md
@@ -159,6 +159,7 @@ Methodology, tables and caveats are in [docs/BENCHMARKS.md](https://github.com/M
| [Recipes](https://github.com/Murat7Ay/Writeback/blob/master/docs/examples/RECIPES.md) | aggregates, many-to-many, paging, upsert, type handlers, DI, bulk |
| [Benchmarks](https://github.com/Murat7Ay/Writeback/blob/master/docs/BENCHMARKS.md) | raw Dapper vs Writeback vs EF Core |
| [Migration](https://github.com/Murat7Ay/Writeback/blob/master/docs/MIGRATION.md) | from the original 2017 DapperHelper API |
+| [Releasing](https://github.com/Murat7Ay/Writeback/blob/master/docs/RELEASING.md) | publishing to nuget.org with Trusted Publishing |
## Building and testing
diff --git a/docs/RELEASING.md b/docs/RELEASING.md
new file mode 100644
index 0000000..9fcd551
--- /dev/null
+++ b/docs/RELEASING.md
@@ -0,0 +1,37 @@
+# Releasing
+
+Packages are published to nuget.org by [`.github/workflows/release.yml`](../.github/workflows/release.yml) using
+**NuGet Trusted Publishing**. GitHub Actions exchanges a short-lived OIDC token for a one-hour API key, so no
+long-lived key is stored anywhere.
+
+## One-time setup
+
+1. **nuget.org:** sign in, click your user name, choose **Trusted Publishing**, and add a policy:
+ - **Repository Owner:** `Murat7Ay`
+ - **Repository:** `Writeback`
+ - **Workflow File:** `release.yml` (the file name only)
+ - **Environment:** `nuget`
+ - **Scopes:** allow pushing new packages and new versions for `Writeback*`.
+2. **GitHub:** add a repository secret `NUGET_USER` containing your nuget.org **profile name** (not your email):
+ ```bash
+ gh secret set NUGET_USER --repo Murat7Ay/Writeback
+ ```
+3. **Optional:** under *Settings → Environments → nuget*, add yourself as a required reviewer, so every publish
+ waits for your approval.
+
+## Publishing a version
+
+1. Set `` in `Directory.Build.props` (e.g. `0.2.0`) and merge to `master`.
+2. Tag the merged commit and push the tag:
+ ```bash
+ git tag v0.2.0
+ ```
+ ```bash
+ git push origin v0.2.0
+ ```
+3. The workflow checks that the tag matches ``, builds, runs the unit tests, packs, and pushes
+ `Writeback`, `Writeback.SqlServer` and `Writeback.PostgreSql` (with symbol packages). Re-running a tag is
+ safe (`--skip-duplicate`).
+
+The policy may start as *temporarily active* for 7 days and becomes permanent after the first successful publish.
+The temporary API key is valid for one hour and is requested right before the push.