diff --git a/.github/workflows/regenerate-sdks.yml b/.github/workflows/regenerate-sdks.yml index e224580..2718861 100644 --- a/.github/workflows/regenerate-sdks.yml +++ b/.github/workflows/regenerate-sdks.yml @@ -39,6 +39,7 @@ on: permissions: contents: write pull-requests: write + issues: write jobs: regenerate: @@ -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: | @@ -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'], + }); + } diff --git a/.github/workflows/release-on-version-bump.yml b/.github/workflows/release-on-version-bump.yml new file mode 100644 index 0000000..a671384 --- /dev/null +++ b/.github/workflows/release-on-version-bump.yml @@ -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 diff --git a/scripts/bump-sdk-version.py b/scripts/bump-sdk-version.py new file mode 100755 index 0000000..f9dce3c --- /dev/null +++ b/scripts/bump-sdk-version.py @@ -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\d+)\.(?P\d+)\.(?P\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())