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/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/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/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/.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/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/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..9082ba0 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. @@ -41,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. 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 + @(