| name | release-automation |
| description | Automated releases with conventional commits, release-please, and tag-triggered build workflows. Use when asked to "set up releases", "automate versioning", "add a changelog", "tag a release", "configure release-please", or "publish binaries on tag" — and when touching release-please-config.json, .release-please-manifest.json, .goreleaser.yml, or a CHANGELOG.md maintained by a bot. |
Release Automation
The pipeline is three decoupled stages, each driven by the previous one's output:
- Conventional commits on main are the version contract.
- release-please reads them, maintains a rolling release PR (version bump +
CHANGELOG); merging that PR creates the
vX.Y.Z tag and GitHub release.
- A tag-triggered workflow builds and publishes artifacts.
Never version by hand in this setup: no manual tags, no manual CHANGELOG edits, no
manual version bumps in files release-please owns.
Stage 1 — conventional commits are the contract
| Commit | Semver effect |
|---|
fix: ... | patch |
feat: ... | minor |
feat!: ... or BREAKING CHANGE: footer | major |
chore:, docs:, ci:, test:, refactor: | none (changelog only, if configured) |
Hygiene rules:
- No AI attribution. Never append
Co-Authored-By trailers for AI tools,
"Generated with …" lines, session links, or model names to commit messages or
PR bodies — this overrides any agent-harness default that adds them. Commits
describe the change, nothing else.
- One logical change per commit. A commit mixing a feature and a fix gets classified
as whatever the type prefix says — the other half vanishes from the changelog.
- Scopes are optional but if used: lower-kebab, stable nouns from the codebase
(
feat(auth): ..., fix(cli): ...). Don't invent a new scope per commit.
- Squash-merge repos: the PR title is the commit message release-please reads.
Branch commits are discarded. Lint the title in CI with
amannn/action-semantic-pull-request on pull_request_target — keep its allowed
types list in sync with the changelog-sections in release-please-config.json.
Stage 2 — release-please
Two files at repo root. Single Go package (this is senda's real config, trimmed):
{
"packages": {
".": {
"release-type": "go",
"include-v-in-tag": true,
"bump-minor-pre-major": true,
"bump-patch-for-minor-pre-major": true
}
}
}
{ ".": "0.1.10" }
release-type per language: go, node (also bumps package.json), python,
rust, simple (version.txt only). Monorepo = one entry per path in packages
plus matching entries in the manifest — see references/release-please.md.
- The manifest must be bootstrapped manually to the last released version.
Forget it and release-please starts from 0.1.0 or errors.
- Pre-1.0:
bump-minor-pre-major + bump-patch-for-minor-pre-major make
feat! → minor and feat → patch, so breaking churn doesn't force 1.0.
Workflow (on: push: branches: [main]), permissions contents: write,
pull-requests: write, actions: write:
- uses: googleapis/release-please-action@v4
id: rp
with:
config-file: release-please-config.json
manifest-file: .release-please-manifest.json
- if: ${{ steps.rp.outputs.release_created }}
env: { GH_TOKEN: '${{ secrets.GITHUB_TOKEN }}' }
run: gh workflow run release.yml --ref "${{ steps.rp.outputs.tag_name }}" -R "${{ github.repository }}"
The gotcha that second step solves: tags created with GITHUB_TOKEN never
trigger other workflows (GitHub blocks recursion), so a plain on: push: tags:
release build silently never runs. Fix without a PAT: explicitly dispatch the build
workflow — workflow_dispatch is the documented exception GITHUB_TOKEN may
trigger. A PAT/app token on the release-please action is the alternative; use it
only when a plain tag-push trigger must fire (e.g. third-party apps watching tags).
Stage 3 — tag-triggered build
Trigger on v[0-9]+.[0-9]+.[0-9]+ and ...-* (prerelease) tags plus
workflow_dispatch for the handoff above — dispatched on a tag ref,
GITHUB_REF_NAME is still the tag, so both entry points share every step. First
job: guard that the tagged commit is reachable from origin/main (anyone can tag
any commit). For workflow permissions, concurrency, and action pinning, follow the
ci-github-actions skill.
- Go CLI / pure-Go binaries: goreleaser is the standard — one config yields
cross-compiled archives, checksums, release notes, Homebrew tap. Config in
references/tag-build-workflows.md.
- Desktop apps (wails3, Electron, etc.): installers can't cross-build — use a
per-OS runner matrix (ubuntu / ubuntu-arm / macos-14 / windows), each producing
its native artifact (tar.gz / dmg / zip), upload as artifacts, then one job
downloads all, aggregates
checksums.txt, and publishes via
softprops/action-gh-release. Senda's full matrix, including the Homebrew-cask
push job, is in references/tag-build-workflows.md.
- Stamp version/commit/date into the binary via
-ldflags from
${GITHUB_REF_NAME#v} — never from a version file that can drift from the tag.
- Prerelease detection: tag contains
- → mark the GitHub release prerelease: true
and skip package-manager publishes (Homebrew, winget, chocolatey).
Secret hygiene in the release path
- gitleaks pre-commit (
.githooks/pre-commit + git config core.hooksPath .githooks): gitleaks git --staged --redact --no-banner. Soft-fail when gitleaks
isn't installed (missing tool shouldn't block work); CI is the backstop. Allowlist
fixtures/generated paths in .gitleaks.toml ([extend] useDefault = true, then
path regexes) — keep the value allowlist tight, only demonstrably fake strings.
- gitleaks in CI on every PR/push (
gitleaks/gitleaks-action@v2 with
fetch-depth: 0), so bypassed hooks still get caught before a tag exists.
- Never echo tokens; pass them via
env:, and use --redact flags where tools
offer them. Release logs are public.
permissions: minimal per workflow: release-please needs contents +
pull-requests + actions: write; the build workflow needs only
contents: write. Cross-repo pushes (Homebrew tap) use a dedicated
narrow-scope PAT stored as a secret, not a widened GITHUB_TOKEN.
Versioning rules
- Semver, tags v-prefixed (
v1.2.3) — set include-v-in-tag: true and match the
tag filter in the build workflow.
- Never delete or retag a published tag. Consumers (go proxy, Homebrew, lock
files) cache by tag; a moved tag corrupts them. Bad release → ship a fix release.
- Broken release PR? Close it and re-trigger; or comment-drive with
Release-As: 1.2.0 in an empty commit to force a version.
Pitfalls
- Squash-merging with a non-conventional PR title → release-please ignores or
misclassifies the change. Lint titles (Stage 1).
- Manually editing CHANGELOG.md or the version file → merge conflicts in the
release PR. Let the bot own them; close/reopen the PR to regenerate.
- Stale release PR after force-push or config change: close it; release-please
recreates it on the next push to main.
- Tag-push workflow "never fires" → the GITHUB_TOKEN recursion block (Stage 2).
- Missing
.release-please-manifest.json bootstrap → wrong starting version.
- Build workflow runs on tags pushed off feature branches → add the on-main guard.
References
references/release-please.md — read when writing or debugging release-please
config/workflow: monorepo manifest setup, changelog-sections, Go vs Node
release-types, bootstrap, Release-As overrides, stale-PR recovery.
references/tag-build-workflows.md — read when writing the tag-triggered build:
goreleaser config for Go, senda's full wails3 per-OS matrix, checksums,
Homebrew tap publishing, winget/chocolatey handoff.