diff --git a/.github/workflows/pr-prerelease.yml b/.github/workflows/pr-prerelease.yml new file mode 100644 index 0000000..602dafd --- /dev/null +++ b/.github/workflows/pr-prerelease.yml @@ -0,0 +1,80 @@ +name: PR Prerelease + +on: + pull_request: + branches: + - 'release-*' + types: [opened, synchronize, reopened] + +jobs: + prerelease: + name: Tag and Publish RC + runs-on: ubuntu-latest + permissions: + contents: write + packages: write + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + ref: ${{ github.event.pull_request.head.sha }} + + - name: Determine RC version + id: version + run: | + BASE_BRANCH="${{ github.base_ref }}" + XY="${BASE_BRANCH#release-}" + X="${XY%%.*}" + Y="${XY##*.}" + + LATEST_RELEASE=$(git tag -l "v${X}.${Y}.*" | grep -v -- '-rc\.' | sort -V | tail -1) + if [ -z "$LATEST_RELEASE" ]; then + NEXT_Z=0 + else + CURRENT_Z=$(echo "$LATEST_RELEASE" | sed "s/v${X}\.${Y}\.\([0-9]*\)/\1/") + NEXT_Z=$((CURRENT_Z + 1)) + fi + + RC_INT=$(git tag -l "v${X}.${Y}.${NEXT_Z}-rc.*" | wc -l | tr -d ' ') + + echo "tag=v${X}.${Y}.${NEXT_Z}-rc.${RC_INT}" >> $GITHUB_OUTPUT + echo "version=${X}.${Y}.${NEXT_Z}-rc.${RC_INT}" >> $GITHUB_OUTPUT + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '8.x' + source-url: https://nuget.pkg.github.com/${{ github.repository_owner }}/index.json + env: + NUGET_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Add Keyfactor NuGet source credentials + run: dotnet nuget update source Keyfactor --username ${{ github.actor }} --password ${{ secrets.V2BUILDTOKEN }} --store-password-in-clear-text + + - name: Restore dependencies + run: dotnet restore + + - name: Build + run: dotnet build --no-restore --configuration Release + + - name: Pack + run: dotnet pack aws-auth-library/aws-auth-library.csproj --no-restore --configuration Release -p:Version=${{ steps.version.outputs.version }} --output ./nupkg + + - name: Create and push RC tag + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git tag ${{ steps.version.outputs.tag }} + git push origin ${{ steps.version.outputs.tag }} + + - name: Create GitHub Prerelease + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ steps.version.outputs.tag }} + name: ${{ steps.version.outputs.tag }} + prerelease: true + generate_release_notes: true + files: ./nupkg/*.nupkg + + - name: Push to GitHub Packages + run: dotnet nuget push ./nupkg/*.nupkg --source https://nuget.pkg.github.com/${{ github.repository_owner }}/index.json --skip-duplicate diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4473cc1..ef1a730 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,51 +2,40 @@ name: Release and Publish on: push: - tags: [ 'v*' ] + branches: + - 'release-*' jobs: - build: - name: Build - runs-on: ubuntu-latest - permissions: - packages: read - steps: - - uses: actions/checkout@v4 - - - name: Setup .NET - uses: actions/setup-dotnet@v4 - with: - dotnet-version: '8.x' - - - name: Add Keyfactor NuGet source credentials - run: dotnet nuget update source Keyfactor --username ${{ github.actor }} --password ${{ secrets.V2BUILDTOKEN }} --store-password-in-clear-text - - - name: Restore dependencies - run: dotnet restore - - - name: Build - run: dotnet build --no-restore --configuration Release - release: - name: Release and Publish + name: Tag and Publish Release runs-on: ubuntu-latest - needs: build permissions: contents: write packages: write steps: - uses: actions/checkout@v4 + with: + fetch-depth: 0 - - name: Set version from tag + - name: Determine release version + id: version run: | - VERSION="${GITHUB_REF#refs/tags/v}" - echo "PACKAGE_VERSION=$VERSION" >> $GITHUB_ENV - if [[ "$VERSION" == *-rc* ]]; then - echo "IS_PRERELEASE=true" >> $GITHUB_ENV + BRANCH="${GITHUB_REF#refs/heads/}" + XY="${BRANCH#release-}" + X="${XY%%.*}" + Y="${XY##*.}" + + LATEST_RELEASE=$(git tag -l "v${X}.${Y}.*" | grep -v -- '-rc\.' | sort -V | tail -1) + if [ -z "$LATEST_RELEASE" ]; then + NEXT_Z=0 else - echo "IS_PRERELEASE=false" >> $GITHUB_ENV + CURRENT_Z=$(echo "$LATEST_RELEASE" | sed "s/v${X}\.${Y}\.\([0-9]*\)/\1/") + NEXT_Z=$((CURRENT_Z + 1)) fi + echo "tag=v${X}.${Y}.${NEXT_Z}" >> $GITHUB_OUTPUT + echo "version=${X}.${Y}.${NEXT_Z}" >> $GITHUB_OUTPUT + - name: Setup .NET uses: actions/setup-dotnet@v4 with: @@ -61,15 +50,25 @@ jobs: - name: Restore dependencies run: dotnet restore + - name: Build + run: dotnet build --no-restore --configuration Release + - name: Pack - run: dotnet pack aws-auth-library/aws-auth-library.csproj --no-restore --configuration Release -p:Version=${{ env.PACKAGE_VERSION }} --output ./nupkg + run: dotnet pack aws-auth-library/aws-auth-library.csproj --no-restore --configuration Release -p:Version=${{ steps.version.outputs.version }} --output ./nupkg + + - name: Create and push release tag + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git tag ${{ steps.version.outputs.tag }} + git push origin ${{ steps.version.outputs.tag }} - name: Create GitHub Release uses: softprops/action-gh-release@v2 with: - tag_name: ${{ github.ref_name }} - name: ${{ github.ref_name }} - prerelease: ${{ env.IS_PRERELEASE }} + tag_name: ${{ steps.version.outputs.tag }} + name: ${{ steps.version.outputs.tag }} + prerelease: false generate_release_notes: true files: ./nupkg/*.nupkg diff --git a/CICD.md b/CICD.md new file mode 100644 index 0000000..c06f372 --- /dev/null +++ b/CICD.md @@ -0,0 +1,103 @@ +# CI/CD + +## Branch strategy + +Release branches follow the naming convention `release-X.Y` where `X` is the major version and `Y` is the minor version. Each branch owns its own patch series independently — `release-1.0` produces `v1.0.*` tags, `release-1.1` produces `v1.1.*` tags, and so on. This allows security fixes and hotfixes to be shipped for older minor versions without disturbing newer ones. + +The `main` branch is the integration target for feature development. Work flows from feature branches → `main` → `release-X.Y` when a version is being prepared for release. + +## Workflows + +```mermaid +flowchart TD + DEV([Developer]) + + DEV -->|push to any branch| BUILD_T + DEV -->|open PR targeting release-X.Y\nor push to open PR| RC_T + DEV -->|merge PR into release-X.Y| REL_T + + subgraph BUILD_T ["build.yml — all branches"] + B1[Restore] --> B2[Build] + end + + subgraph RC_T ["pr-prerelease.yml — PR → release-X.Y"] + direction TB + RC_V["Z = highest non-RC vX.Y.* patch + 1 +N = count of existing vX.Y.Z-rc.* tags"] + RC_V --> RC1[Restore → Build → Pack\nvX.Y.Z-rc.N] + RC1 --> RC2[Push tag vX.Y.Z-rc.N\nto PR head SHA] + RC2 --> RC3[GitHub Prerelease] + RC2 --> RC4[NuGet Package] + end + + subgraph REL_T ["release.yml — push to release-X.Y"] + direction TB + R_V["Z = highest non-RC vX.Y.* patch + 1\n(RC tags ignored)"] + R_V --> R1[Restore → Build → Pack\nvX.Y.Z] + R1 --> R2[Push tag vX.Y.Z] + R2 --> R3[GitHub Release] + R2 --> R4[NuGet Package] + end +``` + +| Workflow | File | Trigger | Purpose | +|---|---|---|---| +| Build | `build.yml` | Push or PR to any branch | Verifies the project compiles — no publish | +| PR Prerelease | `pr-prerelease.yml` | PR opened/updated targeting `release-*` | Tags and publishes an RC on every push | +| Release | `release.yml` | Push/merge to `release-*` | Tags and publishes the final release | + +## Example lifecycle + +```mermaid +timeline + title PR targeting release-1.0 (latest tag v1.0.0) + PR opened : v1.0.1-rc.0 prerelease created + Push to PR : v1.0.1-rc.1 prerelease created + Push to PR : v1.0.1-rc.2 prerelease created + PR merged : v1.0.1 release created + Next PR opened : v1.0.2-rc.0 prerelease created +``` + +## Versioning rules + +| Component | Rule | +|---|---| +| `Z` (patch) | Highest non-RC `vX.Y.*` tag + 1. Zero if no releases exist yet for this `X.Y`. | +| `N` (RC int) | Count of existing `vX.Y.Z-rc.*` tags. Zero-based — resets to 0 each time `Z` advances. | +| Tag scope | `vX.Y.*` glob is anchored to the exact major and minor from the branch name. Tags from other `X.Y` series are invisible. | +| RC tags | Excluded from `Z` computation in both workflows. Only shipped releases drive the next patch number. | + +## Shipping a hotfix or security patch + +Because each `release-X.Y` branch is independent, patching an older version does not require touching newer ones: + +1. Check out the target release branch: `git checkout release-X.Y` +2. Create a fix branch off it: `git checkout -b fix/description` +3. Commit the fix and open a PR **targeting `release-X.Y`** (not `main`) +4. Each push to the PR automatically publishes an RC prerelease for validation +5. Merge the PR — the release workflow publishes `vX.Y.Z` and the NuGet package + +If the fix also applies to `main` or other release branches, cherry-pick it separately after merging. + +## Required secrets + +| Secret | Used by | Purpose | +|---|---|---| +| `V2BUILDTOKEN` | All workflows | Authenticates the private Keyfactor NuGet source | +| `GITHUB_TOKEN` | `pr-prerelease.yml`, `release.yml` | Pushes tags and publishes GitHub releases and packages (provided automatically by Actions) | + +## Known limitations + +**Concurrent PRs:** if two PRs targeting the same `release-X.Y` branch have commits pushed at the exact same moment, both workflow runs may compute the same RC tag and the second push will fail. This is inherent to the count-based tagging approach and is unlikely in practice. Re-running the failed workflow run resolves it. + +**Direct pushes to release branches:** a direct push to `release-X.Y` (without a PR) triggers `release.yml` and creates a new release tag, the same as a merge. Avoid pushing directly to release branches unless intentional. + +## Testing the version logic + +The version computation can be validated locally without a git repo, network access, or any commits: + +```sh +bash scripts/test-semver.sh +``` + +This runs 23 test cases covering RC and release versioning, cross-branch tag isolation, double-digit patch numbers, and a full end-to-end PR lifecycle. If the shell logic in the workflows is ever modified, update the matching functions in `scripts/test-semver.sh` and re-run. diff --git a/scripts/test-semver.sh b/scripts/test-semver.sh new file mode 100755 index 0000000..a98a395 --- /dev/null +++ b/scripts/test-semver.sh @@ -0,0 +1,246 @@ +#!/usr/bin/env bash +# Tests the semver logic used in pr-prerelease.yml and release.yml. +# No git repo or network access needed — pass mock tag lists directly. +# +# HOW IT WORKS +# ------------ +# The two workflow scripts extract X.Y from the branch name (release-X.Y), +# then query git for existing tags to compute the next version. These functions +# mirror that logic exactly, accepting a newline-separated tag list in place of +# a live git repo so scenarios can be tested without commits or network calls. +# +# ASSUMPTIONS UNDER TEST +# ---------------------- +# - Z (patch) = highest non-RC patch for this X.Y + 1. Zero if no releases exist yet. +# - RC int = count of existing vX.Y.Z-rc.* tags for the computed Z. Zero-based. +# - Tags from other major/minor versions are completely ignored — the grep pattern +# anchors on the exact X.Y extracted from the branch name. +# - RC tags are invisible to release Z computation (grep -v strips them before sort). +# - sort -V handles double-digit patch numbers correctly (9 < 10, not lexicographic). + +PASS=0 +FAIL=0 + +# Mirrors the version logic in pr-prerelease.yml. +# Args: +compute_rc() { + local base_branch="$1" + local tags="$2" + + local XY="${base_branch#release-}" + local X="${XY%%.*}" + local Y="${XY##*.}" + + # Find the highest released (non-RC) patch for this X.Y only. + local LATEST_RELEASE + LATEST_RELEASE=$(echo "$tags" | grep -E "^v${X}\.${Y}\.[0-9]+" | grep -v -- '-rc\.' | sort -V | tail -1) + + local NEXT_Z + if [ -z "$LATEST_RELEASE" ]; then + NEXT_Z=0 + else + local CURRENT_Z + CURRENT_Z=$(echo "$LATEST_RELEASE" | sed "s/v${X}\.${Y}\.\([0-9]*\)/\1/") + NEXT_Z=$((CURRENT_Z + 1)) + fi + + # RC int = how many RC tags already exist for this exact X.Y.Z (0-based). + local RC_INT + RC_INT=$(echo "$tags" | grep -E "^v${X}\.${Y}\.${NEXT_Z}-rc\.[0-9]+$" | wc -l | tr -d ' ') + + echo "v${X}.${Y}.${NEXT_Z}-rc.${RC_INT}" +} + +# Mirrors the version logic in release.yml. +# Args: +compute_release() { + local branch="$1" + local tags="$2" + + local XY="${branch#release-}" + local X="${XY%%.*}" + local Y="${XY##*.}" + + # RC tags are intentionally excluded — only shipped releases determine next Z. + local LATEST_RELEASE + LATEST_RELEASE=$(echo "$tags" | grep -E "^v${X}\.${Y}\.[0-9]+" | grep -v -- '-rc\.' | sort -V | tail -1) + + local NEXT_Z + if [ -z "$LATEST_RELEASE" ]; then + NEXT_Z=0 + else + local CURRENT_Z + CURRENT_Z=$(echo "$LATEST_RELEASE" | sed "s/v${X}\.${Y}\.\([0-9]*\)/\1/") + NEXT_Z=$((CURRENT_Z + 1)) + fi + + echo "v${X}.${Y}.${NEXT_Z}" +} + +assert() { + local description="$1" + local expected="$2" + local actual="$3" + + if [ "$expected" = "$actual" ]; then + echo " PASS $description" + ((PASS++)) + else + echo " FAIL $description" + echo " expected: $expected" + echo " actual: $actual" + ((FAIL++)) + fi +} + +# --------------------------------------------------------------------------- + +echo "" +echo "RC versioning (pr-prerelease.yml logic)" +echo "---------------------------------------" + +# Brand-new branch with no prior tags — Z starts at 0, RC int starts at 0. +assert "no existing tags → first RC is rc.0" \ + "v1.0.0-rc.0" \ + "$(compute_rc "release-1.0" "")" + +# One released patch exists; next Z must increment past it. +assert "v1.0.0 exists → next RC is v1.0.1-rc.0" \ + "v1.0.1-rc.0" \ + "$(compute_rc "release-1.0" "v1.0.0")" + +# One RC already created for the in-progress Z; RC int increments by counting it. +assert "v1.0.0 + v1.0.1-rc.0 exist → next RC is v1.0.1-rc.1" \ + "v1.0.1-rc.1" \ + "$(compute_rc "release-1.0" "$(printf 'v1.0.0\nv1.0.1-rc.0')")" + +# Two RCs already exist; confirms count-based increment keeps working past rc.1. +assert "v1.0.0 + rc.0 + rc.1 exist → next RC is v1.0.1-rc.2" \ + "v1.0.1-rc.2" \ + "$(compute_rc "release-1.0" "$(printf 'v1.0.0\nv1.0.1-rc.0\nv1.0.1-rc.1')")" + +# Two releases shipped; Z must jump to 2 and RC int resets to 0 for the new Z. +assert "v1.0.0 + v1.0.1 exist → patch increments to v1.0.2-rc.0" \ + "v1.0.2-rc.0" \ + "$(compute_rc "release-1.0" "$(printf 'v1.0.0\nv1.0.1')")" + +# Tags from a different minor on the same major must not bleed into this branch. +# v1.1.0 should be invisible when computing versions for release-1.0. +assert "tags from other minor (v1.1.0) don't affect release-1.0" \ + "v1.0.1-rc.0" \ + "$(compute_rc "release-1.0" "$(printf 'v1.0.0\nv1.1.0')")" + +# Reverse: v1.0.x tags must not affect release-1.1. +assert "tags from other minor (v1.0.x) don't affect release-1.1" \ + "v1.1.1-rc.0" \ + "$(compute_rc "release-1.1" "$(printf 'v1.0.5\nv1.1.0')")" + +# Tags from a completely different major must not affect version computation. +assert "tags from other major (v2.0.5) don't affect release-1.0" \ + "v1.0.0-rc.0" \ + "$(compute_rc "release-1.0" "v2.0.5")" + +# Real-world regression scenario: release-2.0 has been shipped alongside release-1.2. +# The v2.x tags must be fully invisible when a PR targets release-1.2. +# The dot in git tag -l "v1.2.*" is literal, so v2.x never matches v1.2.*. +assert "release-1.2 with release-2.0 already shipped → targets v1.2.x only" \ + "v1.2.1-rc.0" \ + "$(compute_rc "release-1.2" "$(printf 'v1.2.0\nv2.0.0\nv2.0.1-rc.0\nv2.0.1')")" + +# sort -V must order numerically, not lexicographically (9 < 10, not 9 > 10). +assert "double-digit patch (v1.0.9) → next is v1.0.10-rc.0" \ + "v1.0.10-rc.0" \ + "$(compute_rc "release-1.0" "v1.0.9")" + +# Branch name with a non-zero minor to confirm X.Y parsing handles both digits. +assert "release-2.3 branch parses correctly" \ + "v2.3.1-rc.0" \ + "$(compute_rc "release-2.3" "v2.3.0")" + +# --------------------------------------------------------------------------- + +echo "" +echo "Release versioning (release.yml logic)" +echo "---------------------------------------" + +# No prior tags; first release of this branch is Z=0. +assert "no existing tags → first release is v1.0.0" \ + "v1.0.0" \ + "$(compute_release "release-1.0" "")" + +# One released patch; next release increments Z. +assert "v1.0.0 exists → next release is v1.0.1" \ + "v1.0.1" \ + "$(compute_release "release-1.0" "v1.0.0")" + +# RC tags for the in-progress Z must be ignored; release Z is still based on the +# last non-RC only. Two rc tags present but release should still be v1.0.1. +assert "v1.0.0 + v1.0.1-rc.0 exist → release is v1.0.1 (RC tags ignored)" \ + "v1.0.1" \ + "$(compute_release "release-1.0" "$(printf 'v1.0.0\nv1.0.1-rc.0\nv1.0.1-rc.1')")" + +# Two shipped patches; next is Z=2. +assert "v1.0.0 + v1.0.1 exist → next release is v1.0.2" \ + "v1.0.2" \ + "$(compute_release "release-1.0" "$(printf 'v1.0.0\nv1.0.1')")" + +# Tags from a different minor must not affect release Z computation. +assert "tags from other minor don't affect release version" \ + "v1.0.1" \ + "$(compute_release "release-1.0" "$(printf 'v1.0.0\nv1.1.5')")" + +# Numeric sort: patch 9 → 10, not lexicographic ordering. +assert "double-digit patch (v1.0.9) → next release is v1.0.10" \ + "v1.0.10" \ + "$(compute_release "release-1.0" "v1.0.9")" + +# Non-zero minor in branch name parses correctly. +assert "release-2.3 branch parses correctly" \ + "v2.3.1" \ + "$(compute_release "release-2.3" "v2.3.0")" + +# --------------------------------------------------------------------------- + +echo "" +echo "End-to-end scenario: PR lifecycle" +echo "----------------------------------" +# Simulates a full PR from open to merge, accumulating tags as each workflow +# run would, then verifies the next PR starts from the right RC. + +TAGS="" + +# PR opened against a branch with no history. +RC1=$(compute_rc "release-1.0" "$TAGS") +assert "PR opened, no prior tags → v1.0.0-rc.0" "v1.0.0-rc.0" "$RC1" +TAGS="$RC1" + +# Developer pushes a fix to the open PR; rc int increments. +RC2=$(compute_rc "release-1.0" "$TAGS") +assert "push to open PR → v1.0.0-rc.1" "v1.0.0-rc.1" "$RC2" +TAGS="$(printf '%s\n%s' "$TAGS" "$RC2")" + +# Another push; keeps incrementing. +RC3=$(compute_rc "release-1.0" "$TAGS") +assert "second push to open PR → v1.0.0-rc.2" "v1.0.0-rc.2" "$RC3" +TAGS="$(printf '%s\n%s' "$TAGS" "$RC3")" + +# PR is merged; release workflow runs with all three RC tags in place. +# RC tags must be ignored — release is v1.0.0, not v1.0.3. +REL=$(compute_release "release-1.0" "$TAGS") +assert "PR merged → v1.0.0" "v1.0.0" "$REL" +TAGS="$(printf '%s\n%s' "$TAGS" "$REL")" + +# A subsequent PR opens after the release; Z increments to 1, RC resets to 0. +RC_NEXT=$(compute_rc "release-1.0" "$TAGS") +assert "next PR after release → v1.0.1-rc.0" "v1.0.1-rc.0" "$RC_NEXT" + +# --------------------------------------------------------------------------- + +echo "" +if [ "$FAIL" -eq 0 ]; then + echo "All $PASS tests passed." +else + echo "$PASS passed, $FAIL failed." + exit 1 +fi +echo ""