Follow these steps in order to release appsec-advisor. The reference covers branches, version formats, validation gates, and CI.
- You are on
devwith the work for this release finished and committed. claudeCLI is onPATH(the end-to-end step needs it).- Authenticated for the LLM run:
claude /login(subscription) orANTHROPIC_API_KEYset. - First release only: the
devbranch must exist. See Appendix: createdev.
[ ] 1. Sync the latest signed baseline and commit any updates
[ ] 2. Curate Unreleased, align pyproject.toml + plugin.json + badge, promote to a dated CHANGELOG.md heading
[ ] 3. make release-all # deterministic gate, then live e2e (stops if gate fails)
[ ] 4. Merge dev → main, tag, push
[ ] 5. Verify GitHub release was created by the tag workflow
[ ] 6. Reopen dev for development (next .dev version marker)
Run make baseline-sync first, before the changelog is curated. It fetches and verifies the latest signed baseline release, then updates the bundled fallback and modular bundle when the id is unchanged. If it exits with ACTION NEEDED because the published id changed, review that release and rerun with make baseline-sync ACCEPT_ID=<published-id> to update the fallback, modular bundle, configured id, and README together. Commit any changes on dev; do not tag a commit with an older fallback by skipping this step.
A new baseline id reaches every user who installs or updates the baseline, so add one Changed bullet under ## Unreleased that names the new id. A refresh under the same id needs no entry.
The sync uses the network and changes tracked files, so it runs during release preparation rather than inside the offline release-check that CI reruns on the immutable tag. If the source cannot be verified, resolve that before releasing.
Entries under ## Unreleased accumulate one change at a time, so review them as a whole before promoting them. Apply the CHANGELOG.md rule in AGENTS.md to the complete list:
- Remove bullets that describe internal implementation, refactors, tests, documentation, or maintainer tooling rather than a change users notice.
- Merge bullets that describe the same user-facing outcome, including across
Added,Changed, andFixed, into one short sentence. - Move each remaining bullet to the category that fits the released result: new capability under
Added, changed behavior underChanged, removed behavior underRemoved, and corrected defects underFixed. A defect in a feature added in the same release is not aFixedentry; fold it into that feature's bullet. - Order bullets within each category by user impact, most significant first.
Then set the release version in pyproject.toml, .claude-plugin/plugin.json, and the README version badge, and move the curated notes into the dated matching CHANGELOG.md heading. Keep the ## Unreleased heading empty; check_release_meta.py rejects both a missing heading and remaining notes. Commit all metadata changes together:
git commit -am "release: 0.6.0b1"Step 3 fails at
check_release_meta.pyuntil the release metadata is committed.
Run the deterministic checks followed by the live LLM assessment. The assessment starts only if the checks pass:
make release-allThis is equivalent to running both gates in sequence:
make release-check # ruff, format, config, fragment-registry drift, full pytest+coverage, check_release_meta
make e2e-full # live LLM pipeline against the bundled fixture (~10–15 min, ~30–50% of a Pro 5h window)Fix failures before continuing. See Troubleshooting the gate.
Optional, depending on what you changed:
| Command | When to use it |
|---|---|
make e2e-full-standard |
You changed Stage-3 QA, actor discovery, enriched §7, or standard-depth routing. |
make e2e-full-thorough |
You changed thorough-depth routing or the Stage-4 architect review. |
make e2e-full-repair |
You touched the QA / Re-Render Loop; this builds a standard seed and verifies a real fragment-fixer repair. |
make e2e-full-eval |
You changed threat-generation prompts or severity/coverage logic and want the adversarial semantic-quality gate. |
make e2e-fixture-suite |
You changed language/framework detection or recall logic and have the external fixture checkout available. |
git checkout main
git merge --no-ff dev
git tag -a v0.6.0-beta.1 -m "0.6.0 beta 1"
git push origin main --follow-tagsPushing the tag triggers .github/workflows/release.yml: it re-runs
make release-check on the tagged commit, then creates the GitHub release (with
the prerelease flag for a beta or RC). Confirm the release appears on GitHub and
the workflow is green.
GitHub refuses to mark a prerelease as "latest", so a beta published this way leaves the previous release showing as latest. When the beta is the recommended version, clear the prerelease flag and promote it with the REST API:
id=$(gh api repos/<owner>/<repo>/releases/tags/v0.5.0-beta --jq .id)
gh api -X PATCH repos/<owner>/<repo>/releases/$id -f prerelease=false -f make_latest=trueSet version to the next dev marker:
git checkout dev
# set version to e.g. 0.5.0.dev0 in pyproject.toml, commitdev: day-to-day development. It stays ahead ofmainbetween releases.main: releases and release tags.
When a release is ready, merge dev into main and tag the merge commit on
main. A tag points at a commit, not a branch, so once it lands on main it's
reachable from both branches and never needs re-tagging.
A -beta.N suffix marks the Nth pre-release of the version in front of it, so
0.6.0-beta.1 is followed by further betas, optionally an -rc.N, and then the
stable 0.6.0. The tags v0.4.0-beta and v0.5.x-beta predate this rule: there
-beta labelled the release line itself, which is why those lines have no stable
release.
The same version appears in five places, using PEP 440-equivalent spellings:
| Where | Format | Example |
|---|---|---|
pyproject.toml |
PEP 440 | 0.6.0b1 |
.claude-plugin/plugin.json |
SemVer-style, PEP 440-equivalent | 0.6.0-beta.1 |
| README version badge | plugin.json spelling, - escaped as -- |
version-0.6.0--beta.1-orange.svg |
| Git tag | leading v |
v0.6.0-beta.1 |
CHANGELOG.md heading |
version + date | ## 0.6.0-beta.1 (2026-08-22) |
scripts/check_release_meta.py normalizes these before comparing, so 0.6.0b1
and 0.6.0-beta.1 count as equal. It also rejects pending Unreleased content
and validates the plugin's analysis-version compatibility declaration.
The README badge is the exception: it must equal plugin.json at all times, not only at a release, so tests/test_marketplace_manifest.py checks it on every run of the suite rather than at the release boundary.
make check: lint, format, configuration validation, drift guards, and the complete test suite. Run it for the changes that require the full gate in Contributing.make release-check:make checkpluscheck_release_meta.py, which verifies the version, tag, and changelog. Run it before tagging; CI repeats it on the tag. An ordinary development commit fails the release-metadata check.make release-all:release-checkfollowed bye2e-full. It stops if the first gate fails.
make release-check stops at the first failure. Identify the stage from its error and fix the producer. Lint and formatting support automatic repair. For other failures, correct the source; do not relax schemas, patch generated output, or weaken tests to pass the gate.
| # | Stage | Symptom | Fix |
|---|---|---|---|
| 1 | ruff check |
file:line + rule code (e.g. F401) |
make fix (runs ruff check --fix), or fix manually. Don't silence with # noqa unless justified. |
| 2 | ruff format --check |
Would reformat: … |
make fix (runs ruff format). Never hand-format runtime/resolve_config.py; it is intentionally excluded in pyproject.toml. |
| 3 | validators/validate_config.py |
config/YAML schema error | Correct the offending field. Fix the producer, don't loosen the schema. |
| 4 | validators/check_fragment_registry.py |
registry maps out of sync | Align all registry maps. See adding-a-section.md and schema-invariants.md §4f. |
| 5 | pytest + coverage |
failing tests or coverage below floor | Separate pre-existing failures from new ones. Run a single file with pytest tests/test_x.py -v --tb=short. Add tests for new code; don't lower the floor. |
| 6 | check_release_meta.py |
version/plugin manifest/tag/changelog mismatch or unpromoted notes | Expected on a development commit. For a real release, reconcile all version formats and promote Unreleased. To check only code health, run make check instead. |
Auto-repair: make fix handles stages 1–2 (ruff check --fix + ruff format) and then prints what stages 3–6 still need from you. It deliberately
does not touch the semantic stages.
After make fix, rerun make release-check to verify all stages:
make fix # repair lint + format automatically
make release-check # re-check; fix any remaining stage 3–6 failure by handTriage helper (maintainer dev tool). Every make release-check run captures its full output to .cache/release-check.log (gitignored). When the gate fails, run the project-local slash command /triage-release-check: it reads that log, identifies the first red stage, and recommends the producer-side fix using the table above. It proposes changes and applies them only when requested. It is a .claude/commands/ dev command (like e2e-full), not part of the shipped plugin.
- Test workflow: every push and PR to
mainanddev: lint, format, config validation, fragment-registry drift, full pytest across Python 3.10–3.12, Codecov upload. - Release workflow: only on a
v*tag: runsmake release-checkand publishes the release.
Run the live end-to-end assessment manually before tagging. CI excludes it because it is non-deterministic and consumes model budget.
To hand out a testable build before the real release, tag the current tip of
dev:
git checkout dev
git tag -a v0.5.0-alpha.1 -m "0.5.0 alpha 1 snapshot"
git push origin v0.5.0-alpha.1
gh release create v0.5.0-alpha.1 --prerelease --target dev --notes "Snapshot for testing."The tag stays on the dev line and won't appear on main until that commit is merged for a release. The same tag then becomes reachable from main without being recreated.
A fresh clone has no dev branch. Create it once from main, push it, and make
it the default branch on GitHub:
git branch dev
git push -u origin dev
# GitHub > Settings > Branches: set default branch to devOptionally protect main so it only receives merges. Both branches start at the
same commit and diverge with the first version bump.