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
| Job | Inputs / output | Gate |
|---|---|---|
test:function | Locked development environment; JUnit, Cobertura XML, and HTML coverage reports retained 14 days | Failed tests or inconsistent dependencies |
docs:build | Markdown, theme, locked docs tools; docs-site/ artifact for 7 days | Build, generated-link, asset, or navigation errors |
sbom:prepare | Matching cached inventories, or hash-locked runtime and dev environments; artifacts for 1 day | Cache input, install, or dependency errors |
syft_sbom | GitLab CI Utils Syft template; generates inventories on cache miss; two raw CycloneDX artifacts for 1 day | Scanner errors |
sbom | Application metadata and schema validation; two CycloneDX reports for 30 days | Identity, dependency, or schema errors |
security:web | Locked Biome; public/ | Lint errors |
security:python | Locked Bandit; app and Python CI helpers | Medium-or-higher severity and confidence |
semgrep-sast | GitLab Semgrep analyzer; gl-sast-report.json retained 30 days | Scanner errors; findings are informational |
security:grype | One job scans both SBOMs; two JSON vulnerability reports for 30 days | High/Critical vulnerabilities or scan errors in either inventory |
deploy:production | Locked SWA CLI; generated Sentry configuration; public/ | Configuration generation and Azure deployment command |
deploy:function | Pinned Azure CLI; Function ZIP and remote build | Azure configuration/deployment commands |
docs:pages | Validated docs artifact | Publishes 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:
| Input | Lock | Purpose |
|---|---|---|
requirements.in | requirements.txt | Runtime; also installed by Azure remote build |
requirements-dev.in | requirements-dev.txt | Runtime plus test dependencies, constrained to the runtime lock |
ci/requirements.in | ci/requirements.txt | Full local toolchain and constraints for the smaller CI tool locks |
ci/requirements-bandit.in | ci/requirements-bandit.txt | Bandit and its dependencies only |
ci/requirements-sbom.in | ci/requirements-sbom.txt | CycloneDX JSON schema validation only |
ci/requirements-pip.in | ci/requirements-pip.txt | Pinned pip for isolated dependency installation |
ci/docs/package.json | ci/docs/package-lock.json | Starlight, Astro, and Mermaid; isolated from application packages |
Use Python 3.11 on Linux to update locks:
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.shPass --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:
npm install --prefix ci/biome --save-dev --save-exact @biomejs/biome@VERSIONnpm install --prefix ci/swa --save-dev --save-exact @azure/static-web-apps-cli@VERSIONReplace 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.