From d9ae20a3016b9d6bd25ea561a22166d4e37d87c3 Mon Sep 17 00:00:00 2001 From: Athanasia Monika Mowinckel Date: Thu, 1 Oct 2026 18:12:17 +0200 Subject: [PATCH 1/5] feat(packaging): install Entracte with Scoop on Windows (#359) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scoop reads manifests from a repository's `bucket/` directory, so the bucket lives here rather than in a separate `scoop-entracte` repo — one repo, no cross-repo PAT for CI to push with, and the same arrangement `Casks/` already uses. (Homebrew *requires* a separate repo for a tap; Scoop does not, so this is the closer parity, not a departure.) `release.yml` now also builds `Entracte__x64-portable.zip`: Scoop extracts archives rather than running installers, and the NSIS setup's own install location would fight Scoop's `~/scoop/apps` layout. The app is a single self-contained exe, so staging `target/release/entracte.exe` as `Entracte.exe` beside the LICENSE and compressing it is the whole job. It rides as its own artifact rather than inside `windows-unsigned`, which goes to SignPath — whose policy covers the two bundles, not an archive. `bump-scoop.yml` regenerates the manifest on `release: published` and commits it straight to `main`, shaped after `bump-cask.yml` for the same reasons: Actions cannot open PRs here, a release-triggered checkout defaults to the tag rather than `main`, and nothing downstream notices a red run, so it files an issue against itself. The hash comes from the release's own `SHA256SUMS.txt`. Stable releases only, like the cask: `checkver` reads `releases/latest` (which excludes prereleases), the generator rejects a prerelease version, and the workflow refuses a prerelease tag. A `scoop` install cannot see the in-app update-channel setting, so it would have no way to opt out. The three bugs the Cairn review caught are avoided rather than re-introduced: the commit step stages before asking whether anything changed (`git diff` says nothing changed about a file it has never tracked, which would have made the first release a silent no-op); `checkver`'s regex is the same constant the generator validates against; and the asset name is a single function pinned across all three files by `scoop-manifest.test.ts`. `bucket/entracte.json` is deliberately absent until the first release that ships a portable zip — committing it now would advertise a 404. Co-Authored-By: Claude Opus 5 (1M context) --- .github/audit/cspell/project-words.txt | 7 + .github/workflows/bump-scoop.yml | 224 +++++++++++++++++++++++ .github/workflows/release.yml | 54 ++++++ CHANGELOG.md | 4 + README.md | 7 + bucket/README.md | 60 ++++++ docs/developer/releases.md | 10 +- docs/guide/install.md | 20 +- scripts/scoop-manifest.d.mts | 23 +++ scripts/scoop-manifest.mjs | 107 +++++++++++ src/test-fixtures/scoop-manifest.test.ts | 113 ++++++++++++ 11 files changed, 627 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/bump-scoop.yml create mode 100644 bucket/README.md create mode 100644 scripts/scoop-manifest.d.mts create mode 100644 scripts/scoop-manifest.mjs create mode 100644 src/test-fixtures/scoop-manifest.test.ts diff --git a/.github/audit/cspell/project-words.txt b/.github/audit/cspell/project-words.txt index d4e69cbe..a4456c92 100644 --- a/.github/audit/cspell/project-words.txt +++ b/.github/audit/cspell/project-words.txt @@ -198,3 +198,10 @@ unacked RUSTSEC preopened dryrun +Scoop +scoop +checkver +autoupdate +pwsh +APPDATA +Chocolatey diff --git a/.github/workflows/bump-scoop.yml b/.github/workflows/bump-scoop.yml new file mode 100644 index 00000000..3f3f4a87 --- /dev/null +++ b/.github/workflows/bump-scoop.yml @@ -0,0 +1,224 @@ +name: Bump Scoop manifest + +# Regenerates bucket/entracte.json straight on main whenever a GitHub release +# is published, so `scoop update entracte` picks it up (#359). +# +# Deliberately shaped like bump-cask.yml, because the constraints are the same: +# +# * It commits directly to main. Actions is not permitted to create pull +# requests on this repo (`can_approve_pull_request_reviews` is off), which +# is what left the cask stranded behind eleven releases (#349). A generated +# version-and-checksum bump has nothing to review anyway. +# * It checks out `ref: main` explicitly. On a `release: published` event the +# default checkout resolves to the *tag* commit, so pushing that to main +# would revert everything merged since the release was cut. +# * It fails loudly. Nothing downstream consumes this workflow, so a red run +# is invisible unless it says something — the final step files (or comments +# on) an issue. +# +# It runs on `release: published`, not on the tag push that builds the release: +# `release.yml` produces a DRAFT whose assets 404 for everyone but the +# maintainer, so bumping then would advertise a download nobody could fetch. + +on: + release: + types: [published] + workflow_dispatch: + inputs: + tag: + description: "Release tag to bump from (e.g. v0.1.2)" + required: true + +permissions: + contents: write + issues: write + +concurrency: + group: bump-scoop-${{ github.event.release.tag_name || inputs.tag }} + cancel-in-progress: false + +jobs: + bump-scoop: + # Stable releases only, for the same reason the cask is stable-only: a + # `scoop` install cannot see the in-app update-channel setting, so a beta + # pushed through this bucket would reach its users with no way to opt out. + # Offering betas over Scoop would need a second `entracte-beta` manifest. + if: >- + github.event_name == 'workflow_dispatch' || + !github.event.release.prerelease + runs-on: ubuntu-latest + steps: + - name: Resolve tag and version + id: meta + # Pipe untrusted workflow_dispatch input via env, never interpolate + # `${{ inputs.tag }}` into the shell directly — a tag like + # `v1.0.0; rm -rf /` would otherwise be evaluated. + env: + EVENT_NAME: ${{ github.event_name }} + RELEASE_TAG: ${{ github.event.release.tag_name }} + DISPATCH_TAG: ${{ inputs.tag }} + run: | + if [ "$EVENT_NAME" = "release" ]; then + tag="$RELEASE_TAG" + else + tag="$DISPATCH_TAG" + fi + # Basic shape validation: anything not in [A-Za-z0-9._-] is rejected. + if ! printf '%s' "$tag" | grep -Eq '^[A-Za-z0-9._-]+$'; then + echo "::error::refusing to use tag '$tag' — contains characters outside [A-Za-z0-9._-]" + exit 1 + fi + # The job condition already filters prerelease *events*; this covers + # workflow_dispatch, where a human could otherwise type a beta tag + # and put a prerelease version into the stable bucket. + case "$tag" in + *-*) + echo "::error::'$tag' is a prerelease tag — the Scoop bucket tracks stable releases only (betas go through the in-app beta channel)" + exit 1 + ;; + esac + echo "tag=${tag}" >> "$GITHUB_OUTPUT" + echo "version=${tag#v}" >> "$GITHUB_OUTPUT" + + # Must pin to main, and with full history: the commit step rebases if + # main moved while the release was being published, and a shallow clone + # has no merge base to rebase onto. + - uses: actions/checkout@v7 + with: + ref: main + fetch-depth: 0 + + - uses: actions/setup-node@v7 + with: + node-version: lts/* + + # Releases already carry SHA256SUMS.txt (composed by release.yml's + # `checksums` job over every asset on the draft), so the hash is read + # from there rather than re-downloading and hashing the zip. + - name: Fetch SHA256SUMS.txt + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh release download "${{ steps.meta.outputs.tag }}" \ + --repo "${{ github.repository }}" \ + --pattern "SHA256SUMS.txt" + + - name: Regenerate bucket/entracte.json + env: + TAG: ${{ steps.meta.outputs.tag }} + run: | + set -euo pipefail + version="${TAG#v}" + # Spelled the same way in release.yml (which builds the zip) and in + # scripts/scoop-manifest.mjs (which tells Scoop where to get it); + # scoop-manifest.test.ts pins all three against each other, because + # drift here is a 404 that only surfaces on a user's machine. + asset="Entracte_${version}_x64-portable.zip" + + hash=$(awk -v f="$asset" '$2 == f { print $1 }' SHA256SUMS.txt) + if [ -z "$hash" ]; then + echo "::error::no checksum for ${asset} in SHA256SUMS.txt — did the release build the portable zip?" + echo "SHA256SUMS.txt contents:" + cat SHA256SUMS.txt + exit 1 + fi + + node scripts/scoop-manifest.mjs "$version" "$hash" + cat bucket/entracte.json + + - name: Commit and push to main + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + VERSION: ${{ steps.meta.outputs.version }} + TAG: ${{ steps.meta.outputs.tag }} + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + # Stage FIRST, then ask whether anything changed. On the very first + # release the manifest is untracked, and `git diff` reports no change + # about a file git has never heard of — which would make the one run + # that matters most a silent no-op. + git add bucket/entracte.json + if git diff --cached --quiet; then + echo "Manifest already at ${VERSION} — nothing to bump." + echo "Scoop manifest already at \`${VERSION}\` — nothing to bump." >> "$GITHUB_STEP_SUMMARY" + exit 0 + fi + + git commit -m "chore(scoop): point the bucket at ${TAG}" + + # Retry on a lost race: another commit can land on main between the + # checkout and the push. Rebase and try again. A conflict means + # someone hand-edited the manifest concurrently, so the rebase fails + # the step rather than guessing which side wins. + pushed="" + for attempt in 1 2 3; do + if git push origin HEAD:main; then + pushed="$attempt" + break + fi + echo "push rejected (attempt ${attempt}) — rebasing onto origin/main" + if ! git pull --rebase origin main; then + git rebase --abort || true + echo "::error::Scoop manifest bump conflicts with a concurrent edit to bucket/entracte.json — resolve by hand" + exit 1 + fi + done + + if [ -z "$pushed" ]; then + echo "::error::could not push the Scoop manifest bump to main after 3 attempts" + exit 1 + fi + + { + echo "### Scoop manifest bumped to \`${VERSION}\`" + echo + echo "Pushed to \`main\` as $(git rev-parse --short HEAD) (attempt ${pushed})." + echo + echo "\`scoop update entracte\` now picks up ${VERSION}." + } >> "$GITHUB_STEP_SUMMARY" + + # Same reasoning as bump-cask.yml: nothing downstream consumes this + # workflow's result, so a red run is invisible unless it says something. + - name: Report a failed bump + if: failure() + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ steps.meta.outputs.tag }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + tag="${TAG:-unknown}" + title="bump-scoop failed for ${tag}" + + # Build the body in a FILE, not via `body=$(cat < "${RUNNER_TEMP}/bump-scoop-failure.md" < \` with the + \`Entracte__x64-portable.zip\` checksum from that release's + \`SHA256SUMS.txt\` and commit the result. + + This issue is filed automatically: the workflow has no downstream + consumer, so a red run would otherwise be silent — the lesson of #349. + EOF + + existing=$(gh issue list --state open --limit 100 --json number,title \ + | jq -r --arg t "$title" '.[] | select(.title == $t) | .number' | head -1) + + if [ -n "$existing" ]; then + gh issue comment "$existing" --body-file "${RUNNER_TEMP}/bump-scoop-failure.md" + echo "commented on existing issue #${existing}" + else + gh issue create --title "$title" --label bug --body-file "${RUNNER_TEMP}/bump-scoop-failure.md" + fi diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 48a1d774..0dec820f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -307,6 +307,48 @@ jobs: cp src-tauri/target/release/bundle/msi/*.msi* unsigned/ cp src-tauri/target/release/bundle/nsis/*.exe* unsigned/ + # Portable zip for the Scoop bucket (#359). Scoop extracts an archive + # rather than running an installer, so neither Windows bundle suits it: + # pointing it at the NSIS setup would shell out to the installer, whose + # own install location fights Scoop's `~/scoop/apps` layout. Tauri has + # no portable target and does not need one — the app is a single + # self-contained exe (WebView2 comes from the OS), so staging the Cargo + # output under the product name and zipping it is the whole job. + # + # The rename matters: Cargo emits `entracte.exe`, and the manifest's + # `bin`/`shortcuts` name what is inside the zip. + # + # Uploaded as its own artifact rather than added to `windows-unsigned`, + # which is submitted to SignPath — its signing policy covers the `.msi` + # / `.exe` bundles, so slipping an archive into that artifact would be + # asking it to validate something it was not configured for. The + # consequence is that the exe inside the zip stays Authenticode-unsigned + # even once SignPath is wired; that is the same status every Windows + # artifact has today, and the install docs say so. + - name: Package the portable zip + shell: pwsh + env: + REF_NAME: ${{ github.ref_name }} + run: | + $ErrorActionPreference = 'Stop' + $version = $env:REF_NAME -replace '^v', '' + $stage = Join-Path $env:RUNNER_TEMP 'portable' + New-Item -ItemType Directory -Force -Path $stage | Out-Null + Copy-Item src-tauri/target/release/entracte.exe (Join-Path $stage 'Entracte.exe') + Copy-Item LICENSE $stage + New-Item -ItemType Directory -Force -Path portable | Out-Null + # Spelled identically in bump-scoop.yml and scripts/scoop-manifest.mjs; + # src/test-fixtures/scoop-manifest.test.ts pins the three together. + Compress-Archive -Path "$stage/*" ` + -DestinationPath "portable/Entracte_${version}_x64-portable.zip" -Force + + # SHA-pinned. Update via dependabot.yml. + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: windows-portable + path: portable/ + if-no-files-found: error + # SHA-pinned: this action stages the unsigned Windows bundles # for `sign-windows` to consume. A compromised upstream tag # could swap in artifacts that SignPath then signs and ships. @@ -518,6 +560,15 @@ jobs: name: windows-unsigned path: unsigned + # The Scoop portable zip (#359). It never goes through SignPath, so it + # rides along here to land on the same draft release — which also puts it + # in `SHA256SUMS.txt`, where bump-scoop.yml reads its hash. + # SHA-pinned. Update via dependabot. + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: windows-portable + path: portable + - name: Assemble release bundle (binaries + Tauri .sig sidecars) shell: bash run: | @@ -527,6 +578,9 @@ jobs: # SignPath path `signed/` already contains the signed binaries # but not the sidecars, on the no-SignPath path it's empty. cp unsigned/*.sig signed/ + # Unconditional, like the sidecars: SignPath emits only the signed + # binaries it was asked for, so the zip is never in its output. + cp portable/*.zip signed/ if [ "${{ steps.signpath.outputs.available }}" != "true" ]; then cp unsigned/*.msi unsigned/*.exe signed/ fi diff --git a/CHANGELOG.md b/CHANGELOG.md index 1c3ab51a..44e67086 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ Versions on the `0.0.X` line are public beta releases; `0.1.X` and onwards will ## [Unreleased] +### Added + +- **Install Entracte with Scoop on Windows.** `scoop bucket add entracte https://github.com/drmowinckels/entracte` then `scoop install entracte/entracte` — no installer, no SmartScreen prompt, and `entracte` lands on your `PATH` so the CLI works from any shell. Releases now also carry a portable `Entracte__x64-portable.zip` for anyone who wants the app without an installer at all; Scoop is what it is built for. The bucket tracks stable releases only, like the Homebrew cask, because a `scoop` install cannot see the in-app update-channel setting. ([#359](https://github.com/drmowinckels/entracte/issues/359)) + ## [0.0.13] — 2026-10-01 ### Added diff --git a/README.md b/README.md index 2a8ea5b8..200fc58c 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,13 @@ brew tap drmowinckels/entracte https://github.com/drmowinckels/entracte brew install --cask drmowinckels/entracte/entracte ``` +**Windows** — via Scoop (the bucket lives in this repo too): + +```powershell +scoop bucket add entracte https://github.com/drmowinckels/entracte +scoop install entracte/entracte +``` + **Linux / Windows** — download the `.deb`, `.rpm`, `.AppImage`, `.msi`, or `.exe` from the [Releases page](https://github.com/drmowinckels/entracte/releases) (pick the latest tag; pre-releases — the `0.0.X` beta line — don't surface under `releases/latest`). > **Windows users:** the `.msi` / `.exe` aren't code-signed yet — SignPath Foundation turned down our first application on visibility grounds (the project is too new). SmartScreen will warn you when you run the installer; click **More info → Run anyway** to proceed. See [the install guide](https://entracte.drmowinckels.io/guide/install#windows) for how you can [help us get there](https://entracte.drmowinckels.io/guide/install#help-us-get-windows-signed) — stars, forks, mentions, and contributions all count. diff --git a/bucket/README.md b/bucket/README.md new file mode 100644 index 00000000..decd4fc3 --- /dev/null +++ b/bucket/README.md @@ -0,0 +1,60 @@ +# Scoop bucket + +A [Scoop](https://scoop.sh/) bucket is just a repository with a `bucket/` +directory full of manifests, so Entracte's lives here rather than in a separate +`scoop-entracte` repo — one repo, no cross-repo token for CI to push with, and +the same arrangement the [Homebrew cask](../Casks/entracte.rb) already uses. +(Homebrew _requires_ a separate repo for a tap; Scoop does not, so this is the +closer parity, not a departure from it.) + +```powershell +scoop bucket add entracte https://github.com/drmowinckels/entracte +scoop install entracte/entracte +``` + +`scoop update entracte` picks up new releases. The shim is lowercase +`entracte`, matching the CLI (`entracte help`). + +## What it installs + +The portable `Entracte__x64-portable.zip` from the GitHub release, not +either Windows installer: Scoop extracts archives rather than running setup +programs, and the NSIS installer's own location would fight Scoop's +`~/scoop/apps` layout. + +The exe inside is not Authenticode-signed — the same status as every Windows +artifact Entracte ships until SignPath Foundation approves the project (see the +[install guide](https://entracte.drmowinckels.io/guide/install#windows)), and it +is not submitted for signing even once that lands, because SignPath's policy +covers the two bundles rather than an archive. Scoop verifies the SHA-256 in the manifest against the download, and that hash +comes from the release's own `SHA256SUMS.txt`. + +Your settings live in `%APPDATA%\io.drmowinckels.entracte`, outside the Scoop +app directory, so `scoop uninstall` leaves them alone. + +The one thing a portable install gives up is the installer's WebView2 +bootstrap. The manifest's `notes` say so: Windows 11 and current Windows 10 +already ship the runtime, and a `depends` on a bucket we do not control is +worse than a note. + +## Stable releases only + +The manifest tracks stable releases, like the cask does: `checkver` reads +`releases/latest`, which GitHub excludes prereleases from, and +`bump-scoop.yml` refuses a prerelease tag. A `scoop` install cannot see the +in-app update-channel setting, so it has no way to opt out of betas. To run +betas, install from the [release page](https://github.com/drmowinckels/entracte/releases) +and switch the channel in **Preferences → About**. + +## Maintenance + +`entracte.json` is generated, never hand-edited. +[`.github/workflows/bump-scoop.yml`](../.github/workflows/bump-scoop.yml) +regenerates it from +[`scripts/scoop-manifest.mjs`](../scripts/scoop-manifest.mjs) whenever a release +is _published_ (not when the tag is pushed — that produces a draft whose assets +404 for everyone but the maintainer) and commits the result straight to `main`. + +The manifest appears here after the first release that ships a portable zip; +until then this bucket is empty on purpose, rather than advertising a download +that 404s. diff --git a/docs/developer/releases.md b/docs/developer/releases.md index 680bcaf6..d412b6d4 100644 --- a/docs/developer/releases.md +++ b/docs/developer/releases.md @@ -86,7 +86,7 @@ The version arithmetic lives in [`scripts/next-beta-version.mjs`](https://github ### What betas do not touch -The Homebrew cask tracks stable only (`bump-cask.yml` skips prereleases): a `brew` install cannot see the in-app channel setting, so it has no way to opt out. Offering betas over Homebrew would need a separate `entracte-beta` cask. +The Homebrew cask tracks stable only (`bump-cask.yml` skips prereleases): a `brew` install cannot see the in-app channel setting, so it has no way to opt out. Offering betas over Homebrew would need a separate `entracte-beta` cask. The Scoop bucket is stable-only for exactly the same reason (`bump-scoop.yml` refuses prerelease tags, and its `checkver` reads `releases/latest`, which GitHub excludes prereleases from). ## Homebrew cask @@ -98,6 +98,14 @@ It commits directly rather than opening a PR because GitHub Actions is not permi To bump by hand — after a failure, or for a tag that predates the workflow — re-run it from the Actions tab via **Run workflow**, which takes the tag as an input. +## Scoop bucket + +Publishing also fires [`.github/workflows/bump-scoop.yml`](https://github.com/drmowinckels/entracte/blob/main/.github/workflows/bump-scoop.yml), the Windows counterpart ([#359](https://github.com/drmowinckels/entracte/issues/359)). It regenerates `bucket/entracte.json` from [`scripts/scoop-manifest.mjs`](https://github.com/drmowinckels/entracte/blob/main/scripts/scoop-manifest.mjs), reading the `Entracte__x64-portable.zip` checksum out of the release's `SHA256SUMS.txt`, and commits it straight to `main` — same reasoning, same failure-reports-itself step, same `ref: main` checkout (a `release: published` checkout defaults to the _tag_, which is behind `main` by everything merged since). + +Scoop's bucket is a `bucket/` directory in an ordinary repository, so unlike a Homebrew tap it needs no second repo and no cross-repo token. The manifest points at the portable zip rather than the `.msi` / `.exe`, because Scoop extracts archives rather than running installers. `build-windows-unsigned` builds that zip by staging `src-tauri/target/release/entracte.exe` as `Entracte.exe` next to the `LICENSE` and compressing it; the asset name is spelled in three places (`release.yml`, `bump-scoop.yml`, the generator) and [`src/test-fixtures/scoop-manifest.test.ts`](https://github.com/drmowinckels/entracte/blob/main/src/test-fixtures/scoop-manifest.test.ts) pins them together, because drift there is a 404 that only surfaces on a user's machine. + +The zip does not pass through SignPath: its signing policy covers the two bundles, so the exe inside stays Authenticode-unsigned even once SignPath is approved. That matches what every Windows artifact ships as today, and the install guide says so. + ## What the workflow does Three jobs in [`.github/workflows/release.yml`](https://github.com/drmowinckels/entracte/blob/main/.github/workflows/release.yml): diff --git a/docs/guide/install.md b/docs/guide/install.md index 7ec61484..3369e385 100644 --- a/docs/guide/install.md +++ b/docs/guide/install.md @@ -32,6 +32,7 @@ brew install --cask drmowinckels/entracte/entracte - `Entracte__x64-setup.exe` — NSIS installer - `Entracte__x64_en-US.msi` — MSI for managed deployment +- `Entracte__x64-portable.zip` — the bare app, no installer (what Scoop installs) ::: warning Currently unsigned Windows installers are **not code-signed yet**. When you run the installer, Windows SmartScreen will show a blue "Windows protected your PC" dialog naming an "unknown publisher". To continue: click **More info**, then **Run anyway**. @@ -43,6 +44,23 @@ We applied to the [SignPath Foundation](https://signpath.org/) free OSS code-sig Double-click the installer; once past the SmartScreen prompt, the standard Windows installation wizard takes over. +#### Scoop + +The project ships its own [Scoop](https://scoop.sh/) bucket, hosted in this repo — a bucket is just a repository with a `bucket/` directory, so no second repo is needed: + +```powershell +scoop bucket add entracte https://github.com/drmowinckels/entracte +scoop install entracte/entracte +``` + +`scoop update entracte` handles updates. Scoop installs the portable zip rather than either installer — it extracts archives instead of running setup programs — and puts the CLI on `PATH` as `entracte`, so `entracte pause 30m` and friends work from any shell. Scoop also verifies the download against the SHA-256 in the manifest, which is read from the release's own `SHA256SUMS.txt`; the exe itself is unsigned, same as the installers above, so no SmartScreen prompt appears but no publisher is attested either. + +Your settings live in `%APPDATA%\io.drmowinckels.entracte`, outside Scoop's app directory, so `scoop uninstall entracte` leaves them in place. + +One caveat of a portable install: the NSIS installer bootstraps the Microsoft Edge WebView2 Runtime, and extracting an archive cannot. Windows 11 and up-to-date Windows 10 already have it; if Entracte's window opens blank, install the [WebView2 Runtime](https://developer.microsoft.com/microsoft-edge/webview2/) and relaunch. + +Like the Homebrew cask, the bucket tracks **stable releases only** — a `scoop` install cannot see the in-app update-channel setting, so it has no way to opt out of betas. For betas, install from the [release page](https://github.com/drmowinckels/entracte/releases) and switch the channel in **Preferences → About**. + #### Help us get Windows signed SignPath Foundation rejected our first application on the grounds that Entracte doesn't yet show enough public adoption to qualify. They don't judge the code — they look at the project's external footprint. Concrete things that move the needle: @@ -51,7 +69,7 @@ SignPath Foundation rejected our first application on the grounds that Entracte - 🗣️ Talk about it where you hang out — Reddit (r/macapps, r/windows, r/productivity), Mastodon, Bluesky, blog posts, YouTube, Hacker News. Independent mentions are weighted heavily. - 🐛 [File a bug](https://github.com/drmowinckels/entracte/issues/new?template=bug_report.yml), [request a feature](https://github.com/drmowinckels/entracte/issues/new?template=feature_request.yml), or [send some praise](https://github.com/drmowinckels/entracte/issues/new?template=praise.yml) — engagement counts. - 🔧 [Contribute a fix](https://github.com/drmowinckels/entracte/blob/main/CONTRIBUTING.md) — being able to point at a contributor list demonstrates a real community. -- 📦 If you maintain a package repo (Scoop, Chocolatey, winget), packaging Entracte for it adds another data point. +- 📦 If you maintain a package repo (Chocolatey, winget — [Scoop is covered](#scoop)), packaging Entracte for it adds another data point. Once we have evidence to satisfy SignPath's criteria, we'll reapply. The CI signing pipeline is already wired up — the day approval comes through, the very next release ships signed with no code changes required. The bring-up notes live in [.github/SIGNPATH_SETUP.md](https://github.com/drmowinckels/entracte/blob/main/.github/SIGNPATH_SETUP.md). diff --git a/scripts/scoop-manifest.d.mts b/scripts/scoop-manifest.d.mts new file mode 100644 index 00000000..af314b78 --- /dev/null +++ b/scripts/scoop-manifest.d.mts @@ -0,0 +1,23 @@ +// See set-version.d.mts for why these declarations exist. + +export declare function assetName(version: string): string; + +export interface ScoopManifest { + version: string; + description: string; + homepage: string; + license: string; + architecture: { "64bit": { url: string; hash: string } }; + bin: string[][]; + shortcuts: string[][]; + notes: string[]; + checkver: { url: string; regex: string }; + autoupdate: { architecture: { "64bit": { url: string } } }; +} + +export declare function buildManifest(input: { + version: string; + hash: string; +}): ScoopManifest; + +export declare function renderManifest(manifest: ScoopManifest): string; diff --git a/scripts/scoop-manifest.mjs b/scripts/scoop-manifest.mjs new file mode 100644 index 00000000..c6edc548 --- /dev/null +++ b/scripts/scoop-manifest.mjs @@ -0,0 +1,107 @@ +#!/usr/bin/env node +// Generates bucket/entracte.json, the Scoop manifest for the in-repo bucket +// (#359). +// +// Kept as a pure function over (version, hash) so the couplings that only +// break on a user's machine can be unit-tested: nothing in the build reads +// this manifest, so a wrong URL, a renamed binary, or a stale asset name is a +// 404 nobody here would see. +// +// Usage: +// node scripts/scoop-manifest.mjs 0.0.14 + +import { writeFileSync } from "node:fs"; +import { argv } from "node:process"; +import { pathToFileURL } from "node:url"; + +const REPO = "https://github.com/drmowinckels/entracte"; +const API = "https://api.github.com/repos/drmowinckels/entracte"; + +// Stable releases only — the same policy the Homebrew cask follows +// (bump-cask.yml refuses prerelease tags). A `scoop` install cannot see the +// in-app update-channel setting, so pushing a beta through this bucket would +// give its users no way to opt out. `checkver` reads `releases/latest`, which +// GitHub already excludes prereleases from, and this regex is shared with the +// manifest generator so the two can never disagree about what a version +// looks like. +const VERSION = String.raw`\d+\.\d+\.\d+`; + +/** + * The portable-zip asset `release.yml` uploads. Scoop extracts an archive + * rather than running an installer, so the bucket points here and not at the + * NSIS setup or the MSI. + * + * Called with the literal version for assertions, with `${version}` to match + * the shell/pwsh spelling in the workflows, and with `$version` for Scoop's + * `autoupdate` template — the name has to stay a pure function of the version + * for that last one to work. + */ +export const assetName = (version) => `Entracte_${version}_x64-portable.zip`; + +const downloadUrl = (tag, file) => `${REPO}/releases/download/${tag}/${file}`; + +export function buildManifest({ version, hash }) { + if (!new RegExp(`^${VERSION}$`).test(version)) { + throw new Error( + `not a bare stable version (drop any leading "v"; the bucket tracks stable releases only): ${version}`, + ); + } + if (!/^[0-9a-f]{64}$/.test(hash)) { + throw new Error(`not a lowercase sha256 digest: ${hash}`); + } + return { + version, + description: + "Cross-platform break reminder named after the theatre interval between acts", + homepage: REPO, + license: "Apache-2.0", + architecture: { + "64bit": { + url: downloadUrl(`v${version}`, assetName(version)), + hash, + }, + }, + // A plain `"Entracte.exe"` would shim as `Entracte`; the pair names the + // shim `entracte`, matching the CLI the README documents and the + // `binary` line in the Homebrew cask. + bin: [["Entracte.exe", "entracte"]], + shortcuts: [["Entracte.exe", "Entracte"]], + notes: [ + "Entracte runs from the tray; `entracte help` lists the CLI.", + // The NSIS installer bootstraps WebView2; a plain extraction cannot. + // Windows 11 and up-to-date Windows 10 already ship the runtime, so this + // is a note rather than a `depends` on a bucket we do not control. + "Needs the Microsoft Edge WebView2 Runtime, preinstalled on Windows 11 and on current Windows 10. If the window stays blank, install it from https://developer.microsoft.com/microsoft-edge/webview2/", + "Your settings live in %APPDATA%\\io.drmowinckels.entracte, outside the Scoop app directory — uninstalling leaves them in place.", + ], + checkver: { + url: `${API}/releases/latest`, + regex: String.raw`"tag_name":\s*"v(${VERSION})"`, + }, + autoupdate: { + architecture: { + "64bit": { + url: downloadUrl("v$version", assetName("$version")), + }, + }, + }, + }; +} + +export const renderManifest = (manifest) => + `${JSON.stringify(manifest, null, 4)}\n`; + +if (argv[1] && import.meta.url === pathToFileURL(argv[1]).href) { + const [version, hash] = argv.slice(2); + if (!version || !hash) { + throw new Error("usage: node scripts/scoop-manifest.mjs "); + } + // Resolved against this module, not the process CWD: the manifest has one + // home, and a run from the wrong directory should not quietly write a + // second one somewhere else. + writeFileSync( + new URL("../bucket/entracte.json", import.meta.url), + renderManifest(buildManifest({ version, hash })), + "utf8", + ); +} diff --git a/src/test-fixtures/scoop-manifest.test.ts b/src/test-fixtures/scoop-manifest.test.ts new file mode 100644 index 00000000..7a7d5184 --- /dev/null +++ b/src/test-fixtures/scoop-manifest.test.ts @@ -0,0 +1,113 @@ +import { describe, it, expect } from "vitest"; +import { readFileSync } from "node:fs"; + +import { + assetName, + buildManifest, + renderManifest, +} from "../../scripts/scoop-manifest.mjs"; + +const release = readFileSync(".github/workflows/release.yml", "utf8"); +const bump = readFileSync(".github/workflows/bump-scoop.yml", "utf8"); + +const HASH = "a".repeat(64); +const manifest = buildManifest({ version: "0.0.14", hash: HASH }); + +/** + * A Scoop manifest only ever fails on a user's machine: nothing in the build + * reads it, so a wrong URL, a renamed binary, or a stale asset name surfaces + * as `scoop install` exploding for someone else (#359). These tests pin the + * couplings that span files — the asset the release workflow uploads, the name + * inside the zip, and the URL `autoupdate` reconstructs. + */ +describe("Scoop manifest (#359)", () => { + it("points at the asset the release workflow actually uploads", () => { + // Three files spell this name independently — release.yml (which builds + // and uploads it), bump-scoop.yml (which looks its checksum up in + // SHA256SUMS.txt), and the manifest that tells Scoop where to get it. + // `${version}` is the spelling both workflows use: pwsh and bash agree on + // it, and the braces are required because `$version_x64` would otherwise + // parse as one variable name. + expect(release).toContain(assetName("${version}")); + expect(bump).toContain(assetName("${version}")); + expect(manifest.architecture["64bit"].url).toBe( + "https://github.com/drmowinckels/entracte/releases/download/v0.0.14/Entracte_0.0.14_x64-portable.zip", + ); + }); + + it("names the binary the zip actually contains", () => { + // release.yml stages the Cargo output (`entracte.exe`) under the product + // name before zipping, so `bin`/`shortcuts` must match that name. + expect(release).toMatch(/Copy-Item .*release.entracte\.exe.*Entracte\.exe/); + expect(manifest.shortcuts).toEqual([["Entracte.exe", "Entracte"]]); + }); + + it("shims the CLI as lowercase `entracte`, like the cask's binary", () => { + // `bin: "Entracte.exe"` would shim as `Entracte`; the documented CLI is + // `entracte …`, and the Homebrew cask links the binary under that name. + expect(manifest.bin).toEqual([["Entracte.exe", "entracte"]]); + }); + + it("warns about the WebView2 bootstrap a portable install gives up", () => { + // The NSIS installer installs the runtime if it is missing; extracting a + // zip cannot, and the symptom is a blank window rather than an error. + expect(manifest.notes.join(" ")).toMatch(/WebView2/); + }); + + it("rebuilds the same URL from $version alone, for autoupdate", () => { + const template = manifest.autoupdate.architecture["64bit"].url; + expect(template.replace(/\$version/g, "0.0.14")).toBe( + manifest.architecture["64bit"].url, + ); + }); + + it("tracks stable releases only, with checkver agreeing", () => { + // `releases/latest` excludes prereleases, which is the whole point: a + // `scoop` install cannot see the in-app channel setting, so it must not be + // offered betas. The regex has to accept exactly the shape buildManifest + // accepts, or Scoop reads a truncated version back out of a tag. + expect(manifest.checkver.url).toContain("releases/latest"); + const tags = new RegExp(manifest.checkver.regex); + expect('"tag_name": "v0.1.2",'.match(tags)?.[1]).toBe("0.1.2"); + expect('"tag_name": "v0.1.2-beta.1",'.match(tags)).toBeNull(); + expect(() => + buildManifest({ version: "0.1.2-beta.1", hash: HASH }), + ).toThrow(/stable releases only/); + }); + + it("is bumped by a workflow rather than by hand", () => { + expect(bump).toContain("scripts/scoop-manifest.mjs"); + expect(bump).toContain("bucket/entracte.json"); + // The cask's lesson (#349): Actions cannot open PRs on this repo, and a + // release-triggered checkout defaults to the tag, not main. + expect(bump).toMatch(/ref: main/); + expect(bump).not.toContain("gh pr create"); + // Staging before the emptiness check — `git diff` reports no change for a + // file git has never tracked, which would make the first release a no-op. + expect(bump).toMatch( + /git add bucket\/entracte\.json\s+if git diff --cached --quiet/, + ); + }); + + it("is bumped only once the release is published", () => { + // The tag push produces a DRAFT release, whose assets 404 for everyone but + // the maintainer. Bumping then would publish a download nobody can fetch. + expect(bump).toMatch(/release:\n {4}types: \[published\]/); + }); + + it("writes JSON Scoop can parse", () => { + expect(JSON.parse(renderManifest(manifest))).toEqual(manifest); + }); + + it("refuses a version or digest Scoop would choke on", () => { + expect(() => buildManifest({ version: "v0.1.0", hash: HASH })).toThrow( + /leading "v"/, + ); + expect(() => buildManifest({ version: "0.1.0", hash: "nope" })).toThrow( + /sha256/, + ); + expect(() => + buildManifest({ version: "0.1.0", hash: HASH.toUpperCase() }), + ).toThrow(/sha256/); + }); +}); From ea6c903586d43ef5827193ec811c60826f52f539 Mon Sep 17 00:00:00 2001 From: Athanasia Monika Mowinckel Date: Thu, 1 Oct 2026 18:16:15 +0200 Subject: [PATCH 2/5] feat(docs): list the portable zip in the download picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The install page's "All platforms & formats" list is curated by `download-detect.ts`'s RULES, not derived from the release's asset list, so a new artifact that matches no rule is silently invisible there — while install.md now names it. Ranked last for Windows: right for Scoop and for anyone who wants no installer, but it does not bootstrap WebView2. Co-Authored-By: Claude Opus 5 (1M context) --- docs/.vitepress/theme/components/download-detect.test.ts | 7 ++++++- docs/.vitepress/theme/components/download-detect.ts | 4 ++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/docs/.vitepress/theme/components/download-detect.test.ts b/docs/.vitepress/theme/components/download-detect.test.ts index 84b41008..5faab1db 100644 --- a/docs/.vitepress/theme/components/download-detect.test.ts +++ b/docs/.vitepress/theme/components/download-detect.test.ts @@ -18,6 +18,10 @@ const assets: Asset[] = [ { name: "Entracte_0.0.10_x64.dmg", url: `${BASE}/Entracte_0.0.10_x64.dmg` }, { name: "Entracte_0.0.10_x64-setup.exe", url: `${BASE}/Entracte_0.0.10_x64-setup.exe` }, { name: "Entracte_0.0.10_x64_en-US.msi", url: `${BASE}/Entracte_0.0.10_x64_en-US.msi` }, + { + name: "Entracte_0.0.10_x64-portable.zip", + url: `${BASE}/Entracte_0.0.10_x64-portable.zip`, + }, { name: "Entracte_0.0.10_amd64.AppImage", url: `${BASE}/Entracte_0.0.10_amd64.AppImage` }, { name: "Entracte_0.0.10_amd64.deb", url: `${BASE}/Entracte_0.0.10_amd64.deb` }, { name: "Entracte-0.0.10-1.x86_64.rpm", url: `${BASE}/Entracte-0.0.10-1.x86_64.rpm` }, @@ -28,7 +32,7 @@ const assets: Asset[] = [ ]; describe("classify", () => { - it("recognises exactly the seven installer artefacts", () => { + it("recognises exactly the eight installer artefacts", () => { const got = classify(assets); expect(got.map((i) => i.name).sort()).toEqual( [ @@ -39,6 +43,7 @@ describe("classify", () => { "Entracte_0.0.10_x64-setup.exe", "Entracte_0.0.10_x64.dmg", "Entracte_0.0.10_x64_en-US.msi", + "Entracte_0.0.10_x64-portable.zip", ].sort(), ); }); diff --git a/docs/.vitepress/theme/components/download-detect.ts b/docs/.vitepress/theme/components/download-detect.ts index 8f781fc8..40da5b66 100644 --- a/docs/.vitepress/theme/components/download-detect.ts +++ b/docs/.vitepress/theme/components/download-detect.ts @@ -46,6 +46,10 @@ const RULES: { { test: /_x64\.dmg$/, os: "macos", rank: 1, label: "Intel", arch: "x64" }, { test: /-setup\.exe$/, os: "windows", rank: 0, label: "Installer (.exe)" }, { test: /\.msi$/, os: "windows", rank: 1, label: "MSI" }, + // The portable zip the Scoop bucket installs (#359). Ranked last for + // Windows: it is the right choice for Scoop and for anyone who wants no + // installer, but it does not bootstrap the WebView2 runtime. + { test: /_x64-portable\.zip$/, os: "windows", rank: 2, label: "Portable (.zip)" }, { test: /\.AppImage$/, os: "linux", rank: 0, label: "AppImage" }, { test: /\.deb$/, os: "linux", rank: 1, label: "Debian / Ubuntu (.deb)" }, { test: /\.rpm$/, os: "linux", rank: 2, label: "Fedora / openSUSE (.rpm)" }, From 81316ac1f23a5e4900b27f30612933b57b01d30f Mon Sep 17 00:00:00 2001 From: Athanasia Monika Mowinckel Date: Thu, 1 Oct 2026 18:38:13 +0200 Subject: [PATCH 3/5] fix(packaging): keep the Scoop path off the release's critical path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review findings on the Scoop channel, in the order they would have bitten: * **A zip failure would have sunk signed Windows distribution.** The packaging step sat before the `windows-unsigned` upload, so any error in it failed `build-windows-unsigned`, which skipped `sign-windows` via `needs:` and left the release with no `.msi`, no `.exe` and no `.sig`. Both Scoop steps now run last in the job and are `continue-on-error`, the artifact download in `sign-windows` likewise, and the copy tolerates a missing zip with a warning. `bump-scoop.yml`'s "did the release build the portable zip?" guard is the alarm — which is what it was written for. * **Nothing exercised the pwsh step before a real release.** `audit:workflow-shell` parses bash only, so a wrong exe path or an empty archive would have surfaced at release time with the bundles already built — the AppImage-icon class of bug this repo has already been bitten by. `build-preview.yml` now packages the same zip from the debug build on every PR and asserts `Entracte.exe` is at its root. * **The CLI claim was not true on Windows.** The release binary is `windows_subsystem = "windows"`, so it never attaches to the calling console: `entracte pause 30m` lands over IPC, but `help`, `status` and every error message print into a closed handle. Putting `entracte` on `PATH` is the first thing that made this reachable. Fixing it properly means an `AttachConsole` shim plus the pure-function test the coverage rule asks for — out of scope for a packaging PR, so it is tracked as #364 and the docs now say what actually happens instead of overselling it. * **The failure reporter could not file its issue.** `gh issue` had no base repository: the only step that can fail before the checkout is the tag validation, and at that point there is no git remote to infer one from, so the loud-failure mechanism was itself silent. `GH_REPO` added here and in `bump-cask.yml`, which has the identical shape. * `bucket/` is only tracked by virtue of its README, so the generator creates the directory rather than ENOENT-ing at release time if that ever moves. * The install instructions now say the manifest arrives with the first stable release that ships the zip, rather than publishing commands that report no manifest the day this merges. Also from review: `$schema` for editor/lint validation, an `autoupdate` hash read from the release's `SHA256SUMS.txt` instead of re-downloading the archive to digest it, the tag piped through `env` in the download step like its sibling, and the SmartScreen wording softened to what is actually guaranteed. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/build-preview.yml | 27 ++++++++++++ .github/workflows/bump-cask.yml | 5 +++ .github/workflows/bump-scoop.yml | 11 +++-- .github/workflows/release.yml | 53 +++++++++++++++++------- CHANGELOG.md | 2 +- README.md | 2 +- bucket/README.md | 9 +++- docs/developer/releases.md | 2 + docs/guide/cli.md | 4 ++ docs/guide/install.md | 8 +++- scripts/scoop-manifest.d.mts | 5 ++- scripts/scoop-manifest.mjs | 26 ++++++++---- src/test-fixtures/scoop-manifest.test.ts | 22 ++++++++++ 13 files changed, 145 insertions(+), 31 deletions(-) diff --git a/.github/workflows/build-preview.yml b/.github/workflows/build-preview.yml index b8d9e13d..533dae91 100644 --- a/.github/workflows/build-preview.yml +++ b/.github/workflows/build-preview.yml @@ -98,6 +98,33 @@ jobs: echo '```' } >> "$GITHUB_STEP_SUMMARY" + # Guard the Scoop portable zip (#359). The release job packages it with + # pwsh, which `audit:workflow-shell` cannot check (it parses bash only) + # and which nothing else runs before a real release — so a wrong exe + # path or an empty archive would first surface at release time, with the + # Windows bundles already built. Same class of bug as the AppImage icon + # below: invisible unless something looks inside the archive. + # + # `--debug` here, so the exe is under target/debug. + - name: Verify the portable zip can be packaged + if: matrix.platform == 'windows-latest' + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $stage = Join-Path $env:RUNNER_TEMP 'portable-check' + New-Item -ItemType Directory -Force -Path $stage | Out-Null + Copy-Item src-tauri/target/debug/entracte.exe (Join-Path $stage 'Entracte.exe') + Copy-Item LICENSE $stage + $zip = Join-Path $env:RUNNER_TEMP 'portable-check.zip' + Compress-Archive -Path "$stage/*" -DestinationPath $zip -Force + Add-Type -AssemblyName System.IO.Compression.FileSystem + $archive = [IO.Compression.ZipFile]::OpenRead($zip) + try { $names = $archive.Entries.FullName } finally { $archive.Dispose() } + Write-Output "portable zip contents: $($names -join ', ')" + if ($names -notcontains 'Entracte.exe') { + throw "no Entracte.exe at the zip root - the Scoop manifest bin/shortcuts would break" + } + # Guard the AppImage's required AppDir files. tauri-bundler < 2.9.4 # wrote `.DirIcon` and the `.desktop` entry as ABSOLUTE symlinks # pointing into the build machine's AppDir, so once the AppImage is diff --git a/.github/workflows/bump-cask.yml b/.github/workflows/bump-cask.yml index 79e1ac4f..257caf94 100644 --- a/.github/workflows/bump-cask.yml +++ b/.github/workflows/bump-cask.yml @@ -211,6 +211,11 @@ jobs: if: failure() env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # Without GH_REPO, `gh issue` has no base repository to resolve: the + # only step that can fail before the checkout is the tag validation + # above, and at that point there is no git remote to infer one from — + # so the loud-failure mechanism would itself be silent. + GH_REPO: ${{ github.repository }} TAG: ${{ steps.meta.outputs.tag }} RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} run: | diff --git a/.github/workflows/bump-scoop.yml b/.github/workflows/bump-scoop.yml index 3f3f4a87..06c4a4ca 100644 --- a/.github/workflows/bump-scoop.yml +++ b/.github/workflows/bump-scoop.yml @@ -98,10 +98,10 @@ jobs: - name: Fetch SHA256SUMS.txt env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} + TAG: ${{ steps.meta.outputs.tag }} run: | - gh release download "${{ steps.meta.outputs.tag }}" \ - --repo "${{ github.repository }}" \ - --pattern "SHA256SUMS.txt" + gh release download "$TAG" --pattern "SHA256SUMS.txt" - name: Regenerate bucket/entracte.json env: @@ -185,6 +185,11 @@ jobs: if: failure() env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # Without GH_REPO, `gh issue` has no base repository to resolve: the + # only step that can fail before the checkout is the tag validation + # above, and at that point there is no git remote to infer one from — + # so the loud-failure mechanism would itself be silent. + GH_REPO: ${{ github.repository }} TAG: ${{ steps.meta.outputs.tag }} RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} run: | diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0dec820f..045b07d6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -307,6 +307,17 @@ jobs: cp src-tauri/target/release/bundle/msi/*.msi* unsigned/ cp src-tauri/target/release/bundle/nsis/*.exe* unsigned/ + # SHA-pinned: this action stages the unsigned Windows bundles + # for `sign-windows` to consume. A compromised upstream tag + # could swap in artifacts that SignPath then signs and ships. + # Update via dependabot.yml. + - id: upload + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: windows-unsigned + path: unsigned/ + if-no-files-found: error + # Portable zip for the Scoop bucket (#359). Scoop extracts an archive # rather than running an installer, so neither Windows bundle suits it: # pointing it at the NSIS setup would shell out to the installer, whose @@ -315,8 +326,9 @@ jobs: # self-contained exe (WebView2 comes from the OS), so staging the Cargo # output under the product name and zipping it is the whole job. # - # The rename matters: Cargo emits `entracte.exe`, and the manifest's - # `bin`/`shortcuts` name what is inside the zip. + # The rename matters: Cargo emits `entracte.exe` (no `mainBinaryName` in + # tauri.conf.json, so tauri-bundler leaves the Cargo name alone), and the + # manifest's `bin`/`shortcuts` name what is inside the zip. # # Uploaded as its own artifact rather than added to `windows-unsigned`, # which is submitted to SignPath — its signing policy covers the `.msi` @@ -325,8 +337,17 @@ jobs: # consequence is that the exe inside the zip stays Authenticode-unsigned # even once SignPath is wired; that is the same status every Windows # artifact has today, and the install docs say so. + # + # Deliberately LAST in this job, and `continue-on-error`: everything + # above it produces the signed Windows distribution. A Scoop + # nice-to-have must not be able to fail this job, because that would + # skip `sign-windows` entirely and leave the release with no `.msi`, no + # `.exe` and no `.sig` at all. When it does fail, `bump-scoop.yml` + # notices the missing checksum and files an issue — that is what its + # "did the release build the portable zip?" guard is for. - name: Package the portable zip shell: pwsh + continue-on-error: true env: REF_NAME: ${{ github.ref_name }} run: | @@ -344,22 +365,12 @@ jobs: # SHA-pinned. Update via dependabot.yml. - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + continue-on-error: true with: name: windows-portable path: portable/ if-no-files-found: error - # SHA-pinned: this action stages the unsigned Windows bundles - # for `sign-windows` to consume. A compromised upstream tag - # could swap in artifacts that SignPath then signs and ships. - # Update via dependabot.yml. - - id: upload - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: windows-unsigned - path: unsigned/ - if-no-files-found: error - # Compose the signed `latest.json` manifest that `tauri-plugin-updater` # fetches from the configured endpoint. macOS `.app.tar.gz`, Linux # `.AppImage`, and Windows `.msi` all ship with Tauri-signed `.sig` @@ -563,8 +574,12 @@ jobs: # The Scoop portable zip (#359). It never goes through SignPath, so it # rides along here to land on the same draft release — which also puts it # in `SHA256SUMS.txt`, where bump-scoop.yml reads its hash. + # + # `continue-on-error`, matching the packaging step: if the zip was not + # built, that must not cost the release its signed `.msi` / `.exe`. # SHA-pinned. Update via dependabot. - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + continue-on-error: true with: name: windows-portable path: portable @@ -579,8 +594,16 @@ jobs: # but not the sidecars, on the no-SignPath path it's empty. cp unsigned/*.sig signed/ # Unconditional, like the sidecars: SignPath emits only the signed - # binaries it was asked for, so the zip is never in its output. - cp portable/*.zip signed/ + # binaries it was asked for, so the zip is never in its output. A + # missing zip warns rather than failing the step — see the + # `continue-on-error` note on the packaging step. + shopt -s nullglob + zips=(portable/*.zip) + if [ ${#zips[@]} -gt 0 ]; then + cp "${zips[@]}" signed/ + else + echo "::warning::no portable zip on this run — the Scoop manifest bump will fail and file an issue" + fi if [ "${{ steps.signpath.outputs.available }}" != "true" ]; then cp unsigned/*.msi unsigned/*.exe signed/ fi diff --git a/CHANGELOG.md b/CHANGELOG.md index 44e67086..d0c7cd68 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ Versions on the `0.0.X` line are public beta releases; `0.1.X` and onwards will ### Added -- **Install Entracte with Scoop on Windows.** `scoop bucket add entracte https://github.com/drmowinckels/entracte` then `scoop install entracte/entracte` — no installer, no SmartScreen prompt, and `entracte` lands on your `PATH` so the CLI works from any shell. Releases now also carry a portable `Entracte__x64-portable.zip` for anyone who wants the app without an installer at all; Scoop is what it is built for. The bucket tracks stable releases only, like the Homebrew cask, because a `scoop` install cannot see the in-app update-channel setting. ([#359](https://github.com/drmowinckels/entracte/issues/359)) +- **Install Entracte with Scoop on Windows.** `scoop bucket add entracte https://github.com/drmowinckels/entracte` then `scoop install entracte/entracte` — no installer to click through, and `entracte` lands on your `PATH`, so `entracte pause 30m` works from any shell. (Commands that print something back are still silent on Windows; that is [#364](https://github.com/drmowinckels/entracte/issues/364).) Releases now also carry a portable `Entracte__x64-portable.zip` for anyone who wants the app without an installer at all; Scoop is what it is built for. The bucket tracks stable releases only, like the Homebrew cask, because a `scoop` install cannot see the in-app update-channel setting. ([#359](https://github.com/drmowinckels/entracte/issues/359)) ## [0.0.13] — 2026-10-01 diff --git a/README.md b/README.md index 200fc58c..040b5c2b 100644 --- a/README.md +++ b/README.md @@ -77,7 +77,7 @@ brew tap drmowinckels/entracte https://github.com/drmowinckels/entracte brew install --cask drmowinckels/entracte/entracte ``` -**Windows** — via Scoop (the bucket lives in this repo too): +**Windows** — via Scoop (the bucket lives in this repo too), from the next stable release onwards; the manifest is generated by the first release that ships the portable zip, so until then use the installer below: ```powershell scoop bucket add entracte https://github.com/drmowinckels/entracte diff --git a/bucket/README.md b/bucket/README.md index decd4fc3..fcb45b56 100644 --- a/bucket/README.md +++ b/bucket/README.md @@ -13,7 +13,14 @@ scoop install entracte/entracte ``` `scoop update entracte` picks up new releases. The shim is lowercase -`entracte`, matching the CLI (`entracte help`). +`entracte`, matching the CLI, so `entracte pause 30m` reaches the running app +from any shell — but commands that print something back are silent on Windows +until [#364](https://github.com/drmowinckels/entracte/issues/364) lands, because +the release binary is GUI-subsystem and never attaches to the calling console. + +`entracte.json` is generated by the first stable release that ships the portable +zip, so until then `scoop install` reports no manifest (see +[Maintenance](#maintenance)). ## What it installs diff --git a/docs/developer/releases.md b/docs/developer/releases.md index d412b6d4..3ee74f64 100644 --- a/docs/developer/releases.md +++ b/docs/developer/releases.md @@ -104,6 +104,8 @@ Publishing also fires [`.github/workflows/bump-scoop.yml`](https://github.com/dr Scoop's bucket is a `bucket/` directory in an ordinary repository, so unlike a Homebrew tap it needs no second repo and no cross-repo token. The manifest points at the portable zip rather than the `.msi` / `.exe`, because Scoop extracts archives rather than running installers. `build-windows-unsigned` builds that zip by staging `src-tauri/target/release/entracte.exe` as `Entracte.exe` next to the `LICENSE` and compressing it; the asset name is spelled in three places (`release.yml`, `bump-scoop.yml`, the generator) and [`src/test-fixtures/scoop-manifest.test.ts`](https://github.com/drmowinckels/entracte/blob/main/src/test-fixtures/scoop-manifest.test.ts) pins them together, because drift there is a 404 that only surfaces on a user's machine. +Both the packaging step and its artifact upload are `continue-on-error`, and they are the last steps in the job on purpose: everything before them produces the _signed_ Windows distribution. If a Scoop nice-to-have could fail that job, `sign-windows` would be skipped by `needs:` and the release would end up with no `.msi`, no `.exe` and no `.sig` at all. When the zip is missing, `bump-scoop.yml`'s "did the release build the portable zip?" guard is the alarm. [`build-preview.yml`](https://github.com/drmowinckels/entracte/blob/main/.github/workflows/build-preview.yml) packages the same zip from the debug build on every PR and asserts `Entracte.exe` sits at its root, because `audit:workflow-shell` parses bash only and would never see a broken `pwsh` step. + The zip does not pass through SignPath: its signing policy covers the two bundles, so the exe inside stays Authenticode-unsigned even once SignPath is approved. That matches what every Windows artifact ships as today, and the install guide says so. ## What the workflow does diff --git a/docs/guide/cli.md b/docs/guide/cli.md index 41b1ecd6..dd264468 100644 --- a/docs/guide/cli.md +++ b/docs/guide/cli.md @@ -2,6 +2,10 @@ The `entracte` binary doubles as a small CLI. The tray app starts when you launch it with no arguments; CLI commands forward to the already-running instance via the [single-instance plugin](https://v2.tauri.app/plugin/single-instance/), so you can wire up shortcuts, scripts, or editor commands without juggling a separate daemon. +::: warning Windows: commands work, output does not +The Windows build is compiled as a GUI binary (so launching the tray app never flashes a console window), and a GUI binary does not attach to the console it was started from. Action commands like `entracte pause 30m` still reach the running app, but nothing is printed back — `entracte help`, `entracte status` and the other query commands return silently, and so do error messages. Tracked in [#364](https://github.com/drmowinckels/entracte/issues/364); until it is fixed, treat the CLI as macOS/Linux for anything you need to read. +::: + ## Synopsis ```sh diff --git a/docs/guide/install.md b/docs/guide/install.md index 3369e385..53b7459e 100644 --- a/docs/guide/install.md +++ b/docs/guide/install.md @@ -53,7 +53,13 @@ scoop bucket add entracte https://github.com/drmowinckels/entracte scoop install entracte/entracte ``` -`scoop update entracte` handles updates. Scoop installs the portable zip rather than either installer — it extracts archives instead of running setup programs — and puts the CLI on `PATH` as `entracte`, so `entracte pause 30m` and friends work from any shell. Scoop also verifies the download against the SHA-256 in the manifest, which is read from the release's own `SHA256SUMS.txt`; the exe itself is unsigned, same as the installers above, so no SmartScreen prompt appears but no publisher is attested either. +::: warning Arrives with the next stable release +The bucket's manifest is generated by the first stable release that ships the portable zip, so until that release is published `scoop install` reports that it cannot find a manifest. Use the `.exe` or `.msi` above in the meantime. +::: + +`scoop update entracte` handles updates. Scoop installs the portable zip rather than either installer — it extracts archives instead of running setup programs — and puts `entracte` on `PATH` via its shim, so `entracte pause 30m` and `entracte resume` work from any shell. (Commands that print something back, like `entracte status`, stay silent on Windows for now — see the [CLI page](/guide/cli).) Scoop also verifies the download against the SHA-256 in the manifest, which is read from the release's own `SHA256SUMS.txt`. + +The exe is unsigned, same as the installers above. You skip the installer's "Windows protected your PC" dialog because there is no installer to run, but Windows may still warn the first time you launch the app. Your settings live in `%APPDATA%\io.drmowinckels.entracte`, outside Scoop's app directory, so `scoop uninstall entracte` leaves them in place. diff --git a/scripts/scoop-manifest.d.mts b/scripts/scoop-manifest.d.mts index af314b78..fa14967a 100644 --- a/scripts/scoop-manifest.d.mts +++ b/scripts/scoop-manifest.d.mts @@ -3,6 +3,7 @@ export declare function assetName(version: string): string; export interface ScoopManifest { + $schema: string; version: string; description: string; homepage: string; @@ -12,7 +13,9 @@ export interface ScoopManifest { shortcuts: string[][]; notes: string[]; checkver: { url: string; regex: string }; - autoupdate: { architecture: { "64bit": { url: string } } }; + autoupdate: { + architecture: { "64bit": { url: string; hash: { url: string } } }; + }; } export declare function buildManifest(input: { diff --git a/scripts/scoop-manifest.mjs b/scripts/scoop-manifest.mjs index c6edc548..5e0e997a 100644 --- a/scripts/scoop-manifest.mjs +++ b/scripts/scoop-manifest.mjs @@ -10,7 +10,7 @@ // Usage: // node scripts/scoop-manifest.mjs 0.0.14 -import { writeFileSync } from "node:fs"; +import { mkdirSync, writeFileSync } from "node:fs"; import { argv } from "node:process"; import { pathToFileURL } from "node:url"; @@ -50,6 +50,8 @@ export function buildManifest({ version, hash }) { throw new Error(`not a lowercase sha256 digest: ${hash}`); } return { + $schema: + "https://raw.githubusercontent.com/ScoopInstaller/Scoop/master/schema.json", version, description: "Cross-platform break reminder named after the theatre interval between acts", @@ -67,7 +69,11 @@ export function buildManifest({ version, hash }) { bin: [["Entracte.exe", "entracte"]], shortcuts: [["Entracte.exe", "Entracte"]], notes: [ - "Entracte runs from the tray; `entracte help` lists the CLI.", + // No claim that the CLI prints anything: the Windows build is a + // GUI-subsystem binary, so it never attaches to the calling console and + // every CLI code path is silent (#364). The action commands do reach the + // running app, which is what this note promises and no more. + "Entracte runs from the tray. `entracte pause 30m` and `entracte resume` reach the running app from any shell; commands that print (help, status) stay silent on Windows until #364 lands.", // The NSIS installer bootstraps WebView2; a plain extraction cannot. // Windows 11 and up-to-date Windows 10 already ship the runtime, so this // is a note rather than a `depends` on a bucket we do not control. @@ -82,6 +88,11 @@ export function buildManifest({ version, hash }) { architecture: { "64bit": { url: downloadUrl("v$version", assetName("$version")), + // Point Scoop's updater at the release's own checksum file instead + // of letting it download the whole archive to hash it. Scoop's + // default text-file mode matches ` `, which is + // exactly what `shasum -a 256` writes into SHA256SUMS.txt. + hash: { url: downloadUrl("v$version", "SHA256SUMS.txt") }, }, }, }, @@ -98,10 +109,9 @@ if (argv[1] && import.meta.url === pathToFileURL(argv[1]).href) { } // Resolved against this module, not the process CWD: the manifest has one // home, and a run from the wrong directory should not quietly write a - // second one somewhere else. - writeFileSync( - new URL("../bucket/entracte.json", import.meta.url), - renderManifest(buildManifest({ version, hash })), - "utf8", - ); + // second one somewhere else. `bucket/` is only tracked by virtue of its + // README, so create it rather than ENOENT at release time if that moves. + const out = new URL("../bucket/entracte.json", import.meta.url); + mkdirSync(new URL("./", out), { recursive: true }); + writeFileSync(out, renderManifest(buildManifest({ version, hash })), "utf8"); } diff --git a/src/test-fixtures/scoop-manifest.test.ts b/src/test-fixtures/scoop-manifest.test.ts index 7a7d5184..74c701ff 100644 --- a/src/test-fixtures/scoop-manifest.test.ts +++ b/src/test-fixtures/scoop-manifest.test.ts @@ -48,6 +48,15 @@ describe("Scoop manifest (#359)", () => { expect(manifest.bin).toEqual([["Entracte.exe", "entracte"]]); }); + it("does not promise CLI output Windows cannot give (#364)", () => { + // The release binary is GUI-subsystem, so it never attaches to the calling + // console: action commands land, anything that prints is silent. The note + // must not read as "the CLI works", which is what the first draft said. + const notes = manifest.notes.join(" "); + expect(notes).toMatch(/silent on Windows/); + expect(notes).not.toMatch(/`entracte help` lists/); + }); + it("warns about the WebView2 bootstrap a portable install gives up", () => { // The NSIS installer installs the runtime if it is missing; extracting a // zip cannot, and the symptom is a blank window rather than an error. @@ -61,6 +70,19 @@ describe("Scoop manifest (#359)", () => { ); }); + it("lets autoupdate read the hash instead of downloading the zip", () => { + // Scoop would otherwise fetch the whole archive just to digest it, when + // the release already publishes the checksum. + const hash = manifest.autoupdate.architecture["64bit"].hash.url; + expect(hash.replace(/\$version/g, "0.0.14")).toBe( + "https://github.com/drmowinckels/entracte/releases/download/v0.0.14/SHA256SUMS.txt", + ); + }); + + it("declares the Scoop schema so editors and linters can validate it", () => { + expect(manifest.$schema).toMatch(/ScoopInstaller\/Scoop\/.*schema\.json$/); + }); + it("tracks stable releases only, with checkver agreeing", () => { // `releases/latest` excludes prereleases, which is the whole point: a // `scoop` install cannot see the in-app channel setting, so it must not be From 5fd5c5af094f83876d1a8bbd639839a85f8292ae Mon Sep 17 00:00:00 2001 From: Athanasia Monika Mowinckel Date: Thu, 1 Oct 2026 18:42:26 +0200 Subject: [PATCH 4/5] docs(packaging): say what the zip check actually covers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `build-preview.yml` is opt-in behind the `build:installers` label, not a per-PR job, so calling the portable-zip check a per-PR guard oversold it. It is a pre-release smoke test to run when the packaging step changes — which is how it was exercised on this PR. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/build-preview.yml | 13 ++++++++----- docs/developer/releases.md | 2 +- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/.github/workflows/build-preview.yml b/.github/workflows/build-preview.yml index 533dae91..6334ca88 100644 --- a/.github/workflows/build-preview.yml +++ b/.github/workflows/build-preview.yml @@ -99,11 +99,14 @@ jobs: } >> "$GITHUB_STEP_SUMMARY" # Guard the Scoop portable zip (#359). The release job packages it with - # pwsh, which `audit:workflow-shell` cannot check (it parses bash only) - # and which nothing else runs before a real release — so a wrong exe - # path or an empty archive would first surface at release time, with the - # Windows bundles already built. Same class of bug as the AppImage icon - # below: invisible unless something looks inside the archive. + # pwsh, which `audit:workflow-shell` cannot check (it parses bash only), + # so a wrong exe path or an empty archive would otherwise first surface + # at release time, with the Windows bundles already built. Same class of + # bug as the AppImage icon below: invisible unless something looks inside + # the archive. + # + # This job is opt-in (`build:installers`), so this is a pre-release + # smoke test to run when the packaging changes — not a per-PR gate. # # `--debug` here, so the exe is under target/debug. - name: Verify the portable zip can be packaged diff --git a/docs/developer/releases.md b/docs/developer/releases.md index 3ee74f64..c5a6471c 100644 --- a/docs/developer/releases.md +++ b/docs/developer/releases.md @@ -104,7 +104,7 @@ Publishing also fires [`.github/workflows/bump-scoop.yml`](https://github.com/dr Scoop's bucket is a `bucket/` directory in an ordinary repository, so unlike a Homebrew tap it needs no second repo and no cross-repo token. The manifest points at the portable zip rather than the `.msi` / `.exe`, because Scoop extracts archives rather than running installers. `build-windows-unsigned` builds that zip by staging `src-tauri/target/release/entracte.exe` as `Entracte.exe` next to the `LICENSE` and compressing it; the asset name is spelled in three places (`release.yml`, `bump-scoop.yml`, the generator) and [`src/test-fixtures/scoop-manifest.test.ts`](https://github.com/drmowinckels/entracte/blob/main/src/test-fixtures/scoop-manifest.test.ts) pins them together, because drift there is a 404 that only surfaces on a user's machine. -Both the packaging step and its artifact upload are `continue-on-error`, and they are the last steps in the job on purpose: everything before them produces the _signed_ Windows distribution. If a Scoop nice-to-have could fail that job, `sign-windows` would be skipped by `needs:` and the release would end up with no `.msi`, no `.exe` and no `.sig` at all. When the zip is missing, `bump-scoop.yml`'s "did the release build the portable zip?" guard is the alarm. [`build-preview.yml`](https://github.com/drmowinckels/entracte/blob/main/.github/workflows/build-preview.yml) packages the same zip from the debug build on every PR and asserts `Entracte.exe` sits at its root, because `audit:workflow-shell` parses bash only and would never see a broken `pwsh` step. +Both the packaging step and its artifact upload are `continue-on-error`, and they are the last steps in the job on purpose: everything before them produces the _signed_ Windows distribution. If a Scoop nice-to-have could fail that job, `sign-windows` would be skipped by `needs:` and the release would end up with no `.msi`, no `.exe` and no `.sig` at all. When the zip is missing, `bump-scoop.yml`'s "did the release build the portable zip?" guard is the alarm. [`build-preview.yml`](https://github.com/drmowinckels/entracte/blob/main/.github/workflows/build-preview.yml) packages the same zip from its debug build and asserts `Entracte.exe` sits at the archive root, because `audit:workflow-shell` parses bash only and would never see a broken `pwsh` step. That job is opt-in — add the `build:installers` label to a PR — so run it whenever the packaging step changes. The zip does not pass through SignPath: its signing policy covers the two bundles, so the exe inside stays Authenticode-unsigned even once SignPath is approved. That matches what every Windows artifact ships as today, and the install guide says so. From b99ea4be82beabcc5cdce5c95d42aa1774a6ee00 Mon Sep 17 00:00:00 2001 From: Athanasia Monika Mowinckel Date: Fri, 2 Oct 2026 17:01:52 +0200 Subject: [PATCH 5/5] fix(packaging): close the gaps a pre-merge review found in the Scoop path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adversarial review, security review and a simplification pass over #361. Two findings were real enough to change behaviour. `nullglob` leaked out of the portable-zip block in `sign-windows` and into `cp unsigned/*.msi unsigned/*.exe signed/`. With it on, a missing `.msi` silently vanishes from the argument list and the release ships with only the `.exe` — the exact class of failure the zip's `continue-on-error` exists to avoid. Replaced with `compgen -G`, so no process-global flag is touched. The cross-file asset-name pin never ran for the file most likely to drift: `ci.yml`'s `paths-ignore` covers `.github/workflows/release.yml` *and* `docs/**`, and `paths-ignore` skips the whole workflow when every changed file is ignored. A rename in either file therefore sailed past the one test that catches it. The test now also runs from `audit.yml`, which carries no path filter by design; `release.yml` stays ignored, so no CI minutes are added. Also in `bump-scoop.yml`: * The tag check validated characters, not shape, and accepted `0.0.14` — which the generator turns into a `v0.0.14` download URL. Tightened to `vMAJOR.MINOR.PATCH[-prerelease]`, admitting prereleases only so the next guard can reject them with a message that explains why. * The manifest hash now comes from the downloaded bytes, with `SHA256SUMS.txt` as a cross-check that must agree. That hash is the only integrity check a `scoop install` performs, and the sums file is composed once over whatever was on the draft at that moment, so a later `--clobber` re-upload or a partial re-run of `checksums` could otherwise put a stale hash in the bucket. * A missing zip is named explicitly: `gh release download` exits 0 as long as one of its two patterns matched. * Download and regenerate merged into one step, so the asset name is spelled once per file; downloads land in `RUNNER_TEMP`, not the checkout the next step commits from. * The failure reporter reads the tag from the event rather than `steps.meta.outputs.tag`, which the tag validation fails before publishing — so a rejected tag filed "bump-scoop failed for unknown". `bump-cask.yml` had two of the same latent bugs: the reporter's tag, and a depth-1 checkout under the same rebase-and-retry push loop, which has no merge base to rebase onto. The portable zip now carries `NOTICE` as well as `LICENSE`; Apache-2.0 4(d) requires it in a redistribution and an extracted archive has nowhere else to put it. `build-preview.yml`'s archive check used `-notcontains`, which is case-insensitive in PowerShell and so would have accepted an `entracte.exe` that was never renamed — the one bug that step exists to catch. Tests, against the symbols they cover: * `src/test-fixtures/scoop-manifest.test.ts` gains five cases over `bump-scoop.yml`, `build-preview.yml` and `audit.yml`: the tag-shape pattern is lifted out of the workflow and exercised (accepts `v0.0.14`, rejects `0.0.14`/`V0.0.14`, admits `v0.1.2-beta.1`); the hash-from-bytes flow and its cross-check; the reporter deriving the tag from the event; the pwsh `-cnotcontains` guards and the `NOTICE` copy; and that this file runs from a workflow with no `paths-ignore`, or it pins nothing. * The structural claims (`release: published`, `ref: main`, `fetch-depth: 0`, the reporter's `env`) now parse the YAML instead of grepping it, so a reflow can neither pass nor fail them. Three assertions that could not fail were dropped or replaced: the `$schema` regex against a literal in the same module, the `JSON.parse(JSON.stringify(x))` round-trip (replaced by the 4-space-and-trailing-newline formatting that a bucket commit actually cares about), and the `$version` round-trip through the helpers that produced it (replaced by the literal template Scoop substitutes into). * `assetName` is pinned against the docs download picker's regex, which lives behind `docs/**` and so was previously unguarded from this side. Co-Authored-By: Claude Opus 5 (1M context) --- .github/AGENTS.md | 5 +- .github/audit/cspell/project-words.txt | 1 + .github/workflows/audit.yml | 8 ++ .github/workflows/build-preview.yml | 10 +- .github/workflows/bump-cask.yml | 11 +- .github/workflows/bump-scoop.yml | 84 ++++++++++----- .github/workflows/ci.yml | 6 +- .github/workflows/release.yml | 16 ++- bucket/README.md | 10 +- docs/developer/releases.md | 4 +- scripts/scoop-manifest.mjs | 18 +++- src/test-fixtures/scoop-manifest.test.ts | 126 +++++++++++++++++++++-- 12 files changed, 247 insertions(+), 52 deletions(-) diff --git a/.github/AGENTS.md b/.github/AGENTS.md index 23f8150e..742f57b9 100644 --- a/.github/AGENTS.md +++ b/.github/AGENTS.md @@ -188,7 +188,7 @@ The `audit` CI job runs the always-hard-fail ones (knip / cspell / size-limit / ## CI / deployment -Three workflows in [.github/workflows/](workflows/): +The main workflows in [.github/workflows/](workflows/): - **[ci.yml](workflows/ci.yml)** — runs on every push / PR. Four jobs in parallel: - **frontend** (ubuntu): `tsc --noEmit`, `npm run coverage`, `npm run build`, `audit:a11y`. Uploads `coverage/lcov.info` to Codecov with flag `frontend`. @@ -197,7 +197,8 @@ Three workflows in [.github/workflows/](workflows/): - **advisory** (ubuntu): cargo-deny + lychee + npm audit, results posted as a sticky PR comment. Step-level `continue-on-error: true` keeps the comment posting even when an audit fails; a final step re-fails the job on lychee breakage so broken links block merges. - **[docs.yml](workflows/docs.yml)** — runs on push to `main` when `docs/**`, `src/**`, `src-tauri/**`, or `.config/typedoc/**` change. Builds the VitePress site + rustdoc + TypeDoc, deploys to GitHub Pages. - **[docs-preview.yml](workflows/docs-preview.yml)** — runs on `pull_request` against the same path set. Mirrors the production docs build and pushes the result to Netlify as a per-PR preview, then sticky-comments the URL on the PR. Requires `NETLIFY_AUTH_TOKEN` (user token) and `NETLIFY_SITE_ID` (per-site) repo secrets. Skips fork PRs (no secret access). Production deploys stay on GitHub Pages via `docs.yml` — Netlify is preview-only. -- **[release.yml](workflows/release.yml)** — runs on `v*` tag push (or `workflow_dispatch`). Full bundle via `tauri-action` across all platforms, creates a draft GitHub release. +- **[release.yml](workflows/release.yml)** — runs on `v*` tag push (or `workflow_dispatch`). Full bundle via `tauri-action` across all platforms, creates a draft GitHub release. It stays in `ci.yml`'s `paths-ignore`, so editing it does not run the test matrix — but `src/test-fixtures/scoop-manifest.test.ts` reads it to pin the portable-zip asset name, which is why `audit.yml` (no path filter) runs that test too. +- **[bump-cask.yml](workflows/bump-cask.yml)** / **[bump-scoop.yml](workflows/bump-scoop.yml)** — run on `release: published` and commit `Casks/entracte.rb` / `bucket/entracte.json` straight to `main`. Actions cannot open PRs on this repo (`can_approve_pull_request_reviews` is off — see #349), so each ends with an `if: failure()` step that files or comments on an issue; nothing downstream would otherwise notice a red run. Stable releases only. Codecov targets: project + patch, both `informational: true` (no merge block on coverage drops) — see [.github/codecov.yml](codecov.yml). diff --git a/.github/audit/cspell/project-words.txt b/.github/audit/cspell/project-words.txt index a4456c92..2f553aec 100644 --- a/.github/audit/cspell/project-words.txt +++ b/.github/audit/cspell/project-words.txt @@ -205,3 +205,4 @@ autoupdate pwsh APPDATA Chocolatey +cnotcontains diff --git a/.github/workflows/audit.yml b/.github/workflows/audit.yml index 1c5ab17f..8e2570a2 100644 --- a/.github/workflows/audit.yml +++ b/.github/workflows/audit.yml @@ -67,6 +67,14 @@ jobs: - name: workflow shell syntax run: npm run audit:workflow-shell + # The Scoop packaging pins span files that ci.yml's `paths-ignore` + # excludes — `.github/workflows/release.yml` and `docs/**` — so a rename + # in either would skip ci.yml's `frontend` job and sail past the one test + # that catches it. This workflow has no path filter, which is exactly the + # property the pins need, and the job already has `npm ci` (#359). + - name: cross-file packaging pins + run: npm test -- src/test-fixtures/scoop-manifest.test.ts + # Advisory audits: surface signal without blocking merges. # cargo-deny covers licenses + CVEs + duplicate-version checks. # npm audit is externally-validated and can flap, so it stays advisory. diff --git a/.github/workflows/build-preview.yml b/.github/workflows/build-preview.yml index 6334ca88..55f89ba8 100644 --- a/.github/workflows/build-preview.yml +++ b/.github/workflows/build-preview.yml @@ -118,15 +118,23 @@ jobs: New-Item -ItemType Directory -Force -Path $stage | Out-Null Copy-Item src-tauri/target/debug/entracte.exe (Join-Path $stage 'Entracte.exe') Copy-Item LICENSE $stage + Copy-Item NOTICE $stage $zip = Join-Path $env:RUNNER_TEMP 'portable-check.zip' Compress-Archive -Path "$stage/*" -DestinationPath $zip -Force Add-Type -AssemblyName System.IO.Compression.FileSystem $archive = [IO.Compression.ZipFile]::OpenRead($zip) try { $names = $archive.Entries.FullName } finally { $archive.Dispose() } Write-Output "portable zip contents: $($names -join ', ')" - if ($names -notcontains 'Entracte.exe') { + # `-cnotcontains`, not `-notcontains`: PowerShell's default string + # comparison is case-INsensitive, so `-notcontains` would accept an + # `entracte.exe` that never got renamed and pass on exactly the bug + # this step exists to catch. + if ($names -cnotcontains 'Entracte.exe') { throw "no Entracte.exe at the zip root - the Scoop manifest bin/shortcuts would break" } + if ($names -cnotcontains 'NOTICE') { + throw "no NOTICE at the zip root - Apache-2.0 4(d) requires it in a redistribution" + } # Guard the AppImage's required AppDir files. tauri-bundler < 2.9.4 # wrote `.DirIcon` and the `.desktop` entry as ABSOLUTE symlinks diff --git a/.github/workflows/bump-cask.yml b/.github/workflows/bump-cask.yml index 257caf94..5ada28be 100644 --- a/.github/workflows/bump-cask.yml +++ b/.github/workflows/bump-cask.yml @@ -93,6 +93,10 @@ jobs: - uses: actions/checkout@v7 with: ref: main + # Full history, like bump-scoop.yml: the commit step below rebases if + # main moved while the release was being published, and a shallow + # clone has no merge base to rebase onto. + fetch-depth: 0 - name: Fetch SHA256SUMS.txt env: @@ -216,7 +220,12 @@ jobs: # above, and at that point there is no git remote to infer one from — # so the loud-failure mechanism would itself be silent. GH_REPO: ${{ github.repository }} - TAG: ${{ steps.meta.outputs.tag }} + # Taken from the event, NOT from `steps.meta.outputs.tag`: the tag + # validation in that step is the one thing that can fail before the + # output is published, which is exactly when this reporter runs — and + # "failed for unknown" is not a bug report anyone can act on. Same + # derivation the `concurrency` group above uses. + TAG: ${{ github.event.release.tag_name || inputs.tag }} RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} run: | tag="${TAG:-unknown}" diff --git a/.github/workflows/bump-scoop.yml b/.github/workflows/bump-scoop.yml index 06c4a4ca..e22af4fd 100644 --- a/.github/workflows/bump-scoop.yml +++ b/.github/workflows/bump-scoop.yml @@ -63,9 +63,15 @@ jobs: else tag="$DISPATCH_TAG" fi - # Basic shape validation: anything not in [A-Za-z0-9._-] is rejected. - if ! printf '%s' "$tag" | grep -Eq '^[A-Za-z0-9._-]+$'; then - echo "::error::refusing to use tag '$tag' — contains characters outside [A-Za-z0-9._-]" + + # Shape validation, not just an injection guard. `release.yml` only + # builds `v*` tags, and scripts/scoop-manifest.mjs reconstructs the + # download URL as `v${version}`, so anything that is not + # `vMAJOR.MINOR.PATCH[-prerelease]` would produce a manifest pointing + # at a URL that does not exist. Prereleases are admitted here only so + # the next check can reject them with a message that explains why. + if ! printf '%s' "$tag" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+(-[A-Za-z0-9.]+)?$'; then + echo "::error::refusing to use tag '$tag' — expected vMAJOR.MINOR.PATCH (release.yml only builds v-prefixed semver tags)" exit 1 fi # The job condition already filters prerelease *events*; this covers @@ -92,34 +98,61 @@ jobs: with: node-version: lts/* - # Releases already carry SHA256SUMS.txt (composed by release.yml's - # `checksums` job over every asset on the draft), so the hash is read - # from there rather than re-downloading and hashing the zip. - - name: Fetch SHA256SUMS.txt + # One step, so the asset name is spelled once: it is also spelled in + # release.yml (which builds the zip) and scripts/scoop-manifest.mjs (which + # tells Scoop where to get it), and scoop-manifest.test.ts pins the three + # against each other — drift is a 404 that only surfaces on a user's + # machine. Downloads land in RUNNER_TEMP, never the checkout, because the + # next step commits out of this working tree. + - name: Regenerate bucket/entracte.json env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_REPO: ${{ github.repository }} TAG: ${{ steps.meta.outputs.tag }} - run: | - gh release download "$TAG" --pattern "SHA256SUMS.txt" - - - name: Regenerate bucket/entracte.json - env: - TAG: ${{ steps.meta.outputs.tag }} + # Lower-cased deliberately: release.yml's pwsh step spells the asset + # with `${version}` too, and the test pins the two spellings together. + version: ${{ steps.meta.outputs.version }} run: | set -euo pipefail - version="${TAG#v}" - # Spelled the same way in release.yml (which builds the zip) and in - # scripts/scoop-manifest.mjs (which tells Scoop where to get it); - # scoop-manifest.test.ts pins all three against each other, because - # drift here is a 404 that only surfaces on a user's machine. asset="Entracte_${version}_x64-portable.zip" + sums="${RUNNER_TEMP}/SHA256SUMS.txt" + zip="${RUNNER_TEMP}/${asset}" + + gh release download "$TAG" -D "$RUNNER_TEMP" \ + --pattern "SHA256SUMS.txt" --pattern "$asset" - hash=$(awk -v f="$asset" '$2 == f { print $1 }' SHA256SUMS.txt) - if [ -z "$hash" ]; then - echo "::error::no checksum for ${asset} in SHA256SUMS.txt — did the release build the portable zip?" + # `gh release download` exits 0 as long as ONE pattern matched, so a + # missing zip has to be named here rather than left to a confusing + # error further down. release.yml packages the zip with + # `continue-on-error`, deliberately, so that a packaging failure + # cannot cost the release its signed .msi / .exe — this is the alarm + # that trade-off relies on. + for f in "$sums" "$zip"; do + if [ ! -f "$f" ]; then + echo "::error::release ${TAG} has no $(basename "$f") — did the release build the portable zip? (release.yml packages it with continue-on-error)" + exit 1 + fi + done + + # The hash goes into the manifest straight from the bytes, because + # that is what Scoop will re-digest: this hash is the ONLY integrity + # check `scoop install` performs. + hash=$(sha256sum "$zip" | cut -d' ' -f1) + + # SHA256SUMS.txt is then a cross-check rather than the source. It is + # composed once, over whatever was on the draft at that moment, so a + # later `--clobber` re-upload or a partial re-run of `checksums` would + # leave it describing a file the release no longer has. Disagreement + # means one of the two is stale and neither can be trusted. + published=$(awk -v f="$asset" '$2 == f { print $1 }' "$sums") + if [ -z "$published" ]; then + echo "::error::${asset} is on release ${TAG} but has no line in SHA256SUMS.txt — the checksums job did not see it" echo "SHA256SUMS.txt contents:" - cat SHA256SUMS.txt + cat "$sums" + exit 1 + fi + if [ "$published" != "$hash" ]; then + echo "::error::SHA256SUMS.txt says ${published} for ${asset}, but the asset on the release digests to ${hash} — one of the two is stale; refusing to publish" exit 1 fi @@ -190,7 +223,12 @@ jobs: # above, and at that point there is no git remote to infer one from — # so the loud-failure mechanism would itself be silent. GH_REPO: ${{ github.repository }} - TAG: ${{ steps.meta.outputs.tag }} + # Taken from the event, NOT from `steps.meta.outputs.tag`: the tag + # validation in that step is the one thing that can fail before the + # output is published, which is exactly when this reporter runs — and + # "failed for unknown" is not a bug report anyone can act on. Same + # derivation the `concurrency` group above uses. + TAG: ${{ github.event.release.tag_name || inputs.tag }} RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} run: | tag="${TAG:-unknown}" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8a00113f..fc6d7c07 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,7 +6,11 @@ name: Checks # triggers — paths-ignore only skips when every changed file is ignored. # # Spell-check / link-check / dependency audits live in audit.yml with -# NO paths-ignore filter, so docs-only PRs still get those gates. +# NO paths-ignore filter, so docs-only PRs still get those gates. The +# cross-file packaging pins (src/test-fixtures/scoop-manifest.test.ts, which +# reads release.yml AND docs/.vitepress/.../download-detect.ts) live there too +# for the same reason: both of those paths are ignored here, so a rename in +# either would otherwise skip the one test that catches it (#359). # # `workflow_dispatch` ignores path filters, so a manual rerun always # runs the full suite if needed. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 045b07d6..b6a14577 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -356,7 +356,11 @@ jobs: $stage = Join-Path $env:RUNNER_TEMP 'portable' New-Item -ItemType Directory -Force -Path $stage | Out-Null Copy-Item src-tauri/target/release/entracte.exe (Join-Path $stage 'Entracte.exe') + # LICENSE *and* NOTICE: Apache-2.0 4(d) requires a redistribution to + # carry the NOTICE text, and an extracted zip is the one Entracte + # artifact where nothing else can carry it. Copy-Item LICENSE $stage + Copy-Item NOTICE $stage New-Item -ItemType Directory -Force -Path portable | Out-Null # Spelled identically in bump-scoop.yml and scripts/scoop-manifest.mjs; # src/test-fixtures/scoop-manifest.test.ts pins the three together. @@ -597,10 +601,14 @@ jobs: # binaries it was asked for, so the zip is never in its output. A # missing zip warns rather than failing the step — see the # `continue-on-error` note on the packaging step. - shopt -s nullglob - zips=(portable/*.zip) - if [ ${#zips[@]} -gt 0 ]; then - cp "${zips[@]}" signed/ + # + # `compgen -G` rather than `shopt -s nullglob`: nullglob is + # process-global, and the `.msi` / `.exe` copy below depends on an + # unmatched glob staying literal so `cp` fails loudly. Left on, a + # missing `.msi` would silently vanish from the argument list and + # ship a release with only the `.exe`. + if compgen -G 'portable/*.zip' > /dev/null; then + cp portable/*.zip signed/ else echo "::warning::no portable zip on this run — the Scoop manifest bump will fail and file an issue" fi diff --git a/bucket/README.md b/bucket/README.md index fcb45b56..f7542738 100644 --- a/bucket/README.md +++ b/bucket/README.md @@ -27,14 +27,18 @@ zip, so until then `scoop install` reports no manifest (see The portable `Entracte__x64-portable.zip` from the GitHub release, not either Windows installer: Scoop extracts archives rather than running setup programs, and the NSIS installer's own location would fight Scoop's -`~/scoop/apps` layout. +`~/scoop/apps` layout. The archive holds `Entracte.exe` plus the `LICENSE` and +`NOTICE`. The exe inside is not Authenticode-signed — the same status as every Windows artifact Entracte ships until SignPath Foundation approves the project (see the [install guide](https://entracte.drmowinckels.io/guide/install#windows)), and it is not submitted for signing even once that lands, because SignPath's policy -covers the two bundles rather than an archive. Scoop verifies the SHA-256 in the manifest against the download, and that hash -comes from the release's own `SHA256SUMS.txt`. +covers the two bundles rather than an archive. + +Scoop verifies the SHA-256 in the manifest against the download. That hash +comes from the release's own `SHA256SUMS.txt`, and `bump-scoop.yml` re-digests +the published asset to confirm the two agree before committing the manifest. Your settings live in `%APPDATA%\io.drmowinckels.entracte`, outside the Scoop app directory, so `scoop uninstall` leaves them alone. diff --git a/docs/developer/releases.md b/docs/developer/releases.md index c5a6471c..62c2fa42 100644 --- a/docs/developer/releases.md +++ b/docs/developer/releases.md @@ -102,7 +102,9 @@ To bump by hand — after a failure, or for a tag that predates the workflow — Publishing also fires [`.github/workflows/bump-scoop.yml`](https://github.com/drmowinckels/entracte/blob/main/.github/workflows/bump-scoop.yml), the Windows counterpart ([#359](https://github.com/drmowinckels/entracte/issues/359)). It regenerates `bucket/entracte.json` from [`scripts/scoop-manifest.mjs`](https://github.com/drmowinckels/entracte/blob/main/scripts/scoop-manifest.mjs), reading the `Entracte__x64-portable.zip` checksum out of the release's `SHA256SUMS.txt`, and commits it straight to `main` — same reasoning, same failure-reports-itself step, same `ref: main` checkout (a `release: published` checkout defaults to the _tag_, which is behind `main` by everything merged since). -Scoop's bucket is a `bucket/` directory in an ordinary repository, so unlike a Homebrew tap it needs no second repo and no cross-repo token. The manifest points at the portable zip rather than the `.msi` / `.exe`, because Scoop extracts archives rather than running installers. `build-windows-unsigned` builds that zip by staging `src-tauri/target/release/entracte.exe` as `Entracte.exe` next to the `LICENSE` and compressing it; the asset name is spelled in three places (`release.yml`, `bump-scoop.yml`, the generator) and [`src/test-fixtures/scoop-manifest.test.ts`](https://github.com/drmowinckels/entracte/blob/main/src/test-fixtures/scoop-manifest.test.ts) pins them together, because drift there is a 404 that only surfaces on a user's machine. +It also downloads the zip itself and re-digests it, refusing to commit unless the two agree. That hash is the only integrity check a `scoop install` performs, and `SHA256SUMS.txt` is composed once over whatever was on the draft at that moment — so a later `--clobber` re-upload or a partial re-run of `checksums` would otherwise put a hash in the bucket that is wrong only on a user's machine. + +Scoop's bucket is a `bucket/` directory in an ordinary repository, so unlike a Homebrew tap it needs no second repo and no cross-repo token. The manifest points at the portable zip rather than the `.msi` / `.exe`, because Scoop extracts archives rather than running installers. `build-windows-unsigned` builds that zip by staging `src-tauri/target/release/entracte.exe` as `Entracte.exe` next to the `LICENSE` and `NOTICE` (Apache-2.0 §4(d) requires the latter in a redistribution, and an extracted zip has nowhere else to carry it) and compressing it; the asset name is spelled in three places (`release.yml`, `bump-scoop.yml`, the generator) and [`src/test-fixtures/scoop-manifest.test.ts`](https://github.com/drmowinckels/entracte/blob/main/src/test-fixtures/scoop-manifest.test.ts) pins them together, because drift there is a 404 that only surfaces on a user's machine. Both the packaging step and its artifact upload are `continue-on-error`, and they are the last steps in the job on purpose: everything before them produces the _signed_ Windows distribution. If a Scoop nice-to-have could fail that job, `sign-windows` would be skipped by `needs:` and the release would end up with no `.msi`, no `.exe` and no `.sig` at all. When the zip is missing, `bump-scoop.yml`'s "did the release build the portable zip?" guard is the alarm. [`build-preview.yml`](https://github.com/drmowinckels/entracte/blob/main/.github/workflows/build-preview.yml) packages the same zip from its debug build and asserts `Entracte.exe` sits at the archive root, because `audit:workflow-shell` parses bash only and would never see a broken `pwsh` step. That job is opt-in — add the `build:installers` label to a PR — so run it whenever the packaging step changes. diff --git a/scripts/scoop-manifest.mjs b/scripts/scoop-manifest.mjs index 5e0e997a..27fc8b9e 100644 --- a/scripts/scoop-manifest.mjs +++ b/scripts/scoop-manifest.mjs @@ -11,7 +11,7 @@ // node scripts/scoop-manifest.mjs 0.0.14 import { mkdirSync, writeFileSync } from "node:fs"; -import { argv } from "node:process"; +import { argv, exit } from "node:process"; import { pathToFileURL } from "node:url"; const REPO = "https://github.com/drmowinckels/entracte"; @@ -24,7 +24,7 @@ const API = "https://api.github.com/repos/drmowinckels/entracte"; // GitHub already excludes prereleases from, and this regex is shared with the // manifest generator so the two can never disagree about what a version // looks like. -const VERSION = String.raw`\d+\.\d+\.\d+`; +const VERSION_PATTERN = String.raw`\d+\.\d+\.\d+`; /** * The portable-zip asset `release.yml` uploads. Scoop extracts an archive @@ -41,7 +41,7 @@ export const assetName = (version) => `Entracte_${version}_x64-portable.zip`; const downloadUrl = (tag, file) => `${REPO}/releases/download/${tag}/${file}`; export function buildManifest({ version, hash }) { - if (!new RegExp(`^${VERSION}$`).test(version)) { + if (!new RegExp(`^${VERSION_PATTERN}$`).test(version)) { throw new Error( `not a bare stable version (drop any leading "v"; the bucket tracks stable releases only): ${version}`, ); @@ -80,9 +80,14 @@ export function buildManifest({ version, hash }) { "Needs the Microsoft Edge WebView2 Runtime, preinstalled on Windows 11 and on current Windows 10. If the window stays blank, install it from https://developer.microsoft.com/microsoft-edge/webview2/", "Your settings live in %APPDATA%\\io.drmowinckels.entracte, outside the Scoop app directory — uninstalling leaves them in place.", ], + // `checkver`/`autoupdate` are not used by anything in this repo — + // bump-scoop.yml regenerates the manifest outright, and `scoop update + // ` does not evaluate them. They are here for the Scoop-side tooling + // that does (`scoop checkver -u`, a bucket excavator), and so a fork or a + // downstream bucket can track releases without this workflow. checkver: { url: `${API}/releases/latest`, - regex: String.raw`"tag_name":\s*"v(${VERSION})"`, + regex: String.raw`"tag_name":\s*"v(${VERSION_PATTERN})"`, }, autoupdate: { architecture: { @@ -105,7 +110,10 @@ export const renderManifest = (manifest) => if (argv[1] && import.meta.url === pathToFileURL(argv[1]).href) { const [version, hash] = argv.slice(2); if (!version || !hash) { - throw new Error("usage: node scripts/scoop-manifest.mjs "); + // Matches set-version.mjs: a usage mistake gets a one-line message and a + // distinct exit code, not a stack trace. + console.error("usage: scoop-manifest.mjs "); + exit(2); } // Resolved against this module, not the process CWD: the manifest has one // home, and a run from the wrong directory should not quietly write a diff --git a/src/test-fixtures/scoop-manifest.test.ts b/src/test-fixtures/scoop-manifest.test.ts index 74c701ff..ed412297 100644 --- a/src/test-fixtures/scoop-manifest.test.ts +++ b/src/test-fixtures/scoop-manifest.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect } from "vitest"; import { readFileSync } from "node:fs"; +import { parse } from "yaml"; import { assetName, @@ -9,6 +10,16 @@ import { const release = readFileSync(".github/workflows/release.yml", "utf8"); const bump = readFileSync(".github/workflows/bump-scoop.yml", "utf8"); +const preview = readFileSync(".github/workflows/build-preview.yml", "utf8"); +const audit = readFileSync(".github/workflows/audit.yml", "utf8"); +const downloadDetect = readFileSync( + "docs/.vitepress/theme/components/download-detect.ts", + "utf8", +); + +// Parsed, not grepped, wherever the claim is about workflow STRUCTURE: a +// reflow or a change of indentation must not be able to pass or fail these. +const bumpYaml = parse(bump); const HASH = "a".repeat(64); const manifest = buildManifest({ version: "0.0.14", hash: HASH }); @@ -54,7 +65,9 @@ describe("Scoop manifest (#359)", () => { // must not read as "the CLI works", which is what the first draft said. const notes = manifest.notes.join(" "); expect(notes).toMatch(/silent on Windows/); - expect(notes).not.toMatch(/`entracte help` lists/); + // Linked to the tracking issue, so the caveat and its fix cannot drift + // apart: whoever lands #364 has to come back here to drop the note. + expect(notes).toMatch(/#364/); }); it("warns about the WebView2 bootstrap a portable install gives up", () => { @@ -64,9 +77,12 @@ describe("Scoop manifest (#359)", () => { }); it("rebuilds the same URL from $version alone, for autoupdate", () => { + // Scoop substitutes `$version` as a plain token, so the template is pinned + // literally rather than only round-tripped through the same helpers that + // produced it — a round-trip on its own cannot fail. const template = manifest.autoupdate.architecture["64bit"].url; - expect(template.replace(/\$version/g, "0.0.14")).toBe( - manifest.architecture["64bit"].url, + expect(template).toBe( + "https://github.com/drmowinckels/entracte/releases/download/v$version/Entracte_$version_x64-portable.zip", ); }); @@ -79,10 +95,6 @@ describe("Scoop manifest (#359)", () => { ); }); - it("declares the Scoop schema so editors and linters can validate it", () => { - expect(manifest.$schema).toMatch(/ScoopInstaller\/Scoop\/.*schema\.json$/); - }); - it("tracks stable releases only, with checkver agreeing", () => { // `releases/latest` excludes prereleases, which is the whole point: a // `scoop` install cannot see the in-app channel setting, so it must not be @@ -102,7 +114,13 @@ describe("Scoop manifest (#359)", () => { expect(bump).toContain("bucket/entracte.json"); // The cask's lesson (#349): Actions cannot open PRs on this repo, and a // release-triggered checkout defaults to the tag, not main. - expect(bump).toMatch(/ref: main/); + const checkout = bumpYaml.jobs["bump-scoop"].steps.find( + (step: { uses?: string }) => step.uses?.startsWith("actions/checkout"), + ); + expect(checkout.with.ref).toBe("main"); + // The rebase-retry loop below needs a merge base to rebase onto, which a + // shallow clone does not have. + expect(checkout.with["fetch-depth"]).toBe(0); expect(bump).not.toContain("gh pr create"); // Staging before the emptiness check — `git diff` reports no change for a // file git has never tracked, which would make the first release a no-op. @@ -114,11 +132,97 @@ describe("Scoop manifest (#359)", () => { it("is bumped only once the release is published", () => { // The tag push produces a DRAFT release, whose assets 404 for everyone but // the maintainer. Bumping then would publish a download nobody can fetch. - expect(bump).toMatch(/release:\n {4}types: \[published\]/); + expect(bumpYaml.on.release.types).toEqual(["published"]); + }); + + it("writes the 4-space, newline-terminated JSON a bucket commit expects", () => { + // `JSON.parse(JSON.stringify(x))` round-trips by construction, so what + // earns its place here is the *formatting*: Scoop buckets are 4-space + // indented, and a missing trailing newline makes every bump a + // no-newline-at-end-of-file diff. + const rendered = renderManifest(manifest); + expect(JSON.parse(rendered)).toEqual(manifest); + expect(rendered).toMatch(/^\{\n {4}"\$schema"/); + expect(rendered.endsWith("}\n")).toBe(true); + }); + + it("verifies the checksum against the asset before committing it", () => { + // The manifest hash is the only integrity check `scoop install` performs, + // and SHA256SUMS.txt is composed once, over whatever was on the draft at + // that moment. Dropping this comparison would let a stale or clobbered + // checksum reach the bucket, where it is loud only on a user's machine. + // The hash written into the manifest comes from the downloaded bytes... + expect(bump).toContain('hash=$(sha256sum "$zip"'); + // ...and SHA256SUMS.txt is a cross-check that must agree with it. + expect(bump).toContain('if [ "$published" != "$hash" ]'); + }); + + it("accepts only v-prefixed semver tags, because the URL re-adds the v", () => { + // buildManifest reconstructs the download URL as `v${version}`, so a tag + // without the prefix would produce a manifest pointing at a tag that does + // not exist. The character-class check this replaced accepted `0.0.14`. + // The pattern is lifted out of the workflow and exercised rather than + // compared as text, so a behaviour-preserving rewrite does not fail here + // and a behaviour-changing one does. + const source = bump.match(/grep -Eq '(\^v[^']+)'/)?.[1]; + expect(source).toBeDefined(); + const tagShape = new RegExp(source!); + expect("v0.0.14").toMatch(tagShape); + expect("0.0.14").not.toMatch(tagShape); + expect("V0.0.14").not.toMatch(tagShape); + expect("v0.0.14; rm -rf /").not.toMatch(tagShape); + // Prereleases pass the shape check on purpose, so the next guard can + // reject them with a message that explains why. + expect("v0.1.2-beta.1").toMatch(tagShape); }); - it("writes JSON Scoop can parse", () => { - expect(JSON.parse(renderManifest(manifest))).toEqual(manifest); + it("names the tag in the issue a failed bump files", () => { + // The tag validation is the one step that can fail before + // `steps.meta.outputs.tag` is published, and it is exactly when the + // reporter runs — so the reporter reads the event instead, or it would file + // "bump-scoop failed for unknown". + const reporter = bumpYaml.jobs["bump-scoop"].steps.find( + (step: { name?: string }) => step.name === "Report a failed bump", + ); + expect(reporter.if).toBe("failure()"); + expect(reporter.env.TAG).not.toContain("steps.meta"); + expect(reporter.env.TAG).toContain("github.event.release.tag_name"); + }); + + it("checks the pwsh packaging nothing else can check", () => { + // `audit:workflow-shell` parses bash only, so build-preview.yml's archive + // check is the only gate over the pwsh steps. PowerShell's `-notContains` + // is case-INsensitive and would accept a `entracte.exe` that never got + // renamed, passing on the exact bug the check exists to catch. + expect(preview).toContain("-cnotcontains 'Entracte.exe'"); + // Apache-2.0 4(d): the portable zip is the one Entracte artifact where + // nothing else carries the NOTICE text. + expect(release).toMatch(/Copy-Item NOTICE \$stage/); + expect(preview).toContain("-cnotcontains 'NOTICE'"); + }); + + it("keeps the docs download picker matching what the bucket installs", () => { + // `download-detect.ts` curates the install page's picker by regex, so an + // asset it matches no rule for is simply invisible there. Its own tests run + // under docs/'s separate vitest root, which `docs.yml` only triggers for + // `docs/**` — a rename here would not reach them. Pinning both spellings + // in one assertion makes the drift fail in the always-on CI job instead. + expect(assetName("0.0.14")).toMatch(/_x64-portable\.zip$/); + expect(downloadDetect).toContain(String.raw`/_x64-portable\.zip$/`); + }); + + it("runs in a workflow with no paths-ignore, or pins nothing", () => { + // Every assertion above reads a file ci.yml's `paths-ignore` excludes — + // `.github/workflows/release.yml` and `docs/**` — and `paths-ignore` skips + // the whole workflow when every changed file is ignored. So a rename in + // either would skip ci.yml's `frontend` job and sail past this file. It has + // to run from audit.yml, which carries no path filter by design. + const auditYaml = parse(audit); + expect(Object.keys(auditYaml.on)).toContain("pull_request"); + expect(auditYaml.on.pull_request?.["paths-ignore"]).toBeUndefined(); + expect(audit).toContain( + "npm test -- src/test-fixtures/scoop-manifest.test.ts", + ); }); it("refuses a version or digest Scoop would choke on", () => {