Untether release-coordination
git clone https://github.com/littlebearapps/untether
T=$(mktemp -d) && git clone --depth=1 https://github.com/littlebearapps/untether "$T" && mkdir -p ~/.claude/skills && cp -r "$T/.claude/skills/release-coordination" ~/.claude/skills/littlebearapps-untether-release-coordination && rm -rf "$T"
.claude/skills/release-coordination/SKILL.mdRelease Coordination
Step-by-step release workflow for Untether. Covers the full lifecycle from issue audit through PyPI publishing and post-release verification.
Key files
| File | Purpose |
|---|---|
| Package version () |
| Release notes with issue links |
| Locked dependency versions |
| Tag-triggered PyPI publish (OIDC trusted publishing, reviewer approval gate) |
| PR/push CI (format, lint, ty, pytest, build, lockfile, audit, bandit, docs, testpypi, release-validation) |
| Weekly pre-release dependency testing (informational) |
| Automated changelog/version validation (runs in CI on version-bump PRs) |
| Post-deploy health check (systemd, version, logs, Bot API) |
| Staging install helper (TestPyPI rc install, rollback, status) |
| git-cliff config for changelog drafting from conventional commits |
| Auto-loaded rule enforcing issue/changelog discipline |
Release workflow phases
1. Issue audit → 2. Version decision → 3. Changelog → 4. Validate → 5. Integration test → 6. Staging → 7. Tag & publish
All seven phases happen in a single branch (typically
master for patches, feature/* for minors). The CI release pipeline triggers on v* tags pushed to master.
Phase 1: Issue audit
Find commits since the last release that lack GitHub issues.
# Find the last release tag LAST_TAG=$(git describe --tags --abbrev=0) # List commits since last tag git log --oneline "$LAST_TAG"..HEAD # List open issues direnv exec . gh issue list --state open # List recently closed issues direnv exec . gh issue list --state closed --limit 20
For each commit without a corresponding issue:
- Create an issue with: title, description, impact, affected files
- Label it:
,bug
, orenhancementdocumentation - If already fixed, close immediately with a comment referencing the commit/PR
direnv exec . gh issue create --title "..." --label "bug" --body "..." direnv exec . gh issue close N --comment "Fixed in <commit-or-PR>"
Phase 2: Version decision
Analyse commits since the last tag and determine the version bump:
| Bump | When | Examples |
|---|---|---|
| Patch (0.23.x) | Bug fixes only, schema additions for new upstream events, dependency updates | macOS credentials fix, rate_limit_event schema |
| Minor (0.x.0) | New features, new commands, new engine support, config additions | command, Pi runner, cost tracking |
| Major (x.0.0) | Breaking changes to config format, runner protocol, or public API | Remove , change TOML schema |
Decision rule: If ANY commit is breaking → major. If ANY commit adds features → minor. Otherwise → patch.
Phase 3: Changelog drafting
Format
## vX.Y.Z (YYYY-MM-DD) ### fixes - description [#N](https://github.com/littlebearapps/untether/issues/N) - implementation detail (no issue link needed on sub-bullets) ### changes - description [#N](https://github.com/littlebearapps/untether/issues/N) ### breaking - description [#N](https://github.com/littlebearapps/untether/issues/N) ### docs - description [#N](https://github.com/littlebearapps/untether/issues/N) ### tests - description [#N](https://github.com/littlebearapps/untether/issues/N)
Rules
- Every entry links to a GitHub issue:
[#N](...) - Sub-bullets for implementation details (no issue link needed)
- Sections appear only when they have entries (omit empty sections)
- Section order:
→fixes
→changes
→breaking
→docstests - One changelog section per release — no retroactive edits to prior sections
- Date is the date of the release tag, not the date of the commit
git-cliff drafting
Use
git-cliff to generate a draft from conventional commits, then hand-edit:
git-cliff --unreleased # draft next release section git-cliff --latest # show latest tag's section
Config in
cliff.toml maps fix: to fixes, feat: to changes, docs: to docs, test: to tests. Skips ci:, deps:, chore:.
Phase 4: Pre-release validation
Run all checks before tagging:
# Tests (all Python versions are tested in CI, but run locally on current) uv run pytest # Lint uv run ruff check src/ # Format check uv run ruff format --check src/ tests/ # Lockfile sync uv lock --check # Verify version matches changelog python3 -c " import tomllib, re with open('pyproject.toml', 'rb') as f: v = tomllib.load(f)['project']['version'] with open('CHANGELOG.md') as f: first_heading = re.search(r'## v([\d.]+)', f.read()).group(1) assert v == first_heading, f'Version mismatch: pyproject.toml={v}, CHANGELOG={first_heading}' print(f'Version {v} matches changelog ✓') "
Checklist
- All related GitHub issues exist
- All issues referenced in CHANGELOG.md with
[#N](...) -
version matches changelog headingpyproject.toml - Tests pass:
uv run pytest - Lint clean:
uv run ruff check src/ - Format clean:
uv run ruff format --check src/ tests/ - Lockfile synced:
uv lock --check - Release validation:
python3 scripts/validate_release.py - No uncommitted changes:
git status
Phase 5: Integration testing (MANDATORY)
NEVER skip this phase. Run the structured integration test suite against
@untether_dev_bot before tagging. See docs/reference/integration-testing.md for the full playbook.
NEVER use
(staging) for initial dev testing. ALWAYS use @hetz_lba1_bot
first. Stage rc versions on @untether_dev_bot
@hetz_lba1_bot only after dev integration tests pass.
# Restart dev bot to pick up latest code systemctl --user restart untether-dev # Tail logs in a separate terminal journalctl --user -u untether-dev -f
Required tiers per release type
| Release type | Required tiers | Time |
|---|---|---|
| Patch | Tier 7 (command smoke) + Tier 1 (affected engine + Claude) + relevant Tier 6 (stress) | ~30 min |
| Minor | Tier 7 + Tier 1 (all 6 engines) + Tier 2 (Claude interactive) + relevant Tier 3-4 + Tier 6 + upgrade path | ~75 min |
| Major | ALL tiers (1-7), ALL engines, full upgrade path testing | ~120 min |
What to focus on per change type
| Changed area | Must-run integration tests |
|---|---|
Runner code () | U1-U4, U6, U7 (all engines) |
Telegram transport () | T1-T10, S7, S8 |
Control channel () | C1-C6, T8, S9 |
Config/settings () | O1-O9, S5, upgrade path |
Cost tracking () | B1-B3, U8 |
Progress/formatting () | U3, T6, T7, S4, S8 |
Commands () | Tier 7 (all) + specific command test |
Automated testing via Telegram MCP
All integration test tiers are fully automated by Claude Code via Telegram MCP tools and Bash. Claude Code sends test prompts to the 6
ut-dev: engine chats, reads back responses, verifies expected behaviour, checks logs, and creates GitHub issues for any bugs found.
MCP tools used:
send_message, get_history, get_messages, list_inline_buttons, press_inline_button, reply_to_message
Test chat IDs:
| Chat | Chat ID |
|---|---|
| 5284581592 |
| 4929463515 |
| 5200822877 |
| 5156256333 |
| 5207762142 |
| 5230875989 |
Workflow pattern:
— send test prompt or command to engine chatsend_message- Wait for bot response (sleep 10-30s depending on engine)
/get_history
— read back response, verify contentget_messages
→list_inline_buttons
for interactive testspress_inline_button
for resume/session continuation testsreply_to_message
All tiers are fully automatable. Voice tests use
send_voice, file/media tests use send_file, SIGTERM uses Bash kill -TERM, log inspection uses Bash journalctl.
Post-test: Check dev bot logs via Bash for warnings/errors. Track each test as pass/fail/error with reason. Distinguish Untether bugs from engine API errors. Create GitHub issues for any Untether bugs found.
Checklist
- Dev bot restarted:
systemctl --user restart untether-dev - Required tiers executed via
per release type@untether_dev_bot - No warnings/errors in logs:
journalctl --user -u untether-dev --since "1 hour ago" | grep -E "WARNING|ERROR" - Upgrade path tested (minor/major): old config parses, state files survive restart
Phase 6: Staging (recommended for minor+, optional for patches)
Before tagging a final release, publish a release candidate to TestPyPI and dogfood it on
@hetz_lba1_bot for ~1 week.
Enter staging
# Bump to rc version (no changelog entry needed) # Edit pyproject.toml: version = "X.Y.Zrc1" uv lock git add pyproject.toml uv.lock git commit -m "chore: staging X.Y.Zrc1" git push origin master # Wait for CI to publish to TestPyPI, then install scripts/staging.sh install X.Y.Zrc1 systemctl --user restart untether scripts/healthcheck.sh --version X.Y.Zrc1
During staging
- Dogfood with all chat routes on
for ~1 week@hetz_lba1_bot - The issue watcher catches bugs automatically (monitors the same service)
- If bugs found: fix → bump to
→ push → installX.Y.Zrc2
Promote to release
When staging is stable, proceed to Phase 7 (Tag and publish) with the final version.
Rollback
scripts/staging.sh rollback # Reverts to last stable PyPI version systemctl --user restart untether
Conventions
- rc versions are NOT git-tagged (avoids triggering
)release.yml - rc versions do NOT require changelog entries (
skips them)validate_release.py - Commit message:
chore: staging X.Y.ZrcN
Phase 7: Merge to master (single-gate release)
The release flow uses a single approval gate: the master PR review IS the release approval. Once Nathan squash-merges a PR with a stable version (e.g.
0.35.2, no rc/a/b/dev suffix) to master, everything else is automatic.
Claude Code's role
- Prepare the version bump on a feature branch (
,pyproject.toml
,CHANGELOG.md
)uv.lock - Open a PR from
→dev
with a release summarymaster - Wait for CI to go green
- Hand off to Nathan with a one-line instruction: "merge PR #N when ready"
Nathan's role
- Review the PR on GitHub
- Squash-merge to master in the GitHub UI
That's it. No tag creation, no PyPI environment approval. The git tag and PyPI publish happen automatically:
fires on the master push, readsauto-tag-on-master.yml
, and pushes apyproject.toml
tag (skips pre-release versions likevX.Y.Z
)0.35.2rc1
fires on the tag push:release.yml- Validates the tag matches
versionpyproject.toml - Runs the full pytest suite (Python 3.12/3.13/3.14)
- Builds wheel + sdist via
, validates withuv build
andtwine checkcheck-wheel-contents - Publishes to PyPI via OIDC trusted publishing (no manual approval — the PR merge was the approval)
- Creates a GitHub Release with auto-generated notes and uploads the dist artifacts
- Validates the tag matches
Why the single gate is safe
The defenses that the legacy
pypi environment reviewer was providing are already covered upstream:
- Branch protection on master: only Nathan can merge via PR
runs in CI on version-bump PRs (changelog format, issue links, date)validate_release.py- All CI checks must pass before the PR can merge
re-validates tag-vs-version matchrelease.yml- PyPI trusted publishing via OIDC (no static API token to leak)
- The release-guard hooks block Claude Code from creating tags or pushing master directly
Manual override (rare)
If
auto-tag-on-master.yml fails or you need to tag manually:
git checkout master && git pull git tag vX.Y.Z git push origin vX.Y.Z # triggers release.yml directly
Both Claude Code and Nathan can do this. The release-guard hook blocks Claude Code from
git tag v*, so this path is Nathan-only by design.
Post-release verification
# Check CI release workflow direnv exec . gh run list --workflow=release.yml --limit=3 # Verify PyPI (wait ~60s for index propagation) pip index versions untether # Verify GitHub Release exists direnv exec . gh release view vX.Y.Z # Install and test locally pipx upgrade untether untether --version # Health check (after service restart) scripts/healthcheck.sh --version X.Y.Z scripts/healthcheck.sh --dev --version X.Y.Z
Rollback procedures
Failed CI (tag pushed, PyPI publish failed)
# Delete the tag locally and remotely git tag -d vX.Y.Z git push origin :refs/tags/vX.Y.Z # Fix the issue, re-tag git tag vX.Y.Z git push origin master --tags
Bad release (already on PyPI)
# Yank the release (hides from default install, but still accessible by version) # Requires PyPI API token with yank permissions pip install twine twine yank untether X.Y.Z # Cut a patch release with the fix # Bump to vX.Y.(Z+1), fix the issue, follow the full release workflow
Never re-upload the same version to PyPI — PyPI rejects duplicate version numbers even after yanking.
Revert commit on master
git revert <commit-sha> git push origin master # Then cut a new patch release
Common failure modes
| Failure | Cause | Fix |
|---|---|---|
fails at version check | Tag doesn't match version | Delete tag, fix version, re-tag |
fails at pytest | Tests pass locally but fail in CI | Check Python version matrix (3.12/3.13/3.14), platform differences |
fails | changed without running | Run and commit |
| Changelog missing issue links | Issue not created before release | Create issue retroactively, amend changelog in next release |
| PyPI publish fails with 403 | Trusted publisher not configured for this repo | Check PyPI project settings → Publishing → Trusted Publishers |
| GitHub Release not created | Workflow missing step | Check workflow file, ensure runs |
Untether-specific considerations
- macOS vs Linux: Some features are platform-specific (e.g., Keychain credential storage). Test on both when possible.
- Engine compatibility: Version bumps may coincide with upstream CLI changes (Claude Code, Codex). Note upstream version requirements in changelog.
- Schema forward-compatibility: Use
on msgspec structs so new upstream JSONL fields don't break existing releases.forbid_unknown_fields=False - Entry points: New engines require
entry point registration —pyproject.toml
must follow.uv lock