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
6 changes: 4 additions & 2 deletions .claude/skills/release/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <merge commit>`, `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 <file> --repo Geeooff/GameModeExecutor`
on a downloaded file.

## 4. After

Expand Down
17 changes: 17 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
@@ -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
34 changes: 30 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
34 changes: 34 additions & 0 deletions .github/workflows/dependabot-automerge.yml
Original file line number Diff line number Diff line change
@@ -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 }}
33 changes: 28 additions & 5 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 <file> --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:
Expand Down
4 changes: 3 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <file> --repo Geeooff/GameModeExecutor`.

## [0.4.0] - 2026-10-17

One log file a day, a Start menu entry to bring the watcher back, stop
Expand Down
35 changes: 22 additions & 13 deletions docs/design/20-build-chain.md
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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 <file> --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 <file> --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.
Expand All @@ -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.
2 changes: 1 addition & 1 deletion docs/design/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions docs/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion docs/how-it-works.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <file> --repo Geeooff/GameModeExecutor`
checks.

## Removing it

Expand Down
7 changes: 4 additions & 3 deletions docs/reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
5 changes: 5 additions & 0 deletions rust-toolchain.toml
Original file line number Diff line number Diff line change
@@ -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"]
4 changes: 3 additions & 1 deletion scripts/release-notes.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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 <file> --repo Geeooff/GameModeExecutor`. Their SHA-256:',
'',
'```'
) + $sums + @(
Expand Down