-
-
Notifications
You must be signed in to change notification settings - Fork 2
165 lines (158 loc) · 7.75 KB
/
Copy pathattest-release.yml
File metadata and controls
165 lines (158 loc) · 7.75 KB
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
# The second half of a release, after a person has signed it.
#
# The build workflow cannot sign. The key lives on a cryptographic card in a USB
# reader and cannot be exported, so .github/scripts/sign_release.py signs on the
# maintainer's machine and then dispatches this. Here the signed files get the
# statement that travels with them, and the release is still a draft when this
# finishes.
#
# 🔴 It DOWNLOADS what the release holds rather than trusting the digest it was
# handed. Everything attested here is then a statement about bytes this job is
# holding, which is the whole difference between an attestation and a rumour.
# The digest input is kept as a cross-check: if it disagrees with the file on the
# release, something moved between signing and publishing and the run stops.
#
# What it does NOT do: build provenance. That belongs to the workflow that
# actually built something, and it is made there, over the unsigned build.
# Claiming here that this workflow produced files a person signed on their own
# machine would be false in the one document nobody should have to doubt.
name: Attest a signed release
on:
workflow_dispatch:
inputs:
tag:
description: "The release tag, for example v0.2.0"
required: true
digest:
description: "sha256 of verify-SHA256SUMS.txt, as sign_release.py printed it"
required: true
permissions:
contents: read
jobs:
attest:
name: attest the signed files
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
# Reading the draft's assets and uploading the bundle back to it.
contents: write
# id-token mints the short lived OIDC token that signs the attestation,
# attestations writes the result to this repository's store.
id-token: write
attestations: write
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: fetch what the maintainer signed
shell: bash
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ inputs.tag }}
CLAIMED: ${{ inputs.digest }}
run: |
set -euo pipefail
mkdir -p signed
cd signed
# The checksums first, because they are the list of what to fetch.
#
# 🔴 This used to be a hand written list of patterns beside them, and
# the first real release is what said why that cannot work: the
# patterns fetched the archives, the SBOM and the checksums, and the
# checksums also describe the provenance bundle - which nothing in
# that list matched. sha256sum then reported "No such file or
# directory" for a file that was on the release page all along, and
# the run stopped after the signing was already done.
#
# Two lists of what a release holds is one list too many. The names
# come out of the checksums now, so the thing being verified is also
# the thing that says what to verify, and adding an asset cannot make
# them disagree again.
gh release download "$TAG" --pattern 'verify-SHA256SUMS.txt'
actual="$(sha256sum verify-SHA256SUMS.txt | cut -d' ' -f1)"
echo "the release carries: $actual"
if [ "$actual" != "$CLAIMED" ]; then
echo "::error::the checksums file on the release hashes to $actual, but this run"
echo "::error::was dispatched for $CLAIMED - something changed in between"
exit 1
fi
# Collected before anything is fetched, because gh reads stdin and a
# download inside the read loop eats the rest of the list.
names=()
while read -r _ name; do
[ -n "${name:-}" ] && names+=("$name")
done < verify-SHA256SUMS.txt
if [ "${#names[@]}" -eq 0 ]; then
echo "::error::verify-SHA256SUMS.txt names no files, so there is nothing to attest"
exit 1
fi
echo "the checksums describe ${#names[@]} file(s)"
for name in "${names[@]}"; do
gh release download "$TAG" --pattern "$name"
done
# And the checksums have to describe the files that came with them,
# because everything below is a statement about that list.
sha256sum -c verify-SHA256SUMS.txt
sbom="$(ls -- *.spdx.json)"
echo "SBOM=signed/${sbom}" >> "$GITHUB_ENV"
echo "SUMS=signed/verify-SHA256SUMS.txt" >> "$GITHUB_ENV"
# The bill of materials, bound to the files a person actually downloads.
# Before the signature existed this binding was made at build time. It is
# made here now, because the signature changes the bytes and a statement
# about the wrong bytes verifies against nothing.
- name: attest what is inside the signed files
id: attestation
uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2
with:
subject-checksums: ${{ env.SUMS }}
sbom-path: ${{ env.SBOM }}
- name: the release notes promise a command, so check the command works
shell: bash
env:
BUNDLE: ${{ steps.attestation.outputs.bundle-path }}
# 🔴 The notes tell people to pass --predicate-type, because gh asks for
# build provenance unless told otherwise and this is not that. The URI in
# those notes is written by hand, so it is checked here against the
# statement that was actually made - the only moment where the real value
# exists. A wrong URI would make a correct release look broken, and the
# message a person gets is "no attestation found", which reads like a
# missing file rather than a wrong flag.
run: |
set -euo pipefail
promised="https://spdx.dev/Document/v2.3"
actual="$(python3 - "$BUNDLE" <<'PY'
import base64, json, sys
bundle = json.load(open(sys.argv[1], encoding="utf-8"))
payload = bundle["dsseEnvelope"]["payload"]
statement = json.loads(base64.b64decode(payload))
print(statement["predicateType"])
PY
)"
echo "the statement carries: $actual"
if [ "$actual" != "$promised" ]; then
echo "::error::the release notes tell people to pass --predicate-type $promised"
echo "::error::and the statement that was just made is $actual."
echo "::error::Fix the notes in release.yml, because as written the command answers"
echo "::error::\"no attestation found\" and that reads like a broken release."
exit 1
fi
- name: publish the statement beside the files
shell: bash
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ inputs.tag }}
BUNDLE: ${{ steps.attestation.outputs.bundle-path }}
# 🔴 As an ASSET, not only in the attestation store. Scorecard's signed
# releases check reads assets by file extension and never opens that
# store, and a person whose network has no route to the API cannot use it
# either. "gh attestation verify <file> --bundle <this file>" answers
# offline, from a mirror, from anywhere.
run: |
set -euo pipefail
# verify- so it lands at the end of the download list with the other
# three files a person checks a download against, rather than in the
# middle of the archives. GitHub sorts that list by file name and by
# nothing else - measured 2026-08-28.
name="verify-tfg_${TAG#v}.sbom.sigstore.json"
cp "$BUNDLE" "$name"
python3 -c "import json,sys; json.load(open(sys.argv[1])); print('the bundle parses as JSON')" "$name"
gh release upload "$TAG" "$name" --clobber
echo "attached $name to $TAG, which is still a draft"