Skip to content

Repository files navigation

GitHub Actions — TinyCDI CI/CD

Five workflows, all with every action pinned by full commit SHA (version in the trailing comment) and every downloaded tool sha256-verified.

Workflow Trigger Purpose
ci.yml PRs + push to main + weekly schedule go vet / go test -race on envtest, tests/integration against a pinned postgres service container, portal UI (web/: npm ci, npm audit --omit=dev --audit-level=high, tsc, vitest, vite build), the soak/drill harness (tests/soak: npm ci, npm audit, tsc, unit tests + dry-run), helm lint --strict + go test ./deploy/helm/, govulncheck, actionlint + yamllint + zizmor, .github regression/policy tests, dependency-review (PRs, gated), kasm catalog policy (check-kasm-catalog.sh), and the kasm adapter contract + catalog scan (weekly/on-dispatch/main pushes/PRs touching kasm paths — pulls the digest-pinned catalog images and runs the trivy gate, engine freshness floor and TestKasmAdapterChromium)
images.yml push to main, workflow_dispatch digest-only build of the seven images (backend, frontend, operator, linux-base, linux-desktop, browser, kasm-adapter) → isolated trivy gate + SBOM → promote ghcr.io/tinyorbitvn/tinycdi-<name>:{sha-<short>,main} + cosign keyless signature/SBOM attestation. Publishes only when github.ref == refs/heads/main; a dispatch elsewhere builds + scans without pushing.
release.yml tag v*.*.*, workflow_dispatch (dry-run only) digest-only build of the build/release-images.txt set → isolated trivy gate → environment: release publish job: sign + attest digests, helm push to oci://ghcr.io/tinyorbitvn/charts + sign the chart, then promote :<semver>/latest tags, GitHub Release with binaries + CRDs + SBOMs + KasmVNC source bundle + sha256sums.txt + sigstore bundles
runtime-freshness.yml daily schedule, workflow_dispatch runs check-browser-freshness.sh for both pinned engines (chromium and firefox-esr); when bookworm-security offers a newer build — or the pinned version no longer exists there ("pinned version gone": the browser image can no longer be built from scratch) — bump-browser-pin.sh repins build/browser/Dockerfile (and, for firefox-esr, build/linux-desktop/Dockerfile — the desktop image carries the same Firefox pin) + the doc pins (firefox-esr: with its deb sha256) and one pin-bump PR is opened (gh pr create). Also runs check-runtime-image-age.sh: fails when the newest runtime-* release is older than 14 days (D28)
runtime-images.yml push to main touching build/{linux-base,linux-desktop,browser}/**, weekly schedule, workflow_dispatch the runtime image release train (D27): digest-only build of linux-base, then linux-desktop + browser (both FROM the base digest) → isolated trivy gate → cosign sign + SBOM attest (+ attest-build-provenance under TRAIN_ATTESTATIONS_ENABLED, off by default) → promote rt-YYYYMMDD.N tag (N = next free number over the day's published rt-* tags — successful publishes only) → runtime-images.json sign-blob'd and attached to GitHub Release runtime-YYYY.MM.DD. Never builds or tags control-plane images; publishes only on refs/heads/main

Supply-chain pipeline shape

The publishing workflows (images.yml, release.yml, runtime-images.yml) split publish rights from scanner execution (SEC-04/SEC-05):

  1. build jobs (packages: write) run only checkout + docker actions: docker buildx build --output type=registry,push-by-digest=true pushes the manifest by digest only — no tags exist yet. Registry secrets (BASE_MIRROR_*, optional) are step-scoped to the login step, and docker logout runs at the end of the job.
  2. scan jobs hold only packages: read (+ security-events/actions for the optional SARIF upload) — no secrets, no id-token, no attestations. trivy is installed from a release tarball with a pinned sha256 (the trivy-action runtime download is not used) and runs with an empty --config plus an explicit --ignorefile pointing at the effective ignorefile generated at scan time by check-trivyignore.sh --out — an expired or malformed .trivyignore entry fails the scan job, and expiry is enforced by trivy's native exp: as well. SPDX SBOMs are generated by the build jobs (syft, sha256-pinned, empty --config) — the unprivileged scan job produces only trivy reports, which are never merged into release bundles (SUPR-2/SUPR-8).
  3. promote/publish jobs run only after every scan leg passes and use only GitHub/docker/sigstore-pinned actions. Order matters (SUPR-13): cosign signs the digests and attaches the SBOM attestations (plus GitHub build-provenance when ATTESTATIONS_ENABLED) BEFORE docker buildx imagetools create promotes any tag — a signing failure leaves nothing addressable. The release publish job additionally downloads every input by exact artifact name (never a glob — a forged artifact could overwrite a same-named file mid-merge) and .github/scripts/collect-publish-inputs.sh validates them strictly: exactly one refs/<img>.ref per released image matching ghcr.io/tinyorbitvn/tinycdi-<img>@sha256:<64hex>, one SBOM each, only the chart tgz + static binaries + CRD bundle + KasmVNC source bundle in the release bundle, and the digests stamped into the packaged chart must equal the image refs. The images.yml promote job validates refs against the same strict regex (validate-image-refs.sh). Scan jobs apply the same strict form to artifact-supplied refs before writing them to $GITHUB_ENV (SUPF-10).

A non-publishing run (release.yml dry_run, images.yml on a non-main ref) exports the image as a type=docker tar artifact instead; the scan job scans the tar, so rehearsal coverage is identical.

Release protection (SEC-15 / SUPR-5)

The publish job runs under environment: release. The environment, tag ruleset, branch protection and Actions variables all require the repo to be public — once it is, run:

# dry-run first (default), then apply:
.github/scripts/setup-repo-protection.sh
.github/scripts/setup-repo-protection.sh --apply
# optional: add more required reviewers of the `release` environment
RELEASE_REVIEWERS="vanlongme,alice" .github/scripts/setup-repo-protection.sh --apply

The script is idempotent (GET-then-create/update only what differs) and sets:

  • release environment — required reviewers (RELEASE_REVIEWERS, a comma list of GitHub logins resolved to ids at run time; default: vanlongme) + a deployment tag policy matching v*.
  • release-tags ruleset on refs/tags/v* — creation/update/deletion restricted to repo admins only (no GitHub Actions app bypass). Consequence: release.yml workflow_dispatch is dry-run only — a real release is made by an admin pushing the v*.*.* tag.
  • Branch protection on main — required status checks = every ci.yml job except kasm adapter contract + catalog scan (network-dependent, must not gate merges; its fast catalog policy leg still runs inside the required workflow-policy job). The browser engine freshness check is equally non-gating — it now lives in runtime-freshness.yml (daily) rather than ci.yml, so it never appears as a required check at all. Strict (up-to-date) mode, enforce admins, dismiss stale reviews, conversation resolution required, no force-push/delete. required_approving_review_count is 0 while the repo has a single maintainer — raise it when a second maintainer joins.
  • Security features — private vulnerability reporting, secret scanning and push protection.
  • Actions variables ATTESTATIONS_ENABLED=true and CODE_SCANNING_ENABLED=true (see "Variables").

prepare additionally verifies that any real publish (tag pushes only — dispatches are refused unless dry_run=true, since the tag ruleset grants the Actions app no bypass) is a commit on main's ancestry — gh api compare/<sha>...<main-sha> must report ahead or identical, with refs/heads/main resolved to a SHA first — so a release can never ship a commit that hasn't landed on main. It also refuses to overwrite: an existing GitHub Release, GHCR image tag :<version> or chart version aborts the run before anything is pushed, and every existence check fails closed on non-404 API errors.

Remaining manual settings (needs admin):

  • .github/CODEOWNERS routes everything to @vanlongme (single maintainer) — enable Require review from Code Owners when a second maintainer joins.

Variables

Created by setup-repo-protection.sh --apply (or manually under Settings → Secrets and variables → Actions → Variables):

Variable Value Effect
CODE_SCANNING_ENABLED true trivy SARIF also pushed to Security → Code scanning; dependency-review runs on PRs. Both need Advanced Security on private repos — off today
ATTESTATIONS_ENABLED true additionally emits actions/attest-build-provenance for each release image. Attestation storage on private repos needs GitHub Enterprise (SEC-I12) — off until the repo goes public
TRAIN_ATTESTATIONS_ENABLED true same for the runtime train's publish job (runtime-images.yml) — a separate variable so train provenance stays off until verified on a tag build. NOT set by setup-repo-protection.sh; create it manually when enabling
BASE_MIRROR_REGISTRY registry host optional private mirror for Dockerfile FROM bases — when set, build jobs docker-login to it with the BASE_MIRROR_* secrets below
unset (default) the gated steps are skipped; SARIF/SBOM artifacts and cosign signing are unaffected

Secrets

Secret Scope Required Used by
GITHUB_TOKEN automatic — GHCR pull/push (packages), SARIF upload (security-events), cosign OIDC (id-token), helm push, gh release create (contents: write). No PAT needed.
BASE_MIRROR_USERNAME org/repo optional docker login to vars.BASE_MIRROR_REGISTRY so Dockerfiles can FROM private secured bases. Scoped to the login step's env only; unused when the variable is unset
BASE_MIRROR_PASSWORD org/repo optional same as above

Secrets are only ever consumed via --password-stdin or action inputs, and never at job level — a compromised step sees only its own env.

Vulnerability gate semantics (SEC-44 / SUPR-3 / SUPR-4)

TRIVY_GATE_SEVERITY (workflow env, currently CRITICAL,HIGH) decides which severities with an available fix fail the pipeline — trivy image --ignore-unfixed --exit-code 1. The full finding set (including unfixed) is always written to trivy-<img>.json, converted to SARIF for artifacts/code scanning, and the unfixed HIGH/CRITICAL findings are listed per image and platform in the job summary so ignore-unfixed can't silently accumulate risk.

.trivyignore (repo root) is an expiring-exceptions file: every CVE entry must carry non-empty # reason:, # approver: and # expires: YYYY-MM-DD comments, and the expiry must be at most 30 days ahead. .github/scripts/check-trivyignore.sh enforces the policy in ci.yml and inside the scan jobs themselves: each scan generates an effective ignorefile (--out) that drops expired entries and pins survivors with trivy's native exp: suffix, passed via --ignorefile — expiry is enforced at scan time even if the lint job were bypassed. trivy and syft run with --config pointing at an empty file so a committed trivy.yaml/.syft.yaml can never steer the tools (the regression suite asserts none exists).

The browser image carries a second gate: .github/scripts/check-browser-freshness.sh (runtime-freshness.yml, daily) fails once Debian bookworm-security publishes a chromium or firefox-esr newer than the pinned CHROMIUM_APT_VERSION / FIREFOX_ESR_APT_VERSION — or once a pinned version no longer exists there (bookworm-security keeps only the newest build, so a stale pin turns into a failing from-scratch build: "pinned version gone") — and the same run opens the pin-bump PR via bump-browser-pin.sh.

Runtime image release train (D27/D28)

runtime-images.yml publishes the runtime images (linux-base, linux-desktop, browser) on their own cadence — every main push touching build/linux-base/**, build/linux-desktop/** or build/browser/**, weekly, and on workflow_dispatch — independent of control-plane v*.*.* releases. Promoted digests get the rt-YYYYMMDD.N tag where N is the next free number over the day's already-published rt-* tags — derived by .github/scripts/next-rt-tag.sh at promote time, so failed builds and non-publishing rehearsals consume no number and the per-day series stays consecutive for successful publishes. The train never promotes main/latest and never builds control-plane images. Each publish writes runtime-images.json and attaches it to the GitHub Release runtime-YYYY.MM.DD (re-uploaded when more than one train runs in a day):

{
  "builtAt": "2026-10-20T03:10:00Z",
  "images": [
    {"name": "linux-base", "ref": "ghcr.io/tinyorbitvn/tinycdi-linux-base",
     "digest": "sha256:…", "tag": "rt-20261020.1"},
    {"name": "linux-desktop", "ref": "ghcr.io/tinyorbitvn/tinycdi-linux-desktop",
     "digest": "sha256:…", "tag": "rt-20261020.1",
     "firefox": "153.4.0esr-1~deb12u1"},
    {"name": "browser", "ref": "ghcr.io/tinyorbitvn/tinycdi-browser",
     "digest": "sha256:…", "tag": "rt-20261020.1",
     "chromium": "154.0.8037.92-1~deb12u1",
     "firefox": "153.4.0esr-1~deb12u1"}
  ]
}

The chromium/firefox fields are the full Debian package versions the signed images actually installed — read off each image's attested SPDX SBOM at publish time (what dpkg-query reports), not the Dockerfile pins that requested them. linux-base carries no browser.

Chart values stay GitOps-owned (P7): deployments bump images.*.digest/images.*.builtAt from the manifest — the train mutates nothing downstream. runtime-freshness.yml's image-age job fails daily when the newest runtime-* release is older than 14 days; the SLO and the kasmweb/* allowlist rule are in docs/security/vulnerability-policy.md §5.

Train images verify like :main-channel images, with the runtime-images.yml signer identity:

cosign verify ghcr.io/tinyorbitvn/tinycdi-browser:rt-<date>.<n> \
  --certificate-oidc-issuer https://token.actions.githubusercontent.com \
  --certificate-identity \
  'https://github.com/tinyorbitvn/tinycdi/.github/workflows/runtime-images.yml@refs/heads/main'

runtime-images.json itself is signed — cosign sign-blob in the same publish job — and the release carries runtime-images.json.sigstore.json next to it. Verify the manifest before taking digests from it:

cosign verify-blob --bundle runtime-images.json.sigstore.json \
  runtime-images.json \
  --certificate-oidc-issuer https://token.actions.githubusercontent.com \
  --certificate-identity \
  'https://github.com/tinyorbitvn/tinycdi/.github/workflows/runtime-images.yml@refs/heads/main'

Released image set

release.yml builds, scans, signs and publishes exactly the images listed in build/release-images.txt (one name per line, # comments allowed; must match a build/<name>/Dockerfile). Comment an image out to hold it back from a release, e.g. when its gate cannot pass. linux-base builds in its own base job because linux-desktop and browser FROM its pushed digest; both require linux-base in the set. images.yml on main builds and scans every image in the set.

Verifying a release

Identities are exact --certificate-identity matches (SUPR-6): the release signer cert is https://github.com/tinyorbitvn/tinycdi/.github/workflows/release.yml@refs/tags/v<X.Y.Z> and the :main-channel signer cert is https://github.com/tinyorbitvn/tinycdi/.github/workflows/images.yml@refs/heads/main. Substitute the real tag/digest. A release carries one more image than the chart deploys: verify tinycdi-linux-base with the same commands (the chart pins its digest as images.linuxBase but never pulls it).

# Release image signature (keyless, Fulcio + Rekor):
cosign verify ghcr.io/tinyorbitvn/tinycdi-backend@sha256:<digest> \
  --certificate-oidc-issuer https://token.actions.githubusercontent.com \
  --certificate-identity \
  'https://github.com/tinyorbitvn/tinycdi/.github/workflows/release.yml@refs/tags/vX.Y.Z'

# :main channel image (from images.yml):
cosign verify ghcr.io/tinyorbitvn/tinycdi-backend@sha256:<digest> \
  --certificate-oidc-issuer https://token.actions.githubusercontent.com \
  --certificate-identity \
  'https://github.com/tinyorbitvn/tinycdi/.github/workflows/images.yml@refs/heads/main'

# Image SBOM attestation (same exact identity):
cosign verify-attestation --type spdxjson \
  ghcr.io/tinyorbitvn/tinycdi-backend@sha256:<digest> \
  --certificate-oidc-issuer https://token.actions.githubusercontent.com \
  --certificate-identity \
  'https://github.com/tinyorbitvn/tinycdi/.github/workflows/release.yml@refs/tags/vX.Y.Z'

# Helm chart signature:
cosign verify ghcr.io/tinyorbitvn/charts/tinycdi@sha256:<digest> \
  --certificate-oidc-issuer https://token.actions.githubusercontent.com \
  --certificate-identity \
  'https://github.com/tinyorbitvn/tinycdi/.github/workflows/release.yml@refs/tags/vX.Y.Z'

# GitHub build provenance (only when ATTESTATIONS_ENABLED was set).
# Pin the signing ref — SUPF-7: without it a same-named workflow on ANY
# ref would satisfy the check.
gh attestation verify oci://ghcr.io/tinyorbitvn/tinycdi-backend:X.Y.Z \
  --repo tinyorbitvn/tinycdi \
  --source-ref refs/tags/vX.Y.Z \
  --signer-workflow 'tinyorbitvn/tinycdi/.github/workflows/release.yml@refs/tags/vX.Y.Z'

# Release assets: verify the sha256sums, then each sigstore bundle:
cosign verify-blob --bundle <asset>.sigstore.json <asset> \
  --certificate-oidc-issuer https://token.actions.githubusercontent.com \
  --certificate-identity \
  'https://github.com/tinyorbitvn/tinycdi/.github/workflows/release.yml@refs/tags/vX.Y.Z'

Release semantics

  • Tag push v1.2.3 → images :1.2.3 + :latest, chart 1.2.3, release marked latest; a - suffix (v1.2.3-rc.1) marks the release prerelease and skips the :latest tag. The packaged chart's images.*.digest values are stamped with the exact pushed digests, so a released chart pulls immutable references (SEC-18).
  • workflow_dispatch is a dry-run rehearsal only: everything is built, scanned and packaged, and uploaded as workflow artifacts — no pushes, no release. dry_run: false is refused up front (the v* tag ruleset grants the Actions app no bypass, so a dispatch could never create the tag gh release create needs). Real releases come only from an admin pushing a v*.*.* tag.
  • -ldflags "-X main.version=<tag>" stamps binaries; it is a no-op until cmd/* export var version. Add var version = "dev" to cmd/backend and cmd/operator to light it up. The release ships two static binaries: tinycdi-backend (public API + session gateway) and tinycdi-operator.

Images

Image Dockerfile Build context
backend, frontend, operator, linux-base, linux-desktop, browser, kasm-adapter build/<name>/Dockerfile repo root (context: .)

linux-desktop and browser FROM linux-base (ARG BASE_IMAGE). A docker-container buildx builder cannot see the daemon's image store, so linux-base builds first in its own base job and the two profile legs consume the exact pushed manifest by digest (BASE_IMAGE=ghcr.io/tinyorbitvn/tinycdi-linux-base@sha256:<digest>). On non-publishing runs they consume the base job's image-tar artifact via docker load. Every Dockerfile is self-contained — docker build -f build/<name>/Dockerfile . from a clean checkout works (the frontend image builds the web/ SPA in-image; the two profile images need a local base or a BASE_IMAGE override).

All images carry the standard org.opencontainers.image.* labels (source, licenses, title, description, version, revision, created) stamped at build time so the GHCR package pages link back to this repo. licenses is per-image: MIT for the Go images, and MIT AND GPL-2.0-only AND MPL-2.0 for linux-base/linux-desktop/browser — which redistribute KasmVNC (GPL-2.0) and noVNC/Firefox ESR (MPL-2.0); the SBOM + THIRD_PARTY_LICENSES.md carry the full inventory. Those images also carry io.tinycdi.kasmvnc-source-offer, a pointer to the kasmvnc-<ver>-corresponding-source.tar.gz release asset produced by hack/kasmvnc-source-bundle.sh (the GPL-2.0 written offer — see NOTICE).

images.yml and release.yml accept a platforms input — default linux/amd64; set linux/amd64,linux/arm64 to opt into arm64.

Validating workflow changes locally

actionlint .github/workflows/*.yml
yamllint -c .github/.yamllint.yml .github/workflows/
uvx zizmor .github/workflows/            # security lint
.github/tests/validate-release-version.test.sh
.github/tests/release-topology.test.sh
.github/scripts/check-trivyignore.sh

.github/.yamllint.yml relaxes only line-length (SHA pins + comments) and document-start; the --- marker is present anyway for editor-friendliness.

About

TinyCDI — Kubernetes-native Linux cloud desktops (KasmVNC workspaces, operator, gateway, Helm chart)

Resources

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages