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 |
The publishing workflows (images.yml, release.yml, runtime-images.yml)
split publish rights from scanner execution (SEC-04/SEC-05):
- build jobs (
packages: write) run only checkout + docker actions:docker buildx build --output type=registry,push-by-digest=truepushes the manifest by digest only — no tags exist yet. Registry secrets (BASE_MIRROR_*, optional) are step-scoped to the login step, anddocker logoutruns at the end of the job. - scan jobs hold only
packages: read(+security-events/actionsfor the optional SARIF upload) — no secrets, noid-token, noattestations. trivy is installed from a release tarball with a pinned sha256 (the trivy-action runtime download is not used) and runs with an empty--configplus an explicit--ignorefilepointing at the effective ignorefile generated at scan time bycheck-trivyignore.sh --out— an expired or malformed.trivyignoreentry fails the scan job, and expiry is enforced by trivy's nativeexp: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). - 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) BEFOREdocker buildx imagetools createpromotes 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.shvalidates them strictly: exactly onerefs/<img>.refper released image matchingghcr.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.
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 --applyThe script is idempotent (GET-then-create/update only what differs) and sets:
releaseenvironment — required reviewers (RELEASE_REVIEWERS, a comma list of GitHub logins resolved to ids at run time; default:vanlongme) + a deployment tag policy matchingv*.release-tagsruleset onrefs/tags/v*— creation/update/deletion restricted to repo admins only (no GitHub Actions app bypass). Consequence:release.ymlworkflow_dispatchis dry-run only — a real release is made by an admin pushing thev*.*.*tag.- Branch protection on
main— required status checks = every ci.yml job exceptkasm 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 inruntime-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_countis 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=trueandCODE_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/CODEOWNERSroutes everything to@vanlongme(single maintainer) — enable Require review from Code Owners when a second maintainer joins.
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 |
| 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.
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-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'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.
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'- Tag push
v1.2.3→ images:1.2.3+:latest, chart1.2.3, release marked latest; a-suffix (v1.2.3-rc.1) marks the release prerelease and skips the:latesttag. The packaged chart'simages.*.digestvalues are stamped with the exact pushed digests, so a released chart pulls immutable references (SEC-18). workflow_dispatchis a dry-run rehearsal only: everything is built, scanned and packaged, and uploaded as workflow artifacts — no pushes, no release.dry_run: falseis refused up front (the v* tag ruleset grants the Actions app no bypass, so a dispatch could never create the taggh release createneeds). Real releases come only from an admin pushing av*.*.*tag.-ldflags "-X main.version=<tag>"stamps binaries; it is a no-op untilcmd/*exportvar version. Addvar version = "dev"tocmd/backendandcmd/operatorto light it up. The release ships two static binaries:tinycdi-backend(public API + session gateway) andtinycdi-operator.
| 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.
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.