The pipeline, and the gates in the right order
coreintermediateA pipeline is a fixed sequence that runs on every push: lint, type check, unit tests, integration tests, security scan, build, deploy, migrate, health check, smoke test. **GitHub Actions** and **GitLab CI** both describe it as YAML in your repository — jobs made of steps, running on a fresh machine each time. A **test environment** is what those tests run against: a real PostgreSQL and Redis started for the job, not mocks and not production.
Think of it as
The order in the roadmap's diagram is not decorative — it is cheapest-first, and that ordering is the whole economics of CI. Lint and type checks take seconds and catch a category of mistake outright, so they run before anything expensive. Unit tests run next because they need no services. Integration tests come after, because they need a database and a cache, which cost time to start. The security scan sits before the build so a known-vulnerable dependency stops the pipeline before you spend minutes producing an image with it inside. Then build once, and everything after that operates on the artefact rather than on the source. What makes this a *pipeline* rather than a script is that each gate can fail independently and tells you which class of problem you have: a lint failure and a smoke-test failure are the same red cross with completely different meanings. Both providers share the same shape — a YAML file in the repository, jobs that run on a fresh machine, steps inside a job that share a working directory — so the transferable knowledge is the structure and the discipline, not the syntax. Three properties are worth insisting on regardless of provider. First, every run starts clean: a fresh container, nothing cached unless you asked for it, which is what makes a green pipeline mean something. Second, the environment is declared, not discovered — a pinned Python version, a pinned PostgreSQL service, a lock file — so a pipeline that was green in March is green in September for the same commit. Third, the test environment is a real database. Django's test runner creates and destroys a test database, so pointing the job at a PostgreSQL service container gives you the same engine as production, which is the only way constraints, transactions and migrations are actually exercised. SQLite in CI against PostgreSQL in production is a well-worn way to discover a difference in a release rather than a test. Two Django-specific gates deserve a place in the list. `manage.py makemigrations --check --dry-run` fails when a model change has no migration, which is the most common way a deploy breaks with a green test suite. And `manage.py check --deploy` runs Django's own production checks; Django's checklist tells you to "run it against your production settings file", which in CI means setting `DJANGO_SETTINGS_MODULE` to the production module rather than the test one.
What we're doing: Write a Django CI job that runs against a real database and catches the two Django-specific failures a test suite does not.
- 5–10
- Static checks as a separate job, so lint and type results arrive in parallel with the slower test job rather than serialising behind it. `permissions` is narrowed on both jobs.
- 22–31
- A real PostgreSQL service with a health check, so the test step does not start against a database that is still initialising. `pg_isready` is the same probe the Compose file uses.
- 33–35
- The database URL and settings module are job-level environment, which keeps every step consistent and makes the test environment a declared thing rather than an assumed one.
- 42–45
- `makemigrations --check --dry-run` exits non-zero when a model change has no migration. This is the single most valuable Django-specific line in a CI file, because the test suite passes without it.
- 47–51
- `check --deploy` is only meaningful against production settings — Django's checklist says so explicitly — so the module is overridden for this step alone. `--fail-level WARNING` makes it a gate rather than a note.
Why this works: Cheap checks fail fast and in parallel, tests run against the production database engine, and the two Django failures that a green test suite hides — a missing migration and an unsafe production setting — both stop the pipeline.
Testing against SQLite when production is PostgreSQL
Wrong
Better
What you see: A migration that applies cleanly in CI and fails in production, a `UniqueConstraint` with a condition that is silently not enforced, or a `JSONField` query that works in one engine and not the other.
Why: Databases differ in exactly the places Django lets you be specific: constraint support, transactional DDL, locking behaviour, `select_for_update` semantics, JSON operators, and how migrations behave on a large table. A test suite on SQLite exercises your Python and very little of your schema, so it is green precisely when the schema is wrong. A service container costs a few seconds of start-up and removes an entire class of "worked in CI" incident. Where the suite is genuinely slow, the answer is to run integration tests as a separate job or to parallelise them — not to change the engine underneath them.
- Whole: jobs: test: runs-on: ubuntu-latest permissions: { contents: read } services: db: { image: postgres:16 } steps: - uses: actions/checkout@v4 - run: python -m pytest -q
- jobs: — Jobs run in parallel by default: Independent jobs start together; `needs:` is what imposes order. Splitting lint and tests into separate jobs gives you both results at once instead of stopping at the first.
- runs-on: ubuntu-latest — A fresh machine, every run: Nothing survives from the last run unless you cache it deliberately. That is what makes a green pipeline meaningful — and why "works on my machine" cannot happen here.
- permissions: { contents: read } — The token this job gets: Narrow it explicitly. A job that only runs tests has no reason to be able to write to the repository, and a compromised dependency inherits exactly these permissions.
- services: — Real backing services: A PostgreSQL container started for this job. Django creates and destroys its test database inside it, so constraints, transactions and migrations run against the engine production uses.
- actions/checkout@v4 — A pinned third-party step: A version, not a moving branch. An action runs arbitrary code in a context that holds your secrets, so what it resolves to should not change between runs without you choosing it.
- python -m pytest -q — The step that is just a command: It runs the same thing you run locally. When the pipeline gets clever here — bespoke flags, CI-only paths — a green pipeline stops predicting a green machine.
The roadmap's pipeline, with what each stage actually catches
Together
GitHub Actions and GitLab CI — the same shape, two vocabularies
Together
Remember: Run the gates cheapest-first — lint, types, unit, integration, security scan — so a two-second failure never waits behind a five-minute one, and build once so everything afterwards acts on the artefact. Both GitHub Actions and GitLab CI are the same shape: YAML in the repository, jobs of steps, a fresh machine each run. Test against a real PostgreSQL service container, because SQLite is green exactly when the schema is wrong. Install from a lock file so a red build means your change. And add the two Django gates a test suite does not give you: `makemigrations --check --dry-run`, and `check --deploy` run against the production settings module.
See also: secrets and dependency scanning · artifacts approvals and rollback · pytest django and fixtures · dependencies secrets and deployment checks

