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
5 changes: 5 additions & 0 deletions .commitlintrc.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
extends:
- '@commitlint/config-conventional'

rules:
header-max-length: [0, 'always', 100]
3 changes: 3 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
*.gen.go -diff linguist-generated=true
*.gen.json -diff linguist-generated=true
**/mocks/** -diff linguist-generated=true
131 changes: 131 additions & 0 deletions .github/scripts/compute_release.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
#!/usr/bin/env python3
"""Compute the next stable tag for one module in this repository."""

from __future__ import annotations

import argparse
import re
import subprocess
import sys
from pathlib import Path
from typing import Callable


REPOSITORY_ROOT = Path(__file__).resolve().parents[2]
MODULES = (
"codexapp",
"config",
"debugserver",
"di",
"filesystem",
"health",
"healthotel",
"healthserver",
"healthzap",
"lifecycle",
"log",
"oapivalidator",
"postgresdb",
"sqlitedb",
"telemetry",
"txmanager",
)
BUMPS = ("patch", "minor", "major")
Runner = Callable[[list[str]], subprocess.CompletedProcess[str]]


def run_command(arguments: list[str]) -> subprocess.CompletedProcess[str]:
return subprocess.run(
arguments,
cwd=REPOSITORY_ROOT,
check=False,
capture_output=True,
text=True,
)


def stable_tags(module: str, runner: Runner = run_command) -> list[tuple[tuple[int, int, int], str]]:
result = runner(["git", "tag", "--list", f"{module}/v*"])
if result.returncode != 0:
raise RuntimeError(result.stderr.strip() or "failed to list git tags")
pattern = re.compile(rf"^{re.escape(module)}/v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$")
tags: list[tuple[tuple[int, int, int], str]] = []
for candidate in result.stdout.splitlines():
match = pattern.fullmatch(candidate.strip())
if match:
tags.append((tuple(map(int, match.groups())), candidate.strip()))
return sorted(tags)


def bump_version(version: tuple[int, int, int], bump: str) -> tuple[int, int, int]:
major, minor, patch = version
if bump == "major":
return major + 1, 0, 0
if bump == "minor":
return major, minor + 1, 0
if bump == "patch":
return major, minor, patch + 1
raise ValueError(f"unsupported bump: {bump}")


def compute_release(module: str, bump: str, runner: Runner = run_command) -> tuple[str, str]:
if module not in MODULES:
raise ValueError(f"unsupported module: {module}")
if bump not in BUMPS:
raise ValueError(f"unsupported bump: {bump}")
tags = stable_tags(module, runner)
previous_version, previous_tag = tags[-1] if tags else ((0, 0, 0), "")
next_version = bump_version(previous_version, bump)
next_tag = f"{module}/v{next_version[0]}.{next_version[1]}.{next_version[2]}"
return previous_tag, next_tag


def require_new_commits(module: str, previous_tag: str, runner: Runner = run_command) -> None:
revision = f"{previous_tag}..HEAD" if previous_tag else "HEAD"
result = runner(["git", "rev-list", "--count", revision, "--", module])
if result.returncode != 0:
raise RuntimeError(result.stderr.strip() or "failed to inspect module commits")
if int(result.stdout.strip()) == 0:
raise RuntimeError(f"no unreleased commits touch {module}/")


def require_absent_tag(tag: str, runner: Runner = run_command) -> None:
local = runner(["git", "rev-parse", "--verify", "--quiet", f"refs/tags/{tag}"])
if local.returncode == 0:
raise RuntimeError(f"tag already exists locally: {tag}")
if local.returncode != 1:
raise RuntimeError(local.stderr.strip() or "failed to inspect local tags")
remote = runner(["git", "ls-remote", "--exit-code", "--tags", "origin", f"refs/tags/{tag}"])
if remote.returncode == 0:
raise RuntimeError(f"tag already exists on origin: {tag}")
if remote.returncode not in (2,):
raise RuntimeError(remote.stderr.strip() or "failed to inspect remote tags")


def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser()
parser.add_argument("--module", required=True, choices=MODULES)
parser.add_argument("--bump", required=True, choices=BUMPS)
parser.add_argument("--previous", action="store_true", help="print the previous stable tag")
parser.add_argument("--require-new-commits", action="store_true")
parser.add_argument("--require-absent-tag", action="store_true")
return parser.parse_args()


def main() -> int:
args = parse_args()
try:
previous_tag, next_tag = compute_release(args.module, args.bump)
if args.require_new_commits:
require_new_commits(args.module, previous_tag)
if args.require_absent_tag:
require_absent_tag(next_tag)
except (RuntimeError, ValueError) as error:
print(error, file=sys.stderr)
return 1
print(previous_tag if args.previous else next_tag)
return 0


if __name__ == "__main__":
raise SystemExit(main())
Empty file.
55 changes: 55 additions & 0 deletions .github/scripts/tests/test_compute_release.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
from __future__ import annotations

import subprocess
import unittest

from compute_release import bump_version, compute_release, require_absent_tag, require_new_commits


def completed(stdout: str = "", returncode: int = 0, stderr: str = "") -> subprocess.CompletedProcess[str]:
return subprocess.CompletedProcess([], returncode, stdout, stderr)


class ReleaseVersionTests(unittest.TestCase):
def test_initial_minor_release_is_v0_1_0(self) -> None:
previous, next_tag = compute_release("health", "minor", lambda _: completed())
self.assertEqual("", previous)
self.assertEqual("health/v0.1.0", next_tag)

def test_bumps_latest_stable_tag_and_ignores_nonstable_tags(self) -> None:
tags = "health/v0.2.9\nhealth/v0.3.0-rc.1\nhealth/v0.10.1\nother/v9.0.0\n"
previous, next_tag = compute_release("health", "patch", lambda _: completed(tags))
self.assertEqual("health/v0.10.1", previous)
self.assertEqual("health/v0.10.2", next_tag)

def test_all_bump_kinds_reset_lower_components(self) -> None:
self.assertEqual((2, 0, 0), bump_version((1, 2, 3), "major"))
self.assertEqual((1, 3, 0), bump_version((1, 2, 3), "minor"))
self.assertEqual((1, 2, 4), bump_version((1, 2, 3), "patch"))

def test_rejects_unknown_module(self) -> None:
with self.assertRaisesRegex(ValueError, "unsupported module"):
compute_release("unknown", "patch", lambda _: completed())

def test_requires_module_commits_after_previous_tag(self) -> None:
calls: list[list[str]] = []

def runner(arguments: list[str]) -> subprocess.CompletedProcess[str]:
calls.append(arguments)
return completed("0\n")

with self.assertRaisesRegex(RuntimeError, "no unreleased commits"):
require_new_commits("health", "health/v0.1.0", runner)
self.assertEqual(
["git", "rev-list", "--count", "health/v0.1.0..HEAD", "--", "health"],
calls[0],
)

def test_rejects_existing_remote_tag(self) -> None:
responses = iter((completed(returncode=1), completed("tag\n")))
with self.assertRaisesRegex(RuntimeError, "already exists on origin"):
require_absent_tag("health/v0.1.0", lambda _: next(responses))


if __name__ == "__main__":
unittest.main()
101 changes: 101 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
name: CI

on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_call:
inputs:
force_go_ci:
description: Run Go checks regardless of changed paths.
type: boolean
default: false
run_postgres_integration:
description: Run PostgreSQL integration tests regardless of changed paths.
type: boolean
default: false

concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

permissions:
contents: read
pull-requests: read

jobs:
changes:
runs-on: ubuntu-latest
outputs:
go_ci: ${{ steps.filter.outputs.go_ci }}
postgres: ${{ steps.filter.outputs.postgres }}
steps:
- uses: actions/checkout@v6
- name: Detect changed paths
id: filter
if: ${{ !inputs.force_go_ci && !inputs.run_postgres_integration }}
uses: dorny/paths-filter@v4
with:
filters: |
go_ci:
- '**/*.go'
- '**/go.mod'
- '**/go.sum'
- 'go.work'
- 'go.work.sum'
- '.golangci.yml'
- 'mise.toml'
- 'mise.lock'
- '.github/workflows/**'
- '.github/scripts/**'
postgres:
- 'postgresdb/**'
- 'txmanager/**'
- 'go.work'
- 'go.work.sum'
- '.github/workflows/ci.yml'

quality:
needs: changes
if: inputs.force_go_ci || needs.changes.outputs.go_ci == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: jdx/mise-action@v4
- name: Lint
run: mise run lint
- name: Check generated code
run: mise run check-generated
- name: Test with race detector
run: mise run test:race
- name: Test release helper
run: python3 -m unittest discover -s .github/scripts/tests -t .github/scripts

postgres-integration:
needs: changes
if: inputs.run_postgres_integration || needs.changes.outputs.postgres == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: jdx/mise-action@v4
- name: Run PostgreSQL integration tests
run: mise run postgresdb:test-integration

gate:
if: always()
needs: [changes, quality, postgres-integration]
runs-on: ubuntu-latest
steps:
- name: Verify CI result
env:
CHANGES_RESULT: ${{ needs.changes.result }}
QUALITY_RESULT: ${{ needs.quality.result }}
POSTGRES_RESULT: ${{ needs.postgres-integration.result }}
run: |
for result in "$CHANGES_RESULT" "$QUALITY_RESULT" "$POSTGRES_RESULT"; do
case "$result" in
success|skipped) ;;
*) exit 1 ;;
esac
done
42 changes: 42 additions & 0 deletions .github/workflows/commitlint.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
name: Commit checks

on:
push:
branches: [main]
pull_request:
branches: [main]

concurrency:
group: commitlint-${{ github.ref }}
cancel-in-progress: true

permissions:
contents: read

jobs:
commitlint:
if: >-
(github.event_name == 'pull_request' && github.event.pull_request.user.login != 'dependabot[bot]')
|| (github.event_name == 'push' && github.event.head_commit.author.name != 'dependabot[bot]')
runs-on: ubuntu-latest
steps:
- name: Enforce single-commit PRs
if: github.event_name == 'pull_request'
env:
PR_COMMIT_COUNT: ${{ github.event.pull_request.commits }}
run: test "$PR_COMMIT_COUNT" -eq 1
- uses: actions/checkout@v6
with:
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
- uses: actions/setup-node@v6
with:
node-version: "22"
- name: Install commitlint
run: npm install --no-save --no-package-lock @commitlint/cli @commitlint/config-conventional
- name: Validate PR title
if: github.event_name == 'pull_request'
env:
PR_TITLE: ${{ github.event.pull_request.title }}
run: printf '%s\n' "$PR_TITLE" | npx commitlint --verbose
- name: Validate last commit
run: npx commitlint --last --verbose
Loading