From 9b75dd77d6d640d665626bcaf35d3617a2c9455d Mon Sep 17 00:00:00 2001 From: Geoffrey Vancoetsem <10533139+geeooff@users.noreply.github.com> Date: Sat, 17 Oct 2026 15:18:05 +0200 Subject: [PATCH 1/2] Build with official actions, and attest the release files Rust comes from rustup, already on GitHub's runners, with the toolchain named in rust-toolchain.toml; the cache is actions/cache, keyed on the compiler and the lock file. Both workflows get least-privilege permissions, a timeout, and a checkout that keeps no credentials; CI cancels a superseded run. The release attests its files with actions/attest, fed SHA256SUMS.txt, and the notes say how to verify. Actions stay referenced by tag, at the maintainer's choice. Co-Authored-By: Claude Opus 5.5 --- .claude/skills/release/SKILL.md | 6 ++++-- .github/workflows/ci.yml | 34 +++++++++++++++++++++++++++++---- .github/workflows/release.yml | 33 +++++++++++++++++++++++++++----- CHANGELOG.md | 6 ++++++ docs/design/20-build-chain.md | 25 +++++++++++++++--------- docs/design/README.md | 2 +- docs/getting-started.md | 4 ++-- docs/how-it-works.md | 5 ++++- docs/reference.md | 7 ++++--- rust-toolchain.toml | 5 +++++ scripts/release-notes.ps1 | 4 +++- 11 files changed, 103 insertions(+), 28 deletions(-) create mode 100644 rust-toolchain.toml diff --git a/.claude/skills/release/SKILL.md b/.claude/skills/release/SKILL.md index 1915f21..5b7467c 100644 --- a/.claude/skills/release/SKILL.md +++ b/.claude/skills/release/SKILL.md @@ -52,8 +52,10 @@ On `release/X.Y.Z`, branched from `main`: 1. Push the branch, open the PR, wait for CI, merge. 2. Tag the merge commit and push the tag alone: `git tag vX.Y.Z `, `git push origin vX.Y.Z`. -3. The workflow builds and publishes. Check the release page: installer, zip, - recipes zip, `SHA256SUMS.txt`, and the changelog section as the notes. +3. The workflow builds, attests and publishes. Check the release page: + installer, zip, recipes zip, `SHA256SUMS.txt`, the changelog section as the + notes; and `gh attestation verify --repo Geeooff/GameModeExecutor` + on a downloaded file. ## 4. After diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6e5ac8d..915d953 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,20 +6,46 @@ on: pull_request: workflow_dispatch: +permissions: + contents: read + +# A newer push to the same branch makes the running check moot. +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + env: CARGO_TERM_COLOR: always jobs: release-bundle: runs-on: windows-latest + timeout-minutes: 30 steps: - uses: actions/checkout@v7 - - - uses: dtolnay/rust-toolchain@stable with: - components: rustfmt, clippy + persist-credentials: false + + # rustup is on GitHub's runners; rust-toolchain.toml names the toolchain + # and its components, as it does on a developer's machine. + - name: Rust toolchain + id: rust + shell: pwsh + run: | + rustup toolchain install + "version=$((rustc -V) -replace '[^\w.]+', '-')" >> $env:GITHUB_OUTPUT - - uses: Swatinem/rust-cache@v2 + # Keyed on the compiler too: a new stable cannot reuse what an older one + # compiled. + - uses: actions/cache@v6 + with: + path: | + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + ~/.cargo/git/db/ + target/ + key: cargo-${{ runner.os }}-${{ steps.rust.outputs.version }}-${{ hashFiles('Cargo.lock') }} + restore-keys: cargo-${{ runner.os }}-${{ steps.rust.outputs.version }}- # The same checklist a developer runs: fmt, clippy -D warnings, tests, # every shipped config.toml validated, every documentation link diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3cbff24..a772582 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,28 +2,42 @@ name: Release # A tag alone produces the release: push vX.Y.Z and this runs the same # checklist a developer runs, builds the installer, the zip and the recipes -# from that exact commit, and publishes them with their checksums. Nothing is built -# or uploaded by hand. docs/design/08-distribution.md has the rules. +# from that exact commit, attests them, and publishes them with their +# checksums. Nothing is built or uploaded by hand. +# docs/design/08-distribution.md has the rules. on: push: tags: ['v*'] +# Publishing, and the attestation: the OIDC token signs it, and the two +# others store it with the files it vouches for. permissions: contents: write + id-token: write + attestations: write + artifact-metadata: write + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false jobs: release: runs-on: windows-latest + timeout-minutes: 30 steps: - uses: actions/checkout@v7 with: # The notes list the commits since the previous tag. fetch-depth: 0 + persist-credentials: false - - uses: dtolnay/rust-toolchain@stable - with: - components: rustfmt, clippy + # rustup is on GitHub's runners; rust-toolchain.toml names the + # toolchain. No cache: a release builds from nothing. + - name: Rust toolchain + shell: pwsh + run: rustup toolchain install # The version is bumped in the release commit, so the tag and Cargo.toml # must agree; a release named after one and built from the other would @@ -45,6 +59,15 @@ jobs: shell: pwsh run: .\scripts\release-notes.ps1 -Tag $env:GITHUB_REF_NAME + # GitHub signs that this workflow built each file from this tag's + # commit; `gh attestation verify --repo Geeooff/GameModeExecutor` + # checks it. Taken from the checksums file, so the attestation names + # the very files and digests the release publishes. + - name: Attest the release files + uses: actions/attest@v4 + with: + subject-checksums: dist/SHA256SUMS.txt + - name: Publish shell: pwsh env: diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b5577c..69da342 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,12 @@ a section is written. ## [Unreleased] +### Added + +- Each release file comes with GitHub's attestation that the release workflow + built it from the tagged commit: + `gh attestation verify --repo Geeooff/GameModeExecutor`. + ## [0.4.0] - 2026-10-17 One log file a day, a Start menu entry to bring the watcher back, stop diff --git a/docs/design/20-build-chain.md b/docs/design/20-build-chain.md index 548d84c..742b0e3 100644 --- a/docs/design/20-build-chain.md +++ b/docs/design/20-build-chain.md @@ -1,8 +1,9 @@ # Lot 20 — The build chain, official and kept current -**Status: proposed and decided 2026-10-17, to take next**, while Lot 19's -spike waits for an Office update. Two parts, in this order: Dependabot keeps -current whatever the first part settles on. +**Status: decided 2026-10-17; part 1 built the same day**, its attestation to +be seen at the next release. Taken while Lot 19's spike waits for an Office +update. Two parts, in this order: Dependabot keeps current whatever the first +part settles on. ## 1. Official actions, current practice @@ -13,17 +14,23 @@ Read on 2026-10-17: CI uses `actions/checkout@v7`, `actions/upload-artifact@v7` - Rust from `rustup`, already on GitHub's Windows runners, with the toolchain named in `rust-toolchain.toml`, which pins it on the developer's machine too, instead of `dtolnay/rust-toolchain`. -- The cache: `Swatinem/rust-cache` or `actions/cache` with an explicit key, - decided on CI time measured with each. +- The cache: `actions/cache`, keyed on the compiler and `Cargo.lock`, chosen + on CI time measured on 2026-10-17: 1 min 27 s with a warm cache, 4 min 07 s + cold, against 1 min 47 s to 2 min 06 s with `Swatinem/rust-cache` on `main` + the same day. - Actions named by version tag, not by commit — the maintainer's choice on 2026-10-17: a tag is a release its publisher calls stable, and Dependabot moves it. - Least privilege: `contents: read` for CI; the release keeps `contents: write` and adds what the attestation needs. -- `timeout-minutes`, and a `concurrency` group that cancels a superseded run. -- The release's files attested with `actions/attest-build-provenance`: GitHub - signs, for each file, that this workflow built it from this commit, and - `gh attestation verify --repo geeooff/GameModeExecutor` checks it. +- `timeout-minutes`, a `concurrency` group that cancels a superseded CI run, + and `persist-credentials: false` on checkout, since nothing pushes. +- The release's files attested with `actions/attest`, fed the release's + `SHA256SUMS.txt` so it names exactly the published files and digests. Not + `actions/attest-build-provenance`: its publisher now calls it a wrapper + and says new implementations should use `actions/attest`. GitHub signs, + for each file, that this workflow built it from this commit, and + `gh attestation verify --repo Geeooff/GameModeExecutor` checks it. Not a code signature — SmartScreen still warns — but proof the installer was built from the public source, which `SHA256SUMS.txt`, published on the same page as the files, cannot give. diff --git a/docs/design/README.md b/docs/design/README.md index a04d3de..f6a2a8f 100644 --- a/docs/design/README.md +++ b/docs/design/README.md @@ -30,7 +30,7 @@ the pages keep the history. | 17 | [A log that turns over each day](17-log-rotation.md) | done 2026-09-26; the zip's update path to see at the next release | | 18 | [When the game is gone and nobody is there](18-game-gone-nobody-there.md) | proposed, not decided | | 19 | [Starting from Windows' Startup list](19-startup-list.md) | proposed; a spike first | -| 20 | [The build chain, official and kept current](20-build-chain.md) | decided, to take next | +| 20 | [The build chain, official and kept current](20-build-chain.md) | part 1 built 2026-10-17; Dependabot next | Logging came before the icon, so the icon was written in the final log vocabulary; the windowless build came before it too, so the window and message diff --git a/docs/getting-started.md b/docs/getting-started.md index c5d4df3..863b4c8 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -17,8 +17,8 @@ Run one thing when a game starts, another when it stops. Five minutes. **The installer** (`.msi`) needs no administrator rights. It installs for you alone in `%LOCALAPPDATA%\Programs\GameModeExecutor`, writes a starter configuration, registers the logon task and starts the watcher. Windows warns -because it is not signed; the release page lists its SHA-256. A newer release -installs over it and keeps your configuration. +because it is not signed; the release page says how to check it came from this +repository. A newer release installs over it and keeps your configuration. **The zip** holds the same files. Unzip it somewhere it will stay — `%LOCALAPPDATA%\Programs\GameModeExecutor` is the convention — not in diff --git a/docs/how-it-works.md b/docs/how-it-works.md index 5bb56f6..9b13ee0 100644 --- a/docs/how-it-works.md +++ b/docs/how-it-works.md @@ -145,7 +145,10 @@ unpacks the zip over the folder, keeping the old executables as `.old`, and runs `install-task`. The hash proves the file is the one the release published; the program has no -code signature ([why](design/13-updating.md)). +code signature ([why](design/13-updating.md)). Each release file also carries +GitHub's attestation that the release workflow built it from the tagged +commit, which `gh attestation verify --repo Geeooff/GameModeExecutor` +checks. ## Removing it diff --git a/docs/reference.md b/docs/reference.md index ed2a713..27705f2 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -182,9 +182,10 @@ version block (without it the build warns and goes on). ``` A release is a tag `vX.Y.Z` on the commit that bumps `Cargo.toml` and dates -the version's `CHANGELOG.md` section. The workflow then runs the same script -and publishes the installer, the zips and their checksums, with that section -as the notes. A version the changelog lacks is refused. +the version's `CHANGELOG.md` section. The workflow then runs the same script, +attests the installer and the zips (`actions/attest`), and publishes them with +their checksums and that section as the notes. A version the changelog lacks +is refused. From VS Code, `Ctrl+Shift+B` builds; *Run Task* has the same modes and two for an installed watcher. diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..d812c31 --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,5 @@ +# The toolchain for this repository, on CI and on a developer's machine alike: +# rustup reads this file, and CI installs it with `rustup toolchain install`. +[toolchain] +channel = "stable" +components = ["rustfmt", "clippy"] diff --git a/scripts/release-notes.ps1 b/scripts/release-notes.ps1 index 3488005..23d8380 100644 --- a/scripts/release-notes.ps1 +++ b/scripts/release-notes.ps1 @@ -75,7 +75,9 @@ $lines = @( '', $commits, '', - '## SHA-256', + '## Verifying', + '', + 'GitHub attests that this repository''s release workflow built each file from this tag: `gh attestation verify --repo Geeooff/GameModeExecutor`. Their SHA-256:', '', '```' ) + $sums + @( From 19437a1b2e9a8942b331cd26dccd14a0ae9ce630 Mon Sep 17 00:00:00 2001 From: Geoffrey Vancoetsem <10533139+geeooff@users.noreply.github.com> Date: Sat, 17 Oct 2026 15:20:44 +0200 Subject: [PATCH 2/2] Keep crates and actions current with Dependabot Weekly Dependabot PRs for crates and actions, crate patches grouped. A crate update Cargo calls compatible -- a patch, or a minor at 1.0 -- merges itself once CI passes, through GitHub's documented fetch-metadata pattern; the rest waits for the maintainer. AGENTS.md records it as the one standing approval. Security advisories stay with GitHub's own alerts and security updates, already on and carrying RustSec's, so no separate audit. Co-Authored-By: Claude Opus 5.5 --- .github/dependabot.yml | 17 +++++++++++ .github/workflows/dependabot-automerge.yml | 34 ++++++++++++++++++++++ AGENTS.md | 4 ++- docs/design/20-build-chain.md | 10 ++++--- 4 files changed, 60 insertions(+), 5 deletions(-) create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/dependabot-automerge.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..1c2d9cb --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,17 @@ +# Lot 20: crates and actions kept current, weekly. Crate patches come grouped +# in one PR; dependabot-automerge.yml merges the crate updates Cargo calls +# compatible once CI passes, and everything else waits for the maintainer. +version: 2 +updates: + - package-ecosystem: cargo + directory: / + schedule: + interval: weekly + groups: + patches: + update-types: [patch] + + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly diff --git a/.github/workflows/dependabot-automerge.yml b/.github/workflows/dependabot-automerge.yml new file mode 100644 index 0000000..6c93a76 --- /dev/null +++ b/.github/workflows/dependabot-automerge.yml @@ -0,0 +1,34 @@ +name: Dependabot auto-merge + +# Lot 20: a crate update Cargo calls compatible -- a patch, or a minor once +# the crate is at 1.0 -- merges itself after the required CI check passes. +# A 0.x minor, a major, and every action update wait for the maintainer. +# The pattern is GitHub's own, from "Automating Dependabot with GitHub +# Actions"; the rule on main is what makes the merge wait for CI. + +on: pull_request + +permissions: + contents: write + pull-requests: write + +jobs: + automerge: + if: github.event.pull_request.user.login == 'dependabot[bot]' && github.repository == 'geeooff/GameModeExecutor' + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Dependabot metadata + id: metadata + uses: dependabot/fetch-metadata@v3 + + - name: Merge once CI passes + if: >- + steps.metadata.outputs.package-ecosystem == 'cargo' && + (steps.metadata.outputs.update-type == 'version-update:semver-patch' || + (steps.metadata.outputs.update-type == 'version-update:semver-minor' && + !startsWith(steps.metadata.outputs.previous-version, '0.'))) + run: gh pr merge --auto --merge "$PR_URL" + env: + PR_URL: ${{ github.event.pull_request.html_url }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/AGENTS.md b/AGENTS.md index d55ba6f..2b18628 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -52,7 +52,9 @@ This file says how to work here. The *why* is in decides. - **Explicit approval, every time:** pushing to the repository, publishing a release, changing the scheduled tasks or the configuration on the - maintainer's machine, rewriting `main`'s history. + maintainer's machine, rewriting `main`'s history. The one standing + approval: Dependabot's crate updates that Cargo calls compatible merge + themselves once CI passes ([Lot 20](docs/design/20-build-chain.md)). - **A branch's history is yours to rewrite until it reaches `main`**, pushed pull request branches included (force-push allowed), and you should: before a push, reduce a lot to its logical commits. A fix, a second diff --git a/docs/design/20-build-chain.md b/docs/design/20-build-chain.md index 742b0e3..9082ba0 100644 --- a/docs/design/20-build-chain.md +++ b/docs/design/20-build-chain.md @@ -48,12 +48,14 @@ Read on 2026-10-17: CI uses `actions/checkout@v7`, `actions/upload-artifact@v7` automatic merge does not wait for it: `allow_auto_merge` is on and `main` unprotected, read on 2026-10-17. A change to the repository's settings, so with approval. -- An audit of `Cargo.lock` against RustSec's advisories in CI, so a vulnerable - crate fails the build without waiting for an update. +- Security advisories: GitHub's own Dependabot alerts and security updates, + found already on on 2026-10-17 with no alert open. GitHub's advisory + database carries RustSec's, so the CI audit first planned here would only + repeat them. - `AGENTS.md`: an automatic merge is a standing approval for that class of update and nothing else. **Done when** CI and the release use official actions or justified ones, by tag, with least privilege; a release's files carry a verifiable attestation; -Dependabot opens grouped PRs for crates and actions, the compatible ones -merging themselves after a green CI; the audit runs in CI. +Dependabot opens PRs for crates and actions, the compatible crate updates +merging themselves after a green CI.