Skip to content

Commit 71ab228

Browse files
waleedlatif1claude
andauthored
feat(self-hosting): sign and attest published images, and correct the self-hosting docs (#7437)
* feat(supply-chain): sign and attest every published image, and inventory the chart's images Enterprise security reviews ask for artifacts Sim did not publish: a signature proving who built an image, SLSA provenance describing how, and an SBOM listing what is inside. A new attest-subjects job resolves the tags this run actually published to their digests, and attest-images signs each with cosign keyless signing and attaches provenance and, for single-architecture subjects, a Syft SBOM. All of it is pushed to the registry as referrers, so a mirrored registry carries the attestations with the image. Resolving tags rather than assuming them is what makes the coverage complete. `imagetools create` writes an index even from one manifest, so `:version-amd64` has a different digest from the `:sha-amd64` manifest it wraps, and which tags exist depends on whether the run is a release and whether the latest guard held. A moving alias is taken only when it resolves to this run's index, and that read is retried so registry lag is not mistaken for a deliberate skip. The SBOM is generated from the plain `<sha>-<arch>` manifest rather than the subject digest. Syft resolves an index against the runner's platform, so an arm64-only index fails outright on an amd64 runner — and the plain manifest is identical content, shared by both subjects for that architecture. helm/sim/images.yaml is the set of images a complete install pulls, generated from the rendered chart because an image can be referenced from a template that no values key names. Each entry carries the reference to pull from and the path the chart resolves to under a mirror; those differ, and the second is derived from a second render rather than described as a rule that could drift. The NVIDIA device plugin was hardcoded, so an air-gapped cluster could not redirect it. It now takes its image from `ollama.gpu.devicePlugin.image` through the shared helper, and the chart minor is bumped for the new key. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015BwsJTEQRzWJaY4BRCkPZt * docs(self-hosting): add sandboxes and reference architectures, and correct the existing pages An audit of 27 pages checked every concrete claim against the code it describes. It found claims that made a documented path fail outright, and claims that understated what ships. Errors that broke a documented path: - `openssl rand -hex 32` prints 64 hex characters, not the "32 hex chars" five entries claimed, and only two of those secrets are length-validated at all. - `API_ENCRYPTION_KEY` was missing from the whole Kubernetes path. It is optional, and unset means API keys are stored in plain text behind one warning. - Bucket fallback behaviour was wrong for all three object-storage providers, in both directions. - The remote-sandbox table omitted the immutable template and snapshot refs, so the documented configuration could not work. - The GKE snippet set a values key no template renders. - Retention deletion is scheduled by neither shipped deployment, so a deployment that followed the docs deleted nothing. - Every "Settings → Enterprise/Security → X" path named nav groups that do not exist, across eleven pages. Claims that understated what ships: - The outbound-proxy section said no platform traffic can be proxied. The server runs on Bun, whose fetch honors the proxy variables, so provider and Resend traffic already proxies; the guarded egress, SMTP, S3 and OTLP do not. Replaced with a per-path table and the NO_PROXY guidance internal services need. - Telemetry is off by default on Helm, not on. - The CronJob count was 18 in two places; there are 22. - The permission-group table was stale throughout, named a deployment key that does not exist, and inverted the checkbox semantic. Adds two pages. Sandboxes carries the base-image runbook that was buried in an enterprise enablement page. Reference architectures answers what an operator provisions versus what the chart provisions, per cloud — the question behind "do you have Terraform or CloudFormation templates?". Also documents around 25 environment variables nothing described, the migration advisory lock and its operational knobs, and `sim-setup doctor`. Removes redundancy where a page restated itself: FAQ blocks repeating their own body, and four drifting copies of the same tables. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015BwsJTEQRzWJaY4BRCkPZt * fix: address the first review round on the combined PR The mirror override in the inventory header double-prefixed. It told operators to override the device plugin with `<your-registry>/nvidia/k8s-device-plugin` while global rewriting stayed on, so `sim.image` prepended the registry a second time and the DaemonSet landed in ImagePullBackOff. Verified by rendering both forms; the override has to be the bare repository. The plugin's DaemonSet also now carries `global.imagePullSecrets`, which it was the only workload to omit — an authenticated mirror could not pull it. The resolver skipped `latest*` whenever the alias did not resolve to this run's index, which conflated "the guard withheld it" with "the read was stale". create-ghcr-manifests now publishes its guard decision and the resolver fails instead of skipping when the guard says latest was published this run. Docs corrections, each verified against source: - The loopback carve-out does lift plain HTTP and the private-address block; it leaves only the denied-port list in force. `egress.ts` skips the port check for allowlist vouching alone, which is what makes `http://localhost:5432` stay refused. - Not every guarded response is capped — the pinned fetch used by provider SDKs sets no limit. - A partially configured storage backend is skipped when a later one is complete; it fails only when none is. - GCE Ingress can serve a provisioned TLS Secret, so cert-manager is an alternative to a ManagedCertificate rather than requiring a different controller. - Workload Identity needs roles/iam.serviceAccountTokenCreator to sign GCS URLs. - NEXT_PUBLIC_BRAND_BACKGROUND_COLOR is a contrast flag, not a background. - NEXT_PUBLIC_SANDBOXES_ENABLED is a browser gate, not provider readiness. - An incomplete certificate chain is not fixed by NODE_EXTRA_CA_CERTS; the endpoint has to serve the intermediate. - Script migrations run once — the eight retries are for SQL lock timeouts. - NO_PROXY belongs in the application environment, not a shell assignment, and the GKE annotate command no longer hard-codes a release name and namespace. - Pi's own image, the Microsoft login callback, the custom-block identity split, the whitelabeling surfaces that ignore configured URLs, and sim-setup's Compose-only scope are all now stated. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015BwsJTEQRzWJaY4BRCkPZt * fix: address the second review round Two chart defects, both the same shape as the uploads-path bug this branch already documents: a path that looks right but sits outside where the app actually serves from. - `branding.mountPath` defaulted to `/app/public/branding`, while the standalone build places the public directory at `/app/apps/sim/public`. Every branded asset the docs tell operators to reference would have 404'd. Corrected in the chart and the example. - The device plugin was the only `sim.image` call site not passing `chartAppVersion`, so `tag: ""` — a natural way to ask for the chart default — errored instead of falling back. Docs, each verified against source: - Local disk is selected unless a backend's *activating* variables are set; credentials alone activate nothing. - Six backoff retries follow the initial failure, so up to seven attempts. - The migrations init container reads only the database Secret, and the chart's Secret templates carry just the password — so `MIGRATION_DATABASE_URL` needs an operator-supplied Secret, not the generated one. - The Ollama stack's missing Redis also breaks CLI authentication outright, while the idempotency store and progress markers fall back to Postgres. - `sim-setup reset` leaves the PostgreSQL PVC on Kubernetes. - The copilot ingress route renders only when `copilot.enabled` is set. - The Postgres exposure applies to whichever Compose file started the install. - The GKE annotate command resolves the Service name from the release rather than assuming it. - `API_ENCRYPTION_KEY` warns on every use, not once, and the whitelabeling coverage claim now describes the two surfaces that only partially honour it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015BwsJTEQRzWJaY4BRCkPZt * fix: address the third review round Two of these were fixes from earlier rounds that never landed — the edits were in batches that aborted partway, and I reported them applied without re-verifying. The LM Studio recreate command still hard-coded the production Compose file, and the proxy table still claimed "four exceptions" above a row listing fifteen providers. The rest: - The NO_PROXY example omitted `ollama`, so on the Ollama stack local model requests would go to the external proxy. - The `lock_timeout` sentence had its trade-off backwards: it trades a table-wide stall for a failed migration, not the reverse. - `NEXT_PUBLIC_CUSTOM_CSS_URL` has to be same-origin or CSP-allowlisted; `style-src` blocks a stylesheet from another origin. - Any of a storage backend's keys activates it — a dedicated bucket, `S3_ENDPOINT`, an Azure credential — so a half-configured backend fails at startup rather than falling back to local disk. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015BwsJTEQRzWJaY4BRCkPZt * fix: address the fourth review round - The LM Studio recreate snippet now shows both Compose assignments instead of describing the alternative in a comment. - `sim-setup logs` on a source checkout prints where the logs are rather than following a stream; the table said it follows. - The GKE BackendConfig annotation targeted `<release>-realtime`, but the chart names the Service `<fullname>-realtime` — and the fullname is `<release>-sim` unless the release name already contains `sim`. It now selects by the release and component labels, which holds for either. - The Helm `NO_PROXY` example listed only the `.svc` suffixes, which match none of the bare Service names the chart wires into `SOCKET_SERVER_URL`, `PII_URL`, and `OLLAMA_URL`. Those requests would still have gone to the proxy. - `sim-setup` detects only the production and local Compose files, so an Ollama-stack install is invisible to it. The upgrade section now says so and gives the direct commands. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015BwsJTEQRzWJaY4BRCkPZt * fix: address the fifth review round - The Ollama upgrade commands ran unprofiled, so Compose would have skipped the profile-scoped `ollama` service and left `OLLAMA_URL` pointing at nothing. Both commands now carry the install's `--profile`. - `start` / `stop` / `restart` are only whole-install operations on Compose. On a source checkout they manage just the Postgres and Redis containers, and on Kubernetes they print kubectl commands rather than acting. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015BwsJTEQRzWJaY4BRCkPZt * fix: address the sixth review round - The Ollama upgrade commands hard-coded `--profile gpu` while the comment said to use the install's profile. It is a variable now. - `sim-setup reset` on Kubernetes runs `helm uninstall`, which leaves the Postgres volumes behind — they come from a StatefulSet's `volumeClaimTemplates`, which Kubernetes does not delete. The row promised a full data wipe. - Noted that the command table drops the `npx` prefix for brevity. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015BwsJTEQRzWJaY4BRCkPZt * fix: address the seventh review round - "`statement_timeout` is 0, so a long migration is never cut off" was absolute, and `0076_damp_vector.sql` disproves it — it sets 180s locally. It is the session default now, with the override named. - The migration-log command hard-coded the production Compose file, so a source-built or Ollama install would have inspected the wrong stack. - The Compose rollback applied `SIM_VERSION` to every install, but the local and Ollama stacks build from source and ignore it. Those now get the checkout-and-rebuild path. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015BwsJTEQRzWJaY4BRCkPZt * fix: address the eighth review round `docker-compose.ollama.yml` builds the app, realtime server, and migrator from source, which I had missed: - The Ollama upgrade block ran `pull` then `up -d`. That refreshes only the Ollama and Postgres images and leaves every Sim service on its old build. It is a checkout plus `build --pull` now. - The source rollback showed only the local file and no profile; the Ollama stack needs both. - Dropped the Ollama mention from the `COMPOSE_FILE` comment above, since that stack is covered by its own profile-aware commands rather than the CLI. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015BwsJTEQRzWJaY4BRCkPZt * fix: audit the docs against source instead of one review round at a time Eight review rounds kept finding defects because I was patching what the last round flagged and writing new prose in the process, which nothing then checked. This is a full pass over the Compose files, the sim-setup CLI, and the chart. The mirror inventory (Greptile's blocker): the bare device-plugin override changes where the chart pulls from, to `<registry>/nvidia/k8s-device-plugin`, while the inventory tells operators to mirror to `<registry>/nvcr.io/nvidia/...`. Following both instructions produced an unpopulated path. Verified by rendering the chart both ways; the header now names the override's destination. Compose: - Nine `docker compose` commands across troubleshooting, verify, and background-jobs had no `-f`. The repo ships no default compose file, so every one of them failed with "no configuration file provided". - `simstudio` declares `env_file: .env` in all three files, so the LM Studio fix does not need a hand-edited `environment:` block — and hand-editing a managed Compose file makes `sim-setup update` refuse. - `OLLAMA_URL` as a one-shot shell prefix reverts to the localhost default on the next `up -d`, pointing the container at itself. It belongs in `.env`. - `pg_dump` through `exec` without `-T` corrupts the dump; the restore beside it already used `-T`. - Live cross-process status is a Redis consequence, not a cron one. - The local Compose file was missing from three "the file that started your install" lists and from the exposed-Postgres warning. - No shipped Compose file defines a `pii` service. - The rollback said "edit .env" but appended a second SIM_VERSION line. sim-setup CLI — the table stated Compose behavior as universal: - `down` runs `helm uninstall` on Kubernetes and removes only Postgres and Redis on a source checkout; both prompt first. - `reset` archives four env files on anything but Compose, including when no install is detected. - `--no-open` was described backwards: the link always prints, the flag skips the download prompt. - `start`/`restart` print port-forward hints on Kubernetes, not equivalents. - `--dir` is a global option and scopes the lifecycle commands. - `--quick` only preselects the setup style; `add` and `desktop` have refusals worth knowing; the update hand-edit refusal is narrower than stated. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015BwsJTEQRzWJaY4BRCkPZt * fix: parameterize the Ollama rollback profile The rollback hard-coded `--profile gpu` while the upgrade example above it uses a variable, so a CPU install would have started the GPU service. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015BwsJTEQRzWJaY4BRCkPZt * fix: correct the Kubernetes and chart claims the audit found The install command was the serious one: the cloud values files hardcode a placeholder domain in six places, and the documented command overrode only `NEXT_PUBLIC_APP_URL`. Following the page literally left sign-in pointed at the placeholder, realtime rejecting every socket upgrade, and the Ingress serving the wrong host — the exact failure the page warns about further down. - The PII recipe's `INTERNAL_API_BASE_URL` self-call is blocked by the NetworkPolicy the same page recommends enabling: the app policy permits Postgres, Redis, realtime, Ollama, PII, telemetry, DNS, and 443, but has no app-to-app rule. Redaction would fail closed. - The GPU device-plugin DaemonSet is not `restricted`-compliant — it sets only two of the four controls and mounts three `hostPath` volumes — so enforcing the label namespace-wide breaks a GPU install. - Under External Secrets the chart writes nothing from `app.env`; an unmapped key fails the render. The page said the two modes behave alike. - The CronJob pods have no NetworkPolicy either. - `app.env.DATABASE_URL` is silently discarded: the chart computes it and inlines it on the container, so the PgBouncer advice pointed at a key that does nothing. - The PDB also renders from `autoscaling.minReplicas`, the reference topology could not render without an `externalDatabase.password`, `copilot`'s security-context keys nest one level deeper, and `NO_PROXY` needs the app's own Service name. - Untangled a garbled `externalSecrets.apiVersion` comment that contradicted its own default. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015BwsJTEQRzWJaY4BRCkPZt * fix: correct the env-var and runtime-behavior claims the audit found - White-labeling, session policies, and the inbox are already on wherever billing is disabled, which is every self-hosted deployment. The docs told operators to enable white-labeling with a flag it does not need, and listed all thirteen features as symmetric opt-ins. - `NEXT_PUBLIC_CUSTOM_CSS_URL` must be same-origin. The CSP's `style-src` is a hardcoded literal that takes no configured hosts, so both the "point them at absolute URLs" advice and my own earlier "or a host you have added to style-src" were wrong — there is no such remedy short of editing source. - Client variables are read at boot from the container environment, not baked at build. The troubleshooting note invited a pointless image rebuild. - Script migrations run with `lock_timeout = 0` and `statement_timeout = 0`, so one blocked on an app-held lock hangs forever rather than failing with `55P03`. The table described only the SQL phase. - With no mail provider the mailer logs recipient, subject, and sender at `info` and reports success — never the body, and nothing at all at the production default of `ERROR`. - A malformed storage backend stops startup; it does not fall through to a later fully configured one. - `EMAIL_PASSWORD_SIGNUP_ENABLED=false` disables sign-in and OTP too, and `SIGNUP_MX_VALIDATION_ENABLED` covers only email signup. - The Pi template accepts an immutable ref; the page forbade one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015BwsJTEQRzWJaY4BRCkPZt * fix: correct the audit pass's own errors, and six it missed Four of these are defects I introduced in the audit commits by applying suggested replacement text without verifying the replacement the way I verified the finding: - `postgresql.enabled: false` alone renders an empty `DATABASE_URL`; `externalDatabase.enabled: true` is required too. I dropped it. - The external-database password check is skipped under `existingSecret` and External Secrets, so "the chart refuses to render" was false. Under ESO the mapping is what matters. - `LOG_LEVEL` is an uppercase-only enum, so the `info` I told operators to set would fail validation. - `add cache` writes `REDIS_URL`, which `realtime` reads too, so recreating only the app container leaves Socket.IO pointed at the old Redis. Six the audits missed: - `PII_URL` used `<release>-pii`, the same fullname bug already fixed for the realtime Service. A manual value is also ignored when `pii.enabled`. - External Redis does not need `redis.enabled: false` — a non-empty `app.env.REDIS_URL` overrides the bundled URL and suppresses its Deployment. - `sim-setup doctor` was recommended to Ollama-stack users, which it cannot detect. - The CA-bundle snippet said "in either case" directly after explaining that an incomplete chain must be repaired server-side. - `update` refuses only the `dev` mode; a source checkout on `docker-compose.local.yml` is a Compose install and does update. - Doctor cannot tell whether migrations are current on a standalone install — the published package ships no migration journal. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015BwsJTEQRzWJaY4BRCkPZt * fix: describe the PDB activation condition accurately The autoscaling term is computed once from the global `autoscaling` block and reused for both PodDisruptionBudgets, so `minReplicas > 1` creates realtime's even when `autoscaling.realtime.enabled` is false and no realtime HPA exists. Calling the two independent was wrong. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015BwsJTEQRzWJaY4BRCkPZt * fix: use a renderable placeholder for externalDatabase.password The chart validates the password against ^[a-zA-Z0-9._-]+$, so the `<password>` placeholder I added last round made the reference topology fail to render if copied as-is. Replaced with a compliant placeholder and documented the character restriction, which is a real constraint operators hit when generating one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015BwsJTEQRzWJaY4BRCkPZt --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 7395a37 commit 71ab228

48 files changed

Lines changed: 2139 additions & 674 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/ci.yml

Lines changed: 292 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -554,6 +554,11 @@ jobs:
554554
permissions:
555555
contents: read
556556
packages: write
557+
# Every matrix leg evaluates the same guard against the same commit, so the
558+
# value is identical whichever leg reports it last. attest-subjects needs it
559+
# to tell "the guard withheld latest" from "the registry read was stale".
560+
outputs:
561+
latest_fresh: ${{ steps.guard.outputs.fresh }}
557562
strategy:
558563
matrix:
559564
include:
@@ -611,6 +616,280 @@ jobs:
611616
"${IMAGE}:${SHA}-amd64" "${IMAGE}:${SHA}-arm64"
612617
fi
613618
619+
# Sign the published images and attach SLSA provenance and an SBOM to each.
620+
#
621+
# This runs after create-ghcr-manifests rather than inside the build because
622+
# buildx's own provenance/sbom attestations stay off (see the note in
623+
# .github/actions/docker-build): the extra manifests they add to an index
624+
# break the `imagetools create` retagging that promote-images depends on.
625+
# Attaching attestations here instead leaves the index itself untouched — they
626+
# are stored as separate referrer manifests that point at it.
627+
#
628+
# Resolve the set of digests that actually got published, so the attestation
629+
# job below covers every tag a customer can pull.
630+
#
631+
# A static list is not enough. `imagetools create` always writes an INDEX, so
632+
# `:<version>-amd64` is a single-entry index whose digest differs from the
633+
# `:<sha>-amd64` manifest it wraps — attesting the manifest leaves the tag
634+
# people actually pin unverifiable. Which tags exist also varies per run:
635+
# version tags only on a release, and the latest tags only when the monotonic
636+
# guard in create-ghcr-manifests passed. Resolving tag -> digest here and
637+
# de-duplicating is what keeps the two in step without hardcoding that logic
638+
# twice.
639+
attest-subjects:
640+
name: Resolve Attestation Subjects
641+
runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-2vcpu-ubuntu-2404' || 'ubuntu-latest' }}
642+
timeout-minutes: 10
643+
needs: [create-ghcr-manifests, detect-version]
644+
if: >-
645+
!cancelled() &&
646+
needs.create-ghcr-manifests.result == 'success' &&
647+
needs.detect-version.result == 'success' &&
648+
github.event_name == 'push' && github.ref == 'refs/heads/main'
649+
permissions:
650+
contents: read
651+
packages: read
652+
outputs:
653+
subjects: ${{ steps.resolve.outputs.subjects }}
654+
count: ${{ steps.resolve.outputs.count }}
655+
steps:
656+
- name: Login to GHCR
657+
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4
658+
with:
659+
registry: ghcr.io
660+
username: ${{ github.repository_owner }}
661+
password: ${{ secrets.GITHUB_TOKEN }}
662+
663+
- name: Resolve published tags to digests
664+
id: resolve
665+
env:
666+
IS_RELEASE: ${{ needs.detect-version.outputs.is_release }}
667+
VERSION: ${{ needs.detect-version.outputs.version }}
668+
SHA: ${{ github.sha }}
669+
LATEST_FRESH: ${{ needs.create-ghcr-manifests.outputs.latest_fresh }}
670+
run: |
671+
set -euo pipefail
672+
673+
IMAGES="simstudio migrations realtime pii cron"
674+
675+
# Prints the digest, or nothing when the tag is genuinely absent.
676+
#
677+
# An absent tag and a registry hiccup both make `inspect` fail, and
678+
# treating them alike is how a published image silently ends up
679+
# unsigned while this job still goes green. So: retry, and only report
680+
# "absent" when the registry actually says the manifest is unknown.
681+
# Anything else fails the step.
682+
digest_of() {
683+
local ref="$1" attempt raw err absent=0
684+
for attempt in 1 2 3; do
685+
if raw="$(docker buildx imagetools inspect "$ref" --format '{{json .Manifest}}' 2>/tmp/inspect.err)"; then
686+
printf '%s' "$raw" | jq -r '.digest // empty'
687+
return 0
688+
fi
689+
err="$(cat /tmp/inspect.err)"
690+
# Absence is retried like any other failure: GHCR can report a
691+
# just-published alias as unknown for a moment, and accepting that
692+
# on the first attempt would skip a tag this run did publish.
693+
case "$err" in
694+
*"not found"*|*MANIFEST_UNKNOWN*|*"no such manifest"*|*"NAME_UNKNOWN"*) absent=1 ;;
695+
*) absent=0 ;;
696+
esac
697+
if [ "$attempt" -lt 3 ]; then
698+
sleep "$((attempt * 3))"
699+
fi
700+
done
701+
# Only call it absent if the registry said so on the final attempt.
702+
if [ "$absent" -eq 1 ]; then
703+
return 0
704+
fi
705+
echo "::error::Could not inspect ${ref} after 3 attempts: ${err}" >&2
706+
return 1
707+
}
708+
709+
# Records a subject. `platform` tells the attestation job whether this
710+
# digest is a single-architecture image, which is the only case where
711+
# a Syft SBOM describes what the puller actually gets.
712+
emit() {
713+
jq -nc --arg image "$1" --arg digest "$2" --arg platform "$3" \
714+
'{image: $image, digest: $digest, platform: $platform}' >> /tmp/subjects.jsonl
715+
}
716+
717+
: > /tmp/subjects.jsonl
718+
for name in $IMAGES; do
719+
image="ghcr.io/simstudioai/${name}"
720+
721+
# The sha tags are this run's own output. All three must resolve —
722+
# a missing one means the publish did not complete, not that the tag
723+
# is optional.
724+
seen=""
725+
sha_index=""
726+
for tag in "${SHA}" "${SHA}-amd64" "${SHA}-arm64"; do
727+
digest="$(digest_of "${image}:${tag}")"
728+
if [ -z "$digest" ]; then
729+
echo "::error::${image}:${tag} was not published by this run"
730+
exit 1
731+
fi
732+
case "$tag" in
733+
*-amd64) platform=amd64 ;;
734+
*-arm64) platform=arm64 ;;
735+
*) platform=index; sha_index="$digest" ;;
736+
esac
737+
seen="$seen $digest"
738+
emit "$image" "$digest" "$platform"
739+
done
740+
741+
# A moving alias is only taken when it resolves to the same index
742+
# digest this run published — content identity, which is what a
743+
# digest can prove. create-ghcr-manifests holds the latest tags back when
744+
# its monotonic guard sees a newer commit, and they then still point
745+
# at an older build — attesting those would put this run's signature
746+
# and provenance on an image it did not produce. The per-arch
747+
# aliases are published in the same guarded block as `latest`, so
748+
# that one comparison gates all three.
749+
alias_groups="latest"
750+
if [ "${IS_RELEASE}" = "true" ]; then
751+
alias_groups="${alias_groups} ${VERSION}"
752+
fi
753+
754+
for alias in $alias_groups; do
755+
# A mismatch has two very different causes: the guard deliberately
756+
# held the tag back, or GHCR is still serving the previous digest
757+
# moments after this run wrote it. Re-read before concluding the
758+
# former, or a read landing a second early silently drops three
759+
# subjects from the matrix.
760+
alias_index=""
761+
for alias_attempt in 1 2 3; do
762+
alias_index="$(digest_of "${image}:${alias}")"
763+
[ "$alias_index" = "$sha_index" ] && break
764+
[ "$alias_attempt" -lt 3 ] && sleep 5 || true
765+
done
766+
767+
if [ "$alias_index" != "$sha_index" ]; then
768+
# `latest` is allowed to lag, but only when the guard actually
769+
# withheld it. If the guard published latest this run, a mismatch
770+
# here is a stale read, not a deliberate skip — and silently
771+
# dropping it would leave a published tag unsigned.
772+
if [ "$alias" = "latest" ]; then
773+
if [ "${LATEST_FRESH}" = "true" ]; then
774+
echo "::error::${image}:latest was published by this run but resolves to ${alias_index:-nothing}"
775+
exit 1
776+
fi
777+
echo "Skipping latest* for ${image}: the monotonic guard withheld it this run."
778+
continue
779+
fi
780+
# A version tag has no such carve-out. This run published it, so
781+
# a release must not ship a version image nothing has attested.
782+
echo "::error::${image}:${alias} does not resolve to this run's index (${sha_index:-none}); refusing to publish an unattested release image"
783+
exit 1
784+
fi
785+
for tag in "${alias}" "${alias}-amd64" "${alias}-arm64"; do
786+
digest="$(digest_of "${image}:${tag}")"
787+
if [ -z "$digest" ]; then
788+
echo "::error::${image}:${tag} is missing though ${image}:${alias} is current"
789+
exit 1
790+
fi
791+
case " $seen " in *" $digest "*) continue ;; esac
792+
seen="$seen $digest"
793+
case "$tag" in
794+
*-amd64) emit "$image" "$digest" amd64 ;;
795+
*-arm64) emit "$image" "$digest" arm64 ;;
796+
*) emit "$image" "$digest" index ;;
797+
esac
798+
done
799+
done
800+
done
801+
802+
if [ ! -s /tmp/subjects.jsonl ]; then
803+
echo "::error::Resolved no image digests to attest"
804+
exit 1
805+
fi
806+
807+
echo "Resolved $(wc -l < /tmp/subjects.jsonl) distinct subjects:"
808+
cat /tmp/subjects.jsonl
809+
echo "subjects=$(jq -sc . /tmp/subjects.jsonl)" >> "$GITHUB_OUTPUT"
810+
echo "count=$(wc -l < /tmp/subjects.jsonl | tr -d ' ')" >> "$GITHUB_OUTPUT"
811+
812+
# One leg per distinct published digest. Attesting each subject separately is
813+
# also what makes the SBOMs truthful: the amd64 and arm64 images contain
814+
# different packages, and one SBOM attached to the index cannot describe both.
815+
attest-images:
816+
name: Attest Images
817+
runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-2vcpu-ubuntu-2404' || 'ubuntu-latest' }}
818+
timeout-minutes: 15
819+
needs: [attest-subjects]
820+
if: >-
821+
!cancelled() &&
822+
needs.attest-subjects.result == 'success'
823+
permissions:
824+
contents: read
825+
packages: write
826+
# Sigstore signs against the runner's OIDC identity; no key material is stored.
827+
id-token: write
828+
attestations: write
829+
strategy:
830+
fail-fast: false
831+
matrix:
832+
include: ${{ fromJSON(needs.attest-subjects.outputs.subjects) }}
833+
834+
steps:
835+
- name: Login to GHCR
836+
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4
837+
with:
838+
registry: ghcr.io
839+
username: ${{ github.repository_owner }}
840+
password: ${{ secrets.GITHUB_TOKEN }}
841+
842+
# Skipped for index subjects: Syft resolves an index to one platform, so
843+
# the SBOM it produces would describe amd64 while the index also serves
844+
# arm64. The per-arch subjects below carry an accurate SBOM each, and the
845+
# index still gets a signature and provenance.
846+
#
847+
# Scanned by the `<sha>-<arch>` tag rather than by `matrix.digest`. Half
848+
# the per-arch subjects are single-entry INDEXES (`imagetools create`
849+
# writes an index even from one manifest), and Syft resolves an index
850+
# against the RUNNER's platform — so an arm64-only index fails outright on
851+
# an amd64 runner with "no child with platform linux/arm64". The sha tag is
852+
# the plain manifest that index wraps: identical content, no platform
853+
# resolution, and one pull shared by both subjects instead of two.
854+
- name: Generate SBOM
855+
if: matrix.platform != 'index'
856+
uses: anchore/sbom-action@3ad7283483fc7af8ff2b4ea19663c2d5ca935e26 # v0.24.2
857+
with:
858+
image: ${{ matrix.image }}:${{ github.sha }}-${{ matrix.platform }}
859+
format: spdx-json
860+
output-file: sbom.spdx.json
861+
# The action's own release upload is for workflows triggered by a
862+
# release; these attach to the image instead.
863+
upload-artifact: false
864+
upload-release-assets: false
865+
866+
- name: Attest SBOM
867+
if: matrix.platform != 'index'
868+
uses: actions/attest-sbom@c604332985a26aa8cf1bdc465b92731239ec6b9e # v4.1.0
869+
with:
870+
subject-name: ${{ matrix.image }}
871+
subject-digest: ${{ matrix.digest }}
872+
sbom-path: sbom.spdx.json
873+
# Stored alongside the image so a mirrored registry carries the
874+
# attestation with it, rather than only being retrievable from GitHub.
875+
push-to-registry: true
876+
877+
- name: Attest build provenance
878+
uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2
879+
with:
880+
subject-name: ${{ matrix.image }}
881+
subject-digest: ${{ matrix.digest }}
882+
push-to-registry: true
883+
884+
- name: Install Cosign
885+
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
886+
887+
# The attestations above prove how the image was built; this is the plain
888+
# signature that admission controllers (Kyverno, the Sigstore policy
889+
# controller) verify before admitting a pod.
890+
- name: Sign image
891+
run: cosign sign --yes "${{ matrix.image }}@${{ matrix.digest }}"
892+
614893
# Check if docs changed
615894
# Smallest runner on purpose: a depth-2 checkout plus a path filter, no
616895
# install and no build.
@@ -652,11 +931,22 @@ jobs:
652931
name: Create GitHub Release
653932
runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-latest' }}
654933
timeout-minutes: 10
655-
needs: [create-ghcr-manifests, detect-version]
656-
# Explicit results: see migrate's comment.
934+
needs: [create-ghcr-manifests, attest-subjects, attest-images, detect-version]
935+
# Explicit results: see migrate's comment. attest-images is a gate, not just
936+
# an ordering edge — a release must not advertise images whose signature or
937+
# attestation failed to publish. The count check is belt and braces: today
938+
# attest-subjects already fails on an empty subject set, so this only bites
939+
# if that guard is ever removed.
940+
#
941+
# Note this gates the GitHub release, not the production deploy: CodePipeline
942+
# fires from the ECR tags moved by promote-images, upstream of this job, and
943+
# only the GHCR mirrors are attested.
657944
if: >-
658945
!cancelled() &&
659946
needs.create-ghcr-manifests.result == 'success' &&
947+
needs.attest-subjects.result == 'success' &&
948+
needs.attest-subjects.outputs.count != '0' &&
949+
needs.attest-images.result == 'success' &&
660950
needs.detect-version.result == 'success' &&
661951
needs.detect-version.outputs.is_release == 'true'
662952
permissions:

.github/workflows/helm.yml

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,19 @@ on:
66
paths:
77
- 'helm/sim/**'
88
- '.github/workflows/helm.yml'
9+
# The image inventory is generated from the chart and checked here, so a
10+
# change to its generator has to run this workflow too.
11+
- 'scripts/generate-image-manifest.ts'
12+
- 'package.json'
913
pull_request:
1014
branches: [main, staging, dev]
1115
paths:
1216
- 'helm/sim/**'
1317
- '.github/workflows/helm.yml'
18+
# The image inventory is generated from the chart and checked here, so a
19+
# change to its generator has to run this workflow too.
20+
- 'scripts/generate-image-manifest.ts'
21+
- 'package.json'
1422

1523
concurrency:
1624
group: helm-${{ github.ref }}
@@ -43,6 +51,13 @@ jobs:
4351
- name: Scheduler parity (docker/crontab vs helm cronjobs)
4452
run: bun run scripts/check-cron-parity.ts
4553

54+
# helm/sim/images.yaml is what an operator mirrors into a disconnected
55+
# registry, so a chart change that adds an image has to update it. Lives
56+
# here rather than in `check:audits` because it renders the chart, and the
57+
# audits job has no Helm.
58+
- name: Image inventory is current
59+
run: bun run images:check
60+
4661
- name: Helm lint
4762
run: helm lint helm/sim --values helm/sim/ci/default-values.yaml
4863

0 commit comments

Comments
 (0)