Anonymized. This describes a real engagement, with all client identifiers removed. The architecture and the reasoning are as built. The companion repository is devsecops-pipeline-patterns.

The problem

A single repository where everyone can push anywhere has a quiet property: the same access that lets someone fix a typo also lets them change a deployment step, rewrite a quality gate, or push straight to production.

That is tolerable for one person. It stops being tolerable the moment a second person is added — a junior, a contractor, an agency — because the blast radius of a mistake, or a compromised account, is now everything.

The obvious fix — "give them a restricted account" — does not work, because the repository is the thing that carries production reach: its pipeline holds the deploy credentials. Restricting the account while leaving the pipeline reachable solves nothing.

The design

Split the repository from the deployment target, and make exactly one automated job the bridge between them.

GitHub                                    GitLab
──────                                    ──────
main        (protected, owner only)  ──►  main   (build stage, automatic)
  │              │                              │
  │              │  merge = the approval         │
  │              ▼                              ▼
  │         mirror job  ── push ──►        deploy_production
  │         (sole holder of the           (when: manual — owner only)
  │          GitLab push token)
  │
staging    (contributors push here
  │         freely; four gates run)
  └── PR  staging → main  (owner reviews, CODEOWNERS requires it)

The people doing day-to-day work never hold a GitLab account. They cannot see the deploy job, cannot read its variables, and cannot reach the host. The only thing that crosses the boundary is reviewed code, pushed by one job that is itself part of the reviewed repository.

The four gates

Before a human looks at a change at all, four automated gates have to pass. The point is that by the time the reviewer reads the diff, the mechanical objections are already settled and their attention goes to the parts only a human can judge.

GateAnswers
1. Lint + unit testsIs the change internally consistent, does the logic work?
2. Integration testsDoes it work on real Postgres with real migrations?
3. Security scanVulnerable dependency, or a dangerous pattern?
4. Code qualityDoes it meet the project's bar?

Why linting is scoped to changed files

Turning a linter on repo-wide fails immediately on an unknown number of pre-existing violations that have nothing to do with the change under review. That is a blocked pipeline, not a useful gate. Scoping the linter to the files that changed still blocks on any violation in a changed line — every new and modified line is held to the standard from day one — while the rest of the codebase is cleaned up incrementally as files are touched. That beats one large, unreviewable cleanup pull request before linting can be switched on at all.

Why Gate 2 verifies the migration result, not the command

A migration once failed silently in production. The command exited without an error, the schema change rolled back, and the database stayed on the previous revision — while the deploy reported success. Running migrations in CI would not have caught it, because the migration command succeeded.

So Gate 2 reads back the revision the database actually reports and compares it to the migration head. A mismatch fails the build. The lesson generalises past migrations: verify outcomes, not commands, and a check whose failure mode is "logs an error and continues" is documentation, not a gate.

Why the access split is the point

The gates are the visible part. The structure underneath is what makes them worth having:

  • Contributors push only to the working branch. The gates run there, so feedback is immediate without a review round-trip.
  • Every path to the protected branch goes through a reviewed pull request. CODEOWNERS requires the owner's review; branch protection requires that review before merge. This is the only human approval gate, and it cannot be skipped — merging is the approval.
  • The mirror job is the only writer to GitLab. A single job holding a single scoped token is auditable in a way that "everyone has access" never is.
  • Tokens are scoped to one project. A deploy token cannot push, a project access token is unavailable on a free group project, and a legacy personal token over-grants. A fine-grained personal access token, resource Code, permission Push, for one project, is the shape that actually fits.
  • Secrets live on a named environment, not on the repository. Any job that wants them must declare that environment; a job running from a contributor's branch cannot read them at all.
  • Production deploy stays manual. The build stage is automatic so nobody waits on a human for a routine build; the deploy is not. It is the last, independent gate.

A guardrail that cannot be edited away

A workflow comments loudly on any pull request that touches the pipeline's own definition or the review-ownership file. The choice was not to block those edits — Git already shows them in the diff, and the reviewer is the right control — but to make sure a change to the pipeline's security logic is never scrolled past inside a large diff.

It cannot be defeated by deleting it, because deleting it is itself such a change, and the same rule would flag it.

What it costs, and when not to do it

Two forges instead of one is real operational overhead: two sets of tokens, two user interfaces, one more thing to learn. It is only worth paying if the access separation is an actual requirement.

On a single-person project it is overhead for no benefit — one forge and the four gates are the right shape. The design earns its cost when at least one contributor must work without production access. Stating that plainly matters: a pattern presented as universally good is less useful than one that says where it stops applying.