diff --git a/.github/AGENTS.md b/.github/AGENTS.md index 23f8150..742f57b 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 7acc4bd..0fb886b 100644 --- a/.github/audit/cspell/project-words.txt +++ b/.github/audit/cspell/project-words.txt @@ -205,3 +205,11 @@ dryrun worktree worktrees triggerable +Scoop +scoop +checkver +autoupdate +pwsh +APPDATA +Chocolatey +cnotcontains diff --git a/.github/workflows/audit.yml b/.github/workflows/audit.yml index ad1639d..9582e6e 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 b8d9e13..55f89ba 100644 --- a/.github/workflows/build-preview.yml +++ b/.github/workflows/build-preview.yml @@ -98,6 +98,44 @@ 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), + # 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 + 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 + 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 ', ')" + # `-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 # 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 79e1ac4..5ada28b 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: @@ -211,7 +215,17 @@ jobs: if: failure() env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - TAG: ${{ steps.meta.outputs.tag }} + # 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 }} + # 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 new file mode 100644 index 0000000..e22af4f --- /dev/null +++ b/.github/workflows/bump-scoop.yml @@ -0,0 +1,267 @@ +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 + + # 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 + # 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/* + + # 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 }} + # 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 + 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" + + # `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 "$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 + + 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 }} + # 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 }} + # 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}" + 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/ci.yml b/.github/workflows/ci.yml index 8a00113..fc6d7c0 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 48a1d77..b6a1457 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -318,6 +318,63 @@ jobs: 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 + # 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` (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` + # / `.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. + # + # 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: | + $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') + # 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. + 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 + continue-on-error: true + with: + name: windows-portable + path: portable/ + 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` @@ -518,6 +575,19 @@ 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. + # + # `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 + - name: Assemble release bundle (binaries + Tauri .sig sidecars) shell: bash run: | @@ -527,6 +597,21 @@ 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. A + # missing zip warns rather than failing the step — see the + # `continue-on-error` note on the packaging step. + # + # `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 if [ "${{ steps.signpath.outputs.available }}" != "true" ]; then cp unsigned/*.msi unsigned/*.exe signed/ fi diff --git a/CHANGELOG.md b/CHANGELOG.md index 5549e88..c0ced07 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 to click through, and `entracte` lands on your `PATH`, so `entracte pause 30m` 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)) + ### Fixed - **Entracte no longer crashes at random on Linux/X11.** On X11 the app reads the system idle counter on a background thread while the window system runs on the main one, and the X client library only makes that safe if it is told up front that the app is multi-threaded. It wasn't, so now and then the two collided and X aborted the whole app — most visibly when a break fired and the overlay window was being built, which looked like "the app vanishes when a break starts". Threading is now initialised before any window exists, and the two remaining places that asked X about your monitors from a background thread (building a break overlay, and generating a diagnostics report) now ask on the main thread. macOS and Windows were never affected. ([#333](https://github.com/drmowinckels/entracte/issues/333)) diff --git a/README.md b/README.md index 2a8ea5b..040b5c2 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), 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 +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 0000000..f754273 --- /dev/null +++ b/bucket/README.md @@ -0,0 +1,71 @@ +# 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, 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 + +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 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. 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. + +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/.vitepress/theme/components/download-detect.test.ts b/docs/.vitepress/theme/components/download-detect.test.ts index 84b4100..5faab1d 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 8f781fc..40da5b6 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)" }, diff --git a/docs/developer/releases.md b/docs/developer/releases.md index 680bcaf..62c2fa4 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,18 @@ 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). + +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. + +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/cli.md b/docs/guide/cli.md index 41b1ecd..dd26446 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 7ec6148..53b7459 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,29 @@ 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 +``` + +::: 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. + +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 +75,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 0000000..fa14967 --- /dev/null +++ b/scripts/scoop-manifest.d.mts @@ -0,0 +1,26 @@ +// See set-version.d.mts for why these declarations exist. + +export declare function assetName(version: string): string; + +export interface ScoopManifest { + $schema: string; + 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; hash: { 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 0000000..27fc8b9 --- /dev/null +++ b/scripts/scoop-manifest.mjs @@ -0,0 +1,125 @@ +#!/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 { mkdirSync, writeFileSync } from "node:fs"; +import { argv, exit } 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_PATTERN = 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_PATTERN}$`).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 { + $schema: + "https://raw.githubusercontent.com/ScoopInstaller/Scoop/master/schema.json", + 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: [ + // 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. + "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_PATTERN})"`, + }, + autoupdate: { + 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") }, + }, + }, + }, + }; +} + +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) { + // 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 + // 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 new file mode 100644 index 0000000..ed41229 --- /dev/null +++ b/src/test-fixtures/scoop-manifest.test.ts @@ -0,0 +1,239 @@ +import { describe, it, expect } from "vitest"; +import { readFileSync } from "node:fs"; +import { parse } from "yaml"; + +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 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 }); + +/** + * 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("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/); + // 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", () => { + // 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", () => { + // 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).toBe( + "https://github.com/drmowinckels/entracte/releases/download/v$version/Entracte_$version_x64-portable.zip", + ); + }); + + 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("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. + 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. + 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(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("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", () => { + 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/); + }); +});