From 75f6a9f756321a3610946793f0f03ddb0b9b0e67 Mon Sep 17 00:00:00 2001 From: Louis Seubert Date: Sun, 25 Oct 2026 21:19:42 +0100 Subject: [PATCH] chore(ci): add versioning --- .forgejo/actions/version/action.yml | 176 ++++++++++++++++++++++++++++ .forgejo/workflows/ci.yml | 67 ++--------- .forgejo/workflows/release.yml | 60 ++++++++++ doc/versioning.md | 90 ++++++++++++++ 4 files changed, 333 insertions(+), 60 deletions(-) create mode 100644 .forgejo/actions/version/action.yml create mode 100644 .forgejo/workflows/release.yml create mode 100644 doc/versioning.md diff --git a/.forgejo/actions/version/action.yml b/.forgejo/actions/version/action.yml new file mode 100644 index 0000000..2ddc710 --- /dev/null +++ b/.forgejo/actions/version/action.yml @@ -0,0 +1,176 @@ +name: Calculate version +description: Validate Conventional Commits and calculate the build version +inputs: + commit-types: + description: Comma-separated Conventional Commit types + required: false + default: feat,fix,docs,style,refactor,perf,test,build,ci,chore,revert +outputs: + version: + description: Version for dotnet build and pack + value: ${{ steps.version.outputs.version }} + base-version: + description: Version before the current changes + value: ${{ steps.version.outputs.base-version }} + label: + description: Build label, such as next or pr.42 + value: ${{ steps.version.outputs.label }} + metadata: + description: Build metadata, such as sha.abc123 + value: ${{ steps.version.outputs.metadata }} + bump: + description: Highest version increment found + value: ${{ steps.version.outputs.bump }} +runs: + using: composite + steps: + - id: version + shell: bash + env: + VERSION_EVENT_NAME: ${{ github.event_name }} + VERSION_REF_NAME: ${{ github.ref_name }} + VERSION_BASE_REF: ${{ github.base_ref }} + VERSION_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + VERSION_PR_NUMBER: ${{ github.event.pull_request.number }} + VERSION_COMMIT_TYPES: ${{ inputs.commit-types }} + run: | + set -euo pipefail + + error() { + echo "::error::$*" >&2 + exit 1 + } + + command -v git >/dev/null || error "git is required" + + event_name="${VERSION_EVENT_NAME:-${GITHUB_EVENT_NAME:-}}" + ref_name="${VERSION_REF_NAME:-${GITHUB_REF_NAME:-}}" + base_ref="${VERSION_BASE_REF:-${GITHUB_BASE_REF:-}}" + head_sha="${VERSION_HEAD_SHA:-${GITHUB_SHA:-HEAD}}" + pr_number="${VERSION_PR_NUMBER:-${PR_NUMBER:-}}" + commit_types="${VERSION_COMMIT_TYPES:-feat,fix,docs,style,refactor,perf,test,build,ci,chore,revert}" + type_pattern="${commit_types//,/|}" + output_file="${GITHUB_OUTPUT:-}" + + write_output() { + [[ -n "$output_file" ]] && printf '%s=%s\n' "$1" "$2" >>"$output_file" + } + + valid_version() { + [[ "$1" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] + } + + tag_version() { + valid_version "$1" || error "Tag '$1' is not a valid release version (expected MAJOR.MINOR.PATCH)" + printf '%s' "$1" + } + + latest_release_tag() { + git tag --list '[0-9]*' --sort=-version:refname | head -n 1 + } + + version_parts() { + IFS=. read -r major minor patch <<<"$1" + printf '%s %s %s' "$major" "$minor" "$patch" + } + + bump_version() { + local version=$1 bump=$2 major minor patch + read -r major minor patch <<<"$(version_parts "$version")" + case "$bump" in + major) major=$((major + 1)); minor=0; patch=0 ;; + minor) minor=$((minor + 1)); patch=0 ;; + patch) patch=$((patch + 1)) ;; + none) ;; + *) error "Unknown version bump '$bump'" ;; + esac + printf '%s.%s.%s' "$major" "$minor" "$patch" + } + + commit_bump() { + local commit=$1 validate=${2:-1} subject body bump=none type + subject=$(git log -1 --format=%s "$commit") + body=$(git log -1 --format=%b "$commit") + + if ! [[ "$subject" =~ ^($type_pattern)(\([^()]+\))?(!)?:[[:space:]]+[^[:space:]].*$ ]]; then + [[ "$validate" == 0 ]] && { printf none; return; } + error "Commit $commit has an invalid Conventional Commit subject: $subject" + fi + type=${BASH_REMATCH[1]} + + if [[ "$subject" =~ ^[^:]+\!: ]] || [[ "$body" == *"BREAKING CHANGE:"* ]] || [[ "$body" == *"BREAKING-CHANGE:"* ]]; then + bump=major + elif [[ "$type" == feat ]]; then + bump=minor + elif [[ "$type" == fix ]]; then + bump='patch' + fi + printf '%s' "$bump" + } + + higher_bump() { + case "$1:$2" in + major:*) printf major ;; *:major) printf major ;; + minor:minor|minor:patch|patch:minor) printf minor ;; + patch:patch) printf patch ;; + none:*) printf '%s' "$2" ;; + *:none) printf '%s' "$1" ;; + *) error "Cannot compare bumps '$1' and '$2'" ;; + esac + } + + commits_between() { + git rev-list --reverse --no-merges "$1..$2" + } + + calculate_bump() { + local commits=$1 validate=${2:-1} commit bump=none current + while IFS= read -r commit; do + [[ -n "$commit" ]] || continue + current=$(commit_bump "$commit" "$validate") + bump=$(higher_bump "$bump" "$current") + done <<<"$commits" + printf '%s' "$bump" + } + + tag=$(latest_release_tag || true) + if [[ -n "$tag" ]]; then + base_version=$(tag_version "$tag") + tag_start=$tag + else + base_version=0.0.0 + tag_start=$(git rev-list --max-parents=0 HEAD | tail -n 1) + fi + + if [[ "$event_name" == pull_request || -n "$base_ref" ]]; then + [[ -n "$pr_number" ]] || error "Pull request number is required" + [[ -n "$base_ref" ]] || error "Pull request base ref is required" + base_commit=$(git rev-parse "$base_ref" 2>/dev/null || git rev-parse "origin/$base_ref") + head_commit=$(git rev-parse "$head_sha") + pr_commits=$(commits_between "$base_commit" "$head_commit") + pr_bump=$(calculate_bump "$pr_commits") + base_bump=$(calculate_bump "$(commits_between "$tag_start" "$base_commit")" 0) + bump=$(higher_bump "$base_bump" "$pr_bump") + version=$(bump_version "$base_version" "$bump")-pr."$pr_number".sha."$head_commit" + label="pr.$pr_number" + metadata="sha.$head_commit" + elif [[ "$event_name" == push && "$ref_name" == [0-9]* || "$ref_name" == [0-9]* ]]; then + version=$(tag_version "$ref_name") + bump=none + label=release + metadata=none + else + head_commit=$(git rev-parse "$head_sha") + commits=$(commits_between "$tag_start" "$head_commit") + bump=$(calculate_bump "$commits") + version=$(bump_version "$base_version" "$bump")-next.sha."$head_commit" + label=next + metadata="sha.$head_commit" + fi + + write_output version "$version" + write_output base-version "$base_version" + write_output label "$label" + write_output metadata "$metadata" + write_output bump "$bump" + printf 'Calculated version: %s\n' "$version" diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index 458d77c..8e6688d 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -3,12 +3,9 @@ name: CI on: push: branches: [main] - tags: ['*'] pull_request: branches: [main] -enable-openid-connect: true - jobs: build: name: build @@ -18,65 +15,15 @@ jobs: - name: checkout uses: https://code.geekeey.de/actions/checkout@54953ce9d6c7d0a9cbd89d185c5b2c7515358c5f # 1 + - name: version + id: version + uses: ./.forgejo/actions/version + - name: dotnet format check run: dotnet format arcade.slnx --verify-no-changes --verbosity minimal - - name: dotnet pack - shell: bash - env: - TAG: ${{ forgejo.ref }} - TAG_NAME: ${{ forgejo.ref_name }} - run: | - set -euo pipefail - - if [[ "$TAG" == refs/tags/* ]]; then - version="${TAG_NAME#v}" - if ! printf '%s' "$version" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then - echo "::error::Tag '$TAG_NAME' is not a valid release version (expected MAJOR.MINOR.PATCH)" - exit 1 - fi - export version - fi - - dotnet pack arcade.slnx - - - name: upload packages - uses: https://code.geekeey.de/actions/artifacts/push@ae8d20e174a8706675e6d54ebd3719a2802ea9dd # 1 - with: - name: nuget-packages - path: artifacts/package/release - pattern: | - *.nupkg - *.snupkg + - name: dotnet build + run: dotnet build arcade.slnx -p:Version=${{ steps.version.outputs.version }} - name: dotnet test - run: dotnet test src/sdk.tests/Geekeey.Arcade.NET.Tests.csproj - - - name: publish to nuget - if: startsWith(forgejo.ref, 'refs/tags/') - shell: bash - env: - AUDIENCE: "u:1:00000000-0000-0000-0000-000000000000" - run: | - set -euo pipefail - - if [[ -z "${ACTIONS_ID_TOKEN_REQUEST_URL:-}" || -z "${ACTIONS_ID_TOKEN_REQUEST_TOKEN:-}" ]]; then - echo "::error::OpenID Connect is not enabled. Set enable-openid-connect: true on the workflow or job." - exit 1 - fi - - response=$(curl --fail --silent --show-error \ - --header "Authorization: bearer ${ACTIONS_ID_TOKEN_REQUEST_TOKEN}" "${ACTIONS_ID_TOKEN_REQUEST_URL}&audience=${AUDIENCE}") - token=$(printf '%s' "${response}" | grep --only-matching '"value":"[^"]*"' | cut --delimiter '"' --fields 4) - - if [[ -z "${token}" ]]; then - echo "::error::Failed to obtain an OpenID Connect token from Forgejo." - exit 1 - fi - - echo "::add-mask::${token}" - - feed="${{ forgejo.server_url }}/api/packages/${{ forgejo.repository_owner }}/nuget/index.json" - - dotnet nuget add source --name forgejo --username forgejo-publish --password "$token" --store-password-in-clear-text "$feed" - dotnet nuget push "artifacts/package/release/*.nupkg" "artifacts/package/release/*.snupkg" --source forgejo --skip-duplicate + run: dotnet test arcade.slnx -p:Version=${{ steps.version.outputs.version }} diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml new file mode 100644 index 0000000..9609014 --- /dev/null +++ b/.forgejo/workflows/release.yml @@ -0,0 +1,60 @@ +name: release + +on: + push: + tags: ['*'] + +enable-openid-connect: true + +jobs: + publish: + name: publish + runs-on: debian-latest + container: mcr.microsoft.com/dotnet/sdk:10.0@sha256:0d99ebf1ccad15037b5c98c9fe8979d6b8dbac23812feae2277a0b277b5c5ffa + steps: + - name: checkout + uses: https://code.geekeey.de/actions/checkout@54953ce9d6c7d0a9cbd89d185c5b2c7515358c5f # 1 + + - name: version + id: version + uses: ./.forgejo/actions/version + + - name: dotnet pack + run: dotnet pack arcade.slnx -p:Version=${{ steps.version.outputs.version }} + + - name: upload packages + uses: https://code.geekeey.de/actions/artifacts/push@ae8d20e174a8706675e6d54ebd3719a2802ea9dd # 1 + with: + name: nuget-packages + path: artifacts/package/release + pattern: | + *.nupkg + *.snupkg + + - name: publish to nuget + shell: bash + env: + AUDIENCE: "u:1:49f9431c-8baf-4432-8254-72fd1b00b8f9" + run: | + set -euo pipefail + + if [[ -z "${ACTIONS_ID_TOKEN_REQUEST_URL:-}" || -z "${ACTIONS_ID_TOKEN_REQUEST_TOKEN:-}" ]]; then + echo "::error::OpenID Connect is not enabled. Set enable-openid-connect: true on the workflow or job." + exit 1 + fi + + response=$(curl --fail --silent --show-error \ + --header "Authorization: bearer ${ACTIONS_ID_TOKEN_REQUEST_TOKEN}" "${ACTIONS_ID_TOKEN_REQUEST_URL}&audience=${AUDIENCE}") + token=$(printf '%s' "${response}" | grep --only-matching '"value":"[^"]*"' | cut --delimiter '"' --fields 4) + + if [[ -z "${token}" ]]; then + echo "::error::Failed to obtain an OpenID Connect token from Forgejo." + exit 1 + fi + + echo "::add-mask::${token}" + + feed="${{ forgejo.server_url }}/api/packages/${{ forgejo.repository_owner }}/nuget/index.json" + + dotnet nuget add source --name forgejo --username forgejo-publish --password "$token" --store-password-in-clear-text "$feed" + dotnet nuget push "artifacts/package/release/*.nupkg" "artifacts/package/release/*.snupkg" --source forgejo --skip-duplicate diff --git a/doc/versioning.md b/doc/versioning.md new file mode 100644 index 0000000..6f01a93 --- /dev/null +++ b/doc/versioning.md @@ -0,0 +1,90 @@ +# Versioning + +Versions are driven by semantic commits and release tags. We do not infer a +release version from branch names or build history. + +## Semantic commits + +Use [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) +for changes. The format is: + +```text +[optional scope]: +``` + +Common types are: + +- `feat`: a new feature +- `fix`: a bug fix +- `docs`: documentation-only changes +- `style`: formatting or other non-functional changes +- `refactor`: code changes without a feature or fix +- `perf`: a performance improvement +- `test`: tests +- `build`: build or dependency changes +- `ci`: continuous-integration changes +- `chore`: maintenance work +- `revert`: a reverted commit + +Scopes are optional and identify the affected area, for example: +`feat(api): add health endpoint`. Use a short, consistent, project-specific +scope when one is useful. + +Append `!` after the type or scope for a breaking change, or include a +`BREAKING CHANGE:` footer. For example: + +```text +feat(api)!: replace authentication endpoint +``` + +## Version increments + +The next release version is incremented from the previous release using only +`fix`, `feat`, and breaking-change markers: + +- **Major**: any commit marked with `!` or containing a `BREAKING CHANGE:` + footer +- **Minor**: otherwise, any `feat` commit +- **Patch**: otherwise, any `fix` commit + +Other commit types do not increment the version. If multiple applicable +commits exist, the highest increment wins. + +## Build metadata + +Builds that are not releases carry metadata identifying their source: + +- **Pull requests:** `pr..sha.` +- **Main branch:** `sha.` + +The main branch is labelled `next` for the upcoming release. + +## Releases + +A tag is the release. The version is taken directly from the tag; it is never +computed by the build system. Release tags must therefore contain the complete +release version (for example, `1.2.3`). The tag and the version exported to +the .NET CLI are both `1.2.3`; no `v` prefix is used. + +## Version action + +Version calculation and commit validation are provided by a reusable action. It +is written in Bash only and runs as the first step after checkout in each +workflow. + +The action selects commits according to the workflow ref: + +- **Pull requests:** commits belonging to the pull request only; commits + already present on the target branch are excluded. +- **Main:** commits since the previous release tag. +- **Release tags:** no version is computed; the tag version is used directly. + +The action then: + +1. validates the applicable commit messages against the Conventional Commits + format; +2. calculates the version from the previous release tag and the applicable + commits, unless the current ref is a release tag; and +3. exports the calculated version and its label/build metadata as action + outputs for later steps. Jobs are responsible for mapping those outputs to + environment variables when needed.