1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
name: release-image
# Build the FeatherReader container image on a version tag, run the upgrade-boot
# gate on it, and only then push that exact image and sign SLSA build provenance
# (and, optionally, a CycloneDX SBOM) for its DIGEST. The last job dispatches
# release-crate.yml against the tag, so a failed gate publishes nothing anywhere.
# This does NOT deploy to Fly — that stays with the deploy runbook.
#
# Trigger: pushing a semver tag, e.g. git tag v0.4.4 && git push origin v0.4.4
#
# Deploy contract: the attestations are bound to the image DIGEST. The runbook
# resolves the tag to that digest, runs `gh attestation verify` (fail-closed),
# then `fly deploy -i ghcr.io/...@<digest>`. Deploying a mutable tag defeats the
# guarantee. `:latest` is NOT only the newest release: metadata-action has no
# memory of earlier tags, and latest=auto emits :latest for ANY non-prerelease
# semver tag, so a hotfix on an older line pushed after a newer release moves
# :latest backward. Deploy by digest regardless.
on:
push:
tags:
- "v*.*.*"
workflow_dispatch:
inputs:
enable_sbom:
description: "PART C: build with cargo-auditable + emit & attest a CycloneDX SBOM"
type: boolean
default: false
# Top-level default is intentionally narrowed to least privilege — checkout only.
# Any FUTURE job that forgets its own `permissions:` block inherits only this, so
# it can never accidentally push or sign. The publish + attestation scopes
# (packages:write / id-token:write / attestations:write) are granted explicitly at
# JOB level below, exactly where they are needed and nowhere else.
permissions:
contents: read
env:
IMAGE: ghcr.io/justin-stanley/feather-reader
# PART C master toggle. Flip to '1' (or dispatch with enable_sbom=true) to build
# the Rust binary with cargo-auditable AND generate + attest a CycloneDX SBOM.
# Default '0' keeps the image a plain --release build with provenance only.
ENABLE_SBOM: "0"
jobs:
# Always-run visibility guard. The build job is gated to public repos, and a
# skipped job leaves the run looking green — so on a private repo a pushed tag
# would silently build/push NOTHING. This job runs unconditionally and emits a
# loud warning annotation so the no-op is obvious in the run summary.
gate:
name: release gate
runs-on: ubuntu-latest
steps:
- name: Report release gate
run: |
if [ "${{ github.event.repository.private }}" = "true" ]; then
echo "::warning title=release-image skipped::Repo is PRIVATE — no image was built or pushed. Flip the repo public to enable tag releases."
else
echo "Repo is public — the build/push/attest job runs."
fi
build-push-attest:
name: build, push, attest
runs-on: ubuntu-latest
if: ${{ github.event.repository.private == false }}
# Least privilege — exactly the scopes the publish + attestation need:
# contents:read checkout only (no tag/release writes)
# packages:write push to GHCR
# id-token:write OIDC token for keyless (Sigstore) attestation signing
# attestations:write record the provenance/SBOM attestations on the image
permissions:
contents: read
packages: write
id-token: write
attestations: write
steps:
# All actions are SHA-pinned (comment = the human-readable tag). Re-resolve
# with `gh api repos/<owner>/<repo>/commits/<tag> --jq .sha` when bumping.
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- name: Resolve SBOM toggle
id: toggle
env:
DISPATCH_SBOM: ${{ github.event.inputs.enable_sbom }}
run: |
if [ "${DISPATCH_SBOM}" = "true" ] || [ "${ENABLE_SBOM}" = "1" ]; then
echo "enabled=1" >> "$GITHUB_OUTPUT"
else
echo "enabled=0" >> "$GITHUB_OUTPUT"
fi
- name: Set up Buildx
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
- name: Log in to GHCR
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Derive image tags + labels
id: meta
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
with:
images: ${{ env.IMAGE }}
# latest=auto emits :latest for EVERY non-prerelease semver tag:
# metadata-action has no memory of earlier tags, so a hotfix on an older
# line pushed after a newer release moves :latest backward. (Deploys
# resolve a DIGEST — :latest is a convenience, never a deploy target.)
flavor: |
latest=auto
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=sha
# --- Upgrade-boot gate: nothing is pushed until this passes -------------
# Builds the image locally and runs it against a database the last good
# release created (scripts/upgrade-boot.sh). 0.3.9 was pushed, published
# and deployed with a schema bug that failed on every existing database;
# this is the check that would have stopped it.
#
# **Built ONCE.** The step after the gate pushes this exact image, rather
# than building again: a second build only matches the tested one if every
# layer comes from the cache, and a cache eviction or a failed cache export
# would publish — and attest — layers nothing tested.
- name: Build the candidate for the upgrade-boot gate
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
with:
context: .
load: true
push: false
tags: feather-reader:candidate
labels: ${{ steps.meta.outputs.labels }}
# PART C: pass the cargo-auditable build-arg through to the Dockerfile.
build-args: |
ENABLE_SBOM=${{ steps.toggle.outputs.enabled }}
provenance: false
sbom: false
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Upgrade-boot gate
run: |
prev="ghcr.io/justin-stanley/feather-reader:$(tr -d '[:space:]' < deploy/upgrade-from)"
./scripts/upgrade-boot.sh "$prev" feather-reader:candidate
- name: Push the image the gate tested
id: build
env:
TAGS: ${{ steps.meta.outputs.tags }}
run: |
set -euo pipefail
while IFS= read -r tag; do
[ -n "$tag" ] || continue
docker tag feather-reader:candidate "$tag"
docker push "$tag"
done <<< "$TAGS"
# Every tag is the same local image, so the registry digest is shared;
# read it back rather than trusting push output formatting.
digest="$(docker inspect --format '{{range .RepoDigests}}{{println .}}{{end}}' feather-reader:candidate \
| grep "^${IMAGE}@" | head -n1 | cut -d@ -f2)"
case "$digest" in
sha256:*) echo "digest=$digest" >> "$GITHUB_OUTPUT" ;;
*) echo "::error::could not read the pushed digest back"; exit 1 ;;
esac
# --- SLSA build provenance for the pushed image DIGEST -----------------
# Signs a provenance attestation bound to the exact digest, stored in the
# GHCR registry alongside the image. Keyless (Sigstore) via the OIDC token.
- name: Attest build provenance
uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2
with:
subject-name: ${{ env.IMAGE }}
subject-digest: ${{ steps.build.outputs.digest }}
push-to-registry: true
# ======================================================================
# PART C (TOGGLABLE) — CycloneDX SBOM + SBOM attestation.
# Runs only when the SBOM toggle is on. The image was already built with
# `cargo auditable` (ENABLE_SBOM build-arg) so the Rust dep graph is
# embedded in the binary (readable later with `cargo audit bin`).
# ======================================================================
- name: Generate CycloneDX SBOM (PART C)
if: steps.toggle.outputs.enabled == '1'
id: sbom
uses: anchore/sbom-action@66cbf4bc1f1c0d2edc94016e65bc221b6bb0ad6c # v0.24.3
with:
image: ${{ env.IMAGE }}@${{ steps.build.outputs.digest }}
format: cyclonedx-json
output-file: sbom.cdx.json
artifact-name: feather-reader-sbom.cdx.json
- name: Attest SBOM against the image digest (PART C)
if: steps.toggle.outputs.enabled == '1'
uses: actions/attest-sbom@c604332985a26aa8cf1bdc465b92731239ec6b9e # v4.1.0
with:
subject-name: ${{ env.IMAGE }}
subject-digest: ${{ steps.build.outputs.digest }}
sbom-path: sbom.cdx.json
push-to-registry: true
# --- Verification note (no deploy) ------------------------------------
# Deployment is intentionally NOT automated here — it stays in the deploy
# runbook and MUST deploy the DIGEST after verifying its attestation:
#
# DIGEST="${{ steps.build.outputs.digest }}"
# gh attestation verify \
# oci://ghcr.io/justin-stanley/feather-reader@${DIGEST} \
# --repo justin-stanley/feather-reader # fail-closed gate
# fly deploy -i ghcr.io/justin-stanley/feather-reader@${DIGEST}
#
# (Add `--predicate-type https://cyclonedx.org/bom` to check the SBOM
# attestation specifically when PART C is enabled.)
- name: Verification note
run: |
echo "Image (deploy THIS, by digest): ${IMAGE}@${{ steps.build.outputs.digest }}"
echo "Verify: gh attestation verify oci://${IMAGE}@${{ steps.build.outputs.digest }} --repo ${{ github.repository }}"
echo "Then: fly deploy -i ${IMAGE}@${{ steps.build.outputs.digest }}"
# Publish the crate only once this image passed the gate and was pushed. A
# dispatch, not a `workflow_run` trigger in release-crate.yml: crates.io
# Trusted Publishing refuses `workflow_run`. GITHUB_TOKEN may dispatch a
# workflow (the one event it is allowed to start), with `actions: write`.
dispatch-crate:
name: dispatch the crate release
needs: build-push-attest
if: ${{ github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') }}
runs-on: ubuntu-latest
permissions:
actions: write
steps:
- name: Dispatch release-crate.yml against this tag
env:
GH_TOKEN: ${{ github.token }}
run: gh workflow run release-crate.yml --repo "$GITHUB_REPOSITORY" --ref "$GITHUB_REF_NAME"