diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml new file mode 100644 index 0000000..ea4a418 --- /dev/null +++ b/.github/workflows/release-please.yml @@ -0,0 +1,43 @@ +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 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] + +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 }} + 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. + 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 }} + sha: ${{ needs.release-please.outputs.sha }} + secrets: inherit diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 59688fc..01476f3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,12 +1,24 @@ name: Release permissions: - contents: read + contents: write # publish the draft GitHub release after NuGet push + id-token: write # NuGet Trusted Publishing (OIDC) -# 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 + sha: + description: Commit to release. The draft release's tag does not exist yet. + required: true + type: string env: DOTNET_NOLOGO: true @@ -20,6 +32,7 @@ jobs: steps: - uses: actions/checkout@v4 with: + ref: ${{ inputs.sha || github.ref_name }} fetch-depth: 0 - name: Set up .NET @@ -31,9 +44,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 @@ -75,18 +89,28 @@ 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 + + # 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-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/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. 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..6aec4d7 --- /dev/null +++ b/release-please-config.json @@ -0,0 +1,20 @@ +{ + "$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, + "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" } + ] + } + } +}