diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml new file mode 100644 index 0000000..8c0ecdf --- /dev/null +++ b/.github/workflows/ci-tests.yml @@ -0,0 +1,75 @@ +name: CI Tests + +on: + pull_request: + branches: + - '**' + push: + branches: + - main + +env: + GO_VERSION: 1.25.3 + GOLANGCI_LINT_VERSION: v2.6.2 + +jobs: + golangci: + name: Golang CI lint + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-go@v6 + with: + go-version: ${{ env.GO_VERSION }} + + - name: Run golangci-lint + uses: golangci/golangci-lint-action@v9 + with: + version: ${{ env.GOLANGCI_LINT_VERSION }} + args: -v + + unit-tests-race: + name: Unit tests (race) + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-go@v6 + with: + go-version: ${{ env.GO_VERSION }} + - run: go mod download + - run: go test -mod=readonly ./... -count=1 -shuffle=on -race + + unit-tests-flaky: + name: Unit tests (flaky) + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-go@v6 + with: + go-version: ${{ env.GO_VERSION }} + - run: go mod download + - run: go test -mod=readonly ./... -count=16 -shuffle=on + + summary: + name: CI Summary + runs-on: ubuntu-24.04 + needs: [golangci, unit-tests-race, unit-tests-flaky] + if: always() # Always run, even if earlier jobs fail + steps: + - name: Write CI summary + run: | + { + echo "## ๐Ÿงช CI Test Summary" + echo "" + echo "- **Lint:** ${{ needs.golangci.result }}" + echo "- **Unit (race):** ${{ needs.unit-tests-race.result }}" + echo "- **Unit (flaky):** ${{ needs.unit-tests-flaky.result }}" + echo "" + echo "### Commit" + echo "[${{ github.sha }}](${{ github.server_url }}/${{ github.repository }}/commit/${{ github.sha }})" + echo "" + echo "Triggered by: @${{ github.actor }}" + echo "Event: \`${{ github.event_name }}\`" + echo "" + echo "[View workflow run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})" + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..872a364 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,302 @@ +name: Manual Release + +on: + workflow_dispatch: + inputs: + version: + description: "Release version (e.g. v0.1.2 or 0.1.2)" + required: true + type: string + prerelease: + description: "Mark as pre-release?" + required: false + default: false + type: boolean + +permissions: + contents: write + +env: + GO_VERSION: 1.25.3 + GOLANGCI_LINT_VERSION: v2.6.2 + +jobs: + initialize: + name: Initialize inputs + runs-on: ubuntu-24.04 + outputs: + raw_input: ${{ steps.norm.outputs.raw_input }} + base_version: ${{ steps.norm.outputs.base_version }} + steps: + - name: Normalize version input + id: norm + shell: bash + run: | + set -euo pipefail + RAW_INPUT="${{ github.event.inputs.version }}" + RAW_INPUT="$(echo "$RAW_INPUT" | xargs)" + INPUT_NO_V="${RAW_INPUT#v}" + + # final-only semver + if [[ "$INPUT_NO_V" =~ ^([0-9]+(\.[0-9]+){2})$ ]]; then + BASE_VERSION="${BASH_REMATCH[1]}" + else + echo "::error::Input must be final SemVer like 0.1.2 or v0.1.2. Do not include -rc; use prerelease checkbox instead." + exit 1 + fi + + echo "raw_input=$RAW_INPUT" >> "$GITHUB_OUTPUT" + echo "base_version=$BASE_VERSION" >> "$GITHUB_OUTPUT" + + version-bump-check: + name: Version bump check + runs-on: ubuntu-24.04 + needs: [initialize] + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Version bump check vs latest FINAL tag + shell: bash + run: | + set -euo pipefail + + RAW_INPUT="${{ needs.initialize.outputs.raw_input }}" + BASE_VERSION="${{ needs.initialize.outputs.base_version }}" + + # Latest FINAL tag strictly vX.Y.Z (ignore prereleases) + LATEST_FINAL_TAG=$(git tag --list 'v[0-9]*' \ + | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' \ + | sort -V | tail -n1 || true) + LATEST_FINAL_TAG=${LATEST_FINAL_TAG:-v0.0.0} + OLD_FINAL_VERSION=${LATEST_FINAL_TAG#v} + + echo "Latest FINAL tag: $LATEST_FINAL_TAG" + echo "Requested base: $BASE_VERSION (from input: $RAW_INPUT)" + + # Require strictly newer than latest FINAL + if [[ "$BASE_VERSION" == "$OLD_FINAL_VERSION" ]]; then + echo "::error::Requested version $BASE_VERSION must be greater than latest final $OLD_FINAL_VERSION" + exit 1 + fi + if [[ "$(printf '%s\n' "$OLD_FINAL_VERSION" "$BASE_VERSION" | sort -V | tail -n1)" != "$BASE_VERSION" ]]; then + echo "::error::Requested version $BASE_VERSION must be greater than latest final $OLD_FINAL_VERSION" + exit 1 + fi + + echo "OK: $OLD_FINAL_VERSION โ†’ $BASE_VERSION" + + test-race: + name: Test race + runs-on: ubuntu-24.04 + needs: [version-bump-check] + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 1 + + - uses: actions/setup-go@v6 + with: + go-version: ${{ env.GO_VERSION }} + + - name: Run race tests (1x -race -shuffle) + shell: bash + run: go test -mod=readonly ./... -count=1 -shuffle=on -race + + test-flaky: + name: Test flaky + runs-on: ubuntu-24.04 + needs: [version-bump-check] + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 1 + + - uses: actions/setup-go@v6 + with: + go-version: ${{ env.GO_VERSION }} + + - name: Run flaky tests (16x -shuffle) + shell: bash + run: go test -mod=readonly ./... -count=16 -shuffle=on + + golangci-lint: + runs-on: ubuntu-24.04 + needs: [version-bump-check] + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 1 + + - uses: actions/setup-go@v6 + with: + go-version: ${{ env.GO_VERSION }} + + - name: lint + uses: golangci/golangci-lint-action@v9 + with: + version: ${{ env.GOLANGCI_LINT_VERSION }} + args: -v + + release: + name: Tag & release + needs: [initialize, version-bump-check, test-race, test-flaky, golangci-lint] + runs-on: ubuntu-24.04 + outputs: + version: ${{ steps.compute_tag.outputs.version }} + tag: ${{ steps.compute_tag.outputs.tag }} + is_prerelease: ${{ steps.compute_tag.outputs.is_prerelease }} + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Compute tag (final or next -rc.N from input) + id: compute_tag + shell: bash + run: | + set -euo pipefail + + PRERELEASE="${{ github.event.inputs.prerelease }}" + BASE_VERSION="${{ needs.initialize.outputs.base_version }}" + + final_exists() { git rev-parse "v${BASE_VERSION}" >/dev/null 2>&1; } + + next_rc_tag() { + local base="$1" + local maxn + maxn=$(git tag --list "v${base}-rc*" \ + | sed -E 's/^.*-rc\.?([0-9]+)$/\1/' \ + | grep -E '^[0-9]+$' \ + | sort -n | tail -n1 || true) + if [[ -z "${maxn:-}" ]]; then + echo "v${base}-rc.1" + else + echo "v${base}-rc.$((maxn+1))" + fi + } + + # If a final already exists for this base, block both prerelease and final + if final_exists; then + if [[ "$PRERELEASE" == "true" ]]; then + echo "::error::Cannot create prerelease: final tag v${BASE_VERSION} already exists" + else + echo "::error::Final tag v${BASE_VERSION} already exists; choose a higher version" + fi + exit 1 + fi + + if [[ "$PRERELEASE" == "true" ]]; then + TAG="$(next_rc_tag "$BASE_VERSION")" + IS_PRE="true" + else + TAG="v${BASE_VERSION}" + IS_PRE="false" + fi + + # Final guard: do not reuse an existing tag + if git rev-parse "$TAG" >/dev/null 2>&1; then + echo "::error::Tag $TAG already exists" + exit 1 + fi + + echo "version=$BASE_VERSION" >> "$GITHUB_OUTPUT" + echo "tag=$TAG" >> "$GITHUB_OUTPUT" + echo "is_prerelease=$IS_PRE" >> "$GITHUB_OUTPUT" + + echo "Computed tag: $TAG (prerelease=$IS_PRE)" + + - name: Create Git tag + shell: bash + run: | + set -euo pipefail + TAG="${{ steps.compute_tag.outputs.tag }}" + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + if git rev-parse "$TAG" >/dev/null 2>&1; then + echo "Tag $TAG already exists; skipping tag creation." + else + git tag -a "$TAG" -m "chore(release): $TAG" + git push origin "$TAG" + fi + + - name: Find previous final tag (-rc.N excluded) + id: prev_final + shell: bash + run: | + set -euo pipefail + PREV=$(git tag --list 'v[0-9]*' \ + | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' \ + | sort -V | tail -n2 | head -n1 || true) + echo "prev=$PREV" >> "$GITHUB_OUTPUT" + + - name: Generate changelog (RC) + id: git-cliff-rc + if: ${{ steps.compute_tag.outputs.is_prerelease == 'true' }} + uses: orhun/git-cliff-action@v4 + with: + args: --current --no-exec + env: + GITHUB_REPO: ${{ github.repository }} + GITHUB_TOKEN: ${{ github.token }} + + - name: Generate changelog (final release) + id: git-cliff-final + if: ${{ steps.compute_tag.outputs.is_prerelease == 'false' }} + uses: orhun/git-cliff-action@v4 + with: + args: ${{ steps.prev_final.outputs.prev != '' && + format('{0}..{1} --no-exec', steps.prev_final.outputs.prev, steps.compute_tag.outputs.tag) || + '--current --no-exec' }} + env: + GITHUB_REPO: ${{ github.repository }} + GITHUB_TOKEN: ${{ github.token }} + + - name: Publish GitHub Release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ steps.compute_tag.outputs.tag }} + name: threadsafe ${{ steps.compute_tag.outputs.tag }} + body: ${{ steps.compute_tag.outputs.is_prerelease == 'true' + && steps.git-cliff-rc.outputs.content + || steps.git-cliff-final.outputs.content }} + prerelease: ${{ steps.compute_tag.outputs.is_prerelease }} + env: + GITHUB_TOKEN: ${{ github.token }} + + summary: + name: Release summary + runs-on: ubuntu-24.04 + needs: [initialize, version-bump-check, test-race, test-flaky, golangci-lint, release] + if: ${{ always() }} # ensure this job runs even if dependencies failed + steps: + - name: Write release summary + run: | + { + echo "## ๐Ÿš€ Release Summary" + echo "" + echo "### Job Status" + echo "- Initialize: ${{ needs.initialize.result }}" + echo "- Version bump: ${{ needs['version-bump-check'].result }}" + echo "- Tests (race): ${{ needs['test-race'].result }}" + echo "- Tests (flaky): ${{ needs['test-flaky'].result }}" + echo "- Lint: ${{ needs.golangci-lint.result }}" + echo "- Release: ${{ needs.release.result }}" + echo "" + # Only print release details/links if the release job succeeded and produced outputs + if [ "${{ needs.release.result }}" = "success" ] && [ -n "${{ needs.release.outputs.tag }}" ]; then + echo "### Release" + echo "- **Version:** \`${{ needs.release.outputs.version }}\`" + echo "- **Tag:** \`${{ needs.release.outputs.tag }}\`" + echo "- **Prerelease:** \`${{ needs.release.outputs.is_prerelease }}\`" + echo "- **Release page:** [${{ needs.release.outputs.tag }}](${{ github.server_url }}/${{ github.repository }}/releases/tag/${{ needs.release.outputs.tag }})" + else + echo "> Release not created (see job statuses above)." + fi + echo "" + echo "Triggered by: @${{ github.actor }}" + echo "Commit: [${{ github.sha }}](${{ github.server_url }}/${{ github.repository }}/commit/${{ github.sha }})" + echo "[View workflow run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})" + } >> "$GITHUB_STEP_SUMMARY"