GitOps: edit → merge → release → deploy
Neon Law Navigator's entire lifecycle hangs off one branch — main. Every change reaches it the same way (a PR that
auto-merges), main is what the production cluster pulls, and the daily release rides off main's history. This doc is
the source of truth for that flow; the workspace CLAUDE.md carries only the short rules and links here.
For agents, this sits inside the three codebase actions: create or groom an issue, create a PR, or review/update an existing PR. The branch ceremony, test gate, release tag, and deploy hand-off are all supporting steps inside those actions.
main is sacred and squash-merge-only
- Never commit directly to
main. It advances solely through pull requests — there is no direct push, ever. Every PR lands by squash. Squash is the only merge strategy: each PR collapses to exactly one commit onmain, regardless of how many commits (orMerge branch 'main'commits) the branch carried. Merge commits and rebase-merge are disabled on the repo — there is no other way to land. Somain's history is one linear commit per PR, and a branch's internal history never reaches it. mainis what production runs. The GKE cluster's Config Sync pullsexamples/deploy/k8s/gkefrommain(seegke-prod.md), and the nightly release tag is cut frommain's tip. A bad merge tomainis a production concern, not just a code-review one.
The branch → PR → merge queue flow
Every task — agent or human — follows the same three steps. No workflow invents its own branch ceremony; they all inherit this.
- Branch. Before the first edit, run
cargo run -p cli -- dev worktree-env up --branch <kebab-topic>(for example,daily-cd-pipeline) and continue in the printed task checkout. The command branches an agent-supplied checkout in place or creates a sibling worktree fromorigin/main; never commit tomain. - Push + open a PR.
git push -u origin <branch>thengh pr create. - Let the merge queue land it. Merging is hands-off through GitHub's native merge queue (configured on the
mainbranch ruleset, squash method, batch size 1).ci.yml'senable-automergejob turns on GitHub auto-merge when the PR opens — and re-enables it on every push, since GitHub silently disables auto-merge whenever the queue evicts a PR (routine when a PR ahead of it merges and invalidates its speculative batch), which would otherwise strand it green-but-unqueued. To deliberately hold a ready PR, convert it to draft (the job skips drafts); do not rely on disabling auto-merge, which the next push turns back on. Enabling auto-merge enqueues the PR the moment the gate is green and every review thread is resolved. The queue then builds a temporarygh-readonly-queue/main/...ref —mainrebased + your PR alone — fires amerge_groupevent that re-runs the fullcargo test (workspace)suite against that rebased commit (the exact tree that will land), and squash-merges only if it passes. So the suite runs against the real merge result on every rebase, never just the stale PR branch. You do not babysit the merge, rebase by hand, or click "Merge when ready". The whole PR becomes one commit onmain; write the PR title as the Conventional Commit you want inmain's history, since that title (not the branch's individual commits) is the squashed commit's subject.
Codified GitHub merge gate
navigator ops github setup is the source of truth for the repository-side merge gate: the production ruleset, DevX's
issue labels, and the repository's squash-only/delete-branch settings. It reads GITHUB_TOKEN and discovers the target
from GITHUB_REPOSITORY or the local origin remote. Run navigator ops github setup --dry-run first to inspect the
exact plan; the non-dry run applies only drift. The command also requires a nonempty .github/CODEOWNERS file, so its
code-owner approval setting has an owner to enforce.
The ruleset requires one fresh code-owner approval and resolved review threads before a PR can enter the merge queue. Every push dismisses prior approvals. This applies to automated and human PRs alike: a human code owner must approve the final commit before it can land.
Auto-merge runs as a GitHub App, not
GITHUB_TOKEN. Theenable-automergejob mints an installation token viaactions/create-github-app-tokenand enables auto-merge with that. This is deliberate: GitHub's docs state that "events triggered by theGITHUB_TOKENwill not create a new workflow run" (Triggering a workflow), so a PR the default token enqueues never firesmerge_group, the requiredcargo test (workspace)check never reports, and the entry times out and is silently dropped — the PR sits "auto-merge enabled, CLEAN" forever (see community discussion #70310). A GitHub App token (or a PAT) is the documented fix. The job is guarded on the App being configured: setAUTOMERGE_APP_IDandAUTOMERGE_APP_PRIVATE_KEYin thenavigator-gitopsDoppler config, which auto-syncs to GitHub Actions secrets (this repo keeps no Actions variables — Doppler is the single source of truth, so the App ID is stored as a secret too and hoisted to job-levelenvso the stepifcan gate on it). The App is installed on the repo withcontents: write+pull_requests: write. Until both exist the step is skipped and the job falls back toGITHUB_TOKEN— which enables auto-merge but does not reliably enqueue, so the first PR after each fresh setup may need a one-time manual nudge (gh pr merge <n>as a real user).One wrinkle: a Dependabot-opened PR runs with the Dependabot secret store, not the Actions one, so the same two App secrets must also be set there or the bump PR enables auto-merge but never enqueues. NeonLaw mirrors them once; forks consume the published GHCR image and never hit this.
TDD and the pre-commit gate
-
Tests land in the same commit as the implementation they cover. When a PR changes Rust files or build/runtime configuration, run before committing:
cargo fmt cargo clippy --workspace --all-targets -- -D warnings cargo test --workspace -
When a PR changes only Markdown or other prose files and no Rust files changed, the full Rust suite is not required. Run the Markdown gate for the touched docs instead:
cargo run -p cli -- validate <path> -
After the PR is created or updated, clean task-owned build and e2e resources.
cargo cleanthe task worktree when Rust commands created local build artifacts, stop the KIND/dev stack you started, and prune task-created Docker build cache or images. Do not prune Docker volumes without explicit approval.
CI/CD — three workflows, plus maintenance
GitHub Actions carries exactly three CI/CD workflows, one per trigger — do not fold new gate logic into a fourth. Periodic housekeeping is the one carve-out: it lives in a separate maintenance workflow on its own cron, outside the CI/CD path, so a retention change never lands in a release diff and a cleanup run never shares state with a deploy.
| Workflow | Trigger | Job |
|---|---|---|
ci.yml | pull_request → main | fmt + Markdown CLI + clippy + tests |
release-tag.yml | cron 01:11 UTC | cut + push the YY.M.D tag |
deploy.yml | tag push or dispatch | integration → push images → Slack |
cleanup.yml | cron 15:00 UTC | prune ghcr versions > 14 days (maintenance) |
PR flow — ci.yml
Runs only on every pull_request targeting main — never on push, so main itself runs no CI on merge (it
advances merge-only, and the heavy paths ride the release tag). Lean by design: a format check, a repository-wide
Markdown validation pass through the navigator CLI, a clippy pass with warnings as errors, then the workspace test
suite — nothing else. The Markdown pass builds navigator once and runs the local debug binary with validate ., so
ordinary docs get prose Markdown rules and notation templates get the stricter questionnaire/workflow/template rule set.
The job keeps target artifacts out of the cache, disables CI debug info, and runs cargo clean between clippy and test
so the standard hosted runner has enough disk. It still uses two Rust-specific caches: Swatinem/rust-cache restores
Cargo's registry, git, and tool caches, while sccache stores reusable rustc outputs in GitHub Actions cache. That
gives successive PRs a compiler cache without restoring the full target/ tree that previously exhausted runner disk.
One shared postgres:17-alpine container backs the whole job via TEST_DATABASE_URL (so store::test_support makes a
per-test schema in that single container instead of spawning a testcontainer per binary).
Integration/KIND/docker/browser work does not run here.
Cron flow — release-tag.yml
Fires daily at 01:11 UTC (11 1 * * * — cron is UTC-only, no DST). Its only job is to cut a calendar release tag
YY.M.D (e.g. 26.6.18 for 2026-06-18) and push it with a PAT (secrets.RELEASE_PAT) so the push re-triggers the tag
flow below. Every component carries no leading zeros — the firm-wide version convention — so June 5 is 26.6.5.
That keeps each tag a valid semver (which the Zed-extension publish requires) and makes the calendar tag, the ghcr image
tag, the GitHub Release, the Homebrew formula, and navigator --version all agree on one shape.
Tag flow — deploy.yml
Triggered by the YY.M.D tag push, or manually with workflow_dispatch when an operator needs another publish during
the same day. The nightly path keeps the plain calendar tag. A manual dispatch derives a YY.M.D.H tag from the UTC
hour, so a run on June 25, 2026 at 14:00 UTC publishes 26.6.25.14 instead of overwriting 26.6.25. Either path runs
the full KIND integration suite, then builds and pushes every image — the service images (navigator-web,
navigator-workflows-service, navigator-git) and the five CronJob trigger images (navigator-*-trigger) — to
ghcr.io tagged with that release version plus latest. The service images publish as linux/amd64 + linux/arm64
manifest lists from native runners, so Apple-Silicon KIND pulls do not rely on emulation. The trigger images stay
amd64-only because their CronJobs are rare local paths and their shared Containerfile is x86_64-pinned. In parallel with
image publishing, it builds the public navigator CLI and navigator-lsp binaries on native Linux, macOS, and Windows
runners, records GitHub artifact attestations for the downloadable archives, and attaches those six archives to the
GitHub Release for that version. Once the Release is up, a homebrew-tap job cross-pollinates the public
neon-law-foundation/homebrew-tap repo: it recomputes the macOS checksum and rewrites only the navigator CLI formula
to the new release version, then pushes it with the gitops PAT secrets.GHCR_CLEANUP_PAT (which must carry
contents:write on the homebrew-tap repo) so that invoking brew install neon-law-foundation/tap/navigator always
tracks the latest build. The published formula is Apple-Silicon only, matching the single macOS binary the release
builds. Separately, the Zed extension flow pulls the matching navigator-lsp release binary for editor installs. On
success it posts a "ready to deploy" message to the engineering Slack channel (the prod ops incoming webhook,
secrets.SLACK_WEBHOOK_URL, synced from Doppler), tagging Nick with the exact ship command to roll the new images to
prod; a failure on any stage posts a separate alert to the same channel, also tagging Nick. The images are published,
not rolled out — see Publish vs. roll out below.
Maintenance flow — cleanup.yml
Separate from the CI/CD three, on its own cron and knowing nothing about tags. Fires daily at 15:00 UTC — well after
the day's tag cut and deploy, so the day's fresh images already exist — and prunes ghcr: it discovers every
navigator-* container package through GitHub's package API, then deletes versions older than 14 days through gh api
authenticated with secrets.GHCR_CLEANUP_PAT. That secret must be a classic PAT from an org/package admin with
read:packages and delete:packages; fine-grained PATs cannot list org packages through this endpoint. latest and
the recent dated tags are re-pushed daily by deploy.yml, so their versions stay under the cutoff and only stale images
are swept. It then posts a Slack summary to #engineering with secrets.SLACK_ENGINEERING_WEBHOOK_URL, tagging Nick on
failure. New scheduled maintenance belongs here, not in a CI/CD workflow.
Publish vs. roll out
The tag flow publishes dated images to ghcr.io; it does not roll them onto the cluster. There is no automatic production rollout, by design — promoting a dated image to prod is a separate, deliberate, operator-driven step. This keeps every cluster mutation in the hands of a human at a trusted, authenticated workstation: GitHub Actions holds no GCP credential, no cluster access, and no path to write to prod.
The manual deploy
When the "ready to deploy" Slack message lands (the green-deploy hand-off from deploy.yml), an operator rolls the
published image onto the GKE cluster with ship — this is the exact command the Slack message hands you, with the date
filled in:
doppler run --project navigator --config prd -- \
cargo run --release -p cli -- ops ship --tag YY.M.D
ship builds nothing — the images already exist from the tag flow — and runs from any directory. It renders the
CLI-embedded manifest tree and kubectl apply -ks it (the unconditional reconcile, owned by
gke-prod.md), confirms the prod Secret satisfies the new binary's boot invariants,
rolls navigator-web, workflows-service, and the optional navigator-git Deployment out together at the tag, re-pins
every trigger CronJob to the same tag so a roll is atomic across all navigator images, and re-registers the worker
with Restate. ship --restart-only is the no-rebuild path after a bare Secret rotation. The full operational recipe —
the pre-roll Secret check and the restart path — lives in cloud-operations.md; the cluster's
pull-based, credential-free image delivery is in gke-prod.md.
Forks that run a GitOps controller (Config Sync, Argo CD, Flux) can let the controller reconcile the manifests instead
of running ship by hand; this repo's production roll is the manual ship above.