feat(ci): sign, describe and scan what we publish - #56
Merged
Conversation
nikzen
force-pushed
the
feat/supply-chain
branch
from
July 30, 2026 17:00
e01ac03 to
414fd0a
Compare
nikzen
force-pushed
the
feat/supply-chain
branch
2 times, most recently
from
August 3, 2026 13:53
414fd0a to
fcd7e6e
Compare
5 tasks
This was referenced Aug 3, 2026
A registry entry and a scanner both ask an image where it is from, and ours had no answer: the annotations OCI reserves for that were absent, so the only handle on a published image was its tag — and a tag moves. The build takes the repository, the commit and the ref name as optional arguments and turns them into annotations. Optional, because a build by hand has no commit to name, and refusing to build then would be worse than an image that says less. Deliberately without `org.opencontainers.image.created`: a timestamp would make two builds of one commit differ, and the commit named here has a date already. Co-authored-by: Cursor <cursoragent@cursor.com>
The server sent none of them, because it takes headers only from a file and had none. It has one now, generated from the same option the smoke test reads, so the image and the test that fetches from it cannot disagree about what it sends. What is in it by default says nothing about any particular application: no sniffing, no framing, a referrer only within one origin, no camera, microphone, location, payment or usb, and HSTS in case the ingress ever forgets it. `Content-Security-Policy` is not among them — a policy that fits one application forbids another one's inline bootstrap, so it stays with the project. Cross-origin isolation is an option and off by default: it buys a Flutter build the threaded renderer it otherwise ships without using, and costs every cross-origin resource that does not opt in. The server also answers `/health` now, which is what Kubernetes asks before it sends anyone here, and the smoke test asks the same. Co-authored-by: Cursor <cursoragent@cursor.com>
Three things every one of these workflows was missing. Each workflow now starts its jobs at `contents: read`, so a job added later reads the repository and nothing else until it says otherwise. The two that publish — Pages and the review app cleanup — already asked for what they need, and now stand out for it. Each job carries a timeout. Not to hurry a build that works: a runner waiting for something that will never arrive holds a queue for six hours, and the only workflow this replaced that bounded anything bounded it at five minutes. And the checkout no longer leaves the token in `.git/config`, where every later step could read it — including code generators and package scripts. Nothing of ours talks to the remote after the checkout; the steps that use the API take the token as their own environment variable. Co-authored-by: Cursor <cursoragent@cursor.com>
Nobody could answer what is inside one of our images without pulling it apart, which is the question every advisory starts with. An SPDX document is now written for each archive before it is pushed, from the archive rather than from the recipe, so it describes what is about to be published and not what was meant to be. A second one is written from the lockfile: a compiled bundle carries no trace of the packages it came from, so the image's own contents say nothing about the application's dependencies — and those are the ones advisories are written about. Both are kept as an artefact of the run. Tying them to the image in the registry follows. Co-authored-by: Cursor <cursoragent@cursor.com>
A pull by tag got whatever the registry had under that tag, and there was no way to tell whether it came from here. Now every digest we push is signed, and the SPDX documents from the previous commit are attached to the digests they describe, so what an image holds travels with it instead of expiring with the run that built it. Keyless, so there is no key of ours to hold, leak or rotate: the signature is bound to this workflow's identity and GitHub vouches for that over OIDC, which is why the job now asks for `id-token`. The price is a public record in the transparency log naming the image and its digest. The manifest list is signed too, and it is the one that matters: it is what a pull by tag resolves to, so it is what a policy will check. Co-authored-by: Cursor <cursoragent@cursor.com>
Nothing held our images or their dependencies against an advisory database, so a package with a known hole travelled to production and nobody was told. The scanner reads the documents from the previous commits rather than digging through the archives again, which means what it reports is about exactly the list of packages that ships. The report lands in the run's summary, where it is read without opening a log. It reports and does not block, and `ci.advisories.failOn` is what turns it into a gate. Off to begin with on purpose: a threshold set before anyone has seen a report either stops every release on the day it lands or teaches everyone to scroll past it. Co-authored-by: Cursor <cursoragent@cursor.com>
A tag says a version exists and nothing more. Whoever is looking for what changed has to find the changelog in the tree and read the right section of it, and anyone watching the repository for releases sees nothing at all. The workflow takes that section — whichever heading level it is written at — and publishes it as the release for the tag. A version with a suffix is marked as a prerelease, so a release candidate does not present itself as the current version. When the changelog has nothing to say about the version, GitHub's summary of the commits stands in with a warning rather than failing the run: by the time the tag is pushed, the file can no longer be fixed for it. Co-authored-by: Cursor <cursoragent@cursor.com>
A Flutter web application needs the WebAssembly module and its glue, which the native library is not a variant of. Both are cut from one pinned source now, so the Dart package can never talk to bindings from another revision. Collects everything vodozemac under nix/dart/vodozemac/ on the way. Signed-off-by: Niklas Zender <n.zender@famedly.com> Co-authored-by: Cursor <cursoragent@cursor.com>
A Flutter project depends on flutter_vodozemac, not on the plain package, and the hook then found no constraint at all and failed every commit. Both names are cut from the tag the bindings are built from. Signed-off-by: Niklas Zender <n.zender@famedly.com> Co-authored-by: Cursor <cursoragent@cursor.com>
nikzen
force-pushed
the
feat/supply-chain
branch
from
August 3, 2026 20:20
d2daab9 to
9a0d950
Compare
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 9a0d950. Configure here.
The vodozemac module and LiveKit's encryption worker are compiled artefacts of dependencies that a web build expects in web/, and a repository that carried them would carry copies that silently age. The worker is compiled from the package the project already resolved, so it follows pubspec.lock instead of a second clone pinned by hand. One script does both, and the devshell carries it: an asset only CI can produce is one a developer cannot reproduce when the site misbehaves in the browser. Signed-off-by: Niklas Zender <n.zender@famedly.com> Co-authored-by: Cursor <cursoragent@cursor.com>
`./pkg/` is the default `wasmPath` of both the plugin and the plain package, so an application that gets the module from us now needs to pass nothing at all. An application that passes a path of its own has to drop it. Signed-off-by: Niklas Zender <n.zender@famedly.com>
The eight-core larger runner was the only way to build for arm64 when these workflows were written: GitHub's standard arm64 runners were public repositories only. They reached private ones in January, so the choice is now one of price against wait. Measured, both runners on the same commit, twice each. The larger one has eight cores, 31 GB and 393 GB of disk; the standard one four, 15 GB and 109 GB — more than the documentation promises, and far more than a three gigabyte Flutter closure needs. Fetching and unpacking the Dart SDK takes the same time on both, compiling the vodozemac bindings 44 against 59 seconds, and the job as a whole 137 and 122 against 157 and 148. That is 1.16 times faster for 2.8 times the minute, since a larger runner also draws nothing from the included minutes. It would have to reach 2.8 to break even. Half of a build of ours is spent moving bytes rather than compiling them, and no number of cores shortens that. The option stays for the project whose arm64 build really is compilation from end to end, and `arm64Release` still follows it for one that wants to spend more on a release than on a nightly. Signed-off-by: Niklas Zender <n.zender@famedly.com> Co-authored-by: Cursor <cursoragent@cursor.com>
nikzen
force-pushed
the
feat/supply-chain
branch
from
August 4, 2026 07:09
9a0d950 to
c753a8a
Compare
This was referenced Aug 4, 2026
yash-garg
approved these changes
Aug 5, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.

Everything we publish now says where it came from, and can be checked against
what it says.
image. No
createdtimestamp, deliberately: it would make two builds of onecommit differ.
rather than the recipe, plus one from the lockfile — a compiled bundle
carries no trace of the packages advisories are written about.
grypereadsthem and reports into the run summary;
ci.advisories.failOnturns that intoa gate and is null to begin with.
cosignsigns every digest keyless, so there is no key ofours to leak or rotate, and attaches each document to the digest it
describes. The manifest list is signed too: it is what a pull by tag resolves
to.
so it sent none. It now gets one generated from the same option the smoke
test reads, so the image and the test cannot disagree. CSP stays with the
project, since a policy that fits one application forbids another's inline
bootstrap.
crossOriginIsolationis opt-in: COEP blocks every cross-originresource that does not opt in.
/healthfor Kubernetes while we are here.contents: read; the two that publish askfor more and stand out for it. Every job has a timeout, because a runner
waiting for something that never comes holds a queue for six hours. The
checkout no longer leaves its token in
.git/config.notes, marking a suffixed version as a prerelease. An empty section falls
back to GitHub's commit summary and a warning — by the time the tag is
pushed, the file can no longer be fixed for it.
when these workflows were written, GitHub's standard arm64 runners were
public repositories only. They reached private ones in January. Measured
twice on both: the larger runner finishes 1.16 times faster and costs 2.8
times the minute, and it would have to reach 2.8 to break even — half of a
build of ours is spent fetching and unpacking, where cores buy nothing. The
standard runner also turns out to have four cores, 15 GB and 109 GB of disk
rather than the documented two and eight. The option stays for a project
whose arm64 build really is compilation throughout.
Also folds in #60: vodozemac moves into
nix/dart/vodozemac/, split intosource, native and web, and a web project's assets are assembled before the
build.
Not here: SLSA provenance via
actions/attest-build-provenance, which needsthe action on the organisation's allow list first. Everything above runs from
the locked nixpkgs instead.
Test plan
nix flake check --all-systems,prek --all-files --stage pre-push122s against 157s and 148s for the whole job
registry: labels, headers,
/health, SBOM, scan report, signature andattestation
Made with Cursor
The stack
#63 and #59 stand outside the chain and can land in any order. #59 and #56
both touch the vodozemac module, so whichever goes second wants a rebase.