Skip to content
Open
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
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ lint: ## Run template test lint checks
$(UV) --with interrogate interrogate --fail-under 100 tests/

typecheck: ## Run template test type checks
$(UV) --with pytest --with pytest-copier --with pyyaml --with syrupy --with make-parser ty check tests/
$(UV) --with pytest --with pytest-copier --with pyyaml --with syrupy --with make-parser ty@0.0.56 check tests/

help: ## Show available targets
@grep -E '^[a-zA-Z_-]+:.*?##' $(MAKEFILE_LIST) | \
Expand Down
42 changes: 42 additions & 0 deletions docs/adr-003-pin-ty-version.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# ADR-003: Pin the ty typechecker version

## Status

Accepted.

## Context

Both the parent repository and generated projects typecheck with `ty`. The
parent Makefile ran it through an unpinned `uvx` invocation, and the templated
dev dependency group declared `"ty"` without a version, so every install
resolved the latest release.

`ty` is pre-1.0 and its diagnostics change between releases. When ty 0.0.56
landed, estate repositories with unpinned installations broke without any code
change: the falcon-correlate and polythene main branches were red for days.
An unpinned typechecker makes gate outcomes depend on release timing rather
than on the code under review.

## Decision

Pin `ty` to an exact version everywhere it is installed:

- the parent Makefile's `typecheck` target runs `uvx … ty@0.0.56 check`;
- the templated dev dependency group declares `"ty==0.0.56"`, which pins the
generated `uv run ty` invocations;
- contract tests assert the pinned forms so drift fails the suite.

Version bumps are deliberate: update the pin, fix any new diagnostics it
surfaces, and land both in a single pull request. Update the parent Makefile,
the templated dev group, and the contract assertions together, and re-render
both template toggle states before merging.

## Consequences

- Gate results are reproducible: a new ty release cannot break `main` in the
parent repository or in generated projects.
- New ty diagnostics arrive only when a maintainer chooses to take them,
alongside the fixes they require.
- The pin must be maintained by hand; generated projects inherit whatever
version the template pinned at render time and manage their own bumps
thereafter.
9 changes: 8 additions & 1 deletion docs/developers-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,11 @@ template itself.
- `make lint` — runs Ruff lint checks and `interrogate --fail-under 100` against
the `tests/` directory in the parent template test suite.
- `make typecheck` — runs `ty check` against the parent template test suite,
supplying the test dependencies through `uvx`.
supplying the test dependencies through `uvx`. The `ty` version is pinned
(`ty@0.0.56`) because unpinned installations broke estate repositories when
ty 0.0.56 landed (falcon-correlate and polythene mains were red for days).
Version bumps are deliberate: update the pin, fix any new diagnostics, and
land both in one pull request (see ADR-003).
- `make test` — runs the template test suite via `uvx`, supplying
`pytest-copier`, `pyyaml`, `syrupy`, and `make-parser` without a manually
managed virtual environment.
Expand Down Expand Up @@ -45,6 +49,9 @@ Docker-dependent tests isolated from the standard template test gate:

`template/Makefile.jinja` defines the generated developer workflow. The default
`all` target runs build, formatting, linting, typechecking, and tests.
Generated projects pin `ty` in the dev dependency group (`ty==0.0.56`), so
`make typecheck` resolves the pinned typechecker; bumps follow the same
deliberate policy as the parent repository (ADR-003).
The generated `audit` target is an explicit security gate run by CI. It runs
`pip-audit` for every rendered project and, when `use_rust` is enabled, also
runs `cargo audit` in the Rust extension crate.
Expand Down
5 changes: 5 additions & 0 deletions template/docs/developers-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@ considered complete.
`make lint` runs Ruff, `interrogate --fail-under 100 $(PYTHON_TARGETS)` for
100% docstring coverage across `$(PYTHON_TARGETS)`, and Pylint.

`make typecheck` runs `ty`, pinned in the dev dependency group
(`ty==0.0.56`): unpinned installations broke repositories when ty 0.0.56
landed. Bump the pin deliberately — update the version and fix any new
diagnostics in the same pull request.

Run `make audit` as the dependency vulnerability gate. It runs `pip-audit` for
Python dependencies, and Rust-enabled projects also run `cargo audit` from the
`rust_extension` crate directory.
Expand Down
2 changes: 1 addition & 1 deletion template/pyproject.toml.jinja
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ dev = [
"pip-audit",
"ruff",
"pyright",
"ty",
"ty==0.0.56",
"pytest-timeout",
"pytest-xdist",
]
Expand Down
15 changes: 9 additions & 6 deletions tests/helpers/pyproject_contracts.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,19 +32,22 @@ def _assert_pyproject_contracts(
assert isinstance(dev_dependencies, list), (
"expected generated pyproject.toml to include a dev dependency group"
)
for dependency in [
expected_dev_dependencies = {
"pytest",
"interrogate",
"pip-audit",
"ruff",
"pyright",
"ty",
"ty==0.0.56",
"pytest-timeout",
"pytest-xdist",
]:
assert dependency in dev_dependencies, (
f"expected generated dev dependencies to include {dependency}"
)
}
assert set(dev_dependencies) == expected_dev_dependencies, (
"expected generated dev dependencies to match the pinned contract"
)
assert len(dev_dependencies) == len(expected_dev_dependencies), (
"expected generated dev dependencies to contain no duplicates"
)
pytest_options = require_mapping(
require_mapping(pyproject, "tool", "pyproject.toml"),
"pytest",
Expand Down
58 changes: 56 additions & 2 deletions tests/test_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,10 @@

from __future__ import annotations

import os
import shlex
import subprocess
import sys
from pathlib import Path
from typing import TYPE_CHECKING, Any, cast

Expand Down Expand Up @@ -464,8 +467,10 @@ def test_parent_makefile_test_target_uses_requisite_pytest_command() -> None:
)
assert (
"$(UV) --with pytest --with pytest-copier --with pyyaml --with syrupy "
"--with make-parser ty check tests/" in makefile
), "expected parent Makefile typecheck target to run ty with test dependencies"
"--with make-parser ty@0.0.56 check tests/" in makefile
), (
"expected parent Makefile typecheck target to run pinned ty with test dependencies"
)
assert "test: ## Run template tests" in makefile, (
"expected parent Makefile to expose a documented test target"
)
Expand All @@ -490,6 +495,55 @@ def test_parent_makefile_test_target_uses_requisite_pytest_command() -> None:
)


@pytest.mark.skipif(sys.platform == "win32", reason="requires a POSIX shell shim")
def test_parent_makefile_typecheck_invokes_pinned_ty(tmp_path: Path) -> None:
"""Validate the parent ``make typecheck`` boundary runs pinned ty.

Parameters
----------
tmp_path : Path
Temporary directory holding the fake ``uvx`` shim and its capture
file.

Returns
-------
None
The test passes when ``make typecheck`` resolves ``uvx`` from PATH
and invokes it with the pinned ``ty@0.0.56`` tool and the ``check``
subcommand.
"""
capture = tmp_path / "uvx-args.txt"
shim = tmp_path / "uvx"
shim.write_text(
f"#!/bin/sh\nprintf '%s\\n' \"$@\" > {shlex.quote(str(capture))}\n",
encoding="utf-8",
)
shim.chmod(0o755)
environment = os.environ.copy()
environment["PATH"] = f"{tmp_path}{os.pathsep}{environment['PATH']}"

result = subprocess.run(
["make", "typecheck"],
cwd=REPO_ROOT,
env=environment,
check=False,
capture_output=True,
text=True,
)

assert result.returncode == 0, (
"expected make typecheck to succeed with the fake uvx shim: "
f"{result.stdout}\n{result.stderr}"
)
invocation = capture.read_text(encoding="utf-8").splitlines()
assert "ty@0.0.56" in invocation, (
"expected make typecheck to invoke the pinned ty version through uvx"
)
assert "check" in invocation, (
"expected make typecheck to run the ty check subcommand"
)


def _ci_workflow(*, persist_credentials: str, coverage_inputs: str) -> str:
"""Return a minimal generated CI workflow for coverage-contract tests."""
return f"""\
Expand Down
Loading