From 8fc00c251105852c4d1798c119f0891ac9e2b286 Mon Sep 17 00:00:00 2001 From: Oleg Date: Mon, 11 May 2026 16:28:19 -0700 Subject: [PATCH] =?UTF-8?q?chore(channels):=20drop=20beta=20channel=20?= =?UTF-8?q?=E2=80=94=20trunk-based=20only=20(#450)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 finale of the trunk-based migration. The beta channel and branch are retired; production now follows the latest GitHub Release tag on main. api.py: - /admin/channel: GET always returns {channel:stable, branch:main}; POST rejects anything except "stable" with 400. Legacy PINKYBOT_CHANNEL=beta env is logged + silently coerced to stable. - /admin/update: rejects branch="beta" (or any non-main value) with 400. Empty branch defaults to main. Always resolves to latest release tag (use_release_tags is unconditionally True now). .github/workflows/release.yml: NEW — workflow_dispatch with version input, creates annotated tag + draft release. Validates calver YY.MM.NNN format and that it runs from main. Auto-generates release notes from commits since prior tag. CI: ci.yml, codeql.yml, docker-publish.yml — drop "beta" from branch filters (no more beta branch to test). docker-publish.yml: tag pattern `26.*` already matches our calver tags, so :latest publishing on releases needs no change. CLAUDE.md: update Deploy section — single channel + release-workflow doc. Tests: TestTrunkBasedChannel class covers rejection of beta/arbitrary branches, default-to-main, legacy env coercion, and channel endpoints. All 28 admin-update tests pass. 🤖 Opened by Barsik Co-Authored-By: Claude Opus 4.7 --- .github/workflows/ci.yml | 4 +- .github/workflows/codeql.yml | 4 +- .github/workflows/docker-publish.yml | 2 +- .github/workflows/release.yml | 116 +++++++++++++++++++++++++++ CLAUDE.md | 3 +- src/pinky_daemon/api.py | 75 ++++++++++++----- tests/test_admin_update.py | 106 ++++++++++++++++++++---- 7 files changed, 267 insertions(+), 43 deletions(-) create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c0cc16b1..018c3d94 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,9 +2,9 @@ name: CI on: push: - branches: [main, beta] + branches: [main] pull_request: - branches: [main, beta] + branches: [main] concurrency: group: ci-${{ github.ref }} diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 8a86d2ef..ba460ced 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -2,9 +2,9 @@ name: CodeQL on: push: - branches: [main, beta] + branches: [main] pull_request: - branches: [main, beta] + branches: [main] schedule: # Weekly — Monday 08:00 UTC (Monday 01:00 PDT / 00:00 PST) - cron: "0 8 * * 1" diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index f26762b8..0a644bb7 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -5,7 +5,7 @@ on: branches: [main] tags: ["26.*"] pull_request: - branches: [main, beta] + branches: [main] paths: - "Dockerfile" - ".dockerignore" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..42854823 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,116 @@ +name: Release + +# Cut a tagged release. Trunk-based since #450 — production hosts pull from +# the latest tag, not main HEAD. Manual on purpose: every prod-bound release +# is an explicit decision (no auto-promote on green main). +# +# Usage: GitHub Actions → "Release" → "Run workflow" → pick branch (main only) +# + version (e.g. 26.05.071 — calver YY.MM.NNN, sequential per month). +# +# The Docker workflow (docker-publish.yml) is already wired to tags matching +# `26.*` and tags this as :latest — production update_and_restart then picks +# up the new tag on the next call. + +on: + workflow_dispatch: + inputs: + version: + description: 'Release version tag (e.g. 26.05.071 — calver YY.MM.NNN, no v prefix)' + required: true + type: string + release_notes: + description: 'Custom release notes (optional — auto-generated from commits if empty)' + required: false + type: string + +permissions: + contents: write # create tags + releases + +jobs: + validate: + name: Validate version + runs-on: ubuntu-latest + outputs: + version: ${{ steps.check.outputs.version }} + steps: + - name: Check version format + branch + id: check + run: | + version="${{ inputs.version }}" + if [[ ! "$version" =~ ^[0-9]{2}\.[0-9]{2}\.[0-9]{2,3}$ ]]; then + echo "::error::Version must be calver YY.MM.NNN (e.g. 26.05.071), got: $version" + exit 1 + fi + if [[ "${{ github.ref }}" != "refs/heads/main" ]]; then + echo "::error::Releases can only be cut from main (got: ${{ github.ref }})" + exit 1 + fi + echo "version=$version" >> "$GITHUB_OUTPUT" + + release: + name: Tag + publish release + needs: validate + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 # full history so git log can find prior tag + + - name: Configure git identity + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + - name: Verify tag doesn't exist + run: | + if git rev-parse "${{ needs.validate.outputs.version }}" >/dev/null 2>&1; then + echo "::error::Tag ${{ needs.validate.outputs.version }} already exists" + exit 1 + fi + + - name: Generate release notes + id: notes + run: | + version="${{ needs.validate.outputs.version }}" + custom_notes="${{ inputs.release_notes }}" + prior_tag=$(git describe --tags --abbrev=0 2>/dev/null || echo "") + + { + echo "## Release $version" + echo + if [ -n "$custom_notes" ]; then + echo "$custom_notes" + echo + fi + echo "### Changes" + if [ -n "$prior_tag" ]; then + git log "$prior_tag..HEAD" --pretty='- %s (%h)' --no-merges + else + echo "- Initial tagged release" + fi + echo + echo "### Deploy" + echo "Production hosts running \`PINKYBOT_CHANNEL=stable\` (the only channel) will pull this tag on the next \`update_and_restart\`." + } > /tmp/notes.md + + # Multiline output via heredoc + { + echo 'body<> "$GITHUB_OUTPUT" + + - name: Create annotated tag + run: | + version="${{ needs.validate.outputs.version }}" + git tag -a "$version" -m "Release $version" + git push origin "$version" + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ needs.validate.outputs.version }} + name: ${{ needs.validate.outputs.version }} + body: ${{ steps.notes.outputs.body }} + draft: false + prerelease: false diff --git a/CLAUDE.md b/CLAUDE.md index 405f4ea2..0fd910a5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -47,6 +47,7 @@ pytest ## Deploy - Production on Mac Mini (`oleg@10.0.0.209`) -- Self-update: agents call `update_and_restart()` (pinky-self MCP) — pulls the branch matching `PINKYBOT_CHANNEL` env (`stable`→`main`, `beta`→`beta`) and restarts the daemon +- Self-update: agents call `update_and_restart()` (pinky-self MCP) — pulls the latest GitHub Release tag from `main` and restarts the daemon. Trunk-based since #450; only `PINKYBOT_CHANNEL=stable` is supported. +- Releases are cut manually via the `Release` workflow (Actions → Release → Run workflow → version `YY.MM.NNN`). Production picks up new tags on the next `update_and_restart()`. - CI auto-runs on push (`ci.yml`); `main` is branch-protected and requires passing CI + PR review - Agent data in `data/agents/{name}/` (gitignored) diff --git a/src/pinky_daemon/api.py b/src/pinky_daemon/api.py index fa6d3a91..e0b684c2 100644 --- a/src/pinky_daemon/api.py +++ b/src/pinky_daemon/api.py @@ -7183,23 +7183,44 @@ async def get_shared_mcp_status(): } # ── Admin: Release Channel ──────────────────────────── + # + # Trunk-based since 26.05.070 (#450). The only channel is "stable" — + # production hosts pull the latest GitHub Release tag from main. + # PINKYBOT_CHANNEL is retained as an env var for back-compat, but the + # only accepted value is "stable"; anything else is logged and coerced. @app.get("/admin/channel") async def get_release_channel(): - """Return the current release channel.""" - channel = os.environ.get("PINKYBOT_CHANNEL", "stable") - branch = "beta" if channel == "beta" else "main" - return {"channel": channel, "branch": branch} + """Return the current release channel. + + Always reports "stable" / "main" — there is no other channel. + Legacy PINKYBOT_CHANNEL=beta is silently coerced to stable. + """ + raw = os.environ.get("PINKYBOT_CHANNEL", "stable") + if raw != "stable": + _log( + f"admin: PINKYBOT_CHANNEL={raw!r} is deprecated; " + "trunk-based since #450 — treating as 'stable'" + ) + return {"channel": "stable", "branch": "main"} @app.post("/admin/channel") async def set_release_channel(channel: str = "stable"): - """Switch release channel (stable or beta). + """Set the release channel — only "stable" is accepted. + + Trunk-based since #450: the beta channel was removed. This endpoint + is preserved so legacy clients/UIs don't 404, but anything other + than "stable" returns 400. Persists to .env file and updates the running env var. - Does NOT restart — call /admin/update after to pull the new branch. + Does NOT restart — call /admin/update after to refresh. """ - if channel not in ("stable", "beta"): - raise HTTPException(400, "Channel must be 'stable' or 'beta'") + if channel != "stable": + raise HTTPException( + 400, + "Only 'stable' is supported. The beta channel was removed in " + "the trunk-based migration (#450).", + ) os.environ["PINKYBOT_CHANNEL"] = channel @@ -7218,9 +7239,8 @@ async def set_release_channel(channel: str = "stable"): lines.append(f"PINKYBOT_CHANNEL={channel}") env_path.write_text("\n".join(lines) + "\n") - branch = "beta" if channel == "beta" else "main" - _log(f"admin: release channel set to {channel} (branch={branch})") - return {"channel": channel, "branch": branch} + _log(f"admin: release channel set to {channel} (branch=main)") + return {"channel": "stable", "branch": "main"} # ── Admin: Update & Restart ─────────────────────────── @@ -7236,10 +7256,11 @@ async def admin_update( The process manager (launchctl/systemd) must be installed for auto-restart. Without it, the daemon will stop and stay stopped. - Stable channel: updates to the latest GitHub Release tag. - Beta channel: pulls HEAD of the beta branch. - Branch defaults to PINKYBOT_CHANNEL env var ("stable" -> release tags, - "beta" -> beta branch), falling back to "stable" if unset. + Trunk-based since #450: production always updates to the latest + GitHub Release tag on main. The branch arg is preserved for + compatibility but only "main" (or empty, which defaults to main) + is accepted. branch="beta" returns 400 — the beta channel was + removed in the trunk-based migration. force=True discards local modifications to TRACKED files (`git checkout -- .`) before pulling, to recover from a dirty working @@ -7255,11 +7276,23 @@ async def admin_update( import subprocess as sp import sys - if not branch: - channel = os.environ.get("PINKYBOT_CHANNEL", "stable") - branch = "beta" if channel == "beta" else "main" + if branch and branch != "main": + raise HTTPException( + 400, + f"Only branch='main' is supported (got {branch!r}). The beta " + "channel was removed in the trunk-based migration (#450).", + ) + + # Legacy env back-compat: PINKYBOT_CHANNEL=beta → log + treat as stable. + raw_channel = os.environ.get("PINKYBOT_CHANNEL", "stable") + if raw_channel != "stable": + _log( + f"admin: PINKYBOT_CHANNEL={raw_channel!r} is deprecated; " + "trunk-based since #450 — treating as 'stable'" + ) - use_release_tags = branch == "main" + branch = "main" + use_release_tags = True repo_dir = str(Path(__file__).resolve().parent.parent.parent) # Current state @@ -7289,7 +7322,7 @@ async def admin_update( except sp.CalledProcessError as e: return {"error": f"git fetch failed: {e.output.decode()[:500]}"} - # Resolve target — latest release tag for stable, HEAD for beta + # Resolve target — always latest release tag on main (trunk-based). target_tag = None if use_release_tags: try: @@ -7341,7 +7374,7 @@ async def admin_update( result["latest_release"] = target_tag return result - # Update — always pull branch HEAD (stable=main, beta=beta). + # Update — always pull main HEAD; release tags are resolved separately. # Ensure we're on the correct branch first (shallow clones may be in detached HEAD). try: current_branch = sp.check_output( diff --git a/tests/test_admin_update.py b/tests/test_admin_update.py index 99e26a47..7530dc2b 100644 --- a/tests/test_admin_update.py +++ b/tests/test_admin_update.py @@ -30,7 +30,7 @@ def __init__( dirty_files: list[str] | None = None, before_hash: str = "abc1234", after_hash: str = "def5678", - branch: str = "beta", + branch: str = "main", ): self.calls: list[list[str]] = [] self.dirty_files = dirty_files or [] @@ -119,7 +119,7 @@ def test_force_false_is_default(self): patch("os.kill"), ): client = _make_client() - r = client.post("/admin/update?branch=beta") + r = client.post("/admin/update?branch=main") assert r.status_code == 200 body = r.json() assert body.get("updated") is True @@ -138,7 +138,7 @@ def test_force_true_resets_dirty_tracked_files(self): patch("os.kill"), ): client = _make_client() - r = client.post("/admin/update?branch=beta&force=true") + r = client.post("/admin/update?branch=main&force=true") assert r.status_code == 200 body = r.json() assert body.get("forced_reset") is True @@ -163,7 +163,7 @@ def test_force_true_clean_tree_no_reset(self): patch("os.kill"), ): client = _make_client() - r = client.post("/admin/update?branch=beta&force=true") + r = client.post("/admin/update?branch=main&force=true") assert r.status_code == 200 body = r.json() assert body.get("forced_reset") is False @@ -180,7 +180,7 @@ def test_force_never_runs_git_clean(self): patch("os.kill"), ): client = _make_client() - client.post("/admin/update?branch=beta&force=true") + client.post("/admin/update?branch=main&force=true") assert not gm.did_clean(), "force must not delete untracked files" def test_force_ignored_in_dry_run(self): @@ -191,7 +191,7 @@ def test_force_ignored_in_dry_run(self): patch("shutil.which", return_value=None), ): client = _make_client() - r = client.post("/admin/update?branch=beta&force=true&dry_run=true") + r = client.post("/admin/update?branch=main&force=true&dry_run=true") assert r.status_code == 200 body = r.json() assert body.get("dry_run") is True @@ -210,11 +210,11 @@ def test_dry_run_reports_pending_commits(self): patch("shutil.which", return_value=None), ): client = _make_client() - r = client.post("/admin/update?branch=beta&dry_run=true") + r = client.post("/admin/update?branch=main&dry_run=true") assert r.status_code == 200 body = r.json() assert body.get("dry_run") is True - assert body.get("branch") == "beta" + assert body.get("branch") == "main" # _GitMock returns one canned "feat: example" commit for git log assert body.get("pending_commits") == 1 assert body.get("up_to_date") is False @@ -234,7 +234,7 @@ def fail_on_pull(cmd, **kwargs): patch("os.kill"), ): client = _make_client() - r = client.post("/admin/update?branch=beta") + r = client.post("/admin/update?branch=main") assert r.status_code == 200 body = r.json() assert "error" in body @@ -278,7 +278,7 @@ def test_force_deps_triggers_pip_install_even_on_clean_pull(self): patch("os.kill"), ): client = _make_client() - r = client.post("/admin/update?branch=beta&force_deps=true") + r = client.post("/admin/update?branch=main&force_deps=true") assert r.status_code == 200 body = r.json() assert body.get("deps_rebuilt") is True @@ -298,7 +298,7 @@ def test_force_and_force_deps_combine(self): patch("os.kill"), ): client = _make_client() - r = client.post("/admin/update?branch=beta&force=true&force_deps=true") + r = client.post("/admin/update?branch=main&force=true&force_deps=true") assert r.status_code == 200 body = r.json() assert body.get("forced_reset") is True @@ -324,7 +324,7 @@ def test_no_force_deps_skips_pip_when_pyproject_unchanged(self): patch("os.kill"), ): client = _make_client() - r = client.post("/admin/update?branch=beta") + r = client.post("/admin/update?branch=main") body = r.json() assert body.get("deps_rebuilt") is False assert gm.pip_calls == [] @@ -349,7 +349,7 @@ def fail_on_pip(cmd, **kwargs): patch("os.kill"), ): client = _make_client() - r = client.post("/admin/update?branch=beta&force_deps=true") + r = client.post("/admin/update?branch=main&force_deps=true") body = r.json() assert body.get("deps_rebuilt") is False assert body.get("deps_error") @@ -511,7 +511,7 @@ def record_subprocess(cmd, **kwargs): patch("os.kill"), ): client = _make_client() - r = client.post("/admin/update?branch=beta") + r = client.post("/admin/update?branch=main") body = r.json() assert body.get("deps_rebuilt") is True assert body.get("deps_drift") == fake_drift @@ -544,7 +544,7 @@ def record_subprocess(cmd, **kwargs): patch("os.kill"), ): client = _make_client() - r = client.post("/admin/update?branch=beta") + r = client.post("/admin/update?branch=main") body = r.json() assert body.get("deps_rebuilt") is False assert body.get("deps_drift") == [] @@ -567,10 +567,84 @@ def boom(_repo_dir): patch("os.kill"), ): client = _make_client() - r = client.post("/admin/update?branch=beta") + r = client.post("/admin/update?branch=main") assert r.status_code == 200 body = r.json() # No reinstall (no drift, no diff, no force), and the failure didn't # propagate as a 500. assert body.get("updated") is True assert body.get("deps_drift") == [] + + +class TestTrunkBasedChannel: + """The beta channel was removed in the trunk-based migration (#450). + + Only branch=main / channel=stable should be accepted; everything else + must 400. Legacy PINKYBOT_CHANNEL=beta is coerced (logged) to stable. + """ + + def test_admin_update_rejects_beta_branch(self): + """branch=beta must 400 — beta channel was removed.""" + client = _make_client() + r = client.post("/admin/update?branch=beta") + assert r.status_code == 400 + assert "beta" in r.json()["detail"].lower() or "main" in r.json()["detail"].lower() + + def test_admin_update_rejects_arbitrary_branch(self): + """Only 'main' or empty is accepted.""" + client = _make_client() + r = client.post("/admin/update?branch=feature/foo") + assert r.status_code == 400 + + def test_admin_update_empty_branch_defaults_to_main(self): + """No branch arg → defaults to main (release tags).""" + gm = _GitMock(dirty_files=[], branch="main") + with ( + patch("subprocess.check_output", side_effect=gm), + patch("shutil.which", return_value=None), + patch("os.kill"), + ): + client = _make_client() + r = client.post("/admin/update") + assert r.status_code == 200 + assert gm.did_pull() + + def test_admin_update_coerces_legacy_beta_env(self): + """PINKYBOT_CHANNEL=beta env should not crash; coerced to stable.""" + gm = _GitMock(dirty_files=[], branch="main") + with ( + patch.dict(os.environ, {"PINKYBOT_CHANNEL": "beta"}), + patch("subprocess.check_output", side_effect=gm), + patch("shutil.which", return_value=None), + patch("os.kill"), + ): + client = _make_client() + r = client.post("/admin/update") + assert r.status_code == 200 + # Should still attempt to pull main, not beta + pull_calls = [c for c in gm.calls if c[:3] == ["git", "pull", "origin"]] + assert pull_calls and pull_calls[0][-1] == "main" + + def test_admin_channel_get_always_returns_stable(self): + client = _make_client() + r = client.get("/admin/channel") + assert r.status_code == 200 + body = r.json() + assert body == {"channel": "stable", "branch": "main"} + + def test_admin_channel_get_coerces_legacy_beta_env(self): + with patch.dict(os.environ, {"PINKYBOT_CHANNEL": "beta"}): + client = _make_client() + r = client.get("/admin/channel") + assert r.status_code == 200 + assert r.json() == {"channel": "stable", "branch": "main"} + + def test_admin_channel_post_rejects_beta(self): + client = _make_client() + r = client.post("/admin/channel?channel=beta") + assert r.status_code == 400 + + def test_admin_channel_post_rejects_arbitrary(self): + client = _make_client() + r = client.post("/admin/channel?channel=edge") + assert r.status_code == 400