From 01e2fadd38a46f6fe320fb10a4464a14ad82fd07 Mon Sep 17 00:00:00 2001 From: David Khourshid Date: Fri, 11 Sep 2026 16:20:18 -0400 Subject: [PATCH 1/4] Automate releases with Release Please --- .github/workflows/release-please.yml | 32 ++++++++++++++++++++++++++++ .github/workflows/release.yml | 15 ++++++++++--- .release-please-manifest.json | 3 +++ Directory.Build.props | 4 ++-- release-please-config.json | 19 +++++++++++++++++ 5 files changed, 68 insertions(+), 5 deletions(-) create mode 100644 .github/workflows/release-please.yml create mode 100644 .release-please-manifest.json create mode 100644 release-please-config.json diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml new file mode 100644 index 0000000..2050cfc --- /dev/null +++ b/.github/workflows/release-please.yml @@ -0,0 +1,32 @@ +name: Release Please + +# On every push to main, Release Please reads conventional commits (feat:, fix:, feat!: ...) +# and keeps a release PR open that bumps in Directory.Build.props and CHANGELOG.md. +# Merging that PR creates the GitHub release + `v` tag and publishes to NuGet. +on: + push: + branches: [main] + +permissions: + contents: write + pull-requests: write + +jobs: + release-please: + runs-on: ubuntu-latest + outputs: + release_created: ${{ steps.rp.outputs.release_created }} + tag_name: ${{ steps.rp.outputs.tag_name }} + steps: + - uses: googleapis/release-please-action@v4 + id: rp + + # A tag created with GITHUB_TOKEN does not fire `on: push: tags`, so call the release + # workflow directly instead of relying on the tag trigger. + publish: + needs: release-please + if: ${{ needs.release-please.outputs.release_created == 'true' }} + uses: ./.github/workflows/release.yml + with: + tag: ${{ needs.release-please.outputs.tag_name }} + secrets: inherit diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 59688fc..938e223 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -3,10 +3,17 @@ name: Release permissions: contents: read -# Tag the release commit `v` (Version lives in Directory.Build.props). +# Normally invoked by release-please.yml after a release PR is merged. Pushing a +# `v` tag by hand (Version lives in Directory.Build.props) also works. on: push: tags: ['v*'] + workflow_call: + inputs: + tag: + description: Release tag, e.g. v0.1.0-alpha + required: true + type: string env: DOTNET_NOLOGO: true @@ -20,6 +27,7 @@ jobs: steps: - uses: actions/checkout@v4 with: + ref: ${{ inputs.tag || github.ref_name }} fetch-depth: 0 - name: Set up .NET @@ -31,9 +39,10 @@ jobs: - name: Verify the tag matches run: | + tag="${{ inputs.tag || github.ref_name }}" version=$(sed -n 's:.*\(.*\).*:\1:p' Directory.Build.props | head -1) - if [ "v$version" != "$GITHUB_REF_NAME" ]; then - echo "::error::Tag '$GITHUB_REF_NAME' does not match $version in Directory.Build.props." + if [ "v$version" != "$tag" ]; then + echo "::error::Tag '$tag' does not match $version in Directory.Build.props." exit 1 fi diff --git a/.release-please-manifest.json b/.release-please-manifest.json new file mode 100644 index 0000000..6bf28b4 --- /dev/null +++ b/.release-please-manifest.json @@ -0,0 +1,3 @@ +{ + ".": "0.1.0-alpha" +} diff --git a/Directory.Build.props b/Directory.Build.props index 111ddad..2e6abdf 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -15,8 +15,8 @@ - + 0.1.0-alpha Stately Stately diff --git a/release-please-config.json b/release-please-config.json new file mode 100644 index 0000000..74587c4 --- /dev/null +++ b/release-please-config.json @@ -0,0 +1,19 @@ +{ + "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", + "release-type": "simple", + "include-component-in-tag": false, + "bump-minor-pre-major": true, + "bump-patch-for-minor-pre-major": true, + "prerelease": true, + "prerelease-type": "alpha", + "versioning": "prerelease", + "packages": { + ".": { + "package-name": "xstate", + "changelog-path": "CHANGELOG.md", + "extra-files": [ + { "type": "xml", "path": "Directory.Build.props", "xpath": "//Version" } + ] + } + } +} From a409588d0f8ae72188c6668a7cfaf7007e056448 Mon Sep 17 00:00:00 2001 From: David Khourshid Date: Fri, 11 Sep 2026 16:35:59 -0400 Subject: [PATCH 2/4] Publish via NuGet Trusted Publishing (OIDC) --- .github/workflows/release.yml | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 938e223..52d6446 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,6 +2,7 @@ name: Release permissions: contents: read + id-token: write # NuGet Trusted Publishing (OIDC) # Normally invoked by release-please.yml after a release PR is merged. Pushing a # `v` tag by hand (Version lives in Directory.Build.props) also works. @@ -84,18 +85,20 @@ jobs: artifacts/*.snupkg if-no-files-found: error - # No NUGET_API_KEY secret => nothing is published; the packed artifacts above still - # upload, so a tag on a fork or before the secret exists is a harmless dry run. - # (The `secrets` context is not available in a step-level `if`, hence the env check.) + # Trusted Publishing: nuget.org has a policy for statelyai/xstate-csharp + release.yml + # (owner davidkpiano, packages XState*). NuGet/login exchanges the GitHub OIDC token + # for a short-lived API key; no stored secret. + - name: NuGet login (OIDC) + id: nuget-login + if: ${{ github.repository == 'statelyai/xstate-csharp' }} + uses: NuGet/login@v1 + with: + user: davidkpiano + - name: Push to NuGet - env: - NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }} + if: ${{ github.repository == 'statelyai/xstate-csharp' }} run: | - if [ -z "$NUGET_API_KEY" ]; then - echo "::notice::NUGET_API_KEY is not configured; skipping publish." - exit 0 - fi dotnet nuget push "artifacts/*.nupkg" \ - --api-key "$NUGET_API_KEY" \ + --api-key "${{ steps.nuget-login.outputs.NUGET_API_KEY }}" \ --source https://api.nuget.org/v3/index.json \ --skip-duplicate From 877eff13ab68aa111030c1c834f8f65359db0af4 Mon Sep 17 00:00:00 2001 From: David Khourshid Date: Fri, 11 Sep 2026 16:38:56 -0400 Subject: [PATCH 3/4] Release Please: draft release until NuGet push succeeds; optional PAT for release PR CI --- .github/workflows/release-please.yml | 13 ++++++++++++- .github/workflows/release.yml | 16 ++++++++++++++-- release-please-config.json | 1 + 3 files changed, 27 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index 2050cfc..ea4a418 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -2,7 +2,14 @@ name: Release Please # On every push to main, Release Please reads conventional commits (feat:, fix:, feat!: ...) # and keeps a release PR open that bumps in Directory.Build.props and CHANGELOG.md. -# Merging that PR creates the GitHub release + `v` tag and publishes to NuGet. +# Merging that PR creates a *draft* GitHub release; release.yml then builds, tests, runs the +# W3C gate, publishes to NuGet, and only then publishes the release (which creates the tag). +# So a failed publish never leaves a public release or tag behind. +# +# Optional: set a RELEASE_PLEASE_TOKEN secret (fine-grained PAT, contents + pull-requests +# write) so the release PR is opened as a user and CI runs on it. With the default +# GITHUB_TOKEN, PRs opened by Actions do not trigger pull_request workflows; release.yml +# re-runs the full suite before publishing, so this is a convenience, not a gate. on: push: branches: [main] @@ -17,9 +24,12 @@ jobs: outputs: release_created: ${{ steps.rp.outputs.release_created }} tag_name: ${{ steps.rp.outputs.tag_name }} + sha: ${{ steps.rp.outputs.sha }} steps: - uses: googleapis/release-please-action@v4 id: rp + with: + token: ${{ secrets.RELEASE_PLEASE_TOKEN || secrets.GITHUB_TOKEN }} # A tag created with GITHUB_TOKEN does not fire `on: push: tags`, so call the release # workflow directly instead of relying on the tag trigger. @@ -29,4 +39,5 @@ jobs: uses: ./.github/workflows/release.yml with: tag: ${{ needs.release-please.outputs.tag_name }} + sha: ${{ needs.release-please.outputs.sha }} secrets: inherit diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 52d6446..01476f3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,7 +1,7 @@ name: Release permissions: - contents: read + contents: write # publish the draft GitHub release after NuGet push id-token: write # NuGet Trusted Publishing (OIDC) # Normally invoked by release-please.yml after a release PR is merged. Pushing a @@ -15,6 +15,10 @@ on: description: Release tag, e.g. v0.1.0-alpha required: true type: string + sha: + description: Commit to release. The draft release's tag does not exist yet. + required: true + type: string env: DOTNET_NOLOGO: true @@ -28,7 +32,7 @@ jobs: steps: - uses: actions/checkout@v4 with: - ref: ${{ inputs.tag || github.ref_name }} + ref: ${{ inputs.sha || github.ref_name }} fetch-depth: 0 - name: Set up .NET @@ -102,3 +106,11 @@ jobs: --api-key "${{ steps.nuget-login.outputs.NUGET_API_KEY }}" \ --source https://api.nuget.org/v3/index.json \ --skip-duplicate + + # Called from release-please.yml: the release is still a draft (no tag yet). Publishing it + # creates the tag, so a failed build/test/push above leaves nothing public behind. + - name: Publish GitHub release + if: ${{ inputs.tag != '' }} + env: + GH_TOKEN: ${{ github.token }} + run: gh release edit "${{ inputs.tag }}" --repo "$GITHUB_REPOSITORY" --draft=false diff --git a/release-please-config.json b/release-please-config.json index 74587c4..6aec4d7 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -1,6 +1,7 @@ { "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", "release-type": "simple", + "draft": true, "include-component-in-tag": false, "bump-minor-pre-major": true, "bump-patch-for-minor-pre-major": true, From af6120e136aa4ab19baaa866b984db91e25a8de9 Mon Sep 17 00:00:00 2001 From: David Khourshid Date: Fri, 11 Sep 2026 16:46:02 -0400 Subject: [PATCH 4/4] Add CONTRIBUTING.md with release process --- CONTRIBUTING.md | 63 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 CONTRIBUTING.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..9e0ec74 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,63 @@ +# Contributing + +## Build and test + +Requires the .NET 10 SDK (pinned in `global.json`). + +```bash +dotnet build XState.slnx +dotnet test XState.slnx +``` + +CI runs the same on every PR and push to `main`. The W3C SCXML suite must match +[CONFORMANCE.md](CONFORMANCE.md) exactly: an unexpected pass fails the run, same as an +unexpected failure. Update the `KnownFailing` list and CONFORMANCE.md together. + +## Commit messages + +Use [Conventional Commits](https://www.conventionalcommits.org/). Release Please reads +them to pick the next version and write the changelog: + +| Prefix | Effect | +|---|---| +| `fix:` | patch bump, listed under Bug Fixes | +| `feat:` | minor bump, listed under Features | +| `feat!:` or `BREAKING CHANGE:` footer | major bump (while pre-1.0: minor) | +| `chore:`, `docs:`, `ci:`, `test:`, `refactor:` | no release, not in changelog | + +Squash-merge PRs and make the squash title the conventional commit. + +## Releasing + +Releases are automated. Do not edit `` in `Directory.Build.props` or +`CHANGELOG.md` by hand. + +1. Merge PRs to `main` as usual. +2. Release Please keeps a PR open titled `chore(main): release `. It bumps + `` and updates `CHANGELOG.md`. Review the changelog there. +3. Merge that PR. This creates a draft GitHub release and runs `release.yml`, which builds, + tests, runs the W3C gate, packs, and pushes `XState` and `XState.Scxml` to nuget.org. +4. On success the workflow publishes the release, which creates the `v` tag. + On failure nothing is public: fix on `main`, then re-run the failed workflow. + +### Versioning + +Pre-1.0: `feat:` bumps minor, `fix:` bumps patch. Versions are alpha prereleases +(`0.1.0-alpha`, ...). To go stable, remove `prerelease`, `prerelease-type`, and +`versioning` from `release-please-config.json`. + +### Publishing credentials + +NuGet uses Trusted Publishing (OIDC): nuget.org holds a policy for +`statelyai/xstate-csharp` + `release.yml`, packages `XState*`, owner `davidkpiano`. +No API key secret exists. `NuGet/login@v1` exchanges the workflow's OIDC token for a +short-lived key. Manage the policy at https://www.nuget.org/account/trustedpublishing. + +Optional: a `RELEASE_PLEASE_TOKEN` repo secret (fine-grained PAT with contents and +pull-requests write) makes the release PR open as a user so CI runs on it. Without it the +PR still works; `release.yml` runs the full suite before publishing regardless. + +### Manual release + +Pushing a `v` tag matching `Directory.Build.props` also runs `release.yml`. +Use only if the automation is broken.