From 98f7195557b429d63beabe4b89b664d1c8658781 Mon Sep 17 00:00:00 2001 From: McNultyyy Date: Mon, 1 Jun 2026 19:11:25 +0100 Subject: [PATCH 1/2] feat: automated versioning and workflow_dispatch release process - Add nuget-version.yaml (major/minor only) as version source - Remove hardcoded version from src/Directory.Build.props; local builds produce 0.0.0-local - Rewrite release.yml: workflow_dispatch trigger, auto-computed patch from GitHub release history, concurrency serialisation, beta support from non-main branches - Update ci.yml: add version-preview job (PR-only, path-filtered to src/props/nuget-version.yaml changes) showing next beta and stable version in step summary - Update AGENTS.md to reflect new no-tag, no-version-bump workflow Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/ci.yml | 53 +++++++++++++++++++++ .github/workflows/release.yml | 88 +++++++++++++++++++++++++++-------- AGENTS.md | 50 +++++++++----------- nuget-version.yaml | 2 + src/Directory.Build.props | 2 +- 5 files changed, 147 insertions(+), 48 deletions(-) create mode 100644 nuget-version.yaml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c43658f..079dc80 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,3 +15,56 @@ jobs: windows-parity: uses: ./.github/workflows/_windows-parity.yml + + version-preview: + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + + - name: Check for source changes + id: changes + run: | + BASE_SHA="${{ github.event.pull_request.base.sha }}" + if git diff --name-only "$BASE_SHA" HEAD | grep -qE '^src/|^[^/]+\.props$|^nuget-version\.yaml$'; then + echo "has_source_changes=true" >> "$GITHUB_OUTPUT" + else + echo "has_source_changes=false" >> "$GITHUB_OUTPUT" + fi + + - name: Compute beta version preview + if: steps.changes.outputs.has_source_changes == 'true' + env: + GH_TOKEN: ${{ github.token }} + run: | + MAJOR=$(grep -E '^major:' nuget-version.yaml | awk '{print $2}') + MINOR=$(grep -E '^minor:' nuget-version.yaml | awk '{print $2}') + + LAST_PATCH=$(gh release list --exclude-drafts --limit 200 --json tagName \ + --jq "[.[] | select(.tagName | test(\"^v${MAJOR}\\.${MINOR}\\.[0-9]+$\")) | .tagName | ltrimstr(\"v\") | split(\".\")[2] | tonumber] | max // -1") + LAST_PATCH=${LAST_PATCH:--1} + NEXT_PATCH=$((LAST_PATCH + 1)) + + BRANCH="${{ github.head_ref }}" + SAFE_BRANCH=$(echo "$BRANCH" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/--*/-/g' | sed 's/^-//;s/-$//') + + LAST_BETA=$(gh release list --exclude-drafts --limit 200 --json tagName \ + --jq "[.[] | select(.tagName | test(\"^v${MAJOR}\\.${MINOR}\\.${NEXT_PATCH}-beta\\.[0-9]+\\.${SAFE_BRANCH}$\")) | .tagName | capture(\"beta\\.(?[0-9]+)\") | .n | tonumber] | max // 0" 2>/dev/null) + LAST_BETA=${LAST_BETA:-0} + NEXT_BETA=$((LAST_BETA + 1)) + + BETA_VERSION="${MAJOR}.${MINOR}.${NEXT_PATCH}-beta.${NEXT_BETA}.${SAFE_BRANCH}" + STABLE_VERSION="${MAJOR}.${MINOR}.${NEXT_PATCH}" + + { + echo "## 📦 Version Preview" + echo "" + echo "| | Version |" + echo "|---|---|" + echo "| This PR (beta) | \`${BETA_VERSION}\` |" + echo "| Next stable release | \`${STABLE_VERSION}\` |" + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0974549..0e7ba9a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,10 +1,71 @@ name: Release on: - push: - tags: [ 'v*' ] + workflow_dispatch: + inputs: + prerelease: + type: boolean + description: Publish as prerelease (beta) + default: false + +concurrency: + group: release + cancel-in-progress: false jobs: + compute-version: + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + version: ${{ steps.compute.outputs.VERSION }} + prerelease: ${{ steps.compute.outputs.PRERELEASE }} + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + + - name: Compute version + id: compute + env: + GH_TOKEN: ${{ github.token }} + run: | + MAJOR=$(grep -E '^major:' nuget-version.yaml | awk '{print $2}') + MINOR=$(grep -E '^minor:' nuget-version.yaml | awk '{print $2}') + BRANCH="${GITHUB_REF_NAME}" + + LAST_PATCH=$(gh release list --exclude-drafts --limit 200 --json tagName \ + --jq "[.[] | select(.tagName | test(\"^v${MAJOR}\\.${MINOR}\\.[0-9]+$\")) | .tagName | ltrimstr(\"v\") | split(\".\")[2] | tonumber] | max // -1") + LAST_PATCH=${LAST_PATCH:--1} + NEXT_PATCH=$((LAST_PATCH + 1)) + + # Warn if no source changes since the last stable release + if [[ "$LAST_PATCH" -ge 0 ]]; then + LAST_TAG="v${MAJOR}.${MINOR}.${LAST_PATCH}" + if ! git diff --name-only "${LAST_TAG}" HEAD | grep -qE '^src/|^[^/]+\.props$|^nuget-version\.yaml$'; then + echo "::warning::No source code changes found since ${LAST_TAG}. A version bump may not be needed." + fi + fi + + if [[ "${{ inputs.prerelease }}" == "true" || "$BRANCH" != "main" ]]; then + SAFE_BRANCH=$(echo "$BRANCH" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/--*/-/g' | sed 's/^-//;s/-$//') + + LAST_BETA=$(gh release list --exclude-drafts --limit 200 --json tagName \ + --jq "[.[] | select(.tagName | test(\"^v${MAJOR}\\.${MINOR}\\.${NEXT_PATCH}-beta\\.[0-9]+\\.${SAFE_BRANCH}$\")) | .tagName | capture(\"beta\\.(?[0-9]+)\") | .n | tonumber] | max // 0" 2>/dev/null) + LAST_BETA=${LAST_BETA:-0} + NEXT_BETA=$((LAST_BETA + 1)) + + VERSION="${MAJOR}.${MINOR}.${NEXT_PATCH}-beta.${NEXT_BETA}.${SAFE_BRANCH}" + PRERELEASE=true + else + VERSION="${MAJOR}.${MINOR}.${NEXT_PATCH}" + PRERELEASE=false + fi + + echo "VERSION=${VERSION}" >> "$GITHUB_OUTPUT" + echo "PRERELEASE=${PRERELEASE}" >> "$GITHUB_OUTPUT" + echo "📦 Publishing version: **${VERSION}**" >> "$GITHUB_STEP_SUMMARY" + test: uses: ./.github/workflows/_build-and-test.yml @@ -12,33 +73,21 @@ jobs: uses: ./.github/workflows/_windows-parity.yml release: - needs: [test, windows-parity] + needs: [compute-version, test, windows-parity] runs-on: ubuntu-latest permissions: contents: write - steps: - uses: actions/checkout@v5 - uses: ./.github/actions/setup-dotnet-build with: build: false - - name: Extract version from tag - id: version - run: | - VERSION="${GITHUB_REF_NAME#v}" - echo "VERSION=$VERSION" >> $GITHUB_OUTPUT - if [[ "$VERSION" == *-* ]]; then - echo "PRERELEASE=true" >> $GITHUB_OUTPUT - else - echo "PRERELEASE=false" >> $GITHUB_OUTPUT - fi - - name: Build - run: dotnet build CosmosDB.InMemoryEmulator.sln --configuration Release -p:Version=${{ steps.version.outputs.VERSION }} + run: dotnet build CosmosDB.InMemoryEmulator.sln --configuration Release -p:Version=${{ needs.compute-version.outputs.version }} - name: Pack - run: dotnet pack CosmosDB.InMemoryEmulator.sln --configuration Release --no-build --output ./nupkgs -p:Version=${{ steps.version.outputs.VERSION }} + run: dotnet pack CosmosDB.InMemoryEmulator.sln --configuration Release --no-build --output ./nupkgs -p:Version=${{ needs.compute-version.outputs.version }} - name: Push to NuGet run: dotnet nuget push ./nupkgs/*.nupkg --api-key ${{ secrets.NUGET_API_KEY }} --source https://api.nuget.org/v3/index.json --skip-duplicate @@ -46,9 +95,10 @@ jobs: - name: Create GitHub Release uses: softprops/action-gh-release@v2 with: + tag_name: v${{ needs.compute-version.outputs.version }} generate_release_notes: true - prerelease: ${{ steps.version.outputs.PRERELEASE == 'true' }} - make_latest: ${{ steps.version.outputs.PRERELEASE != 'true' }} + prerelease: ${{ needs.compute-version.outputs.prerelease == 'true' }} + make_latest: ${{ needs.compute-version.outputs.prerelease != 'true' }} files: | ./nupkgs/*.nupkg ./nupkgs/*.snupkg diff --git a/AGENTS.md b/AGENTS.md index 2d719de..542fc73 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -53,9 +53,12 @@ Every piece of behavioral logic in the source code — status codes, validation ## Versioning & Release -- After every session of bug fixes is complete and the full test suite has passed, increment the patch version in `src/Directory.Build.props` (the single `` property shared by all three packages). -- **On `main`:** Commit, create a git tag (`v{version}`), and push both the commit and the tag to origin. -- **On any other branch:** Commit and push the code changes and version bump only. Do not create or push a tag. +Version numbers are computed automatically from `nuget-version.yaml` at the repo root and the existing GitHub release history — **no manual version edits are needed for patch releases**. + +- `nuget-version.yaml` contains only `major` and `minor`. The patch is auto-incremented from the latest stable GitHub release for that `major.minor` pair each time a release is triggered. +- For a **major or minor bump**, edit `nuget-version.yaml` and merge to `main` before triggering the next release. +- **Do not** edit `` in `src/Directory.Build.props` — it produces `0.0.0-local` for local builds, which is correct. The CI/release workflows always pass `-p:Version=X.Y.Z` at build time. +- After bug-fix sessions: **no version file changes are needed** — just commit and push your code changes. ## Test Classification Rules @@ -81,40 +84,31 @@ The Integration project does **not** have `InternalsVisibleTo` access. If a test ## Publishing & Releases -Packages are published to NuGet via the `release.yml` GitHub Actions workflow, triggered by pushing a `v*` tag. The workflow runs the full test suite before publishing — if tests fail, nothing is published. - -### Beta / Prerelease +Releases are published manually via the **Actions → Release** workflow (`workflow_dispatch`). The full test suite runs before anything is published. Releases are serialised — if two releases are triggered simultaneously, the second queues behind the first. -To publish a prerelease package for testing before merging: +### PR Version Preview -1. Ensure your fix is committed and pushed to a branch. -2. Create and push a tag with a prerelease suffix: - ```bash - git tag v4.0.18-beta.1 - git push origin v4.0.18-beta.1 - ``` -3. The workflow extracts the version from the tag (strips the `v` prefix), passes it to `dotnet pack -p:Version=...`, and publishes to NuGet as a prerelease package. -4. Consumers install with: `dotnet add package CosmosDB.InMemoryEmulator --version 4.0.18-beta.1` +When a PR changes files in `src/`, `*.props`, or `nuget-version.yaml`, the CI `version-preview` job shows in the workflow step summary what the next beta and stable versions would be if published from that branch. -The tag can be on any branch — the workflow checks out the tagged commit. +### Stable Release (from `main`) -### Stable Release +1. Merge all desired changes to `main`. Update `CHANGELOG.md` if appropriate. +2. Go to **Actions → Release** and trigger the workflow from `main` with **Prerelease: unchecked** (the default). +3. The workflow auto-computes the next patch version, runs all tests, publishes to NuGet.org, and creates a GitHub Release with auto-generated notes. -After merging to `main`: +### Prerelease / Beta (from any branch) -1. Ensure `src/Directory.Build.props` has the correct `` (e.g. `4.0.18`). -2. Commit, create a tag, and push: - ```bash - git tag v4.0.18 - git push origin v4.0.18 - ``` -3. The workflow publishes stable packages and creates a GitHub Release with auto-generated release notes. +1. Push your branch. +2. Go to **Actions → Release**, select your branch from the branch dropdown, and trigger with **Prerelease: checked** (beta is also automatic when triggering from any non-`main` branch). +3. The computed version will be `X.Y.Z-beta.N.sanitised-branch-name`, where N auto-increments. +4. Consumers install with: `dotnet add package CosmosDB.InMemoryEmulator --version X.Y.Z-beta.N.branch-name` ### Version Conventions -- `Directory.Build.props` `` is the target stable version — increment the patch after each release. -- The CI workflow **overrides** the version from the tag, so the `.props` value doesn't need to match beta suffixes. -- Prerelease format: `X.Y.Z-beta.N` (increment N for successive betas of the same version). +- Patch auto-increments: the release workflow queries the highest existing `vX.Y.*` GitHub release tag and adds 1. +- Beta format: `X.Y.Z-beta.N.sanitised-branch-name` +- Stable format: `X.Y.Z` +- To bump major or minor: edit `nuget-version.yaml` (`major`/`minor` fields) and merge to `main` before the next release. ## Documentation diff --git a/nuget-version.yaml b/nuget-version.yaml new file mode 100644 index 0000000..fb1b15e --- /dev/null +++ b/nuget-version.yaml @@ -0,0 +1,2 @@ +major: 4 +minor: 0 diff --git a/src/Directory.Build.props b/src/Directory.Build.props index cc5f590..b57a64b 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -5,7 +5,7 @@ true - 4.0.20 + 0.0.0-local lemonlion Copyright (c) 2026 lemonlion MIT From 223b824098936558bed014be1ca1c33cabe5ad5f Mon Sep 17 00:00:00 2001 From: McNultyyy Date: Mon, 1 Jun 2026 19:39:16 +0100 Subject: [PATCH 2/2] refactor: extract version computation into composite action Move duplicated version bash logic from ci.yml and release.yml into .github/actions/compute-nuget-version/action.yml. Outputs: stable-version, beta-version, last-patch. Both workflows now delegate to the action and use the outputs directly. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../actions/compute-nuget-version/action.yml | 47 +++++++++++++++++++ .github/workflows/ci.yml | 34 ++++---------- .github/workflows/release.yml | 38 ++++++--------- 3 files changed, 72 insertions(+), 47 deletions(-) create mode 100644 .github/actions/compute-nuget-version/action.yml diff --git a/.github/actions/compute-nuget-version/action.yml b/.github/actions/compute-nuget-version/action.yml new file mode 100644 index 0000000..67164da --- /dev/null +++ b/.github/actions/compute-nuget-version/action.yml @@ -0,0 +1,47 @@ +name: Compute NuGet Version +description: Computes the next NuGet package version from release history and nuget-version.yaml + +inputs: + branch: + description: The branch name (e.g. main, feature/foo) + required: true + +outputs: + stable-version: + description: The next stable version (e.g. 4.0.21) + value: ${{ steps.compute.outputs.stable_version }} + beta-version: + description: The next beta version (e.g. 4.0.21-beta.1.branch-name) + value: ${{ steps.compute.outputs.beta_version }} + last-patch: + description: The last published patch number (-1 if no releases exist for this major.minor) + value: ${{ steps.compute.outputs.last_patch }} + +runs: + using: composite + steps: + - name: Compute version + id: compute + shell: bash + env: + GH_TOKEN: ${{ github.token }} + run: | + MAJOR=$(grep -E '^major:' nuget-version.yaml | awk '{print $2}') + MINOR=$(grep -E '^minor:' nuget-version.yaml | awk '{print $2}') + + LAST_PATCH=$(gh release list --exclude-drafts --limit 200 --json tagName \ + --jq "[.[] | select(.tagName | test(\"^v${MAJOR}\\.${MINOR}\\.[0-9]+$\")) | .tagName | ltrimstr(\"v\") | split(\".\")[2] | tonumber] | max // -1") + LAST_PATCH=${LAST_PATCH:--1} + NEXT_PATCH=$((LAST_PATCH + 1)) + + BRANCH="${{ inputs.branch }}" + SAFE_BRANCH=$(echo "$BRANCH" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/--*/-/g' | sed 's/^-//;s/-$//') + + LAST_BETA=$(gh release list --exclude-drafts --limit 200 --json tagName \ + --jq "[.[] | select(.tagName | test(\"^v${MAJOR}\\.${MINOR}\\.${NEXT_PATCH}-beta\\.[0-9]+\\.${SAFE_BRANCH}$\")) | .tagName | capture(\"beta\\.(?[0-9]+)\") | .n | tonumber] | max // 0" 2>/dev/null) + LAST_BETA=${LAST_BETA:-0} + NEXT_BETA=$((LAST_BETA + 1)) + + echo "stable_version=${MAJOR}.${MINOR}.${NEXT_PATCH}" >> "$GITHUB_OUTPUT" + echo "beta_version=${MAJOR}.${MINOR}.${NEXT_PATCH}-beta.${NEXT_BETA}.${SAFE_BRANCH}" >> "$GITHUB_OUTPUT" + echo "last_patch=${LAST_PATCH}" >> "$GITHUB_OUTPUT" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 079dc80..f1458be 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,35 +36,21 @@ jobs: echo "has_source_changes=false" >> "$GITHUB_OUTPUT" fi - - name: Compute beta version preview + - name: Compute version preview if: steps.changes.outputs.has_source_changes == 'true' - env: - GH_TOKEN: ${{ github.token }} - run: | - MAJOR=$(grep -E '^major:' nuget-version.yaml | awk '{print $2}') - MINOR=$(grep -E '^minor:' nuget-version.yaml | awk '{print $2}') - - LAST_PATCH=$(gh release list --exclude-drafts --limit 200 --json tagName \ - --jq "[.[] | select(.tagName | test(\"^v${MAJOR}\\.${MINOR}\\.[0-9]+$\")) | .tagName | ltrimstr(\"v\") | split(\".\")[2] | tonumber] | max // -1") - LAST_PATCH=${LAST_PATCH:--1} - NEXT_PATCH=$((LAST_PATCH + 1)) - - BRANCH="${{ github.head_ref }}" - SAFE_BRANCH=$(echo "$BRANCH" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/--*/-/g' | sed 's/^-//;s/-$//') - - LAST_BETA=$(gh release list --exclude-drafts --limit 200 --json tagName \ - --jq "[.[] | select(.tagName | test(\"^v${MAJOR}\\.${MINOR}\\.${NEXT_PATCH}-beta\\.[0-9]+\\.${SAFE_BRANCH}$\")) | .tagName | capture(\"beta\\.(?[0-9]+)\") | .n | tonumber] | max // 0" 2>/dev/null) - LAST_BETA=${LAST_BETA:-0} - NEXT_BETA=$((LAST_BETA + 1)) - - BETA_VERSION="${MAJOR}.${MINOR}.${NEXT_PATCH}-beta.${NEXT_BETA}.${SAFE_BRANCH}" - STABLE_VERSION="${MAJOR}.${MINOR}.${NEXT_PATCH}" + id: version + uses: ./.github/actions/compute-nuget-version + with: + branch: ${{ github.head_ref }} + - name: Write version preview summary + if: steps.changes.outputs.has_source_changes == 'true' + run: | { echo "## 📦 Version Preview" echo "" echo "| | Version |" echo "|---|---|" - echo "| This PR (beta) | \`${BETA_VERSION}\` |" - echo "| Next stable release | \`${STABLE_VERSION}\` |" + echo "| This PR (beta) | \`${{ steps.version.outputs.beta-version }}\` |" + echo "| Next stable release | \`${{ steps.version.outputs.stable-version }}\` |" } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0e7ba9a..ceeb632 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -26,20 +26,18 @@ jobs: fetch-depth: 0 - name: Compute version - id: compute + id: version + uses: ./.github/actions/compute-nuget-version + with: + branch: ${{ github.ref_name }} + + - name: Warn if no source changes since last release env: GH_TOKEN: ${{ github.token }} run: | + LAST_PATCH="${{ steps.version.outputs.last-patch }}" MAJOR=$(grep -E '^major:' nuget-version.yaml | awk '{print $2}') MINOR=$(grep -E '^minor:' nuget-version.yaml | awk '{print $2}') - BRANCH="${GITHUB_REF_NAME}" - - LAST_PATCH=$(gh release list --exclude-drafts --limit 200 --json tagName \ - --jq "[.[] | select(.tagName | test(\"^v${MAJOR}\\.${MINOR}\\.[0-9]+$\")) | .tagName | ltrimstr(\"v\") | split(\".\")[2] | tonumber] | max // -1") - LAST_PATCH=${LAST_PATCH:--1} - NEXT_PATCH=$((LAST_PATCH + 1)) - - # Warn if no source changes since the last stable release if [[ "$LAST_PATCH" -ge 0 ]]; then LAST_TAG="v${MAJOR}.${MINOR}.${LAST_PATCH}" if ! git diff --name-only "${LAST_TAG}" HEAD | grep -qE '^src/|^[^/]+\.props$|^nuget-version\.yaml$'; then @@ -47,23 +45,17 @@ jobs: fi fi - if [[ "${{ inputs.prerelease }}" == "true" || "$BRANCH" != "main" ]]; then - SAFE_BRANCH=$(echo "$BRANCH" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/--*/-/g' | sed 's/^-//;s/-$//') - - LAST_BETA=$(gh release list --exclude-drafts --limit 200 --json tagName \ - --jq "[.[] | select(.tagName | test(\"^v${MAJOR}\\.${MINOR}\\.${NEXT_PATCH}-beta\\.[0-9]+\\.${SAFE_BRANCH}$\")) | .tagName | capture(\"beta\\.(?[0-9]+)\") | .n | tonumber] | max // 0" 2>/dev/null) - LAST_BETA=${LAST_BETA:-0} - NEXT_BETA=$((LAST_BETA + 1)) - - VERSION="${MAJOR}.${MINOR}.${NEXT_PATCH}-beta.${NEXT_BETA}.${SAFE_BRANCH}" - PRERELEASE=true + - name: Pick version + id: compute + run: | + if [[ "${{ inputs.prerelease }}" == "true" || "${{ github.ref_name }}" != "main" ]]; then + VERSION="${{ steps.version.outputs.beta-version }}" + echo "PRERELEASE=true" >> "$GITHUB_OUTPUT" else - VERSION="${MAJOR}.${MINOR}.${NEXT_PATCH}" - PRERELEASE=false + VERSION="${{ steps.version.outputs.stable-version }}" + echo "PRERELEASE=false" >> "$GITHUB_OUTPUT" fi - echo "VERSION=${VERSION}" >> "$GITHUB_OUTPUT" - echo "PRERELEASE=${PRERELEASE}" >> "$GITHUB_OUTPUT" echo "📦 Publishing version: **${VERSION}**" >> "$GITHUB_STEP_SUMMARY" test: