diff --git a/development/tag-triggered-release-deploy.md b/development/tag-triggered-release-deploy.md new file mode 100644 index 0000000..11f3b6e --- /dev/null +++ b/development/tag-triggered-release-deploy.md @@ -0,0 +1,254 @@ +--- +title: "Tag-Triggered Release and Deploy Guide" +description: "CalVer tag-triggered CI/CD workflow: push a git tag to build Docker images, then deploy with a script. Reference implementation from Major Domo Discord bot." +type: guide +domain: development +tags: [docker, gitea, deployment, ci, calver, scripts, bash] +--- + +# Tag-Triggered Release and Deploy Guide + +Standard release workflow for Dockerized applications using CalVer git tags to trigger CI builds and a deploy script for production rollout. Decouples code merging from releasing — merges to `main` are free, releases are intentional. + +## Overview + +``` +merge PR to main → code lands, nothing builds +.scripts/release.sh → creates CalVer tag, pushes to trigger CI +CI builds → Docker image tagged with version + "production" +.scripts/deploy.sh → pulls image on production host, restarts container +``` + +## CalVer Format + +`YYYY.M.BUILD` — year, month (no leading zero), incrementing build number within that month. + +Examples: `2026.3.10`, `2026.3.11`, `2026.4.1` + +## Release Script + +`.scripts/release.sh` — creates a git tag and pushes it to trigger CI. + +```bash +# Auto-generate next version +.scripts/release.sh + +# Explicit version +.scripts/release.sh 2026.3.11 + +# Skip confirmation +.scripts/release.sh -y + +# Both +.scripts/release.sh 2026.3.11 -y +``` + +### What it does + +1. Verifies you're on `main` and in sync with origin +2. Auto-generates next CalVer build number from existing tags (or uses the one you passed) +3. Validates version format and checks for duplicate tags +4. Shows commits since last tag for review +5. Confirms, then tags and pushes + +### Reference implementation + +```bash +#!/usr/bin/env bash +set -euo pipefail + +SKIP_CONFIRM=false +VERSION="" + +for arg in "$@"; do + case "$arg" in + -y) SKIP_CONFIRM=true ;; + *) VERSION="$arg" ;; + esac +done + +# Ensure we're on main and up to date +BRANCH=$(git rev-parse --abbrev-ref HEAD) +if [[ "$BRANCH" != "main" ]]; then + echo "ERROR: Must be on main branch (currently on ${BRANCH})" + exit 1 +fi + +git fetch origin main --tags --quiet +LOCAL=$(git rev-parse HEAD) +REMOTE=$(git rev-parse origin/main) +if [[ "$LOCAL" != "$REMOTE" ]]; then + echo "ERROR: Local main is not up to date with origin. Run: git pull" + exit 1 +fi + +# Determine version +YEAR=$(date +%Y) +MONTH=$(date +%-m) + +if [[ -z "$VERSION" ]]; then + LAST_BUILD=$(git tag --list "${YEAR}.${MONTH}.*" --sort=-v:refname | head -1 | awk -F. '{print $3}') + NEXT_BUILD=$(( ${LAST_BUILD:-0} + 1 )) + VERSION="${YEAR}.${MONTH}.${NEXT_BUILD}" +fi + +# Validate +if [[ ! "$VERSION" =~ ^20[0-9]{2}\.[0-9]+\.[0-9]+$ ]]; then + echo "ERROR: Invalid version format '${VERSION}'. Expected YYYY.M.BUILD" + exit 1 +fi + +if git rev-parse "refs/tags/${VERSION}" &>/dev/null; then + echo "ERROR: Tag ${VERSION} already exists" + exit 1 +fi + +# Show what's being released +LAST_TAG=$(git tag --sort=-v:refname | head -1) +echo "Version: ${VERSION}" +echo "Previous: ${LAST_TAG:-none}" +echo "Commit: $(git log -1 --format='%h %s')" + +if [[ -n "$LAST_TAG" ]]; then + echo "Changes since ${LAST_TAG}:" + git log "${LAST_TAG}..HEAD" --oneline --no-merges +fi + +# Confirm +if [[ "$SKIP_CONFIRM" != true ]]; then + read -rp "Create tag ${VERSION} and trigger release? [y/N] " answer + [[ "$answer" =~ ^[Yy]$ ]] || { echo "Aborted."; exit 0; } +fi + +# Tag and push +git tag "$VERSION" +git push origin tag "$VERSION" + +echo "==> Tag ${VERSION} pushed. CI will build the image." +echo "Deploy with: .scripts/deploy.sh" +``` + +## CI Workflow (Gitea Actions) + +`.gitea/workflows/docker-build.yml` — triggered by tag push, builds and pushes Docker image. + +```yaml +name: Build Docker Image + +on: + push: + tags: + - '20*' # matches CalVer tags like 2026.3.11 + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Extract version from tag + id: version + run: | + VERSION=${GITHUB_REF#refs/tags/} + echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "sha_short=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT + + - uses: docker/setup-buildx-action@v3 + - uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - uses: docker/build-push-action@v5 + with: + context: . + push: true + tags: | + myorg/myapp:${{ steps.version.outputs.version }} + myorg/myapp:production + cache-from: type=registry,ref=myorg/myapp:buildcache + cache-to: type=registry,ref=myorg/myapp:buildcache,mode=max +``` + +### Docker image tags + +| Tag | Type | Purpose | +|-----|------|---------| +| `2026.3.10` | Immutable | Pinpoints exact version, rollback target | +| `production` | Floating | Always the latest release, used in docker-compose | + +## Deploy Script + +`.scripts/deploy.sh` — pulls the latest `production` image and restarts the container. + +```bash +# Interactive (confirms before deploying) +.scripts/deploy.sh + +# Non-interactive +.scripts/deploy.sh -y +``` + +### What it does + +1. Shows current branch, commit, and target +2. Saves previous image digest (for rollback) +3. Pulls latest image via `docker compose pull` +4. Restarts container via `docker compose up -d` +5. Waits 5 seconds, shows container status and logs +6. Prints rollback command if image changed + +### Key details + +- Uses SSH alias (`ssh akamai`) — never hardcode `ssh -i` paths +- Image tag is `production` (floating) — compose always pulls the latest release +- Rollback uses the saved digest, not a version tag, so it's exact + +### Production docker-compose + +```yaml +services: + myapp: + image: myorg/myapp:production + restart: unless-stopped + volumes: + - ./credentials.json:/app/credentials.json:ro # secrets read-only + - ./storage:/app/storage:rw # state files writable + - ./logs:/app/logs +``` + +## Hotfix Workflow + +When production is broken and you need to fix fast: + +1. Create a `hotfix/` branch from `main` +2. Fix, test, push, open PR +3. Merge PR to `main` +4. Delete the current tag and re-release on the new HEAD: + +```bash +git tag -d YYYY.M.BUILD +git push origin :refs/tags/YYYY.M.BUILD +.scripts/release.sh YYYY.M.BUILD -y +# wait for CI... +.scripts/deploy.sh -y +``` + +Re-using the same version number is fine for hotfixes — it keeps the version meaningful ("this is what's deployed") rather than burning a new number for a 1-line fix. + +## Why This Pattern + +| Alternative | Downside | +|-------------|----------| +| Branch-push trigger | Every merge = build = deploy pressure. Can't batch changes. | +| `next-release` staging branch | Extra ceremony, merge conflicts, easy to forget to promote | +| `workflow_dispatch` (manual UI button) | Less scriptable, no git tag trail | +| GitHub Releases UI | Heavier than needed for Docker-only deploys | + +Tag-triggered releases give you: +- Clear audit trail (`git tag` history) +- Easy rollbacks (`docker pull myorg/myapp:2026.3.9`) +- Scriptable (`release.sh` + `deploy.sh`) +- Decoupled merge and release cycles diff --git a/major-domo/release-2026.3.20.md b/major-domo/release-2026.3.20.md new file mode 100644 index 0000000..e26b438 --- /dev/null +++ b/major-domo/release-2026.3.20.md @@ -0,0 +1,101 @@ +--- +title: Major Domo v2 Release — 2026.3.20 +description: "Performance release: parallelized API calls, caching improvements, CI overhaul to tag-triggered releases, and async hotfix." +type: reference +domain: major-domo +tags: [discord, major-domo, deployment, release-notes, docker, ci] +--- + +# Major Domo v2 Release — 2026.3.20 + +**Date:** 2026-03-20 +**Tag:** `2026.3.10` +**Image:** `manticorum67/major-domo-discordapp:production` +**Server:** akamai (`/root/container-data/major-domo`) +**Deploy method:** `.scripts/release.sh` → CI → `.scripts/deploy.sh` + +## Release Summary + +Performance-focused release with 12 merged PRs covering parallelized API calls, caching improvements, CI workflow overhaul, and a production hotfix. Also retired the `next-release` staging branch in favor of direct-to-main merges with tag-triggered releases. + +## Hotfix During Release + +**PR #117** — ScorecardTracker async mismatch. PR #106 added `await` to all `scorecard_tracker` method calls across `scorebug.py`, `live_scorebug_tracker.py`, and `cleanup_service.py`, but the tracker methods themselves were still synchronous. This caused `TypeError: object NoneType can't be used in 'await' expression` on `/scorebug` and `TypeError: object list can't be used in 'await' expression` in the background scorebug update loop. Fixed by making all 6 public `ScorecardTracker` methods async and adding 5 missing `await`s in `cleanup_service.py`. + +**Root cause:** PR #106 was created by an issue-worker agent that modified callers without modifying the tracker class. The async tracker conversion existed only in uncommitted working tree changes that were never included in any PR. + +**Lesson:** Issue-worker agent PRs that add `await` to calls must verify the called methods are actually async — not just that the callers compile. + +## Infrastructure Changes + +### CI: Tag-triggered releases (PRs #110, #113) + +Replaced branch-push CI with tag-push CI. Merging to `main` no longer triggers a Docker build. + +- **Before:** Push to `main` or `next-release` → auto-build → auto-tag CalVer +- **After:** Push CalVer tag (`git tag 2026.3.11 && git push --tags`) → build → Docker image tagged `:version` + `:production` + +Also removed the `pull_request` trigger that was building Docker images on every PR branch push. + +### Release and deploy scripts (PRs #114, #115) + +- `.scripts/release.sh` — auto-generates next CalVer tag, shows changelog, confirms, pushes tag +- `.scripts/deploy.sh` — updated to use SSH alias (`ssh akamai`) and `:production` image tag + +### Docker volume split (PR #86) + +Split the single `./storage:/app/data` volume into: +- `./storage/major-domo-service-creds.json:/app/data/major-domo-service-creds.json:ro` (credentials) +- `./storage:/app/storage:rw` (state files) + +Production compose on akamai was updated manually before deploy. All 5 tracker default paths changed from `data/` to `storage/`. + +### Retired `next-release` branch + +All references to `next-release` removed from CLAUDE.md and CI workflow. New workflow: branch from `main` → PR to `main` → tag to release. + +## Performance Changes + +### Parallelized API calls + +| PR | What | Impact | +|----|------|--------| +| #88 | `schedule_service`: `get_team_schedule`, `get_recent_games`, `get_upcoming_games` use `asyncio.gather()` | Up to 18 sequential HTTP requests → concurrent | +| #90 | Team lookups in `/publish-scorecard`, `/scorebug`, `/injury`, trade validation | 2 sequential calls → concurrent per location | +| #102 | `asyncio.gather()` across multiple command files | Broad latency reduction | + +### Caching + +| PR | What | Impact | +|----|------|--------| +| #99 | Cache user team lookup in `player_autocomplete` with 60s TTL, reduce Discord limit to 25 | Faster autocomplete on repeat use | +| #98 | Replace Redis `KEYS` with `SCAN` for cache invalidation | Non-blocking invalidation | + +### Micro-optimizations + +| PR | What | Impact | +|----|------|--------| +| #93 | Use `channel.purge()` instead of per-message `message.delete()` loops | 1 API call vs up to 100 per channel clear | +| #96 | Replace `json.dumps(value)` probe with `isinstance()` in JSON logger | Eliminates full serialization on every log call | +| #97 | Cache `inspect.signature()` at decoration time in all 3 decorators | Introspection cost paid once, not per-call | + +## Cleanup + +| PR | What | +|----|------| +| #104 | Remove dead `@self.tree.interaction_check` decorator block and duplicate `self.maintenance_mode` assignment in `bot.py` | +| #103 | Remove unused `weeks_ahead` parameter from `get_upcoming_games` | + +## Test Coverage + +- 16 new tests for `schedule_service` (`test_services_schedule.py` — first coverage for this service) +- Tests use existing `GameFactory`/`TeamFactory` from `tests/factories.py` +- 2 existing scorebug tests updated for async tracker methods +- Full suite: 967+ tests passing + +## Deployment Notes + +- Production compose updated on akamai before deploy (volume split) +- Image tag changed from `:latest` to `:production` +- Deployed twice: once before hotfix (broken `/scorebug`), once after (#117) +- Final deploy confirmed healthy — all background tasks started, gateway connected diff --git a/workstation/claude-code-config.md b/workstation/claude-code-config.md index 02ced43..ead471f 100644 --- a/workstation/claude-code-config.md +++ b/workstation/claude-code-config.md @@ -90,12 +90,9 @@ All defined in `~/.claude.json` under `mcpServers`: Hooks are configured in `~/.claude/settings.json` under the `hooks` key. They run shell commands or HTTP requests in response to events. -### Current Hooks +Current hooks are defined in `~/.claude/settings.json` — check the `hooks` key for the live list. -| Event | Action | -|-------|--------| -| `PostToolUse` (Edit/Write/MultiEdit) | Auto-format code via `format-code.sh` | -| `SubagentStop` | Notify via `notify-subagent-done.sh` | +Additionally, the `format-on-save@agent-toolkit` plugin registers its own `PostToolUse` hook for auto-formatting files on Edit/Write (runs ruff for Python, prettier for JS/TS, shfmt for shell, etc). See the plugin source at `~/.claude/plugins/cache/agent-toolkit/format-on-save/` for the full formatter list. ## Permissions @@ -103,5 +100,44 @@ Permission rules live in `~/.claude/settings.json` under `permissions.allow` and Common patterns: - `"mcp__gitea-mcp__*"` — allow all gitea MCP tools -- `"WebFetch(domain:docs.example.com)"` — allow fetching from specific domain -- `"Bash(ssh:*)"` — allow SSH commands +- `"WebFetch(domain:*)"` — allow fetching from any domain +- `"Bash"` — allow all Bash commands (subject to cmd-gate hook below) + +## permission-manager Plugin (cmd-gate) + +The `permission-manager@agent-toolkit` plugin adds a `PreToolUse` hook on all `Bash` tool calls. It parses commands into an AST via `shfmt --tojson`, classifies each segment, and returns allow/ask/deny. + +### How it works + +1. Compound commands (`&&`, `||`, pipes) are split into segments +2. Each segment is checked against **custom allow patterns** first (bypasses classifiers) +3. Then dispatched to language/tool-specific classifiers (git, docker, npm, etc.) +4. Most restrictive result wins across all segments + +### Custom allow patterns + +Loaded from two files (both checked, merged): +- **Global:** `~/.claude/command-permissions.json` +- **Project-level:** `.claude/command-permissions.json` (relative to cwd) + +Format: +```json +{ + "allow": [ + "git pull*", + "git push origin main" + ] +} +``` + +Patterns are **bash glob matched** per-segment against the command string. Project-level patterns are resolved from `$PWD` at hook time (i.e., the project Claude Code is working in, not the target of a `cd` in the command). + +### Protected branches + +The git classifier denies `git push` to `main` or `master` by default. To allow pushes to main from a specific project, add `"git push origin main"` to that project's `.claude/command-permissions.json`. + +### Bypass + +- Scheduled tasks use `--permission-mode bypassPermissions` which skips cmd-gate entirely +- For classifier development details, see `development/permission-manager-classifier-development.md` +- Plugin source: `~/.claude/plugins/cache/agent-toolkit/permission-manager/`