From 4f50bb676388ddd275d6c92fb6ab4814cac1a18d Mon Sep 17 00:00:00 2001 From: Jason Robert Date: Tue, 3 Mar 2026 18:49:35 -0500 Subject: [PATCH 1/5] docs: add release management brainstorm and solution design Add planning documents for the auto-update system: - release-management.brainstorm.md: initial design notes covering tag-triggered releases, update checks, and conductor update command - release-management.plan.md: detailed solution design with architecture, data flow, security considerations, and phased implementation plan --- .../releases/release-management.brainstorm.md | 129 +++++ .../releases/release-management.plan.md | 508 ++++++++++++++++++ 2 files changed, 637 insertions(+) create mode 100644 docs/projects/releases/release-management.brainstorm.md create mode 100644 docs/projects/releases/release-management.plan.md diff --git a/docs/projects/releases/release-management.brainstorm.md b/docs/projects/releases/release-management.brainstorm.md new file mode 100644 index 0000000..f1cf16e --- /dev/null +++ b/docs/projects/releases/release-management.brainstorm.md @@ -0,0 +1,129 @@ +# Plan: Auto-Update System for Conductor + +## Context + +Conductor is distributed as a `uv tool` installed from GitHub (`uv tool install git+https://github.com/microsoft/conductor.git`). There is currently **no mechanism** to check for updates, notify users, or upgrade. The version is hardcoded at `0.1.0` with no git tags or GitHub releases. Users who installed once will never know when improvements land. + +**Goal:** Add a complete update lifecycle: +1. Tag-triggered GitHub Release workflow (à la Octane) +2. Proactive update-check on every CLI run (cached 24h) +3. `conductor update` CLI command +4. Skill awareness for Claude Code users + +--- + +## Part 1: Tag-Triggered Release Workflow + +**New file: `.github/workflows/release.yml`** + +Triggered on push of tags matching `v*` (e.g., `v0.2.0`). Based on the Octane pattern: + +```yaml +on: + push: + tags: ['v*'] +``` + +The workflow will: +1. Checkout code, set up Python + uv +2. Run tests and lint (gate the release) +3. Extract version from tag (`${GITHUB_REF#refs/tags/v}`) +4. Build the package (`uv build`) +5. Generate release notes from git log since previous tag +6. Create a GitHub Release with `gh release create` + the built artifacts +7. Support prerelease tags (`v0.2.0-beta.1` → `--prerelease` flag) + +**Release process for maintainers:** +```bash +# 1. Bump version in __init__.py and pyproject.toml +# 2. Commit and push +git tag v0.2.0 +git push origin v0.2.0 +# GitHub Actions creates the release automatically +``` + +--- + +## Part 2: Update Check System + +**New file: `src/conductor/cli/update.py`** + +### Functions + +| Function | Description | +|----------|-------------| +| `get_cache_path()` | Returns `~/.conductor/update-check.json` | +| `read_cache()` | Read cached version info, `None` if missing or older than 24h | +| `write_cache(version, url)` | Write latest version + timestamp to cache | +| `fetch_latest_version()` | `GET api.github.com/repos/microsoft/conductor/releases/latest` via `urllib.request`, 2s timeout. Returns `(version, url)` or `None` | +| `is_newer(remote, local)` | Simple semver comparison (split on `.`, compare tuples) | +| `check_for_update_hint(console)` | Called from `main()` callback. Reads cache, fetches if stale, prints one-line hint if outdated | +| `detect_install_method()` | Parse `uv tool list` to see if installed from git or other source | +| `run_update(console)` | Execute upgrade: `uv tool install --force git+https://github.com/microsoft/conductor.git`, show before/after versions, clear cache | + +### Behavior + +**On every CLI run** (in the `@app.callback` `main()` function): +- Only if stderr is a TTY and not `--silent` mode +- Read cache → if fresh + version matches local → nothing +- Read cache → if fresh + version is newer → print hint: + ``` + 💡 Conductor v0.3.0 available (you have v0.1.0). Run `conductor update` to upgrade. + ``` +- Cache stale/missing → fetch from GitHub API (2s timeout, fail silently) → cache → maybe print hint + +**`conductor update` command:** +- Shows current version +- Fetches latest from GitHub API +- If already up to date, says so and exits +- If update available, runs `uv tool install --force git+https://github.com/microsoft/conductor.git` +- Shows before/after version +- Clears update cache + +--- + +## Part 3: Skill + Docs Updates + +Update skill and documentation to reflect the new command: +- `.claude/skills/conductor/SKILL.md` — add `conductor update` to Quick Reference +- `.claude/skills/conductor/references/execution.md` — add `conductor update` section +- `AGENTS.md` — add `update` to common commands + +--- + +## Files to Create/Modify + +| File | Action | Description | +|------|--------|-------------| +| `.github/workflows/release.yml` | **Create** | Tag-triggered release workflow +| `src/conductor/cli/update.py` | **Create** | Update check + self-update logic | +| `src/conductor/cli/app.py` | **Modify** | Add `update` command + call `check_for_update_hint()` in `main()` | +| `.claude/skills/conductor/SKILL.md` | **Modify** | Add `conductor update` to Quick Reference | +| `.claude/skills/conductor/references/execution.md` | **Modify** | Add `conductor update` docs section | +| `AGENTS.md` | **Modify** | Add update command to common commands | +| `tests/test_cli/test_update.py` | **Create** | Tests for cache logic, version comparison, hint display, update command | + +### Key design decisions +- **No new dependencies** — uses `urllib.request` (stdlib) for HTTP, simple tuple comparison for semver +- **Cache at `~/.conductor/update-check.json`** — not inside the project +- **2s network timeout** — never block the user's workflow +- **TTY-only hints** — no noise in piped/scripted usage +- **`--silent` suppresses hints** — respects the existing verbosity system + +--- + +## Verification + +1. **`make test`** — all existing tests pass +2. **`make check`** — lint + typecheck pass +3. **New tests** in `tests/test_cli/test_update.py`: + - Cache read/write/expiry + - Version comparison (`is_newer`) + - Hint display with mocked fetch + - `conductor update` with mocked subprocess +4. **Manual testing:** + - `conductor update` — runs the upgrade flow + - Write a fake stale cache → run any command → see hint + - `conductor --silent run ...` → no hint + - Pipe output → no hint +5. **Release workflow:** Push a `v0.1.0` tag to a fork to verify the workflow runs diff --git a/docs/projects/releases/release-management.plan.md b/docs/projects/releases/release-management.plan.md new file mode 100644 index 0000000..5784970 --- /dev/null +++ b/docs/projects/releases/release-management.plan.md @@ -0,0 +1,508 @@ +# Solution Design: Auto-Update System for Conductor + +| Field | Value | +|-------|-------| +| **Status** | Draft | +| **Author** | Copilot | +| **Revision** | 2 — Address technical review feedback | +| **Source** | `docs/projects/releases/release-management.brainstorm.md` | + +--- + +## Executive Summary + +This design introduces a complete auto-update lifecycle for Conductor: (1) a tag-triggered GitHub Actions workflow that builds and publishes GitHub Releases on `v*` tag pushes, (2) a lightweight update-check system that queries GitHub's releases API on every CLI invocation (cached 24 hours, 2-second timeout, TTY-only hints, zero new dependencies), and (3) a `conductor update` command that self-upgrades by pinning to the detected release tag via `uv tool install --force git+...@v{version}`. Together these ensure users are notified of new versions non-intrusively and can upgrade with a single command, while maintainers get a one-step release process backed by CI quality gates. + +--- + +## Background + +### Current State + +- Conductor is distributed as a `uv` tool installed from GitHub: `uv tool install git+https://github.com/microsoft/conductor.git`. +- The version is hardcoded as `__version__ = "0.1.0"` in `src/conductor/__init__.py` and mirrored in `pyproject.toml`. +- There are no git tags, no GitHub Releases, and no mechanism to notify users of updates. +- The existing CI workflow (`.github/workflows/ci.yml`) runs lint, typecheck, tests, and build on push/PR to `main`. +- The CLI already uses `~/.conductor/` for runtime state (PID files in `~/.conductor/runs/`), establishing a precedent for user-level state. +- The CLI has a `--silent` flag and outputs progress to stderr via a Rich `Console(stderr=True)`. + +### Why Now + +The project is approaching its first meaningful release cycle. Without update notifications, early adopters will silently fall behind, creating support burden and fragmented bug reports. Adding this now — before the user base grows — means the mechanism is in place for every future release. + +--- + +## Problem Statement + +1. **No release process**: There is no automated way to build, test, and publish a release. Maintainers must manually create GitHub Releases. +2. **No update awareness**: Users have no way to know when a new version is available. The only option is to manually check the repository. +3. **No upgrade command**: Even if a user discovers a new version, they must remember the full `uv tool install --force git+...` incantation. + +--- + +## Goals and Non-Goals + +### Goals + +1. **Automated releases**: Pushing a `v*` tag triggers a CI workflow that runs quality gates and creates a GitHub Release with build artifacts. +2. **Passive update notification**: Every CLI invocation checks for updates (cached, non-blocking, TTY-only) and prints a one-line hint when a newer version exists. +3. **One-command upgrade**: `conductor update` fetches the latest version and self-upgrades. +4. **Zero new dependencies**: All network and comparison logic uses Python stdlib (`urllib.request`, tuple comparison). +5. **Non-intrusive**: Update checks never block the CLI (2s timeout), never print in piped/scripted usage, and respect `--silent`. + +### Non-Goals + +- **PyPI publishing**: Not in scope. Distribution remains via GitHub. +- **Auto-update without user action**: The system hints; the user must run `conductor update`. +- **Changelog generation tooling**: Release notes are generated from `git log` in the workflow; no separate changelog tool is added. +- **Pre-release channel management**: Pre-release tags are supported (marked as `--prerelease` in the GitHub Release) but there is no opt-in/opt-out mechanism for pre-release notifications. +- **Windows-specific testing**: The workflow and update system target Unix-like systems; Windows is best-effort. + +--- + +## Requirements + +### Functional Requirements + +| ID | Requirement | +|----|-------------| +| FR-1 | A GitHub Actions workflow triggers on `v*` tag push and creates a GitHub Release. | +| FR-2 | The workflow runs tests, lint, and build as quality gates before creating the release. | +| FR-3 | Pre-release tags (e.g., `v0.2.0-beta.1`) produce a pre-release GitHub Release. | +| FR-4 | Release notes are auto-generated from commit history since the previous tag. | +| FR-5 | Build artifacts (`.whl`, `.tar.gz`) are attached to the release. | +| FR-6 | `check_for_update_hint()` is called on every CLI invocation in the `@app.callback`. | +| FR-7 | The update check result is cached at `~/.conductor/update-check.json` for 24 hours. | +| FR-8 | Update hints are only displayed when stderr is a TTY and verbosity is not `SILENT`. | +| FR-9 | `is_newer(remote, local)` performs semver-aware comparison using tuple logic. | +| FR-10 | `conductor update` runs `uv tool install --force git+https://github.com/microsoft/conductor.git@v{version}` pinned to the detected release tag, shows before/after versions, and clears the cache. | + +### Non-Functional Requirements + +| ID | Requirement | +|----|-------------| +| NFR-1 | The GitHub API fetch has a 2-second timeout and fails silently on any error. | +| NFR-2 | No new runtime dependencies are added. | +| NFR-3 | Update check adds < 50ms overhead when cache is fresh. | +| NFR-4 | All new code passes `make lint`, `make typecheck`, and `make test`. | + +--- + +## Proposed Design + +### Architecture Overview + +``` +┌───────────────────────────────────────────────────────┐ +│ GitHub Actions │ +│ ┌─────────────────────────────────────────────────┐ │ +│ │ release.yml (on push tags: v*) │ │ +│ │ lint → typecheck → test → build → gh release │ │ +│ └─────────────────────────────────────────────────┘ │ +└───────────────────────────────────────────────────────┘ + +┌───────────────────────────────────────────────────────┐ +│ CLI (app.py) │ +│ │ +│ @app.callback main() │ +│ └── check_for_update_hint(console) │ +│ (skipped when subcommand is 'update') │ +│ │ +│ @app.command update │ +│ └── run_update(console) │ +└───────────────────────────────────────────────────────┘ + +┌───────────────────────────────────────────────────────┐ +│ update.py (new module) │ +│ │ +│ ┌─────────────┐ ┌──────────────────┐ │ +│ │ Cache Layer │ │ GitHub API │ │ +│ │ read_cache() │ │ fetch_latest() │ │ +│ │ write_cache()│ │ urllib.request │ │ +│ │ get_cache_ │ │ 2s timeout │ │ +│ │ path() │ └──────────────────┘ │ +│ └─────────────┘ │ +│ │ +│ ┌──────────────┐ ┌──────────────────┐ │ +│ │ Comparison │ │ Update Action │ │ +│ │ is_newer() │ │ run_update() │ │ +│ │ parse_version │ │ subprocess.run │ │ +│ │ has_prerelease│ │ uv tool install │ │ +│ └──────────────┘ │ @v{tag_name} │ │ +│ └──────────────────┘ │ +│ │ +│ check_for_update_hint(console) │ +│ → read_cache or fetch → compare → print hint │ +└───────────────────────────────────────────────────────┘ + +┌───────────────────────────────────────────────────────┐ +│ ~/.conductor/update-check.json │ +│ { │ +│ "latest_version": "0.3.0", │ +│ "tag_name": "v0.3.0", │ +│ "release_url": "https://github.com/...", │ +│ "checked_at": "2026-03-03T22:00:00Z" │ +│ } │ +└───────────────────────────────────────────────────────┘ +``` + +### Key Components + +#### 1. GitHub Release Workflow (`.github/workflows/release.yml`) + +**Responsibilities:** +- Trigger on `v*` tag pushes +- Run full CI quality gates (lint, typecheck, test) +- Extract version from the git tag +- Build the package with `uv build` +- Generate release notes from git log (commits since previous tag) +- Create a GitHub Release via `gh release create`, attaching build artifacts +- Mark pre-release tags appropriately + +**Key design decisions:** +- Reuses the same Python/uv setup pattern as `ci.yml` for consistency. +- Uses `gh release create` (GitHub CLI) rather than the `actions/create-release` action, which is archived. `gh` is pre-installed on GitHub-hosted runners. +- Release notes are generated with `gh release create --generate-notes` which uses GitHub's built-in release notes generation. +- Pre-release detection uses a simple pattern match: if the tag contains `-` after the version (e.g., `v0.2.0-beta.1`), it's a pre-release. + +#### 2. Update Check Module (`src/conductor/cli/update.py`) + +**Responsibilities:** +- Cache management (read/write/expiry at `~/.conductor/update-check.json`) +- GitHub API fetching (latest release version) +- Version comparison (semver tuple comparison) +- Hint display (one-line Rich-formatted message) +- Update execution (subprocess calling `uv tool install --force` pinned to release tag) + +**Functions:** + +| Function | Signature | Description | +|----------|-----------|-------------| +| `get_cache_path()` | `() -> Path` | Returns `~/.conductor/update-check.json` | +| `read_cache()` | `() -> dict | None` | Read cache, return `None` if missing/expired (>24h) | +| `write_cache(version, tag_name, url)` | `(str, str, str) -> None` | Write version, tag_name, and timestamp to cache | +| `fetch_latest_version()` | `() -> tuple[str, str, str] | None` | GET GitHub API, 2s timeout, returns `(version, tag_name, url)` or `None` on any error | +| `parse_version(version_str)` | `(str) -> tuple[int, ...]` | Parse `"0.2.0"` → `(0, 2, 0)`, strips leading `v` and pre-release suffix | +| `has_prerelease(version_str)` | `(str) -> bool` | Returns `True` if version contains a pre-release suffix (e.g., `-beta.1`) | +| `is_newer(remote, local)` | `(str, str) -> bool` | Compare version strings via parsed tuples; also returns `True` when tuples are equal but local has a pre-release suffix and remote does not | +| `check_for_update_hint(console)` | `(Console) -> None` | Main entry: cache-or-fetch → compare → print hint | +| `run_update(console)` | `(Console) -> None` | Fetch latest, compare, run `uv tool install --force git+...@{tag_name}`, show result, clear cache | + +**Cache format (`~/.conductor/update-check.json`):** + +```json +{ + "latest_version": "0.3.0", + "tag_name": "v0.3.0", + "release_url": "https://github.com/microsoft/conductor/releases/tag/v0.3.0", + "checked_at": "2026-03-03T22:00:00+00:00" +} +``` + +**Cache expiry:** 24 hours based on `checked_at` timestamp. + +#### 3. CLI Integration (`src/conductor/cli/app.py`) + +**Changes:** +- Import and call `check_for_update_hint(console)` at the end of the `main()` callback, guarded by: `console.is_terminal and console_verbosity.get() != ConsoleVerbosity.SILENT`. Additionally, skip the check when the invoked subcommand is `update` (detected via `sys.argv`) to avoid showing "Update available!" immediately before updating. +- Register a new `update` command that calls `run_update(console)`. + +### Data Flow + +#### Update Hint Flow (every CLI invocation) + +``` +main() callback + ├── Set verbosity (existing) + └── check_for_update_hint(console) + ├── Guard: is stderr a TTY? Is verbosity != SILENT? → skip if no + ├── Guard: is subcommand 'update'? → skip if yes + ├── read_cache() + │ ├── File missing → None + │ ├── JSON invalid → None + │ └── checked_at > 24h ago → None + │ └── Valid → {latest_version, tag_name, release_url, checked_at} + ├── If cache is None → fetch_latest_version() + │ ├── GET https://api.github.com/repos/microsoft/conductor/releases/latest + │ │ Headers: Accept: application/vnd.github.v3+json + │ │ Timeout: 2s + │ ├── Parse response JSON → tag_name, html_url + │ ├── write_cache(version, tag_name, url) + │ └── Return (version, tag_name, url) or None on any error + ├── Compare: is_newer(remote_version, __version__) + │ ├── Parse both to tuples, compare numerically + │ └── If tuples equal: return True if local has prerelease suffix and remote does not + └── If newer → console.print("💡 Conductor vX.Y.Z available ...") +``` + +#### Update Command Flow + +``` +conductor update + ├── Print current version + ├── fetch_latest_version() (always fresh, bypass cache) + │ └── On failure → print error, exit + │ └── Returns (version, tag_name, url) + ├── is_newer(remote, local) + │ └── If not newer → "Already up to date!", exit + ├── Print "Updating to vX.Y.Z..." + ├── subprocess.run(["uv", "tool", "install", "--force", + │ "git+https://github.com/microsoft/conductor.git@{tag_name}"]) + │ └── tag_name is the raw tag from the API (e.g., "v0.3.0") + │ └── On failure → print error, exit 1 + ├── Print "Updated successfully! vOLD → vNEW" + └── Clear cache (delete update-check.json) +``` + +### Design Decisions + +| Decision | Rationale | +|----------|-----------| +| **`urllib.request` over `httpx`/`requests`** | Zero new dependencies. The single GET request is trivial; stdlib is sufficient. | +| **Tuple comparison over `packaging.version`** | Avoids adding `packaging` as a dependency. Conductor uses simple `X.Y.Z` semver; tuple comparison is correct and adequate. Pre-release suffixes (e.g., `-beta.1`) are stripped for the numeric comparison. When tuples are equal but the local version has a pre-release suffix and the remote does not, the remote is treated as newer (pre-release → release upgrade). | +| **Version-pinned install (`@{tag_name}`)** | `run_update()` pins the install to the exact release tag (e.g., `git+...@v0.3.0`) rather than installing from main HEAD. This ensures the installed version matches the detected release, avoiding unreleased commits. The raw `tag_name` from the GitHub API is used directly. | +| **24-hour cache TTL** | Balances freshness with API rate limits. GitHub's unauthenticated rate limit is 60 req/hour; once per 24h is negligible. | +| **2-second network timeout** | Prevents blocking the CLI. If the API is slow or unreachable, the user's workflow is unaffected. | +| **TTY-only + non-silent guard** | Piped/scripted usage (CI, automation) should never see update hints. `--silent` explicitly opts out of all progress output. | +| **Skip update hint for `update` subcommand** | Running `conductor update` should not first print "Update available!" before proceeding to update. The subcommand is detected via `sys.argv` in the `main()` callback. | +| **`gh release create --generate-notes`** | GitHub's built-in release notes generation produces categorized PR-based notes. Avoids custom commit parsing. | +| **Pre-release detection via `-` in tag** | Semver spec: pre-release versions have a hyphen after the patch number. Simple string check is sufficient. | +| **Cache in `~/.conductor/`** | Consistent with existing PID file storage in `~/.conductor/runs/`. User-level, not project-level. | +| **`detect_install_method()` deferred** | The brainstorm document includes install method detection (`uv tool list` parsing). This is deferred from v1: `conductor update` will attempt `uv tool install --force` and print a clear error on failure. If users report issues with non-git installs, the detection can be added as a fast follow. | + +--- + +## Dependencies + +### External Dependencies + +| Dependency | Type | Notes | +|------------|------|-------| +| GitHub API (`api.github.com`) | Service | Unauthenticated; 60 req/hour rate limit; only used once per 24h | +| `gh` CLI | CI tool | Pre-installed on GitHub-hosted runners; used in release workflow | +| `uv` | CLI tool | Required for `conductor update`; already a prerequisite for installation | + +### Internal Dependencies + +| Component | Dependency | +|-----------|-----------| +| `update.py` | `conductor.__version__` for local version | +| `update.py` | `~/.conductor/` directory (created by `get_cache_path()`) | +| `app.py` changes | `update.py` functions | +| Release workflow | Existing CI checks (lint, typecheck, test, build) | + +### Sequencing Constraints + +- The release workflow (Epic 1) can be developed independently. +- The update module (Epic 2) can be developed independently. +- CLI integration (Epic 3) depends on Epic 2. +- Docs/skill updates (Epic 4) depend on Epics 2-3. +- Tests (within each epic) are developed alongside implementation. + +--- + +## Impact Analysis + +### Components Affected + +| Component | Impact | +|-----------|--------| +| `src/conductor/cli/app.py` | Add `update` command + `check_for_update_hint()` call in `main()` callback | +| `src/conductor/cli/` | New `update.py` module | +| `.github/workflows/` | New `release.yml` workflow | +| `AGENTS.md` | Add `conductor update` to common commands | +| `.claude/skills/conductor/SKILL.md` | Add `conductor update` to Quick Reference | +| `.claude/skills/conductor/references/execution.md` | Add `conductor update` section | +| `tests/test_cli/` | New `test_update.py` | + +### Backward Compatibility + +- **Fully backward compatible.** No existing behavior changes. +- The update hint is additive (only appears on TTY, non-silent). +- The `update` command is a new subcommand; no existing commands are affected. +- The release workflow triggers only on tag push; it doesn't affect existing CI. + +### Performance Implications + +- **Cache hit path**: ~10-20ms (file read + JSON parse + timestamp comparison). Negligible. +- **Cache miss path**: Up to 2s network request, but this happens at most once per 24 hours per machine. +- **No impact on workflow execution**: The check runs in the `main()` callback before any subcommand, and it's synchronous but fast. + +--- + +## Security Considerations + +- **No authentication tokens stored.** The GitHub API request is unauthenticated. +- **No code execution from remote.** The update command runs a fixed `uv tool install` command with a hardcoded repository URL. It does not download and execute arbitrary code. +- **Cache file permissions.** The cache file is written to `~/.conductor/` with default user permissions. No sensitive data is stored (only version string and URL). +- **HTTPS only.** All GitHub API requests use HTTPS. + +--- + +## Risks and Mitigations + +| Risk | Likelihood | Impact | Mitigation | +|------|-----------|--------|------------| +| GitHub API rate limiting (60 req/hour unauthenticated) | Low | Low | 24h cache ensures at most 1 request/day/machine | +| GitHub API unavailable | Low | Low | 2s timeout + silent failure; CLI works normally | +| `uv` not available on PATH during `conductor update` | Low | Medium | If user installed Conductor, `uv` is available. Print clear error if not. | +| Cache file corruption | Low | Low | Treat invalid JSON same as missing cache; re-fetch | +| Version comparison edge cases (pre-release, non-semver) | Low | Low | `parse_version` strips pre-release suffixes; `has_prerelease` handles pre→release upgrades; non-parseable versions return `(0,)` | +| Non-git install breaks `conductor update` | Low | Medium | `uv tool install --force git+...` may fail if user installed via pip/other. Print clear error message. `detect_install_method()` deferred to v2 if reports emerge. | + +--- + +## Open Questions + +| # | Question | Status | +|---|----------|--------| +| 1 | Should `conductor update` support installing a specific version (e.g., `conductor update v0.2.0`)? | **Deferred** — not in initial scope; can be added later. | +| 2 | Should pre-release versions trigger update hints for stable users? | **No** — only the `/releases/latest` endpoint is queried, which excludes pre-releases by default. | +| 3 | Should there be a `--no-update-check` flag or environment variable to disable hints globally? | **Deferred** — `--silent` covers the immediate need. Can add `CONDUCTOR_NO_UPDATE_CHECK=1` later if requested. | +| 4 | Should `detect_install_method()` be included in v1? | **No** — deferred. The brainstorm document includes `detect_install_method()` to parse `uv tool list` and detect if Conductor was installed via git. For v1, `conductor update` assumes `uv tool install --force git+...` and prints a clear error on failure. If users report issues with non-git installs, this function can be added as a fast follow to warn before attempting the upgrade. | + +--- + +## Implementation Phases + +### Phase 1: Release Workflow +**Exit criteria:** Pushing a `v*` tag to the repository triggers a workflow that runs quality gates and creates a GitHub Release with artifacts. + +### Phase 2: Update Check Module +**Exit criteria:** `update.py` module exists with full cache, fetch, compare, hint, and update logic. All functions are unit-tested. + +### Phase 3: CLI Integration +**Exit criteria:** `conductor update` command works. Update hints appear on TTY, non-silent CLI runs when a newer version is cached/fetched. Existing tests still pass. + +### Phase 4: Documentation & Skill Updates +**Exit criteria:** `AGENTS.md`, skill files, and execution docs reflect the new `conductor update` command. + +--- + +## Files Affected + +### New Files + +| File Path | Purpose | +|-----------|---------| +| `.github/workflows/release.yml` | Tag-triggered release workflow | +| `src/conductor/cli/update.py` | Update check, version comparison, and self-update logic | +| `tests/test_cli/test_update.py` | Tests for all update module functionality | + +### Modified Files + +| File Path | Changes | +|-----------|---------| +| `src/conductor/cli/app.py` | Add `update` command; call `check_for_update_hint()` in `main()` callback | +| `AGENTS.md` | Add `conductor update` to Common Commands section | +| `.claude/skills/conductor/SKILL.md` | Add `conductor update` to Quick Reference | +| `.claude/skills/conductor/references/execution.md` | Add `conductor update` CLI command section | + +### Deleted Files + +| File Path | Reason | +|-----------|--------| +| *(none)* | | + +--- + +## Implementation Plan + +### Epic 1: GitHub Release Workflow + +**Goal:** Create a tag-triggered CI/CD workflow that produces GitHub Releases. + +**Prerequisites:** None. + +| Task ID | Type | Description | Files | Status | +|---------|------|-------------|-------|--------| +| E1-T1 | IMPL | Create `.github/workflows/release.yml` with tag trigger (`v*`), Python + uv setup, lint/typecheck/test jobs, build step, version extraction from tag, pre-release detection, and `gh release create` with `--generate-notes` and artifact upload. | `.github/workflows/release.yml` | TO DO | + +**Acceptance Criteria:** +- [ ] Workflow YAML is valid and follows the patterns established in `ci.yml` +- [ ] Workflow triggers only on `v*` tag pushes (not branches) +- [ ] Quality gates (lint, typecheck, test) run before release creation +- [ ] Pre-release tags produce pre-release GitHub Releases +- [ ] Build artifacts (`.whl`, `.tar.gz`) are attached to the release + +--- + +### Epic 2: Update Check Module + +**Goal:** Implement the core update-check logic with cache, fetch, comparison, hint display, and update execution. + +**Prerequisites:** None (can be developed in parallel with Epic 1). + +| Task ID | Type | Description | Files | Status | +|---------|------|-------------|-------|--------| +| E2-T1 | IMPL | Create `src/conductor/cli/update.py` with: `get_cache_path()` returning `~/.conductor/update-check.json`; `read_cache()` that returns cached data or `None` if missing/expired/invalid; `write_cache(version, tag_name, url)` that writes JSON with `tag_name` and `checked_at` timestamp. | `src/conductor/cli/update.py` | TO DO | +| E2-T2 | IMPL | Add `fetch_latest_version()` using `urllib.request.urlopen` with 2s timeout to GET `api.github.com/repos/microsoft/conductor/releases/latest`, parse JSON response for `tag_name` and `html_url`, strip leading `v` for version, return `(version, tag_name, url)` 3-tuple or `None` on any error. | `src/conductor/cli/update.py` | TO DO | +| E2-T3 | IMPL | Add `parse_version(version_str)` that strips leading `v`, splits on `-` to remove pre-release suffix, splits on `.`, converts to `tuple[int, ...]`. Add `has_prerelease(version_str)` that returns `True` if the version contains a `-` after the numeric portion. Add `is_newer(remote, local)` that compares parsed tuples; additionally, if tuples are equal but local has a pre-release suffix and remote does not, return `True` (pre-release → release upgrade). | `src/conductor/cli/update.py` | TO DO | +| E2-T4 | IMPL | Add `check_for_update_hint(console)` that reads cache (or fetches if stale), compares versions with `is_newer()`, and prints a one-line Rich hint: `💡 Conductor vX.Y.Z available (you have vCURRENT). Run 'conductor update' to upgrade.` | `src/conductor/cli/update.py` | TO DO | +| E2-T5 | IMPL | Add `run_update(console)` that fetches latest version (bypassing cache), compares with local, runs `subprocess.run(["uv", "tool", "install", "--force", "git+https://github.com/microsoft/conductor.git@{tag_name}"])` where `{tag_name}` is the raw tag from the API (e.g., `v0.3.0`), prints before/after versions, and deletes the cache file. | `src/conductor/cli/update.py` | TO DO | +| E2-T6 | TEST | Create `tests/test_cli/test_update.py` with tests for: `get_cache_path()` returns correct path; `read_cache()` returns `None` for missing/expired/invalid files and valid data for fresh cache; `write_cache()` creates valid JSON with `tag_name` field; `parse_version()` handles `"0.1.0"`, `"v0.2.0"`, `"0.3.0-beta.1"`; `has_prerelease()` returns correct results; `is_newer()` for various version pairs including pre-release → release upgrade (e.g., `is_newer("0.3.0", "0.3.0-beta.1")` → `True`). | `tests/test_cli/test_update.py` | TO DO | +| E2-T7 | TEST | Add tests for `fetch_latest_version()` with mocked `urllib.request.urlopen` (success returns 3-tuple, timeout, HTTP error, malformed JSON). Add tests for `check_for_update_hint()` with mocked fetch and cache (fresh cache newer, fresh cache same, stale cache triggers fetch, non-TTY skips, silent mode skips, `update` subcommand skips). | `tests/test_cli/test_update.py` | TO DO | +| E2-T8 | TEST | Add tests for `run_update()` with mocked subprocess (success with version-pinned install, failure, already up to date). Verify the subprocess command includes `@{tag_name}` suffix. Verify cache is cleared on success. Verify before/after version display. | `tests/test_cli/test_update.py` | TO DO | + +**Acceptance Criteria:** +- [ ] All functions in `update.py` have docstrings and type hints +- [ ] Cache read/write/expiry logic works correctly; cache includes `tag_name` field +- [ ] `fetch_latest_version()` handles all error cases silently and returns 3-tuple +- [ ] `is_newer()` correctly compares semver versions including pre-release → release upgrade +- [ ] `has_prerelease()` correctly identifies pre-release version strings +- [ ] `check_for_update_hint()` respects TTY and verbosity guards, skips when subcommand is `update` +- [ ] `run_update()` runs `uv tool install --force git+...@{tag_name}` with version-pinned install and reports results +- [ ] All tests pass; no new dependencies added +- [ ] `make lint` and `make typecheck` pass + +--- + +### Epic 3: CLI Integration + +**Goal:** Wire the update module into the CLI app. + +**Prerequisites:** Epic 2. + +| Task ID | Type | Description | Files | Status | +|---------|------|-------------|-------|--------| +| E3-T1 | IMPL | In `app.py` `main()` callback, add a call to `check_for_update_hint(console)` at the end, guarded by `console.is_terminal` and `console_verbosity.get() != ConsoleVerbosity.SILENT`. Skip when the invoked subcommand is `update` (check `sys.argv`). Use deferred import to avoid startup overhead. | `src/conductor/cli/app.py` | TO DO | +| E3-T2 | IMPL | In `app.py`, add a new `@app.command() def update()` command that imports and calls `run_update(console)`, wrapping errors in `print_error()` and `typer.Exit(code=1)`. | `src/conductor/cli/app.py` | TO DO | +| E3-T3 | TEST | Add CLI-level tests using `CliRunner` to verify: `conductor update` invokes `run_update`; update hint appears in non-silent TTY mode; update hint does not appear in silent mode; update hint does not appear when subcommand is `update`. | `tests/test_cli/test_update.py` | TO DO | + +**Acceptance Criteria:** +- [ ] `conductor update` is a registered command visible in `conductor --help` +- [ ] Update hints appear in TTY, non-silent mode when a newer version is cached +- [ ] Update hints do NOT appear in `--silent` mode or when piped +- [ ] Update hints do NOT appear when the subcommand is `update` +- [ ] All existing tests still pass +- [ ] `make lint` and `make typecheck` pass + +--- + +### Epic 4: Documentation & Skill Updates + +**Goal:** Update all documentation and skill files to reflect the new `conductor update` command. + +**Prerequisites:** Epics 2-3. + +| Task ID | Type | Description | Files | Status | +|---------|------|-------------|-------|--------| +| E4-T1 | IMPL | Add `conductor update` to `AGENTS.md` Common Commands section, after the `conductor stop` entries. | `AGENTS.md` | TO DO | +| E4-T2 | IMPL | Add `conductor update` to `.claude/skills/conductor/SKILL.md` Quick Reference section. | `.claude/skills/conductor/SKILL.md` | TO DO | +| E4-T3 | IMPL | Add a `### conductor update` section to `.claude/skills/conductor/references/execution.md` after the `### conductor stop` section, documenting the command, its behavior, and examples. | `.claude/skills/conductor/references/execution.md` | TO DO | + +**Acceptance Criteria:** +- [ ] `AGENTS.md` lists `conductor update` in Common Commands +- [ ] Skill Quick Reference includes `conductor update` +- [ ] Execution reference documents the `update` command with examples + +--- + +## References + +- [Brainstorm document](./release-management.brainstorm.md) — original design notes +- [GitHub REST API — Latest Release](https://docs.github.com/en/rest/releases/releases#get-the-latest-release) — API endpoint used for version checks +- [`gh release create` docs](https://cli.github.com/manual/gh_release_create) — GitHub CLI release creation +- [Semver spec](https://semver.org/) — versioning standard +- [Existing CI workflow](../../.github/workflows/ci.yml) — pattern reference for the release workflow +- [PID file utilities](../../src/conductor/cli/pid.py) — precedent for `~/.conductor/` usage From c8a690c30eff2bbf072de2bb7d7b08ddbdae99d8 Mon Sep 17 00:00:00 2001 From: Jason Robert Date: Tue, 3 Mar 2026 18:53:46 -0500 Subject: [PATCH 2/5] Epic 1: GitHub Release Workflow - Add .github/workflows/release.yml with tag-triggered release pipeline - Workflow runs lint, typecheck, and test quality gates before release - Pre-release tags (containing hyphen) produce pre-release GitHub Releases - Build artifacts (.whl, .tar.gz) attached via gh release create --generate-notes - Concurrency group cancels in-progress runs for same tag ref - Update Epic 1 status to DONE in plan document Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/release.yml | 158 ++++++++++++++++++ .../releases/release-management.plan.md | 14 +- 2 files changed, 166 insertions(+), 6 deletions(-) create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..6e7d5d2 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,158 @@ +# Release workflow for Conductor +# +# Runs on: +# - Push of v* tags (e.g., v0.1.0, v1.0.0-beta.1) +# +# Jobs: +# - lint: Runs ruff linter and formatter check +# - typecheck: Runs ty (Red Knot) type checker +# - test: Runs pytest on Python 3.12 and 3.13 +# - release: Builds package and creates GitHub Release with artifacts + +name: Release + +on: + push: + tags: + - "v*" + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + PYTHON_VERSION: "3.12" + +permissions: + contents: write + +jobs: + lint: + name: Lint + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v4 + with: + version: "latest" + enable-cache: true + + - name: Set up Python + run: uv python install ${{ env.PYTHON_VERSION }} + + - name: Install dependencies + run: uv sync --group dev + + - name: Run ruff linter + run: uv run ruff check src tests + + - name: Run ruff formatter check + run: uv run ruff format --check src tests + + typecheck: + name: Type Check + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v4 + with: + version: "latest" + enable-cache: true + + - name: Set up Python + run: uv python install ${{ env.PYTHON_VERSION }} + + - name: Install dependencies + run: uv sync --group dev + + - name: Run ty type checker + run: uv run ty check src + + test: + name: Test (Python ${{ matrix.python-version }}) + runs-on: ubuntu-latest + needs: [lint, typecheck] + strategy: + matrix: + python-version: ["3.12", "3.13"] + fail-fast: false + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v4 + with: + version: "latest" + enable-cache: true + + - name: Set up Python ${{ matrix.python-version }} + run: uv python install ${{ matrix.python-version }} + + - name: Install dependencies + run: uv sync --group dev + + - name: Run tests + run: uv run pytest -m "not real_api" + env: + ANTHROPIC_API_KEY: "sk-ant-test-fake-key-for-mocking" + + release: + name: Create Release + runs-on: ubuntu-latest + needs: [test] + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v4 + with: + version: "latest" + enable-cache: true + + - name: Set up Python + run: uv python install ${{ env.PYTHON_VERSION }} + + - name: Extract version from tag + id: version + run: | + VERSION="${GITHUB_REF_NAME#v}" + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + # Detect pre-release: any tag with a hyphen after the version (e.g., v0.2.0-beta.1) + if [[ "$VERSION" == *-* ]]; then + echo "prerelease=true" >> "$GITHUB_OUTPUT" + else + echo "prerelease=false" >> "$GITHUB_OUTPUT" + fi + + - name: Build package + run: uv build + + - name: Verify package contents + run: | + ls -la dist/ + uv run python -m zipfile -l dist/*.whl + + - name: Create GitHub Release + env: + GH_TOKEN: ${{ github.token }} + run: | + PRERELEASE_FLAG="" + if [[ "${{ steps.version.outputs.prerelease }}" == "true" ]]; then + PRERELEASE_FLAG="--prerelease" + fi + gh release create "${{ github.ref_name }}" \ + dist/* \ + --generate-notes \ + $PRERELEASE_FLAG diff --git a/docs/projects/releases/release-management.plan.md b/docs/projects/releases/release-management.plan.md index 5784970..b2fd64e 100644 --- a/docs/projects/releases/release-management.plan.md +++ b/docs/projects/releases/release-management.plan.md @@ -410,20 +410,22 @@ conductor update ### Epic 1: GitHub Release Workflow +**Status:** DONE + **Goal:** Create a tag-triggered CI/CD workflow that produces GitHub Releases. **Prerequisites:** None. | Task ID | Type | Description | Files | Status | |---------|------|-------------|-------|--------| -| E1-T1 | IMPL | Create `.github/workflows/release.yml` with tag trigger (`v*`), Python + uv setup, lint/typecheck/test jobs, build step, version extraction from tag, pre-release detection, and `gh release create` with `--generate-notes` and artifact upload. | `.github/workflows/release.yml` | TO DO | +| E1-T1 | IMPL | Create `.github/workflows/release.yml` with tag trigger (`v*`), Python + uv setup, lint/typecheck/test jobs, build step, version extraction from tag, pre-release detection, and `gh release create` with `--generate-notes` and artifact upload. | `.github/workflows/release.yml` | DONE | **Acceptance Criteria:** -- [ ] Workflow YAML is valid and follows the patterns established in `ci.yml` -- [ ] Workflow triggers only on `v*` tag pushes (not branches) -- [ ] Quality gates (lint, typecheck, test) run before release creation -- [ ] Pre-release tags produce pre-release GitHub Releases -- [ ] Build artifacts (`.whl`, `.tar.gz`) are attached to the release +- [x] Workflow YAML is valid and follows the patterns established in `ci.yml` +- [x] Workflow triggers only on `v*` tag pushes (not branches) +- [x] Quality gates (lint, typecheck, test) run before release creation +- [x] Pre-release tags produce pre-release GitHub Releases +- [x] Build artifacts (`.whl`, `.tar.gz`) are attached to the release --- From e3ce7bc855319868474649ed83f05510381ac83b Mon Sep 17 00:00:00 2001 From: Jason Robert Date: Tue, 3 Mar 2026 19:00:59 -0500 Subject: [PATCH 3/5] Epic 2: Implement update check module - Add src/conductor/cli/update.py with full update-check logic: get_cache_path, read_cache, write_cache (24-hour TTL), fetch_latest_version (GitHub releases API, 2s timeout), parse_version, has_prerelease, is_newer (semver + pre-release), check_for_update_hint (TTY/silent/subcommand guards), run_update (uv tool install --force git+...@{tag_name}) - Add 46 comprehensive tests in tests/test_cli/test_update.py - Update plan document: Epic 2 status DONE, acceptance criteria checked Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../releases/release-management.plan.md | 36 +- src/conductor/cli/update.py | 314 +++++++++++ tests/test_cli/test_update.py | 504 ++++++++++++++++++ 3 files changed, 837 insertions(+), 17 deletions(-) create mode 100644 src/conductor/cli/update.py create mode 100644 tests/test_cli/test_update.py diff --git a/docs/projects/releases/release-management.plan.md b/docs/projects/releases/release-management.plan.md index b2fd64e..24b53fd 100644 --- a/docs/projects/releases/release-management.plan.md +++ b/docs/projects/releases/release-management.plan.md @@ -431,31 +431,33 @@ conductor update ### Epic 2: Update Check Module +**Status:** DONE + **Goal:** Implement the core update-check logic with cache, fetch, comparison, hint display, and update execution. **Prerequisites:** None (can be developed in parallel with Epic 1). | Task ID | Type | Description | Files | Status | |---------|------|-------------|-------|--------| -| E2-T1 | IMPL | Create `src/conductor/cli/update.py` with: `get_cache_path()` returning `~/.conductor/update-check.json`; `read_cache()` that returns cached data or `None` if missing/expired/invalid; `write_cache(version, tag_name, url)` that writes JSON with `tag_name` and `checked_at` timestamp. | `src/conductor/cli/update.py` | TO DO | -| E2-T2 | IMPL | Add `fetch_latest_version()` using `urllib.request.urlopen` with 2s timeout to GET `api.github.com/repos/microsoft/conductor/releases/latest`, parse JSON response for `tag_name` and `html_url`, strip leading `v` for version, return `(version, tag_name, url)` 3-tuple or `None` on any error. | `src/conductor/cli/update.py` | TO DO | -| E2-T3 | IMPL | Add `parse_version(version_str)` that strips leading `v`, splits on `-` to remove pre-release suffix, splits on `.`, converts to `tuple[int, ...]`. Add `has_prerelease(version_str)` that returns `True` if the version contains a `-` after the numeric portion. Add `is_newer(remote, local)` that compares parsed tuples; additionally, if tuples are equal but local has a pre-release suffix and remote does not, return `True` (pre-release → release upgrade). | `src/conductor/cli/update.py` | TO DO | -| E2-T4 | IMPL | Add `check_for_update_hint(console)` that reads cache (or fetches if stale), compares versions with `is_newer()`, and prints a one-line Rich hint: `💡 Conductor vX.Y.Z available (you have vCURRENT). Run 'conductor update' to upgrade.` | `src/conductor/cli/update.py` | TO DO | -| E2-T5 | IMPL | Add `run_update(console)` that fetches latest version (bypassing cache), compares with local, runs `subprocess.run(["uv", "tool", "install", "--force", "git+https://github.com/microsoft/conductor.git@{tag_name}"])` where `{tag_name}` is the raw tag from the API (e.g., `v0.3.0`), prints before/after versions, and deletes the cache file. | `src/conductor/cli/update.py` | TO DO | -| E2-T6 | TEST | Create `tests/test_cli/test_update.py` with tests for: `get_cache_path()` returns correct path; `read_cache()` returns `None` for missing/expired/invalid files and valid data for fresh cache; `write_cache()` creates valid JSON with `tag_name` field; `parse_version()` handles `"0.1.0"`, `"v0.2.0"`, `"0.3.0-beta.1"`; `has_prerelease()` returns correct results; `is_newer()` for various version pairs including pre-release → release upgrade (e.g., `is_newer("0.3.0", "0.3.0-beta.1")` → `True`). | `tests/test_cli/test_update.py` | TO DO | -| E2-T7 | TEST | Add tests for `fetch_latest_version()` with mocked `urllib.request.urlopen` (success returns 3-tuple, timeout, HTTP error, malformed JSON). Add tests for `check_for_update_hint()` with mocked fetch and cache (fresh cache newer, fresh cache same, stale cache triggers fetch, non-TTY skips, silent mode skips, `update` subcommand skips). | `tests/test_cli/test_update.py` | TO DO | -| E2-T8 | TEST | Add tests for `run_update()` with mocked subprocess (success with version-pinned install, failure, already up to date). Verify the subprocess command includes `@{tag_name}` suffix. Verify cache is cleared on success. Verify before/after version display. | `tests/test_cli/test_update.py` | TO DO | +| E2-T1 | IMPL | Create `src/conductor/cli/update.py` with: `get_cache_path()` returning `~/.conductor/update-check.json`; `read_cache()` that returns cached data or `None` if missing/expired/invalid; `write_cache(version, tag_name, url)` that writes JSON with `tag_name` and `checked_at` timestamp. | `src/conductor/cli/update.py` | DONE | +| E2-T2 | IMPL | Add `fetch_latest_version()` using `urllib.request.urlopen` with 2s timeout to GET `api.github.com/repos/microsoft/conductor/releases/latest`, parse JSON response for `tag_name` and `html_url`, strip leading `v` for version, return `(version, tag_name, url)` 3-tuple or `None` on any error. | `src/conductor/cli/update.py` | DONE | +| E2-T3 | IMPL | Add `parse_version(version_str)` that strips leading `v`, splits on `-` to remove pre-release suffix, splits on `.`, converts to `tuple[int, ...]`. Add `has_prerelease(version_str)` that returns `True` if the version contains a `-` after the numeric portion. Add `is_newer(remote, local)` that compares parsed tuples; additionally, if tuples are equal but local has a pre-release suffix and remote does not, return `True` (pre-release → release upgrade). | `src/conductor/cli/update.py` | DONE | +| E2-T4 | IMPL | Add `check_for_update_hint(console)` that reads cache (or fetches if stale), compares versions with `is_newer()`, and prints a one-line Rich hint: `💡 Conductor vX.Y.Z available (you have vCURRENT). Run 'conductor update' to upgrade.` | `src/conductor/cli/update.py` | DONE | +| E2-T5 | IMPL | Add `run_update(console)` that fetches latest version (bypassing cache), compares with local, runs `subprocess.run(["uv", "tool", "install", "--force", "git+https://github.com/microsoft/conductor.git@{tag_name}"])` where `{tag_name}` is the raw tag from the API (e.g., `v0.3.0`), prints before/after versions, and deletes the cache file. | `src/conductor/cli/update.py` | DONE | +| E2-T6 | TEST | Create `tests/test_cli/test_update.py` with tests for: `get_cache_path()` returns correct path; `read_cache()` returns `None` for missing/expired/invalid files and valid data for fresh cache; `write_cache()` creates valid JSON with `tag_name` field; `parse_version()` handles `"0.1.0"`, `"v0.2.0"`, `"0.3.0-beta.1"`; `has_prerelease()` returns correct results; `is_newer()` for various version pairs including pre-release → release upgrade (e.g., `is_newer("0.3.0", "0.3.0-beta.1")` → `True`). | `tests/test_cli/test_update.py` | DONE | +| E2-T7 | TEST | Add tests for `fetch_latest_version()` with mocked `urllib.request.urlopen` (success returns 3-tuple, timeout, HTTP error, malformed JSON). Add tests for `check_for_update_hint()` with mocked fetch and cache (fresh cache newer, fresh cache same, stale cache triggers fetch, non-TTY skips, silent mode skips, `update` subcommand skips). | `tests/test_cli/test_update.py` | DONE | +| E2-T8 | TEST | Add tests for `run_update()` with mocked subprocess (success with version-pinned install, failure, already up to date). Verify the subprocess command includes `@{tag_name}` suffix. Verify cache is cleared on success. Verify before/after version display. | `tests/test_cli/test_update.py` | DONE | **Acceptance Criteria:** -- [ ] All functions in `update.py` have docstrings and type hints -- [ ] Cache read/write/expiry logic works correctly; cache includes `tag_name` field -- [ ] `fetch_latest_version()` handles all error cases silently and returns 3-tuple -- [ ] `is_newer()` correctly compares semver versions including pre-release → release upgrade -- [ ] `has_prerelease()` correctly identifies pre-release version strings -- [ ] `check_for_update_hint()` respects TTY and verbosity guards, skips when subcommand is `update` -- [ ] `run_update()` runs `uv tool install --force git+...@{tag_name}` with version-pinned install and reports results -- [ ] All tests pass; no new dependencies added -- [ ] `make lint` and `make typecheck` pass +- [x] All functions in `update.py` have docstrings and type hints +- [x] Cache read/write/expiry logic works correctly; cache includes `tag_name` field +- [x] `fetch_latest_version()` handles all error cases silently and returns 3-tuple +- [x] `is_newer()` correctly compares semver versions including pre-release → release upgrade +- [x] `has_prerelease()` correctly identifies pre-release version strings +- [x] `check_for_update_hint()` respects TTY and verbosity guards, skips when subcommand is `update` +- [x] `run_update()` runs `uv tool install --force git+...@{tag_name}` with version-pinned install and reports results +- [x] All tests pass; no new dependencies added +- [x] `make lint` and `make typecheck` pass --- diff --git a/src/conductor/cli/update.py b/src/conductor/cli/update.py new file mode 100644 index 0000000..f808dd2 --- /dev/null +++ b/src/conductor/cli/update.py @@ -0,0 +1,314 @@ +"""Update check and self-upgrade utilities for Conductor CLI. + +This module provides: +- Cache-based update checking against the GitHub Releases API +- Semantic version comparison (including pre-release detection) +- A one-line Rich hint when a newer version is available +- A ``run_update()`` function that self-upgrades via ``uv tool install`` + +The cache file lives at ``~/.conductor/update-check.json`` and is refreshed +every 24 hours. Network requests use a 2-second timeout and fail silently +so they never block the CLI. +""" + +from __future__ import annotations + +import json +import logging +import subprocess +import sys +import urllib.error +import urllib.request +from datetime import UTC, datetime +from pathlib import Path + +from rich.console import Console + +from conductor import __version__ + +logger = logging.getLogger(__name__) + +_CACHE_FILE_NAME = "update-check.json" +_CACHE_TTL_SECONDS = 86_400 # 24 hours +_API_URL = "https://api.github.com/repos/microsoft/conductor/releases/latest" +_FETCH_TIMEOUT_SECONDS = 2 +_REPO_GIT_URL = "https://github.com/microsoft/conductor.git" + + +# --------------------------------------------------------------------------- +# Cache helpers +# --------------------------------------------------------------------------- + + +def get_cache_path() -> Path: + """Return the path to the update-check cache file. + + Returns: + ``~/.conductor/update-check.json`` + """ + return Path.home() / ".conductor" / _CACHE_FILE_NAME + + +def read_cache() -> dict | None: + """Read and return cached update-check data, or ``None`` if stale/missing. + + The cache is considered valid when: + - The file exists and contains valid JSON + - It has a ``checked_at`` ISO-8601 timestamp + - The timestamp is less than ``_CACHE_TTL_SECONDS`` old + + Returns: + A dict with ``tag_name``, ``version``, ``url``, and ``checked_at`` keys, + or ``None`` if the cache is missing, expired, or invalid. + """ + cache_path = get_cache_path() + try: + data = json.loads(cache_path.read_text()) + checked_at = datetime.fromisoformat(data["checked_at"]) + age = (datetime.now(UTC) - checked_at).total_seconds() + if age > _CACHE_TTL_SECONDS: + return None + return data + except (OSError, KeyError, ValueError, json.JSONDecodeError): + return None + + +def write_cache(version: str, tag_name: str, url: str) -> None: + """Write update-check data to the cache file. + + Args: + version: The latest version string (without leading ``v``). + tag_name: The raw tag name from the GitHub Release (e.g. ``v0.3.0``). + url: The ``html_url`` of the release page. + """ + cache_path = get_cache_path() + cache_path.parent.mkdir(parents=True, exist_ok=True) + + data = { + "version": version, + "tag_name": tag_name, + "url": url, + "checked_at": datetime.now(UTC).isoformat(), + } + cache_path.write_text(json.dumps(data, indent=2)) + + +# --------------------------------------------------------------------------- +# Version comparison +# --------------------------------------------------------------------------- + + +def parse_version(version_str: str) -> tuple[int, ...]: + """Parse a version string into a comparable tuple of integers. + + Leading ``v`` is stripped and any pre-release suffix (after ``-``) is + removed before splitting on ``.``. + + Args: + version_str: A version like ``"0.1.0"``, ``"v0.2.0"``, or + ``"0.3.0-beta.1"``. + + Returns: + A tuple of integers, e.g. ``(0, 3, 0)``. + """ + v = version_str.lstrip("v") + # Remove pre-release suffix + v = v.split("-", 1)[0] + return tuple(int(part) for part in v.split(".")) + + +def has_prerelease(version_str: str) -> bool: + """Return ``True`` if *version_str* contains a pre-release suffix. + + A pre-release suffix is anything after a ``-`` in the version string + (after stripping a leading ``v``). + + Args: + version_str: A version string such as ``"0.3.0-beta.1"``. + + Returns: + ``True`` if the version has a pre-release component. + """ + v = version_str.lstrip("v") + return "-" in v + + +def is_newer(remote: str, local: str) -> bool: + """Return ``True`` if *remote* is newer than *local*. + + Comparison is based on the numeric portion (via :func:`parse_version`). + If the numeric tuples are equal but *local* has a pre-release suffix + and *remote* does not, *remote* is considered newer (pre-release → + release upgrade). + + Args: + remote: The remote version string. + local: The locally installed version string. + + Returns: + ``True`` if an upgrade is available. + """ + remote_tuple = parse_version(remote) + local_tuple = parse_version(local) + + if remote_tuple > local_tuple: + return True + # Pre-release → release upgrade + return remote_tuple == local_tuple and has_prerelease(local) and not has_prerelease(remote) + + +# --------------------------------------------------------------------------- +# Network fetch +# --------------------------------------------------------------------------- + + +def fetch_latest_version() -> tuple[str, str, str] | None: + """Fetch the latest release from GitHub. + + Sends a GET request to the GitHub Releases API with a 2-second timeout. + Any network or parsing error is caught and ``None`` is returned so the + CLI is never blocked. + + Returns: + A 3-tuple ``(version, tag_name, html_url)`` on success, or ``None`` + on any error. *version* has the leading ``v`` stripped. + """ + try: + req = urllib.request.Request( + _API_URL, + headers={"Accept": "application/vnd.github+json"}, + ) + with urllib.request.urlopen(req, timeout=_FETCH_TIMEOUT_SECONDS) as resp: + data = json.loads(resp.read().decode()) + + tag_name: str = data["tag_name"] + html_url: str = data["html_url"] + version = tag_name.lstrip("v") + return version, tag_name, html_url + except Exception: # noqa: BLE001 + logger.debug("Failed to fetch latest version", exc_info=True) + return None + + +# --------------------------------------------------------------------------- +# Hint display +# --------------------------------------------------------------------------- + + +def check_for_update_hint(console: Console) -> None: + """Print a one-line update hint if a newer version is available. + + The hint is suppressed when: + - stderr is not a TTY + - The CLI is in ``SILENT`` mode + - The invoked subcommand is ``update`` + + Cache is read first; if the cache is stale or missing, a network fetch + is performed and the result is cached. + + Args: + console: The Rich console (stderr) used for output. + """ + # Guard: non-TTY + if not console.is_terminal: + return + + # Guard: silent mode + from conductor.cli.app import ConsoleVerbosity, console_verbosity + + if console_verbosity.get() == ConsoleVerbosity.SILENT: + return + + # Guard: 'update' subcommand + if _is_update_subcommand(): + return + + # Try cache first + cached = read_cache() + if cached is not None: + remote_version = cached.get("version", "") + if is_newer(remote_version, __version__): + _print_hint(console, remote_version) + return + + # Cache miss — fetch from network + result = fetch_latest_version() + if result is None: + return + + version, tag_name, url = result + write_cache(version, tag_name, url) + + if is_newer(version, __version__): + _print_hint(console, version) + + +def _is_update_subcommand() -> bool: + """Return ``True`` if the CLI was invoked with the ``update`` subcommand.""" + args = sys.argv[1:] + # Skip global options to find the subcommand + for arg in args: + if not arg.startswith("-"): + return arg == "update" + return False + + +def _print_hint(console: Console, remote_version: str) -> None: + """Print the update-available hint line. + + Args: + console: Rich console for output. + remote_version: The newer version available. + """ + console.print( + f"💡 Conductor v{remote_version} available " + f"(you have v{__version__}). " + f"Run [bold]'conductor update'[/bold] to upgrade.", + style="yellow", + ) + + +# --------------------------------------------------------------------------- +# Self-upgrade +# --------------------------------------------------------------------------- + + +def run_update(console: Console) -> None: + """Fetch the latest version and self-upgrade via ``uv tool install``. + + This always bypasses the cache and fetches from the network. On success + the cache file is deleted so the next invocation will re-check cleanly. + + Args: + console: Rich console for output. + """ + console.print("[bold]Checking for updates…[/bold]") + + result = fetch_latest_version() + if result is None: + console.print("[bold red]Error:[/bold red] Could not reach GitHub to check for updates.") + return + + version, tag_name, _url = result + current = __version__ + + if not is_newer(version, current): + console.print(f"[green]Already up to date[/green] (v{current}).") + return + + console.print(f"Upgrading Conductor: v{current} → v{version}") + + install_url = f"git+{_REPO_GIT_URL}@{tag_name}" + cmd = ["uv", "tool", "install", "--force", install_url] + + proc = subprocess.run(cmd, capture_output=True, text=True) # noqa: S603 + + if proc.returncode == 0: + console.print(f"[green]Successfully upgraded to v{version}[/green]") + # Clear cache so next run re-checks + cache_path = get_cache_path() + cache_path.unlink(missing_ok=True) + else: + console.print(f"[bold red]Upgrade failed[/bold red] (exit code {proc.returncode})") + if proc.stderr: + console.print(f"[dim]{proc.stderr.strip()}[/dim]") diff --git a/tests/test_cli/test_update.py b/tests/test_cli/test_update.py new file mode 100644 index 0000000..e8ddee9 --- /dev/null +++ b/tests/test_cli/test_update.py @@ -0,0 +1,504 @@ +"""Tests for update check and self-upgrade utilities (``conductor.cli.update``). + +Covers: +- Cache path, read/write, and expiry logic +- Version parsing, pre-release detection, and comparison +- ``fetch_latest_version()`` with mocked network responses +- ``check_for_update_hint()`` with TTY/verbosity/subcommand guards +- ``run_update()`` with mocked subprocess execution +""" + +from __future__ import annotations + +import json +from datetime import UTC, datetime, timedelta +from io import StringIO +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest +from rich.console import Console + +from conductor.cli.update import ( + _CACHE_TTL_SECONDS, + check_for_update_hint, + fetch_latest_version, + get_cache_path, + has_prerelease, + is_newer, + parse_version, + read_cache, + run_update, + write_cache, +) + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture() +def cache_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + """Redirect ``get_cache_path()`` to a temp directory.""" + cache_file = tmp_path / "update-check.json" + monkeypatch.setattr("conductor.cli.update.get_cache_path", lambda: cache_file) + return tmp_path + + +def _make_console(*, is_terminal: bool = True) -> tuple[Console, StringIO]: + """Create a Rich Console writing to a StringIO buffer. + + Returns: + A ``(console, buffer)`` pair. + """ + buf = StringIO() + c = Console(file=buf, force_terminal=is_terminal, no_color=True, highlight=False) + return c, buf + + +# =================================================================== +# E2-T6: Cache, parse_version, has_prerelease, is_newer +# =================================================================== + + +class TestGetCachePath: + """Tests for ``get_cache_path``.""" + + def test_returns_expected_path(self) -> None: + path = get_cache_path() + assert path == Path.home() / ".conductor" / "update-check.json" + + def test_returns_path_object(self) -> None: + assert isinstance(get_cache_path(), Path) + + +class TestReadCache: + """Tests for ``read_cache``.""" + + def test_returns_none_for_missing_file(self, cache_dir: Path) -> None: + assert read_cache() is None + + def test_returns_none_for_invalid_json(self, cache_dir: Path) -> None: + (cache_dir / "update-check.json").write_text("not json{{{") + assert read_cache() is None + + def test_returns_none_for_missing_checked_at(self, cache_dir: Path) -> None: + data = {"version": "0.2.0", "tag_name": "v0.2.0", "url": "https://example.com"} + (cache_dir / "update-check.json").write_text(json.dumps(data)) + assert read_cache() is None + + def test_returns_none_for_expired_cache(self, cache_dir: Path) -> None: + old_time = datetime.now(UTC) - timedelta(seconds=_CACHE_TTL_SECONDS + 100) + data = { + "version": "0.2.0", + "tag_name": "v0.2.0", + "url": "https://example.com", + "checked_at": old_time.isoformat(), + } + (cache_dir / "update-check.json").write_text(json.dumps(data)) + assert read_cache() is None + + def test_returns_data_for_fresh_cache(self, cache_dir: Path) -> None: + now = datetime.now(UTC) + data = { + "version": "0.2.0", + "tag_name": "v0.2.0", + "url": "https://example.com", + "checked_at": now.isoformat(), + } + (cache_dir / "update-check.json").write_text(json.dumps(data)) + result = read_cache() + assert result is not None + assert result["version"] == "0.2.0" + assert result["tag_name"] == "v0.2.0" + + +class TestWriteCache: + """Tests for ``write_cache``.""" + + def test_creates_valid_json(self, cache_dir: Path) -> None: + write_cache("0.3.0", "v0.3.0", "https://github.com/microsoft/conductor/releases/v0.3.0") + cache_file = cache_dir / "update-check.json" + assert cache_file.exists() + data = json.loads(cache_file.read_text()) + assert data["version"] == "0.3.0" + assert data["tag_name"] == "v0.3.0" + assert data["url"] == "https://github.com/microsoft/conductor/releases/v0.3.0" + assert "checked_at" in data + + def test_checked_at_is_iso_format(self, cache_dir: Path) -> None: + write_cache("0.3.0", "v0.3.0", "https://example.com") + data = json.loads((cache_dir / "update-check.json").read_text()) + # Should parse without error + datetime.fromisoformat(data["checked_at"]) + + +class TestParseVersion: + """Tests for ``parse_version``.""" + + def test_simple_version(self) -> None: + assert parse_version("0.1.0") == (0, 1, 0) + + def test_strips_leading_v(self) -> None: + assert parse_version("v0.2.0") == (0, 2, 0) + + def test_strips_prerelease_suffix(self) -> None: + assert parse_version("0.3.0-beta.1") == (0, 3, 0) + + def test_strips_v_and_prerelease(self) -> None: + assert parse_version("v1.0.0-rc.2") == (1, 0, 0) + + def test_two_part_version(self) -> None: + assert parse_version("1.0") == (1, 0) + + def test_four_part_version(self) -> None: + assert parse_version("1.2.3.4") == (1, 2, 3, 4) + + +class TestHasPrerelease: + """Tests for ``has_prerelease``.""" + + def test_stable_version(self) -> None: + assert has_prerelease("0.3.0") is False + + def test_stable_version_with_v(self) -> None: + assert has_prerelease("v0.3.0") is False + + def test_beta_prerelease(self) -> None: + assert has_prerelease("0.3.0-beta.1") is True + + def test_rc_prerelease(self) -> None: + assert has_prerelease("v1.0.0-rc.2") is True + + def test_alpha_prerelease(self) -> None: + assert has_prerelease("0.1.0-alpha") is True + + +class TestIsNewer: + """Tests for ``is_newer``.""" + + def test_newer_patch(self) -> None: + assert is_newer("0.1.1", "0.1.0") is True + + def test_newer_minor(self) -> None: + assert is_newer("0.2.0", "0.1.0") is True + + def test_newer_major(self) -> None: + assert is_newer("1.0.0", "0.9.9") is True + + def test_same_version(self) -> None: + assert is_newer("0.1.0", "0.1.0") is False + + def test_older_version(self) -> None: + assert is_newer("0.1.0", "0.2.0") is False + + def test_prerelease_to_release_upgrade(self) -> None: + # Same numeric version, local is pre-release, remote is stable → upgrade + assert is_newer("0.3.0", "0.3.0-beta.1") is True + + def test_both_prerelease_same_numeric(self) -> None: + # Both are pre-release with same numeric part → not newer + assert is_newer("0.3.0-beta.2", "0.3.0-beta.1") is False + + def test_remote_prerelease_same_numeric(self) -> None: + # Local is stable, remote is pre-release with same numeric → not newer + assert is_newer("0.3.0-beta.1", "0.3.0") is False + + def test_with_v_prefix(self) -> None: + assert is_newer("v0.2.0", "v0.1.0") is True + + def test_mixed_v_prefix(self) -> None: + assert is_newer("0.2.0", "v0.1.0") is True + + +# =================================================================== +# E2-T7: fetch_latest_version, check_for_update_hint +# =================================================================== + + +class TestFetchLatestVersion: + """Tests for ``fetch_latest_version`` with mocked network.""" + + def test_success_returns_3_tuple(self) -> None: + response_data = json.dumps( + { + "tag_name": "v0.5.0", + "html_url": "https://github.com/microsoft/conductor/releases/tag/v0.5.0", + } + ).encode() + + mock_resp = MagicMock() + mock_resp.read.return_value = response_data + mock_resp.__enter__ = lambda s: s + mock_resp.__exit__ = MagicMock(return_value=False) + + with patch("conductor.cli.update.urllib.request.urlopen", return_value=mock_resp): + result = fetch_latest_version() + + assert result is not None + version, tag_name, url = result + assert version == "0.5.0" + assert tag_name == "v0.5.0" + assert "v0.5.0" in url + + def test_timeout_returns_none(self) -> None: + import urllib.error + + with patch( + "conductor.cli.update.urllib.request.urlopen", + side_effect=urllib.error.URLError("timeout"), + ): + assert fetch_latest_version() is None + + def test_http_error_returns_none(self) -> None: + import urllib.error + + with patch( + "conductor.cli.update.urllib.request.urlopen", + side_effect=urllib.error.HTTPError( + url="", + code=404, + msg="Not Found", + hdrs=None, + fp=None, # type: ignore[arg-type] + ), + ): + assert fetch_latest_version() is None + + def test_malformed_json_returns_none(self) -> None: + mock_resp = MagicMock() + mock_resp.read.return_value = b"not json" + mock_resp.__enter__ = lambda s: s + mock_resp.__exit__ = MagicMock(return_value=False) + + with patch("conductor.cli.update.urllib.request.urlopen", return_value=mock_resp): + assert fetch_latest_version() is None + + def test_missing_tag_name_returns_none(self) -> None: + response_data = json.dumps({"html_url": "https://example.com"}).encode() + mock_resp = MagicMock() + mock_resp.read.return_value = response_data + mock_resp.__enter__ = lambda s: s + mock_resp.__exit__ = MagicMock(return_value=False) + + with patch("conductor.cli.update.urllib.request.urlopen", return_value=mock_resp): + assert fetch_latest_version() is None + + +class TestCheckForUpdateHint: + """Tests for ``check_for_update_hint``.""" + + def test_non_tty_skips(self, cache_dir: Path) -> None: + """Non-TTY console should not print anything.""" + c, buf = _make_console(is_terminal=False) + check_for_update_hint(c) + assert buf.getvalue() == "" + + def test_silent_mode_skips(self, cache_dir: Path) -> None: + """Silent verbosity should suppress the hint.""" + from conductor.cli.app import ConsoleVerbosity + + c, buf = _make_console(is_terminal=True) + with patch("conductor.cli.app.console_verbosity") as mock_cv: + mock_cv.get.return_value = ConsoleVerbosity.SILENT + check_for_update_hint(c) + assert buf.getvalue() == "" + + def test_update_subcommand_skips(self, cache_dir: Path) -> None: + """When the subcommand is 'update', skip the hint.""" + c, buf = _make_console(is_terminal=True) + with ( + patch("conductor.cli.update.sys.argv", ["conductor", "update"]), + patch("conductor.cli.app.console_verbosity") as mock_cv, + ): + from conductor.cli.app import ConsoleVerbosity + + mock_cv.get.return_value = ConsoleVerbosity.FULL + check_for_update_hint(c) + assert buf.getvalue() == "" + + def test_fresh_cache_newer_shows_hint(self, cache_dir: Path) -> None: + """Fresh cache with newer version should print the hint.""" + now = datetime.now(UTC) + data = { + "version": "99.0.0", + "tag_name": "v99.0.0", + "url": "https://example.com", + "checked_at": now.isoformat(), + } + (cache_dir / "update-check.json").write_text(json.dumps(data)) + + c, buf = _make_console(is_terminal=True) + with ( + patch("conductor.cli.update.sys.argv", ["conductor", "run", "wf.yaml"]), + patch("conductor.cli.app.console_verbosity") as mock_cv, + ): + from conductor.cli.app import ConsoleVerbosity + + mock_cv.get.return_value = ConsoleVerbosity.FULL + check_for_update_hint(c) + + output = buf.getvalue() + assert "99.0.0" in output + assert "conductor update" in output + + def test_fresh_cache_same_version_no_hint(self, cache_dir: Path) -> None: + """Fresh cache with same version should not print anything.""" + now = datetime.now(UTC) + data = { + "version": __import__("conductor").__version__, + "tag_name": f"v{__import__('conductor').__version__}", + "url": "https://example.com", + "checked_at": now.isoformat(), + } + (cache_dir / "update-check.json").write_text(json.dumps(data)) + + c, buf = _make_console(is_terminal=True) + with ( + patch("conductor.cli.update.sys.argv", ["conductor", "run", "wf.yaml"]), + patch("conductor.cli.app.console_verbosity") as mock_cv, + ): + from conductor.cli.app import ConsoleVerbosity + + mock_cv.get.return_value = ConsoleVerbosity.FULL + check_for_update_hint(c) + + assert buf.getvalue() == "" + + def test_stale_cache_triggers_fetch(self, cache_dir: Path) -> None: + """Expired cache should trigger a network fetch.""" + old_time = datetime.now(UTC) - timedelta(seconds=_CACHE_TTL_SECONDS + 100) + data = { + "version": "0.0.1", + "tag_name": "v0.0.1", + "url": "https://example.com", + "checked_at": old_time.isoformat(), + } + (cache_dir / "update-check.json").write_text(json.dumps(data)) + + c, buf = _make_console(is_terminal=True) + with ( + patch("conductor.cli.update.sys.argv", ["conductor", "run", "wf.yaml"]), + patch("conductor.cli.app.console_verbosity") as mock_cv, + patch( + "conductor.cli.update.fetch_latest_version", + return_value=("99.0.0", "v99.0.0", "https://example.com"), + ) as mock_fetch, + ): + from conductor.cli.app import ConsoleVerbosity + + mock_cv.get.return_value = ConsoleVerbosity.FULL + check_for_update_hint(c) + + mock_fetch.assert_called_once() + output = buf.getvalue() + assert "99.0.0" in output + + +# =================================================================== +# E2-T8: run_update +# =================================================================== + + +class TestRunUpdate: + """Tests for ``run_update`` with mocked subprocess.""" + + def test_successful_upgrade(self, cache_dir: Path) -> None: + """Successful upgrade prints before/after and clears cache.""" + # Pre-populate cache to verify it's deleted + cache_file = cache_dir / "update-check.json" + cache_file.write_text("{}") + + c, buf = _make_console(is_terminal=True) + mock_proc = MagicMock() + mock_proc.returncode = 0 + mock_proc.stderr = "" + + with ( + patch( + "conductor.cli.update.fetch_latest_version", + return_value=("99.0.0", "v99.0.0", "https://example.com"), + ), + patch("conductor.cli.update.subprocess.run", return_value=mock_proc) as mock_run, + ): + run_update(c) + + output = buf.getvalue() + assert "99.0.0" in output + assert "Successfully upgraded" in output + + # Verify subprocess was called with the correct command + args = mock_run.call_args[0][0] + assert args[0] == "uv" + assert "--force" in args + assert any("@v99.0.0" in a for a in args) + + # Cache should be deleted + assert not cache_file.exists() + + def test_already_up_to_date(self, cache_dir: Path) -> None: + """When local == remote, should say 'already up to date'.""" + import conductor + + c, buf = _make_console(is_terminal=True) + with patch( + "conductor.cli.update.fetch_latest_version", + return_value=( + conductor.__version__, + f"v{conductor.__version__}", + "https://example.com", + ), + ): + run_update(c) + + output = buf.getvalue() + assert "Already up to date" in output + + def test_upgrade_failure(self, cache_dir: Path) -> None: + """Failed subprocess should report the error.""" + c, buf = _make_console(is_terminal=True) + mock_proc = MagicMock() + mock_proc.returncode = 1 + mock_proc.stderr = "some error output" + + with ( + patch( + "conductor.cli.update.fetch_latest_version", + return_value=("99.0.0", "v99.0.0", "https://example.com"), + ), + patch("conductor.cli.update.subprocess.run", return_value=mock_proc), + ): + run_update(c) + + output = buf.getvalue() + assert "Upgrade failed" in output + + def test_network_failure(self, cache_dir: Path) -> None: + """When fetch fails, should print an error.""" + c, buf = _make_console(is_terminal=True) + with patch("conductor.cli.update.fetch_latest_version", return_value=None): + run_update(c) + + output = buf.getvalue() + assert "Could not reach GitHub" in output + + def test_command_includes_tag_name(self, cache_dir: Path) -> None: + """The subprocess command must include ``@{tag_name}``.""" + c, buf = _make_console(is_terminal=True) + mock_proc = MagicMock() + mock_proc.returncode = 0 + mock_proc.stderr = "" + + with ( + patch( + "conductor.cli.update.fetch_latest_version", + return_value=("2.0.0", "v2.0.0", "https://example.com"), + ), + patch("conductor.cli.update.subprocess.run", return_value=mock_proc) as mock_run, + ): + run_update(c) + + args = mock_run.call_args[0][0] + install_arg = [a for a in args if a.startswith("git+")] + assert len(install_arg) == 1 + assert install_arg[0].endswith("@v2.0.0") From a9031c9f9dbab143ca777d20a2b0bcbc05003c8e Mon Sep 17 00:00:00 2001 From: Jason Robert Date: Tue, 3 Mar 2026 19:08:46 -0500 Subject: [PATCH 4/5] Epic 3: CLI Integration - wire update module into CLI app - Add check_for_update_hint() call in main() callback guarded by TTY, non-SILENT verbosity, and subcommand != 'update' with deferred import - Add conductor update command (@app.command()) with error handling - Add 7 CLI-level tests: TestUpdateCommand (3) and TestUpdateHintCLI (4) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../releases/release-management.plan.md | 20 +++-- src/conductor/cli/app.py | 32 +++++++ tests/test_cli/test_update.py | 88 +++++++++++++++++++ 3 files changed, 131 insertions(+), 9 deletions(-) diff --git a/docs/projects/releases/release-management.plan.md b/docs/projects/releases/release-management.plan.md index 24b53fd..e5d3622 100644 --- a/docs/projects/releases/release-management.plan.md +++ b/docs/projects/releases/release-management.plan.md @@ -463,23 +463,25 @@ conductor update ### Epic 3: CLI Integration +**Status:** DONE + **Goal:** Wire the update module into the CLI app. **Prerequisites:** Epic 2. | Task ID | Type | Description | Files | Status | |---------|------|-------------|-------|--------| -| E3-T1 | IMPL | In `app.py` `main()` callback, add a call to `check_for_update_hint(console)` at the end, guarded by `console.is_terminal` and `console_verbosity.get() != ConsoleVerbosity.SILENT`. Skip when the invoked subcommand is `update` (check `sys.argv`). Use deferred import to avoid startup overhead. | `src/conductor/cli/app.py` | TO DO | -| E3-T2 | IMPL | In `app.py`, add a new `@app.command() def update()` command that imports and calls `run_update(console)`, wrapping errors in `print_error()` and `typer.Exit(code=1)`. | `src/conductor/cli/app.py` | TO DO | -| E3-T3 | TEST | Add CLI-level tests using `CliRunner` to verify: `conductor update` invokes `run_update`; update hint appears in non-silent TTY mode; update hint does not appear in silent mode; update hint does not appear when subcommand is `update`. | `tests/test_cli/test_update.py` | TO DO | +| E3-T1 | IMPL | In `app.py` `main()` callback, add a call to `check_for_update_hint(console)` at the end, guarded by `console.is_terminal` and `console_verbosity.get() != ConsoleVerbosity.SILENT`. Skip when the invoked subcommand is `update` (check `sys.argv`). Use deferred import to avoid startup overhead. | `src/conductor/cli/app.py` | DONE | +| E3-T2 | IMPL | In `app.py`, add a new `@app.command() def update()` command that imports and calls `run_update(console)`, wrapping errors in `print_error()` and `typer.Exit(code=1)`. | `src/conductor/cli/app.py` | DONE | +| E3-T3 | TEST | Add CLI-level tests using `CliRunner` to verify: `conductor update` invokes `run_update`; update hint appears in non-silent TTY mode; update hint does not appear in silent mode; update hint does not appear when subcommand is `update`. | `tests/test_cli/test_update.py` | DONE | **Acceptance Criteria:** -- [ ] `conductor update` is a registered command visible in `conductor --help` -- [ ] Update hints appear in TTY, non-silent mode when a newer version is cached -- [ ] Update hints do NOT appear in `--silent` mode or when piped -- [ ] Update hints do NOT appear when the subcommand is `update` -- [ ] All existing tests still pass -- [ ] `make lint` and `make typecheck` pass +- [x] `conductor update` is a registered command visible in `conductor --help` +- [x] Update hints appear in TTY, non-silent mode when a newer version is cached +- [x] Update hints do NOT appear in `--silent` mode or when piped +- [x] Update hints do NOT appear when the subcommand is `update` +- [x] All existing tests still pass +- [x] `make lint` and `make typecheck` pass --- diff --git a/src/conductor/cli/app.py b/src/conductor/cli/app.py index a2c5bca..e9db085 100644 --- a/src/conductor/cli/app.py +++ b/src/conductor/cli/app.py @@ -196,6 +196,18 @@ def main( verbose_mode.set(verbosity != ConsoleVerbosity.SILENT) full_mode.set(verbosity == ConsoleVerbosity.FULL) + # Show update hint (deferred import to avoid startup overhead) + if console.is_terminal and verbosity != ConsoleVerbosity.SILENT: + import sys + + # Skip when the subcommand is 'update' + args = sys.argv[1:] + subcommand = next((a for a in args if not a.startswith("-")), None) + if subcommand != "update": + from conductor.cli.update import check_for_update_hint + + check_for_update_hint(console) + @app.command() def run( @@ -820,3 +832,23 @@ def _print_running_list(entries: list[dict], con: Console) -> None: ) con.print(table) + + +@app.command() +def update() -> None: + """Check for and install the latest version of Conductor. + + Fetches the latest release from GitHub and upgrades using + ``uv tool install --force``. + + \b + Examples: + conductor update + """ + from conductor.cli.update import run_update + + try: + run_update(console) + except Exception as e: + print_error(e) + raise typer.Exit(code=1) from None diff --git a/tests/test_cli/test_update.py b/tests/test_cli/test_update.py index e8ddee9..003ef18 100644 --- a/tests/test_cli/test_update.py +++ b/tests/test_cli/test_update.py @@ -18,7 +18,9 @@ import pytest from rich.console import Console +from typer.testing import CliRunner +from conductor.cli.app import app from conductor.cli.update import ( _CACHE_TTL_SECONDS, check_for_update_hint, @@ -502,3 +504,89 @@ def test_command_includes_tag_name(self, cache_dir: Path) -> None: install_arg = [a for a in args if a.startswith("git+")] assert len(install_arg) == 1 assert install_arg[0].endswith("@v2.0.0") + + +# =================================================================== +# E3-T3: CLI-level tests +# =================================================================== + +runner = CliRunner() + + +class TestUpdateCommand: + """CLI tests for ``conductor update``.""" + + def test_update_command_invokes_run_update(self, cache_dir: Path) -> None: + """``conductor update`` should call ``run_update``.""" + with patch("conductor.cli.update.run_update") as mock_run: + result = runner.invoke(app, ["update"]) + assert result.exit_code == 0 + mock_run.assert_called_once() + + def test_update_command_visible_in_help(self) -> None: + """``update`` should appear in ``conductor --help``.""" + result = runner.invoke(app, ["--help"]) + assert result.exit_code == 0 + assert "update" in result.output + + def test_update_command_error_exits_with_code_1(self, cache_dir: Path) -> None: + """Errors during update should exit with code 1.""" + with patch( + "conductor.cli.update.run_update", + side_effect=RuntimeError("boom"), + ): + result = runner.invoke(app, ["update"]) + assert result.exit_code == 1 + + +class TestUpdateHintCLI: + """CLI tests for update hint integration in ``main()`` callback.""" + + def test_hint_appears_in_tty_non_silent(self, cache_dir: Path) -> None: + """Hint is called when console.is_terminal is True and not silent.""" + mock_console = MagicMock(spec=Console) + mock_console.is_terminal = True + with ( + patch("conductor.cli.app.console", mock_console), + patch("conductor.cli.update.check_for_update_hint") as mock_hint, + patch("sys.argv", ["conductor", "validate", "--help"]), + ): + runner.invoke(app, ["validate", "--help"]) + mock_hint.assert_called_once_with(mock_console) + + def test_hint_not_shown_in_silent_mode(self, cache_dir: Path) -> None: + """Hint should NOT appear when ``--silent`` is passed.""" + mock_console = MagicMock(spec=Console) + mock_console.is_terminal = True + with ( + patch("conductor.cli.app.console", mock_console), + patch("conductor.cli.update.check_for_update_hint") as mock_hint, + patch("sys.argv", ["conductor", "--silent", "validate", "--help"]), + ): + runner.invoke(app, ["--silent", "validate", "--help"]) + mock_hint.assert_not_called() + + def test_hint_not_shown_for_update_subcommand(self, cache_dir: Path) -> None: + """Hint should NOT appear when the subcommand is ``update``.""" + mock_console = MagicMock(spec=Console) + mock_console.is_terminal = True + with ( + patch("conductor.cli.app.console", mock_console), + patch("conductor.cli.update.check_for_update_hint") as mock_hint, + patch("sys.argv", ["conductor", "update"]), + patch("conductor.cli.update.run_update"), + ): + runner.invoke(app, ["update"]) + mock_hint.assert_not_called() + + def test_hint_not_shown_when_not_tty(self, cache_dir: Path) -> None: + """Hint should NOT appear when console is not a TTY.""" + mock_console = MagicMock(spec=Console) + mock_console.is_terminal = False + with ( + patch("conductor.cli.app.console", mock_console), + patch("conductor.cli.update.check_for_update_hint") as mock_hint, + patch("sys.argv", ["conductor", "validate", "--help"]), + ): + runner.invoke(app, ["validate", "--help"]) + mock_hint.assert_not_called() From cedec97f026dd74cfd2fb61db0556364b0c7ac3a Mon Sep 17 00:00:00 2001 From: Jason Robert Date: Tue, 3 Mar 2026 19:13:19 -0500 Subject: [PATCH 5/5] Epic 4: Documentation & Skill Updates - Add update.py to AGENTS.md cli/ architecture bullet list - Verify AGENTS.md Common Commands includes conductor update - Verify SKILL.md Quick Reference includes conductor update - Verify execution.md has conductor update reference section Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .claude/skills/conductor/SKILL.md | 1 + .../skills/conductor/references/execution.md | 23 +++++++++++++++++++ AGENTS.md | 6 ++++- .../releases/release-management.plan.md | 16 ++++++++----- 4 files changed, 39 insertions(+), 7 deletions(-) diff --git a/.claude/skills/conductor/SKILL.md b/.claude/skills/conductor/SKILL.md index 4598b3f..1f901c0 100644 --- a/.claude/skills/conductor/SKILL.md +++ b/.claude/skills/conductor/SKILL.md @@ -16,6 +16,7 @@ conductor validate workflow.yaml # Validate only conductor init my-workflow --template simple # Create from template conductor templates # List templates conductor stop # Stop background workflow +conductor update # Check for and install latest version ``` Progress output is shown by default. Use `-V` (verbose) for full prompts and detailed tool call info. diff --git a/.claude/skills/conductor/references/execution.md b/.claude/skills/conductor/references/execution.md index e75269c..032463b 100644 --- a/.claude/skills/conductor/references/execution.md +++ b/.claude/skills/conductor/references/execution.md @@ -93,6 +93,29 @@ conductor stop --port 8080 conductor stop --all ``` +### conductor update + +Check for and install the latest version of Conductor: + +```bash +conductor update +``` + +The command: +1. Fetches the latest release from the GitHub Releases API +2. Compares the remote version with the locally installed version +3. If a newer version is available, runs `uv tool install --force git+https://github.com/microsoft/conductor.git@v{version}` to upgrade +4. Clears the update-check cache on success so the next invocation re-checks cleanly + +If already up to date, prints a confirmation message and exits. + +**Examples:** + +```bash +# Check for updates and install if available +conductor update +``` + ### conductor validate Validate without executing: diff --git a/AGENTS.md b/AGENTS.md index 4fe192c..f5c5eac 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -43,6 +43,9 @@ uv run conductor stop # auto-stop if one running, list if multi uv run conductor stop --port 8080 # stop specific port uv run conductor stop --all # stop all background workflows +# Update conductor +uv run conductor update # check for and install latest version + # Validate a workflow uv run conductor validate examples/simple-qa.yaml make validate-examples # validate all examples @@ -52,11 +55,12 @@ make validate-examples # validate all examples ### Core Package Structure (`src/conductor/`) -- **cli/**: Typer-based CLI with commands `run`, `validate`, `init`, `templates`, `stop` +- **cli/**: Typer-based CLI with commands `run`, `validate`, `init`, `templates`, `stop`, `update` - `app.py` - Main entry point, defines the Typer application - `run.py` - Workflow execution command with verbose logging helpers - `bg_runner.py` - Background process forking for `--web-bg` mode - `pid.py` - PID file utilities for tracking/stopping background processes + - `update.py` - Update check, version comparison, and self-upgrade via `uv tool install` - **config/**: YAML loading and Pydantic schema validation - `schema.py` - Pydantic models for all workflow YAML structures (WorkflowConfig, AgentDef, ParallelGroup, ForEachDef, etc.) diff --git a/docs/projects/releases/release-management.plan.md b/docs/projects/releases/release-management.plan.md index e5d3622..fc3768e 100644 --- a/docs/projects/releases/release-management.plan.md +++ b/docs/projects/releases/release-management.plan.md @@ -487,20 +487,24 @@ conductor update ### Epic 4: Documentation & Skill Updates +**Status:** DONE + **Goal:** Update all documentation and skill files to reflect the new `conductor update` command. **Prerequisites:** Epics 2-3. | Task ID | Type | Description | Files | Status | |---------|------|-------------|-------|--------| -| E4-T1 | IMPL | Add `conductor update` to `AGENTS.md` Common Commands section, after the `conductor stop` entries. | `AGENTS.md` | TO DO | -| E4-T2 | IMPL | Add `conductor update` to `.claude/skills/conductor/SKILL.md` Quick Reference section. | `.claude/skills/conductor/SKILL.md` | TO DO | -| E4-T3 | IMPL | Add a `### conductor update` section to `.claude/skills/conductor/references/execution.md` after the `### conductor stop` section, documenting the command, its behavior, and examples. | `.claude/skills/conductor/references/execution.md` | TO DO | +| E4-T1 | IMPL | Add `conductor update` to `AGENTS.md` Common Commands section, after the `conductor stop` entries. | `AGENTS.md` | DONE | +| E4-T2 | IMPL | Add `conductor update` to `.claude/skills/conductor/SKILL.md` Quick Reference section. | `.claude/skills/conductor/SKILL.md` | DONE | +| E4-T3 | IMPL | Add a `### conductor update` section to `.claude/skills/conductor/references/execution.md` after the `### conductor stop` section, documenting the command, its behavior, and examples. | `.claude/skills/conductor/references/execution.md` | DONE | +| E4-T4 | IMPL | Add `update.py` entry to `AGENTS.md` cli/ architecture bullet list, after the `pid.py` entry. | `AGENTS.md` | DONE | **Acceptance Criteria:** -- [ ] `AGENTS.md` lists `conductor update` in Common Commands -- [ ] Skill Quick Reference includes `conductor update` -- [ ] Execution reference documents the `update` command with examples +- [x] `AGENTS.md` lists `conductor update` in Common Commands +- [x] Skill Quick Reference includes `conductor update` +- [x] Execution reference documents the `update` command with examples +- [x] `AGENTS.md` architecture section lists `update.py` in the cli/ bullet list ---