From e81888db637c39ea90fa0867018f22c036e57d73 Mon Sep 17 00:00:00 2001 From: TGPSKI Date: Fri, 10 Apr 2026 10:08:05 -0700 Subject: [PATCH 1/2] Add manual release workflow with version bump, tagging, and proxy verification workflow_dispatch with patch/minor/major bump choice and dry-run option. Verifies CI status, validates CHANGELOG entry, creates annotated tag, publishes GitHub release, triggers Go module proxy indexing, and confirms pkg.go.dev availability. Closes #11 Made-with: Cursor --- .github/workflows/release.yml | 157 ++++++++++++++++++++++++++++++++++ 1 file changed, 157 insertions(+) create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..0154da6 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,157 @@ +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 + + - 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 From b29a01f526a4fb7f08cf41ffeb5b2312a374accc Mon Sep 17 00:00:00 2001 From: TGPSKI Date: Fri, 10 Apr 2026 10:10:50 -0700 Subject: [PATCH 2/2] Gate release workflow to CODEOWNERS and RELEASERS only Adds an authorization step that checks github.actor against CODEOWNERS and an optional RELEASERS file. Fails fast before any version logic runs if the actor is not in either list. Made-with: Cursor --- .github/workflows/release.yml | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0154da6..be8f367 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -29,6 +29,29 @@ jobs: 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