Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -212,7 +212,7 @@ jobs:
IDENTITY: ${{ github.server_url }}/${{ github.repository }}/.github/workflows/release.yml@${{ github.ref }}
run: |
set -euo pipefail
for f in _output/release/*.tar.gz _output/release/*.tar.gz.sha256 _output/release/machine.env; do
for f in _output/release/*.tar.gz _output/release/*.tar.gz.sha256 _output/release/machine.env _output/release/*.cdx.json; do
cosign sign-blob --yes --bundle "${f}.sigstore.json" "${f}" > /dev/null
cosign attest-blob --yes --type slsaprovenance1 --predicate _output/provenance.json \
--bundle "${f}.provenance.sigstore.json" "${f}" > /dev/null
Expand All @@ -222,7 +222,17 @@ jobs:
--certificate-identity "${IDENTITY}" \
--certificate-oidc-issuer https://token.actions.githubusercontent.com "${f}"
done
# The tarball's SBOM (hack/release), attested over it: what it is made of, by the
# workflow that built it. The same SBOM is inside it, at usr/share/spin-stack/sbom.
for f in _output/release/*.tar.gz; do
cosign attest-blob --yes --type cyclonedx --predicate "${f%.tar.gz}.cdx.json" \
--bundle "${f}.cdx.sigstore.json" "${f}" > /dev/null
cosign verify-blob-attestation --type cyclonedx --bundle "${f}.cdx.sigstore.json" \
--certificate-identity "${IDENTITY}" \
--certificate-oidc-issuer https://token.actions.githubusercontent.com "${f}"
done
go tool attestgate files -require provenance _output/release
go tool attestgate files -require cdx -except '*.sha256,machine.env,*.cdx.json' _output/release

# Tagged here when the run generated its own version, so that the next run's count
# of "tags already taken today" is right and the tarball has a commit behind it.
Expand All @@ -246,6 +256,7 @@ jobs:
_output/release/*.tar.gz
_output/release/*.tar.gz.sha256
_output/release/machine.env
_output/release/*.cdx.json
_output/release/*.sigstore.json

# What every combination of the machine's features costs, booted from the tarball just
Expand Down
9 changes: 9 additions & 0 deletions docs/releasing.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,15 @@ cosign verify-blob-attestation --type slsaprovenance1 --bundle f.provenance.sigs
```

A run started by hand ran on `refs/heads/main`, and that is the ref its certificate names.

What the release is made of is its CycloneDX SBOM, written by `hack/release` from what the builds
recorded - the apk databases QEMU and e2fsprogs were statically linked in (each build's
`/lib/apk/db/installed`, extracted to `_output/sbom/`), the image's `packages.txt`, the pins in
`versions.yaml`, and each shipped file by its hash - and checked with `go tool sbom check`: a
component with no version fails the release. It is inside the tarball at
`usr/share/spin-stack/sbom/machine.cdx.json`, beside it as `<tarball>.cdx.json`, and attested
over the tarball as `<tarball>.cdx.sigstore.json` (`cosign verify-blob-attestation --type
cyclonedx`, with the identity above).
`workflows/workflows_test.go` holds every workflow to attesting before it publishes.

## The feature matrix
Expand Down
3 changes: 3 additions & 0 deletions e2fsprogs/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -77,3 +77,6 @@ COPY --from=builder /build/e2fsck/e2fsck /bin/e2fsck
COPY --from=builder /build/misc/dumpe2fs /bin/dumpe2fs
COPY --from=builder /build/debugfs/debugfs /bin/debugfs
COPY --from=builder /build/misc/mke2fs.conf /e2fsprogs/mke2fs.conf
# The packages the builder installed, the static libraries linked among them, for the SBOM
# hack/release writes: a copy out of the finished builder, so nothing the compile reads changes.
COPY --from=builder /lib/apk/db/installed /sbom/e2fsprogs.apk
2 changes: 1 addition & 1 deletion e2fsprogs/Taskfile.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ tasks:
build:
desc: "Build e2fsprogs statically: mkfs.ext4, e2fsck, dumpe2fs, debugfs and the mke2fs.conf they are made with."
cmds:
- rm -rf {{.OUTPUT_ABS}}/bin/mkfs.ext4 {{.OUTPUT_ABS}}/bin/e2fsck {{.OUTPUT_ABS}}/bin/dumpe2fs {{.OUTPUT_ABS}}/bin/debugfs {{.OUTPUT_ABS}}/e2fsprogs
- rm -rf {{.OUTPUT_ABS}}/bin/mkfs.ext4 {{.OUTPUT_ABS}}/bin/e2fsck {{.OUTPUT_ABS}}/bin/dumpe2fs {{.OUTPUT_ABS}}/bin/debugfs {{.OUTPUT_ABS}}/e2fsprogs {{.OUTPUT_ABS}}/sbom/e2fsprogs.apk
- mkdir -p {{.OUTPUT_ABS}}
- |
docker buildx build \
Expand Down
4 changes: 3 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,18 @@ tool (
github.com/spin-stack/go-tools/cmd/mutate
github.com/spin-stack/go-tools/cmd/provenance
github.com/spin-stack/go-tools/cmd/refs
github.com/spin-stack/go-tools/cmd/sbom
github.com/spin-stack/go-tools/cmd/testquality
github.com/spin-stack/go-tools/cmd/versions
)

require (
github.com/spin-stack/go-tools v0.0.0-20261013124106-428d61cfb405
github.com/spin-stack/go-tools v0.0.0-20261013132702-621f429d5b97
golang.org/x/sys v0.48.0
)

require (
github.com/CycloneDX/cyclonedx-go v0.12.0 // indirect
go.yaml.in/yaml/v3 v3.0.5 // indirect
golang.org/x/mod v0.41.0 // indirect
golang.org/x/sync v0.23.0 // indirect
Expand Down
20 changes: 18 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
@@ -1,11 +1,27 @@
github.com/CycloneDX/cyclonedx-go v0.12.0 h1:/7Jum36UA6V043tQZ/fE3jf+Nf9gn/qxUFfd7QReMy8=
github.com/CycloneDX/cyclonedx-go v0.12.0/go.mod h1:V2577HhxDDCDLYfkm55WJrz16nHTfyQZwcWUBSG7Z28=
github.com/bradleyjkemp/cupaloy/v2 v2.8.0 h1:any4BmKE+jGIaMpnU8YgH/I2LPiLBufr6oMMlVBbn9M=
github.com/bradleyjkemp/cupaloy/v2 v2.8.0/go.mod h1:bm7JXdkRd4BHJk9HpwqAI8BoAY1lps46Enkdqw6aRX0=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/in-toto/attestation v1.2.0 h1:aPRUZ3azbqD7yEBD5fP3TD8Dszf+YHo284SOcpahjQk=
github.com/in-toto/attestation v1.2.0/go.mod h1:r79G45gOmzPismgObLSL+rZTFxUgZLOQJI6LofTZgXk=
github.com/spin-stack/go-tools v0.0.0-20261013124106-428d61cfb405 h1:NwRkSsLhvJIZx8eiqCnNTuSxOxldtBvNOXxKG0FeNwo=
github.com/spin-stack/go-tools v0.0.0-20261013124106-428d61cfb405/go.mod h1:GgzYh3wr8jXxFMripzZZEkTmS8n8hYZm+u37cEQSbfo=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/spin-stack/go-tools v0.0.0-20261013132702-621f429d5b97 h1:9O9xGhCP1Ij0ydUba0gqmS1pvGIIvW5iytBzq6RYIvA=
github.com/spin-stack/go-tools v0.0.0-20261013132702-621f429d5b97/go.mod h1:cKPqYf5z2CcqsABqoifumRkiEEYSCBlRCwVNP+3Hz5A=
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
github.com/terminalstatic/go-xsd-validate v0.1.8 h1:UVrTCy1j3DhwaYTTUF+QYO/Nan13S0tf+Jwi+p45Bf0=
github.com/terminalstatic/go-xsd-validate v0.1.8/go.mod h1:1kb47fi2c6onlf+B7UrrQ9VYraOhcYwFm3iG+J6F4Zo=
github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f h1:J9EGpcZtP0E/raorCMxlFGSTBrsSlaDGf3jU/qvAE2c=
github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU=
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0=
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ=
github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74=
github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y=
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c=
Expand Down
48 changes: 48 additions & 0 deletions hack/release
Original file line number Diff line number Diff line change
Expand Up @@ -218,9 +218,57 @@ SOURCES
echo "Sources:"
echo " ok: SOURCES"

# What the release is made of, as a scanner reads it: CycloneDX, one component per thing shipped
# and the version it was built from. Read from what the builds themselves recorded - the apk
# databases the static binaries were linked in, the image's dpkg database, versions.yaml's pins -
# never from a list kept here. A component with no version fails the release (sbom check): a
# scanner cannot match it against anything. The time in it is the commit's, so a release built
# twice from one commit carries the same bytes.
echo "SBOM:"
SOURCE_DATE_EPOCH="${SOURCE_DATE_EPOCH:-$(git log -1 --format=%ct 2>/dev/null || echo 0)}"
export SOURCE_DATE_EPOCH
PARTS="${OUTPUT_DIR}/release/sbom"
mkdir -p "${PARTS}" "${SHARE}/sbom"
sbom() { go tool sbom "$@"; }
for db in qemu e2fsprogs; do
[ -f "${OUTPUT_DIR}/sbom/${db}.apk" ] || { echo "👹 missing ${db}'s apk database: ${OUTPUT_DIR}/sbom/${db}.apk" >&2; echo " run: task build" >&2; exit 1; }
done
# The packages each static binary links, by the -static or -dev package that carries the .a,
# with musl's libc and gcc's libgcc: the rest of each database built them and is formulation.
sbom apk -name qemu-libraries -version "${QEMU_VERSION}" -distro alpine \
-only glib-static,pcre2-static,libffi-dev,pixman-static,zlib-static,libseccomp-static,libcap-ng-static,liburing-dev,libaio-dev,gettext-static,musl-dev,gcc \
-out "${PARTS}/qemu-libraries.cdx.json" "${OUTPUT_DIR}/sbom/qemu.apk"
sbom apk -name e2fsprogs-libraries -version "${E2FSPROGS_VERSION}" -distro alpine \
-only libarchive-static,acl-static,zlib-static,xz-static,zstd-static,bzip2-static,lz4-static,expat-static,openssl-libs-static,musl-dev,gcc \
-out "${PARTS}/e2fsprogs-libraries.cdx.json" "${OUTPUT_DIR}/sbom/e2fsprogs.apk"
sbom dpkg -name base-image -version "${VERSION}" -distro ubuntu -out "${PARTS}/base-image.cdx.json" "${SHARE}/packages.txt"
sbom pins -name sources -version "${VERSION}" -out "${PARTS}/sources.cdx.json" qemu qboot e2fsprogs kernel ubuntu task git-lfs buildkit
# The files themselves, by their hashes: the firmware is the QEMU tarball's own build of it.
for f in "${SHARE}"/bin/qemu-system-x86_64 "${SHARE}"/bin/qemu-system-x86_64-tcg "${SHARE}"/bin/qemu-img \
"${SHARE}"/qemu/bios.bin "${SHARE}"/qemu/bios-256k.bin "${SHARE}"/qemu/pvh.bin "${SHARE}"/qemu/kvmvapic.bin "${SHARE}"/qemu/efi-virtio.rom; do
sbom file -name "$(basename "$f")" -version "${QEMU_VERSION}" -out "${PARTS}/$(basename "$f").cdx.json" "$f"
done
for f in mkfs.ext4 debugfs e2fsck dumpe2fs; do
sbom file -name "$f" -version "${E2FSPROGS_VERSION}" -out "${PARTS}/$f.cdx.json" "${SHARE}/bin/$f"
done
sbom file -name qboot.bin -version "${QBOOT_COMMIT}" -out "${PARTS}/qboot.bin.cdx.json" "${SHARE}/qemu/qboot.bin"
sbom file -name vmlinux -version "${KERNEL_VERSION}" -out "${PARTS}/vmlinux.cdx.json" "${SHARE}/kernel/vmlinux"
sbom file -name rootfs.qcow2 -version "${VERSION}" -out "${PARTS}/rootfs.qcow2.cdx.json" "${SHARE}/image/rootfs.qcow2"
# The machine, as the manifest above describes it; inside the tarball, where whatever lays the
# release keeps it.
sbom merge -name spin-machine -version "${VERSION}" -subject "${SHARE}/machine.env" \
-out "${SHARE}/sbom/machine.cdx.json" "${PARTS}"/*.cdx.json
sbom check "${SHARE}/sbom/machine.cdx.json" > /dev/null
echo " ok: usr/share/spin-stack/sbom/machine.cdx.json"

TARBALL="${OUTPUT_DIR}/release/${RELEASE_NAME}.tar.gz"
tar -czf "${TARBALL}" -C "${OUTPUT_DIR}/release" "${RELEASE_NAME}"
sha256sum "${TARBALL}" | sed "s|${OUTPUT_DIR}/release/||" > "${TARBALL}.sha256"
# And beside it, of the tarball by its hash: what the release's attestation is made of.
sbom merge -name "${RELEASE_NAME}.tar.gz" -version "${VERSION}" -subject "${TARBALL}" \
-out "${TARBALL%.tar.gz}.cdx.json" "${SHARE}/sbom/machine.cdx.json"
sbom check "${TARBALL%.tar.gz}.cdx.json" > /dev/null
rm -rf "${PARTS}"

# The manifest again, beside the tarball rather than inside it, so that it can be published
# as an asset of its own and read back without the 1 GB around it.
Expand Down
4 changes: 4 additions & 0 deletions qemu/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -517,3 +517,7 @@ COPY --from=builder /opt/qemu-tcg/bin/qemu-system-x86_64 /bin/qemu-system-x86_64
COPY --from=builder /opt/qemu/bin/qemu-img /bin/qemu-img
COPY --from=builder /build/pc-bios/ /qemu/
COPY --from=qboot /build/qboot.bin /qemu/qboot.bin
# The packages the builder installed, as apk recorded them: the static libraries the binaries
# link are among them, by the version that was linked, and hack/release's SBOM reads them here.
# A copy out of the finished builder, so nothing the compile depends on changes.
COPY --from=builder /lib/apk/db/installed /sbom/qemu.apk
2 changes: 1 addition & 1 deletion qemu/Taskfile.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ tasks:
# a firmware blob this build no longer produces stays on disk, indistinguishable from
# one it does. That is how a 331 MB firmware tree outlived the build that stopped
# shipping it, and how a qemu-nbd would outlive the decision to stop shipping one.
- rm -rf {{.OUTPUT_ABS}}/bin/qemu-* {{.OUTPUT_ABS}}/qemu
- rm -rf {{.OUTPUT_ABS}}/bin/qemu-* {{.OUTPUT_ABS}}/qemu {{.OUTPUT_ABS}}/sbom/qemu.apk
- mkdir -p {{.OUTPUT_ABS}}
- |
docker buildx build \
Expand Down
2 changes: 1 addition & 1 deletion workflows/workflows_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import (
// Every step that publishes runs after an unconditional provenance attestation in its job; the
// ones that look like an upload and ship nothing anyone installs are named here, each with why.
func TestEveryUploadIsAttested(t *testing.T) {
g := attestgate.Gate{Root: "..", NotPublished: map[string]string{
g := attestgate.Gate{Root: "..", RequireSBOM: true, NotPublished: map[string]string{
"ci.yml/gate/Upload Go coverage HTML": "a coverage page for a person, kept seven days",
"kernel.yml/build/Upload the kernel": "a lane's kernel for a person to fetch; a release builds its own",
"lab.yml/lab/Keep the log": "a measurement's log, for a person",
Expand Down