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
35 changes: 35 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ on:

permissions:
contents: write
# Keyless signing: cosign asks Sigstore for a short-lived certificate bound to this workflow at
# this ref, which is what a machine that takes the release holds its files to.
id-token: write

# One release at a time, across every ref, and not per-ref. Per-ref serialises two runs on
# the same tag and nothing else: a workflow_dispatch on main and a tag push are different
Expand Down Expand Up @@ -190,6 +193,37 @@ jobs:
echo RELEASE_NOTES_EOF
} >> "$GITHUB_OUTPUT"

# v4 is the one that installs cosign v3. Dependabot moves this pin like every other `uses:`.
- name: Install cosign
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2

# Who built each file and from what - this workflow at this commit, every pin in
# versions.yaml - as SLSA provenance, beside the signature that says who made it. The
# predicate has no clock in it, so it is the same for every file of the run.
- name: Write the provenance
env:
VERSION: ${{ steps.version.outputs.version }}
run: go tool provenance -version "${VERSION}" -out _output/provenance.json

# Each file is signed and attested, then each bundle checked against the identity that made
# it, file by file: a bundle nothing verifies is a step that can quietly stop working.
- name: Sign and attest what the release offers
env:
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
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
cosign verify-blob --bundle "${f}.sigstore.json" --certificate-identity "${IDENTITY}" \
--certificate-oidc-issuer https://token.actions.githubusercontent.com "${f}"
cosign verify-blob-attestation --type slsaprovenance1 --bundle "${f}.provenance.sigstore.json" \
--certificate-identity "${IDENTITY}" \
--certificate-oidc-issuer https://token.actions.githubusercontent.com "${f}"
done
go tool attestgate files -require provenance _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.
- name: Tag the commit
Expand All @@ -212,6 +246,7 @@ jobs:
_output/release/*.tar.gz
_output/release/*.tar.gz.sha256
_output/release/machine.env
_output/release/*.sigstore.json

# What every combination of the machine's features costs, booted from the tarball just
# published, as report.json beside it, and what moved against the release before it at the end
Expand Down
2 changes: 1 addition & 1 deletion Taskfile.yml
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,7 @@ tasks:
cmds:
- |
set -euo pipefail
unformatted=$(gofmt -l machine cmd boot versions)
unformatted=$(gofmt -l machine cmd boot versions workflows)
if [ -n "$unformatted" ]; then
echo "not gofmt'd:"; echo "$unformatted"; exit 1
fi
Expand Down
17 changes: 17 additions & 0 deletions docs/releasing.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,23 @@ the machine by the part they changed, the pins that moved, the kernel options tu
or changed, and the patches added, changed or removed), whether checkpoints carry over
(`hack/fingerprint-diff`), and the checksums.

Each file the release offers - the tarball, its `.sha256` and `machine.env` - carries two
Sigstore bundles beside it: `<file>.sigstore.json`, the signature of the workflow that built it,
and `<file>.provenance.sigstore.json`, its SLSA provenance (`go tool provenance`: this workflow at
its commit, and every pin in `versions.yaml`). Whoever takes one checks both against
`release.yml` at the ref it ran on:

```sh
id=https://github.com/spin-stack/spin-machine/.github/workflows/release.yml@refs/tags/<version>
cosign verify-blob --bundle f.sigstore.json --certificate-identity "$id" \
--certificate-oidc-issuer https://token.actions.githubusercontent.com f
cosign verify-blob-attestation --type slsaprovenance1 --bundle f.provenance.sigstore.json \
--certificate-identity "$id" --certificate-oidc-issuer https://token.actions.githubusercontent.com f
```

A run started by hand ran on `refs/heads/main`, and that is the ref its certificate names.
`workflows/workflows_test.go` holds every workflow to attesting before it publishes.

## The feature matrix

A release also says what it costs. After it publishes, the `report` job boots the tarball it
Expand Down
4 changes: 3 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,17 @@ module github.com/spin-stack/spin-machine
go 1.27

tool (
github.com/spin-stack/go-tools/cmd/attestgate
github.com/spin-stack/go-tools/cmd/ctxlife
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/testquality
github.com/spin-stack/go-tools/cmd/versions
)

require (
github.com/spin-stack/go-tools v0.0.0-20260930221820-8ea51671c2d8
github.com/spin-stack/go-tools v0.0.0-20261013124106-428d61cfb405
golang.org/x/sys v0.48.0
)

Expand Down
8 changes: 6 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
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/spin-stack/go-tools v0.0.0-20260930221820-8ea51671c2d8 h1:eTsFFY8DXs90nMg/tdZH+8C0SkCOixAQQdnUrNxU0A4=
github.com/spin-stack/go-tools v0.0.0-20260930221820-8ea51671c2d8/go.mod h1:pQNBiVAi7uoXVlw5UniOQkZ/KYT9dRBeQz2KCW4XtAg=
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/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
Expand All @@ -14,3 +16,5 @@ golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/tools v0.50.0 h1:c2ifzfcuY7L90lZ2aKd8S4K2NpASF08SZx9ZuJkHmSU=
golang.org/x/tools v0.50.0/go.mod h1:7ulVMw3831Mwi5EZD6RomGyffr4VFjuNYXf2BbCEAV0=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
28 changes: 28 additions & 0 deletions workflows/workflows_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
// SPDX-License-Identifier: Apache-2.0

// Package workflows holds this repository's workflows to go-tools' attestgate: what a release
// publishes is attested with its provenance first, in the same job.
package workflows_test

import (
"testing"

"github.com/spin-stack/go-tools/attestgate"
)

// 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{
"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",
"qemu.yml/build/Upload the extracted binaries": "a lane's QEMU for a person to fetch; a release builds its own",
"qemu.yml/build/Publish the runtime image": "what ci.yml's argument check pulls (task qemu:fetch); a release compiles QEMU, it never takes this",
"report.yml/report/Keep the report": "the run's copy of report.json, for a person",
"report.yml/report/Publish the report, and what moved": "report.json is measurement data: nothing installs or verifies it",
}}
if err := g.Check(); err != nil {
t.Error(err)
}
}