Repository rulesets
This page is the versioned source of the protection. Applying it on GitHub is a manual step — the repository has no way to protect itself, and this asymmetry is intentional: whoever loosens the protection has to be a person, with a record.
The policy that these rules apply is in Branch policy.
Current state:
gh api repos/daneiel/brabo/rulesetsreturns 0. Nothing applied yet. Until then, everything here is declared intent, not real protection.
Prerequisite: the three branches must exist
The ladder has three rungs, and a ruleset can't target what doesn't exist.
git fetch origin
git push origin origin/dev:refs/heads/qa
dev and main already exist. qa is born from dev — the ladder starts
empty and is filled by promotions.
The
rcbranch was removed from the ladder. If it still exists on the remote of an old clone, delete it:git push origin --delete rc. A permanent branch that isn't in the ladder is an invitation for a PR to target a rung that no longer exists.
Ruleset 1 — the three permanent branches
Name: permanentes
Enforcement: Active
Target: Branch → Include by pattern, three entries: dev, qa, main
One ruleset for all three, not three rulesets: the requirement is identical across all of them. What differs between rungs is who approves, and that is decided by
approval-ladderbased on the destination, not by the protection.
Rules to check
| rule | value | why |
|---|---|---|
| Restrict deletions | ✅ | deleting main by accident is irreversible through the interface |
| Block force pushes | ✅ | force-pushing to a permanent branch rewrites what has already been promoted; the rung's tag would end up pointing to a nonexistent commit |
| Require a pull request before merging | ✅ | this is the central rule: no change gets in without a PR |
| ↳ Required approvals | 0 | the counting is done by approval-ladder, which knows the mode and the destination. GitHub's native number doesn't distinguish dev from main |
| ↳ Dismiss stale approvals on push | ✅ | approval of an old commit doesn't count — what was approved isn't what's going to be merged |
| ↳ Require review from Code Owners | ✅ | CODEOWNERS sets the owner as reviewer for everything |
| Require status checks to pass | ✅ | see the list below |
| ↳ Require branches to be up to date | ❌ | would force a rebase on every merge into dev; CI already runs on the merge commit |
| Block merge queue | — | not used |
Required approvals = 0 is not a loosening. GitHub can only count; it
doesn't know that main requires PO + manager and dev requires one dev,
nor that in solo mode the owner's own PR passes without review. Setting
1 here would break solo mode: the owner can't approve their own PR
through the interface, and the PR would stay stuck forever. The real
requirement lives in approval-ladder, which is a required check — and a
required check can't be cheated.
The two configurations, side by side
The ruleset is the same in both modes. What changes is the variable — and that's why the migration doesn't go through Settings → Rules.
solo (today) | community (future) | |
|---|---|---|
| Required approvals in the ruleset | 0 | 0 |
| who requires it | the Escada de aprovação check | the same check |
| variables | APPROVAL_MODE=solo, OWNER_HANDLE | APPROVAL_MODE=community, APROVADORES_* |
dev | 1 from the owner | 1 × devs |
qa | 1 from the owner | 2 × devs |
main | 1 from the owner | 1 × PO + 1 × management |
| the owner's own PR | passes without review | follows the ladder like anyone else's |
| distinct people | suspended | applies on main |
Required approvals stays at 0 in both cases, and that's deliberate.
GitHub can only count: it doesn't distinguish dev from main, doesn't
know about roles, and doesn't know that in solo mode the owner's own PR
passes without review. Setting 1 there would break solo mode — the
owner can't approve their own PR through the interface, and every one of
their PRs would stay stuck forever. The real requirement lives in the
check, which is required and can't be cheated.
Copy-paste to activate each mode:
# solo — what's in effect today
gh variable set APPROVAL_MODE --body solo
gh variable set OWNER_HANDLE --body daneiel
# community — fill in the lists BEFORE flipping the switch
gh variable set APROVADORES_DEVS --body "ana,bruno,carla"
gh variable set APROVADORES_PO --body "paula"
gh variable set APROVADORES_GESTAO --body "gustavo"
gh variable set APPROVAL_MODE --body community
Required checks
Exact name, as GitHub registers it (it's the job's name:, not the
workflow's):
The duration column is measured, not estimated — three real runs, cold
and warm cache. It exists so that "optimizing CI" starts from the number,
not the guess. * marks a row measured from a single real run instead of
three — the job is new (or just renamed) and hasn't been re-sampled cold vs.
warm yet; the same number fills both columns until it is.
| check | workflow | cold | warm |
|---|---|---|---|
Build, scan e smoke das imagens de produção | ci.yml | 295s | 109s |
Testes TS (api) | ci.yml | 177s* | 177s* |
Testes TS (web) | ci.yml | 169s* | 169s* |
Testes TS (pacotes) | ci.yml | 56s* | 56s* |
Testes do engine (ExUnit) | ci.yml | 124s | 39s |
Auditoria de dependências | ci.yml | 99s | 85s |
Lint | ci.yml | 66s | 69s |
Drift, gerados e build | docs-check.yml | 53s | 51s |
Manifests de Kubernetes | ci.yml | 14s | 13s |
Gitleaks no repositório | ci.yml | 5s | 7s |
Política de branches | pr-police.yml | 7s | 7s |
Escada de aprovação | approval-ladder.yml | 13s | 15s |
Check de promoção | promotion-check.yml | 9s | 9s |
Backmerge gate | backmerge-gate.yml | 7s | 7s |
After AT-303..306 (free changes only, no paid runner). The names in the table above did not change — a renamed required check locks every PR — and the numbers above are the BEFORE. What changed underneath:
Build, scan e smoke das imagens de produçãoskips its heavy steps when the diff doesn't touch an image (AT-303). The first step classifies the PR's diff withscripts/ci/diff-toca-imagem.ts(an allow-list of what never enters an image, the smoke or the E2E:docs/exceptdocs/gates.yml,website/,scripts/docs/,deploy/k8s/,.github/exceptci.yml, root*.mdexceptTHIRD_PARTY_NOTICES.md,design/*.md; the spec proves the list against the real Dockerfiles, by mutation). Bake, Trivy, broker, smoke and E2E carryif:per step, and the job always finishes — green, saying why in the job summary. Neverpaths:on the trigger norif:on the job: both would leave the required check pending or pasted with a stale verdict. In doubt (empty diff, HEAD that isn't the PR's merge commit,git difffailing) it runs everything.- The same job caches the Playwright browser, the
e2e/pnpm store and the Trivy database (AT-304) — see what the images job caches. Testes TS (api)andTestes TS (web)became AGGREGATORS of two shards each (AT-305). The jobsTestes TS (api) — shard N/2andTestes TS (web) — shard N/2run half the files each with coverage but with the floor zeroed, and write a vitestblobreport; the aggregator — which keeps the required name — checks both shards endedsuccess, that both reports arrived, and runsvitest --merge-reports --coverage, which applies the UNCHANGEDcoverage.thresholdsofvitest.config.ts/vite.config.tsto the merged coverage (measured locally: merged coverage identical to the single-run one). The aggregator hasif: always()on purpose: without it a red shard would make itskipped, andskippedcounts as green for a required check. The shard names are not required checks and must not be listed in a ruleset. Typecheck runs in shard 1 only.Testes do engine (ExUnit)and the api shards wait for a new job,imagens / Ler as imagens do compose(ADR 0197): it reads the pgvector reference fromdocker/docker-compose.ymland hands it to theirservices:, so no workflow holds an image literal. It is not a required check and must not be listed in a ruleset — whatever it decides shows up in the jobs that need it. Those jobs carryif: ${{ !cancelled() }}for the same reason as the aggregators above: ifimagensfailed, the default would SKIPTestes do engine (ExUnit), andskippedcounts as green for a required check; with it the job runs, gets an empty image and fails.claude-code-review.ymlgotconcurrency(cancel-in-progress, per PR) andtimeout-minutes: 30;docs-check.ymlgottimeout-minuteson both jobs (AT-306).
| check | before, cold | before, warm | after, cold | after, warm | after, docs-only PR |
|---|---|---|---|---|---|
Build, scan e smoke das imagens de produção | 295s | 109s | TODO(humano) | TODO(humano) | TODO(humano) |
Testes TS (api) (shards + aggregator, wall clock) | 177s | 177s | TODO(humano) | TODO(humano) | — |
Testes TS (web) (shards + aggregator, wall clock) | 169s | 169s | TODO(humano) | TODO(humano) | — |
TODO(humano): fill the "after" columns from this PR's own runs (cold: the first push; warm: a re-run of the same commit; docs-only: a PR that only touches
docs/), and then move the numbers into the main table.
Consequence for the edited criterion below: the images job now
depends on the BASE too (the diff is taken against it), while ci.yml still
doesn't re-run on edited. The only unsafe direction is a PR that skipped
its images against one base and is then retargeted to a base where its diff
would touch an image — rare, since promotions run from permanent-branch
heads (whose diff always touches something) and retargets usually shrink the
diff. The next push re-classifies.
ci.yml is parallel except for the two aggregators — test-api and
test-web need their shards, nothing else has needs:.
The full PR verdict costs the SLOWEST chain, not the sum (which is ~12min of
CPU). Before AT-303..305, anyone wanting to shorten the PR had two targets,
and only two:
-
cold cache: the images job, where 195s of the 295s are the
docker buildx bake— the single largest item in all of CI, 3× the second. The bakefile already builds all four in parallel withtype=ghacache per image, and the comment at its top measures why breaking it into a job matrix would be WORSE: 1.7 GB of images per artifact costs more than the build, and the smoke test needs all four on the same daemon; -
the old
Testes TS (api + web)job, where 91s of the 159s werepnpm --filter api test, serialized byfileParallelism: falseinapps/api/vitest.config.tsbecause the ~80 specs that touch the database shared ONEbrabo_testand ran TRUNCATE between tests — arrivals in parallel would collide. That's fixed (perf/banco-por-worker-nos-testes-da-api, CHANGELOG): each Vitest worker gets its OWN database instead —test/support/global-setup.tsmigrates a TEMPLATE once and clones one database per worker viaCREATE DATABASE ... TEMPLATE(cheap, a page copy, not a migration replay),test/support/test-db-name.tsresolves which one a given spec connects to fromVITEST_POOL_ID, andvitest.config.tsflipsfileParallelism: truewithmaxWorkers: 4(fixed at the runner's 4 vCPU, not auto-detected — the number has to be the SAME on a dev machine with more cores and on the runner, or the locally measured behavior isn't what runs there). Measured locally: the api suite's own duration dropped from 792.58s to 471.23s on a clean run (~40%, three repeats with no flakiness). Re-sampled on the actual GitHub-hosted runner (PR #491,ci.yml): thepnpm --filter api teststep itself dropped to 96s — right at the91swarm-cache floor this table used to cite as the theoretical best case, not a lucky outlier.That fix changed the shape of the job it ran inside, and that's what made the SECOND cut possible.
apps/web's own test step had grown to 125s in the same run that measured the job's343stotal (PR #494, job101317149006) — no longer the small tail the callout below used to describe, but a peer of the api's.perf/paraleliza-testes-ts(CHANGELOG) split the job into three parallel ones —test-api(keeps thepostgresservice and theDATABASE_URL/TEST_DATABASE_URLenv, since it's the only one that touches the database),test-web, andtest-packages(scripts de CI, porta de Docker, broker, runner — the six components that never needed Postgres) — each with its owncheckout+setup-node+pnpm install. Measured on the actual GitHub-hosted runner (PR #495,ci.yml): 177s (test-api), 169s (test-web), 56s (test-packages), against 343s for the job they replaced — the check that used to gate the PR is now the SLOWEST of the three, not their sum, a real ~48% cut.test-webandtest-packageswere stable across every run of this PR (164–169s and 55–56s).test-apiwas not: a second push measured it at 388s — worse than the old job's 343s total — with every step still green, then a bare re-run of the SAME commit measured 172s. Three real samples oftest-apion this PR: 177s, 388s, 172s. Two out of three land wheretest-webandtest-packagesdo (comfortably inside the ~4min target); the 388s run is consistent with shared-runner contention on GitHub's side rather than a regression from the split — nothing in the job changed between that run and the retry that measured 172s. Recorded as observed, not smoothed away: whoever chases the next slowtest-apirun should know this variance already existed the day the job was split, and check for contention before assuming a new regression.
The "two costs" warning that used to live here doesn't apply anymore — both premises changed. It used to say splitting
Testes TS (api + web)would (1) erase a required check by renaming it, locking every PR forever unless a fan-in job or a Settings change preserved the name, and (2) not pay off, because the api's 91s would carry the setup regardless of how many jobs it's split into, for a ~7s net gain.Premise 2 stopped being true once the web suite grew to cost as much as the api's — splitting three comparably-sized components, not one big one plus small tails, is what turns "≈150s vs 159s" into "177s vs 343s". Premise 1 stopped applying for an orthogonal reason: no ruleset is applied to this repository yet —
gh api repos/daneiel/brabo/branches/dev/protectionstill returns404, same as the "Current state" note at the top of this page — so there was no required check to erase. The required-checks table above already reflects the rename. If a ruleset is ever applied, listTestes TS (api),Testes TS (web)andTestes TS (pacotes)— never the retiredTestes TS (api + web)name, which would silently match nothing and lock every future PR, exactly as the general warning further below describes.
The check's name is smaller than what it guards.
Drift, gerados e buildis three things in the title and five gates in the job:.docmap.ymlintegrity, generated files up to date, drift, site build, and — ever since the API reference shipped broken in two releases — the API reference renders (scripts/docs/api-render-check.mjs).That last one exists because a green build isn't a page that renders: the MDX compiles, the SSR writes the content,
docs:buildpasses, and the page dies at hydration in the browser. The whole mechanism is indocs/explanation/documentation-workflow.md. When adding a gate to this job, the check's name doesn't change — and that's why it doesn't need to be re-entered into the table above, but it needs to be written down somewhere. Here it is.
pull_request_targetrequires the workflow to be on the DEFAULT branch. Being on the PR's base branch isn't enough. This was measured, not assumed: withpull_request_target,pr-policehad zero runs, whileapproval-ladderanddocs-check— which usepull_request— ran normally from the same commit. Since the default ismainand it only advances through the ladder that the check itself guards, the trigger would be a chicken-and-egg problem. Both usepull_request.
Where GitHub reads each workflow from — three families, three answers. This cost three separate discoveries during this phase, and it isn't obvious anywhere:
trigger reads the workflow from consequence here pull_request,pushthe event's branch works from the first PR pull_request_targetthe default branch wasn't running: mainis behindworkflow_dispatchthe default branch doesn't even appear in the workflow list The last two create a chicken-and-egg problem when the default branch is out of date: the workflow that makes the pipeline move needs to already be on
mainto be triggerable. The workaround was running thepromotescript by hand on the first promotion — the same script, just the manual trigger. Oncemainreceives the workflows, the dispatch works forever.
A required check that never runs locks the PR forever. That's why
ci.yml's trigger covers all three permanent branches — before FASE 6 it only fired on PRs targetingdev, and requiring these checks on adev→qapromotion would produce an eternally pending PR. Thepushtrigger was removed: withpull_requestcovering everything, it only duplicated runs. When adding a new job to CI, either it enters this list, or it's left out on purpose and someone writes down why.
And a required check that doesn't RE-run pastes on a stale verdict. This is the other side of the lesson above, and it cost a PR a mistaken failure.
pull_request's defaults areopened,reopened, andsynchronize— none of those cover changing the base. And changing the base is routine: GitHub opens the PR against the default branch, the author then corrects it todev. On PR #71,Drift, gerados e buildran in the first half-dozen seconds, againstorigin/main...HEAD, and failed on seven files that had already been reviewed and merged in #70. The retarget didn't re-run it; the red stayed.The criterion for knowing who needs
editedis what the check depends on:
the check depends on… needs edited?who only the HEAD no ci.yml— tests the commit, and the base doesn't change the result (except the images job's skip decision since AT-303 — see "Consequence for theeditedcriterion" above)the BASE, or the PR's BODY yes pr-police,approval-ladder,promotion-check,backmerge-gate,docs-checkIn
docs-checkit's both: the drift compares a range that starts at the base, and reads the body past thedocs-not-needed:line. Withoutedited, the escape hatch documented just below was unreachable — writing the justification in the body didn't reevaluate anything, and only a fake commit would unlock the PR.
claude-review is deliberately left out of this list, and this is the
"someone writes down why": LLM review is opinionated and costs tokens, so
it informs the PR without being able to block it. Since it isn't required,
the job can be skipped without leaving the PR pending — and it is skipped
on every PR opened by a bot. The action refuses to run with a non-human
actor ("Workflow initiated by non-human actor: <name> (type: Bot)"), so
without that if the check fails every time. That happened twice: on PRs
#64 and #65 of the v0.3.1 cycle (promotion, opened by
github-actions[bot]) and again on PR #526 (Dependabot).
The second time was a consequence of how the condition was written. It
enumerated one login — github-actions[bot] — which is right until the set
changes, and wrong silently the day a new bot shows up; dependabot.yml
landed on PR #520 and the next Dependabot PR reproduced the failure whose
lesson was already written down. The condition now tests the author's
type, mirroring ehAutorBot (scripts/ci/pr-police.ts), with the
[bot] login suffix as a backstop — GitHub does not allow [ in a human
login, so it cannot be forged.
For promotion PRs there is a second, independent reason: it would be the
same diff reviewed again, since the promotion only carries commits already
reviewed in the PR to dev. And for Dependabot there is a third, which
closes the door on its own — its PRs run with the Dependabot secret
store, separate from the Actions one, so CLAUDE_CODE_OAUTH_TOKEN
arrives empty. Even allowing the actor, the review would have no
credential, and a check that fails for a missing secret is worse than a
skipped one: it reads as a code defect.
What a PR between permanent branches can't satisfy
Drift, gerados e build is required, so it runs on every PR — but the
drift step declares itself inapplicable when the head is a permanent
branch of the repository itself (promotion dev→qa, qa→main; back-merge
main→qa, qa→dev). The job's other steps — docmap, generated files, and
site build — keep running: they only depend on the HEAD, and apply at any
rung.
The reason is the same as claude-review's, with one aggravating factor.
Redundancy, first: a PR between permanent branches has no authorship,
it packages commits whose drift was already charged in the PR to dev,
file by file. Charging again means charging the same debt at every rung.
But, unlike LLM review, here the requirement was unsatisfiable — and
it's what failed #72, the promotion for the v1.0.1 cycle, over
docker/** files that came from #70:
| the apparent way out | why it doesn't exist |
|---|---|
| updating the doc in the promotion PR | promotion-check requires a clean range — the head has to be the tip of the source. Committing there fails the other required check |
repeating the original PR's docs-not-needed: | the promotion PR's body is generated by promote; #70's justification doesn't carry across the rung |
| putting the label on every promotion | that's teaching people to use the escape hatch by reflex, until it stops meaning anything — the opposite of what .docmap.yml asks for |
The filter lives inside the step, not in an if: on the job,
following the same principle promotion-check records: a required check
indexed by sha that doesn't run leaves the PR pending forever. The step
runs, decides it doesn't apply, and writes that in the summary — instead
of disappearing.
A head called
maincoming from a fork is not a promotion. It's a third party's working branch, and it goes through the drift check like any other. That's why the condition matches the name and requires the same repository — the same caveatpr-policeapplies when classifying the PR's family.
Bypass
| who | mode | for what |
|---|---|---|
the BRABO_BOT_TOKEN actor | Always | writing .release/gate.json on main |
No person has bypass — not even the owner. This one belongs to the
bot, and it exists for a reason with no workaround: the gate locks the
branches, and a PR to open the lock would be blocked by the gate itself.
The commit stays in git log, with date and content, and tag-release
recognizes it by what it touches (.release/ and nothing else), not by
who it claims to be.
Warning about the bypass's reach. GitHub rulesets grant bypass to the actor, not to a path: whoever can write
.release/gate.jsononmaincan, technically, write anything. There's no way to restrict by path in the interface. What actually limits it is the workflow — it only writes that one file — and the history, where any other direct commit stands out immediately. Recorded for what it is: a limitation of the tool, not a decision.
The BRABO_BOT_TOKEN secret
Classic PAT with repo + workflow scopes, in Settings → Secrets and
variables → Actions:
gh secret set BRABO_BOT_TOKEN --body '<token>'
It isn't a convenience. Two things depend on it, and both fail silently without it:
| what | why |
|---|---|
the tag triggering Release | a tag created with GITHUB_TOKEN doesn't trigger a workflow |
| back-merge PRs being born with checks | a PR opened with GITHUB_TOKEN doesn't trigger a workflow |
It's GitHub's rule against recursion, and it already took its toll: the
v0.2.0 Release never published because of it. The second case is worse
— a back-merge PR without a check would never go green, and the chain
would lock up forever. That's why the gate job fails loudly when the
secret doesn't exist, instead of proceeding and leaving the repository in
a dead end.
The workflow scope isn't overkill: back-merge PRs carry the entire
branch, and it may contain a change to .github/workflows/**. Without
that scope the push is rejected in exactly the case that matters most —
propagating a CI fix.
Current state: configured and exercised. On
v1.1.1the pipeline closed on its own for the first time —tag-releaseskipped the warning step, pushed the tag with the PAT, and that triggeredrelease.yml, which published the Release. A tag pushed withGITHUB_TOKENwouldn't trigger it; that trigger is proof the token is valid and has push scope.Six tags remain orphaned —
v0.2.0,v0.3.0,v0.3.1,v1.0.0,v1.0.1, andv1.1.0— from the period when the secret didn't exist. The PAT doesn't recover them: it only applies to new tags. For those, the procedure is below.
Republishing a tag that was orphaned
release.yml has workflow_dispatch with a tag input, and it exists
because of the six above. Only the push: tags trigger would make the
failure irreversible: republishing would require deleting and
recreating the tag, that is, rewriting the record to fix its effect.
gh workflow run release.yml -f tag=v1.1.0
Three guards, and each exists because of a concrete failure mode:
| guard | against what |
|---|---|
only OWNER_HANDLE can trigger it (solo mode) | same restriction as promote; the push trigger doesn't go through here, because there what authorized it was the tag |
the input has to match ^v[0-9]+\.[0-9]+\.[0-9]+$ | workflow_dispatch accepts free text. Without this, -qa.1 would publish a Release of something nobody validated as final |
| refuses if the Release already exists | a published note is a record; overwriting silently would erase what someone already read or linked to |
And the checkout is done on the tag, not on the event's ref. In a
dispatch, github.ref_name is the default branch — without this care,
republishing would generate a Release called main, with main's
changelog, with no error at all.
The dispatch only works after this reaches
main. It's the chicken-and-egg problem from the trigger table above:workflow_dispatchreads the workflow from the default branch. While the change is only ondev, the input doesn't even show up.
Ruleset 2 — version tags
Name: tags-de-release
Enforcement: Active
Target: Tag → Include by pattern → v*
| rule | value |
|---|---|
| Restrict creations | ✅ |
| Restrict updates | ✅ |
| Restrict deletions | ✅ |
The v* pattern covers the three forms the pipeline creates: -dev.N,
-qa.N, and the final one. Only tag-release can create them.
What a final tag runs, and why none of it is a required check. Pushing
vX.Y.Zfiresrelease.yml,build-runner-binaries.ymlandpublish-runner.ymlin parallel. Their jobs never appear in the required-checks table above, and that is structural rather than an omission: required checks gate a pull request, and by the time a tag exists the PR has already merged. What gates the tag is the ruleset — onlytag-releasecreates it, and only after the pipeline it runs at the end of.A final tag also fires
install-e2e.yml, which exercises the publishedinstall.shon a clean machine — which is what an ephemeral Actions runner is. Since RN-549 it goes all the way to the end: the five links that close the installation, the local agent standing up and waiting, and the firstrunnerproject being picked up. It does not run onpull_request, and the reason is the same one that keeps signing out of PRs: the signed manifest only exists after a final tag, and making the script run there would mean giving it a door to skip verification (RN-534). The consequence is stated rather than hidden — the PR that writes a change to this workflow does not run it — and what does run on every PR isscripts/dev/install-e2e.spec.ts, which fails if the trigger gainspull_requestor if a phrase the workflow greps for stops existing ininstall.sh.Since ADR 0149,
build-runner-binaries.ymlhas two jobs rather than one: thebuildmatrix (four targets,fail-fast: false) and achecksumsjob. Since RN-565 the second one has noneeds:on the matrix — it waits, with a ceiling, for the Release assets rather than the slowest job — so a target that failed to build, or never got a runner, does not deny the others a signed manifest. It names in the log which targets the manifest does not cover, because achecksums.txtthat lists three and stays quiet about the fourth is worse than none: whoever verifies the three concludes they verified the release.The
darwin-x64target (Intel Mac) left the matrix in ADR 0174, and the cause is measured (AT-065, 2026-09-13): itsmacos-13label pointed at an image GitHub retired in December 2025, and the Intel replacement,macos-15-intel, is scheduled within seconds and builds, but fails--self-test-ptyunder Bun (thenode-ptyonDatanever fires — oven-sh/bun#25822, open). The same proof passes under Node, so an Intel Mac installs the runner withnpm install -g @brabo/runner. A workflow run with an emptytaginput is a dry run: it builds and smoke-tests without attaching anything, which is how that was measured without cutting a tag.
Bypass
| who | mode |
|---|---|
| the app/bot that runs the release workflow | Always |
This is the push exception that the policy allows for: a version is
born from a workflow, never by hand. A manually created tag doesn't go
through the verification that the final tag points to the same commit as
the last -qa.N, and it's exactly that verification that prevents
publishing something different from what was validated.
How to apply
Interface: Settings → Rules → Rulesets → New ruleset.
Via API, if you'd rather version the command:
gh api -X POST repos/daneiel/brabo/rulesets --input ruleset-permanentes.json
gh api repos/daneiel/brabo/rulesets --jq '.[] | "\(.name): \(.enforcement)"'
TODO(human): the two payload
.jsonfiles aren't versioned here because the rulesets API requiresrepository_idand app ids that vary by installation — a fixed file would get copied wrong. If you want to version them, generate withgh api repos/daneiel/brabo/rulesets/<id> > docs/reference/...after applying through the interface, and this document will then point to them.
Settings → Pages: the source is the gh-pages branch
Manual application, for the same reason as the rulesets: the repository versions the source, GitHub receives the application.
Settings → Pages → Build and deployment → Source:
Deploy from a branch, with branch gh-pages and folder
/ (root).
Already applied. gh api repos/daneiel/brabo/pages returns
"build_type": "legacy" with source.branch: "gh-pages", and all three
rungs respond live.
The intermediate step is worth recording, because it's confusing: while
build_type was "workflow", docs-deploy.yml committed to gh-pages
and Pages served nothing from there — the publication stayed in the
repository without going live, with the old site still responding from
actions/deploy-pages's last artifact. Nothing in CI goes red in that
state.
Why the switch is mandatory and not a preference: actions/deploy-pages
publishes one artifact as the entire site and doesn't know how to
update part of a tree, which is incompatible with one subdirectory per
rung. The full design, the discarded alternatives, and the push exception
this opens are in
ADR 0034.
| branch | URL | indexed |
|---|---|---|
| — (index of all three) | https://daneiel.github.io/brabo/ | ❌ noindex, follow |
main | https://daneiel.github.io/brabo/prd/ | ✅ |
qa | https://daneiel.github.io/brabo/qa/ | ❌ noIndex |
dev | https://daneiel.github.io/brabo/dev/ | ❌ noIndex |
The three became symmetric in
ADR 0071, which also
explains the generated root and the 404.html that forwards old links to
the stable rung. ADR 0073
separated the PATH from the BRANCH: main publishes at /prd/, because
the address names the environment for the reader. 404.html started
rewriting /brabo/main/<something> to /brabo/prd/<something> — the old
directory leaves the tree, and keep_files: false removes it.
The Pages configuration doesn't change with either of these — the
source remains the gh-pages branch in the / (root) folder; what
changed is the content the workflow assembles before pushing. And prd
is not a branch: it's in no ruleset, it doesn't exist in git, and
it's just the name of a directory in gh-pages.
gh-pagesdoes NOT enter the permanent-branches ruleset. It isn't permanent, and the bot needs to push to it — including it would lock up the publication itself. That's also whyGITHUB_TOKENis enough there, with no bypass and noBRABO_BOT_TOKEN.
And it's excluded from Gitleaks' scope, for a reason that only surfaced later.
gitleaks detectscans every reachable commit, not just the HEAD history — andfetch-depth: 0brings in all refs. As soon asgh-pageswas born, the built site entered the scan: 112generic-api-keyfindings in a single commit, all indev/assets/js/*.js, which are avatar filenames inside a minified bundle with entropy too high for the rule. It failed the promotion PR #78, the first one scanned after its debut.
ci.ymlnow deletesrefs/remotes/origin/gh-pagesbefore scanning. Not via a path allowlist in.gitleaks.toml: that would apply to every commit, and adev/that one day showed up in the source code would silently go unscanned. Excluding the ref excludes exactly the commits that aren't source.Measured with gitleaks 8.30.1, CI's version: 133 commits and 112 findings before, 132 and none after — and a GitHub PAT with real entropy planted in
apps/api/src/is still detected, which is proof it didn't open a hole.
The family labels need to exist
pr-police applies one of the four labels, and gh pr edit --add-label
fails if the label doesn't exist — which would leave the PR silently
unclassified.
gh label create trabalho --color 0E8A16 --description "Work PR: taxonomy prefix for dev"
gh label create promocao --color 1D76DB --description "Promotion between adjacent rungs, going up"
gh label create retropropagacao --color 5319E7 --description "Back-propagation between adjacent rungs, going down"
gh label create correcao-alta --color D93F0B --description "hotfix: a fix that's born high on the ladder"
No || true on purpose: if the command fails, you need to see it.
Verify it's standing
# do the rulesets exist and are they active?
gh api repos/daneiel/brabo/rulesets --jq '.[] | "\(.name): \(.enforcement)"'
# does the protection respond? (should be REJECTED)
git push origin HEAD:dev
The second command is the test that matters. A ruleset configured but not
verified is indistinguishable from a ruleset that's absent — and the
difference only shows up on the day someone pushes directly to main.