Skip to content

Pin uv to a patched image and restructure the CI around per-environment registries - #326

Open
filippo-20tab wants to merge 6 commits into
mainfrom
feature/per-environment-registry-and-uv-pin
Open

filippo-20tab wants to merge 6 commits into
mainfrom
feature/per-environment-registry-and-uv-pin

Conversation

@filippo-20tab

@filippo-20tab filippo-20tab commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

Ports back to the scaffolding a set of CI and image changes validated on two live projects (gs1-tendenze/backend and gs1-one/api).

1. Pin the uv base image to a patched trixie release

The base image was on the floating tag ghcr.io/astral-sh/uv:python<version>-bookworm-slim. Astral publishes versioned bookworm-slim images only up to 0.9.0 — from 0.10.0 on, the variant no longer exists — so that floating tag is effectively frozen on an old uv, missing the fixes released in 0.11.6 (GHSA-pjjw-68hj-v9mw, arbitrary file deletion on uninstall through a malformed RECORD) and 0.11.15. The uv binary ships in the production image and runs the entrypoint, so the vulnerable version was actually deployed.

Pinned to ghcr.io/astral-sh/uv:{uv_version}-python{python_version}-trixie-slim via a new uv_version cookiecutter option, so the version is explicit, auditable and easy to bump. Moving to trixie is required: it is the only distro for which astral still publishes versioned images.

Verified: the tag exists, requires-python still matches, and every apt package used by the Dockerfile (ca-certificates, libpq5, libpq-dev, gcc, libc6-dev, curl, gettext, git, graphviz, openssh-client, postgresql-client) is available in trixie. On a real project the resulting image is also 7.6 MiB smaller.

2. One registry repository per environment

A workflow:rules block derives IMAGE_REPO and IMAGE_REF from the ref, and IMAGE_TAG becomes ${CI_REGISTRY_IMAGE}/${IMAGE_REPO}:${IMAGE_REF}:

Ref Repository Deployed reference
develop …/development :${CI_COMMIT_SHA}
main …/staging :${CI_COMMIT_SHA}
release tag …/production :v${CI_COMMIT_TAG} (plus :${CI_COMMIT_SHA} for rollback)
anything else …/feature :${CI_COMMIT_SHA}

This exists to make GitLab's native cleanup policy usable. Its documented algorithm starts by collecting "all tags for a given repository" and then excludes "the N tags based on the keep_n value": keep_n applies per repository, while container_expiration_policy is a single set of parameters, not a list of rules. Splitting environments into separate repositories is therefore the only way to express "keep the last N per environment, keep every release" natively.

It also fixes a real hazard: deployments pin images by immutable SHA, so a moving :staging tag protected by name_regex_keep would not protect the reference the pods actually pull. With this layout, development/staging deploy often enough that their live image always sits within the kept window, and production is deployed as :v1.2.3 — immutable by nature and kept forever by name_regex_keep: ^v.*.

The moving :development, :staging, :production and :latest tags are no longer needed, so the build job gets simpler, and the layer cache moves to a dedicated …/cache repository.

3. A parallel Check stage, and a build that stops duplicating itself

A Check stage runs lint (ruff), security (bandit) and audit (uv-secure) in parallel before Build, for fast, fail-fast feedback.

build was the only job without rules, so every merge request event created a merge_request_event pipeline that ran only that job — and nobody consumed it, since test, pact and deploy all run in the push pipeline. Measured on a real project: 242s and 312s wasted per merge request event. It now reuses the existing *pipeline-push-rule anchor.

The build also gets STORAGE_DRIVER: overlay and a registry layer cache (--cache-from always, --cache-to only on develop/main), measured at 113s → 73s warm on a real project.

4. Checks and coverage, each in its own script

check.sh keeps every check (ruff, mypy, bandit, and now the uv-secure audit, for symmetry with the Check stage); test.sh is reduced to coverage and reporting. just test depends on just check, so the local behaviour is unchanged, and the CI test job calls mypy explicitly — it is the only place where the project dependencies are installed, which the Check stage deliberately skips.

This also fixes a latent bug: test.sh ran with set -uo pipefail and no status handling, so a failing check.sh did not fail the job — the exit code was the one of report.sh.

5. The test runner is selectable

TEST_RUNNER_TAG (default saas-linux-small-amd64) is a CI variable used by the test job through tags, and runner.py now registers it among the GitLab project variables, so terraform creates it and it can be raised to a bigger runner from the UI without touching the pipeline. Registered unprotected, like TEST_ENV_FILE, so it applies to feature branches too.

Verification

The template renders, the generated .gitlab-ci.yml passes GitLab's CI Lint API, and the existing test suite is green (59 tests).

6. The cleanup policy is created by terraform

gitlab_project.main now carries a container_expiration_policy block, so every scaffolded project is born with the cleanup already enabled:

  container_expiration_policy {
    enabled           = true
    cadence           = "1d"
    keep_n            = 10
    older_than        = "7d"
    name_regex_delete = ".*"
    name_regex_keep   = "^v.*"
  }

Combined with the per-environment repositories above, this yields exactly "keep the last 10 of development and of staging, keep every release, drop feature images after a week" — because keep_n is applied per repository.

Note that this is safe from day zero only for a new project: it starts with the per-environment layout, so no live image ever sits unprotected in the root repository. Existing projects need their environments to deploy on the new paths first.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant