diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0341a4a..550469e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -67,6 +67,23 @@ jobs: - name: Build run: cargo build --all-targets + # Same check as the release workflow's Windows leg: SignPath signs only + # an .exe whose version resource names the project and carries its + # version, and build.rs is what writes it. Checked here so a pull + # request that breaks it fails now rather than at the next release. + - name: Check the Windows version resource + if: runner.os == 'Windows' + shell: pwsh + run: | + $version = (cargo metadata --no-deps --format-version 1 | ConvertFrom-Json).packages | + Where-Object name -eq 'grape' | ForEach-Object version + $info = (Get-Item target\debug\grape.exe).VersionInfo + "ProductName='$($info.ProductName)' ProductVersion='$($info.ProductVersion)' FileVersion='$($info.FileVersion)'" + if ($info.ProductName -cne 'Grape' -or $info.ProductVersion -cne $version) { + "::error::grape.exe has ProductName '$($info.ProductName)' and ProductVersion '$($info.ProductVersion)', expected 'Grape' and '$version'" + exit 1 + } + - name: Test run: cargo test # 19 of the player tests need a real audio device and are #[ignore]d, so diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 84416fe..fa8155f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,29 +1,42 @@ -# Colony Rust program — release workflow -# -# Copy to your repo as .github/workflows/release.yml and replace grape -# with your binary name (lowercase, as it appears in Cargo.toml). +# Grape - release workflow # +# Built from templates/sign-and-publish-caller.yml in Project-Colony-Resources. # Assets follow the Colony naming convention, so Colony auto-detects which -# platforms you ship and your colony.json only needs a name and a category: +# platforms Grape ships: # # grape-linux grape-windows.exe # grape-macos grape-macos-x86 # -# See design/releases.md in Project-Colony-Resources for the full contract. +# The build legs only build, check and upload artifacts; they never see a key. +# Authenticode (SignPath, once approved), the ed25519 .sig/.meta/.meta.sig, +# verification and publishing all happen in +# .github/workflows/sign-and-publish.yml of Project-Colony-Resources, in this +# same run. See design/releases.md there, section "Shared signing workflow". # -# Action refs are pinned to commit SHAs on purpose: this workflow handles the -# organisation's release signing key, and whoever can move a floating tag can -# read that secret. +# Action refs are pinned to commit SHAs on purpose: the called workflow handles +# the organisation's release signing key, and whoever can move a floating tag +# can read that secret. name: Release on: push: branches: [main] - -permissions: - contents: write - pull-requests: write + # Recovery path: finish an existing DRAFT release whose run failed. + # release-please emits `release_created` once per release, so "Re-run all + # jobs" on the original run makes it report nothing to do and every job + # below skip. Start the dispatch from the tag ("Use workflow from: v0.4.1", + # or `gh workflow run release.yml --ref v0.4.1 -f tag=v0.4.1`): with SignPath + # on, sign-and-publish refuses a run whose commit is not the tag's, since + # SignPath records the run's commit as the source of what it signs. + workflow_dispatch: + inputs: + tag: + description: "Existing draft release to build, sign and publish (e.g. v0.4.1). Run from that same tag." + required: true + type: string + +permissions: {} concurrency: group: release-${{ github.ref }} @@ -36,44 +49,54 @@ jobs: # Assembles a release PR from the conventional commits since the last release. # Merging that PR is what creates the tag; everything below runs only then. release-please: + if: github.event_name == 'push' runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write outputs: release_created: ${{ steps.release.outputs.release_created }} tag_name: ${{ steps.release.outputs.tag_name }} steps: + # No `release-type` input, on purpose: when it is set, the action + # ignores both files below, and with them the changelog sections, the + # bump policy and the version in the manifest. The release type lives in + # release-please-config.json. - uses: googleapis/release-please-action@45996ed1f6d02564a971a2fa1b5860e934307cf7 # v5.0.0 id: release with: - release-type: rust - # Named explicitly rather than left to the action's defaults: the - # config is in manifest mode, and a silent fallback to simple mode - # would version the repo differently without saying so. config-file: release-please-config.json manifest-file: .release-please-manifest.json # release-please PUBLISHES the release - and moves /releases/latest - # before a single binary exists. For the whole build window every Colony - # in the field would show an update badge whose every click fails: a - # missing asset first, then a fail-closed signature refusal once the - # binary is up but its .sig is not. Held as a draft here and published by - # the `publish` job once everything is verified present. + # in the field would show an update badge whose every click fails. Held + # as a draft here; sign-and-publish refuses anything else and publishes + # it last, once every file is signed and verified on the release. - name: Hold the release as a draft until it is complete if: ${{ steps.release.outputs.release_created }} env: GH_TOKEN: ${{ github.token }} + TAG: ${{ steps.release.outputs.tag_name }} # `-R` is not optional: this job has no `actions/checkout`, so `gh` has # no git remote to infer the repository from and dies with "fatal: not - # a git repository". That is exactly what happened on v0.3.0 — this step - # failed, `build` and `publish` were skipped, and the tag was published - # with no assets at all. Colony carries the same warning after paying - # for it with an empty v0.10.0. - run: gh release edit "${{ steps.release.outputs.tag_name }}" -R "${{ github.repository }}" --draft=true + # a git repository". That is exactly what happened on v0.3.0: this step + # failed, the build was skipped, and the tag was published with no + # assets at all. + run: gh release edit "$TAG" -R "$GITHUB_REPOSITORY" --draft=true build: name: build ${{ matrix.asset }} needs: release-please - if: ${{ needs.release-please.outputs.release_created }} + # Runs on a merge that created a release, or on a recovery dispatch (when + # release-please is skipped). NOT `always()`: a FAILED release-please must + # stop the run at an empty draft. + if: ${{ !cancelled() && !failure() && (needs.release-please.outputs.release_created || github.event_name == 'workflow_dispatch') }} + # GitHub-hosted runners only: SignPath rejects a signing request if any + # job leading to it ran on a self-hosted runner. runs-on: ${{ matrix.os }} + permissions: + contents: read strategy: fail-fast: false matrix: @@ -94,15 +117,19 @@ jobs: asset: "grape-macos-x86" steps: + # The tag, not the branch: a recovery dispatch must rebuild the tagged + # commit, since the .meta sidecar binds these bytes to that version. - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ needs.release-please.outputs.tag_name || inputs.tag }} + persist-credentials: false - uses: dtolnay/rust-toolchain@89b12181fb390509a0842a86cc55eeb8eb928c1d # stable branch with: targets: ${{ matrix.target }} - - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 - with: - key: ${{ matrix.target }} + # No build cache here: SignPath forbids release builds that reuse + # outputs of earlier, unverified builds. # alsa-sys is the only crate needing a system development package on # Linux; it resolves libasound through pkg-config. aws-lc-sys also links @@ -131,15 +158,10 @@ jobs: # A manifest can PARSE perfectly and still leave the program listed in # Colony with no Download button, because none of its assets match a # convention. `colony validate-manifest` given the asset names catches - # exactly that. Linux only: one check per release is enough. - # - # Deliberately ordered BEFORE the signing step: this downloads and runs a - # binary, and the signing key must never have touched this runner's disk - # while a fetched executable is running on it. + # exactly that. Linux only: one check per release is enough. No key is + # on this runner: signing happens in sign-and-publish. - name: Validate colony.json if: runner.os == 'Linux' - env: - GH_TOKEN: ${{ github.token }} run: | curl -fsSL -o colony-validator \ https://github.com/Project-Colony/Colony/releases/latest/download/colony-linux @@ -159,6 +181,24 @@ jobs: chmod +x "${{ matrix.asset }}" || true "./${{ matrix.asset }}" --version + # SignPath signs only an .exe whose version resource names the project + # and carries its version (build.rs writes it). A build that lost either + # would be refused at signing time, hours later; this fails it here. + - name: Check the Windows version resource + if: runner.os == 'Windows' + shell: pwsh + env: + ASSET: ${{ matrix.asset }} + TAG: ${{ needs.release-please.outputs.tag_name || inputs.tag }} + run: | + $info = (Get-Item $env:ASSET).VersionInfo + $version = $env:TAG -replace '^v', '' + "ProductName='$($info.ProductName)' ProductVersion='$($info.ProductVersion)' FileVersion='$($info.FileVersion)'" + if ($info.ProductName -cne 'Grape' -or $info.ProductVersion -cne $version) { + "::error::$env:ASSET has ProductName '$($info.ProductName)' and ProductVersion '$($info.ProductVersion)', expected 'Grape' and '$version'" + exit 1 + } + # The Intel macOS artefact is cross-compiled on an arm64 runner and # cannot be executed there, so assert its architecture instead - that # being the leg with the fewest users, where a regression survives @@ -170,90 +210,35 @@ jobs: file "${{ matrix.asset }}" | grep -q 'x86_64' \ || { echo "::error::${{ matrix.asset }} is not an x86_64 binary"; exit 1; } - # Signing is opt-in. Delete this step and the next unless colony.json sets - # "signed": true — a program that advertises signatures but ships none - # fails closed and cannot be installed at all. - # - # Requires the COLONY_SIGNING_KEY_PEM organisation secret (the ed25519 - # private key, PEM). The key never lives in a repository. - # - # `shell: bash` rather than skipping Windows: the Windows runner has bash - # and openssl, and an unsigned .exe in a repo that declares - # "signed": true is refused at install time — so excluding it did not - # produce an unsigned-but-working asset, it produced an uninstallable one. - - name: Sign the artifact - shell: bash - env: - SIGNING_KEY: ${{ secrets.COLONY_SIGNING_KEY_PEM }} - run: | - if [ -z "$SIGNING_KEY" ]; then - echo "::error::COLONY_SIGNING_KEY_PEM is not set but signing is enabled" - exit 1 - fi - keyfile="$(mktemp)" - trap 'rm -f "$keyfile"' EXIT - printf '%s' "$SIGNING_KEY" > "$keyfile" - COLONY_SIGNING_KEY="$keyfile" \ - COLONY_RELEASE_VERSION="${{ needs.release-please.outputs.tag_name }}" \ - ./scripts/sign-release.sh "${{ matrix.asset }}" - - - name: Upload to the GitHub release - uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3.0.3 + # The file at the artifact root, under its release name. The name + # prefix is what `artifact-pattern` below matches. + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - # Without this the uploader PUBLISHES the release it is uploading to, - # because `draft` defaults to false and the action updates the release - # rather than only adding files. The first platform to finish - # therefore undoes the draft-hold, and the remaining platforms upload - # into a release that is already at /releases/latest. v0.3.1 went out - # that way: published with Linux and macOS while Windows was still - # compiling. `publish` is what makes it visible, once it has verified - # every asset is present. - draft: true - tag_name: ${{ needs.release-please.outputs.tag_name }} - files: | - ${{ matrix.asset }} - ${{ matrix.asset }}.sig - ${{ matrix.asset }}.meta - ${{ matrix.asset }}.meta.sig - # true: a signature that silently failed to upload is exactly the - # state that makes the release uninstallable, so it must fail here - # rather than at the user's machine. - fail_on_unmatched_files: true - - # Nothing is visible to users until every asset and every signature is really - # on the release. Chained as `needs:` rather than `on: release published`, - # because a release created by release-please with the default GITHUB_TOKEN - # does not fire release events - a workflow written that way never runs, and - # never says so. - publish: - name: Publish the release + name: build-${{ matrix.asset }} + path: ${{ matrix.asset }} + if-no-files-found: error + retention-days: 1 + + # Authenticode (once SignPath is on), .sig/.meta/.meta.sig over the final + # bytes, upload to the draft, download again, verify, publish. Chained as + # `needs:` in this same run: SignPath accepts only an artifact uploaded by + # the run that requests the signature. + sign-and-publish: needs: [release-please, build] - if: ${{ needs.release-please.outputs.release_created }} - runs-on: ubuntu-latest - env: - GH_TOKEN: ${{ github.token }} - TAG: ${{ needs.release-please.outputs.tag_name }} - # Single source of truth for the asset list. Repeating it per step is how - # a platform silently ends up unsigned. - ASSETS: "grape-linux grape-windows.exe grape-macos grape-macos-x86" - # Set to false if colony.json does not declare "signed": true. - SIGNED: "true" - steps: - - name: Verify the release carries everything - run: | - gh release view "$TAG" -R "$GITHUB_REPOSITORY" --json assets --jq '.assets[].name' | sort > /tmp/published - for a in $ASSETS; do - required="$a" - if [ "$SIGNED" = "true" ]; then - required="$a $a.sig $a.meta $a.meta.sig" - fi - for f in $required; do - grep -qx "$f" /tmp/published \ - || { echo "::error::$f is missing from $TAG - Colony would refuse this release"; exit 1; } - done - done - echo "release verified" - - - name: Make the release visible - # Same reason as the draft step above: no checkout in this job either. - run: gh release edit "$TAG" -R "$GITHUB_REPOSITORY" --draft=false + if: ${{ !cancelled() && needs.build.result == 'success' }} + # The ceiling for every job in the called workflow. + permissions: + actions: read + contents: write + uses: Project-Colony/Project-Colony-Resources/.github/workflows/sign-and-publish.yml@619460ff4dc0049f129955f0988d368427b9eedb # main + with: + tag: ${{ needs.release-please.outputs.tag_name || inputs.tag }} + assets: "grape-linux grape-windows.exe grape-macos grape-macos-x86" + artifact-pattern: build-* + # Uncomment once SignPath has approved this project and its project + # exists in the Project-Colony SignPath organisation: + # signpath-project-slug: "grape" + # Named, not `inherit`: the called workflow gets these two and nothing else. + secrets: + COLONY_SIGNING_KEY_PEM: ${{ secrets.COLONY_SIGNING_KEY_PEM }} + SIGNPATH_API_TOKEN: ${{ secrets.SIGNPATH_API_TOKEN }} diff --git a/Cargo.lock b/Cargo.lock index f9d0ae0..9b41d73 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1852,6 +1852,7 @@ dependencies = [ "tray-icon", "unicode-normalization", "windows 0.62.2", + "winresource", "zbus", ] @@ -6730,6 +6731,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "winresource" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0986a8b1d586b7d3e4fe3d9ea39fb451ae22869dcea4aa109d287a374d866087" +dependencies = [ + "version_check", +] + [[package]] name = "wit-bindgen" version = "0.57.1" diff --git a/Cargo.toml b/Cargo.toml index 63642ea..7e2bfd4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -48,6 +48,13 @@ zbus = "5.19.0" [target.'cfg(target_os = "windows")'.dependencies] windows = { version = "0.62.2", features = ["Foundation", "Media", "Storage", "Storage_Streams", "Win32_Foundation", "Win32_System_WinRT"] } +# The Windows version resource, written by build.rs. Not target-specific: a +# build dependency's cfg is the host's, and a Linux host building for Windows +# needs it too. No default features: they only add a TOML parser for reading +# [package.metadata.winresource], which build.rs does not use. +[build-dependencies] +winresource = { version = "0.1.31", default-features = false } + # mockall, pretty_assertions, tokio-test and serial_test were declared here but # referenced nowhere in src/ or tests/. serial_test 4.0.1 also carried a 1.93.1 # rust-version, which would have put the test floor three releases above the diff --git a/README.md b/README.md index a15f87b..cb498b4 100644 --- a/README.md +++ b/README.md @@ -233,6 +233,39 @@ Everything is in [docs/](docs/); start at the index, | [docs/internals/packaging.md](docs/internals/packaging.md) | how a release is built and signed | | [docs/project/roadmap.md](docs/project/roadmap.md) | what is done and what is next | +## Code signing policy + +Free code signing provided by [SignPath.io](https://signpath.io), certificate by [SignPath Foundation](https://signpath.org). + +Windows builds are Authenticode-signed this way once the SignPath Foundation +has accepted the project; until then they ship without Authenticode. Every +release asset, on every platform, is always signed with the Project-Colony +ed25519 release key, and Colony verifies that signature before installing. + +Team roles and members: + +- Committers and reviewers: [MotherSphere](https://github.com/MotherSphere) +- Approvers: [MotherSphere](https://github.com/MotherSphere) + +### Privacy policy + +Grape contacts one networked service, Last.fm, and only after you have turned +it on yourself. Nothing else ever leaves your machine: there is no telemetry, +no analytics, no account and no update check. + +- **What turns it on.** Putting your own Last.fm API key into + `preferences.json` (`metadata_api_key`). The key is empty by default and + there is no field for it in Preferences; with no key, Grape makes no network + request at all. +- **When.** With a key set, Grape asks Last.fm's `album.getInfo` + (`https://ws.audioscrobbler.com/2.0/`) for the album you select, directly or + through its artist, genre or folder, unless it already holds an answer for + that album younger than the cache lifetime (24 hours by default), and again + when you press **Enrich** on an album. +- **What is sent.** Your API key, the album's artist and title, and a + `Grape/` user agent. Like any HTTPS request, it also shows Last.fm + your IP address. Last.fm's answer (genre and year) is cached on your machine. + ## License [GPL-3.0-or-later](LICENSE) © 2026 MotherSphere diff --git a/build.rs b/build.rs new file mode 100644 index 0000000..d794798 --- /dev/null +++ b/build.rs @@ -0,0 +1,20 @@ +// Windows version resource. SignPath signs only an .exe whose ProductName is +// the project name and whose ProductVersion is set, and Windows shows +// FileDescription as the program's name in Task Manager and file dialogs. +// +// Decided on the target, not with cfg!(windows): a build script runs on the +// host, so cfg! would describe the machine building Grape, not the one it is +// built for. +fn main() { + if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("windows") { + // FileVersion and ProductVersion default to CARGO_PKG_VERSION, which + // release-please bumps, so the release version is always the one in + // the resource. Only the names need setting: they default to the + // lowercase package name. + winresource::WindowsResource::new() + .set("ProductName", "Grape") + .set("FileDescription", "Grape") + .compile() + .expect("failed to compile the Windows version resource"); + } +} diff --git a/docs/internals/packaging.md b/docs/internals/packaging.md index a665f66..e847a22 100644 --- a/docs/internals/packaging.md +++ b/docs/internals/packaging.md @@ -1,8 +1,8 @@ # How a release is made -One workflow, `.github/workflows/release.yml`, with three jobs that run in -order: decide, build, sign. It is also the only workflow in the repository — -there is no test or lint job. +`.github/workflows/release.yml` runs three jobs in order: decide, build, then +sign and publish. The last one is the shared workflow from +Project-Colony-Resources. Pull requests are checked separately, by `ci.yml`. ## 1. release-please decides @@ -16,9 +16,10 @@ does not. `release_created` is false on every other push, and both later jobs are gated on it. Configuration lives in `release-please-config.json` (`release-type: rust`, -`bump-minor-pre-major: false`) and the current version in -`.release-please-manifest.json`. Neither `CHANGELOG.md` nor the version in -`Cargo.toml` should ever be edited by hand. +`bump-minor-pre-major: true`) and the current version in +`.release-please-manifest.json`. The action is given those two files and no +`release-type` input: with one, it ignores both files. Neither `CHANGELOG.md` +nor the version in `Cargo.toml` should ever be edited by hand. ## 2. Four targets are built @@ -36,30 +37,38 @@ vendored sources with `cc`, `wayland-sys` dlopens at runtime, and D-Bus is spoken by pure-Rust `zbus` — none of them need an apt package. The GTK 3 stack left the tree when the Linux tray moved to `ksni`. -Each job builds `--release` for its target, copies the binary out under the -asset name, and uploads it to the GitHub release. +Each job builds `--release` for its target with no build cache, copies the +binary out under the asset name, runs it with `--version` (the Intel macOS +binary, which the arm64 runner cannot run, gets an architecture check instead), +and uploads it as a workflow artifact. Nothing reaches the release from a build job, and no build +job sees a key. The Windows job also checks that `grape-windows.exe` carries +the version resource `build.rs` writes: ProductName `Grape` and the release +version as ProductVersion, which SignPath requires before it signs. That build is also the only automated check Windows and macOS ever get: those targets typecheck at release time and are never tested. See [contributing.md](contributing.md#what-the-tests-do-not-cover). -## 3. Everything is signed +## 3. Everything is signed, then published -The `sign` job downloads the release assets, strips the metadata companions -(`.sig`, `.sha256`, `.txt`, `.yml`, `.json`, `.asc`), and signs each remaining -file with the Project-Colony ed25519 key from `secrets.COLONY_SIGNING_KEY_PEM`. -Each signature is verified against the derived public key immediately after -being produced, then uploaded as `.sig`. +The last job calls `sign-and-publish.yml` from Project-Colony-Resources, pinned +by commit, in the same run. It checks the release is still a draft, sends +`grape-windows.exe` to SignPath for Authenticode once a `signpath-project-slug` +is set (it is not yet: SignPath has not accepted the project), then signs every +final file with the Project-Colony ed25519 key from +`secrets.COLONY_SIGNING_KEY_PEM`: `.sig`, `.meta` and +`.meta.sig`. It uploads them to the draft, downloads them again, +verifies them, and only then publishes the release. -Two deliberate details: +The order matters: Authenticode rewrites the `.exe`, so an ed25519 signature +made before it would describe bytes users never download. A missing secret, a +failed check or a refused signing request leaves the release a draft. -- **A missing secret fails the job.** The step checks for an empty key and - exits 1 rather than continuing, so a release cannot quietly ship unsigned. -- **`gh release upload` passes `-R "${{ github.repository }}"`.** The job has no - `actions/checkout`, so there is no git repository for `gh` to infer a target - from. Without `-R` it dies with "not a git repository" *after* the assets are - already signed — which is how a release once shipped unsigned while the job - meant to prevent exactly that reported its failure too late to stop it. +If a run fails after the tag exists, finish the draft from the tag: + +```bash +gh workflow run release.yml -R Project-Colony/Grape --ref vX.Y.Z -f tag=vX.Y.Z +``` ## 4. Colony picks it up diff --git a/release-please-config.json b/release-please-config.json index 6870e0e..a1f41ec 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -48,6 +48,5 @@ "bump-patch-for-minor-pre-major": false } }, - "separate-pull-requests": false, "pull-request-header": "The next release, assembled from the commits since the last one. Merging this tags it, and the tag is what builds and publishes." }