diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index aaa0471..cfa194a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -151,6 +151,24 @@ jobs: ref: ${{ needs.resolve-ref.outputs.sha }} persist-credentials: false + # Release TOOLING is pinned to the WORKFLOW's own ref (github.sha -- + # always a commit on main this run executes from), never to the tag + # being verified. A tag can predate any scripts/release/* helper this + # workflow relies on (that is exactly how the v2.1.0 backfill failed -- + # scripts/release/assert_version.py did not exist yet at that tag), so + # the helpers are checked out into their own path, separate from the + # tag's tree, and invoked from there. The package/code under test stays + # pinned to the tag's resolved (and ancestry-verified) sha above -- + # only the tooling that INSPECTS that tree floats to main. + - name: Check out release tooling from the workflow's own ref + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + ref: ${{ github.sha }} + sparse-checkout: scripts/release + sparse-checkout-cone-mode: false + persist-credentials: false + path: .release-tooling + - name: Derive version from the resolved tag id: version env: @@ -168,7 +186,7 @@ jobs: pip install build twine - name: Assert tag == pyproject.toml version == wave_sdk.__version__ - run: python3 scripts/release/assert_version.py "${{ needs.resolve-ref.outputs.tag }}" + run: python3 .release-tooling/scripts/release/assert_version.py "${{ needs.resolve-ref.outputs.tag }}" - name: pytest (full suite, the checked-out tag) run: python -m pytest -q @@ -203,6 +221,19 @@ jobs: ref: ${{ needs.resolve-ref.outputs.sha }} persist-credentials: false + # See the matching step in the `verify` job above: release tooling is + # pinned to the workflow's own ref (github.sha), never to the tag being + # published, so a backfilled tag from before scripts/release/* existed + # still finds the helper it needs. + - name: Check out release tooling from the workflow's own ref + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + ref: ${{ github.sha }} + sparse-checkout: scripts/release + sparse-checkout-cone-mode: false + persist-credentials: false + path: .release-tooling + - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version: "3.12" @@ -216,7 +247,7 @@ jobs: id: pypi_check run: | set -e - RESULT=$(python3 scripts/release/pypi_version_exists.py "${{ needs.verify.outputs.version }}") + RESULT=$(python3 .release-tooling/scripts/release/pypi_version_exists.py "${{ needs.verify.outputs.version }}") echo "exists=$RESULT" >> "$GITHUB_OUTPUT" - name: Publish to PyPI (Trusted Publishing, OIDC, PEP 740 attestations) diff --git a/scripts/release/assert_version.py b/scripts/release/assert_version.py index 4f2f1a7..4844237 100644 --- a/scripts/release/assert_version.py +++ b/scripts/release/assert_version.py @@ -8,27 +8,72 @@ and it is intentionally a plain script (not inlined YAML) so it can be run and unit-tested locally without pushing a tag first. -Usage: python3 scripts/release/assert_version.py v2.1.0 -Exit 0 if it matches, 1 with a clear message if it does not. +`--repo-root` (default: the current working directory, matching the pattern +already used by scripts/release/check_drift.py) is where pyproject.toml and +the `wave_sdk` package are read from -- deliberately NOT derived from this +script's own file location (`Path(__file__)`). `release.yml` checks out the +release TOOLING (this script) from the workflow's own ref into a separate +`.release-tooling/` path, while the CODE being asserted about is checked out +from the (possibly much older) tag being released -- the two trees are not +siblings once the tooling floats ahead of a backfilled tag, so this script +must never assume "my own directory" is anywhere near the code it inspects. + +Usage: + python3 scripts/release/assert_version.py v2.1.0 + python3 .release-tooling/scripts/release/assert_version.py v2.1.0 --repo-root . + python3 scripts/release/assert_version.py v2.1.0 --repo-root /path/to/other/checkout + +Exit 0 if it matches, 1 with a clear message if it does not, 2 on bad usage. """ from __future__ import annotations +import argparse import sys from pathlib import Path -import tomllib +try: # tomllib is stdlib from 3.11; `tomli` is a dev dependency below that + # (this repo's pyproject.toml pins `tomli>=2.0.0; python_version < '3.11'`, + # matching the same fallback already used in tests/test_packaging.py and + # scripts/release/check_drift.py -- requires-python here is >=3.9, and the + # `pytest (py3.9)` CI matrix leg runs this script as a subprocess). + import tomllib +except ModuleNotFoundError: # pragma: no cover - exercised on 3.9/3.10 only + import tomli as tomllib def main(argv: list[str]) -> int: - if len(argv) != 2: - print(f"usage: {argv[0]} ", file=sys.stderr) - return 2 - - tag = argv[1] + parser = argparse.ArgumentParser( + prog=Path(argv[0]).name if argv else "assert_version.py", + description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + parser.add_argument("tag", help="tag to check, e.g. v2.1.0") + parser.add_argument( + "--repo-root", + default=".", + help=( + "path to the sdk-python checkout whose pyproject.toml/wave_sdk this " + "validates (default: current working directory -- NOT this script's " + "own file location, which may live in a separate sparse checkout " + "pinned to a different ref than the code being verified)" + ), + ) + + try: + args = parser.parse_args(argv[1:]) + except SystemExit as exc: + # argparse already printed a usage message; normalize the exit code + # to this script's documented "bad usage" code (2) either way. + return exc.code if isinstance(exc.code, int) else 2 + + tag = args.tag tag_version = tag[1:] if tag.startswith("v") else tag - repo_root = Path(__file__).resolve().parents[2] + repo_root = Path(args.repo_root).resolve() pyproject_path = repo_root / "pyproject.toml" + if not pyproject_path.is_file(): + print(f"error: {pyproject_path} does not exist -- wrong --repo-root?", file=sys.stderr) + return 2 data = tomllib.loads(pyproject_path.read_text()) pyproject_version = data["project"]["version"] @@ -37,6 +82,7 @@ def main(argv: list[str]) -> int: dunder_version = wave_sdk.__version__ + print(f"repo root : {repo_root}") print(f"tag : {tag} (version {tag_version})") print(f"pyproject.toml : {pyproject_version}") print(f"wave_sdk.__version__ : {dunder_version}") diff --git a/tests/test_release_scripts.py b/tests/test_release_scripts.py new file mode 100644 index 0000000..a8fc3c4 --- /dev/null +++ b/tests/test_release_scripts.py @@ -0,0 +1,136 @@ +"""Unit tests for scripts/release/*.py -- run as subprocesses so a stale +`import wave_sdk` from this test process never leaks into (or masks a bug in) +the script's own import. + +The specific regression under test: `.github/workflows/release.yml` checks +out release TOOLING (this script) from the workflow's own ref into a +separate `.release-tooling/` directory, while the CODE it inspects is +checked out from a (possibly much older) release tag into the workspace +root. `scripts/release/assert_version.py` must resolve `pyproject.toml` and +import `wave_sdk` relative to `--repo-root` (default: cwd) -- NEVER relative +to its own file location (`Path(__file__)`) -- or it silently breaks the +moment it is invoked from anywhere other than the tree it is meant to +inspect. This is exactly how the v2.1.0 backfill failed: the tag's tree +predated scripts/release/ entirely, so the workflow's `verify` job could not +even find the script at the in-tree path, let alone run it against the +wrong tree. +""" +from __future__ import annotations + +import subprocess +import sys +import textwrap +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent +ASSERT_VERSION = REPO_ROOT / "scripts" / "release" / "assert_version.py" + + +def _write_fake_checkout(tmp_path: Path, version: str) -> Path: + """Build a minimal standalone checkout with its own pyproject.toml + wave_sdk.""" + checkout = tmp_path / "fake-checkout" + (checkout / "wave_sdk").mkdir(parents=True) + (checkout / "pyproject.toml").write_text( + textwrap.dedent( + f"""\ + [project] + name = "wave-sdk" + version = "{version}" + """ + ) + ) + (checkout / "wave_sdk" / "__init__.py").write_text(f'__version__ = "{version}"\n') + return checkout + + +def _run_assert_version(*args: str, cwd: Path) -> subprocess.CompletedProcess: + return subprocess.run( + [sys.executable, str(ASSERT_VERSION), *args], + cwd=cwd, + capture_output=True, + text=True, + timeout=30, + ) + + +def test_resolves_repo_root_from_cwd_not_from_its_own_file_location(tmp_path): + """The core regression guard: invoke the script from a directory that is + NOT anywhere near its own file location (mirrors `.release-tooling/scripts/ + release/assert_version.py` being run against an unrelated tag checkout), + relying only on cwd defaulting `--repo-root`. + """ + checkout = _write_fake_checkout(tmp_path, "9.9.9") + + result = _run_assert_version("v9.9.9", cwd=checkout) + + assert result.returncode == 0, result.stderr + assert "OK: tag, pyproject.toml, and wave_sdk.__version__ all agree" in result.stdout + assert str(checkout) in result.stdout # confirms it read the fake checkout, not the real repo + + +def test_explicit_repo_root_overrides_cwd(tmp_path): + """`--repo-root` must work even when invoked from a completely different cwd + (e.g. a workflow step whose default working-directory is the tag checkout, + but the tooling script lives under `.release-tooling/`).""" + checkout = _write_fake_checkout(tmp_path, "1.2.3") + + result = _run_assert_version("v1.2.3", "--repo-root", str(checkout), cwd=tmp_path) + + assert result.returncode == 0, result.stderr + assert "OK: tag, pyproject.toml, and wave_sdk.__version__ all agree" in result.stdout + + +def test_fails_loud_on_version_mismatch(tmp_path): + checkout = _write_fake_checkout(tmp_path, "1.0.0") + + result = _run_assert_version("v2.0.0", cwd=checkout) + + assert result.returncode == 1 + assert "VERSION MISMATCH" in result.stderr + assert "tag 2.0.0 != pyproject.toml 1.0.0" in result.stderr + + +def test_fails_with_usage_code_on_missing_tag_argument(tmp_path): + checkout = _write_fake_checkout(tmp_path, "1.0.0") + + result = _run_assert_version(cwd=checkout) + + assert result.returncode == 2 + + +def test_fails_clearly_on_wrong_repo_root(tmp_path): + """A --repo-root that doesn't contain pyproject.toml must fail loud (exit 2), + not crash with an unhandled traceback or silently read the wrong tree.""" + empty_dir = tmp_path / "empty" + empty_dir.mkdir() + + result = _run_assert_version("v1.0.0", "--repo-root", str(empty_dir), cwd=tmp_path) + + assert result.returncode == 2 + assert "does not exist" in result.stderr + + +def test_real_repo_checkout_passes_when_invoked_from_a_different_cwd(tmp_path): + """Regression check against the ACTUAL repo: running the script with the + real repo root passed via --repo-root, from an unrelated cwd, must still + resolve pyproject.toml/wave_sdk from --repo-root, not from cwd or from + the script's own directory. + """ + result = _run_assert_version( + f"v{_current_repo_version()}", + "--repo-root", + str(REPO_ROOT), + cwd=tmp_path, + ) + + assert result.returncode == 0, result.stderr + + +def _current_repo_version() -> str: + try: + import tomllib + except ModuleNotFoundError: # pragma: no cover - exercised on 3.9/3.10 only + import tomli as tomllib + + data = tomllib.loads((REPO_ROOT / "pyproject.toml").read_text()) + return data["project"]["version"]