Skip to main content
Fabien Fouassier
Back to Writing

Building an Internal Engineering Platform

How I consolidated CI/CD pipelines, local git hooks, and lint rules into a shared engineering platform consumed across Flutter, Swift, React Native, Next.js, NestJS, and Laravel projects at my agency.

Platform EngineeringCI/CDDXToolingJune 28, 2026·14 min readRead article
Building an Internal Engineering Platform

Internal tooling rots quietly. Every project keeps its own copy of the CI pipeline, the git hooks, the lint rules — and over a few years those copies drift, accumulate dead infrastructure, and lose any clear owner. I replaced that across my agency with a shared engineering platform: CI/CD pipelines, local git hooks, and lint rules factored into versioned repositories that projects consume as a dependency. This article is about how I separated the problem so the platform could be owned in one place instead of re-paid in every project.

Key takeaways

  • Define correctness once and run it in two places — lint rules live in one repo, and both CI and local hooks invoke the same definitions, so the two enforcement points cannot drift apart
  • Local hooks are feedback, not enforcement — CI is the only gate, because git commit --no-verify always exists and designing hooks as enforcement creates false confidence
  • Shared tooling needs the same discipline as product code: versioning, pinned consumption, contract checks, and an integration consumer that proves the pieces actually compose

The Problem Was Ownership, Not CI

At Orkester I work across the agency's client projects on a wide spread of stacks: Flutter, Swift, React Native, Next.js, NestJS, Laravel, Android, and PHP. Over the years, each project had accumulated its own tooling — CI templates, lint rules, formatting scripts, git hooks, Docker images, deployment helpers, runner conventions. Some of it was copied between projects. Some had drifted. Some still referenced a previous company name, dead GitLab hosts, deprecated services, or tooling nobody actively owned.

The immediate trigger was the CI/CD layer. The mobile CI setup was spread across 13 repositories accreted over several years and five GitLab groups: Flutter templates, native Android templates, iOS Fastlane templates, Docker images, a private CocoaPods mirror, even a legacy macOS daemon written to throttle CocoaPods updates on a shared runner — a problem the CocoaPods CDN had solved in 2019. Some of it still ran. None of it was maintainable. A recent GitLab migration had left template includes pointing at a dead host and Docker references pointing at registries that no longer existed, and two projects hadn't had a working pipeline in weeks.

But broken CI was the symptom. The real problem was duplicated engineering infrastructure with no clear ownership model. Fixing a bug meant finding and editing every copy. Onboarding a new project meant copying someone else's .gitlab-ci.yml and inheriting its debt. The goal was to replace project-specific, copy-pasted tooling with a shared platform that projects consume as a pinned dependency — and to own it once.

The Split That Organizes Everything

The platform covers three layers of the same quality problem, and the most important decision was refusing to mix them:

  1. CI/CD pipelines — what runs in GitLab CI and blocks merges
  2. Local git hooks — what runs on the developer's machine before code reaches CI
  3. Lint and formatting rules — what defines correctness for both layers

A fourth repository validates the whole system from the outside, from the perspective of a real consumer. The structure is four repositories under a single internal namespace:

RepositoryResponsibility
lint-configsDefines shared lint, format, and type-checking rules
gitlab-ci-componentsRuns those rules and the deployment steps in CI
git-hooksRuns the local pre-commit, pre-push, and commit-msg hooks
components-smoke-testProves the three repositories compose correctly from a consumer's seat

The load-bearing idea is the split between defining rules and running them. lint-configs defines what is correct. gitlab-ci-components and git-hooks run those definitions in two different contexts — one in CI, one on the developer's machine. Because both pull from a single source of truth, a developer's pre-push hook and the CI gate invoke the same rules instead of maintaining two parallel versions of "correct" that slowly diverge. That non-drift guarantee is the entire reason the rules are factored into their own repository rather than duplicated in each runner.

Defining Correctness Once

lint-configs is the rule-definition layer, and it's the part that makes the platform coherent. Without it, the local hooks and the CI components would both run tools, but each project would still define its own rules — moving the duplication rather than removing it.

The repository publishes shared configuration packages under the internal @orkester-group/ scope — eslint-config, tsconfig, and prettier-config — to a private registry. Each stack is organized into two vertical tiers. essentials covers correctness, security, and likely bugs; extended adds stricter style, complexity caps, and opinionated patterns. The invariant is simple and enforced in code: extended spreads essentials and only tightens. It never loosens.

That tiering exists for adoption. A legacy project can start on essentials and get useful signal — tens of findings, not thousands — without turning the migration into a cleanup project. Some rules ship as warn in essentials and error in extended, so a retrofit gets visibility without an unbootable build. Stricter rules stay available for when the project is ready.

Framework variants like react, next, and react-native are additive overlays composed at the consumer, not a pre-built cross-product. Shipping every combination of tier and framework would grow combinatorially — the exact failure mode where a shared config package becomes harder to maintain than the project-specific configs it replaced. Composition gives N + 2 artifacts instead of 2 × N, and consumers assemble only the pieces they need.

Running the Rules in CI

gitlab-ci-components is the CI/CD layer. It replaced the old template sprawl with GitLab CI/CD components: typed, versioned pipeline building blocks consumed through include: component. Each component has a narrow responsibility — flutter-analyze-test runs analysis and tests, flutter-build-android builds Android artifacts, firebase-app-distribution and playstore-upload handle deployment, runner-defaults centralizes runner selection, rules-git-flow centralizes branch and tag policy, and a pre-commit component runs the read-only hook variants in CI.

The design treats components as APIs. Inputs are typed and validated at pipeline-creation time. Consumers pin versions explicitly. Secrets stay as file-type CI variables, never component inputs, and are copied into place rather than echoed. Path inputs carry a consistent _PATH suffix, booleans are always positive (coverage: true, never no_coverage), and inputs that would only rename a CI variable are forbidden — that discipline keeps legacy conventions from leaking back into the typed-input layer. The effect at the consumer site is the brand disappearing and the contract becoming legible:

# Before — one of sixteen near-identical job blocks, pointing at a dead host
include:
  - project: 'android-ci-group/ci-flutter'
    ref: master
    file: '.template-flutter.yml'

build_android_dev:
  extends: .build_android_fvm_template
  variables:
    APPSOLUTE_FLAVOR: 'dev'
    APPSOLUTE_APP_IDENTIFIER: 'fr.example.dev'
    APPSOLUTE_SLEEP_DURATION: 0
  # ...repeated for staging, prod × build/deploy
# After — typed inputs, pinned version, no template chain
include:
  - component: gitlab.com/orkester-group/internal/tools/gitlab-ci-components/rules-git-flow@v1.0.0
  - component: gitlab.com/orkester-group/internal/tools/gitlab-ci-components/flutter-build-android@v1.0.0
    inputs:
      package_id: 'fr.example.dev'
      flavor: 'dev'
      main_file_path: 'lib/main_dev.dart'
      job_name: 'build-android-dev'
  # ...staging and prod override inputs only

The repository also owns the Docker images the components run on: a Flutter image with JDK, Android SDK, and Flutter pinned, and a mobile-tools image carrying the Firebase and Play Store deployment scripts. This matters because CI reliability depends on the runtime, not just the YAML. The old setup relied on opaque images and dead registries; the new one pins base images by digest, verifies downloaded toolchains by SHA before extraction, builds only on semver tags, and never references latest.

Running the Rules Locally

git-hooks is the local feedback layer, and its purpose is deliberately narrow. It is not there to enforce correctness — git hooks are bypassable by design, and treating them as a gate creates false confidence the first time someone runs --no-verify. Their job is to kill one specific waste: the open-MR → CI-fail → fix → push round-trip, by catching formatting, lint, and commit-message problems before code ever reaches a pipeline. The actual gate is always CI.

The repository ships versioned hook bundles consumed through the pre-commit framework. The live bundles cover universal checks (secret detection via gitleaks, commit-message format, branch-name format), Flutter checks (formatting, analysis, pubspec lock consistency), and TS/JS checks (Prettier, ESLint, type-checking). The secret-detection choice was earned: a real credential had once leaked into a Fastlane repo's history that narrow regex scanning missed, so the hook runs a broad ruleset against the staged index instead.

The key pattern is the fix/check split. Every fixable tool ships two hook IDs — a local *-fix variant that may mutate files at commit time, and a read-only *-check variant that CI runs through the same framework with a stage filter. Local hooks fix; CI only verifies and never touches the workspace. Splitting at the hook-definition layer rather than through per-consumer argument toggles keeps the contract centralized in .pre-commit-hooks.yaml instead of scattered across every consumer project.

Proving It Composes

Each tooling repository validates itself. gitlab-ci-components passes its own meta-CI, git-hooks passes its shell fixtures, lint-configs proves its configs load. That's necessary, but none of it proves a real project can consume all three together.

components-smoke-test exists for exactly that. It is not a versioned tooling repo — it's a throwaway consumer that exercises the platform from the outside, pinning the CI components, the hook bundles, and the lint configs the way a real project would. It's structured as a five-stack polyglot monorepo: Flutter, iOS/Swift, Android/Kotlin, JS/TS, and PHP. Each stack ships a deliberately broken fixture, and the smoke test asserts that the broken fixture actually fails. That assertion matters more than a green pipeline: a linter that silently excluded the files it was supposed to check would otherwise turn the whole smoke test into a rubber stamp.

It earns its keep. The smoke test has already caught integration bugs that no isolated repo test could — protected signing variables that aren't injected on merge-request pipelines (which had been hard-failing the Android build), a third-party SwiftLint image shipping without git so the dependency fetch couldn't clone, and a JS/TS package-resolution issue where local dependencies resolved differently in CI than on a developer's machine. Every one of those would otherwise have surfaced inside a client project's pipeline. Absorbing them here, before any client pins a tag, is the point.

Shipping It Like Product Code

The platform uses the same release discipline across every repository. Each one has semantic versioning, tag-pinned consumption, changelog enforcement, a single-page catalog, and CI gates that run at tag time — before any publish step — to prevent the public API, the catalog, and the changelog from drifting apart. One tag releases everything in a repo at that version; consumers always pin a tag, and no project consumes main.

That friction is intentional. A tooling upgrade should be visible in code review. If the CI contract changes, the consumer opts into that change deliberately rather than waking up to a silently retargeted pipeline. The cost is release ceremony; the payoff is reproducibility. Notably, the meta-CI that enforces all of this ended up being comparable in size to the components themselves — the infrastructure that keeps the platform honest is real infrastructure on its own.

Keeping the three tooling repos separate follows the same logic. They have different responsibilities, different consumers, and different failure modes: CI components run remotely and block merges, hooks run locally and provide early feedback, and lint configs shouldn't care whether their rules are invoked by GitLab CI, by pre-commit, or by a developer running a command. A new ESLint rule shouldn't force a CI component release; a new deployment component shouldn't force every project to bump its local hooks. Separate ownership, integrated validation — and the smoke test is what proves the separation still composes.

Migrating Without Breaking Live Projects

I didn't start by writing components. I started with an audit — a 400-line document mapping every legacy repository, every active consumer, every template include, and every piece of dead or duplicated infrastructure. The real risk was never implementation complexity. It was breaking a live project by removing something that looked dead but wasn't. The audit stayed stable while the plan was revised several times; it was the factual ground every later decision was judged against.

That caution paid for itself. My plan had marked Crashlytics dSYM upload as decommissioned. A verification step found that one iOS consumer still set the variable enabling dSYM upload across several jobs — removing that branch would have silently broken its crash symbolication. The framing of "Crashlytics is dead" was the post-migration target state stated as current reality, and those are different things. The right move was to keep the path alive until that consumer's migration removed the dependency explicitly. The sequencing of a platform migration matters as much as the replacement itself: audit first, validate the architecture against the most complex consumers before committing to it, ship the lower-risk Linux-side components first, pilot on safe consumers, migrate active projects one at a time, and keep legacy repositories read-only before archiving them.

That same discipline ran through the design decisions. I reversed several of them deliberately — choosing typed components over the familiar anchors-and-extends pattern once I understood the real consumer count, keeping CocoaPods on macOS rather than moving it to Linux because I had no evidence it would work for the agency's actual Podfiles, flattening a nested template layout when GitLab's component resolution rejected it. A reversal driven by "I lack evidence" is exactly the kind of thing a planning phase is supposed to catch before it reaches code.

What This Changed

The agency now has a shared tooling platform that projects adopt instead of recreating. A project pins the CI components it needs, installs the local hook bundle for its stack, consumes the shared lint configs, and gets a consistent quality baseline across local development and CI — without copying anyone's .gitlab-ci.yml or inheriting its debt. The Flutter and Android build, test, and deploy paths are live and consumed in CI; the shared lint packages and hook bundles back the JS/TS projects; and the five-stack smoke test gates the whole composition before any new tag reaches a client. New projects bootstrap onto the platform by default, and active ones are migrating onto it one at a time.

It's designed to keep growing through real consumers rather than landing in one large release — that's a feature, not an unfinished state. Each stack joins the platform once a real project has run it clean, which is what keeps the contract trustworthy. Thirteen repositories collapsed into a coherent, owned platform, and the duplicated-infrastructure tax that every project used to pay is now paid once.

Reflection

The most important decision wasn't choosing GitLab CI components, the pre-commit framework, or ESLint flat config. It was separating the problem correctly. Rules belong in one place. Execution belongs in another. Integration validation belongs outside both. Once that split was clear, each repository had an obvious responsibility, and the rest of the work became contract design, migration planning, and operational discipline.

That's what internal platform work actually is — not abstracting everything, but deciding where each responsibility should live so future projects stop paying the same setup cost over and over.

Tech at a Glance

  • CI / orchestration: GitLab CI/CD components (typed inputs, catalog resource, semver-tagged releases), GitLab Package Registry
  • Containers: Docker + BuildKit, digest-pinned base images, SHA-verified toolchains, no latest
  • Lint / format: ESLint flat config + typescript-eslint, TypeScript, Prettier, flutter analyze / dart format, packaged as essentials / extended tiers under @orkester-group/
  • Local hooks: pre-commit framework, gitleaks secret scanning, conventional-commit and branch-name checks, fix/check dual-hook pattern
  • Deploy: Firebase App Distribution and Google Play Publisher API via baked deployment scripts
  • Validation: per-repo meta-CI, a five-stack polyglot smoke-test consumer with asserted-red broken fixtures