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 @@ -40,9 +40,44 @@ jobs:
# above it.
id-token: write
attestations: write
# Reading the CI runs for the tagged commit, for the gate that is the first
# step below. Read-only, and it is the whole reason this scope is here.
actions: read
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

# 🔴 FIRST, before anything is installed or built: was CI green on EXACTLY
# this commit? This workflow deliberately does not re-run the tests - it
# builds and publishes - so until 2026-08-21 the only thing standing between a
# red commit and a published release was somebody remembering. That was step 1
# of the recipe, it was written down as manual, and a rule kept by memory is a
# rule that eventually is not.
#
# Keyed on the CI workflow BY NAME rather than "are all checks green": this
# repository has a check that fails for a reason of its own (a code-scanning
# feature the account is not licensed for), and a gate that demands a clean
# sweep of everything would block every release over something unrelated.
- name: Refuse to build unless CI was green on this commit
shell: bash
env:
GH_TOKEN: ${{ github.token }}
run: |
runs="$(gh api \
"repos/$GITHUB_REPOSITORY/actions/runs?head_sha=$GITHUB_SHA&status=completed" \
--jq '[.workflow_runs[] | select(.name == "CI")] | map(.conclusion)')"
echo "completed CI runs on $GITHUB_SHA: $runs"
if [ "$runs" = "[]" ]; then
echo "::error::No completed CI run for $GITHUB_SHA. Tag a commit that CI has"
echo "::error::finished on - this workflow does not run the tests itself."
exit 1
fi
if ! echo "$runs" | grep -q '"success"'; then
echo "::error::CI did not succeed on $GITHUB_SHA ($runs). Releasing this tag"
echo "::error::would publish code that was never proven to build or pass."
exit 1
fi
echo "CI is green on the commit being released."

# The interpreter PyInstaller freezes into the shipped bundle. Keep it in
# step with the build job in ci.yml, or CI smoke-tests one artefact and
# users download another.
Expand Down
164 changes: 164 additions & 0 deletions .github/workflows/verify-release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
# The last step of a release: check the thing people can actually download.
#
# Everything before this verifies an artefact, a draft, or a digest passed between
# workflows. None of it touches the published release page, and the published page is
# the only thing a user ever sees. Until 2026-08-21 the commands in the README were
# run by a human, occasionally, after the fact - and that is exactly how the first
# documented verify command shipped broken for every user of 0.5.0-rc.2 and again
# for 0.5.0, where `gh attestation verify <zip> -R <repo>` answers HTTP 404 because
# it looks for build provenance that a hand-signed archive deliberately does not have.
#
# 🔴 So this runs THE COMMANDS FROM THE README, verbatim, against the published
# assets. Not equivalents, not a re-implementation: if the README's command stops
# working, this has to be what goes red. `tests/test_version_and_release.py` pins the
# two together so neither can drift alone.
#
# Runs on Windows because one of the checks is Authenticode, which does not exist
# anywhere else - and the signature is the half of the release a checksum cannot
# speak for.
name: Verify the published release

on:
release:
types: [published]
# Re-runnable by hand against any published tag, because "did that old release
# still verify" is a question worth being able to ask without cutting a new one.
workflow_dispatch:
inputs:
tag:
description: "Published tag to check, e.g. v0.5.0"
required: true

permissions:
contents: read

jobs:
verify:
name: Download what users download, then check it
runs-on: windows-latest
timeout-minutes: 15
permissions:
# Reading the release assets. Nothing here writes anything, anywhere.
contents: read
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

- name: Work out which release we are checking
id: which
shell: bash
env:
EVENT_TAG: ${{ github.event.release.tag_name }}
INPUT_TAG: ${{ inputs.tag }}
PRERELEASE: ${{ github.event.release.prerelease }}
run: |
tag="${EVENT_TAG:-$INPUT_TAG}"
test -n "$tag" || { echo "no tag to check"; exit 1; }
echo "tag=$tag" >> "$GITHUB_OUTPUT"
# A dispatch has no event payload, so assume "not a pre-release" only when
# the payload said so; otherwise ask the API below rather than guessing.
echo "prerelease=${PRERELEASE:-unknown}" >> "$GITHUB_OUTPUT"
echo "checking $tag (prerelease=${PRERELEASE:-unknown})"

- name: Download every published asset
shell: bash
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ steps.which.outputs.tag }}
run: |
mkdir -p published && cd published
gh release download "$TAG" --repo "$GITHUB_REPOSITORY"
ls -l
# Four assets ship: the archive, the checksums, the SBOM and the bundle.
# A missing one means a phase did not finish, which is precisely the
# half-failure nobody would notice by looking at the page.
for pattern in '*.zip' 'SHA256SUMS.txt' '*.spdx.json' '*.sigstore.json'; do
# shellcheck disable=SC2086
ls $pattern >/dev/null 2>&1 || { echo "MISSING asset: $pattern"; exit 1; }
done

- name: The checksum users are told to compare
shell: bash
working-directory: published
run: |
# The file is written in `sha256sum -c` format; the `*` before the name is
# the binary-mode marker, not a typo, and `sha256sum` expects it.
sha256sum -c SHA256SUMS.txt

- name: The offline command from the README, verbatim
shell: bash
working-directory: published
env:
GH_TOKEN: ${{ github.token }}
run: |
zip="$(ls ./*.zip)"
bundle="$(ls ./*.sigstore.json)"
gh attestation verify "$zip" \
--bundle "$bundle" \
--repo "$GITHUB_REPOSITORY" \
--predicate-type https://spdx.dev/Document/v2.3

- name: The online command from the README, verbatim
shell: bash
working-directory: published
env:
GH_TOKEN: ${{ github.token }}
run: |
zip="$(ls ./*.zip)"
gh attestation verify "$zip" \
-R "$GITHUB_REPOSITORY" \
--predicate-type https://spdx.dev/Document/v2.3

- name: The signature, and that it is OUR certificate
shell: pwsh
working-directory: published
run: |
# Convention 46: pwsh first. This step is the reason the job runs on
# Windows - a checksum says the bytes are unchanged, and says nothing at
# all about who signed them.
$zip = (Get-ChildItem -Filter *.zip)[0].FullName
Expand-Archive -LiteralPath $zip -DestinationPath unpacked -Force
$exe = Get-ChildItem -Path unpacked -Filter BeanNetworkTester.exe -Recurse |
Select-Object -First 1
if (-not $exe) { throw "no BeanNetworkTester.exe inside the archive" }

$sig = Get-AuthenticodeSignature -LiteralPath $exe.FullName
"status : $($sig.Status)"
"subject : $($sig.SignerCertificate.Subject)"
if ($sig.Status -ne 'Valid') { throw "signature status is $($sig.Status)" }

# Without a timestamp the signature dies when the certificate expires, so
# its absence is a real defect and not a detail.
if (-not $sig.TimeStamperCertificate) { throw "the signature carries no timestamp" }
"stamped : $($sig.TimeStamperCertificate.Subject)"

$bytes = [System.Security.Cryptography.SHA256]::Create().ComputeHash(
$sig.SignerCertificate.RawData)
$actual = ($bytes | ForEach-Object { $_.ToString('x2') }) -join ''
# Read the pin out of the source rather than repeating it here: two copies
# of a constant are two things that can disagree, and this one moves when
# the certificate is renewed.
$line = Select-String -Path ../beantester/legal.py -Pattern '^CODESIGN_SHA256 = "([0-9a-f]{64})"'
if (-not $line) { throw "could not read CODESIGN_SHA256 out of beantester/legal.py" }
$pinned = $line.Matches[0].Groups[1].Value
"pinned : $pinned"
"actual : $actual"
if ($actual -ne $pinned) { throw "the shipped file was signed by a DIFFERENT certificate" }

- name: A full release must be the one the website offers
shell: bash
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ steps.which.outputs.tag }}
run: |
# The download button on the project site points at /releases/latest, so
# "published" and "what people get" are only the same thing if this holds.
# Asked of the API rather than the event payload, because a dispatch has no
# payload and a pre-release is SUPPOSED to fail this.
prerelease="$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json isPrerelease --jq .isPrerelease)"
if [ "$prerelease" = "true" ]; then
echo "$TAG is a pre-release: it must NOT be latest, and is not checked here"
exit 0
fi
latest="$(gh api "repos/$GITHUB_REPOSITORY/releases/latest" --jq .tag_name)"
echo "latest is $latest, this release is $TAG"
test "$latest" = "$TAG"
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,15 @@ The format follows [Keep a Changelog](https://keepachangelog.com/); versions fol

## [Unreleased]

### Fixed

- **The command for checking your download did not work.** Both READMEs told you to run
`gh attestation verify <file> -R donislawdev/BeanNetworkTester`, and it answers `HTTP 404`.
It looks for proof the file was built on a runner, and the file you download is signed on
the maintainer's own machine, so what travels with it is proof of the bill of materials
instead. The command now says which proof to ask for, and every published release is
checked with the exact commands the README gives you, so a broken one cannot ship again.

## [0.5.0] - 2026-08-20

**The short version.** Three things, and the first is about not losing your files when the
Expand Down
28 changes: 18 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1495,17 +1495,23 @@ changes is that the warning now says who signed it. The **WinDivert driver itsel
signed by its author**. You can still compare the release's SHA-256 checksum (`SHA256SUMS.txt`) to
confirm the file arrived unchanged.

**You can also check where the download came from, not just that it is unchanged.** Every release
archive carries a signed build attestation, so one command answers "was this really built from that
source by that workflow":
**You can also check what the download contains, not just that it is unchanged.** Every release
archive carries a signed statement listing everything inside it, made by this repository's own
workflow over the exact bytes you downloaded:

```bash
gh attestation verify BeanNetworkTester-v0.5.0-windows-x64.zip -R donislawdev/BeanNetworkTester
gh attestation verify BeanNetworkTester-v0.5.0-windows-x64.zip -R donislawdev/BeanNetworkTester --predicate-type https://spdx.dev/Document/v2.3
```

A checksum proves the file matches what the release page says. This proves the release page itself
was produced by this repository's own workflow, from a specific commit, on a GitHub-hosted runner.
The same command also verifies the SBOM that ships beside the archive.
A checksum proves the file matches what the release page says. This proves the bill of materials
beside it describes those same bytes, and that the statement was made by a workflow in this
repository rather than by whoever handed you the file.

`--predicate-type` is not optional. Without it `gh` looks for a build-provenance statement and
answers `HTTP 404`, because the archive you download was **signed on the maintainer's machine**,
not built on a runner - the signing key is on a card that no runner can reach. Provenance is
attested for the unsigned build inside the workflow. What travels with the release is the bill
of materials over the signed file.

That command asks GitHub which attestations exist. The proof also ships **as a file**,
`BeanNetworkTester-vX.Y.Z.sigstore.json`, so you can check the archive against evidence that
Expand Down Expand Up @@ -1537,8 +1543,10 @@ component in the build with its version, licence and where its source lives. It
The SBOM is **signed against the archive it describes**, so the two cannot be separated:

```bash
gh attestation verify BeanNetworkTester-vX.Y.Z-windows-x64.zip --repo donislawdev/BeanNetworkTester
gh attestation verify BeanNetworkTester-vX.Y.Z-windows-x64.zip --repo donislawdev/BeanNetworkTester --predicate-type https://spdx.dev/Document/v2.3
```

A checksum tells you the file arrived unchanged. The attestation tells you that *this* build,
with *this* bill of materials, came out of this repository's release workflow.
A checksum tells you the file arrived unchanged. The attestation tells you that *this* bill of
materials belongs to *this* archive, and that the statement was made by this repository's release
workflow. Leave `--predicate-type` out and `gh` looks for a build-provenance statement instead and
answers `HTTP 404`.
28 changes: 18 additions & 10 deletions README.pl.md
Original file line number Diff line number Diff line change
Expand Up @@ -1350,17 +1350,23 @@ Sam sterownik **WinDivert jest podpisany cyfrowo przez jego autora**. Sumę
kontrolną SHA-256 wydania (`SHA256SUMS.txt`) nadal możesz porównać, żeby
potwierdzić, że plik dotarł bez zmian.

**Możesz też sprawdzić, skąd ten plik pochodzi, a nie tylko czy się nie zmienił.** Każde archiwum
wydania niesie podpisaną atestację builda, więc jedno polecenie odpowiada na pytanie „czy to
naprawdę zbudowano z tego kodu, tym workflow":
**Możesz też sprawdzić, co ten plik zawiera, a nie tylko czy się nie zmienił.** Każde archiwum
wydania niesie podpisany spis wszystkiego, co jest w środku, zrobiony przez workflow tego
repozytorium nad dokładnie tymi bajtami, które pobrałeś:

```bash
gh attestation verify BeanNetworkTester-v0.5.0-windows-x64.zip -R donislawdev/BeanNetworkTester
gh attestation verify BeanNetworkTester-v0.5.0-windows-x64.zip -R donislawdev/BeanNetworkTester --predicate-type https://spdx.dev/Document/v2.3
```

Suma kontrolna dowodzi, że plik zgadza się z tym, co mówi strona wydania. To dowodzi, że sama
strona wydania powstała z workflow tego repozytorium, z konkretnego commita, na maszynie GitHuba.
Tym samym poleceniem sprawdzisz też SBOM, który jedzie obok archiwum.
Suma kontrolna dowodzi, że plik zgadza się z tym, co mówi strona wydania. To dowodzi, że spis
składników obok niego opisuje te same bajty i że wystawił go workflow w tym repozytorium, a nie
ten, kto podał Ci plik.

`--predicate-type` nie jest opcjonalne. Bez niego `gh` szuka atestacji prowenancji builda i
odpowiada `HTTP 404`, bo archiwum, które pobierasz, jest **podpisywane na maszynie autora**, a nie
budowane na maszynie GitHuba - klucz siedzi na karcie, do której żaden runner nie sięga.
Prowenancja jest atestowana dla niepodpisanego builda wewnątrz workflow. Z wydaniem jedzie spis
składników nad podpisanym plikiem.

To polecenie pyta GitHuba, jakie atestacje istnieją. Dowód jedzie też **jako plik**,
`BeanNetworkTester-vX.Y.Z.sigstore.json`, więc archiwum sprawdzisz wobec dowodu, który
Expand Down Expand Up @@ -1393,10 +1399,12 @@ SBOM jest **podpisany razem z archiwum, które opisuje**, więc nie da się ich
rozdzielić:

```bash
gh attestation verify BeanNetworkTester-vX.Y.Z-windows-x64.zip --repo donislawdev/BeanNetworkTester
gh attestation verify BeanNetworkTester-vX.Y.Z-windows-x64.zip --repo donislawdev/BeanNetworkTester --predicate-type https://spdx.dev/Document/v2.3
```

Suma kontrolna mówi, że plik dotarł niezmieniony. Atestacja mówi, że **ta** wersja,
z **tym** wykazem składników, wyszła z workflow wydania tego repozytorium.
Suma kontrolna mówi, że plik dotarł niezmieniony. Atestacja mówi, że **ten** wykaz
składników należy do **tego** archiwum i że wystawił go workflow wydania tego
repozytorium. Bez `--predicate-type` polecenie szuka atestacji prowenancji builda
i odpowiada `HTTP 404`.

Dokumentacja po angielsku: [README.md](README.md).
25 changes: 22 additions & 3 deletions tests/test_mutation_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -1051,10 +1051,29 @@
# The command in the README stops matching what we attest, and every user who
# follows it gets an error. Nothing here runs `gh`, so only this pairing can
# notice.
"label": "release: the documented verify command loses its predicate type",
#
# 🔴 Anchored on `.sigstore.json` rather than on the flag alone. The flag now
# appears three times in README.md - it was added to the two ONLINE forms on
# 2026-08-21, after the one without it shipped in 0.5.0 answering HTTP 404 -
# and a pattern that matches three places proves nothing about any of them.
# This anchor is version-independent: the file name carries the version, the
# extension does not.
"label": "release: the documented OFFLINE verify command loses its predicate type",
"file": "README.md",
"old": " --predicate-type https://spdx.dev/Document/v2.3",
"new": "",
"old": ".sigstore.json --repo donislawdev/BeanNetworkTester"
" --predicate-type https://spdx.dev/Document/v2.3",
"new": ".sigstore.json --repo donislawdev/BeanNetworkTester",
"test": "test_the_documented_verify_command_matches_what_we_actually_attest",
},
{
# The other half, and the one that actually broke. `-R` is the online form's
# short flag and appears nowhere else in the file, so this anchor stays unique
# for the same reason the one above does.
"label": "release: the documented ONLINE verify command loses its predicate type",
"file": "README.md",
"old": "-R donislawdev/BeanNetworkTester"
" --predicate-type https://spdx.dev/Document/v2.3",
"new": "-R donislawdev/BeanNetworkTester",
"test": "test_the_documented_verify_command_matches_what_we_actually_attest",
},
{
Expand Down
Loading
Loading