From 34e4ba94cb4d407bda085dc00b08119f917b8c77 Mon Sep 17 00:00:00 2001 From: claude <6687499+pike00@users.noreply.github.com> Date: Tue, 4 Aug 2026 02:48:36 -0500 Subject: [PATCH 1/6] tooling: refresh project-kit --- .project-kit/_lib.just | 18 ++- .project-kit/answers.lock.json | 9 +- .project-kit/release.just | 4 +- .project-kit/scripts/doctor.py | 35 ++---- .project-kit/scripts/release.py | 187 ++++++++++++++++++++++++-------- AGENTS.md | 40 +++++++ CLAUDE.md | 1 + 7 files changed, 214 insertions(+), 80 deletions(-) create mode 100644 AGENTS.md create mode 100644 CLAUDE.md diff --git a/.project-kit/_lib.just b/.project-kit/_lib.just index 0f4a435..b4a0129 100644 --- a/.project-kit/_lib.just +++ b/.project-kit/_lib.just @@ -1,4 +1,18 @@ -# .project-kit/_lib.just — universal helpers, identical in every adopter +# .project-kit/_lib.just, generated shared helpers for drape + +# Resolve the real execution host before any host-dependent action. Named +# targets are generic and can be reused by any managed subsystem. +context TARGET="": + #!/usr/bin/env bash + set -euo pipefail + execution_host="$(hostname -s | tr '[:upper:]' '[:lower:]')" + requested_target="{{TARGET}}" + printf 'host=%s\nrepo=%s\n' "$execution_host" "$(pwd)" + matched=false + if [ -n "$requested_target" ] && [ "$matched" != true ]; then + printf 'error=unknown execution target: %s\n' "$requested_target" >&2 + exit 2 + fi [private] _skip LABEL: @@ -6,4 +20,4 @@ _skip LABEL: [private] _step N TOTAL MSG: - @echo "[{{N}}/{{TOTAL}}] {{MSG}}…" + @echo "[{{N}}/{{TOTAL}}] {{MSG}}..." diff --git a/.project-kit/answers.lock.json b/.project-kit/answers.lock.json index 33c32b6..e03af12 100644 --- a/.project-kit/answers.lock.json +++ b/.project-kit/answers.lock.json @@ -31,7 +31,9 @@ "custom_command": null, "enabled": false, "homelab_app": null, + "host": null, "pypi_package": null, + "ssh_alias": null, "target": "none" }, "docs": { @@ -44,6 +46,9 @@ "source_dir": "docs" }, "domain": "", + "execution": { + "targets": {} + }, "language": "python", "managed_subsystems": [ "release" @@ -62,7 +67,7 @@ }, "project_name": "drape", "public": true, - "refreshed_at": "2026-06-27", + "refreshed_at": "2026-08-04", "release": { "enabled": true, "install_command": "uv tool install drape=={version}", @@ -78,7 +83,7 @@ "hooks": true, "precommit": false }, - "skill_version": "0.1.0", + "skill_version": "0.2.0", "test": { "backend": { "cmd": null, diff --git a/.project-kit/release.just b/.project-kit/release.just index 18e0bf9..7412de0 100644 --- a/.project-kit/release.just +++ b/.project-kit/release.just @@ -1,6 +1,6 @@ # .project-kit/release.just — generated for drape -# Generated by project-kit (skill version: 0.1.0) -# Last refreshed: 2026-06-27 +# Generated by project-kit (skill version: 0.2.0) +# Last refreshed: 2026-08-04 release LEVEL="patch": @uv run .project-kit/scripts/release.py cut {{LEVEL}} diff --git a/.project-kit/scripts/doctor.py b/.project-kit/scripts/doctor.py index 7c84d6f..ef7fdbb 100755 --- a/.project-kit/scripts/doctor.py +++ b/.project-kit/scripts/doctor.py @@ -7,17 +7,17 @@ Checks subsystem health for this adopter. Stdlib only. """ + from __future__ import annotations -import os import shutil import subprocess import sys from pathlib import Path EXPECTED_JUST = [ - "_lib", "preview", "release", "test", "deploy", - "build", "db", "setup", "docs", "clean", + "_lib", + "release", ] @@ -51,7 +51,7 @@ def _check_cmd(layer: str, cmd: str) -> int: def main() -> int: repo = Path.cwd() - print(f"project-kit doctor — drape\n") + print("project-kit doctor — drape\n") print("configuration:") pk = repo / ".project-kit" if not pk.is_dir(): @@ -76,7 +76,10 @@ def main() -> int: if shutil.which("just"): proc = subprocess.run( ["just", "--justfile", str(jf), "--summary"], - capture_output=True, text=True, cwd=str(repo), + capture_output=True, + text=True, + cwd=str(repo), + check=False, ) if proc.returncode == 0: _ok("root justfile parses (just --summary)") @@ -101,28 +104,6 @@ def main() -> int: else: _warn("gh CLI not on $PATH") warns += 1 - print("\nhooks:") - hooks_dir = repo / ".project-kit" / "hooks" - for hook in ("pre-commit", "pre-push"): - hp = hooks_dir / hook - if hp.is_file() and os.access(hp, os.X_OK): - _ok(f".project-kit/hooks/{hook} present and executable") - elif hp.is_file(): - _fail(f".project-kit/hooks/{hook} present but not executable (chmod +x)") - fails += 1 - else: - _fail(f".project-kit/hooks/{hook} missing") - fails += 1 - hp_proc = subprocess.run( - ["git", "config", "--local", "core.hooksPath"], - capture_output=True, text=True, cwd=str(repo), - ) - configured = hp_proc.stdout.strip() - if configured == ".project-kit/hooks": - _ok("core.hooksPath -> .project-kit/hooks") - else: - _warn(f"core.hooksPath not set to .project-kit/hooks (got {configured!r}); run `just setup-hooks`") - warns += 1 print(f"\n{fails} failures, {warns} warnings.") if fails: diff --git a/.project-kit/scripts/release.py b/.project-kit/scripts/release.py index 496dcf0..aac1f3a 100755 --- a/.project-kit/scripts/release.py +++ b/.project-kit/scripts/release.py @@ -4,6 +4,7 @@ # dependencies = [ # "typer>=0.12", # "httpx>=0.27", +# "pydantic-settings>=2.13", # ] # /// """Release cut for drape. Generated by project-kit. @@ -11,31 +12,130 @@ Reads .project-kit/cliff.toml, drafts release notes via LiteLLM, tags + pushes, creates GH release. Owned by this repo — edit freely. """ + from __future__ import annotations import json -import os import re +import shlex import subprocess import sys import tempfile +from collections.abc import Mapping from pathlib import Path import httpx import typer +from pydantic import Field, HttpUrl, SecretStr, field_validator, model_validator +from pydantic_settings import BaseSettings, SettingsConfigDict PROJECT_NAME = "drape" CLIFF_CONFIG = ".project-kit/cliff.toml" BRANCH = "main" LITELLM_MODEL = "deepseek-v4-pro-cloud" -# OpenAI-compatible base URL. Read from $LITELLM_BASE_URL at runtime, falling back -# to the value baked from .project-kit answers (public repos bake nothing here, so -# set the env var). release.py appends /chat/completions. -LITELLM_BASE_URL = os.environ.get("LITELLM_BASE_URL") or "" INSTALL_COMMAND = "uv tool install drape=={version}" # `just version` prod-version resolution (baked from .project-kit answers). PROD_SOURCE = "none" PROD_HOMELAB_ENV = "" +BAKED_LITELLM_BASE_URL: str | None = None + + +class Settings(BaseSettings): + """Validated runtime configuration for release operations.""" + + model_config = SettingsConfigDict( + extra="ignore", + validate_default=True, + ) + + # Public repos bake no private endpoint, so their default is None and the + # environment must provide LITELLM_BASE_URL before drafting release notes. + litellm_base_url: HttpUrl | None = Field( + default=BAKED_LITELLM_BASE_URL, + validation_alias="LITELLM_BASE_URL", + ) + litellm_api_key: SecretStr | None = Field( + default=None, + validation_alias="LITELLM_API_KEY", + ) + openai_api_key: SecretStr | None = Field( + default=None, + validation_alias="OPENAI_API_KEY", + ) + editor: str = Field(default="vim", validation_alias="EDITOR") + home: Path = Field(default_factory=Path.home, validation_alias="HOME") + + @field_validator("home", mode="before") + @classmethod + def _home_must_not_be_empty(cls, value: object) -> object: + if value == "": + raise ValueError("HOME must not be empty") + return value + + @field_validator("litellm_base_url", mode="before") + @classmethod + def _empty_url_uses_baked_default(cls, value: object) -> object | None: + if value == "": + return BAKED_LITELLM_BASE_URL + return value + + @field_validator("litellm_api_key", "openai_api_key", mode="before") + @classmethod + def _empty_secret_is_unset(cls, value: object) -> object | None: + return None if value == "" else value + + @field_validator("editor") + @classmethod + def _valid_editor(cls, value: str) -> str: + try: + argv = shlex.split(value) + except ValueError as exc: + raise ValueError("EDITOR must be valid shell-style argv") from exc + if not argv: + raise ValueError("EDITOR must contain an executable") + return value + + @model_validator(mode="after") + def _legacy_litellm_key(self) -> Settings: + """Load the legacy key only when neither preferred environment key exists.""" + if self.litellm_api_key or self.openai_api_key: + return self + cfg_path = self.home / ".config" / "litellm" / "config.json" + if not cfg_path.is_file(): + return self + try: + decoded = json.loads(cfg_path.read_text()) + except (OSError, json.JSONDecodeError): + return self + if not isinstance(decoded, Mapping): + return self + value = decoded.get("api_key", "") + if isinstance(value, str) and value: + self.litellm_api_key = SecretStr(value) + return self + + @property + def llm_api_key(self) -> SecretStr | None: + return self.litellm_api_key or self.openai_api_key + + @property + def editor_argv(self) -> list[str]: + return shlex.split(self.editor) + + def homelab_image_tag(self, relative_env: str) -> tuple[Path, bool, str | None]: + """Read IMAGE_TAG from the configured HOME-relative homelab env file.""" + env_path = self.home / "Documents" / "Homelab" / relative_env + if not env_path.is_file(): + return env_path, False, None + for raw in env_path.read_text().splitlines(): + line = raw.strip() + if line.startswith("IMAGE_TAG="): + value = line.split("=", 1)[1].strip().strip('"').strip("'") + return env_path, True, value + return env_path, True, None + + +SETTINGS = Settings() app = typer.Typer(add_completion=False) @@ -72,40 +172,24 @@ def _next_version(level: str) -> str: return f"v{major}.{minor}.{patch + 1}" -def _llm_api_key() -> str: - """Resolve the LLM API key without baking it into the repo. - - Order: $LITELLM_API_KEY, $OPENAI_API_KEY, then the legacy - ~/.config/litellm/config.json (api_key field) for backward compatibility. - """ - for var in ("LITELLM_API_KEY", "OPENAI_API_KEY"): - val = os.environ.get(var) - if val: - return val - cfg_path = Path.home() / ".config" / "litellm" / "config.json" - if cfg_path.is_file(): - try: - return str(json.loads(cfg_path.read_text()).get("api_key", "")) - except json.JSONDecodeError: - return "" - return "" - - def _draft_notes(version: str, prev_tag: str | None) -> str: """Draft release notes via the configured LLM endpoint. Empty on failure.""" - base_url = LITELLM_BASE_URL.rstrip("/") - api_key = _llm_api_key() + base_url = str(SETTINGS.litellm_base_url or "").rstrip("/") + api_key = SETTINGS.llm_api_key try: rng = f"{prev_tag}..HEAD" if prev_tag else "HEAD" commits = _run(["git", "log", rng, "--pretty=format:- %h %s"]).stdout prompt = ( f"Draft a short, editorial GitHub release narrative for {PROJECT_NAME} {version}.\n" f"Commits in this release:\n{commits}\n\n" - f"{'Install: ' + INSTALL_COMMAND.replace('{version}', version.lstrip('v')) if INSTALL_COMMAND else ''}\n" + f"{( + 'Install: ' + INSTALL_COMMAND.replace('{version}', version.lstrip('v')) + if INSTALL_COMMAND else '' + )}\n" ) r = httpx.post( f"{base_url}/chat/completions", - headers={"Authorization": f"Bearer {api_key}"} if api_key else {}, + headers={"Authorization": f"Bearer {api_key.get_secret_value()}"} if api_key else {}, json={"model": LITELLM_MODEL, "messages": [{"role": "user", "content": prompt}]}, timeout=30, ) @@ -148,15 +232,25 @@ def cut( typer.echo(f"[dry-run] would tag {version}, commit changelog, push, gh release create") return typer.echo("[3/7] regenerating CHANGELOG.md via git-cliff…") - _run(["uvx", "git-cliff@latest", "--config", CLIFF_CONFIG, "--output", "CHANGELOG.md", "--tag", version]) + _run( + [ + "uvx", + "git-cliff@latest", + "--config", + CLIFF_CONFIG, + "--output", + "CHANGELOG.md", + "--tag", + version, + ] + ) typer.echo("[4/7] drafting release notes via LiteLLM…") notes = _draft_notes(version, _latest_tag()) typer.echo("[5/7] opening $EDITOR for review…") - editor = os.environ.get("EDITOR", "vim") with tempfile.NamedTemporaryFile(mode="w", suffix=".md", delete=False) as f: f.write(notes) notes_path = f.name - subprocess.run([editor, notes_path], check=False) + subprocess.run([*SETTINGS.editor_argv, notes_path], check=False) # notes_path is reused below as --notes-file (the editor wrote in place). typer.echo("[6/7] commit + tag + push…") _run(["git", "add", "CHANGELOG.md"]) @@ -176,33 +270,32 @@ def version() -> None: """Print the local latest tag and the deployed prod version (per prod_source).""" typer.echo(f"local: {_latest_tag() or '(none)'}") if PROD_SOURCE == "homelab" and PROD_HOMELAB_ENV: - env_path = ( - Path(os.environ.get("HOME") or str(Path.home())) - / "Documents" / "Homelab" / PROD_HOMELAB_ENV - ) + env_path, exists, image_tag = SETTINGS.homelab_image_tag(PROD_HOMELAB_ENV) label = f"~/Documents/Homelab/{PROD_HOMELAB_ENV} IMAGE_TAG" - if not env_path.is_file(): + if not exists: typer.echo(f"prod: (file not found: {env_path}) [{label}]") return - for raw in env_path.read_text().splitlines(): - line = raw.strip() - if line.startswith("IMAGE_TAG="): - val = line.split("=", 1)[1].strip().strip('"').strip("'") - typer.echo(f"prod: {val or '(empty)'} [{label}]") - return - typer.echo(f"prod: (IMAGE_TAG not set) [{label}]") + if image_tag is None: + typer.echo(f"prod: (IMAGE_TAG not set) [{label}]") + return + typer.echo(f"prod: {image_tag or '(empty)'} [{label}]") elif PROD_SOURCE == "none": typer.echo("prod: (not configured — prod_source = none)") else: - typer.echo(f"prod: (prod_source={PROD_SOURCE!r} not supported by this generated release.py)") + typer.echo( + f"prod: (prod_source={PROD_SOURCE!r} " "not supported by this generated release.py)" + ) @app.command() def notes(tag: str = typer.Option(..., "--tag")) -> None: """Regenerate notes for an existing tag (no commit, no push).""" - prev = _run( - ["git", "describe", "--tags", "--abbrev=0", f"{tag}^", "--match", "v*"], - ).stdout.strip() or None + prev = ( + _run( + ["git", "describe", "--tags", "--abbrev=0", f"{tag}^", "--match", "v*"], + ).stdout.strip() + or None + ) content = _draft_notes(tag, prev) sys.stdout.write(content) diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..dca2d6b --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,40 @@ +# drape + + +## Project-kit recipes + +This repo is managed by project-kit (skill version: 0.2.0, last refreshed: 2026-08-04). +Project-kit-managed operations go through `just`. + +### Execution context + +Run `just context` before every host-dependent action. +Managed recipes enforce their checks automatically. Host roles are descriptive +rather than exclusive; the actual hostname and configured target determine where +an action runs. + +### Quick reference + +| Task | Command | +|---|---| +| Show execution context | `just context [target]` | +| Cut a release | `just release patch` | +| Update CHANGELOG | `just changelog` | +| Health check | `uv run .project-kit/scripts/doctor.py` | + +### Subsystem status + +- release: enabled + +### Where things live + +- Managed recipe imports: 2 (`_lib.just` plus 1 managed subsystem) +- `.project-kit/scripts/` — uv-scripts for non-trivial recipes +- `.project-kit/cliff.toml` — git-cliff config (centralized; passed via `--config`, no root copy) +- `justfile` (root) — imports 2 managed recipe files plus repo-specific recipes + +### How to refresh + +Re-run the project-kit wizard in chat: ask Claude to "refresh project-kit" +or "audit project-kit in this repo". + diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..43c994c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md From 65b526eb5afbd9689f30b37e69d2443e5d52926d Mon Sep 17 00:00:00 2001 From: claude <6687499+pike00@users.noreply.github.com> Date: Tue, 4 Aug 2026 03:14:08 -0500 Subject: [PATCH 2/6] tooling: refresh project-kit release helper --- .project-kit/scripts/release.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.project-kit/scripts/release.py b/.project-kit/scripts/release.py index aac1f3a..d2ec0d8 100755 --- a/.project-kit/scripts/release.py +++ b/.project-kit/scripts/release.py @@ -37,7 +37,7 @@ # `just version` prod-version resolution (baked from .project-kit answers). PROD_SOURCE = "none" PROD_HOMELAB_ENV = "" -BAKED_LITELLM_BASE_URL: str | None = None +BAKED_LITELLM_BASE_URL: HttpUrl | None = None class Settings(BaseSettings): @@ -140,8 +140,8 @@ def homelab_image_tag(self, relative_env: str) -> tuple[Path, bool, str | None]: app = typer.Typer(add_completion=False) -def _run(cmd: list[str], **kw) -> subprocess.CompletedProcess: - return subprocess.run(cmd, check=True, capture_output=True, text=True, **kw) +def _run(cmd: list[str]) -> subprocess.CompletedProcess[str]: + return subprocess.run(cmd, check=True, capture_output=True, text=True) def _current_branch() -> str: From d74dcd222f08801daf32e3cd6311b6c21a6d7087 Mon Sep 17 00:00:00 2001 From: claude <6687499+pike00@users.noreply.github.com> Date: Tue, 4 Aug 2026 03:50:56 -0500 Subject: [PATCH 3/6] tooling: refresh project-kit release versioning --- .project-kit/answers.lock.json | 3 ++- .project-kit/scripts/release.py | 41 ++++++++++++++++++++++++++++++--- README.md | 18 +++++++++------ 3 files changed, 51 insertions(+), 11 deletions(-) diff --git a/.project-kit/answers.lock.json b/.project-kit/answers.lock.json index e03af12..581ca95 100644 --- a/.project-kit/answers.lock.json +++ b/.project-kit/answers.lock.json @@ -74,7 +74,8 @@ "litellm_base_url": "", "litellm_model": "deepseek-v4-pro-cloud", "prod_homelab_env": "", - "prod_source": "none" + "prod_pypi_package": "drape", + "prod_source": "pypi" }, "setup": { "backend": null, diff --git a/.project-kit/scripts/release.py b/.project-kit/scripts/release.py index d2ec0d8..3bb7290 100755 --- a/.project-kit/scripts/release.py +++ b/.project-kit/scripts/release.py @@ -26,7 +26,15 @@ import httpx import typer -from pydantic import Field, HttpUrl, SecretStr, field_validator, model_validator +from pydantic import ( + BaseModel, + Field, + HttpUrl, + SecretStr, + ValidationError, + field_validator, + model_validator, +) from pydantic_settings import BaseSettings, SettingsConfigDict PROJECT_NAME = "drape" @@ -35,11 +43,20 @@ LITELLM_MODEL = "deepseek-v4-pro-cloud" INSTALL_COMMAND = "uv tool install drape=={version}" # `just version` prod-version resolution (baked from .project-kit answers). -PROD_SOURCE = "none" +PROD_SOURCE = "pypi" PROD_HOMELAB_ENV = "" +PROD_PYPI_PACKAGE = "drape" BAKED_LITELLM_BASE_URL: HttpUrl | None = None +class PyPIInfo(BaseModel): + version: str = Field(min_length=1) + + +class PyPIProject(BaseModel): + info: PyPIInfo + + class Settings(BaseSettings): """Validated runtime configuration for release operations.""" @@ -144,6 +161,10 @@ def _run(cmd: list[str]) -> subprocess.CompletedProcess[str]: return subprocess.run(cmd, check=True, capture_output=True, text=True) +def _github_release_url(version: str) -> str: + return _run(["gh", "release", "view", version, "--json", "url", "-q", ".url"]).stdout.strip() + + def _current_branch() -> str: return _run(["git", "rev-parse", "--abbrev-ref", "HEAD"]).stdout.strip() @@ -262,7 +283,8 @@ def cut( if draft: cmd.append("--draft") _run(cmd) - typer.echo(f"done. URL: $(gh release view {version} --json url -q .url)") + release_url = _github_release_url(version) + typer.echo(f"done. URL: {release_url}") @app.command() @@ -279,6 +301,19 @@ def version() -> None: typer.echo(f"prod: (IMAGE_TAG not set) [{label}]") return typer.echo(f"prod: {image_tag or '(empty)'} [{label}]") + elif PROD_SOURCE == "pypi": + try: + response = httpx.get( + f"https://pypi.org/pypi/{PROD_PYPI_PACKAGE}/json", + headers={"Accept": "application/json"}, + timeout=10, + ) + response.raise_for_status() + deployed = PyPIProject.model_validate(response.json()).info.version + except (httpx.HTTPError, json.JSONDecodeError, ValidationError) as exc: + typer.echo(f"prod: (PyPI lookup failed for {PROD_PYPI_PACKAGE}: {exc})", err=True) + raise typer.Exit(code=1) from exc + typer.echo(f"prod: {deployed} [PyPI {PROD_PYPI_PACKAGE}]") elif PROD_SOURCE == "none": typer.echo("prod: (not configured — prod_source = none)") else: diff --git a/README.md b/README.md index 2a0d526..53aca58 100644 --- a/README.md +++ b/README.md @@ -302,25 +302,29 @@ The walker checks only the **last segment** of the path, not parent segments. `a ## Release ```bash +just context +just release-dry patch # show the next version and planned actions without writes just release patch # patch | minor | major ``` -This runs [release-kit](https://github.com/pike00/release-kit) `cut`, which: -1. Preflights (clean tree on `main`, in sync with `origin`). +The managed project-kit release command: + +1. Preflights a clean tree on `main` and verifies the git-cliff configuration. 2. Updates `CHANGELOG.md` via `git-cliff` (mechanical, commits → grouped sections per `cliff.toml`). 3. Drafts the GitHub release body via LiteLLM (`deepseek-v4-pro-cloud`) and opens it in `$EDITOR`. 4. Commits `CHANGELOG.md`, tags the commit, pushes, and runs `gh release create`. -Pushing the `v*.*.*` tag triggers `.github/workflows/release.yml`, which is the only GitHub Actions workflow drape keeps: it builds the sdist + wheel and publishes to PyPI via OIDC trusted publishing (no API token in source). The PyPI page typically updates within a minute. +Pushing the `v*.*.*` tag triggers `.github/workflows/release.yml`, which builds the sdist and wheel and publishes to PyPI via OIDC trusted publishing (no API token in source). The repository also keeps its Codacy analysis workflow. The PyPI page typically updates within a minute. -Preview what will land without releasing: +Supporting commands: ```bash -just changelog-preview # what git-cliff will write into CHANGELOG.md -just notes-dry-run # what the LLM will draft for the GH release body +just changelog # regenerate CHANGELOG.md from git history +just notes v0.3.3 # draft notes for an existing tag to stdout +just version # compare the latest local tag with the PyPI version ``` -`CHANGELOG.md` is generated; do not hand-edit it. To regenerate from full history (e.g. after editing `cliff.toml`), run `just changelog-backfill`. +`CHANGELOG.md` is generated; do not hand-edit it. ## Development From 0abbe7577712c849e05228ff99b05cc063ba188b Mon Sep 17 00:00:00 2001 From: claude <6687499+pike00@users.noreply.github.com> Date: Tue, 4 Aug 2026 04:30:00 -0500 Subject: [PATCH 4/6] tooling: refresh project-kit release correctness --- .project-kit/scripts/release.py | 58 ++++++++++++++++++++++++--------- 1 file changed, 43 insertions(+), 15 deletions(-) diff --git a/.project-kit/scripts/release.py b/.project-kit/scripts/release.py index 3bb7290..d372a64 100755 --- a/.project-kit/scripts/release.py +++ b/.project-kit/scripts/release.py @@ -139,12 +139,34 @@ def llm_api_key(self) -> SecretStr | None: def editor_argv(self) -> list[str]: return shlex.split(self.editor) + def _homelab_env_text(self, env_path: Path) -> str: + try: + if not env_path.name.endswith(".sops"): + return env_path.read_text() + secrets_cli = self.home / "Documents" / "Homelab" / "infra" / "scripts" / "secrets" + if not secrets_cli.is_file(): + raise ValueError(f"Homelab secrets helper not found: {secrets_cli}") + return subprocess.run( + [str(secrets_cli), "sopsx", str(env_path), "-d"], + capture_output=True, + check=True, + text=True, + timeout=10, + ).stdout + except (OSError, subprocess.CalledProcessError, subprocess.TimeoutExpired): + raise ValueError(f"unable to read Homelab env file: {env_path}") from None + def homelab_image_tag(self, relative_env: str) -> tuple[Path, bool, str | None]: """Read IMAGE_TAG from the configured HOME-relative homelab env file.""" - env_path = self.home / "Documents" / "Homelab" / relative_env + try: + homelab_root = (self.home / "Documents" / "Homelab").resolve() + env_path = (homelab_root / relative_env).resolve() + env_path.relative_to(homelab_root) + except (OSError, RuntimeError, ValueError): + raise ValueError("Homelab env path must stay within the Homelab repository") from None if not env_path.is_file(): return env_path, False, None - for raw in env_path.read_text().splitlines(): + for raw in self._homelab_env_text(env_path).splitlines(): line = raw.strip() if line.startswith("IMAGE_TAG="): value = line.split("=", 1)[1].strip().strip('"').strip("'") @@ -193,12 +215,12 @@ def _next_version(level: str) -> str: return f"v{major}.{minor}.{patch + 1}" -def _draft_notes(version: str, prev_tag: str | None) -> str: +def _draft_notes(version: str, prev_tag: str | None, end_ref: str) -> str: """Draft release notes via the configured LLM endpoint. Empty on failure.""" base_url = str(SETTINGS.litellm_base_url or "").rstrip("/") api_key = SETTINGS.llm_api_key try: - rng = f"{prev_tag}..HEAD" if prev_tag else "HEAD" + rng = f"{prev_tag}..{end_ref}" if prev_tag else end_ref commits = _run(["git", "log", rng, "--pretty=format:- %h %s"]).stdout prompt = ( f"Draft a short, editorial GitHub release narrative for {PROJECT_NAME} {version}.\n" @@ -266,7 +288,7 @@ def cut( ] ) typer.echo("[4/7] drafting release notes via LiteLLM…") - notes = _draft_notes(version, _latest_tag()) + notes = _draft_notes(version, _latest_tag(), "HEAD") typer.echo("[5/7] opening $EDITOR for review…") with tempfile.NamedTemporaryFile(mode="w", suffix=".md", delete=False) as f: f.write(notes) @@ -291,16 +313,20 @@ def cut( def version() -> None: """Print the local latest tag and the deployed prod version (per prod_source).""" typer.echo(f"local: {_latest_tag() or '(none)'}") - if PROD_SOURCE == "homelab" and PROD_HOMELAB_ENV: - env_path, exists, image_tag = SETTINGS.homelab_image_tag(PROD_HOMELAB_ENV) + if PROD_SOURCE == "homelab": + try: + env_path, exists, image_tag = SETTINGS.homelab_image_tag(PROD_HOMELAB_ENV) + except ValueError as exc: + typer.echo(f"prod: ({exc})", err=True) + raise typer.Exit(code=1) from exc label = f"~/Documents/Homelab/{PROD_HOMELAB_ENV} IMAGE_TAG" if not exists: - typer.echo(f"prod: (file not found: {env_path}) [{label}]") - return - if image_tag is None: - typer.echo(f"prod: (IMAGE_TAG not set) [{label}]") - return - typer.echo(f"prod: {image_tag or '(empty)'} [{label}]") + typer.echo(f"prod: (file not found: {env_path}) [{label}]", err=True) + raise typer.Exit(code=1) + if image_tag is None or not image_tag.strip(): + typer.echo(f"prod: (IMAGE_TAG not set) [{label}]", err=True) + raise typer.Exit(code=1) + typer.echo(f"prod: {image_tag} [{label}]") elif PROD_SOURCE == "pypi": try: response = httpx.get( @@ -318,8 +344,10 @@ def version() -> None: typer.echo("prod: (not configured — prod_source = none)") else: typer.echo( - f"prod: (prod_source={PROD_SOURCE!r} " "not supported by this generated release.py)" + f"prod: (prod_source={PROD_SOURCE!r} " "not supported by this generated release.py)", + err=True, ) + raise typer.Exit(code=1) @app.command() @@ -331,7 +359,7 @@ def notes(tag: str = typer.Option(..., "--tag")) -> None: ).stdout.strip() or None ) - content = _draft_notes(tag, prev) + content = _draft_notes(tag, prev, tag) sys.stdout.write(content) From a6717586a2fcb889c8e9ad1f753eda23584bc16e Mon Sep 17 00:00:00 2001 From: claude <6687499+pike00@users.noreply.github.com> Date: Tue, 4 Aug 2026 04:56:00 -0500 Subject: [PATCH 5/6] tooling: enforce release context checks --- .project-kit/release.just | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.project-kit/release.just b/.project-kit/release.just index 7412de0..f7e02d4 100644 --- a/.project-kit/release.just +++ b/.project-kit/release.just @@ -3,17 +3,22 @@ # Last refreshed: 2026-08-04 release LEVEL="patch": + @just context @uv run .project-kit/scripts/release.py cut {{LEVEL}} release-dry LEVEL="patch": + @just context @uv run .project-kit/scripts/release.py cut {{LEVEL}} --dry-run changelog: + @just context @uvx git-cliff@latest --config .project-kit/cliff.toml --output CHANGELOG.md notes TAG: + @just context @uv run .project-kit/scripts/release.py notes --tag {{TAG}} # Print local latest tag + deployed prod version (resolved per prod_source). version: + @just context @uv run .project-kit/scripts/release.py version From e52cc94b17dc1c1d78719a2c02bfcb30fd2cf66e Mon Sep 17 00:00:00 2001 From: claude <6687499+pike00@users.noreply.github.com> Date: Tue, 4 Aug 2026 04:56:07 -0500 Subject: [PATCH 6/6] docs: clarify release context workflow --- README.md | 4 +++- justfile | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 53aca58..dde7e1e 100644 --- a/README.md +++ b/README.md @@ -302,8 +302,10 @@ The walker checks only the **last segment** of the path, not parent segments. `a ## Release ```bash -just context +just context # mandatory before previewing a release just release-dry patch # show the next version and planned actions without writes + +just context # mandatory before cutting a release just release patch # patch | minor | major ``` diff --git a/justfile b/justfile index 703d178..4093078 100644 --- a/justfile +++ b/justfile @@ -25,7 +25,7 @@ fmt: uv run black . # Cut a release. Build and PyPI publish happen automatically via GitHub Actions -# OIDC after the tag push (release.yml). Alias: `just tag LEVEL`. +# OIDC after the tag push (release.yml). ship level: @just release {{level}}