diff --git a/.github/workflows/release-contract.yml b/.github/workflows/release-contract.yml new file mode 100644 index 0000000..297a474 --- /dev/null +++ b/.github/workflows/release-contract.yml @@ -0,0 +1,42 @@ +name: Release contract + +on: + pull_request: + paths: + - pyproject.toml + - src/storyforge/version.py + - src/storyforge/cli.py + - .github/workflows/release-contract.yml + push: + tags: ["v*"] + +permissions: + contents: read + +jobs: + version: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Install package + run: pip install . + - name: Verify installed CLI version + run: | + python - <<'PY' + import tomllib, subprocess + expected = tomllib.load(open("pyproject.toml", "rb"))["project"]["version"] + actual = subprocess.check_output(["storyforge", "--version"], text=True).strip() + assert actual == f"storyforge {expected}", (actual, expected) + PY + - name: Verify release tag matches package version + if: startsWith(github.ref, 'refs/tags/v') + run: | + python - <<'PY' + import os, tomllib + expected = "v" + tomllib.load(open("pyproject.toml", "rb"))["project"]["version"] + actual = os.environ["GITHUB_REF_NAME"] + assert actual == expected, f"tag {actual!r} != package {expected!r}" + PY diff --git a/docs/release-contract.md b/docs/release-contract.md new file mode 100644 index 0000000..fbabff1 --- /dev/null +++ b/docs/release-contract.md @@ -0,0 +1,15 @@ +# Release and version contract + +StoryForge has one package version: `project.version` in `pyproject.toml`. + +The installed CLI exposes exactly that version: + +``` +storyforge --version +``` + +A version release tag must be `v`. The release-contract workflow rejects a tag whose version differs from the Python package. + +The OCI workflow already derives version tags from Git refs. Combining the two contracts means a successful `vX.Y.Z` release has the same semantic version in the Python package, CLI, Git tag, and OCI tag. + +Development pushes to `main` may still publish the moving `main` and SHA OCI tags. Immutable version tags are created only from matching Git version tags. diff --git a/src/storyforge/cli.py b/src/storyforge/cli.py index 2ba2da8..ed2570e 100644 --- a/src/storyforge/cli.py +++ b/src/storyforge/cli.py @@ -22,6 +22,7 @@ from .provider_config import load_provider_config from .provider_runtime import resolve_role_providers from .http_transport import json_post_transport +from .version import storyforge_version TEMPLATE = { @@ -44,6 +45,7 @@ def main() -> int: parser = argparse.ArgumentParser(prog="storyforge") + parser.add_argument("--version", action="version", version=f"%(prog)s {storyforge_version()}") sub = parser.add_subparsers(dest="command", required=True) vm = sub.add_parser("validate-migrations") diff --git a/src/storyforge/version.py b/src/storyforge/version.py new file mode 100644 index 0000000..47f00c9 --- /dev/null +++ b/src/storyforge/version.py @@ -0,0 +1,8 @@ +from importlib.metadata import PackageNotFoundError, version + + +def storyforge_version() -> str: + try: + return version("storyforge") + except PackageNotFoundError: + return "0+unknown"