Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/codeql.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/docker-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ on:
branches: [main]
tags: ["26.*"]
pull_request:
branches: [main, beta]
branches: [main]
paths:
- "Dockerfile"
- ".dockerignore"
Expand Down
116 changes: 116 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -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<<NOTES_EOF'
cat /tmp/notes.md
echo NOTES_EOF
} >> "$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
3 changes: 2 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
75 changes: 54 additions & 21 deletions src/pinky_daemon/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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 ───────────────────────────

Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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(
Expand Down
Loading
Loading