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..581ca95 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,14 +67,15 @@ }, "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}", "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, @@ -78,7 +84,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..f7e02d4 100644 --- a/.project-kit/release.just +++ b/.project-kit/release.just @@ -1,19 +1,24 @@ # .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": + @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 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..d372a64 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,37 +12,179 @@ 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 ( + BaseModel, + Field, + HttpUrl, + SecretStr, + ValidationError, + 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_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.""" + + 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_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.""" + 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 self._homelab_env_text(env_path).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) -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 _github_release_url(version: str) -> str: + return _run(["gh", "release", "view", version, "--json", "url", "-q", ".url"]).stdout.strip() def _current_branch() -> str: @@ -72,40 +215,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: +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 = 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" + 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" 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 +275,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()) + notes = _draft_notes(version, _latest_tag(), "HEAD") 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"]) @@ -168,42 +305,61 @@ 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() 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 - ) + 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 env_path.is_file(): - 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 not exists: + 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( + 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: - 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)", + err=True, + ) + raise typer.Exit(code=1) @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 - content = _draft_notes(tag, prev) + prev = ( + _run( + ["git", "describe", "--tags", "--abbrev=0", f"{tag}^", "--match", "v*"], + ).stdout.strip() + or None + ) + content = _draft_notes(tag, prev, tag) 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 diff --git a/README.md b/README.md index 2a0d526..dde7e1e 100644 --- a/README.md +++ b/README.md @@ -302,25 +302,31 @@ The walker checks only the **last segment** of the path, not parent segments. `a ## Release ```bash +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 ``` -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 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}}