CI/CD
Overview
Continuous Integration and Continuous Delivery (CI/CD) automate the steps between writing code and shipping it to users. A CI pipeline typically checks out the source, runs linters, type checkers, and tests, and then builds a distributable artifact. If all checks pass, a CD pipeline publishes the artifact to a package registry, a container registry, or both. Automating these steps eliminates manual errors, enforces quality gates on every change, and ensures that the released artifact is always built from a known-good state of the codebase.
The Depsight project uses GitHub Actions for its CI/CD pipeline. GitHub Actions is a workflow automation platform built into GitHub that executes jobs in response to repository events such as pushes, pull requests, and releases. Workflows are defined as YAML files inside the .github/workflows/ directory and run on GitHub-hosted virtual machines. A typical .github folder looks like this:
.github/
├── actions/ # composite actions shared across workflows
├── scripts/ # shell or Python scripts called from run: steps
└── workflows/
GitHub Actions Workflows
Depsight provides three entry-point workflows that trigger the CI/CD pipeline:
- On Pull Request — quality gate on every PR to
main; lints, type-checks, tests, and builds the wheel without publishing - On Dispatch — manual trigger for on-demand builds; supports toolchain version selection and optional wheel artifact upload
- On Release — fires on a published GitHub Release; publishes the wheel to PyPI and pushes the Docker image to Docker Hub
Each responds to a different GitHub event and delegates the heavy lifting to build.yml via workflow_call. The entry points differ in how they determine version numbers, which inputs they forward, and whether they trigger a release publish.
On Pull Request
The on_pullrequest.yml workflow runs automatically when a pull request is opened or updated against the main branch. It parses the current version from pyproject.toml and calls build.yml with is_release: false, which means the pipeline lints, type-checks, tests, and builds the wheel but does not publish anything. Changes to README.md and the docs/ folder are excluded via paths-ignore so that documentation-only PRs do not trigger a full build.
on:
pull_request:
branches:
- main
paths-ignore:
- 'README.md'
- 'docs/**'
On Dispatch
The on_dispatch.yml workflow is triggered manually from the GitHub Actions UI. It exposes inputs that let the operator optionally override the Python version and the uv version. The Python version defaults to the value in .python-version if left empty. Like the pull request workflow, it calls build.yml with is_release: false, so nothing is published to PyPI or Docker Hub. This workflow is useful for testing a specific configuration or producing a pre-release wheel for local validation.
on:
workflow_dispatch:
inputs:
depsight_version:
description: "Depsight version (must match pyproject.toml, e.g. 1.0.0)"
required: true
type: string
python_version:
description: "Python version override (leave empty to use .python-version)"
required: false
default: ""
type: string
uv_version:
description: "uv version (e.g. 0.11.1)"
required: false
default: "0.11.1"
type: string
upload_artifact:
description: "Upload the wheel as a workflow artifact"
required: false
default: false
type: boolean
On Release
The on_release.yml workflow fires when a GitHub Release is published. It first verifies that the release tag is PEP 440 compliant, then calls build.yml with is_release: true. This flag enables the publish steps that upload the wheel to PyPI and push the Docker image to Docker Hub. The workflow also forwards the PYPI_TOKEN and DOCKER_PAT secrets so that the reusable workflow can authenticate with both registries.
on:
release:
types: [published]
Reusable Build Workflow
The build.yml workflow is the single source of truth for all build, test, and publish logic. It is never triggered directly by a repository event. Instead, the three entry-point workflows call it via workflow_call, passing the version, toolchain pins, and the is_release flag that controls whether artifacts are published.
flowchart LR
subgraph Triggers
PR["on_pullrequest.yml"]
DI["on_dispatch.yml"]
RE["on_release.yml"]
end
subgraph "build.yml"
direction TB
V["Verify version"]
B["Lint, test & build wheel"]
A["Upload artifact"]
FG["Filesystem vulnerability gate\n(source + deps + secrets)"]
D["Build Docker image"]
IG["Container vulnerability gate\n(OS + libraries)"]
P["Publish to PyPI"]
PD["Push Docker image"]
V --> B --> A
B --> FG --> D --> IG
IG --> P
IG --> PD
end
PR -- "is_release: false" --> V
DI -- "is_release: false" --> V
RE -- "is_release: true" --> V
The workflow accepts the following inputs and secrets:
| Input / Secret | Type | Purpose |
|---|---|---|
is_release |
boolean | Enables PyPI and Docker Hub publish steps |
uv_version |
string | uv version to install in the DevContainer |
python_version |
string | Python version override — falls back to .python-version if omitted |
depsight_version |
string | Expected version (validated against pyproject.toml) |
upload_artifact |
boolean | Attach the wheel as a downloadable workflow artifact |
PYPI_TOKEN |
secret | API token for PyPI publishing |
DOCKER_PAT |
secret | Personal access token for Docker Hub |
Build and Publish Pipeline
Version Verification
The workflow compares the depsight_version input against the version in pyproject.toml and fails immediately on a mismatch, preventing releases with inconsistent metadata.
Lint, Test and Build Wheel
The wheel is built inside the same DevContainer used for local development. The devcontainers/ci action spins up the container defined in .devcontainer/devcontainer.json, ensuring the CI environment is identical to the local setup. Inside the container the step verifies lockfile integrity, activates the virtual environment, runs the full quality pipeline (linting, type checking, tests), and only then builds the wheel.
- name: Lint, test & build wheel
uses: devcontainers/ci@v0.3
with:
configFile: .devcontainer/devcontainer.json
runCmd: |
set -e
source .venv/bin/activate
ruff check src/ tests/
mypy src/
python -m pytest tests/ -v --tb=short
uv build
Upload Wheel Artifact
When upload_artifact is true, the wheel is attached to the workflow run with a 14-day retention period. This is useful for validating a pre-release build without publishing to PyPI.
- name: Provide wheel as workflow artifact
if: ${{ inputs.upload_artifact }}
uses: actions/upload-artifact@v4
with:
name: depsight-wheel
path: dist/*.whl
retention-days: 14
if-no-files-found: error
Filesystem Vulnerability Gate
Immediately after the wheel is built, a Trivy filesystem scan checks the repository for CRITICAL Python dependency vulnerabilities and leaked secrets. If anything is found the pipeline fails before both the wheel and container image are published.
- name: Filesystem vulnerability gate (block on CRITICAL)
uses: aquasecurity/trivy-action@master
with:
scan-type: "fs"
scan-ref: "."
format: "table"
exit-code: "1"
ignore-unfixed: true
scanners: "vuln,secret"
severity: "CRITICAL"
Build Docker Image
Docker Buildx is initialised on every run so the image build can use BuildKit features. The image is built on every workflow run so that Dockerfile issues are caught early. The load: true option imports the image into the local daemon without pushing. Python and uv versions are forwarded as build arguments to match the CI test environment. Two tags are applied: the exact version and latest.
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build Docker image
uses: docker/build-push-action@v6
with:
context: .
load: true
build-args: |
PYTHON_VERSION=${{ inputs.python_version }}
UV_VERSION=${{ inputs.uv_version }}
tags: |
${{ vars.DOCKER_REPOSITORY }}:${{ inputs.depsight_version }}
${{ vars.DOCKER_REPOSITORY }}:latest
Container Vulnerability Gate
After the image is built, a Trivy image scan checks the full container inclduing OS packages and installed Python libraries for CRITICAL vulnerabilities. If any unfixed critical CVE is found the pipeline fails and no artifacts are published.
- name: Container vulnerability gate (block on CRITICAL)
uses: aquasecurity/trivy-action@master
with:
image-ref: "${{ vars.DOCKER_REPOSITORY }}:${{ inputs.depsight_version }}"
format: "table"
exit-code: "1"
ignore-unfixed: true
vuln-type: "os,library"
scanners: "vuln"
severity: "CRITICAL"
Two gates, two artifacts
Both vulnerability gates run unconditionally on every build to ensure neither the wheel nor the image is published with known critical vulnerabilities. Continuous monitoring with SARIF upload is handled separately by the Security Pipeline.
Publish Wheel to PyPI (release-only)
Triggered only on release events, this step publishes the Depsight wheel to PyPI. The uv build step runs inside the DevContainer, but uv publish runs on the bare GitHub runner where uv is not pre-installed. The astral-sh/setup-uv action installs the pinned version first, and the PYPI_TOKEN secret is passed via UV_PUBLISH_TOKEN.
- name: Install uv
if: ${{ inputs.is_release }}
uses: astral-sh/setup-uv@v5
with:
version: ${{ inputs.uv_version }}
- name: Upload wheel to PyPI
if: ${{ inputs.is_release }}
env:
UV_PUBLISH_TOKEN: ${{ secrets.PYPI_TOKEN }}
run: uv publish
Push Docker Image (release-only)
Triggered only on release events, this step pushes the Depsight image to Docker Hub. The workflow first authenticates using a username stored as a repository variable and a PAT stored as a repository secret. Keeping the push separate from the build ensures every PR and dispatch run still validates the Dockerfile while only tagged releases are published.
- name: Log in to Docker Hub
if: ${{ inputs.is_release }}
uses: docker/login-action@v3
with:
username: ${{ vars.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PAT }}
- name: Push Docker image
if: ${{ inputs.is_release }}
uses: docker/build-push-action@v6
with:
context: .
push: true
build-args: |
PYTHON_VERSION=${{ inputs.python_version }}
UV_VERSION=${{ inputs.uv_version }}
tags: |
${{ vars.DOCKER_REPOSITORY }}:${{ inputs.depsight_version }}
${{ vars.DOCKER_REPOSITORY }}:latest
Docker Hub Credentials
DOCKER_USERNAME is configured as a repository variable and DOCKER_PAT as a repository secret. The PAT requires the Read & Write permission scope for the target repository on Docker Hub.
Security Pipeline
Depsight uses a two-layer security model: continuous vulnerability monitoring via a dedicated workflow, and blocking vulnerability gates inside the build pipeline.
Vulnerability Monitoring
The trivy.yml workflow runs independently of the build pipeline. It performs two scans — a filesystem scan of the repository source and an image scan of a locally built Docker image (never pushed). Both scans always render findings as a formatted table in the Actions log. In addition, SARIF reports are generated and uploaded to the Security tab for most triggers:
| Trigger | Console output | SARIF | Uploaded to Security tab |
|---|---|---|---|
push to main |
Yes | Yes | Yes |
pull_request to main |
Yes | Yes | Yes |
schedule (weekly) |
Yes | Yes | Yes |
workflow_dispatch on main |
Yes | Yes | Yes |
workflow_dispatch on any other branch |
Yes | No | No |
The console scans run unconditionally on every trigger, giving immediate visibility in the Actions log. When SARIF output is also enabled, results are uploaded to the repository's Security tab via the CodeQL upload action (see section below).
Filesystem Scan
The filesystem scan (trivy fs .) checks the repository source for Python dependency vulnerabilities and leaked secrets. It runs before the Docker image is built, providing fast feedback without waiting for a container build.
The console step runs unconditionally on every trigger and renders findings as a formatted table directly in the Actions log.
- name: Trivy filesystem scan (console)
uses: aquasecurity/trivy-action@master
with:
scan-type: "fs"
scan-ref: "."
format: "table"
exit-code: "0"
ignore-unfixed: true
scanners: "vuln,secret"
severity: "CRITICAL,HIGH"
The SARIF step runs conditionally — only on pushes, pull requests, scheduled runs, and manual dispatches targeting main. It writes findings to a SARIF file instead of the log.
- name: Trivy filesystem scan (SARIF)
if: ${{ github.event_name != 'workflow_dispatch' || github.ref == 'refs/heads/main' }}
uses: aquasecurity/trivy-action@master
with:
scan-type: "fs"
scan-ref: "."
format: "sarif"
output: "trivy-fs-results.sarif"
exit-code: "0"
ignore-unfixed: true
scanners: "vuln,secret"
severity: "CRITICAL,HIGH"
The upload step submits the SARIF file to GitHub under the trivy-filesystem category, making findings visible in Security → Code scanning (see GitHub Security Tab).
- name: Upload filesystem scan results to GitHub Security tab
if: ${{ always() && (github.event_name != 'workflow_dispatch' || github.ref == 'refs/heads/main') }}
uses: github/codeql-action/upload-sarif@v4
with:
sarif_file: "trivy-fs-results.sarif"
category: "trivy-filesystem"
Image Scan
The image scan targets the locally built Docker image and checks OS packages and installed Python libraries for vulnerabilities. It runs after the Docker image is built, complementing the filesystem scan with OS-level coverage.
The console step runs unconditionally on every trigger and renders findings as a formatted table in the Actions log.
- name: Trivy image scan (console)
uses: aquasecurity/trivy-action@master
with:
image-ref: "${{ vars.DOCKER_REPOSITORY }}:${{ github.sha }}"
format: "table"
exit-code: "0"
ignore-unfixed: true
vuln-type: "os,library"
scanners: "vuln"
severity: "CRITICAL,HIGH"
The SARIF step applies the same conditional logic as the filesystem scan — it only runs when results should be tracked in the Security tab.
- name: Trivy image scan (SARIF)
if: ${{ github.event_name != 'workflow_dispatch' || github.ref == 'refs/heads/main' }}
uses: aquasecurity/trivy-action@master
with:
image-ref: "${{ vars.DOCKER_REPOSITORY }}:${{ github.sha }}"
format: "sarif"
output: "trivy-image-results.sarif"
exit-code: "0"
ignore-unfixed: true
vuln-type: "os,library"
scanners: "vuln"
severity: "CRITICAL,HIGH"
The upload step submits findings under the trivy-image category, keeping container vulnerabilities distinct from filesystem findings in the Security tab.
- name: Upload image scan results to GitHub Security tab
if: ${{ always() && (github.event_name != 'workflow_dispatch' || github.ref == 'refs/heads/main') }}
uses: github/codeql-action/upload-sarif@v4
with:
sarif_file: "trivy-image-results.sarif"
category: "trivy-image"
GitHub Security Tab
Once a SARIF file is uploaded via the github/codeql-action/upload-sarif action, GitHub parses it and surfaces each finding as a code scanning alert under Security → Code scanning. Alerts are deduplicated across scan runs and tracked per branch, so a vulnerability first detected on main will show as also present on any branch where it persists.

Each alert includes the CVE identifier, the affected package, the installed and fixed versions, the severity rating, the detecting tool (Trivy), and the branches where the issue is active. Alerts remain open until the vulnerability is resolved or explicitly dismissed, providing a persistent audit trail.

Resolving the CVE
The runtime stage of the Dockerfile initially did not run apt-get update && apt-get upgrade, leaving OS packages at the version baked into the base image. Adding these two commands to the runtime stage ensured that the latest security patches were applied during the image build, which resolved the CVE.
The two scan categories (trivy-filesystem and trivy-image) are tracked independently, so findings from Python dependency CVEs and OS-level vulnerabilities are separated and can be filtered individually in the Security tab.