diff --git a/.github/workflows/release-gate.yml b/.github/workflows/release-gate.yml new file mode 100644 index 00000000..cb5fd6c0 --- /dev/null +++ b/.github/workflows/release-gate.yml @@ -0,0 +1,81 @@ +name: Release Gate + +on: + pull_request: + branches: + - main + types: [opened, synchronize, reopened, ready_for_review] + +jobs: + milestone-closure-check: + name: Verify all active milestones are complete + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write + + steps: + - name: Check for partially complete milestones + id: gate + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + echo "Checking milestone completion status..." + + # Fetch all open milestones; flag any that are partially complete + # (have both closed AND open issues — meaning work started but not finished) + PARTIAL=$(gh api repos/${{ github.repository }}/milestones \ + --jq '[.[] | select(.open_issues > 0 and .closed_issues > 0)] | + map(" ❌ \(.title): \(.open_issues) open issue(s) remaining") | .[]') + + if [ -n "$PARTIAL" ]; then + echo "GATE_PASSED=false" >> "$GITHUB_OUTPUT" + echo "PARTIAL_MILESTONES<> "$GITHUB_OUTPUT" + echo "$PARTIAL" >> "$GITHUB_OUTPUT" + echo "EOF" >> "$GITHUB_OUTPUT" + else + echo "GATE_PASSED=true" >> "$GITHUB_OUTPUT" + fi + + - name: Comment on PR if blocked + if: steps.gate.outputs.GATE_PASSED == 'false' + uses: actions/github-script@v7 + with: + script: | + const partial = `${{ steps.gate.outputs.PARTIAL_MILESTONES }}`; + const body = [ + '## ❌ Release Gate Blocked', + '', + 'This PR cannot be merged to `main` because one or more sprint milestones', + 'have open issues. All milestone issues must be **closed (Done)** before releasing.', + '', + '### Milestones with open issues', + partial, + '', + '**Next steps:**', + '- Close or move the remaining issues listed above.', + '- If an issue is deferred to a future sprint, reassign it to the next milestone.', + '- Once all active milestones are complete, this check will pass automatically.', + ].join('\n'); + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body, + }); + + - name: Fail if gate did not pass + if: steps.gate.outputs.GATE_PASSED == 'false' + run: | + echo "" + echo "❌ Release gate FAILED. Partially complete milestones detected:" + echo "${{ steps.gate.outputs.PARTIAL_MILESTONES }}" + echo "" + echo "Close or reassign all open issues before merging to main." + exit 1 + + - name: Gate passed + if: steps.gate.outputs.GATE_PASSED == 'true' + run: | + echo "✅ Release gate PASSED — all active milestones are complete." diff --git a/.squad/playbooks/release-myblog.md b/.squad/playbooks/release-myblog.md index 4be51b24..b6a60cd7 100644 --- a/.squad/playbooks/release-myblog.md +++ b/.squad/playbooks/release-myblog.md @@ -21,7 +21,7 @@ Use this playbook when MyBlog is ready to move validated work from `dev` to | **Release Branch** | `main` | Release-only branch | | **Hotfix Branches** | `hotfix/*` | Branch from `main`, then backport to `dev` | | **Versioning** | `GitVersion.yml` | SemVer labels from branch + git history | -| **Active Workflows** | `ci.yml`, `hotfix-backport-reminder.yml` | No automated release/promote workflow today | +| **Active Workflows** | `ci.yml`, `release-gate.yml`, `hotfix-backport-reminder.yml` | `release-gate.yml` blocks merge to `main` if any active milestone has open issues | | **Published Artifacts** | None automated | No NuGet, Docker, docs, or deploy workflow yet | | **GitHub Release** | Optional manual step | Useful for notes and tags; does not deploy anything | @@ -36,6 +36,20 @@ Use this playbook when MyBlog is ready to move validated work from `dev` to `dev` already contains the source changes. Only hotfixes merged to `main` need a backport to `dev` +## Release readiness gate (automated) + +`release-gate.yml` runs on every PR targeting `main`. It blocks merge if any +milestone has **both open and closed issues** (i.e., a sprint that started but +is not fully done). + +**Gate logic:** +- Milestone with only open issues → future sprint, not yet started → **allowed** +- Milestone with only closed issues → complete sprint → **allowed** +- Milestone with open AND closed issues → partially done sprint → **blocked** + +To unblock: close the remaining open issues, or reassign them to a future +milestone. The check re-runs automatically when the PR is updated. + ## Standard release path (`dev` → `main`) ### 1. Verify `dev` is release-ready @@ -44,6 +58,8 @@ Before opening a release PR, confirm: - All intended `squad/*` work is already merged into `dev` - The latest `dev` commit is green in GitHub Actions (`ci.yml`) +- **Project #4 board shows zero open issues for the milestone(s) being released** + (the `release-gate.yml` workflow enforces this automatically on the PR) - Any release notes summary is ready for the PR body or GitHub Release notes - No emergency hotfix backports are still missing from `dev`