Branch protection cannot be configured from repository contents. This file is the checklist a maintainer clicks through once, on https://github.com/zyndata/vaulta-mark. It mirrors RELEASE §2; if the two ever disagree, RELEASE.md is the source and this file is the thing to fix.
Everything below takes about ten minutes and is done from the repository’s Settings tab. You need admin rights on the repository.
§1–§4 are applied. They were not always applicable.
This section is history, kept because the reason matters. While the repository was private on a Free plan, branch protection was refused outright — it is a paid or public-repository feature. Measured 2026-08-14, both routes answered identically:
$ gh api repos/zyndata/vaulta-mark/rulesets 403 Upgrade to GitHub Pro or make this repository public to enable this feature. $ gh api repos/zyndata/vaulta-mark/branches/main/protection 403 Upgrade to GitHub Pro or make this repository public to enable this feature.The Settings tab did not offer them either — the API and the UI are the same feature gate. So for the whole build, from Phase 0 to 1.0.0, the accident protection was a habit rather than a rule: nothing stopped a force-push over
main, nothing stopped deleting either branch. §1–§4 were kept in full anyway, so that the day the repository was published they would be a ten-minute checklist rather than something to reconstruct.The repository was made public on 2026-08-15 (PLAN.md §2.5, D36), and that day arrived. §1–§4 below are applied, not aspirational; §6 is how to check that for yourself rather than taking this paragraph’s word for it. The habit stays anyway — a rule that stops a force-push is not a reason to reach for one.
Two things are worth carrying forward from the private years. §5’s code-security features were gated the same way and are off by default when they become available — being eligible is not being enabled. And the earlier version of this note claimed §1–§4 “apply unchanged” while private; that was an assumption nobody had tested, and it was wrong. Neither the API nor the UI was consulted before it was written down. Measure the setting; do not reason about the plan.
This is a solo repository. There is deliberately no pull-request requirement and no approval gate — they would gate a reviewer who does not exist, and the ceremony costs more than it catches.
The rules that remain exist for one purpose: preventing accidents that cannot be undone. Losing a branch, rewriting published history, force-pushing over work. Everything else is left off on purpose.
What replaces the PR gate:
npm run verify locally before every push. This is the actual gate — same lint, type-check,
test, build, and invariant scan CI runs.phase-N-done tag as the “finished and green” marker.mainSettings → Rules → Rulesets → New ruleset → New branch ruleset.
| Field | Value |
|---|---|
| Ruleset name | main |
| Enforcement status | Active |
| Bypass list | (empty — do not add yourself; the point is to stop your own accidents) |
| Target branches | Add target → Include by pattern → main |
Rules to enable:
| Rule | State | Why |
|---|---|---|
| Restrict deletions | ✅ | main is what gets tagged, built, and shipped. It should not be deletable. |
| Block force pushes | ✅ | Published history stays published. A release tag must keep pointing at real, reachable code. |
| Require linear history | ❌ | This was listed as ✅ from Phase 0 to 2026-08-15, and it was wrong. The stated reason — that a --no-ff merge “stays linear in the first-parent sense” — describes git log --first-parent, not the setting. GitHub’s Require linear history rejects any push introducing a commit with more than one parent, and a --no-ff release merge is exactly that. Turning it on would block §4 step 5 — every future release — and the error would arrive on release day, on main, with the tag already written. It was never applied, because it was never applicable; the claim went untested for the whole build for the same reason §1–§4 did. |
| Require signed commits | Optional | Worth it for a crypto tool if you already have commit signing set up. Turning it on without a working key just blocks you at an inconvenient moment. |
| Require a pull request before merging | ❌ | No second reviewer exists. Turn this on the day a collaborator joins. |
| Require status checks to pass | ❌ | Only enforceable on pull requests — see §3. |
| Everything else | ❌ |
devSettings → Rules → Rulesets → New ruleset → New branch ruleset.
| Field | Value |
|---|---|
| Ruleset name | dev |
| Enforcement status | Active |
| Bypass list | (empty) |
| Target branches | Add target → Include by pattern → dev |
| Rule | State | Why |
|---|---|---|
| Restrict deletions | ✅ | dev is the whole project’s working history. |
| Block force pushes | ✅ | The important one. When dev breaks, fix forward — never rewrite it. |
| Everything else | ❌ | Direct commits are the workflow here, not an exception to it. |
Do not protect feat/*. Those branches are meant to be rewritten and deleted.
GitHub can only require a status check as a merge condition on a pull request. With no PR
requirement, CI runs on every push but cannot block one. A red commit can land on dev.
That is a deliberate trade, and it is fine only because the replacement gate is real:
npm run verify before every push. Not “usually”. Every push.node_modules, a file
you forgot to commit, a platform difference. Advisory, but not optional to read.dev. Never force-push to unbreak it; that is what the force-push block is for.main is different. It only ever receives a --no-ff merge from a dev commit whose CI is
green. That check is manual and it is the one you must not skip — main is what gets tagged,
built, and shipped to users.If a collaborator ever joins: turn on Require a pull request before merging (1 approval) and
Require status checks to pass with the verify and e2e checks, for main first, then for
dev once the habit sticks. Nothing else about the model has to change.
Settings → Rules → Rulesets → New ruleset → New tag ruleset.
| Field | Value |
|---|---|
| Ruleset name | release-tags |
| Enforcement status | Active |
| Target tags | Include by pattern → v* and phase-*-done |
| Restrict deletions | ✅ |
| Block force pushes | ✅ |
Tags are the immutable record of what was built and what was released. A moved v1.0.0 tag means the
published SHA-256 in the release notes no longer proves anything.
Not enabled, and the trigger for enabling it is a person, not a date: Restrict creations on
v*. The ruleset above stops a tag being moved or deleted; it does not stop one being created.
For a solo repository that is no protection at all — the only account with push access is the one
the rule would restrict. It becomes the control that matters the day a second contributor gains
push access, because pushing a v* tag is what starts the release workflow, and that workflow is
the path to the Chrome Web Store. When you add a collaborator, come back and tick Restrict
creations with a bypass for yourself, in the same sitting as granting the access. It is recorded
here rather than left to be rediscovered, because the moment it becomes necessary is precisely the
moment nobody is thinking about tag rulesets.
Settings → Code security (some are under Settings → General → Features):
All of these are free on a public repository. Every one of them is off by default — becoming eligible for a feature does not switch it on.
| Setting | State | Why |
|---|---|---|
| Private vulnerability reporting | ✅ | This is the channel SECURITY.md and the issue templates point at, and it is public-repositories-only — for the whole private period those links went to a form that only accounts with repository access could reach, which SECURITY.md said out loud. Switching it on was the first thing done after publication. |
| Dependabot alerts | ✅ | |
| Dependabot security updates | ✅ | |
| Dependabot version updates | ✅ | Configured by .github/dependabot.yml — weekly npm, monthly actions. |
| Secret scanning | ✅ | Free on public repositories; on the private Free plan it needed Advanced Security. The Chrome Web Store credentials in RELEASE §6 are exactly the kind of thing that gets pasted into a commit by accident. |
| Secret scanning push protection | ✅ | Blocks the paste before it enters the history. That mattered more when publication was still ahead; now that the history is public, it is the difference between a close call and a rotation. |
| CodeQL / code scanning | ✅ | Free on public repositories, and the upload step that failed on every push while private now works — which is why codeql.yml analyses each push and pull request again rather than only running weekly. |
| Discussions | ✅ | The issue-template config routes questions there. |
| Wiki, Projects | ❌ | Unused; documentation lives in the repository. |
| Secret scanning — non-provider patterns | ❌ unavailable | Generic private keys and connection strings, as opposed to recognised vendor tokens. Part of the paid Secret Protection product, and not available on this repository. Measured 2026-08-15, twice: PATCH /repos/… accepts the field and returns it still disabled — a silent no-op, not a 403 — and Settings → Advanced Security ends at Push protection, with no such control on the page. The API’s silence is the trap: it reads as success. |
| Secret scanning — validity checks | ❌ unavailable | Whether a leaked token is still live, which is the first question after a leak. Same product, same silent no-op, absent from the same page. |
Those last two are the third instance of this file’s own lesson, and the first where the API lied
rather than refused: a 403 is a fact, while a 200 that changes nothing is a claim. Read the
setting back after writing it, and believe the readback over the response. They are also the
reason dev-unpacked.pem no longer lives in the working tree at all
(RELEASE §5.4): the scanner that would
have caught a PEM on its way into a commit is the one this plan does not include, so the file was
moved somewhere a commit cannot reach rather than trusted to a .gitignore line.
Settings → Environments:
| Environment | Protection | Why |
|---|---|---|
github-pages |
GitHub-managed | Created by the Pages deployment; serves the privacy policy. |
chrome-web-store |
Required reviewers = the owner | The gate the release workflow’s publish job waits on, and where the four CWS_* secrets live so they exist only for an approved run. See RELEASE §6.5 — including why it must be created before the first dispatch: a workflow naming an environment that does not exist does not fail, it creates one with no rules, and the documented human gate silently never fires. |
Settings → Actions → General:
| Setting | Value |
|---|---|
| Workflow permissions | Read repository contents (read-only GITHUB_TOKEN) |
| Allow GitHub Actions to create and approve pull requests | ❌ |
| Allowed actions | GitHub-owned only — allowed_actions: selected, github_owned_allowed: true, no patterns, verified creators not allowed |
That last row is stricter than it looks and is worth keeping. Anything outside actions/* and
github/* is refused at workflow startup, before any job’s if is evaluated — so a blocked
action fails the whole run even when the job referencing it would have been skipped. It caught
softprops/action-gh-release in release.yml on 2026-08-16, on the first dispatch that workflow
ever received, meaning it had not been runnable since Phase 13 wrote it. The fix was to drop the
action for gh rather than add a pattern exception: that step runs with contents: write, which
§2’s audit named as the sharpest third-party risk in the repository. Prefer removing an action
over allowlisting one.
The release workflow requests contents: write explicitly in its own file, which is the correct
granularity — the default token stays read-only.
Settings → General → Pull Requests: allow merge commits (release merges are --no-ff), and
enable Automatically delete head branches so feat/* branches clean themselves up.
From a clean clone, these must all fail:
git push --force origin dev # blocked: force push
git push origin --delete dev # blocked: deletion
git push --force origin main # blocked: force push
git push --force origin v1.0.0 # blocked: tag ruleset (once a v* tag exists)
And this must still work, because direct commits on dev are the workflow:
git commit -m "docs: ..." && git push origin dev
If a force push succeeds, the ruleset is either Disabled, targeting the wrong pattern, or you are in its bypass list. Check enforcement status first — a ruleset saved in Evaluate mode reports what it would have done and blocks nothing.
Without pushing anything, this asks GitHub which rules it will actually evaluate for a ref — which is a stronger answer than reading your own ruleset definitions back, since it resolves patterns, bypass lists and enforcement mode the way a real push will:
gh api repos/zyndata/vaulta-mark/rules/branches/main
gh api repos/zyndata/vaulta-mark/rules/branches/dev
Both must list deletion and non_fast_forward. An empty array means nothing is protecting that
branch, whatever the Rulesets page appears to say.
Publishing is one click and is irreversible in practice: the entire history, every branch, and every tag become readable at once, and anything that was ever committed is assumed cloned. This section was written in advance as a pre-flight checklist; it is kept as the record of what was actually run, because “we checked” is not a finding and a future fork deserves the evidence.
The audit, over all 91 commits on every branch and tag:
| Check | Result |
|---|---|
Secrets ever committed — *.pem, .env*, client_secret_*.json, *.crx |
Only .env.example. A secret deleted in a later commit still sits in the history, so this was run over --all, not the working tree. |
Credential material by content — the OAuth client id, GOCSPX, BEGIN PRIVATE KEY, refresh-token prefixes |
git log --all -S finds nothing for any of them. |
| Assistant tooling (PLAN.md §8.1) | git log --all --oneline -- CLAUDE.md CLAUDE.local.md .claude .mcp.json prints nothing. The rule held from the first commit. |
| Real vault data or personal URLs in fixtures | test/fixtures/ uses example.com/.net/.org only. No *.vmv outside the synthetic fixture. |
| Local absolute paths | No C:\Users\… or D:\Work\… in the tree or in any commit. |
| Committer identity — it becomes public with every commit | All 91 commits are zyndata <8853758+zyndata@users.noreply.github.com>. GitHub’s noreply address throughout; no personal email is exposed. |
Applied immediately after the flip, in this order:
SECURITY.md and the issue chooser had been pointing
at a form that could not exist until this was on.push and pull_request triggers in
.github/workflows/codeql.yml, restored. They had been
removed while private because the upload step failed on every single push, and a permanently red
check is worse than no check. On a public repository the upload works, and analysing each change
as it lands is the entire point — weekly alone lets a bad commit sit for six days.main,
dev and release-tags, each with an empty bypass list, because a rule you can bypass does
not stop your own accident. Applying them is also what caught the Require linear history error
in §1: a setting nobody could turn on was a setting nobody had checked.main / docs, which is what finally
answers the Store’s privacy-policy URL (RELEASE §8). It
serves from main, so it is empty until a release merge lands there.README.md, SECURITY.md, CONTRIBUTING.md,
docs/PRIVACY.md, docs/STORE_LISTING.md and D36 in PLAN.md, all rewritten — in the
commit before the flip, so the repository was never publicly claiming to be private.The one real decision at this moment was §3’s, and it was taken deliberately: require a pull request stays off. Outside PRs are now possible, but there is still exactly one maintainer, and a PR gate with a self-approval is ceremony that catches nothing. Revisit it when a second person has commit rights — not when the first outside PR arrives.