diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..be8f367 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,180 @@ +name: Release + +on: + workflow_dispatch: + inputs: + bump: + description: 'Version bump type' + required: true + type: choice + options: + - patch + - minor + - major + dry_run: + description: 'Dry run (skip tag push, release creation, and proxy trigger)' + required: false + type: boolean + default: false + +permissions: + contents: write + +jobs: + release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + fetch-tags: true + + - name: Authorize release actor + run: | + ACTOR="${{ github.actor }}" + ALLOWED="" + + if [ -f CODEOWNERS ]; then + ALLOWED="$ALLOWED $(grep -oP '@\K[\w-]+' CODEOWNERS | sort -u)" + fi + + if [ -f RELEASERS ]; then + ALLOWED="$ALLOWED $(grep -vE '^\s*(#|$)' RELEASERS | tr -s '[:space:]' ' ')" + fi + + for user in $ALLOWED; do + if [ "$ACTOR" = "$user" ]; then + echo "Authorized: $ACTOR is in CODEOWNERS or RELEASERS" + exit 0 + fi + done + + echo "::error::$ACTOR is not authorized to create releases. Only users listed in CODEOWNERS or RELEASERS may trigger this workflow." + exit 1 + + - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + with: + go-version-file: go.mod + + - name: Determine next version + id: version + run: | + LATEST=$(git tag --sort=-v:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | head -1) + if [ -z "$LATEST" ]; then + LATEST="v0.0.0" + fi + echo "current=$LATEST" >> "$GITHUB_OUTPUT" + + MAJOR=$(echo "$LATEST" | sed 's/^v//' | cut -d. -f1) + MINOR=$(echo "$LATEST" | sed 's/^v//' | cut -d. -f2) + PATCH=$(echo "$LATEST" | sed 's/^v//' | cut -d. -f3) + + case "${{ inputs.bump }}" in + major) MAJOR=$((MAJOR + 1)); MINOR=0; PATCH=0 ;; + minor) MINOR=$((MINOR + 1)); PATCH=0 ;; + patch) PATCH=$((PATCH + 1)) ;; + esac + + NEXT="v${MAJOR}.${MINOR}.${PATCH}" + echo "next=$NEXT" >> "$GITHUB_OUTPUT" + echo "### Version bump: $LATEST → $NEXT (${{ inputs.bump }})" >> "$GITHUB_STEP_SUMMARY" + + - name: Verify CI passed on HEAD + run: | + HEAD_SHA=$(git rev-parse HEAD) + echo "Checking CI status for $HEAD_SHA..." + CONCLUSION=$(gh run list --commit "$HEAD_SHA" --workflow ci.yml --json conclusion --jq '.[0].conclusion // "none"') + if [ "$CONCLUSION" != "success" ]; then + echo "::error::CI has not passed on HEAD ($HEAD_SHA). Latest conclusion: $CONCLUSION" + exit 1 + fi + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Verify CHANGELOG has entry for next version + run: | + NEXT="${{ steps.version.outputs.next }}" + if ! grep -q "## $NEXT" CHANGELOG.md; then + echo "::error::CHANGELOG.md does not contain an entry for $NEXT. Add the changelog entry before releasing." + exit 1 + fi + + - name: Build and test + run: | + make build + make test-race + + - name: Extract changelog for release notes + id: notes + run: | + NEXT="${{ steps.version.outputs.next }}" + NOTES=$(awk "/^## $NEXT/{flag=1; next} /^## v[0-9]/{flag=0} flag" CHANGELOG.md) + { + echo "body<> "$GITHUB_OUTPUT" + + - name: Create annotated tag + if: ${{ !inputs.dry_run }} + run: | + NEXT="${{ steps.version.outputs.next }}" + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git tag -a "$NEXT" -m "$NEXT" + git push origin "$NEXT" + + - name: Create GitHub release + if: ${{ !inputs.dry_run }} + run: | + NEXT="${{ steps.version.outputs.next }}" + gh release create "$NEXT" \ + --title "$NEXT" \ + --notes "$RELEASE_NOTES" \ + --verify-tag + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RELEASE_NOTES: ${{ steps.notes.outputs.body }} + + - name: Trigger Go module proxy indexing + if: ${{ !inputs.dry_run }} + run: | + NEXT="${{ steps.version.outputs.next }}" + MODULE=$(head -1 go.mod | awk '{print $2}') + echo "Requesting proxy indexing for ${MODULE}@${NEXT}..." + HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" "https://proxy.golang.org/${MODULE}/@v/${NEXT}.info") + echo "Proxy response: $HTTP_CODE" + if [ "$HTTP_CODE" -ge 400 ]; then + echo "::warning::Go module proxy returned $HTTP_CODE — indexing may be delayed" + fi + + - name: Verify pkg.go.dev availability + if: ${{ !inputs.dry_run }} + run: | + NEXT="${{ steps.version.outputs.next }}" + MODULE=$(head -1 go.mod | awk '{print $2}') + echo "Waiting for pkg.go.dev to index ${MODULE}@${NEXT}..." + for i in 1 2 3 4 5; do + sleep 15 + HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" "https://pkg.go.dev/${MODULE}@${NEXT}") + echo "Attempt $i: HTTP $HTTP_CODE" + if [ "$HTTP_CODE" -eq 200 ]; then + echo "pkg.go.dev is serving ${MODULE}@${NEXT}" + echo "### pkg.go.dev: [${MODULE}@${NEXT}](https://pkg.go.dev/${MODULE}@${NEXT})" >> "$GITHUB_STEP_SUMMARY" + exit 0 + fi + done + echo "::warning::pkg.go.dev has not indexed ${NEXT} yet — check manually at https://pkg.go.dev/${MODULE}@${NEXT}" + + - name: Summary + run: | + NEXT="${{ steps.version.outputs.next }}" + CURRENT="${{ steps.version.outputs.current }}" + if [ "${{ inputs.dry_run }}" = "true" ]; then + echo "### Dry run complete" >> "$GITHUB_STEP_SUMMARY" + echo "Would have tagged **$NEXT** (from $CURRENT, ${{ inputs.bump }} bump)" >> "$GITHUB_STEP_SUMMARY" + else + echo "### Release $NEXT published" >> "$GITHUB_STEP_SUMMARY" + echo "- Tag: [$NEXT](https://github.com/${{ github.repository }}/releases/tag/$NEXT)" >> "$GITHUB_STEP_SUMMARY" + echo "- Bump: $CURRENT → $NEXT (${{ inputs.bump }})" >> "$GITHUB_STEP_SUMMARY" + fi