From 88dbd8366eebf0a2888e84a4c283fd7c42a09e9e Mon Sep 17 00:00:00 2001 From: Adam J Esslinger Date: Tue, 11 Aug 2026 09:46:09 -0400 Subject: [PATCH] Add PR version-suggestion CI comment (precursor to auto-release) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A precursor to eventually automating tag/release creation: when a PR's title implies a version bump, CI now posts a comment suggesting the next tag/version — without creating anything. Several real design decisions, each resolved with the user rather than assumed: - Type -> bump mapping: standard Conventional Commits (feat->minor, fix/refactor/perf->patch, docs/style/chore/test/ci/build->none, !/BREAKING CHANGE->major). - Beta-phase behavior: while the baseline version carries a -beta.N/-rc.N suffix, a release-worthy PR just increments that stage's counter, not a full patch/minor/major recompute -- nothing has shipped stable yet. - Prerelease channel: NOT signaled in the title. Checked precedent first (semantic-release/release-please control channel via labels or release- train branches, not embedded title syntax; no Tabularis repo does this either) and settled on a required prerelease:alpha|beta|rc|stable PR label. Missing label is a hard error -- the job fails rather than guessing a default channel. - Version baseline: latest git tag on main, falling back to main's .tabularium version field if no tag exists (true right now). - "Meaningful change" trigger: only re-comments when the PR's *derived classification* (type+breaking+channel) changes, not on every title edit -- tracked via a hidden HTML marker in the comment body, robust across opened/edited/reopened/synchronize/labeled/unlabeled. - Marking old suggestions outdated: GitHub's real minimizeComment GraphQL mutation with classifier: OUTDATED -- the same action available via the web UI's "..." menu -> "Hide comment" -> "Outdated". - Found and fixed a real trigger gap: the existing pull_request: block had no types:, defaulting to opened/synchronize/reopened -- a title-only edit never even re-ran CI. Widened to include edited/labeled/unlabeled (shared by all pull_request-triggered jobs, including the existing pr-title job, which also now re-validates on edited). Checked precedent before implementing: no sibling Tabularis plugin repo or tabularis itself has anything like this. A separate internal repo has a fuller PR-title-driven auto-tag/auto-release pipeline; decided against porting that whole pipeline here since it's a much larger behavioral change (replaces manual tagging entirely) with no Tabularis-org precedent -- this stays comment-only. Verified thoroughly before trusting the logic in CI: - Extracted the embedded classification regex and ran it in a real bash subshell (not zsh, which handles BASH_REMATCH differently) against 8 title cases -- all classified correctly, including a deliberately unparseable title correctly erroring. - Extracted the embedded version-arithmetic JS and unit-tested it standalone against 7 cases (beta-counter increment, stage transitions, graduation to stable from both beta and rc, stable->prerelease restart) -- all passed. - Wrote a full mock harness for the comment-orchestration logic (fake github.rest.issues.listComments/createComment and github.graphql) and ran 8 end-to-end scenarios covering: fresh suggestion, idempotent no-op on unchanged classification, minimize-and-repost on changed classification, silent on non-release-worthy titles, and the "no-release-needed" transition in both directions. This caught a real bug before it ever reached CI: the "already said none, still none" case incorrectly reposted because the idempotency check compared against a hardcoded string instead of the full classification -- fixed and reverified all 8 scenarios pass. - Confirmed the baseline resolves correctly against the actual current repo state: main now has real tags (v1.0.0-beta.1, v1.0.0-beta.2), so `git describe --tags` resolves directly against origin/main rather than the PR branch; the .tabularium fallback remains for the no-tags-yet case this repo has already moved past. - Both workflow YAML files re-validated after every edit. Also added contributor-facing docs (README's "Contributing: PR Titles & Versioning" section, cross-referenced from CLAUDE.md) so future maintainers understand the convention without reading the workflow YAML. Standard verification: cargo build/test (82/82)/clippy/fmt all pass, no Cargo.lock drift, markdownlint clean across the whole repo, both workflow files parse as valid YAML. Rebased onto main after #5 merged (adds the pr-title job and release validate job this PR builds on) — no functional changes beyond the rebase itself, CHANGELOG merge conflict resolved by keeping both entries. --- .github/workflows/ci.yml | 204 +++++++++++++++++++++++++++++++++++++++ CHANGELOG.md | 24 +++++ CLAUDE.md | 6 ++ README.md | 26 +++++ 4 files changed, 260 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index be41428..69b34dd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,6 +5,10 @@ on: branches: [main] pull_request: branches: [main] + # Default types are opened/synchronize/reopened, which miss title-only + # edits entirely. labeled/unlabeled added so changing the prerelease:* + # label alone re-triggers the version-suggestion job below. + types: [opened, edited, reopened, synchronize, labeled, unlabeled] schedule: # Weekly cargo-audit sweep to catch newly-disclosed CVEs in deps that # haven't otherwise changed. Off-peak minute, not :00/:30. @@ -55,6 +59,206 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + version-suggestion: + name: Version suggestion + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + permissions: + pull-requests: write + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + + # Classify the PR title's Conventional Commits type + breaking-change + # flag into a version-bump class. Requires the prerelease:* label to + # know which channel (alpha/beta/rc/stable) to suggest — see README's + # "Contributing: PR Titles & Versioning" for the full convention. + - name: Classify PR title and resolve prerelease channel + id: classify + env: + PR_TITLE: ${{ github.event.pull_request.title }} + PR_BODY: ${{ github.event.pull_request.body }} + PR_LABELS: ${{ toJson(github.event.pull_request.labels) }} + run: | + PATTERN='^([a-zA-Z]+)(\(([^)]+)\))?(!)?: (.+)$' + if [[ "$PR_TITLE" =~ $PATTERN ]]; then + TYPE="${BASH_REMATCH[1]}" + BANG="${BASH_REMATCH[4]}" + else + echo "::error::PR title does not match Conventional Commits format (type: subject) — cannot classify." + exit 1 + fi + + BREAKING=false + [ -n "$BANG" ] && BREAKING=true + if echo "$PR_BODY" | grep -qiE "^BREAKING[ -]CHANGE:"; then + BREAKING=true + fi + + case "$TYPE" in + feat) CLASS=minor ;; + fix|refactor|perf) CLASS=patch ;; + docs|style|chore|test|ci|build) CLASS=none ;; + *) CLASS=none ;; + esac + [ "$BREAKING" = true ] && CLASS=major + + CHANNEL=$(echo "$PR_LABELS" | jq -r '[.[] | select(.name | startswith("prerelease:")) | .name][0] // ""' | sed 's/^prerelease://') + if [ -z "$CHANNEL" ]; then + echo "::error::No prerelease:alpha|beta|rc|stable label found on this PR. Add one so the version suggestion knows which channel to target — see README's 'Contributing: PR Titles & Versioning'." + exit 1 + fi + case "$CHANNEL" in + alpha|beta|rc|stable) ;; + *) echo "::error::Unrecognized prerelease label value '$CHANNEL' — expected alpha, beta, rc, or stable."; exit 1 ;; + esac + + echo "type=$TYPE" >> "$GITHUB_OUTPUT" + echo "breaking=$BREAKING" >> "$GITHUB_OUTPUT" + echo "class=$CLASS" >> "$GITHUB_OUTPUT" + echo "channel=$CHANNEL" >> "$GITHUB_OUTPUT" + + - name: Resolve baseline version + id: baseline + run: | + git fetch origin main --tags --quiet + TAG=$(git -C . describe --tags --abbrev=0 origin/main 2>/dev/null || true) + if [ -n "$TAG" ]; then + BASELINE="${TAG#v}" + else + BASELINE=$(git show origin/main:.tabularium | jq -r .version) + fi + echo "version=$BASELINE" >> "$GITHUB_OUTPUT" + + - name: Compute suggestion, manage comment + uses: actions/github-script@v7 + with: + script: | + const classification = "${{ steps.classify.outputs.class }}"; + const channel = "${{ steps.classify.outputs.channel }}"; + const type = "${{ steps.classify.outputs.type }}"; + const breaking = "${{ steps.classify.outputs.breaking }}" === "true"; + const baselineStr = "${{ steps.baseline.outputs.version }}"; + const marker = "`, + }); + } + // Otherwise: never suggested anything, or already said "none" — stay silent. + return; + } + + if (previous && previousClassification === currentClassification) { + // Meaningful classification hasn't changed since the last comment. + return; + } + + async function minimizePrevious() { + if (!previous) return; + // REST comment objects expose node_id directly — no separate + // lookup needed to get the GraphQL node id. + await github.graphql( + `mutation($id: ID!) { minimizeComment(input: { subjectId: $id, classifier: OUTDATED }) { clientMutationId } }`, + { id: previous.node_id } + ); + } + + const suggested = computeNextVersion(baselineStr, classification, channel); + const tag = `v${suggested}`; + + await minimizePrevious(); + + const breakingNote = breaking ? " (breaking change)" : ""; + const body = [ + `### Version suggestion`, + ``, + `Based on this PR's title (\`${type}\`${breakingNote}) and the \`prerelease:${channel}\` label:`, + ``, + `| | |`, + `|---|---|`, + `| Current | \`${baselineStr}\` |`, + `| Suggested next tag | \`${tag}\` |`, + ``, + `This is informational only — no tag or release is created automatically yet.`, + ``, + `${marker} classification=${currentClassification} -->`, + ].join("\n"); + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + body, + }); + markdownlint: name: Markdown lint runs-on: ubuntu-latest diff --git a/CHANGELOG.md b/CHANGELOG.md index 702034f..696b935 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,30 @@ ### Added +- `ci.yml`: a `version-suggestion` job posts a PR comment suggesting the + next tag/version based on the PR title's Conventional Commits type + (`feat`→minor, `fix`/`refactor`/`perf`→patch, `docs`/`style`/`chore`/ + `test`/`ci`/`build`→no release, `!`/`BREAKING CHANGE:`→major) and a + required `prerelease:alpha|beta|rc|stable` PR label (missing label fails + the job — no default channel is guessed). Purely informational, a + precursor to eventually automating tag/release creation: nothing is + tagged or released by this job. While the resolved baseline version + carries a prerelease suffix matching the label's channel, the suggestion + just increments that stage's counter (`1.0.0-beta.1` → `-beta.2`) rather + than computing a full patch/minor/major bump — there's no shipped stable + version yet to protect a SemVer contract against. Re-comments only when + the PR's *derived classification* changes (type + breaking + channel), + not on every title edit — tracked via a hidden marker in the comment + body — and marks the previous suggestion as outdated via GitHub's + `minimizeComment` API (same as the web UI's "Hide comment → Outdated") + before posting the new one. Also widens the shared `pull_request:` + trigger's `types:` to include `edited`/`labeled`/`unlabeled` (previously + defaulted to `opened`/`synchronize`/`reopened` only, so a title-only edit + never even re-ran CI). Checked precedent first: no sibling Tabularis + plugin repo or `tabularis` itself has anything like this; a separate + internal repo has a fuller PR-title-driven auto-tag/auto-release + pipeline, but porting that whole pipeline was judged too large a + behavioral change for this pass. - Two more CI checks: - `release.yml`: a `validate` job gates the build matrix on the pushed tag matching `.tabularium`'s `version` field (stripped of the `v` diff --git a/CLAUDE.md b/CLAUDE.md index 71fb529..c70a169 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -87,3 +87,9 @@ migration, now applied across the repo boundary. behavior. Extract pure logic (SQL builders, value binding, pagination math) into testable functions with unit tests in a sibling `_tests.rs` file. +- **PR titles and versioning**: PR titles must be Conventional Commits + (`type: subject`) and every PR needs a `prerelease:alpha|beta|rc|stable` + label — both enforced by CI. See the README's "Contributing: PR Titles & + Versioning" section for the full convention and the type→bump mapping. + CI posts (and keeps up to date) a version-suggestion comment based on + these — informational only, nothing is tagged/released automatically yet. diff --git a/README.md b/README.md index aa5f86a..6c2d2d4 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,7 @@ both drivers against the same live database and compares every response. - [Supported Operations](#supported-operations) - [Building from Source](#building-from-source) - [Development](#development) + - [Contributing: PR Titles & Versioning](#contributing-pr-titles--versioning) - [Changelog](#changelog) - [License](#license) @@ -210,6 +211,31 @@ echo '{"jsonrpc":"2.0","method":"test_connection","params":{"params":{"host":"12 | ./target/release/postgresql-plugin ``` +### Contributing: PR Titles & Versioning + +PR titles must follow [Conventional Commits](https://www.conventionalcommits.org/) +(`type: subject`, `type(scope): subject`, or `type!: subject` for a breaking +change) — enforced by CI on every PR. Add a `BREAKING CHANGE:` footer to the +PR description for breaking changes that don't fit cleanly into the title. + +Every PR also needs exactly one `prerelease:alpha` / `prerelease:beta` / +`prerelease:rc` / `prerelease:stable` label, so CI knows which release +channel to target when suggesting the next version. There's no default — +CI fails with a clear error if the label is missing, rather than guessing. + +| PR title type | Version impact | +| --- | --- | +| `feat` | minor | +| `fix`, `refactor`, `perf` | patch | +| `docs`, `style`, `chore`, `test`, `ci`, `build` | none — no release suggested | +| any type with `!` or a `BREAKING CHANGE:` footer | major | + +CI posts a comment on the PR suggesting the next tag/version based on the +title's type and the `prerelease:*` label — informational only, nothing is +tagged or released automatically (yet). The suggestion updates (and marks +the previous suggestion as outdated) only when the underlying +classification actually changes, not on every edit to the title text. + ### Tech Stack - **Language:** Rust (edition 2021)