diff --git a/.github/workflows/create-release.yaml b/.github/workflows/create-release.yaml index c30c43fe9c..6f2f92c160 100644 --- a/.github/workflows/create-release.yaml +++ b/.github/workflows/create-release.yaml @@ -4,11 +4,12 @@ on: workflow_dispatch: inputs: version_bump: - description: "Version bump type (hotfix branches force 'patch')" + description: "Version bump ('auto' = minor if a FEAT/GATED-FEAT PR merged since the last release, else patch; hotfix branches force 'patch')" required: true - default: "patch" + default: "auto" type: choice options: + - auto - patch - minor - major @@ -39,6 +40,11 @@ jobs: runs-on: ubuntu-latest permissions: contents: write + # 'auto' version_bump classifies merged PR titles via `gh pr list`, which + # needs read access to pull requests. Declaring an explicit permissions + # block sets every unlisted scope to 'none', so without this the query 403s + # and 'auto' falls back to patch (with a warning) on every run. + pull-requests: read defaults: run: shell: bash -euo pipefail {0} @@ -82,7 +88,9 @@ jobs: env: BUMP: ${{ github.event.inputs.version_bump }} run: | - if [[ "$BUMP" != "patch" ]]; then + # 'auto' is allowed here — the "Resolve auto bump" step maps it to 'patch' + # on a hotfix line (hotfixes are patch-only by definition). + if [[ "$BUMP" != "patch" && "$BUMP" != "auto" ]]; then echo "::error::Hotfix branches only support 'patch' bumps. Got: '$BUMP'" exit 1 fi @@ -158,11 +166,78 @@ jobs: echo "✅ $AHEAD new commit(s) on '$BRANCH' since $LATEST_TAG" fi + - name: Resolve auto bump + id: resolve-bump + if: github.event.inputs.version_bump == 'auto' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + MODE: ${{ steps.branch-mode.outputs.mode }} + LATEST_TAG: ${{ steps.get-latest.outputs.latest_tag }} + run: | + # 'auto' only chooses minor-vs-patch on main; hotfix lines are patch-only. + if [[ "$MODE" != "main" ]]; then + echo "resolved=patch" >> "$GITHUB_OUTPUT" + echo "::notice::auto bump on hotfix line -> patch" + exit 0 + fi + + # Classify PRs merged into main after the base release was published: + # any [FEAT]/[GATED-FEAT] title (the only feature PR types in the + # contribution guide) => minor, otherwise patch. Every failure below + # degrades to patch — never over-bump the public version, and never + # hard-fail the release on a transient query hiccup. '// empty' keeps a + # JSON null from leaking through as the literal "null" (matches the + # get-latest step's convention). + SINCE=$(gh api "repos/${{ github.repository }}/releases/tags/$LATEST_TAG" --jq '.published_at // empty' 2>/tmp/gh_since.err || echo "") + if [[ -z "$SINCE" ]]; then + echo "::warning::Could not resolve publish time for $LATEST_TAG; defaulting to patch." + cat /tmp/gh_since.err >&2 || true + echo "resolved=patch" >> "$GITHUB_OUTPUT" + exit 0 + fi + + LIMIT=200 + if ! PR_JSON=$(gh pr list --repo "${{ github.repository }}" \ + --base main --state merged --limit "$LIMIT" --search "merged:>$SINCE" \ + --json title 2>/tmp/gh_pr.err); then + echo "::warning::PR classification query failed; defaulting to patch." + cat /tmp/gh_pr.err >&2 || true # surface 403/permission vs rate-limit/5xx + RESOLVED=patch + else + # Parse defensively: malformed/non-array stdout must degrade to patch, + # not crash the job under `set -e`/pipefail. + TOTAL=$(jq 'length' <<<"$PR_JSON" 2>/dev/null) || TOTAL="" + FEAT_COUNT=$(jq '[.[] | select(.title | test("\\[(FEAT|GATED-FEAT)\\]"; "i"))] | length' <<<"$PR_JSON" 2>/dev/null) || FEAT_COUNT="" + if ! [[ "$TOTAL" =~ ^[0-9]+$ && "$FEAT_COUNT" =~ ^[0-9]+$ ]]; then + echo "::warning::PR classification returned unparseable output; defaulting to patch." + RESOLVED=patch + elif [[ "$FEAT_COUNT" -gt 0 ]]; then + RESOLVED=minor + else + RESOLVED=patch + # Truncation only changes the outcome when no FEAT was found within + # the cap, so only warn in that case (a FEAT beyond it could be missed). + if [[ "$TOTAL" -ge "$LIMIT" ]]; then + echo "::warning::merged-PR query hit the $LIMIT-result cap since $SINCE; a FEAT PR beyond it may be missed — double-check the bump (override with version_bump=minor if needed)." + fi + fi + fi + + echo "resolved=$RESOLVED" >> "$GITHUB_OUTPUT" + if [[ "$RESOLVED" == "minor" ]]; then + echo "::notice::auto bump: $FEAT_COUNT FEAT/GATED-FEAT PR(s) merged since $LATEST_TAG -> minor" + else + echo "::notice::auto bump: no FEAT/GATED-FEAT PR merged since $LATEST_TAG -> patch. Re-run with version_bump=minor to override." + fi + - name: Compute next version id: compute-version env: LATEST_TAG: ${{ steps.get-latest.outputs.latest_tag }} - BUMP_TYPE: ${{ github.event.inputs.version_bump }} + # Non-auto: use the raw input. Auto: use the resolved value, falling back + # to 'patch' (never back to 'auto', which would hit compute-version's + # unknown-bump error). + BUMP_TYPE: ${{ github.event.inputs.version_bump != 'auto' && github.event.inputs.version_bump || steps.resolve-bump.outputs.resolved || 'patch' }} run: | VERSION="${LATEST_TAG#v}" MAJOR=$(echo "$VERSION" | cut -d. -f1) @@ -225,9 +300,12 @@ jobs: LATEST_TAG: ${{ steps.get-latest.outputs.latest_tag }} NEW_TAG: ${{ steps.compute-version.outputs.new_tag }} BUMP: ${{ github.event.inputs.version_bump }} + RESOLVED: ${{ steps.resolve-bump.outputs.resolved }} PRERELEASE: ${{ github.event.inputs.pre_release }} MODE: ${{ steps.branch-mode.outputs.mode }} run: | + BUMP_DISPLAY="$BUMP" + [[ "$BUMP" == "auto" ]] && BUMP_DISPLAY="auto -> ${RESOLVED}" { echo "## Dry Run Summary" echo "" @@ -235,7 +313,7 @@ jobs: echo "|-------|-------|" echo "| Mode | $MODE |" echo "| Current version | $LATEST_TAG |" - echo "| Bump type | $BUMP |" + echo "| Bump type | $BUMP_DISPLAY |" echo "| Next version | $NEW_TAG |" echo "| Pre-release | $PRERELEASE |" echo "" @@ -324,9 +402,12 @@ jobs: LATEST_TAG: ${{ steps.get-latest.outputs.latest_tag }} NEW_TAG: ${{ steps.compute-version.outputs.new_tag }} BUMP: ${{ github.event.inputs.version_bump }} + RESOLVED: ${{ steps.resolve-bump.outputs.resolved }} PRERELEASE: ${{ github.event.inputs.pre_release }} MODE: ${{ steps.branch-mode.outputs.mode }} run: | + BUMP_DISPLAY="$BUMP" + [[ "$BUMP" == "auto" ]] && BUMP_DISPLAY="auto -> ${RESOLVED}" { echo "## Release Created" echo "" @@ -335,7 +416,7 @@ jobs: echo "| Mode | $MODE |" echo "| Previous version | $LATEST_TAG |" echo "| New version | $NEW_TAG |" - echo "| Bump type | $BUMP |" + echo "| Bump type | $BUMP_DISPLAY |" echo "| Pre-release | $PRERELEASE |" echo "| Triggered by | ${{ github.actor }} |" echo ""