Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
111 commits
Select commit Hold shift + click to select a range
f638458
chore: maximum ruff and ty strictness with a module-size gate
barisgit Oct 12, 2026
91d65ed
fix: decide Rich color mode when output is written, not at import
barisgit Oct 12, 2026
0ce114d
style: apply safe ruff fixes and formatting repo-wide
barisgit Oct 12, 2026
137e0b9
test: make the test suite a package
barisgit Oct 12, 2026
0b47176
perf: load heavy third-party modules on first use on Python 3.15
barisgit Oct 12, 2026
a80a069
ci: test on Python 3.15
barisgit Oct 12, 2026
96e8cc1
refactor(release): pass ruff ALL and strict ty in github_release
barisgit Oct 12, 2026
58d0dea
fix: keep every backup taken within one second
barisgit Oct 12, 2026
b98f130
fix(template): print list --json without line wrapping
barisgit Oct 12, 2026
5e4e4f0
fix(update): detect uv only when kilm runs from a uv tool environment
barisgit Oct 12, 2026
769e929
fix(update): detect conda only when kilm runs from a conda environment
barisgit Oct 12, 2026
3f5fafc
fix(template): honor --library in template create and list
barisgit Oct 12, 2026
4ff58bb
fix(template): render %{...} variable defaults before prompting
barisgit Oct 12, 2026
8e59a47
fix: replace kicad_common.json and library tables atomically
barisgit Oct 12, 2026
439c33a
fix(template): keep the template description in interactive make
barisgit Oct 12, 2026
1da0755
fix(update): report Nix and unrecognized installs instead of assuming…
barisgit Oct 12, 2026
7614f40
fix(template): template the main KiCad files in template make
barisgit Oct 12, 2026
db71ec3
fix(template): stop HTML-escaping variables in rendered files
barisgit Oct 12, 2026
411a88f
fix(setup): repair wrapped library URIs and write the tables atomically
barisgit Oct 12, 2026
f94b902
fix(setup): back up the library tables before adding libraries
barisgit Oct 12, 2026
1e75053
fix(unpin): write kicad_common.json atomically
barisgit Oct 12, 2026
17d07ca
fix(sync): run setup with its real defaults after --auto-setup
barisgit Oct 12, 2026
8ad10d4
refactor: type library metadata with TypedDicts instead of Any
barisgit Oct 12, 2026
927f3c8
fix(sync): detect new libraries in every pulled commit
barisgit Oct 12, 2026
bb4386a
fix(add-hook): report a failure once and write the hook with LF endings
barisgit Oct 12, 2026
3a71b78
fix(update): honor --target-version and fail dry runs that cannot update
barisgit Oct 12, 2026
1e21ffc
fix(update): update pinned uv tool installs to the latest release
barisgit Oct 12, 2026
6eb6465
fix(update): answer version checks from the cache for a day
barisgit Oct 12, 2026
16dfe74
refactor: split env_vars lookups and kicad_common.json updates
barisgit Oct 12, 2026
21e8356
fix(update): send the installed version in the PyPI User-Agent
barisgit Oct 12, 2026
4df0c11
fix(update): show why an automatic update failed
barisgit Oct 12, 2026
931fefa
refactor: type library table entries and drop unused writers
barisgit Oct 12, 2026
a1d99a4
build: depend on typing-extensions directly
barisgit Oct 12, 2026
579cdcb
refactor(main): pass the strict ruff and ty gates
barisgit Oct 12, 2026
f1842cf
fix(template): skip excluded directories when suggesting variables
barisgit Oct 12, 2026
d46de84
fix(template): make the make --dry-run preview match the copy
barisgit Oct 12, 2026
b4beb85
fix: report a missing git and keep every hook backup
barisgit Oct 12, 2026
a4e663c
refactor: tidy banner, constants, and utils package for strict lint
barisgit Oct 12, 2026
498c29b
refactor(pin, unpin): pass the strict ruff and ty gates
barisgit Oct 12, 2026
03aee5e
fix(init): report the library folders it creates
barisgit Oct 12, 2026
3dce93e
refactor: bring the import command up to the strict lint rules
barisgit Oct 12, 2026
f6066a2
refactor(init): pass the strict ruff and ty gates
barisgit Oct 12, 2026
329a7a7
refactor(update): split the update service and pass ruff ALL and stri…
barisgit Oct 12, 2026
ec4887a
docs(update): describe the version check cache and installer failures
barisgit Oct 12, 2026
9fa3c12
refactor(add-3d): pass the strict ruff and ty gates
barisgit Oct 12, 2026
ef76517
test: bring the import command tests up to the strict lint rules
barisgit Oct 12, 2026
40b36c8
refactor(add-hook): pass the strict ruff and ty gates
barisgit Oct 12, 2026
f94d60a
docs: give the backup name format with microseconds
barisgit Oct 12, 2026
0056439
refactor(status): pass the strict ruff and ty gates
barisgit Oct 12, 2026
893fcd8
fix(sync): detect libraries renamed by a pull
barisgit Oct 12, 2026
145731c
fix(sync): tell up-to-date libraries apart in any language
barisgit Oct 12, 2026
3c4b2dc
fix(sync): let a failed auto-setup report its error once
barisgit Oct 12, 2026
97452b2
refactor(config): type the configuration model and make its files inj…
barisgit Oct 12, 2026
0a4633b
fix(config): clear the default library when it is removed
barisgit Oct 12, 2026
887ea27
refactor(sync): split syncing into typed steps
barisgit Oct 12, 2026
f1fc050
fix(config): explain an empty type filter in config list
barisgit Oct 12, 2026
4503873
fix(setup): honor --max-backups when pinning libraries
barisgit Oct 12, 2026
6d9ba97
refactor(template): split template code into typed modules
barisgit Oct 12, 2026
bcb92a0
fix(template): write a starting post_create hook that runs
barisgit Oct 12, 2026
c963102
fix(template): drop emojis and a nonexistent make option from list ou…
barisgit Oct 12, 2026
abb94dc
refactor(config): bring the config commands up to the strict lint rules
barisgit Oct 12, 2026
ec12498
Merge branch 'strict/utils' into strict/services
barisgit Oct 12, 2026
f30f6c0
fix(setup): escape quotes in library table entries
barisgit Oct 12, 2026
df11124
test: share the failing-write fault injection
barisgit Oct 12, 2026
d45f03f
refactor(setup): split library sources from configuration changes
barisgit Oct 12, 2026
ffe8a91
test: document the command tests and type their helpers
barisgit Oct 12, 2026
0cbd146
fix: drop the emoji from success panel titles
barisgit Oct 12, 2026
5cec99a
docs(sync): describe how sync detects new library content
barisgit Oct 12, 2026
5cff1e5
refactor(services): split the library service and pass ruff ALL and s…
barisgit Oct 12, 2026
ac9b576
fix(init): fail when kilm.yaml cannot be written
barisgit Oct 12, 2026
50a3e2c
fix(config): keep ConfigService.get_config_file_path for setup
barisgit Oct 12, 2026
1b65bfc
Merge remote-tracking branch 'origin/strict/utils' into chore/max-str…
barisgit Oct 12, 2026
7657d6d
Merge remote-tracking branch 'origin/strict/services' into chore/max-…
barisgit Oct 12, 2026
0e02b34
Merge remote-tracking branch 'origin/strict/commands' into chore/max-…
barisgit Oct 12, 2026
c1729ad
Merge remote-tracking branch 'origin/strict/template' into chore/max-…
barisgit Oct 12, 2026
4df6d19
Merge remote-tracking branch 'origin/strict/tooling' into chore/max-s…
barisgit Oct 12, 2026
786e884
refactor: type add-3d and status with the typed metadata and table en…
barisgit Oct 12, 2026
87b5ff8
refactor: drop the ConfigService shim and duplicate kicad_common cons…
barisgit Oct 12, 2026
61b474e
refactor: make dry_run and create_if_missing keyword-only
barisgit Oct 12, 2026
597b67d
chore: record the measured coverage floor and the new layout
barisgit Oct 12, 2026
7ec1c3a
fix(tests): keep CLI tests out of the real home and off the network
barisgit Oct 12, 2026
06ba733
fix: keep pathspec and packaging out of CLI startup on Python 3.15
barisgit Oct 12, 2026
556e10e
fix(template): point empty-list hints at kilm config list
barisgit Oct 12, 2026
d16ae23
fix(status): describe library state in words instead of check marks
barisgit Oct 12, 2026
5d2dd02
docs: name the 3D model metadata file correctly
barisgit Oct 12, 2026
5846302
refactor(template): one owner per rule, accurate docstrings
barisgit Oct 12, 2026
d2881ed
docs(template): match the make and create arguments and outputs to th…
barisgit Oct 12, 2026
29311c0
refactor: one owner for kicad_common.json access
barisgit Oct 12, 2026
39abc06
refactor: keep KiCad names and model suffixes in utils.constants
barisgit Oct 12, 2026
ff5d617
test: use the suite-wide home fixture instead of per-file HOME setup
barisgit Oct 12, 2026
e11d52c
refactor: drop KiCadService forwarder; pin and status call LibrarySer…
barisgit Oct 12, 2026
9a674b1
refactor: metadata owns the library folder schema and model counting
barisgit Oct 12, 2026
fc2f5ea
fix: PyPI answers whose JSON body is not an object count as no version
barisgit Oct 12, 2026
6b03dc2
docs: correct update-check, PyPI cache, sync, unpin, and update perfo…
barisgit Oct 12, 2026
b58d612
refactor: name library-path discovery for what it searches
barisgit Oct 12, 2026
8b77a23
refactor: one KiCad version ordering for config lookup and kicad-cli …
barisgit Oct 12, 2026
7e5c2b1
test: pin exact config removals, a non-first interactive pick, and IN…
barisgit Oct 12, 2026
c802a63
docs(backup): state that name order is creation order only for a forw…
barisgit Oct 12, 2026
6454876
test: collapse the four mocked git hooks-path cases into relative and…
barisgit Oct 12, 2026
b526a49
fix(config): report a config file that is not UTF-8 and use the defaults
barisgit Oct 12, 2026
f9e90f4
fix(init): keep kilm.yaml entries with non-string keys or mistyped fi…
barisgit Oct 12, 2026
dcf195b
fix(setup): decode escaped library table strings so a second setup ad…
barisgit Oct 12, 2026
e331324
fix(template): keep custom metadata.yaml keys for hooks and list --json
barisgit Oct 12, 2026
37c313f
docs(agents): locate kicad_common.py under utils
barisgit Oct 12, 2026
35e6a9d
fix(metadata): replace kilm.yaml and .kilm_metadata atomically
barisgit Oct 12, 2026
363a401
fix(add-3d): keep .kilm_metadata fields the typed metadata cannot hold
barisgit Oct 12, 2026
61cfda9
fix(template): keep custom metadata.yaml keys as loaded
barisgit Oct 12, 2026
552cdd6
docs(add-3d): describe which .kilm_metadata fields add-3d keeps
barisgit Oct 12, 2026
badd654
fix(template): treat an empty variable pattern list as no patterns
barisgit Oct 12, 2026
22d90f8
fix(update): name kilm update perform in the update notice and releas…
barisgit Oct 12, 2026
eb00241
docs(configuration): config set-default takes a library name, not a path
barisgit Oct 12, 2026
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
219 changes: 144 additions & 75 deletions .github/scripts/github_release.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,20 @@
import hashlib
import json
import os
import shutil
import subprocess
import sys
from collections.abc import Sequence
from dataclasses import dataclass, field
from pathlib import Path
from typing import Protocol, TypedDict
from typing import TYPE_CHECKING, Protocol, TypeVar

if TYPE_CHECKING:
from collections.abc import Mapping, Sequence

DIGEST_PREFIX = "sha256:"
UPLOADED_STATE = "uploaded"

_FieldType = TypeVar("_FieldType")


class ReleaseError(Exception):
Expand All @@ -47,6 +53,21 @@ class Release:
assets: dict[str, str] = field(default_factory=dict)


@dataclass(frozen=True)
class Publication:
"""What one run publishes: release ``tag`` of ``commit`` holding ``files``.

``title`` and ``notes`` are used only when the run creates the release;
an existing release keeps its own.
"""

tag: str
commit: str
files: tuple[Path, ...]
title: str
notes: str


class GitHub(Protocol):
"""The GitHub operations a release needs, addressed by tag name."""

Expand Down Expand Up @@ -76,87 +97,136 @@ def file_digest(path: Path) -> str:
return hashlib.sha256(path.read_bytes()).hexdigest()


def publish_release(
github: GitHub,
*,
tag: str,
commit: str,
files: Sequence[Path],
title: str,
notes: str,
) -> None:
"""Make ``tag`` a published release of ``commit`` that holds ``files``.
def publish_release(github: GitHub, publication: Publication) -> None:
"""Make ``publication.tag`` a published release of its commit and files.

Safe to repeat: each step checks GitHub first and only does what is
missing, so a run that stopped after any step is completed by the next
one. Raises ReleaseError when the tag or a draft belongs to another
commit, or when GitHub does not hold the expected state at the end.
"""
tag, commit = publication.tag, publication.commit
_check_tag(github, tag, commit)
release = github.release(tag)
if release is None:
github.create_draft(tag, commit, title, notes)
github.create_draft(tag, commit, publication.title, publication.notes)
release = Release(draft=True, target=commit)
elif release.draft and release.target != commit:
msg = f"Draft release {tag} targets {release.target}, not {commit}"
raise ReleaseError(msg)

missing = [f for f in files if release.assets.get(f.name) != file_digest(f)]
missing = _differing_files(release, publication.files)
if missing:
github.upload(tag, missing)
if release.draft:
github.publish(tag)

_verify(github, tag, commit, files)
_verify(github, publication)


def _differing_files(release: Release, files: Sequence[Path]) -> list[Path]:
"""Return the ``files`` that ``release`` lacks or holds with other bytes."""
return [f for f in files if release.assets.get(f.name) != file_digest(f)]


def _check_tag(github: GitHub, tag: str, commit: str) -> None:
tag_commit = github.tag_commit(tag)
if tag_commit not in (None, commit):
if tag_commit not in {None, commit}:
msg = f"Tag {tag} points at {tag_commit}, not {commit}"
raise ReleaseError(msg)


def _verify(github: GitHub, tag: str, commit: str, files: Sequence[Path]) -> None:
def _verify(github: GitHub, publication: Publication) -> None:
tag, commit = publication.tag, publication.commit
release = github.release(tag)
if release is None or release.draft:
msg = f"Release {tag} is not published"
raise ReleaseError(msg)
if github.tag_commit(tag) != commit:
msg = f"Tag {tag} does not point at {commit}"
raise ReleaseError(msg)
wrong = [f.name for f in files if release.assets.get(f.name) != file_digest(f)]
wrong = [f.name for f in _differing_files(release, publication.files)]
if wrong:
msg = f"Release {tag} assets differ from the built files: {', '.join(wrong)}"
raise ReleaseError(msg)


class _AssetJson(TypedDict):
name: str
state: str
digest: str | None
def parse_release(text: str) -> Release:
"""Read ``gh release view --json isDraft,targetCommitish,assets`` output.

Only assets in the ``uploaded`` state with a ``sha256:`` digest are kept.
Raises ReleaseError when the JSON does not have the expected shape.
"""
data = _json_object(json.loads(text))
assets: dict[str, str] = {}
for item in _field(data, "assets", list):
asset = _json_object(item)
digest = asset.get("digest")
if (
_field(asset, "state", str) == UPLOADED_STATE
and isinstance(digest, str)
and digest.startswith(DIGEST_PREFIX)
):
assets[_field(asset, "name", str)] = digest.removeprefix(DIGEST_PREFIX)
return Release(
draft=_field(data, "isDraft", bool),
target=_field(data, "targetCommitish", str),
assets=assets,
)


def has_tag_ref(text: str, tag: str) -> bool:
"""Tell whether ``git/matching-refs/tags/<tag>`` output holds exactly ``tag``.

The endpoint matches by prefix, so ``v1.2`` also lists ``v1.2.3``; only an
exact ``refs/tags/<tag>`` counts.
"""
ref = f"refs/tags/{tag}"
refs = _json_list(json.loads(text))
return any(_field(_json_object(item), "ref", str) == ref for item in refs)


def _json_object(value: object) -> Mapping[str, object]:
if not isinstance(value, dict):
msg = f"Expected a JSON object from gh, got {type(value).__name__}"
raise ReleaseError(msg)
# JSON object keys are always strings; the check narrows the key type.
return {key: item for key, item in value.items() if isinstance(key, str)}


class _ReleaseJson(TypedDict):
isDraft: bool
targetCommitish: str
assets: list[_AssetJson]
def _json_list(value: object) -> list[object]:
if not isinstance(value, list):
msg = f"Expected a JSON array from gh, got {type(value).__name__}"
raise ReleaseError(msg)
return list(value)


class _RefJson(TypedDict):
ref: str
def _field(data: Mapping[str, object], key: str, kind: type[_FieldType]) -> _FieldType:
value = data.get(key)
if not isinstance(value, kind):
msg = f"Expected {kind.__name__} {key!r} in gh output, got {value!r}"
raise ReleaseError(msg)
return value


class GhCli:
"""GitHub operations through the ``gh`` CLI, authenticated by GH_TOKEN."""

def __init__(self, repo: str) -> None:
"""Address the repository ``repo`` (``owner/name``)."""
"""Address the repository ``repo`` (``owner/name``) through ``gh`` on PATH.

Raises ReleaseError when ``gh`` is not installed.
"""
executable = shutil.which("gh")
if executable is None:
msg = "The gh CLI is not on PATH"
raise ReleaseError(msg)
self.repo = repo
self._executable = executable

def release(self, tag: str) -> Release | None:
"""Return the release for ``tag``; ``gh release view`` also finds drafts."""
result = _gh(
result = self._run(
"release", "view", tag, "--repo", self.repo,
"--json", "isDraft,targetCommitish,assets",
check=False,
Expand All @@ -165,82 +235,81 @@ def release(self, tag: str) -> Release | None:
if "release not found" in result.stderr:
return None
raise ReleaseError(result.stderr.strip())
data: _ReleaseJson = json.loads(result.stdout)
assets = {
asset["name"]: digest.removeprefix(DIGEST_PREFIX)
for asset in data["assets"]
if asset["state"] == "uploaded"
and (digest := asset["digest"] or "").startswith(DIGEST_PREFIX)
}
return Release(
draft=data["isDraft"], target=data["targetCommitish"], assets=assets
)
return parse_release(result.stdout)

def tag_commit(self, tag: str) -> str | None:
"""Return the commit of ``refs/tags/<tag>``, peeling annotated tags."""
ref = f"refs/tags/{tag}"
# matching-refs answers an absent tag with an empty list instead of an error.
refs: list[_RefJson] = json.loads(
_gh("api", f"repos/{self.repo}/git/matching-refs/tags/{tag}").stdout
)
if all(r["ref"] != ref for r in refs):
refs = self._run("api", f"repos/{self.repo}/git/matching-refs/tags/{tag}")
if not has_tag_ref(refs.stdout, tag):
return None
return _gh(
"api", f"repos/{self.repo}/commits/{ref}", "--jq", ".sha"
).stdout.strip()
commit = self._run(
"api", f"repos/{self.repo}/commits/refs/tags/{tag}", "--jq", ".sha"
)
return commit.stdout.strip()

def create_draft(self, tag: str, target: str, title: str, notes: str) -> None:
"""Create the draft; ``--notes`` is prepended to the generated notes."""
_gh(
self._run(
"release", "create", tag, "--repo", self.repo, "--draft",
"--target", target, "--title", title, "--generate-notes",
"--notes", notes,
) # fmt: skip

def upload(self, tag: str, files: Sequence[Path]) -> None:
"""Upload ``files``; ``--clobber`` replaces partial or stale assets."""
_gh(
"release", "upload", tag, *map(str, files), "--repo", self.repo, "--clobber"
)
paths = [str(path) for path in files]
self._run("release", "upload", tag, *paths, "--repo", self.repo, "--clobber")

def publish(self, tag: str) -> None:
"""Publish the draft, which creates the tag at its target commit."""
_gh("release", "edit", tag, "--repo", self.repo, "--draft=false")
self._run("release", "edit", tag, "--repo", self.repo, "--draft=false")

def _run(self, *args: str, check: bool = True) -> subprocess.CompletedProcess[str]:
"""Run ``gh`` with ``args``; raise ReleaseError on failure when ``check``."""
result = subprocess.run( # noqa: S603 -- fixed gh subcommands; values come from the workflow
[self._executable, *args], capture_output=True, text=True, check=False
)
if check and result.returncode != 0:
msg = f"gh {args[0]} {args[1]} failed: {result.stderr.strip()}"
raise ReleaseError(msg)
return result

def _gh(*args: str, check: bool = True) -> subprocess.CompletedProcess[str]:
result = subprocess.run( # noqa: S603 -- fixed gh subcommands; values come from the workflow
["gh", *args], capture_output=True, text=True, check=False
)
if check and result.returncode != 0:
msg = f"gh {args[0]} {args[1]} failed: {result.stderr.strip()}"
raise ReleaseError(msg)
return result

def publication_from_environment(dist_dir: Path) -> Publication:
"""Describe the release of every file in ``dist_dir`` from NAME and VERSION.

def main(argv: Sequence[str]) -> int:
"""Publish the files in ``argv[1]`` as the release of the environment's version."""
Reads RELEASE_SHA for the commit; raises KeyError when a variable is unset.
"""
name, version = os.environ["NAME"], os.environ["VERSION"]
tag = f"v{version}"
files = sorted(p for p in Path(argv[1]).iterdir() if p.is_file())
notes = (
f"Install with `pipx install {name}=={version}` or "
f"`uv tool install {name}=={version}`; "
"upgrade an existing install with `kilm update`."
"upgrade an existing install with `kilm update perform`."
)
return Publication(
tag=tag,
commit=os.environ["RELEASE_SHA"],
files=tuple(sorted(p for p in dist_dir.iterdir() if p.is_file())),
title=f"Release {tag}",
notes=notes,
)


def main(argv: Sequence[str]) -> int:
"""Publish the files in ``argv[1]`` as the release of the environment's version.

Returns the exit status; a ReleaseError becomes a workflow error annotation.
"""
publication = publication_from_environment(Path(argv[1]))
try:
publish_release(
GhCli(os.environ["GITHUB_REPOSITORY"]),
tag=tag,
commit=os.environ["RELEASE_SHA"],
files=files,
title=f"Release {tag}",
notes=notes,
)
publish_release(GhCli(os.environ["GITHUB_REPOSITORY"]), publication)
except ReleaseError as error:
print(f"::error::{error}", file=sys.stderr)
sys.stderr.write(f"::error::{error}\n")
return 1
print(f"Release {tag} is published with {len(files)} files.")
count = len(publication.files)
sys.stdout.write(f"Release {publication.tag} is published with {count} files.\n")
return 0


Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ jobs:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
python: ["3.10", "3.11", "3.12", "3.13", "3.14"]
python: ["3.10", "3.11", "3.12", "3.13", "3.14", "3.15"]
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
Expand Down
Loading
Loading