From 27024e6f055353030a174a27dc4a90e2f58837e8 Mon Sep 17 00:00:00 2001 From: Kyl-Bempah <24903083+Kyl-Bempah@users.noreply.github.com> Date: Wed, 7 Oct 2026 17:28:03 -0400 Subject: [PATCH 1/2] ci(core): two-tier CI + release scaffold (XWING-2207) Signed-off-by: Kyl-Bempah <24903083+Kyl-Bempah@users.noreply.github.com> --- .github/workflows/ci.yml | 87 +++++++++++++++++++++++++++++++++++ .github/workflows/release.yml | 53 +++++++++++++++++++++ 2 files changed, 140 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..37adb39 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,87 @@ +# Tier 1: runs on every PR and on main. Red blocks merge (XWING-2207 AC#2). +# Mirrors the Makefile so CI and local behave identically. +name: CI + +on: + pull_request: + push: + branches: [main] + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v6 + with: + enable-cache: true + python-version: "3.12" + - run: uv sync --frozen + - run: make lint + + test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.11", "3.12"] # 3.11 is the requires-python floor + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v6 + with: + enable-cache: true + python-version: ${{ matrix.python-version }} + - run: uv sync --frozen + - run: make test + + postgres: + runs-on: ubuntu-latest + services: + postgres: + image: postgres:16 + env: + POSTGRES_USER: traust + POSTGRES_PASSWORD: traust-test-only + POSTGRES_DB: traust_test + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U traust" + --health-interval 5s + --health-timeout 5s + --health-retries 10 + env: + # Matches the Makefile default; psycopg (v3) driver. + TRAUST_TEST_DATABASE_URL: postgresql+psycopg://traust:traust-test-only@127.0.0.1:5432/traust_test + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v6 + with: + enable-cache: true + python-version: "3.12" + - run: uv sync --frozen --extra postgres + - run: make test-integration + + api-check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v6 + with: + enable-cache: true + python-version: "3.12" + - run: uv sync --frozen + # The api-check target is delivered by XWING-2208. Until it lands this is a + # no-op notice rather than a hard failure, and it activates automatically + # once `make api-check` exists. + - name: api-check + run: | + if make -n api-check >/dev/null 2>&1; then + make api-check + else + echo "::notice::'make api-check' not defined yet (XWING-2208) — skipping" + fi diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..6e09d57 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,53 @@ +# Tier 2: manual release (XWING-2207 AC#3). Builds and verifies on dispatch. +# +# SCAFFOLD — the publish target is intentionally not wired yet. traust-core must +# publish to a package index so consumers pin `traust-core>=X.Y` (NOT an exact git +# tag), per traust-platform-api.md "Rules for traust-core". Choosing that index and +# adding its credentials is an open decision; until then this workflow only builds, +# tests and uploads the artifacts to the run for inspection. Enable the TODO steps +# once the index is decided. +name: Release + +on: + workflow_dispatch: + inputs: + version: + description: "Version to release (must equal the version in pyproject.toml)" + required: true + +jobs: + release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v6 + with: + enable-cache: true + python-version: "3.12" + - run: uv sync --frozen + - run: make test + + - name: Verify pyproject version matches the requested version + run: | + pv=$(uv run python -c "import tomllib,pathlib; print(tomllib.loads(pathlib.Path('pyproject.toml').read_text())['project']['version'])") + if [ "$pv" != "${{ inputs.version }}" ]; then + echo "::error::pyproject version '$pv' != requested '${{ inputs.version }}' — bump pyproject.toml first" + exit 1 + fi + + - name: Build wheel + sdist + run: uv build + + - uses: actions/upload-artifact@v4 + with: + name: traust-core-${{ inputs.version }} + path: dist/* + + # TODO(XWING-2207): publish + tag + release, once the package index is chosen. + # 1. Add the index + token as repo secrets. + # 2. uv publish --publish-url --token ${{ secrets.TRAUST_INDEX_TOKEN }} + # 3. git tag v${{ inputs.version }} && git push origin v${{ inputs.version }} + # 4. Dispatch downstream (traust-engine) to bump its traust-core pin. + # Consumers pin with >=, never an exact git tag (traust-platform-api.md). + - name: Publish (not configured) + run: echo "::warning::publish/tag not wired yet (XWING-2207) — choose the index, add the secret, enable the TODO steps" From 4df3ddb55ab5ac361f7875f399aad2252739eeac Mon Sep 17 00:00:00 2001 From: Kyl-Bempah <24903083+Kyl-Bempah@users.noreply.github.com> Date: Wed, 7 Oct 2026 17:48:33 -0400 Subject: [PATCH 2/2] ci(core): self-version via VERSION + release.py; rework release workflow Match the house release pattern (traust/contracts): VERSION file kept equal to pyproject, release.py (status/bump/check/test/build/tag), and make bump/status. Rework release.yml to drive release.py instead of a bespoke version input; no package index yet, so consumers pin the git tag/rev (XWING-2207). --- .github/workflows/release.yml | 54 +++--- Makefile | 20 ++- VERSION | 1 + release.py | 328 ++++++++++++++++++++++++++++++++++ 4 files changed, 369 insertions(+), 34 deletions(-) create mode 100644 VERSION create mode 100755 release.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6e09d57..5b5cfca 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,53 +1,41 @@ -# Tier 2: manual release (XWING-2207 AC#3). Builds and verifies on dispatch. +# Tier 2: manual release (XWING-2207 AC#3). Self-versioned — no package index yet. # -# SCAFFOLD — the publish target is intentionally not wired yet. traust-core must -# publish to a package index so consumers pin `traust-core>=X.Y` (NOT an exact git -# tag), per traust-platform-api.md "Rules for traust-core". Choosing that index and -# adding its credentials is an open decision; until then this workflow only builds, -# tests and uploads the artifacts to the run for inspection. Enable the TODO steps -# once the index is decided. +# traust-core is versioned by the VERSION file (kept equal to pyproject.toml) and +# released with the house release.py: test -> `uv build` -> create the vX.Y.Z git +# tag. There is no package index; consumers pin the git tag/rev (as traust-core +# pins traust-contracts today). traust-platform-api.md calls for moving to an +# index so consumers can use `>=` — wire `uv publish` here once that's decided. +# +# To cut a release: bump in a PR (`make bump patch|minor|major`), merge, then run +# this workflow. Default build+tag is local to the run; set push=true to publish +# the tag to origin. name: Release on: workflow_dispatch: inputs: - version: - description: "Version to release (must equal the version in pyproject.toml)" - required: true + push: + description: "Push the vX.Y.Z tag to origin" + type: boolean + default: false jobs: release: runs-on: ubuntu-latest + permissions: + contents: write steps: - uses: actions/checkout@v4 + with: + fetch-depth: 0 # release.py inspects existing tags - uses: astral-sh/setup-uv@v6 with: enable-cache: true python-version: "3.12" - run: uv sync --frozen - - run: make test - - - name: Verify pyproject version matches the requested version - run: | - pv=$(uv run python -c "import tomllib,pathlib; print(tomllib.loads(pathlib.Path('pyproject.toml').read_text())['project']['version'])") - if [ "$pv" != "${{ inputs.version }}" ]; then - echo "::error::pyproject version '$pv' != requested '${{ inputs.version }}' — bump pyproject.toml first" - exit 1 - fi - - - name: Build wheel + sdist - run: uv build - + - name: Release (check + test + build + tag) + run: ./release.py release ${{ inputs.push && '--push' || '' }} - uses: actions/upload-artifact@v4 with: - name: traust-core-${{ inputs.version }} + name: traust-core-dist path: dist/* - - # TODO(XWING-2207): publish + tag + release, once the package index is chosen. - # 1. Add the index + token as repo secrets. - # 2. uv publish --publish-url --token ${{ secrets.TRAUST_INDEX_TOKEN }} - # 3. git tag v${{ inputs.version }} && git push origin v${{ inputs.version }} - # 4. Dispatch downstream (traust-engine) to bump its traust-core pin. - # Consumers pin with >=, never an exact git tag (traust-platform-api.md). - - name: Publish (not configured) - run: echo "::warning::publish/tag not wired yet (XWING-2207) — choose the index, add the secret, enable the TODO steps" diff --git a/Makefile b/Makefile index dbc60cb..e8d1e25 100644 --- a/Makefile +++ b/Makefile @@ -8,7 +8,11 @@ TRAUST_TEST_DATABASE_URL ?= postgresql+psycopg://$(DB_USER):$(DB_PASSWORD)@127.0 export TRAUST_TEST_DATABASE_URL -.PHONY: help setup sync lint lint-fix test test-integration coverage coverage-html coverage-all db-up db-down +PYTHON ?= python3 +RELEASE := ./release.py +BUMP_PARTS := patch minor major + +.PHONY: help setup sync lint lint-fix test test-integration coverage coverage-html coverage-all db-up db-down status bump $(BUMP_PARTS) help: @echo "Targets ($(notdir $(CURDIR))):" @@ -74,3 +78,17 @@ db-up: db-down: @podman stop $(DB_CONTAINER) 2>/dev/null || true +status: + $(PYTHON) $(RELEASE) status + +$(BUMP_PARTS): + @: + +bump: + @part="$(filter $(BUMP_PARTS),$(MAKECMDGOALS))"; \ + if [ -z "$$part" ]; then \ + echo "usage: make bump patch|minor|major" >&2; \ + exit 1; \ + fi; \ + $(PYTHON) $(RELEASE) bump $$part + diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..6e8bf73 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +0.1.0 diff --git a/release.py b/release.py new file mode 100755 index 0000000..235cab6 --- /dev/null +++ b/release.py @@ -0,0 +1,328 @@ +#!/usr/bin/env python3 +"""Tag and release helper for this repo. Operates on cwd only. + +For workspace-wide stack releases, use tools/release.py in the mono-checkout. +""" + +from __future__ import annotations + +import os +import re +import subprocess +import sys +from dataclasses import dataclass +from pathlib import Path + +VERSION_FILE = "VERSION" +PYPROJECT_FILE = "pyproject.toml" +VERSION_ARGS_FILE = "version.args" +SEMVER = re.compile(r"^\d+\.\d+\.\d+$") +VERSION_LINE = re.compile(r"(?m)^version = \"[^\"]+\"") +PYPROJECT_VERSION = re.compile(r"(?m)^version = \"([^\"]+)\"") +BUMP_PARTS = frozenset({"major", "minor", "patch"}) + + +class ReleaseError(Exception): + pass + + +def semver_tuple(version: str) -> tuple[int, int, int]: + return tuple(map(int, version.split("."))) + + +def bump_version(current: str, part: str) -> str: + if part in BUMP_PARTS: + major, minor, patch = map(int, current.split(".")) + if part == "major": + return f"{major + 1}.0.0" + if part == "minor": + return f"{major}.{minor + 1}.0" + return f"{major}.{minor}.{patch + 1}" + if SEMVER.match(part): + return part + raise ReleaseError(f"bump must be major, minor, patch, or X.Y.Z; got {part!r}") + + +# --- Git --- + + +@dataclass(frozen=True) +class GitOpts: + force: bool = False + allow_dirty: bool = False + + +class Git: + def __init__(self, cwd: Path, opts: GitOpts | None = None) -> None: + self.cwd = cwd + self.opts = opts or GitOpts() + + def _run( + self, + *args: str, + check: bool = True, + capture: bool = False, + ) -> subprocess.CompletedProcess[str]: + return subprocess.run(args, cwd=self.cwd, check=check, text=True, capture_output=capture) + + def status(self) -> str: + return self._run("git", "status", "--porcelain", capture=True).stdout.strip() + + def branch(self) -> str: + return self._run("git", "rev-parse", "--abbrev-ref", "HEAD", capture=True).stdout.strip() + + def head(self) -> str: + return self._run("git", "rev-parse", "--short", "HEAD", capture=True).stdout.strip() + + def tags(self, pattern: str = "v*") -> list[str]: + out = self._run( + "git", "tag", "--list", pattern, "--sort=-v:refname", capture=True + ).stdout.strip() + return out.splitlines() if out else [] + + def tag_exists(self, tag: str) -> bool: + return ( + self._run( + "git", + "rev-parse", + "--verify", + f"refs/tags/{tag}", + check=False, + capture=True, + ).returncode + == 0 + ) + + def create_tag(self, tag: str, message: str) -> None: + if self.opts.force and self.tag_exists(tag): + self._run("git", "tag", "-d", tag) + self._run("git", "tag", "-a", tag, "-m", message) + + def push(self, *refs: str) -> None: + cmd = ["git", "push"] + if self.opts.force: + cmd.append("--force") + cmd.append("origin") + for ref in refs: + self._run(*cmd, ref) + + @property + def dirty(self) -> bool: + return bool(self.status()) + + def require_clean(self) -> None: + if not self.opts.allow_dirty and self.dirty: + raise ReleaseError("working tree is not clean") + + +# --- Repo --- + + +class Repo: + def __init__(self, path: Path, opts: GitOpts) -> None: + self.path = path + self.git = Git(path, opts) + + @property + def name(self) -> str: + return self.path.name + + def version(self) -> str: + vf = self.path / VERSION_FILE + pj = self.path / PYPROJECT_FILE + if not vf.is_file() or not pj.is_file(): + raise ReleaseError(f"missing {VERSION_FILE} or {PYPROJECT_FILE}") + v_file, text = vf.read_text().strip(), pj.read_text() + m = PYPROJECT_VERSION.search(text) + if not m: + raise ReleaseError(f"no [project] version in {PYPROJECT_FILE}") + if v_file != m.group(1): + raise ReleaseError(f"VERSION ({v_file}) != pyproject.toml ({m.group(1)})") + return v_file + + def set_version(self, version: str) -> None: + if not SEMVER.match(version): + raise ReleaseError(f"invalid semver: {version!r}") + self._require_tag_missing(version) + pj = self.path / PYPROJECT_FILE + text = pj.read_text() + if not VERSION_LINE.search(text): + raise ReleaseError(f"could not find version field in {PYPROJECT_FILE}") + (self.path / VERSION_FILE).write_text(f"{version}\n") + pj.write_text(VERSION_LINE.sub(f'version = "{version}"', text, count=1)) + # version.args feeds the container build-args param, and + # `check docs-consistency` requires it to equal VERSION. Bumping + # without it left every release failing that gate between the bump and + # a manual follow-up edit. Conditional: repos without the file are + # unaffected. + va = self.path / VERSION_ARGS_FILE + if va.is_file(): + va.write_text(f"VERSION={version}\n") + + def tag_name(self, version: str | None = None) -> str: + return f"v{version or self.version()}" + + def status(self) -> None: + v = self.version() + tags = self.git.tags() + tagged = self.git.tag_exists(self.tag_name(v)) + latest = tags[0] if tags else "none" + print( + f"{self.name}: {v} ({'tagged' if tagged else 'not tagged'}, " + f"tree {'dirty' if self.git.dirty else 'clean'}, " + f"branch {self.git.branch()}@{self.git.head()}, latest tag {latest})" + ) + + def check(self) -> None: + self.git.require_clean() + self._require_tag_missing(self.version()) + + def test(self) -> None: + env = os.environ.copy() + subprocess.run(("uv", "sync"), cwd=self.path, check=True, env=env) + subprocess.run( + ("uv", "run", "pytest", "tests/", "-q", "-m", "not integration"), + cwd=self.path, + check=True, + env=env, + ) + + def build(self) -> None: + subprocess.run(("uv", "build"), cwd=self.path, check=True) + + def tag(self, message: str | None = None) -> str: + v = self.version() + tag = self.tag_name(v) + self._require_tag_missing(v) + self.git.create_tag(tag, message or f"{self.name} {v}") + return tag + + def push(self) -> None: + v = self.version() + tag = self.tag_name(v) + if not self.git.tag_exists(tag): + raise ReleaseError(f"tag {tag} does not exist locally") + self.git.push(self.git.branch(), tag) + + def release( + self, + *, + skip_test: bool = False, + message: str | None = None, + push: bool = False, + ) -> str: + self.check() + if not skip_test: + print(f"==> test {self.name}") + self.test() + print(f"==> build {self.name}") + self.build() + tag = self.tag(message) + print(f"{self.name}: release ready at {tag}") + if push: + self.push() + print(f"{self.name}: pushed {tag}") + return tag + + def latest_tag_version(self) -> str | None: + tags = self.git.tags() + if not tags: + return None + tag = tags[0] + return tag[1:] if tag.startswith("v") else tag + + def release_if_ready(self, *, message: str | None = None, push: bool = False) -> str | None: + current = self.version() + latest = self.latest_tag_version() + + if self.git.tag_exists(self.tag_name(current)): + print(f"{self.name}: {self.tag_name(current)} already exists") + return None + + if latest is not None and semver_tuple(current) == semver_tuple(latest): + print(f"{self.name}: no pending release (VERSION {current} matches latest tag)") + return None + + if latest is not None and semver_tuple(current) < semver_tuple(latest): + raise ReleaseError(f"VERSION {current} is behind latest tag v{latest}") + + return self.release(skip_test=True, message=message or f"{self.name} {current}", push=push) + + def _require_tag_missing(self, version: str) -> None: + tag = self.tag_name(version) + if self.git.tag_exists(tag) and not self.git.opts.force: + raise ReleaseError(f"tag {tag} already exists") + + +# --- CLI --- + + +def main(argv: list[str] | None = None) -> int: + import argparse + + flags = argparse.ArgumentParser(add_help=False) + flags.add_argument("-m", "--message") + flags.add_argument("--skip-test", action="store_true") + flags.add_argument("--allow-dirty", action="store_true") + flags.add_argument("--push", action="store_true") + flags.add_argument("--force", action="store_true") + + p = argparse.ArgumentParser(description="Release helper (repo-local)", parents=[flags]) + sub = p.add_subparsers(dest="cmd", required=True) + + sub.add_parser("status", parents=[flags]) + bump = sub.add_parser("bump", parents=[flags]) + bump.add_argument("part", help="major, minor, patch, or X.Y.Z") + sub.add_parser("check", parents=[flags]) + sub.add_parser("test", parents=[flags]) + sub.add_parser("build", parents=[flags]) + sub.add_parser("tag", parents=[flags]) + sub.add_parser("push", parents=[flags]) + sub.add_parser("release", parents=[flags]) + sub.add_parser("release-if-ready", parents=[flags]) + + args = p.parse_args(argv) + opts = GitOpts(force=args.force, allow_dirty=args.allow_dirty) + repo = Repo(Path.cwd(), opts) + + try: + match args.cmd: + case "status": + repo.status() + case "bump": + current = repo.version() + new = bump_version(current, args.part) + if new == current: + print(f"{repo.name}: already at {current}") + else: + repo.set_version(new) + print(f"{repo.name}: bumped {current} -> {new}") + case "check": + repo.check() + print(f"{repo.name}: ok ({repo.tag_name()})") + case "test": + repo.test() + case "build": + repo.build() + print(f"{repo.name}: wheel built in dist/") + case "tag": + repo.git.require_clean() + print(f"{repo.name}: created {repo.tag(args.message)}") + case "push": + repo.push() + print(f"{repo.name}: pushed {repo.tag_name()}") + case "release": + repo.release(skip_test=args.skip_test, message=args.message, push=args.push) + case "release-if-ready": + repo.release_if_ready(message=args.message, push=args.push) + except ReleaseError as exc: + print(f"error: {exc}", file=sys.stderr) + return 1 + except subprocess.CalledProcessError as exc: + print(f"error: command failed ({exc.cmd})", file=sys.stderr) + return exc.returncode or 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main())