-
-
Notifications
You must be signed in to change notification settings - Fork 0
Say what is inside the file you ship: a register, an SBOM, an attestation, and a release ritual #5
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
c7ea604
Say what is inside the file you ship: a register, an SBOM, an attesta…
donislawdev f91beae
Take seven review findings, and three of them would have signed or at…
donislawdev a99c84b
A comma made three checks pass by not running, and one of them guards…
donislawdev File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,132 @@ | ||
| # Phase C: attest the bill of materials of the SIGNED bytes. | ||
| # | ||
| # Dispatched by packaging/sign-release.ps1 once the card has signed the executables and the | ||
| # repacked archives are on the draft release. | ||
| # | ||
| # IT DOES NOT ATTEST PROVENANCE, and that is a statement rather than an omission. Build | ||
| # provenance says "this workflow produced these bytes". A person produced these bytes, on their | ||
| # own machine, with a card in a reader - claiming otherwise would be the one lie an attestation | ||
| # must never carry. What CAN be said about the signed archive is what is inside it, so that is | ||
| # what is attested here. The unsigned build phase A made has provenance, and phase B verified it | ||
| # before touching anything. | ||
| # | ||
| # A CONSEQUENCE FOR WHOEVER VERIFIES: the tools default to asking for build provenance, and the | ||
| # signed archive deliberately has none. Without `--predicate-type https://spdx.dev/Document/v2.3` | ||
| # one spelling says "no attestation found" and another returns 404, and both look like a broken | ||
| # release. The commands in README.md carry that flag, and phase D runs those commands verbatim. | ||
| name: Attest the signed release | ||
|
|
||
| on: | ||
| workflow_dispatch: | ||
| inputs: | ||
| tag: | ||
| description: The release tag whose signed assets to attest, e.g. v0.1.0 | ||
| required: true | ||
| type: string | ||
| digests: | ||
| description: Comma-separated <archive>=<sha256> of what phase B signed, as a cross-check | ||
| required: true | ||
| type: string | ||
|
|
||
| permissions: | ||
| contents: read | ||
|
|
||
| jobs: | ||
| attest: | ||
| name: Attest the signed archives | ||
| # Nothing here needs Windows: it downloads, hashes and attests. | ||
| runs-on: ubuntu-latest | ||
| timeout-minutes: 20 | ||
| permissions: | ||
| # Attaching the attestation bundles to the release as assets. | ||
| contents: write | ||
| id-token: write | ||
| attestations: write | ||
| artifact-metadata: write | ||
| env: | ||
| GH_TOKEN: ${{ github.token }} | ||
| # The dispatch inputs reach the scripts through the environment rather than through `${{ }}` | ||
| # inside a `run:` block. Template expansion happens BEFORE the shell sees the script, so an | ||
| # input is pasted into the source of the script and the quotes around it in this file | ||
| # protect nothing - a value carrying a quote closes the string it landed in and the rest of | ||
| # it runs as code, in a job that holds `id-token: write` and `attestations: write`. | ||
| TAG: ${{ inputs.tag }} | ||
| DIGESTS: ${{ inputs.digests }} | ||
| REPO: ${{ github.repository }} | ||
| steps: | ||
| # THE ARCHIVES COME FROM THE RELEASE, not from the caller. Attesting a digest somebody | ||
| # handed us would attest a number rather than a file, and the number and the file are the | ||
| # same thing only if nothing went wrong - which is precisely what is being checked. The | ||
| # digests that were passed in stay as a cross-check below. | ||
| - name: Download what the release actually carries | ||
| run: | | ||
| mkdir -p assets | ||
| gh release download "$TAG" --repo "$REPO" \ | ||
| --pattern '*.zip' --pattern '*.spdx.json' --dir assets | ||
| ls -l assets | ||
|
|
||
| - name: The bytes on the release must be the bytes phase B signed | ||
| run: | | ||
| fail=0 | ||
| for pair in $(echo "$DIGESTS" | tr ',' ' '); do | ||
| name="${pair%%=*}" | ||
| expected="${pair#*=}" | ||
| if [ ! -f "assets/$name" ]; then | ||
| echo "::error::the release carries no $name" | ||
| fail=1 | ||
| continue | ||
| fi | ||
| actual=$(sha256sum "assets/$name" | cut -d' ' -f1) | ||
| if [ "$actual" != "$expected" ]; then | ||
| echo "::error::$name on the release is not what phase B signed" | ||
| echo " signed: $expected" | ||
| echo " released: $actual" | ||
| fail=1 | ||
| else | ||
| echo "$name matches the digest phase B signed" | ||
| fi | ||
| done | ||
| [ "$fail" -eq 0 ] || exit 1 | ||
|
|
||
| # AND EVERY ARCHIVE HAS TO BE NAMED IN THE INPUT, which the loop above cannot say. | ||
| # It walks what DIGESTS carries, so a dispatch with an empty value, a value that is | ||
| # only commas, or one that names a single archive passes it without checking the | ||
| # others - and the steps below then attest archives nobody cross-checked. This is a | ||
| # workflow_dispatch input, so the value is typed by a person on a bad day rather than | ||
| # always produced by phase B. Found by the review of PR #5. | ||
| for zip in assets/*.zip; do | ||
| name=$(basename "$zip") | ||
| case ",$DIGESTS," in | ||
| *",$name="*) ;; | ||
| *) echo "::error::no digest was passed for $name, so nothing cross-checked it"; exit 1 ;; | ||
| esac | ||
| done | ||
|
|
||
| - name: Attest the bill of materials for the window package | ||
| id: gui | ||
| uses: actions/attest-sbom@c604332985a26aa8cf1bdc465b92731239ec6b9e # v4.1.0 | ||
| with: | ||
| subject-path: assets/BetterWindowsServices-win-x64.zip | ||
| sbom-path: assets/BetterWindowsServices-win-x64.zip.spdx.json | ||
|
|
||
| - name: Attest the bill of materials for the command line package | ||
| id: cli | ||
| uses: actions/attest-sbom@c604332985a26aa8cf1bdc465b92731239ec6b9e # v4.1.0 | ||
| with: | ||
| subject-path: assets/bws-cli-win-x64.zip | ||
| sbom-path: assets/bws-cli-win-x64.zip.spdx.json | ||
|
|
||
| # Published as assets, with the extension the tooling and the scanners recognise, so the | ||
| # attestation can be checked offline with `--bundle` by somebody who would rather not call | ||
| # the API - and so a scanner looking over the release page finds it at all. | ||
| - name: Attach the attestation bundles to the release | ||
| env: | ||
| GUI_BUNDLE: ${{ steps.gui.outputs.bundle-path }} | ||
| CLI_BUNDLE: ${{ steps.cli.outputs.bundle-path }} | ||
| run: | | ||
| cp "$GUI_BUNDLE" BetterWindowsServices-win-x64.zip.sigstore.json | ||
| cp "$CLI_BUNDLE" bws-cli-win-x64.zip.sigstore.json | ||
| gh release upload "$TAG" \ | ||
| BetterWindowsServices-win-x64.zip.sigstore.json \ | ||
| bws-cli-win-x64.zip.sigstore.json \ | ||
| --repo "$REPO" --clobber | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,186 @@ | ||
| # Phase A of the release ritual: build here, prove it was built here, open an empty draft. | ||
| # | ||
| # The signing key lives on a cryptographic card in a reader and CANNOT be exported - that is the | ||
| # whole value of it, so no GitHub-hosted runner will ever reach it. A self-hosted runner could, | ||
| # and this is a PUBLIC repository, where a self-hosted runner is a machine strangers can aim a | ||
| # pull request at. So the build happens where builds belong and the signature happens where the | ||
| # card is. | ||
| # | ||
| # THIS PHASE PUBLISHES NOTHING. No archive, no checksums, no bill of materials. Those are not the | ||
| # bytes a user will download, and an unsigned executable on a public release page - even for a | ||
| # quarter of an hour - is a file somebody downloads. The draft it opens is empty on purpose. | ||
| # | ||
| # The four phases, and who runs each: | ||
| # A this file, on a `v*` tag build, attest the UNSIGNED build, open a draft, hand | ||
| # the build back as a workflow artifact | ||
| # B packaging/sign-release.ps1 on the machine with the card: verify A's attestation, | ||
| # sign, repack, write the documents and the checksums, | ||
| # upload, wait | ||
| # C .github/workflows/attest-signed.yml attest the bill of materials of the SIGNED bytes, | ||
| # never their provenance - a person signed those | ||
| # D .github/workflows/verify-release.yml on publish: check the release page as a user does | ||
| # | ||
| # The whole reasoning is ADR-28 in docs/02. | ||
| # | ||
| # READ THIS BEFORE WONDERING WHY THE BUTTON IS MISSING: "To trigger the workflow_dispatch event, | ||
| # your workflow must be in the default branch." That is GitHub's own documentation, and it means | ||
| # the manual half of this file does nothing at all until it is merged to main. | ||
| # | ||
| # The release stays a draft until a person reads it and presses the button. | ||
| name: Release | ||
|
|
||
| on: | ||
| push: | ||
| tags: ['v*'] | ||
| workflow_dispatch: | ||
|
|
||
| permissions: | ||
| contents: read | ||
|
|
||
| concurrency: | ||
| group: release-${{ github.ref }} | ||
| cancel-in-progress: false | ||
|
|
||
| jobs: | ||
| build: | ||
| name: Build and attest the unsigned build | ||
| runs-on: windows-latest | ||
| timeout-minutes: 45 | ||
| permissions: | ||
| # Opening the draft. It gets no assets here. | ||
| contents: write | ||
| # The three the attestation action needs: an OIDC token for a Sigstore certificate, the | ||
| # attestation store, and the artifact metadata record. | ||
| id-token: write | ||
| attestations: write | ||
| artifact-metadata: write | ||
| env: | ||
| GH_TOKEN: ${{ github.token }} | ||
| # THE TAG REACHES THE SCRIPTS THROUGH THE ENVIRONMENT RATHER THAN THROUGH `${{ }}` INSIDE A | ||
| # `run:` BLOCK, and that is not style. Template expansion happens BEFORE the shell sees the | ||
| # script, so a tag is pasted into the source of the script and the quotes around it in this | ||
| # file protect nothing. Git allows a quote, a backtick and a semicolon in a ref name, so a | ||
| # tag can close the string it was pasted into and continue as code - in a job holding | ||
| # `id-token: write` and `attestations: write`. An environment variable is passed by the | ||
| # runner as a value and is never parsed as part of the script. The semgrep gate in this | ||
| # repository blocks the other spelling by name. | ||
| TAG: ${{ github.ref_name }} | ||
| steps: | ||
| - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | ||
| with: | ||
| persist-credentials: false | ||
|
|
||
| # THIS WORKFLOW BUILDS AND PUBLISHES, AND IT DOES NOT TEST. Without this step the only | ||
| # thing standing between a red commit and a release is somebody's memory of having looked. | ||
| # Asked by WORKFLOW FILE NAME rather than "are all the checks green": a repository | ||
| # accumulates checks, some advisory, and "all green" quietly becomes "none of them has said | ||
| # no yet". This repository requires five checks on main and `build` is the one that runs | ||
| # the suite. | ||
| - name: The test workflow must have been green on this exact commit | ||
| if: startsWith(github.ref, 'refs/tags/v') | ||
| shell: pwsh | ||
| run: | | ||
| $sha = $env:GITHUB_SHA | ||
| $runs = gh api "repos/$env:GITHUB_REPOSITORY/actions/workflows/build.yml/runs?head_sha=$sha&per_page=20" --jq '.workflow_runs[] | "\(.conclusion)|\(.status)|\(.html_url)"' | ||
| if ($LASTEXITCODE -ne 0) { throw "cannot read the build runs for $sha" } | ||
| $lines = @($runs -split "`n" | Where-Object { $_ }) | ||
| if ($lines.Count -eq 0) { | ||
| throw "the build workflow never ran on $sha. A tag on a commit nothing has tested is a release nothing checked - push the commit to a branch and let it go through a pull request first." | ||
| } | ||
| if (-not ($lines | Where-Object { $_.StartsWith('success|') })) { | ||
| throw "the build workflow on $sha did not conclude successfully:`n$($lines -join "`n")" | ||
| } | ||
| Write-Host "build was green on $sha" | ||
|
|
||
| # The version lives in exactly one file - rule 11 of CLAUDE.md makes moving it the owner's | ||
| # decision - and a tag is where disagreement costs the most, because every URL and document | ||
| # name downstream is written from one of the two. | ||
| - name: The tag and the version file must agree | ||
| if: startsWith(github.ref, 'refs/tags/v') | ||
| shell: pwsh | ||
| run: | | ||
| $found = Select-String -Path Directory.Build.props -Pattern '<Version>([^<]+)</Version>' | Select-Object -First 1 | ||
| if (-not $found) { throw 'cannot read <Version> from Directory.Build.props' } | ||
| $version = $found.Matches[0].Groups[1].Value | ||
| if ($env:TAG -ne "v$version") { | ||
| throw "the tag is $env:TAG and the product version is $version - one of them was not moved" | ||
| } | ||
| Write-Host "tag $env:TAG matches Directory.Build.props" | ||
|
|
||
| # A changelog with everything still under [Unreleased] is a changelog nobody closed, and the | ||
| # release notes are the one part of a release that cannot be regenerated afterwards. | ||
| - name: The changelog must be closed for this version | ||
| if: startsWith(github.ref, 'refs/tags/v') | ||
| shell: pwsh | ||
| run: | | ||
| $version = $env:TAG.TrimStart('v') | ||
| $lines = Get-Content CHANGELOG.md | ||
| $headings = @() | ||
| for ($i = 0; $i -lt $lines.Count; $i++) { if ($lines[$i] -match '^## \[') { $headings += $i } } | ||
| $unreleased = $headings | Where-Object { $lines[$_] -match '^## \[Unreleased\]' } | Select-Object -First 1 | ||
| if ($null -eq $unreleased) { throw 'CHANGELOG.md has no [Unreleased] section' } | ||
| $next = $headings | Where-Object { $_ -gt $unreleased } | Select-Object -First 1 | ||
| if ($null -eq $next) { $next = $lines.Count } | ||
| $body = $lines[($unreleased + 1)..($next - 1)] | Where-Object { $_.Trim() -match '^[-*]' } | ||
| if ($body) { | ||
| throw "CHANGELOG.md still has $($body.Count) entries under [Unreleased] - move them under '## [$version] - <date>' before tagging" | ||
| } | ||
| if (-not (Select-String -Path CHANGELOG.md -Pattern "^## \[$([regex]::Escape($version))\] - \d{4}-\d{2}-\d{2}" -Quiet)) { | ||
| throw "CHANGELOG.md has no dated section for $version" | ||
| } | ||
| Write-Host "changelog closed for $version" | ||
|
|
||
| - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 | ||
| with: | ||
| dotnet-version: '10.0.x' | ||
|
|
||
| # Publishes both halves, checks the pinned third-party bytes, stages the licences beside | ||
| # each executable, RUNS the command line program out of the staging folder, packs, and | ||
| # writes a bill of materials that checks the component register against the manifest the | ||
| # publish produced - in both directions. The restore inside it is also the package audit: | ||
| # Directory.Build.props turns an advisory into an error, so a vulnerable package stops this | ||
| # workflow before it can produce a file anybody downloads. | ||
| - name: Build the packages | ||
| shell: pwsh | ||
| run: ./packaging/build-dist.ps1 | ||
|
|
||
| # Provenance over the UNSIGNED build, which is what phase B verifies before it touches | ||
| # anything. The archives change when the executable inside them is signed, so this | ||
| # attestation describes the artifact handed to the card machine and NOT what a user | ||
| # downloads. Phase C attests the signed bytes, and deliberately attests only their bill of | ||
| # materials - a person signed those, on their own machine. | ||
| - name: Attest how the unsigned build was made | ||
| uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2 | ||
| with: | ||
| subject-path: | | ||
| dist/BetterWindowsServices-win-x64.zip | ||
| dist/bws-cli-win-x64.zip | ||
|
|
||
| # Empty on purpose. The assets arrive in phase B, signed. | ||
| - name: Open the draft release | ||
| if: startsWith(github.ref, 'refs/tags/v') | ||
| shell: pwsh | ||
| run: | | ||
| gh release view $env:TAG 2>$null | ||
| if ($LASTEXITCODE -ne 0) { | ||
| gh release create $env:TAG --draft --title "Better Windows Services $($env:TAG.TrimStart('v'))" --notes "Draft. Built by the Release workflow, signed on the card, assets attached by phase B - see CHANGELOG.md." | ||
| if ($LASTEXITCODE -ne 0) { throw 'could not create the draft release' } | ||
| Write-Host "opened draft $env:TAG with no assets" | ||
| } else { | ||
| Write-Host "draft $env:TAG already exists" | ||
| } | ||
|
|
||
| # THE SEAM BETWEEN THE BUILD AND THE CARD. The archives, and the build manifest beside each | ||
| # one: phase B has to write the bill of materials again over the signed bytes, and the | ||
| # resolved version of every runtime pack exists only in that manifest. The checksums and the | ||
| # documents build-dist.ps1 wrote describe UNSIGNED bytes and have no business travelling | ||
| # any further. | ||
| - name: Hand the build to the signing step | ||
| uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 | ||
| with: | ||
| name: unsigned-build-${{ github.ref_name }} | ||
| path: | | ||
| dist/*.zip | ||
| dist/*.deps.json | ||
| if-no-files-found: error | ||
| retention-days: 14 |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.