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
84 changes: 84 additions & 0 deletions .github/workflows/regenerate-sdks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ on:
permissions:
contents: write
pull-requests: write
issues: write

jobs:
regenerate:
Expand Down Expand Up @@ -109,6 +110,39 @@ jobs:
diff /tmp/spec-pass-1.yaml dist/coval-openapi.yaml
echo "✓ Codegen output is deterministic across two consecutive runs."

- name: Bump versions if the generated output changed
run: |
set -euo pipefail
# git status, not git diff — a new spec model arrives as an untracked
# file and would not show up in a diff against HEAD.
changed() { [ -n "$(git status --porcelain -- "$1")" ]; }

# An array, and `if` rather than `cmd && assign`: under `set -e` a
# failing `&&` list is itself a failing statement and would abort the
# step the moment one of the two SDKs had no changes.
args=()
python_changed=false
if changed python-sdk/src/coval_sdk; then
args+=(--python)
python_changed=true
fi
if changed typescript-sdk/src/generated; then
args+=(--typescript)
fi

if [ ${#args[@]} -eq 0 ]; then
echo "No generated changes; leaving versions alone."
exit 0
fi

python3 scripts/bump-sdk-version.py "${args[@]}"

# The Python package version is baked into the generated tree by
# generate-sdks.sh, so codegen has to run again to pick up the bump.
if [ "${python_changed}" = true ]; then
bash scripts/generate-sdks.sh
fi

- name: Install TS SDK deps + build + test
working-directory: typescript-sdk
run: |
Expand Down Expand Up @@ -152,3 +186,53 @@ jobs:
typescript-sdk/src/generated/
python-sdk/src/coval_sdk/
python-sdk/GENERATED_README.md
python-sdk/pyproject.toml
python-sdk/tests/test_client.py
typescript-sdk/package.json
typescript-sdk/package-lock.json

# This job failed silently every week from 2026-07-13 to 2026-07-29. A
# scheduled workflow has no reviewer and nobody watches the Actions tab,
# so the SDKs drifted from the API for sixteen days with no signal. File
# an issue on failure, and reuse a single one so repeated weekly failures
# accumulate in one thread instead of spamming.
- name: File an issue if the regen failed
if: failure()
uses: actions/github-script@v7
with:
script: |
const title = 'Weekly SDK regeneration is failing';
const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
const body = [
`The scheduled \`Regenerate SDKs\` job failed.`,
``,
`Run: ${runUrl}`,
``,
`While this is failing no regen PR is opened, so the published SDKs`,
`silently drift from the OpenAPI specs. Worth fixing promptly rather`,
`than letting it accumulate.`,
].join('\n');

const existing = await github.rest.issues.listForRepo({
owner: context.repo.owner,
repo: context.repo.repo,
state: 'open',
labels: 'sdk-regen-failure',
});

if (existing.data.length > 0) {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: existing.data[0].number,
body,
});
} else {
await github.rest.issues.create({
owner: context.repo.owner,
repo: context.repo.repo,
title,
body,
labels: ['sdk-regen-failure'],
});
}
75 changes: 75 additions & 0 deletions .github/workflows/release-on-version-bump.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
name: Release on version bump

# Closes the gap between "a version bump landed on main" and "that version is
# on PyPI/npm". Before this, publishing was always a hand-pushed tag, so the
# weekly regen could regenerate the SDKs but never actually release them.
#
# Tags any version on main that has no matching tag yet. The existing
# publish-coval-sdk.yml / publish-coval-npm.yml workflows trigger on that tag
# push and do the actual publishing, so the tag stays the record of a release.
#
# Requires REGEN_PR_TOKEN (already configured for the regen job). A tag pushed
# with the default GITHUB_TOKEN does NOT trigger other workflows -- GitHub
# suppresses that to prevent recursion -- so the publish jobs would never fire.

on:
push:
branches:
- main
paths:
- python-sdk/pyproject.toml
- typescript-sdk/package.json
workflow_dispatch:

permissions:
contents: read

concurrency:
group: release-on-version-bump
cancel-in-progress: false

jobs:
tag:
name: Tag unreleased versions
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v7
with:
fetch-depth: 0
token: ${{ secrets.REGEN_PR_TOKEN }}

- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: "3.12"

- name: Tag any version that has not been released
run: |
set -euo pipefail
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"

python_version="$(python - <<'PY'
import pathlib
import tomllib
print(tomllib.loads(pathlib.Path("python-sdk/pyproject.toml").read_text())["project"]["version"])
PY
)"
typescript_version="$(node -p "require('./typescript-sdk/package.json').version")"

released=0
for tag in "python-sdk-v${python_version}" "typescript-sdk-v${typescript_version}"; do
if git rev-parse -q --verify "refs/tags/${tag}" >/dev/null; then
echo "${tag} is already tagged; nothing to release."
continue
fi
echo "Tagging ${tag}"
git tag -a "${tag}" -m "${tag}"
git push origin "${tag}"
released=$((released + 1))
done

if [ "${released}" -eq 0 ]; then
echo "No new versions to release."
fi
88 changes: 88 additions & 0 deletions scripts/bump-sdk-version.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
#!/usr/bin/env python3
"""Bump the patch version of the Python and/or TypeScript SDK.

Used by the weekly regen workflow so a regenerated SDK arrives already
releasable. Only touches the hand-maintained manifests and the one pinned
assertion in the Python tests -- the version strings inside the generated tree
are derived from pyproject.toml by generate-sdks.sh, so codegen must be re-run
after this script for them to match.
"""

from __future__ import annotations

import argparse
import json
import re
import sys
from pathlib import Path


REPO_ROOT = Path(__file__).resolve().parent.parent
PYPROJECT = REPO_ROOT / "python-sdk" / "pyproject.toml"
PY_TESTS = REPO_ROOT / "python-sdk" / "tests" / "test_client.py"
TS_PACKAGE = REPO_ROOT / "typescript-sdk" / "package.json"
TS_LOCKFILE = REPO_ROOT / "typescript-sdk" / "package-lock.json"

VERSION_RE = re.compile(r"^(?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)$")


def next_patch(version: str) -> str:
match = VERSION_RE.match(version)
if match is None:
raise SystemExit(f"Cannot bump non-semver version: {version!r}")
return f"{match['major']}.{match['minor']}.{int(match['patch']) + 1}"


def bump_python() -> str:
contents = PYPROJECT.read_text()
current = re.search(r'^version = "([^"]+)"', contents, re.MULTILINE)
if current is None:
raise SystemExit("No version field in python-sdk/pyproject.toml")
new = next_patch(current.group(1))

PYPROJECT.write_text(
contents.replace(f'version = "{current.group(1)}"', f'version = "{new}"', 1)
)

# The test pins __version__, so it has to move in lockstep or CI fails.
tests = PY_TESTS.read_text()
pinned = f'coval_sdk.__version__ == "{current.group(1)}"'
if pinned not in tests:
raise SystemExit(f"Expected {pinned!r} in {PY_TESTS.name}; version pin moved?")
PY_TESTS.write_text(tests.replace(pinned, f'coval_sdk.__version__ == "{new}"', 1))
return new


def bump_typescript() -> str:
package = json.loads(TS_PACKAGE.read_text())
new = next_patch(package["version"])
package["version"] = new
TS_PACKAGE.write_text(json.dumps(package, indent=2) + "\n")

if TS_LOCKFILE.exists():
lock = json.loads(TS_LOCKFILE.read_text())
lock["version"] = new
if "" in lock.get("packages", {}):
lock["packages"][""]["version"] = new
TS_LOCKFILE.write_text(json.dumps(lock, indent=2) + "\n")
return new


def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--python", action="store_true", help="bump the Python SDK")
parser.add_argument("--typescript", action="store_true", help="bump the TypeScript SDK")
args = parser.parse_args()

if not (args.python or args.typescript):
parser.error("nothing to do: pass --python and/or --typescript")

if args.python:
print(f"python-sdk -> {bump_python()}")
if args.typescript:
print(f"typescript-sdk -> {bump_typescript()}")
return 0


if __name__ == "__main__":
sys.exit(main())