@ebalzan/ci
v2.1.0
Published
One set of CI/CD rules for all your GitHub repos: reusable workflows (auto work and release PRs with AI descriptions, PR checks, semver releases), a CLI that syncs Actions config, repo settings and branch rulesets, and a commitlint commit-msg hook.
Maintainers
Readme
@ebalzan/ci
One set of CI/CD rules for all your GitHub repos:
- Composite GitHub Actions: they open work and release PRs with AI descriptions, check every PR, and cut semver releases from Conventional Commits.
- A CLI (
ebalzan-ci): it onboards a repo and keeps its Actions variables, secrets, settings and branch rulesets in sync. - A commitlint
commit-msghook and a Renovate preset for the shared tooling.
Repos call the actions pinned to a major tag (ebalzan/ci/actions/<name>@v2), so fixes reach every repo with each release, with nothing to copy around.
The git flow
flowchart LR
F["feat/… fix/… branch"] -- "work PR<br/>squash merge" --> D[dev]
D -- "release PR<br/>ship label = fast-forward" --> M[main]
M -- "tag vX.Y.Z<br/>GitHub Release" --> R((deploy))There are three kinds of branch, and history stays linear: no merge commits anywhere.
| Branch | Lives | Gets changes by | Rewritten? |
| ---------------------- | ----------- | ------------------------------------------------ | ------------------------------------------ |
| <type>/<description> | hours, days | your commits | yes, by you (rebase, --force-with-lease) |
| dev | forever | squash-merged work PRs | never |
| main | forever | the Release workflow fast-forwarding it to dev | never |
The two hops merge differently:
- work branch →
dev: squash merge. All of a branch's commits become one commit ondev, titled after the PR. dev→main: fast-forward.mainmoves forward todev's commits as they are. It gets no new commits and no merge commit.
What happens to the commits:
1. Work on a branch: as many commits as you like.
feat/search-filters a ── b ── c
/
dev, main ── X ────┘
2. Squash-merge the work PR: a, b and c become ONE new commit S on dev.
The branch is deleted; main has not moved.
dev ── X ── S S = feat: add search filters (#12)
main ── X
3. More work PRs land on dev the same way, one commit each.
dev ── X ── S ── T T = fix: empty results (#13)
main ── X
4. Add the `ship` label to the release PR: the Release workflow adds the
release commit R, then moves main AND dev forward to it. Nothing is merged
or rewritten: main gets dev's exact commits, with the same SHAs.
dev, main ── X ── S ── T ── R R = chore(release): 1.4.0, tagged v1.4.0After step 4, dev and main point at the same commit, so the next work branch starts from the release.
1. Work on a branch
Branch off dev. The branch name is <type>/<description>, where <type> is one of the work types:
git switch dev && git pull
git switch -c feat/search-filters
git commit -m "feat: add search filters" # Conventional Commits, checked by the hook
git push -u origin feat/search-filtersAuto PR opens a PR into dev on that push. It sets:
- the title: the branch name in the Conventional shape —
refact/ifc-property-and-material-parsingbecomesrefact: ifc property and material parsing, orrefact!: …when a commit on the branch is breaking (!or aBREAKING CHANGE:footer). After that the title is yours to edit; only a breaking commit pushed later adds the!to it; - the type label, with you as the assignee;
- the milestone and the project board, when the repo configures them;
- an AI-written description.
PR Checks runs once per push and waits for Auto PR to finish, so it checks the finished PR.
While you work, keep the branch on top of dev by rebasing, never merging. The branch is yours, so rewriting it is fine:
git fetch && git rebase origin/dev
git push --force-with-leaseOptionally, tidy the commits before review: commit fixes with git commit --fixup <sha>, then fold them in with git rebase -i --autosquash origin/dev.
2. Merge the work PR into dev (squash)
PR Checks (pr-check / Work PR) must pass:
- the PR title and every commit follow Conventional Commits;
- the base is
dev; - the branch is
<type>/…, and the title's type matches the branch's type; - it has the type label and an assignee, and the author is the owner, a member or a collaborator;
- it has the milestone and is on the board, when those are set;
- it has a description.
It warns about large PRs, breaking changes and a branch that is behind dev.
Then squash-merge it. The PR title becomes the one commit on dev, and that commit is what drives the next version.
3. The release PR (dev → main)
Every push to dev makes Auto PR open or update the release PR, dev → main:
- Title: the release it will cut, e.g.
feat: release v1.4.0. git-cliff computes it from the commits since the last tag. - Description: an AI summary above the changelog.
- Label:
release.
Its check (pr-check / Release PR) holds it to the computed title. It also requires the label, an assignee, the milestone and board when they're set, and a description. Finally, dev must contain main and must not contain a merge commit.
4. Ship it with the ship label, never the merge button
When the release PR's check passes, add the ship label. The Release workflow then does this:
- Verify.
devis still the PR's head,mainfast-forwards to it, there's no merge commit, and the PR check passed. If the check is still running, it waits. Adding the label doesn't run the check again: PR Checks skips that one event, and Release reads the result the commit already has. Removing the label still runs it. - Version and tag. It recomputes the title and requires it to equal the PR's. In a Node repo it commits
chore(release): X.Y.Zto bumppackage.json. Then it pushesmainanddevto that same commit, along withvX.Y.Zand the moving major tagvX, in one atomic push. - Publish. It creates the GitHub Release
vX.Y.Z, using the PR's description as the notes. - Deploy. It calls the repo's own
.github/workflows/deploy.ymlwith the new tag.
GitHub then marks the release PR as merged. main got exactly dev's commits, with the same SHAs. dev and main end on the same commit, so the next release starts clean and no open work branch is disturbed.
If something moved in the meantime, nothing is pushed: remove the label and add it again. If someone merges the release PR with a GitHub button anyway, the job fails and prints the command that puts main back. Squash and rebase both rewrite dev's commits, which would make main and dev diverge.
There's no hotfix path straight to
main. A fix goesfix/…→dev→ release PR like any other change.mainonly ever moves through a release.
Work types
config/types.json is the only place a type is defined. Each type works as a branch prefix, a Conventional Commit and PR-title type, and a PR label.
| Type | Release |
| ---------------------------------------------------------------------------------- | ------- |
| feat | minor |
| fix, refact, perf, revert, build, ci, docs, test, style, chore | patch |
A ! after the type (feat!: …), or a BREAKING CHANGE: footer, makes any type a major release, from 0.x too. A repo with no tag yet starts at v0.1.0. Note that the type is refact, not refactor.
Branch protection
ebalzan-ci sync applies these rulesets (config/rulesets/) and repository settings (config/repo-settings.json):
| Ruleset | Default | Effect |
| ---------------------------------- | -------- | ------------------------------------------------------------------------------------------ |
| ci: protect main | active | no deletion, no force-push, linear history |
| ci: protect dev | active | no deletion, no force-push, linear history |
| ci: require pull request | disabled | dev changes only via a squash-merged work PR that passed pr-check / Work PR |
| ci: require release pull request | disabled | nobody but GitHub Actions can update main, so the release PR's merge buttons are blocked |
Settings: squash merge only, with the PR title as the commit title. Merge commits and rebase merges are off. Head branches are deleted on merge.
The two disabled rulesets need the GitHub Actions app to be able to bypass them, because the release pushes with GITHUB_TOKEN. Enable one per repo with .github/rulesets/<file>.json containing { "enforcement": "active" }, run one release, and confirm it went through.
Getting started
Requirements:
- Node >= 22.12;
gh, authenticated with admin rights on the repo.
1. Shared secrets (once)
cp config/secrets.env.example config/secrets.env # in a checkout of this repo, or ~/.config/ebalzan-ci/| Secret | Used for |
| ------------------- | ---------------------------------------------------------------------------------------------------------------------------- |
| ANTHROPIC_API_KEY | the AI descriptions of work and release PRs. Without it, PRs keep the commit list or the changelog. |
| GH_PROJECT_TOKEN | a classic PAT (repo + project), so the PRs Auto PR opens trigger PR Checks and can reach a user-owned Projects board |
2. Onboard a repo
In the repo's checkout:
npx @ebalzan/ci onboardonboard does the following:
- Creates the branch
ci/onboard-ebalzan-cifromdev. - Writes the workflows (
auto-pr.yml,pr-checks.yml,release.yml, and a starterdeploy.yml), plus.github/actions.envandrenovate.json. - For a Node repo, adds the husky
commit-msghook, the@ebalzan/ciandhuskydevDependencies, and the shared Prettier and ESLint config. - Gitignores the secrets files and commits everything.
- Creates
devif it's missing, runssync, and pushes.
Auto PR then opens the onboarding PR into dev. Squash-merge it. Run npx @ebalzan/ci onboard --help for the options (--ref, --runs-on, --force FILE, --no-npm, --no-push, --dry-run, …).
Then:
- add the repo's own build, test and lint jobs to
.github/workflows/pr-checks.yml(it has a commented example); - put what ships a release into
.github/workflows/deploy.yml. It's the repo's own file, andonboardnever overwrites it.
3. Keep it in sync
npx @ebalzan/ci sync --dry-run # see the plan
npx @ebalzan/ci syncEach setting comes from two layers, and the repo's own value wins:
| What | Shared (every repo) | The repo's own |
| ------------------- | ----------------------------- | ---------------------------------- |
| variables | config/actions.env | .github/actions.env |
| secrets | <secrets>/secrets.env | .github/secrets.env (gitignored) |
| environment secrets | <secrets>/secrets.<env>.env | .github/secrets.<env>.env |
| repository settings | config/repo-settings.json | .github/repo-settings.json |
| branch rulesets | config/rulesets/<file>.json | .github/rulesets/<file>.json |
How the layers combine:
- JSON files deep-merge.
- Shared variables are read by the actions at run time, never copied onto repos, so changing one only needs a release of this package.
- Removals:
--prunedeletes what no file defines.
Configuration
Repo variables (.github/actions.env) or the shared config/actions.env:
| Variable | Default | Effect |
| ---------------------------------------------- | -------------- | ------------------------------------------------------------- |
| AUTO_PROJECT_NUMBER | none | the Projects (v2) board every PR must be on |
| AUTO_PROJECT_OWNER | the repo owner | that board's owner |
| AUTO_MILESTONE_TITLE | none | the milestone every PR must have |
| AUTO_MAX_CHANGED_LINES | 500 | work PRs over this get a size warning |
| AUTO_DESCRIBE_MODEL / AUTO_DESCRIBE_EFFORT | shared | the Claude model, and its effort, that writes PR descriptions |
What's inside
| Path | What |
| ----------------------------------- | --------------------------------------------------------------------------------- |
| actions/auto-pr | opens or updates the work PR of a branch, or the release PR of dev |
| actions/pr-checks | commitlint on the title and commits, then Danger rules for the PR's kind |
| actions/release | verifies, fast-forwards, tags and publishes the release on the ship label |
| bin/, lib/ | the ebalzan-ci CLI: onboard, sync, commit-msg |
| templates/ | the files onboard writes into a repo |
| config/ | types, shared variables, repo settings, rulesets, the git-cliff config |
| renovate/tooling.json | Renovate preset for the shared tooling (github>ebalzan/ci//renovate/tooling#v2) |
| commitlint.config.mjs, scripts/ | the commit rules, the release title and the changelog |
Upgrading from v1
In v1, the release PR was merged with a merge commit. In v2 it ships with the ship label (a fast-forward). Repos pinned to @v1 keep working until they move. Per repo, between two releases:
- If
git rev-list --merges origin/main..origin/devprints anything (an unreleasedMerge branch 'main' into dev), rebase it away once, before syncing:git switch dev && git rebase origin/main && git push --force-with-lease. - Refresh the workflows.
release.ymlis required;deploy.ymlis kept:npx @ebalzan/ci@2 onboard --ref v2 \ --force .github/workflows/release.yml --force .github/workflows/pr-checks.yml \ --force .github/workflows/auto-pr.yml --force renovate.json - Apply the new settings and rulesets with
npx @ebalzan/ci@2 sync. Drop anyallow_merge_commit: trueoverride in.github/repo-settings.json. - Squash-merge the onboarding PR into
dev, then release by adding theshiplabel to the release PR.
Do steps 2–4 together. The old release.yml expects a merge commit, and step 3 turns merge commits off.
Skipping PR Checks on the ship label
Adding ship to the release PR doesn't need to re-run PR Checks. actions/release picks this up through @v2, but pr-checks.yml is the repo's own file. Per repo, by hand, because onboard --force would drop the repo's own jobs:
- In the
release-prjob'sif, add&& !(github.event.action == 'labeled' && github.event.label.name == 'ship'). Leavework-pras it is. - In the
ifof each of the repo's own jobs, add&& !(github.event.action == 'labeled' && github.event.label.name == 'ship' && github.event.pull_request.head.ref == 'dev'). - Change the concurrency group to
pr-checks-${{ github.event.pull_request.number }}${{ github.event.action == 'labeled' && github.event.label.name == 'ship' && github.event.pull_request.head.ref == 'dev' && '-ship' || '' }}.
Repos that don't change keep working, and keep re-running their checks on ship.
Developing this repo
npm ci
npm run check # bash -n / node --check on every script
npm run lint # ESLint, zero warnings
npm run format:check # Prettier
npm test # node:test, on throwaway git repos with a stubbed ghThe same flow applies here: work branches squash into dev. This repo has no workflows of its own, so release it by hand, with a fast-forward rather than a merge commit:
git switch main && git merge --ff-only dev && git push