Skip to content

CI/CD pipeline

Execution and deployment gates

Selected tests, documentation, Biome, Bandit, GitLab SAST, and SBOM dependency preparation start independently. Dependency preparation and Syft reuse matching raw inventories when available; otherwise they build and scan clean environments. The SBOM job always adds the current commit identity and validates both reports. Grype then scans both inventories once each in one job, sharing its database. The Azure deployment jobs and Pages publication remain in the final stage with no needs shortcut: all selected checks must succeed before deployment.

New commits cancel obsolete validation jobs. Deployment jobs are explicitly non-interruptible and retain their existing resource groups.

flowchart TD
    Commit[Commit] --> Tests[test:function]
    Commit --> Docs[docs:build]
    Commit --> Web[security:web]
    Commit --> Python[security:python]
    Commit --> SAST[semgrep-sast]
    Commit --> Prepare[sbom:prepare]
    Prepare --> Syft[syft_sbom template]
    Syft --> SBOM[sbom validation]
    SBOM --> Grype[Grype runtime and development]
    Tests --> Gate[All validation succeeds]
    Docs --> Gate
    Web --> Gate
    Python --> Gate
    SAST --> Gate
    Grype --> Gate
    Gate --> SWA[deploy:production when web files change]
    Gate --> Function[deploy:function when Function files change]
    Gate --> Pages[docs:pages on default branch]

Jobs and artifacts

JobInputs / outputGate
test:functionLocked development environment; JUnit, Cobertura XML, and HTML coverage reports retained 14 daysFailed tests or inconsistent dependencies
docs:buildMarkdown, theme, locked docs tools; docs-site/ artifact for 7 daysBuild, generated-link, asset, or navigation errors
sbom:prepareMatching cached inventories, or hash-locked runtime and dev environments; artifacts for 1 dayCache input, install, or dependency errors
syft_sbomGitLab CI Utils Syft template; generates inventories on cache miss; two raw CycloneDX artifacts for 1 dayScanner errors
sbomApplication metadata and schema validation; two CycloneDX reports for 30 daysIdentity, dependency, or schema errors
security:webLocked Biome; public/Lint errors
security:pythonLocked Bandit; app and Python CI helpersMedium-or-higher severity and confidence
semgrep-sastGitLab Semgrep analyzer; gl-sast-report.json retained 30 daysScanner errors; findings are informational
security:grypeOne job scans both SBOMs; two JSON vulnerability reports for 30 daysHigh/Critical vulnerabilities or scan errors in either inventory
deploy:productionLocked SWA CLI; generated Sentry configuration; public/Configuration generation and Azure deployment command
deploy:functionPinned Azure CLI; Function ZIP and remote buildAzure configuration/deployment commands
docs:pagesValidated docs artifactPublishes documentation to GitLab Pages

Python tests run when application code, tests, requirements, Python CI helpers, test configuration, or pipeline configuration change. Biome watches the public site, its tools, and configuration. Bandit watches Python application and CI helpers, its own tool lock, and configuration. Browser tests and documentation retain their own change filters. SAST and the SBOM/Grype chain still run on branch pipelines, including docs-only pushes, so fresh vulnerability checks remain in place. New branches can match all change rules because there is no previous push comparison.

Scheduled and manually started (web) pipelines run the full validation suite. Schedules also force fresh dependency installation and Syft inventory generation. They never deploy or publish Pages. A newer commit can cancel obsolete validation; started deployment/publication jobs are non-interruptible.

The Python test job measures line coverage for app/ and function_app.py using .coveragerc; test files and CI helpers are excluded. Its log supplies GitLab’s coverage percentage, and reports/coverage.xml supplies merge request coverage annotations. Download reports/coverage/ from the job artifacts for the HTML report. Coverage is informational and has no minimum-percentage deployment gate.

GitLab SAST

The pipeline includes GitLab’s stable SAST template. Its Semgrep analyzer scans supported Python and JavaScript source in branch pipelines using GitLab’s default rules and exclusions. The job runs in the security stage with needs: [], so it starts without waiting for tests or SBOMs and downloads no earlier artifacts. Merge request pipelines are not enabled by this change.

Download gl-sast-report.json from the job artifacts; it is also registered as a GitLab SAST report. Scanner execution failures block deployment through allow_failure: false. Detected vulnerabilities do not themselves fail the SAST job; the existing Bandit and Grype severity gates remain in place.

Deployment triggers

All publication jobs require a push pipeline on the default branch:

  • Website deployment watches public/**/*, ci/swa/**/*, and .gitlab-ci.yml.
  • Function deployment watches app/**/*, function_app.py, host.json, requirements.txt, and .gitlab-ci.yml.
  • Pages publication watches docs/**/*, ci/docs/**/*, and .gitlab-ci.yml.

A docs-only push therefore publishes documentation without deploying either Azure application. Editing .gitlab-ci.yml triggers both Azure jobs on a default-branch push. The explicit push condition prevents manually started, scheduled, tag, and merge request pipelines from publishing, even when rules:changes matches without a push comparison. See GitLab’s rules reference.

The Azure jobs retain separate resource groups for the website and Function. Pages uses documentation-pages. These serialize deployments to each destination; they do not make Azure and Pages publication an atomic operation.

Scheduled validation and compute measurement

After this configuration reaches the default branch, enable a daily GitLab pipeline schedule against that branch (for example, 17 4 * * * in America/New_York). No schedule variables are required. The schedule runs full validation, rebuilds inventories even when cached, and checks the current Grype database without deploying. Do not enable it against the previous configuration, whose deployment rules did not exclude schedules.

Compare the sum of job durations for docs-only, frontend-only, and backend-only pushes with both cold and warm caches. Pipeline elapsed time measures feedback latency; total job time is the useful starting point for compute savings (runner cost factors also affect billed minutes). As a pre-change reference, successful main pipeline 2864032607 on 2026-09-19 used 6.89 total job minutes, including 1.89 minutes across its two Grype jobs. That is a baseline, not a savings forecast.

Runtime and image pins

Before uploading public/, deploy:production runs node ci/swa/configure-sentry.cjs. It writes the browser DSN, environment, and CI_COMMIT_SHA release into public/sentry-config.js. The configured default DSN requires no extra GitLab variable. Optional overrides and disabling reporting are documented in configuration. This step generates configuration only; it does not upload source maps or require a Sentry auth token.

The Python image uses Python 3.11. If the live Function runtime changes, update the CI image and regenerate the Python locks with that same Python version. Node tools run on Node 22. All container images defined directly in this repository are pinned by digest; their tags provide a readable hint, not a moving dependency. SAST analyzer images and versions are managed by the included GitLab template and are not digest-pinned here.

Python dependency updates

The .in files contain direct dependencies. The corresponding .txt files are committed pip-tools lockfiles containing exact versions and SHA-256 hashes for the full dependency graph:

InputLockPurpose
requirements.inrequirements.txtRuntime; also installed by Azure remote build
requirements-dev.inrequirements-dev.txtRuntime plus test dependencies, constrained to the runtime lock
ci/requirements.inci/requirements.txtFull local toolchain and constraints for the smaller CI tool locks
ci/requirements-bandit.inci/requirements-bandit.txtBandit and its dependencies only
ci/requirements-sbom.inci/requirements-sbom.txtCycloneDX JSON schema validation only
ci/requirements-pip.inci/requirements-pip.txtPinned pip for isolated dependency installation
ci/docs/package.jsonci/docs/package-lock.jsonStarlight, Astro, and Mermaid; isolated from application packages

Use Python 3.11 on Linux to update locks:

Terminal window
python3.11 -m venv .ci-tools
.ci-tools/bin/python -m pip install --require-hashes -r ci/requirements.txt
# Edit the appropriate .in file, then regenerate runtime/dev/tools and CI subsets.
PATH="$PWD/.ci-tools/bin:$PATH" sh ci/update-locks.sh

Pass --upgrade to that script to refresh transitive dependencies as well. Direct dependencies with explicit pins still require edits to the .in files. Keep tool pins aligned between the full toolchain and its smaller input files; the subset locks are constrained by ci/requirements.txt and generated last. Review and commit the input and lock changes together. Run tests after installing requirements-dev.txt into a fresh environment with --require-hashes. The lockfiles are generated for Linux/Python 3.11; regenerate and test when changing the deployment platform or Python minor version.

Node tool and image updates

Biome, the Static Web Apps CLI, and Starlight have separate ci/biome/, ci/swa/, and ci/docs/ package manifests and lockfiles. CI uses npm ci, so tool transitive dependencies are fixed too. Linting does not install the deployment CLI.

Using Node 22, update a tool with an explicit version, for example:

Terminal window
npm install --prefix ci/biome --save-dev --save-exact @biomejs/biome@VERSION
npm install --prefix ci/swa --save-dev --save-exact @azure/static-web-apps-cli@VERSION

Replace VERSION with the reviewed release. Commit both package files for the affected tool. To update container images, resolve the official registry digest for the desired tag and update .gitlab-ci.yml in the same reviewed change. No dependency bot or external integration is required for these updates.

SBOMs

On a cache miss, sbom:prepare creates two clean Python environments without pip or setuptools, installs each hash-locked dependency set using the pip-only tool lock, and runs pip check. It passes installed-package directories and a cache fingerprint to syft_sbom as short-lived artifacts. On a hit, it passes just the raw inventories and fingerprint, avoiding dependency installation entirely.

The inventory cache key includes both runtime and development lockfiles. ci/sbom_cache.py additionally checks a content fingerprint of their inputs, the pip tool inputs/lock, its own implementation, and .gitlab-ci.yml (including image pins and cataloger configuration). Changed inputs, a missing or malformed inventory, or a schedule invalidate both inventories. Cache absence always falls back to generation. Keep GitLab’s protected/unprotected cache separation enabled. Do not add application source catalogers without expanding the fingerprint.

The pipeline includes the GitLab CI Utils Syft template, pinned to the commit for release 54.0.0. The template supplies the Syft 1.51.1 container image pinned by digest and its shell-compatible entrypoint. Local overrides scan uncached runtime and development environments separately with only the installed Python package cataloger. Scanner tools are outside the scanned directories. The template image supports Linux amd64 runners. The Syft job overrides the container user to UID 0 because the Python preparation job produces root-owned artifact directories. This lets it write cached reports while retaining the template’s pinned image and entrypoint.

The sbom job downloads the two raw reports, enriches and validates them, and publishes the final GitLab reports. Raw reports and installed-package artifacts expire after one day; only the final reports are registered as CycloneDX reports. When upgrading, update the template include’s commit and review its image pin and job definition together.

  • sbom.cdx.json: runtime packages, including transitive dependencies.
  • sbom-dev.cdx.json: runtime plus test packages, including transitive dependencies.

ci/generate_sbom.py reads Syft’s CycloneDX 1.6 reports, retains package dependency relationships, and replaces the directory scan root with an application root identified by the GitLab project path and commit SHA. Root edges come from the direct dependencies in the relevant .in file. The SBOM_SUPPLIER variable identifies the application supplier (defaults to Bob Clingan); override it in GitLab if ownership changes. It does not assign that supplier to third-party packages.

Raw inventory timestamps describe the original dependency scan and can predate the current commit on a cache hit. Application identity is refreshed on every pipeline; vulnerability findings are never reused from that inventory cache.

Both documents are validated against CycloneDX 1.6 after enrichment and published as GitLab CycloneDX reports and downloadable artifacts. The inventories cover Python application/test dependencies; they do not inventory OS packages, CI tools, or browser libraries loaded from a CDN.

The combined Grype job retains the existing High/Critical blocking policy. Both scans run even if the first finds vulnerabilities or fails; either nonzero result fails the job. Each scan writes one JSON report, including Medium findings, with no duplicate scan for console formatting. Reports are retained for 30 days, including on scan failure when the scanner produced them.

Caches and artifact transfers

Pip/npm downloads are cached under .cache/ in the workspace. Keys use lockfile contents and job/tool-specific prefixes. Installed environments and node_modules are recreated so stale packages cannot survive a dependency change. Pip verifies locked hashes and npm verifies lockfile integrity during installs.

Raw dependency inventories use .cache/sbom/: preparation only pulls the cache, and Syft only pushes it after success. The preparation artifact carries its cache decision to Syft, so a second cache restore cannot overwrite an invalidation. Installed environments are never cached. Grype shares .cache/grype/db/ between both scans and subsequent pipelines; automatic database updates and age validation stay enabled. Its cache is saved even when a vulnerability gate fails.

Tests, Biome, and Bandit download no earlier job artifacts. Grype downloads the SBOMs explicitly. syft_sbom downloads prepared packages or cached inventories, and sbom downloads only the raw scanner reports. Azure deployment jobs still download no prior artifacts. Pages downloads only the validated documentation output. The docs job uses the npm cache and its own lockfile; see documentation maintenance.

Syft, Grype, and Pages use GIT_STRATEGY: empty: each starts with a clean directory and receives only its required artifacts/cache, without cloning the repository.

Browser regression tests

test:web runs the Playwright suite for changes to public/, ci/playwright/, ci/swa/, or .gitlab-ci.yml. It runs in the test stage with needs: []; failures block the existing deployment stage barrier. Documentation-only changes skip this job to conserve runner minutes.

The version- and digest-pinned Microsoft Playwright image includes browsers and system dependencies; npm ci installs the locked test runner separately. Keep the image version and @playwright/test version in ci/playwright/package.json aligned when updating, refresh the image digest, regenerate its lockfile, and run the suite. The npm download cache is reused; browser binaries and node_modules are not cached.

GitLab receives JUnit results, plus a downloadable HTML report, screenshots and traces for failures under reports/playwright/, retained for seven days. The job has a ten-minute timeout and requires no Azure credentials or live results feed.