Skip to content

feat(ci): sign, describe and scan what we publish - #56

Merged
yash-garg merged 12 commits into
mainfrom
feat/supply-chain
Aug 5, 2026
Merged

feat(ci): sign, describe and scan what we publish#56
yash-garg merged 12 commits into
mainfrom
feat/supply-chain

Conversation

@nikzen

@nikzen nikzen commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Everything we publish now says where it came from, and can be checked against
what it says.

  • Provenance. OCI annotations for repository, commit and ref on every
    image. No created timestamp, deliberately: it would make two builds of one
    commit differ.
  • Contents. An SPDX document per architecture, written from the archive
    rather than the recipe, plus one from the lockfile — a compiled bundle
    carries no trace of the packages advisories are written about. grype reads
    them and reports into the run summary; ci.advisories.failOn turns that into
    a gate and is null to begin with.
  • Signatures. cosign signs every digest keyless, so there is no key of
    ours 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.
  • Headers. The static server takes headers only from a file and had none,
    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. crossOriginIsolation is opt-in: COEP blocks every cross-origin
    resource that does not opt in. /health for Kubernetes while we are here.
  • Least privilege. Jobs start at contents: read; the two that publish ask
    for 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.
  • Releases. A version tag turns the matching changelog section into release
    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.
  • Cheaper arm64. The images built on an eight-core larger runner because,
    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 into
source, native and web, and a web project's assets are assembled before the
build.

Not here: SLSA provenance via actions/attest-build-provenance, which needs
the 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-push
  • both arm64 runners measured on the same commit, two runs each: 137s and
    122s against 157s and 148s for the whole job
  • famedly/famedly-control#278 exercises the rest against the nightly
    registry: labels, headers, /health, SBOM, scan report, signature and
    attestation

Made with Cursor


The stack

main ─┬─ #63  make room on the runner
      ├─ #59  substitute from the shared binary cache
      └─ #56  sign, describe and scan what we publish  ←
            └─ #62  keep the build directory out of vodozemac
                  └─ #61  tell a web build which build it is
                        └─ #64  let a project choose its vodozemac

#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.

Comment thread nix/dart/web/workflows/build.nix
Comment thread nix/general/workflows/release.nix Outdated
@nikzen
nikzen force-pushed the feat/supply-chain branch from e01ac03 to 414fd0a Compare July 30, 2026 17:00
@nikzen
nikzen changed the base branch from feat/flutter-standards to main August 3, 2026 13:47
@nikzen
nikzen force-pushed the feat/supply-chain branch 2 times, most recently from 414fd0a to fcd7e6e Compare August 3, 2026 13:53
Comment thread nix/general/workflows/ci-steps.nix
nikzen and others added 9 commits August 3, 2026 22:19
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
nikzen force-pushed the feat/supply-chain branch from d2daab9 to 9a0d950 Compare August 3, 2026 20:20

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Fix All in Cursor

❌ 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.

Comment thread nix/dart/web/workflows/image.nix
nikzen and others added 3 commits August 4, 2026 09:08
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>
@yash-garg
yash-garg merged commit c9a7215 into main Aug 5, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants