Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
204 changes: 204 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 = "<!-- version-suggestion-bot";

function parseVersion(v) {
const m = v.match(/^(\d+)\.(\d+)\.(\d+)(?:-([a-zA-Z]+)\.(\d+))?$/);
if (!m) throw new Error(`Cannot parse version: ${v}`);
return {
major: Number(m[1]), minor: Number(m[2]), patch: Number(m[3]),
stage: m[4] || null, stageNum: m[5] ? Number(m[5]) : null,
};
}
function formatVersion(v) {
const base = `${v.major}.${v.minor}.${v.patch}`;
return v.stage ? `${base}-${v.stage}.${v.stageNum}` : base;
}
function bumpStable(v, cls) {
const out = { major: v.major, minor: v.minor, patch: v.patch, stage: null, stageNum: null };
if (cls === "major") { out.major += 1; out.minor = 0; out.patch = 0; }
else if (cls === "minor") { out.minor += 1; out.patch = 0; }
else if (cls === "patch") { out.patch += 1; }
return out;
}
function computeNextVersion(baselineStr, classification, channelLabel) {
const baseline = parseVersion(baselineStr);
if (channelLabel === "stable") {
if (baseline.stage) {
return formatVersion({ major: baseline.major, minor: baseline.minor, patch: baseline.patch, stage: null, stageNum: null });
}
return formatVersion(bumpStable(baseline, classification));
}
if (baseline.stage === channelLabel) {
return formatVersion({ ...baseline, stageNum: baseline.stageNum + 1 });
}
let base = { major: baseline.major, minor: baseline.minor, patch: baseline.patch };
if (!baseline.stage) {
const bumped = bumpStable(baseline, classification);
base = { major: bumped.major, minor: bumped.minor, patch: bumped.patch };
}
return formatVersion({ ...base, stage: channelLabel, stageNum: 1 });
}

const prNumber = context.payload.pull_request.number;

// Find our most recent, not-yet-minimized comment on this PR.
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
per_page: 100,
});
const ours = comments.filter(c => c.body.includes(marker));
const previous = ours.length ? ours[ours.length - 1] : null;

let previousClassification = null;
if (previous) {
const m = previous.body.match(/classification=([\w-]+:[\w-]+:[\w-]+)/);
previousClassification = m ? m[1] : null;
}
const currentClassification = `${type}:${classification}:${channel}`;

if (classification === "none") {
if (previous && previousClassification !== currentClassification) {
// Was suggesting something (or saying "none" for a different
// reason/channel), now saying "none" for this reason — say so
// once, then stop.
await minimizePrevious();
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
body: `No release needed for this PR (\`${type}\`).\n\n${marker} classification=${currentClassification} -->`,
});
}
// 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
Expand Down
24 changes: 24 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
6 changes: 6 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
26 changes: 26 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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)
Expand Down
Loading