Skip to content

Commit 57982a2

Browse files
authored
Merge pull request #30 from nickroci/fix/version-from-metadata
fix(version): derive version from metadata; re-lock on release (fixes red main)
2 parents 2530e04 + 2fc0249 commit 57982a2

4 files changed

Lines changed: 48 additions & 8 deletions

File tree

.github/workflows/release.yml

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,11 @@ jobs:
2727
steps:
2828
- uses: actions/checkout@v4
2929

30+
- uses: astral-sh/setup-uv@v6
31+
with:
32+
enable-cache: true
33+
cache-dependency-glob: uv.lock
34+
3035
- name: Validate version
3136
run: |
3237
printf '%s' "${{ inputs.version }}" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$' \
@@ -38,6 +43,9 @@ jobs:
3843
- name: Verify coherence
3944
run: bash scripts/check-version-coherence.sh
4045

46+
- name: Verify the lockfile (the meaningful CI check for a version-only bump)
47+
run: uv sync --locked
48+
4149
- name: Open (or update) the release PR
4250
env:
4351
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -65,14 +73,15 @@ jobs:
6573
set -euo pipefail
6674
# A PR opened by GITHUB_TOKEN does not trigger ci.yml (GitHub's loop
6775
# prevention), so the required `ci-success` check would never report and
68-
# would block the merge. This diff is version/spec strings ONLY and its
69-
# coherence is verified by the step above, so post a passing `ci-success`
70-
# status on the release commit directly. Branch protection matches the
71-
# context name, so this satisfies the gate.
76+
# would block the merge. This diff is version/spec/lock strings ONLY, and
77+
# the steps above verified both coherence AND `uv sync --locked` — the
78+
# only checks that can change for a version bump — so post a passing
79+
# `ci-success` status on the release commit directly. Branch protection
80+
# matches the context name, so this satisfies the gate.
7281
# Safe ONLY because nothing but set-version.sh ever touches a release/*
7382
# branch — never hand-push code there.
7483
SHA="$(git rev-parse HEAD)"
7584
gh api -X POST "repos/${{ github.repository }}/statuses/$SHA" \
7685
-f state=success \
7786
-f context=ci-success \
78-
-f description="version-only release bump; coherence verified by release workflow"
87+
-f description="version-only release bump; coherence + lockfile verified by release workflow"

scripts/set-version.sh

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010
# - "version" in .claude-plugin/plugin.json
1111
# - the git ref of the `git+https://github.com/nickroci/ultan@<ref>` install
1212
# spec in plugin.json AND scripts/ensure-ultan.sh -> @v<version>
13+
# - uv.lock (re-locked so `uv sync --locked` stays valid — the member version
14+
# bump otherwise leaves the lock stale and reddens CI on main)
1315
#
1416
# It does NOT commit, tag, or push. The caller does that, so the version edits
1517
# always land in the commit the tag will point at (the plugin.json install spec
@@ -46,4 +48,14 @@ V="$VERSION" perl -0pi -e 's/("version":\s*")[^"]*(")/$1$ENV{V}$2/' "$ROOT/.clau
4648
TAG="$TAG" perl -0pi -e 's{(nickroci/ultan)\@[A-Za-z0-9._/-]+}{$1\@$ENV{TAG}}g' \
4749
"$ROOT/.claude-plugin/plugin.json" "$ROOT/scripts/ensure-ultan.sh"
4850

51+
# Re-lock: the workspace-member version bumps make uv.lock stale, so
52+
# `uv sync --locked` (CI) would fail on main after the release merges. uv lock
53+
# only rewrites the version strings here (no dependency change), and resolves
54+
# from cache in milliseconds. Guarded so a uv-less local run still does the edits.
55+
if command -v uv >/dev/null 2>&1; then
56+
( cd "$ROOT" && uv lock )
57+
else
58+
echo "warning: uv not found — skipped 'uv lock'; run it before committing or CI will fail" >&2
59+
fi
60+
4961
echo "set version -> $VERSION (install spec ref -> $TAG)"

ultan/__init__.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,22 @@
11
"""Ultan — thin CLI wrapper package (entry-level install)."""
22

3-
__version__ = "0.2.0"
3+
4+
def __getattr__(name: str) -> str:
5+
# Single source of truth for the version: the installed package metadata
6+
# (derived from pyproject at build time), so it can never drift from the
7+
# released dist the way a hardcoded literal did. Computed lazily via PEP 562
8+
# so `import ultan` — and the per-turn `ultan hook` hot path — never pay the
9+
# importlib.metadata lookup; only `ultan --version` / `ultan doctor` touch it.
10+
if name == "__version__":
11+
from importlib.metadata import ( # noqa: PLC0415 — lazy by design
12+
PackageNotFoundError,
13+
version,
14+
)
15+
16+
try:
17+
return version("ultan")
18+
except PackageNotFoundError:
19+
# A source tree that was never installed as a dist (rare; the tool is
20+
# always `uv tool install`ed in practice).
21+
return "0+unknown"
22+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")

ultan/__main__.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,6 @@
1515
import argparse
1616
import sys
1717

18-
from . import __version__
19-
2018

2119
def main() -> int:
2220
ap = argparse.ArgumentParser(prog="ultan", description="Ultan — local memory for Claude Code.")
@@ -48,6 +46,8 @@ def main() -> int:
4846
args = ap.parse_args()
4947

5048
if args.version:
49+
from . import __version__ # lazy: keeps the metadata lookup off the hook path
50+
5151
print(f"ultan {__version__}")
5252
return 0
5353
if args.cmd == "doctor":

0 commit comments

Comments
 (0)