From b6cd143b4a63e8652b8d12dc358d2a99463eaad8 Mon Sep 17 00:00:00 2001 From: Ritwik G Date: Wed, 1 Jul 2026 16:38:05 +0530 Subject: [PATCH 1/3] [MISC] Add 'auto' version bump to OSS create-release MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an 'auto' choice (now the default) to create-release.yaml that picks the OSS version bump from merged PR titles: a [FEAT]/[GATED-FEAT] PR merged since the last release -> minor, otherwise patch. These are the only feature PR types in the contribution guide, so this matches the documented SemVer intent without any new labeling. Details: - 'auto' resolves in a new "Resolve auto bump" step (main mode only; hotfix lines stay patch-only). Fail-safe is always patch, so a compare/PR query hiccup never over-bumps the public version. - Hotfix-mode input validation now accepts 'auto' (maps to patch). - compute-version consumes the resolved bump; dry-run/final summaries show "auto -> minor/patch" for an explicit audit trail. - 'auto' never selects major — that stays behind the confirm_major gate. Validated by replaying the classifier over the last 13 real releases: 12/13 matched the human bump; the lone diff was a discretionary minor with no FEAT PR (recoverable via manual minor override, which the notice points to). Co-Authored-By: Claude Opus 4.8 --- .github/workflows/create-release.yaml | 68 ++++++++++++++++++++++++--- 1 file changed, 62 insertions(+), 6 deletions(-) diff --git a/.github/workflows/create-release.yaml b/.github/workflows/create-release.yaml index c30c43fe9c..20576d7c0f 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 @@ -82,7 +83,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 +161,58 @@ 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. Fail-safe is always + # patch so a query hiccup never over-bumps the public version. + SINCE=$(gh api "repos/${{ github.repository }}/releases/tags/$LATEST_TAG" --jq '.published_at' 2>/dev/null || echo "") + if [[ -z "$SINCE" ]]; then + echo "::warning::Could not resolve publish time for $LATEST_TAG; defaulting to patch." + echo "resolved=patch" >> "$GITHUB_OUTPUT" + exit 0 + fi + + FEAT_COUNT=$(gh pr list --repo "${{ github.repository }}" \ + --base main --state merged --limit 200 --search "merged:>$SINCE" \ + --json title \ + --jq '[.[] | select(.title | test("\\[(FEAT|GATED-FEAT)\\]"; "i"))] | length' 2>/dev/null || echo "") + + if [[ -z "$FEAT_COUNT" ]]; then + echo "::warning::PR classification query failed; defaulting to patch." + RESOLVED=patch + elif [[ "$FEAT_COUNT" -gt 0 ]]; then + RESOLVED=minor + else + RESOLVED=patch + 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 }} + BUMP_TYPE: ${{ github.event.inputs.version_bump == 'auto' && steps.resolve-bump.outputs.resolved || github.event.inputs.version_bump }} run: | VERSION="${LATEST_TAG#v}" MAJOR=$(echo "$VERSION" | cut -d. -f1) @@ -225,9 +275,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 +288,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 +377,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 +391,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 "" From 573f5690dbebbcaca573804aeb6cded03bcc96a3 Mon Sep 17 00:00:00 2001 From: Ritwik G Date: Wed, 1 Jul 2026 16:47:06 +0530 Subject: [PATCH 2/3] [MISC] Address review: PR-read perms, patch fallback, truncation warning - Add `pull-requests: read` to the job (the permissions block sets unlisted scopes to none, so `gh pr list` would 403 and 'auto' would silently always fall back to patch). [CodeRabbit] - BUMP_TYPE falls back to 'patch' instead of 'auto' when resolved is empty, avoiding a latent "Unknown bump type: 'auto'" job failure. [Greptile] - Surface a warning when the merged-PR query hits the 200-result cap instead of silently under-counting FEAT PRs. [Greptile] Co-Authored-By: Claude Opus 4.8 --- .github/workflows/create-release.yaml | 36 ++++++++++++++++++++------- 1 file changed, 27 insertions(+), 9 deletions(-) diff --git a/.github/workflows/create-release.yaml b/.github/workflows/create-release.yaml index 20576d7c0f..10bee08761 100644 --- a/.github/workflows/create-release.yaml +++ b/.github/workflows/create-release.yaml @@ -40,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 (the permissions block above sets every + # unlisted scope to 'none', so this must be explicit or the query 403s and + # 'auto' would silently fall back to patch every time). + pull-requests: read defaults: run: shell: bash -euo pipefail {0} @@ -187,18 +192,28 @@ jobs: exit 0 fi - FEAT_COUNT=$(gh pr list --repo "${{ github.repository }}" \ - --base main --state merged --limit 200 --search "merged:>$SINCE" \ - --json title \ - --jq '[.[] | select(.title | test("\\[(FEAT|GATED-FEAT)\\]"; "i"))] | length' 2>/dev/null || echo "") + LIMIT=200 + PR_JSON=$(gh pr list --repo "${{ github.repository }}" \ + --base main --state merged --limit "$LIMIT" --search "merged:>$SINCE" \ + --json title 2>/dev/null || echo "") - if [[ -z "$FEAT_COUNT" ]]; then + if [[ -z "$PR_JSON" ]]; then + # Empty only on command failure; a no-PR result is the JSON literal "[]". echo "::warning::PR classification query failed; defaulting to patch." RESOLVED=patch - elif [[ "$FEAT_COUNT" -gt 0 ]]; then - RESOLVED=minor else - RESOLVED=patch + TOTAL=$(echo "$PR_JSON" | jq 'length') + FEAT_COUNT=$(echo "$PR_JSON" | jq '[.[] | select(.title | test("\\[(FEAT|GATED-FEAT)\\]"; "i"))] | length') + # Surface truncation rather than silently under-counting: if we hit the cap, + # a FEAT PR beyond it could be missed (biasing toward patch). + if [[ "$TOTAL" -ge "$LIMIT" ]]; then + echo "::warning::merged-PR query hit the $LIMIT-result cap since $SINCE; FEAT detection may under-count — double-check the bump (override with version_bump=minor if needed)." + fi + if [[ "$FEAT_COUNT" -gt 0 ]]; then + RESOLVED=minor + else + RESOLVED=patch + fi fi echo "resolved=$RESOLVED" >> "$GITHUB_OUTPUT" @@ -212,7 +227,10 @@ jobs: id: compute-version env: LATEST_TAG: ${{ steps.get-latest.outputs.latest_tag }} - BUMP_TYPE: ${{ github.event.inputs.version_bump == 'auto' && steps.resolve-bump.outputs.resolved || 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) From 80611db083109d90894f4692ae64c4f2e6f51def Mon Sep 17 00:00:00 2001 From: Ritwik G Date: Wed, 1 Jul 2026 16:53:03 +0530 Subject: [PATCH 3/3] [MISC] Harden auto-bump resolve step (review follow-ups) From a multi-agent review of the auto-bump step: - Guard the jq parses: malformed/non-array stdout now degrades to patch instead of crashing the job under set -e/pipefail (upholds the "never fail the release outright" invariant). Also validates TOTAL/FEAT_COUNT are numeric before arithmetic. - Detect gh failure by exit status (if ! PR_JSON=$(...)) and surface captured stderr, so a permanent 403 (e.g. dropped pull-requests scope) is diagnosable rather than a cause-free warning that silently patches forever. - Add '// empty' to the published_at lookup for consistency with get-latest, so a JSON null can't leak through as the literal "null". - Only emit the truncation warning when no FEAT was found within the cap (the only case where truncation could change the outcome). - Fix an inaccurate comment ("silently" -> "with a warning") and reword the self-referential permissions comment. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/create-release.yaml | 47 +++++++++++++++------------ 1 file changed, 27 insertions(+), 20 deletions(-) diff --git a/.github/workflows/create-release.yaml b/.github/workflows/create-release.yaml index 10bee08761..6f2f92c160 100644 --- a/.github/workflows/create-release.yaml +++ b/.github/workflows/create-release.yaml @@ -41,9 +41,9 @@ jobs: permissions: contents: write # 'auto' version_bump classifies merged PR titles via `gh pr list`, which - # needs read access to pull requests (the permissions block above sets every - # unlisted scope to 'none', so this must be explicit or the query 403s and - # 'auto' would silently fall back to patch every time). + # 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: @@ -183,36 +183,43 @@ jobs: # 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. Fail-safe is always - # patch so a query hiccup never over-bumps the public version. - SINCE=$(gh api "repos/${{ github.repository }}/releases/tags/$LATEST_TAG" --jq '.published_at' 2>/dev/null || echo "") + # 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 - PR_JSON=$(gh pr list --repo "${{ github.repository }}" \ - --base main --state merged --limit "$LIMIT" --search "merged:>$SINCE" \ - --json title 2>/dev/null || echo "") - - if [[ -z "$PR_JSON" ]]; then - # Empty only on command failure; a no-PR result is the JSON literal "[]". + 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 - TOTAL=$(echo "$PR_JSON" | jq 'length') - FEAT_COUNT=$(echo "$PR_JSON" | jq '[.[] | select(.title | test("\\[(FEAT|GATED-FEAT)\\]"; "i"))] | length') - # Surface truncation rather than silently under-counting: if we hit the cap, - # a FEAT PR beyond it could be missed (biasing toward patch). - if [[ "$TOTAL" -ge "$LIMIT" ]]; then - echo "::warning::merged-PR query hit the $LIMIT-result cap since $SINCE; FEAT detection may under-count — double-check the bump (override with version_bump=minor if needed)." - fi - if [[ "$FEAT_COUNT" -gt 0 ]]; then + # 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