Skip to content

Path-filtered pipelines, one shape per service

Three services. Three databases. Three teams that don’t ship on the same schedule.

The wrong answer is one pipeline that deploys all three. The Orders team pushes a table change on a Tuesday and it waits in a queue behind Catalog’s Friday release window. Or worse — a bad migration in Sessions blocks the Orders deploy and now two teams are paged for a problem that isn’t theirs. One combined pipeline for a polyglot fleet is coupling wearing a CI costume.

Hey folks. I’m Forge Barrett, master of the Content Forge here at SchemaSmith.

Last module you organized three packages and got credentials entirely out of source control with SmithySettings_ environment variables. This module you put those two things together inside GitHub Actions. The result is three pipelines, one per service, each built from exactly the same shape — path-filtered to its own folder, WhatIf-gated on every PR, deploying for real on merge. Same tool, same pattern, independent release tracks.

The key teaching in this module is not the YAML syntax. It’s the operational principle: one reusable pipeline shape, instantiated independently per service.

In production, each service is its own repository. Orders has .github/workflows/deploy.yml. Catalog has .github/workflows/deploy.yml. Sessions has .github/workflows/deploy.yml. Three files, three repos, three independent Git histories. No PR on Catalog can block a deploy on Orders. No emergency hotfix to Sessions requires touching anything owned by another team.

The lab co-locates all three pipelines under one folder so you can see the structural parity side by side. Don’t read that as a recommendation. The lab arrangement is a teaching device. The production arrangement is a polyrepo — one repo per service, and each pipeline file is identical in shape down to the job names. Only three things differ per instantiation:

  • The path filter — which folder triggers this pipeline
  • The secret prefix — which set of GitHub secrets supplies the connection
  • The working directory — which service directory the schemaquench command runs from

Everything else is the same file, stamped three times.

Independent pipelines are not a nice-to-have. They’re the only arrangement that lets three services release on three different cadences without mutual interference. A combined pipeline creates a single point of failure and a coordination tax that compounds with every new team member and every new environment.

Here’s the full sqlserver/ci/deploy.yml — the Orders service pipeline on SQL Server:

name: Deploy Orders (SQL Server)
on:
pull_request:
paths:
- 'sqlserver/**'
push:
branches:
- main
paths:
- 'sqlserver/**'
jobs:
whatif:
# Runs on every PR that touches this service's folder.
# Applies nothing — shows reviewers exactly what would change.
if: github.event_name == 'pull_request'
name: WhatIf preview
runs-on: ubuntu-latest
env:
SmithySettings_Target__Server: ${{ secrets.ORDERS_DB_SERVER }}
SmithySettings_Target__User: ${{ secrets.ORDERS_DB_USER }}
SmithySettings_Target__Password: ${{ secrets.ORDERS_DB_PASSWORD }}
steps:
- uses: actions/checkout@v4
- name: Install SchemaSmith
run: curl -fsSL https://schemasmith.com/dl/install.sh | sh
- name: WhatIf gate
working-directory: sqlserver
run: >
schemaquench
--ConfigFile:quench.settings.whatif.json
--LogPath:"$PWD/logs"
- name: Upload WhatIf logs
if: always()
uses: actions/upload-artifact@v4
with:
name: whatif-logs-sqlserver
path: sqlserver/logs/
deploy:
# Runs only when a PR merges to main (push event).
# Performs the real schema convergence.
if: github.event_name == 'push'
name: Deploy to target
runs-on: ubuntu-latest
env:
SmithySettings_Target__Server: ${{ secrets.ORDERS_DB_SERVER }}
SmithySettings_Target__User: ${{ secrets.ORDERS_DB_USER }}
SmithySettings_Target__Password: ${{ secrets.ORDERS_DB_PASSWORD }}
steps:
- uses: actions/checkout@v4
- name: Install SchemaSmith
run: curl -fsSL https://schemasmith.com/dl/install.sh | sh
- name: Deploy schema
working-directory: sqlserver
run: >
schemaquench
--ConfigFile:quench.settings.json
--LogPath:"$PWD/logs"
- name: Upload deploy logs
if: always()
uses: actions/upload-artifact@v4
with:
name: deploy-logs-sqlserver
path: sqlserver/logs/

Read through it once and the structure is plain. The on: block has two triggers. A pull_request trigger with paths: ['sqlserver/**'] — fires only when a PR touches this service’s folder. A push trigger on main with the same path filter — fires only when those changes land. Two events, two jobs, mutual exclusion via if: github.event_name ==.

The whatif job runs on PR. The deploy job runs on merge. They never run at the same time for the same event, and they share the same connection env vars — the SmithySettings_Target__* block pulled from GitHub secrets.

Terminal window
curl -fsSL https://schemasmith.com/dl/install.sh | sh

That’s it. SchemaSmith is not a .NET tool. It ships as a native binary and the install script handles the rest. On Windows CI you’d use choco install schemasmith instead, but GitHub’s ubuntu-latest runner is the standard choice for these pipelines. One step, no SDK required, no version pinning of a runtime.

The whatif job runs this command:

Terminal window
schemaquench \
--ConfigFile:quench.settings.whatif.json \
--LogPath:"$PWD/logs"

quench.settings.whatif.json is identical to the deploy settings file but sets "WhatIfONLY": true. That tells SchemaQuench to compare the declared package state against the live database, emit a full artifact showing what would change, and then exit 0 without applying anything. The database is untouched. The logs land in the artifact upload so every reviewer on the PR can open them and see exactly what the merge would do to the schema.

Driving the preview from a settings file — rather than a command-line flag — is a deliberate safety choice. The gate behaves identically on every SchemaSmith version, and a PR job can never accidentally run a real deploy because a CLI didn’t recognize a flag. The safe default is baked into the file the PR job points at.

When the PR has no pending changes — the package and the database already agree — the WhatIf run exits 0 cleanly and reports nothing to do. That’s the green check that says “merge this and nothing changes.” When there are pending changes, the artifact shows every ADD, DROP, or ALTER that would execute. Reviewers see the full delta before anyone approves.

The real deploy on merge points at the normal settings file, where WhatIfONLY is false:

Terminal window
schemaquench \
--ConfigFile:quench.settings.json \
--LogPath:"$PWD/logs"

SchemaQuench converges the database to the declared state and exits 0. Re-run it immediately after and it finds nothing to do — convergence is idempotent. The startup log echoes WhatIfONLY: true on a preview run, so you can confirm at a glance which mode ran.

Certification note: On all three engines — SQL Server, PostgreSQL, and MySQL — both the WhatIf gate (quench.settings.whatif.json) and the real deploy (quench.settings.json) ran to exit 0 with connection details injected entirely via SmithySettings_Target__* env vars. The WhatIf run engaged preview mode (the startup log confirmed WhatIfONLY: true) and applied nothing.

The env: block on both jobs in the Orders pipeline:

env:
SmithySettings_Target__Server: ${{ secrets.ORDERS_DB_SERVER }}
SmithySettings_Target__User: ${{ secrets.ORDERS_DB_USER }}
SmithySettings_Target__Password: ${{ secrets.ORDERS_DB_PASSWORD }}

Module 3 established the pattern: SmithySettings_ maps to config keys, __ nests. SmithySettings_Target__Server sets Target:Server. SmithySettings_Target__Password sets Target:Password. The quench.settings.json has no Server, no User, no Password — those arrive at runtime from secrets the CI environment guards.

In CI, those env vars are GitHub secrets. No credentials appear in any file that gets committed. The settings file is identical in dev and production; only the env vars differ. Rotate a password and the only thing that changes is the secret value in GitHub — the pipeline, the package, and the settings file are untouched.

Catalog and Sessions — the same shape, different prefixes

Section titled “Catalog and Sessions — the same shape, different prefixes”

postgres/ci/deploy.yml and mysql/ci/deploy.yml are structurally identical to the Orders pipeline. The three things that differ:

Orders (SQL Server)Catalog (PostgreSQL)Sessions (MySQL)
paths: filtersqlserver/**postgres/**mysql/**
Secret prefixORDERS_DB_*CATALOG_DB_*SESSIONS_DB_*
working-directory:sqlserverpostgresmysql

The PostgreSQL pipeline adds SmithySettings_Target__Port: ${{ secrets.CATALOG_DB_PORT }} and the MySQL pipeline adds the same for SESSIONS_DB_PORT, because those engines need an explicit port where SQL Server in this lab uses the default. Everything else — the whatif and deploy job structure, the install step, the schemaquench commands, the artifact uploads — is word-for-word identical across all three.

That’s the point. You learn the shape once. You stamp it per service. In a real polyrepo each stamp lives in its own repo and never sees the others.

Three pipelines with paths: filters means:

  • A PR that touches only sqlserver/** triggers the Orders pipeline. Catalog and Sessions pipelines don’t run. Zero CI minutes charged to the other two teams.
  • The Catalog team can merge a schema change and deploy on Wednesday without touching Orders or Sessions.
  • Sessions can run a hotfix deploy at 2am. The Orders and Catalog pipelines are silent.
  • A broken migration in Sessions cannot block a Orders release. The pipelines have no dependency on each other.

This is not one pipeline that routes by service name. There is no conditional logic inside a single workflow that asks “which service changed?” There is no shared job that blocks three deploys. There is no combined release gate. Three pipelines. Three independent triggers. Three independent deployment histories.

The WhatIf gate enforces a review checkpoint at the PR boundary for each service independently. The Orders team reviews Orders schema changes. The Catalog team reviews Catalog changes. Nobody reviews anyone else’s changes unless they choose to — and they choose by looking at the pipeline artifact, not by waiting in a shared deploy queue.


One shape, three instantiations, three release tracks that will never block each other. A smith who builds three different forges to serve three different workshops doesn’t combine them into one forge with three output ports — that’s a single point of failure with three queues. You build three independent forges from the same blueprint, and each one serves its workshop on its own schedule.

Subscribe and stick around. Next time — the Module 5 capstone — you’ll see what happens when those independent services need to talk to the same reference data: an independent release for one service, and a cross-service reference-data dependency done right via expand/contract. The independence you’ve built here is what makes that pattern safe.

Until then, may every pipeline you forge fire on its own anvil — never waiting on another’s hammer.

— Forge

Check yourself: The Orders team wants to deploy a schema change on Tuesday. The Catalog and Sessions teams have nothing to ship this week. With the per-service pipeline structure from this module, what happens when the Orders PR is merged to main?

Only the Orders pipeline runs. The paths: ['sqlserver/**'] filter on the Orders deploy.yml means the push-to-main event triggers that workflow and only that workflow. The Catalog and Sessions pipelines have paths: ['postgres/**'] and paths: ['mysql/**'] respectively — those filters don’t match the merge, so those pipelines are never triggered. The Catalog and Sessions databases are untouched. The Orders team deploys independently, on their own cadence, without coordination or approval from the other two teams.

Check yourself: The PR job points at `quench.settings.whatif.json` (which sets `WhatIfONLY: true`). What does that do, and what guarantee does it give reviewers before they approve the merge?

With WhatIfONLY: true, SchemaQuench compares the declared package state against the live database, produces a full artifact showing every change that would execute — every ADD, DROP, and ALTER — and then exits 0 without applying anything. The database is untouched. Reviewers can open the uploaded artifact and see the exact delta the merge would produce before they approve. If the package and database already agree, the run exits 0 cleanly with nothing to do — that’s the green check that says “this merge changes nothing.” The deploy job on merge points at quench.settings.json (where WhatIfONLY is false) and SchemaQuench executes the convergence for real. Driving the gate from a settings file rather than a flag means the preview behaves the same on every CLI version — a PR can never accidentally deploy.