Skip to content

Commit 48651a2

Browse files
feat(ci): add release sbom and provenance
attestations
1 parent 1f8e47f commit 48651a2

7 files changed

Lines changed: 300 additions & 15 deletions

File tree

.github/workflows/release.yml

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,13 @@ jobs:
3131
run: uv run --python 3.14 mkdocs build --strict
3232
- name: Build package
3333
run: uv build
34+
- name: Download Syft
35+
id: syft
36+
uses: anchore/sbom-action/download-syft@v0
37+
- name: Generate release integrity artifacts
38+
env:
39+
SYFT: ${{ steps.syft.outputs.cmd }}
40+
run: bash scripts/generate-release-integrity-artifacts.sh dist .release-integrity.env
3441
- name: Coveralls
3542
if: github.event_name == 'push'
3643
env:
@@ -122,6 +129,8 @@ jobs:
122129
runs-on: ubuntu-latest
123130
permissions:
124131
contents: write
132+
attestations: write
133+
id-token: write
125134
steps:
126135
- uses: actions/checkout@v4
127136
with:
@@ -135,6 +144,51 @@ jobs:
135144
run: uv sync --python 3.14
136145
- name: Build package
137146
run: uv build
147+
- name: Download Syft
148+
id: syft
149+
uses: anchore/sbom-action/download-syft@v0
150+
- name: Generate release integrity artifacts
151+
env:
152+
SYFT: ${{ steps.syft.outputs.cmd }}
153+
run: bash scripts/generate-release-integrity-artifacts.sh dist .release-integrity.env
154+
- name: Load release integrity metadata
155+
id: integrity
156+
run: |
157+
source .release-integrity.env
158+
{
159+
printf 'wheel=%s\n' "$wheel"
160+
printf 'wheel_name=%s\n' "$wheel_name"
161+
printf 'wheel_sbom=%s\n' "$wheel_sbom"
162+
printf 'wheel_sbom_name=%s\n' "$wheel_sbom_name"
163+
printf 'sdist=%s\n' "$sdist"
164+
printf 'sdist_name=%s\n' "$sdist_name"
165+
printf 'sdist_sbom=%s\n' "$sdist_sbom"
166+
printf 'sdist_sbom_name=%s\n' "$sdist_sbom_name"
167+
printf 'checksums=%s\n' "$checksums"
168+
printf 'checksums_name=%s\n' "$checksums_name"
169+
} >> "$GITHUB_OUTPUT"
170+
- name: Attest build provenance
171+
id: attest-provenance
172+
uses: actions/attest-build-provenance@v3
173+
with:
174+
subject-path: |
175+
${{ steps.integrity.outputs.wheel }}
176+
${{ steps.integrity.outputs.sdist }}
177+
show-summary: false
178+
- name: Attest wheel SBOM
179+
id: attest-wheel-sbom
180+
uses: actions/attest-sbom@v3
181+
with:
182+
subject-path: ${{ steps.integrity.outputs.wheel }}
183+
sbom-path: ${{ steps.integrity.outputs.wheel_sbom }}
184+
show-summary: false
185+
- name: Attest source distribution SBOM
186+
id: attest-sdist-sbom
187+
uses: actions/attest-sbom@v3
188+
with:
189+
subject-path: ${{ steps.integrity.outputs.sdist }}
190+
sbom-path: ${{ steps.integrity.outputs.sdist_sbom }}
191+
show-summary: false
138192
- name: Upload workflow artifact
139193
uses: actions/upload-artifact@v4
140194
with:
@@ -144,6 +198,77 @@ jobs:
144198
env:
145199
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
146200
run: gh release upload "${{ needs.release.outputs.tag }}" dist/* --clobber
201+
- name: Publish verification summary
202+
env:
203+
RELEASE_TAG: ${{ needs.release.outputs.tag }}
204+
RELEASE_COMMIT_SHA: ${{ needs.release.outputs.commit_sha }}
205+
PROVENANCE_URL: ${{ steps.attest-provenance.outputs.attestation-url }}
206+
WHEEL_SBOM_URL: ${{ steps.attest-wheel-sbom.outputs.attestation-url }}
207+
SDIST_SBOM_URL: ${{ steps.attest-sdist-sbom.outputs.attestation-url }}
208+
run: |
209+
source .release-integrity.env
210+
wheel_sha="$(awk -v file="$wheel_name" '$2 == file { print $1 }' "$checksums")"
211+
sdist_sha="$(awk -v file="$sdist_name" '$2 == file { print $1 }' "$checksums")"
212+
213+
cat > dist/release-verification.md <<EOF
214+
<!-- release-verification:start -->
215+
## Verification
216+
217+
Release commit: \`$RELEASE_COMMIT_SHA\`
218+
219+
| Artifact | SHA256 | SPDX SBOM | Provenance | SBOM attestation |
220+
| --- | --- | --- | --- | --- |
221+
| \`$wheel_name\` | \`$wheel_sha\` | \`$wheel_sbom_name\` | [build provenance]($PROVENANCE_URL) | [SBOM attestation]($WHEEL_SBOM_URL) |
222+
| \`$sdist_name\` | \`$sdist_sha\` | \`$sdist_sbom_name\` | [build provenance]($PROVENANCE_URL) | [SBOM attestation]($SDIST_SBOM_URL) |
223+
224+
Release assets also include \`$checksums_name\`, \`$wheel_sbom_name\`, and \`$sdist_sbom_name\`.
225+
226+
\`\`\`bash
227+
gh release verify "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY"
228+
gh release verify-asset "$RELEASE_TAG" "./$wheel_name" --repo "$GITHUB_REPOSITORY"
229+
gh release verify-asset "$RELEASE_TAG" "./$sdist_name" --repo "$GITHUB_REPOSITORY"
230+
gh attestation verify "./$wheel_name" --repo "$GITHUB_REPOSITORY" --signer-workflow .github/workflows/release.yml
231+
gh attestation verify "./$wheel_name" --repo "$GITHUB_REPOSITORY" --signer-workflow .github/workflows/release.yml --predicate-type https://spdx.dev/Document/v2.3
232+
\`\`\`
233+
234+
Install from Git with an immutable ref, not \`master\`. Example:
235+
236+
\`\`\`bash
237+
pip install "fastapi-paseto @ git+https://github.com/Raze-Systems/fastapi-paseto.git@$RELEASE_TAG"
238+
\`\`\`
239+
240+
\`pip\` and \`uv\` record VCS origin metadata in \`direct_url.json\`, but mutable branch installs cannot be strongly verified after the fact. Prefer a signed tag or commit hash and verify the matching release artifacts.
241+
<!-- release-verification:end -->
242+
EOF
243+
244+
cat dist/release-verification.md >> "$GITHUB_STEP_SUMMARY"
245+
- name: Update release notes with verification details
246+
env:
247+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
248+
RELEASE_TAG: ${{ needs.release.outputs.tag }}
249+
run: |
250+
gh release view "$RELEASE_TAG" --json body --jq .body > dist/release-body.md
251+
python - <<'PY'
252+
from pathlib import Path
253+
254+
start_marker = "<!-- release-verification:start -->"
255+
end_marker = "<!-- release-verification:end -->"
256+
body_path = Path("dist/release-body.md")
257+
section = Path("dist/release-verification.md").read_text(encoding="utf-8").strip()
258+
body = body_path.read_text(encoding="utf-8").strip()
259+
260+
if start_marker in body and end_marker in body:
261+
prefix, remainder = body.split(start_marker, 1)
262+
_, suffix = remainder.split(end_marker, 1)
263+
body = prefix.rstrip()
264+
suffix = suffix.lstrip()
265+
merged = "\n\n".join(part for part in [body, section, suffix] if part)
266+
else:
267+
merged = "\n\n".join(part for part in [body, section] if part)
268+
269+
body_path.write_text(f"{merged}\n", encoding="utf-8")
270+
PY
271+
gh release edit "$RELEASE_TAG" --notes-file dist/release-body.md
147272
148273
build-release-docs:
149274
if: needs.release.outputs.released == 'true'

README.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,5 +69,32 @@ latest docs to GitHub Pages.
6969
The first automated release should start from a bootstrap tag that matches the
7070
current project version.
7171

72+
## Supply Chain Security
73+
Each automated release publishes the wheel, source distribution, `SHA256SUMS`,
74+
and SPDX JSON SBOM files as GitHub Release assets and workflow artifacts. The
75+
release workflow also creates keyless GitHub artifact attestations for both
76+
build provenance and SPDX SBOMs.
77+
78+
To verify a release locally with the GitHub CLI:
79+
80+
```bash
81+
gh release verify vX.Y.Z --repo Raze-Systems/fastapi-paseto
82+
gh release verify-asset vX.Y.Z ./fastapi_paseto-X.Y.Z-py3-none-any.whl --repo Raze-Systems/fastapi-paseto
83+
gh attestation verify ./fastapi_paseto-X.Y.Z-py3-none-any.whl --repo Raze-Systems/fastapi-paseto --signer-workflow .github/workflows/release.yml
84+
gh attestation verify ./fastapi_paseto-X.Y.Z-py3-none-any.whl --repo Raze-Systems/fastapi-paseto --signer-workflow .github/workflows/release.yml --predicate-type https://spdx.dev/Document/v2.3
85+
```
86+
87+
If you install directly from Git, pin an immutable tag or commit hash instead
88+
of `master`:
89+
90+
```bash
91+
pip install "fastapi-paseto @ git+https://github.com/Raze-Systems/fastapi-paseto.git@vX.Y.Z"
92+
```
93+
94+
`pip` and `uv` record VCS origin metadata in `direct_url.json`, which helps
95+
with auditing later, but mutable branch installs cannot be strongly verified
96+
after the fact. Use a signed commit or tag plus the matching release assets if
97+
you need a verifiable supply-chain trail.
98+
7299
## License
73100
This project is licensed under the terms of the MIT license.

docs/contributing.md

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -105,17 +105,19 @@ The release workflow only publishes when validation succeeds:
105105
- tests must pass
106106
- `uv build` must pass
107107
- `uv run --python 3.14 mkdocs build --strict` must pass
108+
- release SBOM generation must pass
108109

109110
The workflow updates `pyproject.toml`, stamps the package fallback version in
110111
`src/fastapi_paseto/_version.py`, regenerates `CHANGELOG.md`, creates the Git
111-
tag and GitHub release, uploads the distributions with Twine, and deploys the
112-
latest documentation to GitHub Pages.
112+
tag and GitHub release, uploads the distributions with `SHA256SUMS` and SPDX
113+
SBOM files, creates GitHub artifact attestations for build provenance and SBOM
114+
verification, and deploys the latest documentation to GitHub Pages.
113115

114-
This repository currently has release tags through `v0.5.4`, while the project
115-
metadata is already at `0.6.0`. Before relying on the automated release job,
116-
create and push a bootstrap tag that matches the current version:
116+
If release automation is being enabled for the first time on a repository with
117+
existing versions, create and push a bootstrap tag that matches the current
118+
version before relying on automated semantic version bumps:
117119

118120
```bash
119-
git tag v0.6.0
120-
git push origin v0.6.0
121+
git tag vX.Y.Z
122+
git push origin vX.Y.Z
121123
```

docs/supply-chain-security.md

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
# Supply Chain Security
2+
3+
FastAPI PASETO release artifacts are built in GitHub Actions and published as
4+
GitHub Release assets. Each release includes:
5+
6+
- the wheel
7+
- the source distribution
8+
- `SHA256SUMS`
9+
- one SPDX JSON SBOM for the wheel
10+
- one SPDX JSON SBOM for the source distribution
11+
- GitHub artifact attestations for build provenance
12+
- GitHub artifact attestations for the SPDX SBOM predicates
13+
14+
The release job writes the same verification details into the workflow summary
15+
and the GitHub Release description so consumers can verify what they downloaded
16+
without reading the workflow source first.
17+
18+
## Verifying a release
19+
20+
Install the <a href="https://cli.github.com/" target="_blank">GitHub CLI</a>,
21+
download the release asset you want to inspect, and run:
22+
23+
```bash
24+
gh release verify vX.Y.Z --repo Raze-Systems/fastapi-paseto
25+
gh release verify-asset vX.Y.Z ./fastapi_paseto-X.Y.Z-py3-none-any.whl --repo Raze-Systems/fastapi-paseto
26+
gh attestation verify ./fastapi_paseto-X.Y.Z-py3-none-any.whl --repo Raze-Systems/fastapi-paseto --signer-workflow .github/workflows/release.yml
27+
gh attestation verify ./fastapi_paseto-X.Y.Z-py3-none-any.whl --repo Raze-Systems/fastapi-paseto --signer-workflow .github/workflows/release.yml --predicate-type https://spdx.dev/Document/v2.3
28+
```
29+
30+
Swap the wheel path for the source distribution if you prefer to verify the
31+
sdist instead.
32+
33+
## Installing from the repository
34+
35+
Most supply-chain guarantees depend on immutable inputs. For Git installs, that
36+
means you should install from a tag or a commit hash:
37+
38+
```bash
39+
pip install "fastapi-paseto @ git+https://github.com/Raze-Systems/fastapi-paseto.git@vX.Y.Z"
40+
pip install "fastapi-paseto @ git+https://github.com/Raze-Systems/fastapi-paseto.git@<commit-sha>"
41+
```
42+
43+
Avoid installs from `master`. A mutable branch reference can change after you
44+
install it, which makes later verification weak even if the branch tip was
45+
signed at the time.
46+
47+
Python installers record VCS origin metadata in `direct_url.json` as described
48+
by PEP 610. That metadata helps downstream auditing tools understand where the
49+
package came from, but it does not replace artifact verification. The strongest
50+
path is:
51+
52+
1. Pin a tag or commit hash.
53+
2. Verify that ref in GitHub.
54+
3. Verify the matching release asset, provenance attestation, and SPDX SBOM.
55+
56+
## Trust model
57+
58+
This repository uses GitHub's keyless OIDC signing flow for attestations. The
59+
trust decision is therefore based on:
60+
61+
- the repository identity
62+
- the release workflow identity
63+
- the artifact digest you downloaded
64+
65+
No long-lived signing key is required for release assets, which reduces manual
66+
key management overhead for maintainers.

mkdocs.yml

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -43,13 +43,14 @@ nav:
4343
- Token Purpose: advanced-usage/purpose.md
4444
- Bigger Applications: advanced-usage/bigger-app.md
4545
- Generate Documentation: advanced-usage/generate-docs.md
46-
- Configuration Options:
47-
- General Options: configuration/general.md
48-
- Headers Options: configuration/headers.md
49-
- Denylist Options: configuration/denylist.md
50-
- API Documentation: api-doc.md
51-
- Development - Contributing: contributing.md
52-
- Release Notes: release-notes.md
46+
- Configuration Options:
47+
- General Options: configuration/general.md
48+
- Headers Options: configuration/headers.md
49+
- Denylist Options: configuration/denylist.md
50+
- API Documentation: api-doc.md
51+
- Supply Chain Security: supply-chain-security.md
52+
- Development - Contributing: contributing.md
53+
- Release Notes: release-notes.md
5354

5455
extra_css:
5556
- 'css/custom.css'
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
#!/usr/bin/env bash
2+
3+
set -euo pipefail
4+
5+
dist_dir="${1:-dist}"
6+
metadata_path="${2:-$dist_dir/release-integrity.env}"
7+
syft_cmd="${SYFT:-syft}"
8+
python_cmd="${PYTHON:-python3}"
9+
10+
if [[ ! -d "$dist_dir" ]]; then
11+
printf 'Distribution directory not found: %s\n' "$dist_dir" >&2
12+
exit 1
13+
fi
14+
15+
mapfile -t package_info < <("$python_cmd" - <<'PY'
16+
from pathlib import Path
17+
import tomllib
18+
19+
pyproject = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8"))
20+
distribution = pyproject["project"]["name"].replace("-", "_")
21+
version = pyproject["project"]["version"]
22+
print(distribution)
23+
print(version)
24+
PY
25+
)
26+
27+
distribution_name="${package_info[0]}"
28+
version="${package_info[1]}"
29+
30+
mapfile -t wheels < <(find "$dist_dir" -maxdepth 1 -type f -name "${distribution_name}-${version}-*.whl" | LC_ALL=C sort)
31+
mapfile -t sdists < <(find "$dist_dir" -maxdepth 1 -type f -name "${distribution_name}-${version}.tar.gz" | LC_ALL=C sort)
32+
33+
if [[ ${#wheels[@]} -ne 1 ]]; then
34+
printf 'Expected exactly one wheel in %s, found %s\n' "$dist_dir" "${#wheels[@]}" >&2
35+
exit 1
36+
fi
37+
38+
if [[ ${#sdists[@]} -ne 1 ]]; then
39+
printf 'Expected exactly one source distribution in %s, found %s\n' "$dist_dir" "${#sdists[@]}" >&2
40+
exit 1
41+
fi
42+
43+
wheel="${wheels[0]}"
44+
sdist="${sdists[0]}"
45+
checksums_path="$dist_dir/SHA256SUMS"
46+
wheel_sbom="$dist_dir/$(basename "$wheel").spdx.json"
47+
sdist_sbom="$dist_dir/$(basename "$sdist").spdx.json"
48+
49+
(cd "$dist_dir" && sha256sum "$(basename "$wheel")" "$(basename "$sdist")") > "$checksums_path"
50+
"$syft_cmd" "$wheel" -o "spdx-json=$wheel_sbom"
51+
"$syft_cmd" "$sdist" -o "spdx-json=$sdist_sbom"
52+
53+
cat > "$metadata_path" <<EOF
54+
wheel=$wheel
55+
wheel_name=$(basename "$wheel")
56+
wheel_sbom=$wheel_sbom
57+
wheel_sbom_name=$(basename "$wheel_sbom")
58+
sdist=$sdist
59+
sdist_name=$(basename "$sdist")
60+
sdist_sbom=$sdist_sbom
61+
sdist_sbom_name=$(basename "$sdist_sbom")
62+
checksums=$checksums_path
63+
checksums_name=$(basename "$checksums_path")
64+
EOF

uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)