| name | ci-and-release |
| description | CI/CD workflow internals, version management and auto-bump mechanics, PyPI publish flow, and Dependabot auth quirks. Use when modifying .github/workflows, releasing, versioning, or debugging CI/publish/Dependabot behaviour. |
CI, Versioning & Release
Version Management
Version is single-sourced in pyproject.toml (version = "…"). src/yeaboi/__init__.py reads it at runtime from the installed package metadata (importlib.metadata.version("yeaboi"), with a 0.0.0+dev fallback for uninstalled source trees). __version__ is imported by cli.py for the --version flag. Package entry points: yeaboi = "yeaboi.cli:main" (canonical) and a one-release back-compat alias scrum-agent = "yeaboi.cli:main". The PyPI distribution was renamed scrum-agent → yeaboi; a thin scrum-agent redirect package (packaging/scrum-agent-shim/) depends on yeaboi so existing installs migrate.
Two manual prerequisites, and each fails in ways nothing else reports. publish-beta.yml needs its own PyPI trusted-publisher entry (workflow publish-beta.yml, environment pypi) alongside publish.yml's — without it every pre-release dies at upload with invalid-publisher, loudly, and the official channel is untouched. And pr-feedback must be a required status check on the main-branch ruleset, or unattended PRs go "gate-green" without any review having run.
Unattended work ships only with a human's merge.
- Unattended PRs never merge on their own. They wait open against
main, individually gate-green, each labelled semver:none so auto-version.yml bumps nothing on their branches. A human reviews, hand-tests where warranted, and merges.
- Official, on every human merge.
publish.yml fires on push to main, and its check job asks scripts/release_lane.py which lane the merged PR came from. A human PR cuts the official release right there, from the merged tree; test → build → PyPI publish (OIDC) → v<version> tag + GitHub Release. An unattended one (the cowork label, or a cowork/, feature/issue-, security/codeql-triage, ci-sentinel/ branch) sets unattended=true, every later step in check is gated on that, and go never reaches the publish job. A failed or unparseable lookup counts as unattended — the recoverable direction, since PyPI has no delete. A human merge that never moved the version line (docs, CI) stops green at --check-promotable's "already released". workflow_dispatch is the manual equivalent. Never tag manually; the workflow owns tagging, and v* is a finals-only namespace that everything else counts from.
The rc number comes from scripts/release_channel.py (stdlib-only, so CI runs it before uv sync). N is git rev-list --count <last-final-tag>..HEAD — a pure function of the commit, so two racing merges get different numbers and a workflow re-run recomputes the same one (skip-existing then makes it a no-op). --manifest builds the release notes from changelog_data.json entries newer than the last final tag plus git log.
An rc string is never committed. It is stamped into a throwaway checkout, inside the publish job only, after the test job — scripts/bump_version.py rejects anything that is not X.Y.Z and auto-version.yml runs it on every release-worthy PR, so an rc on main would break the next PR and the one after with no obvious cause. tests/unit/test_release_channel.py::TestCommittedVersionShape fails on the PR that does it.
The bump itself is automated too (auto-version.yml). On each PR, cheap deterministic guards run first (skip if the version was already changed in the PR, or if no src/yeaboi/** files changed and no semver:* label is present); otherwise Claude classifies the diff into a semver level and commits chore: bump version to X.Y.Z [auto] to the PR branch — so merging fires publish-beta.yml with no manual step. It decides what the next final version will be; it no longer decides that anything ships to users. Rules:
- Bump on the PR branch, not
main — a workflow pushing to main with the default GITHUB_TOKEN would not re-trigger publish-beta.yml (recursion suppression); the human merge does. This means no PAT is needed.
- Override with a label:
semver:major / semver:minor / semver:patch forces the level; release:skip (or semver:none) suppresses the bump.
- Manual bumps still work — if you edit
version yourself, the guard sees it already differs from main and leaves it alone.
- Mechanics live in
scripts/bump_version.py (pure bump() + make bump-patch|bump-minor|bump-major); the LLM only chooses the level.
- Unattended PRs are exempt by label. Every unattended PR carries
semver:none, so the level is "none" and no file is modified — the human merge that ships the work decides the bump.
- The dual-PR race (two human PRs off the same base both bumping) still needs a human in its nasty variant: if PR A bumps minor and PR B bumps patch off the same base,
main ends up below a version already published, and release_channel.py refuses to number it rather than publishing a version that sorts backwards.
Distribution is PyPI-only (via uv tool install / pipx install); Homebrew is not supported because a required dependency (sqlite-vec) ships no sdist, so the omardin14/homebrew-tap formula is permanently disabled.
CI/CD
Workflows in .github/workflows/:
| Workflow | Trigger | Purpose |
|---|
ci.yml | Every push | Lint + test. A scope job diffs the merge-base and drives the rest: the five required jobs always run (scoped to the areas the change touches, via scripts/test_scope.py), and package/eval/compat are skipped when nothing they cover changed |
auto-version.yml | PR | Claude classifies the diff and commits a chore: bump version… to the PR branch (skips docs/chore-only PRs; semver:* / release:skip labels override) |
publish-beta.yml | Push to main | if the version line changed: test → stamp X.Y.ZrcN → build → PyPI pre-release. No tag, no Release |
publish.yml | push to main from a human's PR, or dispatch | test → build → PyPI publish (OIDC) → v<version> tag + GitHub Release, all from the merged tree. An unattended push is classified by scripts/release_lane.py and stops in check |
claude-review.yml | CI workflow succeeds on a PR (workflow_run) | Async Claude code + security review comment; only fires when all CI checks passed (no tokens burned on red PRs); advisory only, never blocks merge (skips drafts, bots, and Dependabot PRs). Rounds are per lane: two findings-bearing verdicts on an unattended PR, one on a local branch. Its marker carries open=N critical=M |
dependabot-auto.yml | CI workflow succeeds on a Dependabot PR (workflow_run) | Claude verifies each bump (release notes vs our actual usage), posts a SAFE-TO-MERGE / NEEDS-HUMAN verdict comment, and enables auto-merge for safe ones. Pip majors and minor+ bumps of TUI/agent-critical packages (rich, sqlite-vec, langgraph, langchain*, ) always get the label instead. Auto-merge waits on the required checks, so nothing red can land |
Merge gating: the main-branch ruleset requires five ci.yml checks — and the contexts are the job names, exactly: Unit tests, Integration & contract tests, Lint (ruff), Format check (ruff), Security scan. (This table used to write the last two as Lint and Format check, which match no posted check; a ruleset configured from the docs rather than from a run would have waited forever on both.) pr-feedback is the sixth, added by hand — see below. Auto-merge (enabled repo-wide) fires only when they're green. Golden evaluators, the front-end bundles, the docs site and the wheel check all stay non-blocking by design, which is what lets the scope job skip them.
Those five jobs must never carry an if:. GitHub reports a skipped job as a passing required check only when the workflow ran and evaluated the condition; a job skipped because its needs: was skipped produces no check at all, and a required context with no status blocks the PR forever. tests/unit/test_workflow_schema.py::TestRequiredChecksAlwaysReport asserts it both ways — no if: on the job, and no conditional job in its needs:. Scoping changes what a required job runs, never whether it reports.
pr-feedback has to be added to that ruleset by hand, and nothing in this repo can do it. The workflow posts a commit status either way, so a missing entry does not fail loudly — it fails by the check going red on a PR that then merges anyway, which is indistinguishable at a glance from the check working. Verify with:
gh api repos/:owner/:repo/rulesets --jq '.[] | select(.name=="main-branch") | .id'
gh api repos/:owner/:repo/rulesets/<id> --jq '.rules[] | select(.type=="required_status_checks")'
pr-feedback must appear in required_status_checks_policy.required_status_checks[].context. It belongs in the same manual-prerequisite bucket as AUTO_VERSION_PAT and the Claude GitHub App: set once, invisible when absent, and load-bearing. A PR that is genuinely stuck behind a broken gate is cleared with the feedback-override label rather than by removing the requirement.
The desktop release is a different repository
release.yml in yeaboi-desktop builds, signs and notarizes the app, and it holds the eight signing secrets that used to live here — CSC_LINK + CSC_KEY_PASSWORD, APPLE_ID + APPLE_APP_SPECIFIC_PASSWORD + APPLE_TEAM_ID, AZURE_TENANT_ID + AZURE_CLIENT_ID + AZURE_CLIENT_SECRET. Nothing here triggers it, and nothing here needs them.
What still matters on this side is the ordering: the desktop wraps a wheel that already exists. Its workflow refuses a wheel version that is not a final X.Y.Z on PyPI, so a desktop release always lags a Python one and is promoted by hand. The app carries its own semver (that repo's package.json, bumped by hand alongside its shell changelog); the bundled wheel version is a separate workflow input, visible at runtime via /api/meta/version. Changelog separation: highlights in src/yeaboi/changelog_data.json may carry a surfaces tag (tui/desktop/web, absent = all three); the TUI's c page filters to tui, the desktop's What's New filters to desktop and merges in its own shell ledger.
When a Claude workflow fails
Seven workflows share one credential, CLAUDE_CODE_OAUTH_TOKEN in the repo's Actions secret store. When it goes bad they all fail at once, and because anthropics/claude-code-action hides SDK output by default most of them fail silently — a bare is_error: true with no reason. Read the signature before touching prompts or models:
| Signature in the run log | Means |
|---|
num_turns: 1, duration_ms ≈ 2000, total_cost_usd: 0, apiKeySource: "none" | auth, not the model |
"api_error_status": 401 / "error": "authentication_failed" | the token is expired, revoked, or the wrong kind |
| a green check on a PR that edits the workflow file itself | the action skipped — it requires the file to be byte-identical to the copy on main, and reports the skip as success |
Fix: claude setup-token on a machine logged into the Claude subscription, then gh secret set CLAUDE_CODE_OAUTH_TOKEN. The value must be a Claude Code OAuth token (sk-ant-oat…), not a Console API key (sk-ant-api…) — the API rejects a Console key in that secret with the same 401 OAuth access token is invalid, so "I rotated it and it still fails" usually means the wrong kind of token was pasted. A Console key belongs in ANTHROPIC_API_KEY, which auto-version.yml and claude-review.yml also read. CLAUDE_CODE_OAUTH_TOKEN is believed to win when both are set — meaning you'd clear it to fall through — but that precedence has never been exercised here and is an assumption, not an observation.
auto-version.yml preflights the credential's shape before spending a turn, and annotates a 401 with the remediation. That preflight is plain shell, so unlike the action it still runs on a PR that edits the workflow. It deliberately does not validate the prefix against an allowlist — it only rejects shapes that cannot work — so a future change to Anthropic's token format won't red every PR.
2026-07-30 incident, for calibration: this exact 401 took every Claude workflow down and was misdiagnosed twice — first as a stale Haiku alias (PR #120 pinned the dated model id, which changed nothing), then as a token that needed rotating (it was rotated, and still failed). The tell was there the whole time, behind show_full_output.
Dependabot notes: updates arrive grouped (one weekly PR per ecosystem; security updates grouped too — see .github/dependabot.yml). Pip Dependabot PRs carry the semver:patch label so merging one publishes a patch release — a merged dependency/CVE fix reaches PyPI users instead of sitting unreleased. Three mechanics to know:
- Auth via
workflow_run, not the Dependabot secret store. Dependabot-triggered runs can only read a separate Dependabot secrets store, which the Claude GitHub App does not populate (it provisions CLAUDE_CODE_OAUTH_TOKEN only into the Actions store). So dependabot-auto.yml triggers on workflow_run (after CI) instead of on Dependabot's pull_request event — a workflow_run job runs from the default branch with the normal Actions secrets, using the App's token directly. No Dependabot secret needs to be created or kept in sync. The PR is resolved from the CI run's head SHA; Claude derives the bumped packages from the PR title + diff (no fetch-metadata, which needs the avoided Dependabot context).
- Labels must pre-exist. Dependabot only applies labels that already exist in the repo —
dependencies/security/ci/semver:patch are created; if one is deleted, Dependabot silently skips it.
- Release trigger. A merge performed by the default
GITHUB_TOKEN does not trigger publish-beta.yml (same recursion suppression as the version bump), so an auto-merged pip bump's pre-release defers to the next human push to main (an optional AUTO_MERGE_TOKEN PAT in the Actions store would make it publish immediately). The official release is unaffected either way — it is promoted, not pushed.
There is no Homebrew tap auto-update: the omardin14/homebrew-tap formula is disabled (see Version Management) and publish.yml no longer dispatches to it.