From a8a949fb835012bbb35dd330e4c93f6c3f4a4034 Mon Sep 17 00:00:00 2001 From: Nicolas Gallagher <239676+necolas@users.noreply.github.com> Date: Fri, 24 Jul 2026 11:08:21 -0700 Subject: [PATCH] tool(release): Sync SDK versions from .version Use one canonical SDK version for npm, PyPI, and Go package metadata. Reject drift in test and publish workflows before a release can start. Consolidate release rules in the root agent guide. --- .../actions/check-package-versions/action.yml | 29 +++ .github/workflows/publish.yml | 27 ++- .github/workflows/test.yml | 5 + .version | 1 + AGENTS.md | 55 ++++- packages/code-storage-go/README.md | 11 - packages/code-storage-go/version.go | 5 +- packages/code-storage-python/DEVELOPMENT.md | 13 - packages/code-storage-python/PUBLISHING.md | 27 +-- packages/code-storage-typescript/AGENTS.md | 75 ------ packages/code-storage-typescript/CLAUDE.md | 75 ------ packages/code-storage-typescript/package.json | 2 +- scripts/sync_versions.py | 226 ++++++++++++++++++ scripts/tests/test_sync_versions.py | 177 ++++++++++++++ 14 files changed, 523 insertions(+), 205 deletions(-) create mode 100644 .github/actions/check-package-versions/action.yml create mode 100644 .version delete mode 100644 packages/code-storage-typescript/AGENTS.md delete mode 100644 packages/code-storage-typescript/CLAUDE.md create mode 100644 scripts/sync_versions.py create mode 100644 scripts/tests/test_sync_versions.py diff --git a/.github/actions/check-package-versions/action.yml b/.github/actions/check-package-versions/action.yml new file mode 100644 index 0000000..c832429 --- /dev/null +++ b/.github/actions/check-package-versions/action.yml @@ -0,0 +1,29 @@ +name: Check package versions +description: > + Test the version sync tool. Reject package version drift and a canonical + version that goes backward. Needs a prior checkout and python3 on PATH. + +runs: + using: composite + steps: + - name: Test version sync tool + shell: bash + run: python3 -m unittest discover -s scripts/tests -p 'test_*.py' -v + + - name: Check package versions + shell: bash + run: | + set -euo pipefail + + # A shallow checkout hides the previous commit. Get one more commit, so + # a caller does not need a specific fetch-depth for the check below. + if ! git rev-parse --verify --quiet HEAD^ >/dev/null; then + git fetch --deepen=1 --quiet || true + fi + + BASELINE=$(git show HEAD^:.version 2>/dev/null || true) + if [ -z "${BASELINE}" ]; then + echo "No previous .version; skipping the forward-only check." + fi + + python3 scripts/sync_versions.py --check --not-below "${BASELINE}" diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 8046c2f..b2dc508 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -15,8 +15,24 @@ concurrency: cancel-in-progress: false jobs: + check-versions: + name: Check package versions + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + # The version check compares .version against HEAD^. + fetch-depth: 2 + + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - uses: ./.github/actions/check-package-versions + publish-typescript: name: Publish TypeScript SDK to npm + needs: check-versions runs-on: ubuntu-latest permissions: contents: read @@ -67,6 +83,7 @@ jobs: publish-python: name: Publish Python SDK to PyPI + needs: check-versions runs-on: ubuntu-latest permissions: contents: read @@ -132,6 +149,7 @@ jobs: publish-go: name: Publish Go SDK tag + needs: check-versions runs-on: ubuntu-latest permissions: contents: write @@ -140,12 +158,17 @@ jobs: with: fetch-depth: 0 + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + - name: Determine release tag id: tag run: | - VERSION=$(sed -nE 's/^[[:space:]]*PackageVersion[[:space:]]*=[[:space:]]*"([^"]+)".*$/\1/p' packages/code-storage-go/version.go | head -n 1) + set -euo pipefail + VERSION=$(python3 scripts/sync_versions.py --print) if [ -z "${VERSION}" ]; then - echo "Failed to extract PackageVersion from packages/code-storage-go/version.go" >&2 + echo "Failed to read .version" >&2 exit 1 fi TAG="packages/code-storage-go/v${VERSION}" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9a4e1e4..41da28f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -11,6 +11,9 @@ jobs: runs-on: blacksmith-4vcpu-ubuntu-2404 steps: - uses: actions/checkout@v4 + with: + # The version check compares .version against HEAD^. + fetch-depth: 2 - uses: actions/setup-node@v4 with: @@ -28,6 +31,8 @@ jobs: with: python-version: '3.12' + - uses: ./.github/actions/check-package-versions + - run: pnpm install working-directory: packages/code-storage-typescript diff --git a/.version b/.version new file mode 100644 index 0000000..4a02d2c --- /dev/null +++ b/.version @@ -0,0 +1 @@ +1.16.2 diff --git a/AGENTS.md b/AGENTS.md index 8e1cd42..98a700f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,6 +24,18 @@ - `moon run code-storage-typescript:build` - `moon run git-storage-sdk-python:test` - `moon run git-storage-sdk-go:test` +- Package versions (repo root): + - Edit only `.version` when you set a package version. + - Use `MAJOR.MINOR.PATCH`. npm, PyPI, and Go do not accept the same + prerelease and build metadata. + - Set a version above the previous version. CI rejects a version that goes + backward. + - `python3 scripts/sync_versions.py` sets all package versions from `.version`. + - `python3 scripts/sync_versions.py --check` detects version differences. + - `python3 scripts/sync_versions.py --print` reads the version for a release + tag. Use it instead of a second parser for `.version`. + - CI runs the check with `.github/actions/check-package-versions`. Reuse that + action in a new workflow. ## Coding Style & Naming Conventions - TypeScript: follow existing `src/` style; use `camelCase` variables and @@ -38,6 +50,47 @@ - Python: pytest; tests in `tests/test_*.py` with coverage options in `pyproject.toml`. - Go: `go test` across the module. +- Version tool: `python3 -m unittest discover -s scripts/tests` runs the tests in + `scripts/tests/`. + +## TypeScript SDK Rules + +The `@pierre/storage` package is a public production SDK. Preserve semver +compatibility and coordinate each breaking change. + +- The package supports ESM and CommonJS consumers. +- The package supports Node and edge runtimes that provide `fetch`. +- The package creates authenticated Git URLs and wraps repository REST APIs. +- Consumers depend on the generated `dist` output. +- `packages/code-storage-typescript/src/index.ts` is the public entry point. +- `packages/code-storage-typescript/src/types.ts` defines the shared API types. +- `packages/code-storage-typescript/tests/index.test.ts` covers the public API. +- `packages/code-storage-typescript/tests/full-workflow.js` is the live smoke + test. +- `packages/code-storage-typescript/tsup.config.ts` defines the API and storage + base URLs. +- Keep the TypeScript types and README in sync with each request or response + change. +- Avoid Node built-in imports that break browser use. +- Keep the `resolveCommitTtlSeconds` default at one hour. +- Use `DEFAULT_TOKEN_TTL_SECONDS` for that default. +- Keep `Repo.restoreCommit` on `repos/restore-commit`. Do not add an automatic + fallback to the legacy endpoints. +- Preserve the `RefUpdateError` status, reason, message, and ref fields. +- Keep `Repo.createCommitFromDiff` on `repos/diff-commit`. +- Keep its return type as `Promise`. +- Reuse the commit-pack error helpers in `Repo.createCommitFromDiff`. +- Keep ES256 private-key authentication compatible with the README example. +- Keep raw API payload names as `*Response`. +- Keep camelCase consumer results as `*Result`. +- Extend `normalizeDiffState` when the API adds a Git status. +- Preserve `state` and `rawState` in normalized diff results. +- Keep webhook push events typed and preserve `WebhookUnknownEvent`. +- Convert commit timestamps to `Date` and preserve `rawDate`. +- Route new commit sources through `toAsyncIterable` and `ensureUint8Array`. +- Keep `CommitFileOptions.mode` restricted to `GitFileMode`. +- Keep UTF-8 as the default text encoding. +- Keep the Node `Buffer` fallback for non-UTF text encodings. ## Commit & Pull Request Guidelines - Git history shows short, informal subjects and no strict convention. Prefer @@ -49,8 +102,6 @@ ## Security & Configuration Notes - Never commit private keys or API tokens. Use local files or environment variables for SDK credentials. -- Keep package-level docs (for example, - `packages/code-storage-typescript/AGENTS.md`) in sync with code changes. - Audit `skills/code-storage/SKILL.md` against the SDK packages whenever public API surface changes (endpoints, request/response shapes, JWT claims, scopes, policy ops, base-repo providers, exported constants/helpers). The skill diff --git a/packages/code-storage-go/README.md b/packages/code-storage-go/README.md index 13b4bd4..c048afd 100644 --- a/packages/code-storage-go/README.md +++ b/packages/code-storage-go/README.md @@ -345,17 +345,6 @@ if err != nil { fmt.Println(repo.ID) ``` -## Releasing a new version - -Because this Go module lives in a monorepo, git tags must be prefixed with the module's subdirectory path: - -```bash -git tag packages/code-storage-go/v0.8.0 -git push origin packages/code-storage-go/v0.8.0 -``` - -Make sure the version in `version.go` (`PackageVersion`) matches the tag before tagging. - ## Features - Create, list, find, and delete repositories. diff --git a/packages/code-storage-go/version.go b/packages/code-storage-go/version.go index ddd759e..3727b94 100644 --- a/packages/code-storage-go/version.go +++ b/packages/code-storage-go/version.go @@ -1,8 +1,9 @@ package storage const ( - PackageName = "code-storage-go-sdk" - PackageVersion = "1.16.1" + PackageName = "code-storage-go-sdk" + // PackageVersion is set from .version by scripts/sync_versions.py. + PackageVersion = "1.16.2" ) func userAgent() string { diff --git a/packages/code-storage-python/DEVELOPMENT.md b/packages/code-storage-python/DEVELOPMENT.md index 4ef1fa7..354fec2 100644 --- a/packages/code-storage-python/DEVELOPMENT.md +++ b/packages/code-storage-python/DEVELOPMENT.md @@ -285,19 +285,6 @@ uv lock --upgrade-package httpx uv run pip-audit ``` -### Version bumping - -Update version in `pyproject.toml`: - -```toml -[project] -version = "0.2.0" -``` - -### Changelog - -Document changes in CHANGELOG.md following Keep a Changelog format. - ## Resources - [Pierre API Documentation](https://docs.pierre.io/api) diff --git a/packages/code-storage-python/PUBLISHING.md b/packages/code-storage-python/PUBLISHING.md index 8db754d..813dc36 100644 --- a/packages/code-storage-python/PUBLISHING.md +++ b/packages/code-storage-python/PUBLISHING.md @@ -254,24 +254,9 @@ twine upload --repository testpypi dist/* twine upload dist/* ``` -## Publishing Updates +## Publish an Update -When you release a new version: - -### 1. Update Version Number - -Edit `pyproject.toml`: - -```toml -[project] -version = "0.1.3" # Increment version -``` - -### 2. Update CHANGELOG (if you have one) - -Document what changed. - -### 3. Build and Upload +### Build and Upload ```bash # Clean old builds @@ -290,7 +275,7 @@ twine check dist/* twine upload --repository testpypi dist/* # Test installation -pip install --index-url https://test.pypi.org/simple/ --extra-index-url https://pypi.org/simple/ pierre-storage==0.1.3 +pip install --index-url https://test.pypi.org/simple/ --extra-index-url https://pypi.org/simple/ pierre-storage # If good, upload to PyPI twine upload dist/* @@ -298,12 +283,6 @@ twine upload dist/* ## Troubleshooting -### Error: "File already exists" - -You can't re-upload the same version. You must increment the version number. - -**Solution**: Update version in `pyproject.toml`, rebuild, and upload. - ### Error: "Invalid username or password" Common mistakes: diff --git a/packages/code-storage-typescript/AGENTS.md b/packages/code-storage-typescript/AGENTS.md deleted file mode 100644 index 20bf273..0000000 --- a/packages/code-storage-typescript/AGENTS.md +++ /dev/null @@ -1,75 +0,0 @@ -# Git Storage SDK – Agent Notes - -This package (`@pierre/storage`) is published publicly and ships to external -customers. Treat the repository as production-critical: follow semver -expectations, avoid breaking changes without coordination, and keep -documentation in sync with code. - -## Package Purpose - -- TypeScript/ESM + CommonJS SDK for Pierre’s Git storage APIs. -- Generates authenticated Git remote URLs and wraps REST endpoints for repo - management, diff retrieval, commit packs, and branch restore operations. -- Distributed via npm; consumers rely on the generated `dist` output. - -## Build & Test - -- Build: `pnpm --filter @pierre/storage build` (tsup with `tsconfig.tsup.json`). -- Tests: `pnpm --filter @pierre/storage exec vitest --run`. -- Full end-to-end smoke test hitting Pierre environments: - `node packages/git-storage-sdk/tests/full-workflow.js -e production -s pierre -k /home/ian/pierre-prod-key.pem` - (change the `-e`, `-s`, and key path for your setup). The script provisions a - repo, commits changes, and exercises diff/list APIs via the SDK. - -## Key Files - -- `src/index.ts`: Public entry point (Repo/GitStorage classes). -- `src/types.ts`: Shared type definitions; keep in sync with gateway JSON. -- `tests/index.test.ts`: Unit coverage for API surface (uses mocked fetch). -- `tests/full-workflow.js`: Live workflow script exercising real services. -- `tsup.config.ts`: Declares build-time constants (API/STORAGE base URLs). - -## Development Notes - -- `resolveCommitTtlSeconds` default TTL = 1 hour unless overridden. -- Use `DEFAULT_TOKEN_TTL_SECONDS` for 1-hour defaults (avoid hard-coded - `1 * 60 * 60`). -- `Repo.restoreCommit` streams metadata to `repos/restore-commit`. Legacy - `restore-commits` and `reset-commits` endpoints remain deployed but the SDK no - longer auto-falls back; callers must hit those routes explicitly if needed. -- Commit builder (`createCommit().send()`) and `Repo.restoreCommit` throw - `RefUpdateError` when the backend rejects a ref update; keep - status/reason/message/ref mapping intact. -- `Repo.createCommitFromDiff` streams pre-generated patches to - `repos/diff-commit` (accepts the diff payload directly and returns a - `Promise`). It shares the same `RefUpdateError` semantics—reuse - the commit-pack helpers when adjusting error handling. -- Authentication relies on ES256 private key; see README for sample key. -- When adjusting request/response shapes, reflect changes in both TypeScript - types and README. -- Avoid importing Node built-ins that break browser usage; the SDK is intended - for Node + edge runtimes with fetch available. -- Maintain the Result vs Response distinction: raw API payloads remain - `*Response` while SDK consumers receive camelCase `*Result` objects. Update - both transformer utilities and docs together. -- Diff responses normalize the Git status via `normalizeDiffState`, exposing - both `state` and `rawState`; extend that mapping instead of passing raw enums - through directly. -- Webhook validation returns typed push events (parsed `Date`, camelCase fields) - or a `WebhookUnknownEvent` fallback—keep this discriminated union intact when - adding new events. -- Commit and commit-list APIs convert timestamps to `Date` while preserving - `rawDate`; apply the same pattern to future time fields. -- Commit builder accepts `Blob`, `File`, `ReadableStream`, and iterable sources; - new sources should be funneled through `toAsyncIterable`/`ensureUint8Array`. -- `CommitFileOptions.mode` is restricted to `GitFileMode` literals; ensure - additional modes are codified there. -- `CommitTextFileOptions.encoding` supports Node `Buffer` encodings and defaults - to UTF-8; retain the Buffer-based fallback for non-UTF encodings. - -## Release Checklist - -- Ensure `pnpm test` and `build` succeed. -- Update version in `package.json` when shipping changes. -- Verify README snippets remain accurate. -- Communicate breaking changes to customer-facing channels. diff --git a/packages/code-storage-typescript/CLAUDE.md b/packages/code-storage-typescript/CLAUDE.md deleted file mode 100644 index 20bf273..0000000 --- a/packages/code-storage-typescript/CLAUDE.md +++ /dev/null @@ -1,75 +0,0 @@ -# Git Storage SDK – Agent Notes - -This package (`@pierre/storage`) is published publicly and ships to external -customers. Treat the repository as production-critical: follow semver -expectations, avoid breaking changes without coordination, and keep -documentation in sync with code. - -## Package Purpose - -- TypeScript/ESM + CommonJS SDK for Pierre’s Git storage APIs. -- Generates authenticated Git remote URLs and wraps REST endpoints for repo - management, diff retrieval, commit packs, and branch restore operations. -- Distributed via npm; consumers rely on the generated `dist` output. - -## Build & Test - -- Build: `pnpm --filter @pierre/storage build` (tsup with `tsconfig.tsup.json`). -- Tests: `pnpm --filter @pierre/storage exec vitest --run`. -- Full end-to-end smoke test hitting Pierre environments: - `node packages/git-storage-sdk/tests/full-workflow.js -e production -s pierre -k /home/ian/pierre-prod-key.pem` - (change the `-e`, `-s`, and key path for your setup). The script provisions a - repo, commits changes, and exercises diff/list APIs via the SDK. - -## Key Files - -- `src/index.ts`: Public entry point (Repo/GitStorage classes). -- `src/types.ts`: Shared type definitions; keep in sync with gateway JSON. -- `tests/index.test.ts`: Unit coverage for API surface (uses mocked fetch). -- `tests/full-workflow.js`: Live workflow script exercising real services. -- `tsup.config.ts`: Declares build-time constants (API/STORAGE base URLs). - -## Development Notes - -- `resolveCommitTtlSeconds` default TTL = 1 hour unless overridden. -- Use `DEFAULT_TOKEN_TTL_SECONDS` for 1-hour defaults (avoid hard-coded - `1 * 60 * 60`). -- `Repo.restoreCommit` streams metadata to `repos/restore-commit`. Legacy - `restore-commits` and `reset-commits` endpoints remain deployed but the SDK no - longer auto-falls back; callers must hit those routes explicitly if needed. -- Commit builder (`createCommit().send()`) and `Repo.restoreCommit` throw - `RefUpdateError` when the backend rejects a ref update; keep - status/reason/message/ref mapping intact. -- `Repo.createCommitFromDiff` streams pre-generated patches to - `repos/diff-commit` (accepts the diff payload directly and returns a - `Promise`). It shares the same `RefUpdateError` semantics—reuse - the commit-pack helpers when adjusting error handling. -- Authentication relies on ES256 private key; see README for sample key. -- When adjusting request/response shapes, reflect changes in both TypeScript - types and README. -- Avoid importing Node built-ins that break browser usage; the SDK is intended - for Node + edge runtimes with fetch available. -- Maintain the Result vs Response distinction: raw API payloads remain - `*Response` while SDK consumers receive camelCase `*Result` objects. Update - both transformer utilities and docs together. -- Diff responses normalize the Git status via `normalizeDiffState`, exposing - both `state` and `rawState`; extend that mapping instead of passing raw enums - through directly. -- Webhook validation returns typed push events (parsed `Date`, camelCase fields) - or a `WebhookUnknownEvent` fallback—keep this discriminated union intact when - adding new events. -- Commit and commit-list APIs convert timestamps to `Date` while preserving - `rawDate`; apply the same pattern to future time fields. -- Commit builder accepts `Blob`, `File`, `ReadableStream`, and iterable sources; - new sources should be funneled through `toAsyncIterable`/`ensureUint8Array`. -- `CommitFileOptions.mode` is restricted to `GitFileMode` literals; ensure - additional modes are codified there. -- `CommitTextFileOptions.encoding` supports Node `Buffer` encodings and defaults - to UTF-8; retain the Buffer-based fallback for non-UTF encodings. - -## Release Checklist - -- Ensure `pnpm test` and `build` succeed. -- Update version in `package.json` when shipping changes. -- Verify README snippets remain accurate. -- Communicate breaking changes to customer-facing channels. diff --git a/packages/code-storage-typescript/package.json b/packages/code-storage-typescript/package.json index 538da70..d488834 100644 --- a/packages/code-storage-typescript/package.json +++ b/packages/code-storage-typescript/package.json @@ -1,6 +1,6 @@ { "name": "@pierre/storage", - "version": "1.15.1", + "version": "1.16.2", "description": "Pierre Git Storage SDK", "repository": { "type": "git", diff --git a/scripts/sync_versions.py b/scripts/sync_versions.py new file mode 100644 index 0000000..f72402a --- /dev/null +++ b/scripts/sync_versions.py @@ -0,0 +1,226 @@ +"""Set each package version from the repository .version file.""" + +from __future__ import annotations + +import argparse +import re +import sys +from dataclasses import dataclass +from pathlib import Path +from re import Pattern + +# npm, PyPI, and Go do not accept the same prerelease and build metadata, so the +# canonical version stays MAJOR.MINOR.PATCH. +VERSION_PATTERN = re.compile(r"(\d+)\.(\d+)\.(\d+)") + + +@dataclass(frozen=True) +class VersionTarget: + """Describe one generated package version field.""" + + path: Path + pattern: Pattern[str] + + +@dataclass(frozen=True) +class TargetState: + """Store a target file and its current version.""" + + target: VersionTarget + content: str + version: str + + +TARGETS = ( + VersionTarget( + Path("packages/code-storage-typescript/package.json"), + re.compile( + r'(?P^\s*"version"\s*:\s*")' + r'(?P[^"]+)' + r'(?P"\s*,?\s*$)', + re.MULTILINE, + ), + ), + VersionTarget( + Path("packages/code-storage-python/pyproject.toml"), + re.compile( + r'(?P^\[project\][\s\S]*?^version\s*=\s*")' + r'(?P[^"]+)' + r'(?P")', + re.MULTILINE, + ), + ), + VersionTarget( + Path("packages/code-storage-python/pierre_storage/version.py"), + re.compile( + r'(?P^PACKAGE_VERSION\s*=\s*")' + r'(?P[^"]+)' + r'(?P"\s*$)', + re.MULTILINE, + ), + ), + VersionTarget( + Path("packages/code-storage-python/uv.lock"), + re.compile( + r"(?P^\[\[package\]\]\s*\n" + r'(?:(?!^\[\[package\]\]).)*?^name\s*=\s*"pierre-storage"\s*\n' + r'(?:(?!^\[\[package\]\]).)*?^version\s*=\s*")' + r'(?P[^"]+)' + r'(?P")', + re.MULTILINE | re.DOTALL, + ), + ), + VersionTarget( + Path("packages/code-storage-go/version.go"), + re.compile( + r'(?P^\s*PackageVersion\s*=\s*")' + r'(?P[^"]+)' + r'(?P"\s*$)', + re.MULTILINE, + ), + ), +) + + +class VersionSyncError(Exception): + """Report an invalid canonical file or package file.""" + + +def parse_version(value: str, source: str) -> tuple[int, ...]: + """Validate one version and return its comparable parts.""" + version = value.strip() + match = VERSION_PATTERN.fullmatch(version) + if match is None: + raise VersionSyncError( + f"Invalid version in {source}: {version!r}. Use MAJOR.MINOR.PATCH. " + "npm, PyPI, and Go do not accept the same prerelease and build " + "metadata." + ) + return tuple(int(part) for part in match.groups()) + + +def read_canonical_version(root: Path) -> str: + """Read and validate the canonical package version.""" + path = root / ".version" + try: + content = path.read_text(encoding="utf-8") + except FileNotFoundError as error: + raise VersionSyncError("Missing canonical version file: .version") from error + + version = content.strip() + parse_version(version, ".version") + return version + + +def check_not_below(version: str, baseline: str) -> None: + """Reject a canonical version below the previous version.""" + if parse_version(version, ".version") < parse_version(baseline, "--not-below"): + raise VersionSyncError( + f"Version goes backward: .version is {version}, " + f"below {baseline.strip()}. Set .version to a higher version." + ) + + +def read_target_states(root: Path) -> list[TargetState]: + """Read each generated package version field.""" + states = [] + for target in TARGETS: + path = root / target.path + try: + content = path.read_text(encoding="utf-8") + except FileNotFoundError as error: + raise VersionSyncError(f"Missing version target: {target.path}") from error + + matches = list(target.pattern.finditer(content)) + if len(matches) != 1: + raise VersionSyncError( + f"Expected one version field in {target.path}, found {len(matches)}" + ) + states.append(TargetState(target, content, matches[0].group("version"))) + return states + + +def replace_version(state: TargetState, version: str) -> str: + """Replace one generated package version field.""" + + def replacement(match: re.Match[str]) -> str: + return f"{match.group('prefix')}{version}{match.group('suffix')}" + + return state.target.pattern.sub(replacement, state.content, count=1) + + +def parse_arguments() -> argparse.Namespace: + """Parse command-line arguments.""" + parser = argparse.ArgumentParser( + description="Set each package version from the repository .version file." + ) + parser.add_argument( + "--check", + action="store_true", + help="Report version differences without file changes.", + ) + parser.add_argument( + "--print", + action="store_true", + dest="print_version", + help="Print the canonical version from .version.", + ) + parser.add_argument( + "--not-below", + metavar="VERSION", + default="", + help="Reject a canonical version below VERSION. An empty value skips it.", + ) + parser.add_argument( + "--root", + type=Path, + default=Path(__file__).resolve().parents[1], + help=argparse.SUPPRESS, + ) + return parser.parse_args() + + +def main() -> int: + """Check or update package versions.""" + arguments = parse_arguments() + root = arguments.root.resolve() + + try: + version = read_canonical_version(root) + if arguments.not_below.strip(): + check_not_below(version, arguments.not_below) + if arguments.print_version: + print(version) + return 0 + states = read_target_states(root) + except VersionSyncError as error: + print(error, file=sys.stderr) + return 2 + + mismatches = [state for state in states if state.version != version] + if arguments.check: + if not mismatches: + print(f"All package versions match .version ({version}).") + return 0 + + print(f"Package versions do not match .version ({version}):", file=sys.stderr) + for state in mismatches: + print( + f"- {state.target.path}: {state.version}", + file=sys.stderr, + ) + print("Run python3 scripts/sync_versions.py.", file=sys.stderr) + return 1 + + for state in mismatches: + path = root / state.target.path + path.write_text(replace_version(state, version), encoding="utf-8") + print(f"Updated {state.target.path}: {state.version} -> {version}") + + if not mismatches: + print(f"All package versions match .version ({version}).") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/tests/test_sync_versions.py b/scripts/tests/test_sync_versions.py new file mode 100644 index 0000000..651c0e5 --- /dev/null +++ b/scripts/tests/test_sync_versions.py @@ -0,0 +1,177 @@ +"""Tests for the repository version sync tool.""" + +from __future__ import annotations + +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +SCRIPT = Path(__file__).parents[1] / "sync_versions.py" +TARGETS = ( + "packages/code-storage-typescript/package.json", + "packages/code-storage-python/pyproject.toml", + "packages/code-storage-python/pierre_storage/version.py", + "packages/code-storage-python/uv.lock", + "packages/code-storage-go/version.go", +) + + +class SyncVersionsTest(unittest.TestCase): + """Test version checks and updates.""" + + def setUp(self) -> None: + """Create a repository fixture.""" + self.temporary_directory = tempfile.TemporaryDirectory() + self.root = Path(self.temporary_directory.name) + self.write(".version", "2.3.4\n") + self.write( + "packages/code-storage-typescript/package.json", + '{\n "name": "@pierre/storage",\n "version": "1.0.0",\n' + ' "dependencyVersion": "9.9.9"\n}\n', + ) + self.write( + "packages/code-storage-python/pyproject.toml", + '[build-system]\nrequires = ["setuptools>=61.0"]\n\n' + '[project]\nname = "pierre-storage"\nversion = "1.0.0"\n' + 'requires-python = ">=3.9"\n', + ) + self.write( + "packages/code-storage-python/pierre_storage/version.py", + 'PACKAGE_NAME = "code-storage-py-sdk"\nPACKAGE_VERSION = "1.0.0"\n', + ) + self.write( + "packages/code-storage-python/uv.lock", + "version = 1\nrevision = 3\n\n" + '[[package]]\nname = "httpx"\nversion = "0.28.1"\n' + 'source = { registry = "https://pypi.org/simple" }\n\n' + '[[package]]\nname = "pierre-storage"\nversion = "1.0.0"\n' + 'source = { virtual = "." }\n\n' + '[[package]]\nname = "pytest"\nversion = "8.4.2"\n' + 'source = { registry = "https://pypi.org/simple" }\n', + ) + self.write( + "packages/code-storage-go/version.go", + 'package storage\n\nconst (\n\tPackageName = "code-storage-go-sdk"\n' + '\tPackageVersion = "1.0.0"\n)\n', + ) + + def tearDown(self) -> None: + """Remove the repository fixture.""" + self.temporary_directory.cleanup() + + def write(self, relative_path: str, content: str) -> None: + """Write a fixture file.""" + path = self.root / relative_path + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + + def run_tool(self, *arguments: str) -> subprocess.CompletedProcess[str]: + """Run the version sync tool.""" + return subprocess.run( + [sys.executable, str(SCRIPT), "--root", str(self.root), *arguments], + check=False, + capture_output=True, + text=True, + ) + + def test_check_reports_each_out_of_sync_file(self) -> None: + """Report each version field that differs from .version.""" + result = self.run_tool("--check") + + self.assertEqual(result.returncode, 1) + for target in TARGETS: + self.assertIn(target, result.stderr) + + def test_sync_updates_each_version_field(self) -> None: + """Set each package version from .version.""" + result = self.run_tool() + + self.assertEqual(result.returncode, 0, result.stderr) + for target in TARGETS: + content = (self.root / target).read_text(encoding="utf-8") + self.assertIn("2.3.4", content) + self.assertNotIn("1.0.0", content) + self.assertIn( + '"dependencyVersion": "9.9.9"', (self.root / TARGETS[0]).read_text() + ) + self.assertIn('requires-python = ">=3.9"', (self.root / TARGETS[1]).read_text()) + self.assertEqual(self.run_tool("--check").returncode, 0) + + def test_sync_keeps_each_other_lock_version(self) -> None: + """Change only the pierre-storage version in the lock file.""" + result = self.run_tool() + + self.assertEqual(result.returncode, 0, result.stderr) + lock = (self.root / "packages/code-storage-python/uv.lock").read_text() + self.assertIn('name = "pierre-storage"\nversion = "2.3.4"', lock) + self.assertIn('name = "httpx"\nversion = "0.28.1"', lock) + self.assertIn('name = "pytest"\nversion = "8.4.2"', lock) + + def test_invalid_canonical_version_fails(self) -> None: + """Reject a canonical version that is not a release version.""" + self.write(".version", "release-latest\n") + + result = self.run_tool("--check") + + self.assertEqual(result.returncode, 2) + self.assertIn("Invalid version in .version", result.stderr) + + def test_prerelease_and_build_metadata_versions_fail(self) -> None: + """Reject a version that npm, PyPI, and Go do not accept together.""" + for version in ("2.3.4-rc.1", "2.3.4+build.5", "v2.3.4", "2.3.4.5"): + with self.subTest(version=version): + self.write(".version", f"{version}\n") + + result = self.run_tool("--check") + + self.assertEqual(result.returncode, 2) + self.assertIn("Use MAJOR.MINOR.PATCH", result.stderr) + + def test_version_below_the_previous_version_fails(self) -> None: + """Reject a canonical version that goes backward.""" + result = self.run_tool("--check", "--not-below", "2.3.5") + + self.assertEqual(result.returncode, 2) + self.assertIn("Version goes backward", result.stderr) + + def test_version_at_or_above_the_previous_version_passes(self) -> None: + """Accept an unchanged or higher canonical version.""" + for baseline in ("2.3.4", "2.3.3", "1.9.9", ""): + with self.subTest(baseline=baseline): + result = self.run_tool("--check", "--not-below", baseline) + + # Exit code 1 reports the drift in the fixture. The forward-only + # check rejects a version with exit code 2. + self.assertEqual(result.returncode, 1, result.stderr) + self.assertNotIn("Version goes backward", result.stderr) + + def test_invalid_previous_version_fails(self) -> None: + """Reject a previous version that the tool cannot compare.""" + result = self.run_tool("--check", "--not-below", "latest") + + self.assertEqual(result.returncode, 2) + self.assertIn("Invalid version in --not-below", result.stderr) + + def test_print_writes_only_the_canonical_version(self) -> None: + """Print the canonical version for the release tag.""" + self.write(".version", " 2.3.4 \n") + + result = self.run_tool("--print") + + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(result.stdout, "2.3.4\n") + + def test_print_rejects_an_invalid_canonical_version(self) -> None: + """Print nothing when .version is not a release version.""" + self.write(".version", "2.3.4+build.5\n") + + result = self.run_tool("--print") + + self.assertEqual(result.returncode, 2) + self.assertEqual(result.stdout, "") + + +if __name__ == "__main__": + unittest.main()