From 0df8a58591e00573bbbfe92122ac58a0d816d9f2 Mon Sep 17 00:00:00 2001 From: PathGao Date: Fri, 4 Sep 2026 08:17:52 +0800 Subject: [PATCH] ci(release): sign the Windows executables through SignPath (#562) One signing request covers all four executables, so it lives in a job of its own rather than in the two Windows matrix entries. The matrix hands its build over as an artifact; nothing Windows reaches the release page until the request comes back approved. Authenticode rewrites the installer, so its `.sig` -- the minisign signature the updater checks -- is regenerated from the signed file before anything is uploaded. Without SIGNPATH_API_TOKEN the job uploads what the matrix built, unsigned, and the release notes keep saying so. Co-Authored-By: Claude Opus 5 --- .github/workflows/build.yml | 199 +++++++++++++++++++++++++++++--- RELEASING.md | 30 ++++- scripts/releaseWorkflow.test.ts | 92 ++++++++++++++- 3 files changed, 298 insertions(+), 23 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index bda967cb..96683588 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -5,6 +5,10 @@ on: permissions: contents: write + # The SignPath action downloads the artifact it submits through the Actions + # API. `permissions:` being spelled out at all makes every scope not named + # here `none`, so the read has to be named. + actions: read # One release build at a time, per version. # @@ -151,6 +155,12 @@ jobs: # names that carry no version. - name: Compose the download table id: download_table + # The Windows note below says the executables are unsigned, and + # `sign-windows` signs them only when this token is set. Two places + # answering the same question from one value, rather than a note that + # has to be remembered when signing is turned on. + env: + SIGNPATH_API_TOKEN: ${{ secrets.SIGNPATH_API_TOKEN }} run: | VERSION='${{ steps.get_version.outputs.version }}' BASE="https://github.com/${GITHUB_REPOSITORY}/releases/download/v${VERSION}" @@ -169,9 +179,15 @@ jobs: echo "| | x86-64 | [\`Markpad_${VERSION}_amd64.deb\`](${BASE}/Markpad_${VERSION}_amd64.deb) | Debian, Ubuntu |" echo "| | x86-64 | [\`Markpad-${VERSION}-1.x86_64.rpm\`](${BASE}/Markpad-${VERSION}-1.x86_64.rpm) | Fedora, RHEL, openSUSE |" echo - echo '> [!NOTE]' - echo '> **Windows SmartScreen Notice**: Windows executables are not signed with an Authenticode certificate, so SmartScreen shows them as an unrecognized app. Click *"More info"* → *"Run anyway"*. The antivirus heuristic that used to flag the portable `.exe` no longer does.' - echo + if [ -z "${SIGNPATH_API_TOKEN:-}" ]; then + echo '> [!NOTE]' + echo '> **Windows SmartScreen Notice**: Windows executables are not signed with an Authenticode certificate, so SmartScreen shows them as an unrecognized app. Click *"More info"* → *"Run anyway"*. The antivirus heuristic that used to flag the portable `.exe` no longer does.' + echo + else + echo '> [!NOTE]' + echo '> **Windows executables are signed** with an Authenticode certificate provided by [SignPath Foundation](https://signpath.org). SmartScreen can still warn until a new certificate has been seen enough times; the publisher it names is the certificate rather than *Unknown publisher*.' + echo + fi echo '> [!NOTE]' echo '> **macOS Gatekeeper Notice**: the `.dmg` is not yet notarized, so the first launch is refused — *"Markpad cannot be opened because the developer cannot be verified"* — and the dialog offers only **Cancel** and **Move to Trash**. Two ways past it, both one-time:' echo '>' @@ -307,17 +323,29 @@ jobs: TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} run: npm run tauri build + # Windows leaves the matrix as a build artifact rather than as a release + # asset. Authenticode signing is one request over all four executables + # (#562) and the two architectures are two jobs, so the release upload + # happens once both have finished, in `sign-windows`. - name: Upload Windows x64 Artifacts if: matrix.os == 'windows' && matrix.arch == 'x64' shell: bash - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | VERSION="${{ needs.create-release.outputs.version }}" - cp src-tauri/target/release/Markpad.exe "Markpad_${VERSION}_x64.exe" - gh release upload v$VERSION "Markpad_${VERSION}_x64.exe" --clobber - find src-tauri/target/release/bundle/nsis -name "*-setup.exe" -exec gh release upload v$VERSION {} --clobber \; - find src-tauri/target/release/bundle/nsis -name "*-setup.exe.sig" -exec gh release upload v$VERSION {} --clobber \; + mkdir -p windows-build + cp src-tauri/target/release/Markpad.exe "windows-build/Markpad_${VERSION}_x64.exe" + find src-tauri/target/release/bundle/nsis -name "*-setup.exe" -exec cp {} windows-build/ \; + find src-tauri/target/release/bundle/nsis -name "*-setup.exe.sig" -exec cp {} windows-build/ \; + + - name: Hand the Windows x64 build to the signing job + if: matrix.os == 'windows' && matrix.arch == 'x64' + uses: actions/upload-artifact@v7 + with: + name: windows-x64 + path: windows-build/ + if-no-files-found: error + overwrite: true + retention-days: 1 # --- Windows Build (ARM64) --- - name: Build Windows ARM64 @@ -330,14 +358,22 @@ jobs: - name: Upload Windows ARM64 Artifacts if: matrix.os == 'windows' && matrix.arch == 'arm64' shell: bash - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | VERSION="${{ needs.create-release.outputs.version }}" - cp src-tauri/target/aarch64-pc-windows-msvc/release/Markpad.exe "Markpad_${VERSION}_arm64.exe" - gh release upload v$VERSION "Markpad_${VERSION}_arm64.exe" --clobber - find src-tauri/target/aarch64-pc-windows-msvc/release/bundle/nsis -name "*-setup.exe" -exec gh release upload v$VERSION {} --clobber \; - find src-tauri/target/aarch64-pc-windows-msvc/release/bundle/nsis -name "*-setup.exe.sig" -exec gh release upload v$VERSION {} --clobber \; + mkdir -p windows-build + cp src-tauri/target/aarch64-pc-windows-msvc/release/Markpad.exe "windows-build/Markpad_${VERSION}_arm64.exe" + find src-tauri/target/aarch64-pc-windows-msvc/release/bundle/nsis -name "*-setup.exe" -exec cp {} windows-build/ \; + find src-tauri/target/aarch64-pc-windows-msvc/release/bundle/nsis -name "*-setup.exe.sig" -exec cp {} windows-build/ \; + + - name: Hand the Windows ARM64 build to the signing job + if: matrix.os == 'windows' && matrix.arch == 'arm64' + uses: actions/upload-artifact@v7 + with: + name: windows-arm64 + path: windows-build/ + if-no-files-found: error + overwrite: true + retention-days: 1 # --- Linux Build --- - name: Pre-download AppImage Dependencies @@ -504,16 +540,143 @@ jobs: find src-tauri/target/universal-apple-darwin/release/bundle/macos -name "*.app.tar.gz" -exec gh release upload v${{ needs.create-release.outputs.version }} {} --clobber \; find src-tauri/target/universal-apple-darwin/release/bundle/macos -name "*.app.tar.gz.sig" -exec gh release upload v${{ needs.create-release.outputs.version }} {} --clobber \; - generate-update-feed: + # Authenticode signing for Windows, through SignPath Foundation (#562). + # + # A job of its own because one signing request covers all four executables + # and the two architectures are two matrix entries. SignPath signs the + # contents of one Actions artifact, an approver clicks once, and the signed + # files come back; two requests would be two approvals for one release. + # + # It is also where Windows reaches the release page at all. The matrix now + # uploads a build artifact instead, so an approval that never comes leaves the + # draft without Windows assets rather than with unsigned ones. + # + # Without SIGNPATH_API_TOKEN the job still runs and uploads what the matrix + # built, unsigned. Same shape as the macOS certificate and for the same reason + # (#294): a release must not be blocked on credentials nobody has configured. + sign-windows: needs: [create-release, build] + runs-on: ubuntu-24.04 + env: + # At job level so the steps below can branch on whether signing is + # configured -- `if:` cannot read `secrets` directly. + SIGNPATH_API_TOKEN: ${{ secrets.SIGNPATH_API_TOKEN }} + VERSION: ${{ needs.create-release.outputs.version }} + steps: + - uses: actions/checkout@v7 + + - name: Setup Node.js + uses: actions/setup-node@v7 + with: + node-version: 24 + + # For `tauri signer sign`, which regenerates the updater signatures below. + - name: Install Frontend Dependencies + run: npm ci + + - name: Collect the Windows builds + uses: actions/download-artifact@v8 + with: + pattern: windows-* + merge-multiple: true + path: windows + + # The signing request is the four executables and nothing else: SignPath's + # artifact configuration names them by those file names, and the `.sig` + # files beside them are minisign signatures for the updater, which + # Authenticode has nothing to do with. + - name: Assemble the signing request + if: env.SIGNPATH_API_TOKEN != '' + run: | + set -euo pipefail + mkdir -p to-sign + cp windows/*.exe to-sign/ + found=$(ls to-sign | wc -l) + if [ "$found" -ne 4 ]; then + echo "::error::expected four Windows executables to sign, found $found" >&2 + exit 1 + fi + + # Named outside `windows-*` on purpose: a re-run of this job downloads + # that pattern, and an artifact from the previous attempt would come back + # as a second copy of the same four file names. + - name: Upload the signing request + id: signing_request + if: env.SIGNPATH_API_TOKEN != '' + uses: actions/upload-artifact@v7 + with: + name: signing-request + path: to-sign/ + if-no-files-found: error + overwrite: true + retention-days: 1 + + - name: Sign the Windows executables + if: env.SIGNPATH_API_TOKEN != '' + uses: signpath/github-action-submit-signing-request@v2 + with: + api-token: ${{ secrets.SIGNPATH_API_TOKEN }} + organization-id: '005f9e34-9ae9-453d-998e-d73e2390ce6b' + project-slug: 'markpad' + signing-policy-slug: 'release-signing' + artifact-configuration-slug: 'initial' + github-artifact-id: ${{ steps.signing_request.outputs.artifact-id }} + wait-for-completion: true + # The request waits for an approver to click, so the default ten + # minutes is a timeout on a person rather than on a service. An hour + # makes an unattended release pause instead of fail; a job that does + # time out is re-runnable, because nothing has been uploaded yet. + wait-for-completion-timeout-in-seconds: '3600' + output-artifact-directory: 'signed' + + # Authenticode rewrites the executable, so the `.sig` `tauri build` + # produced for each installer no longer describes the bytes the updater + # downloads -- every Windows install would reject its next update. Same + # problem the repacked AppImage has, and the same fix: re-sign, then let + # generate-update-feed read the signatures back off the release. + - name: Re-sign the signed installers + if: env.SIGNPATH_API_TOKEN != '' + env: + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + run: | + set -euo pipefail + signed=$(ls signed/*.exe | wc -l) + if [ "$signed" -ne 4 ]; then + echo "::error::SignPath returned $signed executables, expected four" >&2 + exit 1 + fi + cp signed/*.exe windows/ + for installer in windows/*-setup.exe; do + rm -f "$installer.sig" + npm run tauri signer sign -- "$installer" + if [ ! -f "$installer.sig" ]; then + echo "::error::signer sign did not produce $installer.sig" >&2 + exit 1 + fi + done + + - name: Upload Windows Artifacts + shell: bash + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + ls -la windows + gh release upload "v$VERSION" windows/*.exe windows/*.exe.sig --clobber + + generate-update-feed: + needs: [create-release, build, sign-windows] # Pinned like every other runner that this pipeline names. This job only runs # `gh` and `jq`, so the version buys little on its own -- what it buys is that # `ubuntu-latest` moving is a thing that happens to us with a commit rather # than without one. runs-on: ubuntu-24.04 # The updater feed must describe a complete release. Do not publish an - # update if any platform build failed or was cancelled. - if: ${{ always() && needs.create-release.result == 'success' && needs.build.result == 'success' }} + # update if any platform build failed or was cancelled -- including the + # Windows signing job, which is where the two Windows `.sig` files this job + # reads are uploaded. + if: ${{ always() && needs.create-release.result == 'success' && needs.build.result == 'success' && needs.sign-windows.result == 'success' }} steps: - name: Generate latest.json env: diff --git a/RELEASING.md b/RELEASING.md index eed4461e..ea5813a5 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -92,6 +92,29 @@ Apple can revoke a Developer ID certificate. Nobody can revoke this one. Two con Back it up in the same place as the minisign private key, and treat it with the same care. +### 8. Optional: Authenticode signing for Windows + +The certificate comes from [SignPath Foundation](https://signpath.org), free for open-source projects, and it is what stops SmartScreen calling Markpad an unrecognized app from an unknown publisher ([#334](https://github.com/sftwrdotdev/Markpad/issues/334), [#466](https://github.com/sftwrdotdev/Markpad/issues/466), [#562](https://github.com/sftwrdotdev/Markpad/issues/562)). Skip it and Windows releases behave exactly as they did before. + +Set up in [app.signpath.io](https://app.signpath.io), once: + +1. Install the **SignPath GitHub App** on `sftwrdotdev` with access to this repository, and add **GitHub.com** as a trusted build system linked to the project. Both are how SignPath establishes that the artifact came from a workflow run rather than from whoever holds the API token. +2. The **artifact configuration** describes the artifact the workflow submits — a ZIP holding the four executables, each `` with ``: `Markpad_*_x64-setup.exe`, `Markpad_*_x64.exe`, `Markpad_*_arm64-setup.exe`, `Markpad_*_arm64.exe`. +3. Put yourself in the signing policy's **approvers**. Every release stops for that click. +4. Create a **CI user with submitter permission** and generate an API token for it. + +**Add one secret** (Settings → Secrets and variables → Actions): + +| Name | Value | +|---|---| +| `SIGNPATH_API_TOKEN` | the CI user's API token | + +The other four values — organization id, project slug, signing policy slug, artifact configuration slug — are not secrets and are written into the `sign-windows` job in [`build.yml`](.github/workflows/build.yml). Change them there. + +With the secret set, `sign-windows` submits one request for all four executables and waits up to an hour for approval; nothing is uploaded before it comes back, so a timeout is a re-run of one job rather than a half-signed release. Without it, the job uploads what the matrix built, unsigned, and the release notes keep the SmartScreen note. + +**Authenticode changes the installer's bytes, so `*-setup.exe.sig` is regenerated after signing** — that file is the updater's minisign signature, and a stale one makes every Windows install reject its next update. This is why signing lives in a job of its own rather than in the build matrix. + ## Per-release workflow The workflow uses `npm ci`, so its installed dependency graph is exactly the committed lockfile. Do not replace it with `npm install` in release jobs. `scripts/releaseWorkflow.test.ts` guards that. @@ -122,12 +145,13 @@ The workflow uses `npm ci`, so its installed dependency graph is exactly the com Only one release build runs at a time — a second dispatch queues behind the first instead of racing it to the same draft. -4. **Wait** ~30 min for matrix builds to finish, plus ~2 min for `generate-update-feed`. +4. **Wait** ~30 min for matrix builds to finish, plus ~2 min for `generate-update-feed`. Once one-time setup step 8 is done, the `sign-windows` job pauses in between until you approve the signing request in SignPath — it emails you, and the job's log holds the link. 5. **Open the draft release** on the [Releases page](https://github.com/sftwrdotdev/Markpad/releases). Verify the assets: - **macOS**: `*.dmg`, `*.app.tar.gz`, `*.app.tar.gz.sig` - Once one-time setup step 6 is done, check the signature took as well: mount the `.dmg` and run `codesign -d -r-` against the `.app` inside it. It must print `certificate leaf = H"…"`. `code object is not signed at all` means the secrets are missing or the import step exited early — the build is green either way, and shipping it costs every macOS user their folder grants again. - **Windows x64**: `Markpad__x64.exe` (portable), `*_x64-setup.exe` (NSIS installer), `*_x64-setup.exe.sig` - **Windows ARM64**: `Markpad__arm64.exe` (portable), `*_arm64-setup.exe` (NSIS installer), `*_arm64-setup.exe.sig` + - Once one-time setup step 8 is done, check the signature took: right-click a downloaded `.exe` → *Properties* → *Digital Signatures*, which must list a certificate. An unsigned build is green either way, and the release notes will have said the executables are signed. - **Linux**: `*.deb`, `*.rpm`, `*.AppImage`, `*.AppImage.sig` - **Update feed**: `latest.json` (one entry per successfully built platform) 6. **Click "Publish release"** — this is the gate. It activates auto-update for all clients pointing at `releases/latest/download/latest.json`, **and** it starts [`publish-packages.yml`](.github/workflows/publish-packages.yml), which pushes to Chocolatey and the Snap Store. @@ -163,6 +187,8 @@ Say so in that release's notes, e.g.: | Build fails: "missing `TAURI_SIGNING_PRIVATE_KEY`" | Step 2 of one-time setup wasn't done, or Secret name doesn't match. | | macOS build fails at `security import` | The `.p12` was exported by a recent `openssl` with its default cipher, which `security` cannot read (it reports a MAC failure, not a cipher failure). Re-export from Keychain Access, or pass `-legacy -certpbe PBE-SHA1-3DES -keypbe PBE-SHA1-3DES -macalg sha1`. | | macOS build logs `no identity found` | The certificate is a CA rather than a leaf — `codesign` will not sign with it. Keychain Access's *Create a Certificate…* produces the right kind; `openssl req -x509` needs an explicit `basicConstraints=critical,CA:FALSE`. | +| `sign-windows` fails with a timeout | Nobody approved the signing request within the hour. Nothing was uploaded; re-run the job and approve it. | +| `sign-windows` fails before submitting | Usually the SignPath GitHub App is not installed on the repository, or GitHub.com is not a trusted build system for the project — one-time setup step 8.1. | | `generate-update-feed` succeeds but `latest.json` lacks a platform | That platform's matrix build failed silently (or the `.sig` file wasn't produced). Check the failed build's logs. | | `latest.json` missing entirely | The `generate-update-feed` job didn't run — usually because no `*.sig` files were uploaded. Check the `Upload * Artifacts` steps. | | Users don't see the update | (1) Did you click *Publish release*? Drafts aren't visible to clients. (2) Is the user on a version older than the first auto-update-capable release? They need a one-time manual reinstall. | @@ -174,5 +200,5 @@ Say so in that release's notes, e.g.: ## Out of scope (not handled by this workflow) - **Apple Developer ID code-signing & notarization** — not done, and one-time setup step 6 is not a substitute: a self-signed certificate gives the bundle a stable identity for TCC, but macOS still shows a Gatekeeper warning on first launch because the app is not notarized. Minisign verification by the updater is independent of both. -- **Windows Authenticode signing** — neither the portable `.exe` nor the `*-setup.exe` NSIS installer is signed with a code-signing certificate. Users may see a SmartScreen warning. Minisign verification by the updater is independent. +- **Windows Authenticode signing without SignPath** — one-time setup step 8 is the only path wired here. With it unconfigured, neither the portable `.exe` nor the `*-setup.exe` NSIS installer is signed, and users may see a SmartScreen warning. Minisign verification by the updater is independent of both. - **Retroactive signing** of older releases. diff --git a/scripts/releaseWorkflow.test.ts b/scripts/releaseWorkflow.test.ts index febe5536..68e5b501 100644 --- a/scripts/releaseWorkflow.test.ts +++ b/scripts/releaseWorkflow.test.ts @@ -1,7 +1,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { readSource, sliceBetween, sliceFrom } from './sourceTree.js'; +import { offsetOf, readSource, sliceBetween, sliceFrom } from './sourceTree.js'; const workflow = readSource('.github/workflows/build.yml'); const publishWorkflow = readSource('.github/workflows/publish-packages.yml'); @@ -454,14 +454,100 @@ test('a signing identity that is only half configured stops the release', () => assert.match(step, /if \[ -z "\$\{MACOS_SIGNING_IDENTITY:-\}" \]; then[\s\S]*?exit 1/); }); +/** A workflow job as text, comment lines removed. */ +function jobBody(start: string, end: string): string { + return sliceBetween(workflow, start, end) + .split('\n') + .filter((line) => !/^\s*#/.test(line)) + .join('\n'); +} + +test('no unsigned Windows executable can reach the release page', () => { + // The Windows halves of the matrix used to `gh release upload` straight from + // the runner, which puts the bytes users download on the page before anything + // has signed them -- and a later failure leaves them there. They now leave the + // matrix as build artifacts, and `sign-windows` is the only place a Windows + // file is uploaded. + const matrix = jobBody('\n build:', '\n sign-windows:'); + const uploads = matrix.split('\n').filter((line) => /gh release upload/.test(line) && /\.exe/.test(line)); + assert.deepEqual(uploads, [], 'the build matrix uploads a Windows executable to the release'); + assert.match(jobBody('\n sign-windows:', '\n generate-update-feed:'), /gh release upload "v\$VERSION" windows\/\*\.exe/); +}); + +test('one approval covers every Windows executable', () => { + // SignPath signs the contents of one Actions artifact and an approver + // releases each request by hand, so a request per architecture is two clicks + // for one release -- and two chances to sign half of it. The four executables + // are collected into one artifact first, and the count is asserted in the job + // rather than left to whichever files the matrix happened to produce. + const job = jobBody('\n sign-windows:', '\n generate-update-feed:'); + const submits = [...job.matchAll(/uses: signpath\/github-action-submit-signing-request@/g)]; + assert.equal(submits.length, 1, `expected one signing request per release, found ${submits.length}`); + assert.match(job, /pattern: windows-\*/); + assert.match(job, /merge-multiple: true/); + assert.match(job, /\[ "\$found" -ne 4 \]/); + assert.match(job, /\[ "\$signed" -ne 4 \]/); +}); + +test('the updater signature describes the bytes Authenticode left behind', () => { + // `tauri build` signs the installer, SignPath then rewrites it, and + // `*-setup.exe.sig` is a minisign signature over the pre-signing bytes. Ship + // that and every Windows install rejects its next update -- the same defect + // the repacked AppImage had. So the `.sig` is regenerated from the signed + // file, and both the regeneration and the upload come after the signed + // executables land. + const job = jobBody('\n sign-windows:', '\n generate-update-feed:'); + assert.match(job, /rm -f "\$installer\.sig"[\s\S]*?npm run tauri signer sign -- "\$installer"/); + assert.ok( + offsetOf(job, 'cp signed/*.exe windows/') < offsetOf(job, 'npm run tauri signer sign'), + 'the installer is re-signed before the signed one replaces it', + ); + assert.ok( + offsetOf(job, 'npm run tauri signer sign') < offsetOf(job, 'gh release upload'), + 'the release gets the signature that was made before signing', + ); + + // And the feed that points at those signatures cannot be built from a run + // where this job failed: it reads the `.sig` files back off the release. + const feed = sliceBetween(workflow, '\n generate-update-feed:', '\n steps:'); + assert.match(feed, /needs: \[create-release, build, sign-windows\]/); + assert.match(feed, /needs\.sign-windows\.result == 'success'/); +}); + +test('a release without the SignPath token still ships Windows', () => { + // #294 again: a workflow that requires signing credentials blocks every + // release until someone configures them. Each step that talks to SignPath is + // conditional on the token; the upload that puts Windows on the release page + // is not, so an unconfigured repository gets exactly today's release. + const job = jobBody('\n sign-windows:', '\n generate-update-feed:'); + const upload = sliceFrom(job, '- name: Upload Windows Artifacts'); + assert.doesNotMatch(upload, /if: env\.SIGNPATH_API_TOKEN/); + for (const step of ['Assemble the signing request', 'Upload the signing request', 'Sign the Windows executables', 'Re-sign the signed installers']) { + assert.match( + sliceFrom(job, `- name: ${step}`), + /^\s+if: env\.SIGNPATH_API_TOKEN != ''$/m, + `${step} runs whether or not signing is configured`, + ); + } + + // The release notes tell people which of the two happened, from the same + // value. A note that says the executables are unsigned, on a release that + // signed them, is two places answering one question differently. + const notes = sliceBetween(workflow, '- name: Compose the download table', '- name: Create Release'); + assert.match(notes, /SIGNPATH_API_TOKEN: \$\{\{ secrets\.SIGNPATH_API_TOKEN \}\}/); + assert.match(notes, /if \[ -z "\$\{SIGNPATH_API_TOKEN:-\}" \]; then[\s\S]*?Windows SmartScreen Notice[\s\S]*?else[\s\S]*?Windows executables are signed/); +}); + test('RELEASING.md names the signing secrets the workflow reads', () => { // Whoever configures the secrets works from the runbook and never opens the // workflow, so a rename on either side sends them to create a secret nothing // reads. The failure is the quiet one this step is built to allow: the import // exits 0, the build stays green, and the bundle ships unsigned -- which // reaches users as folder access being asked for all over again. - const step = sliceBetween(workflow, 'Import macOS signing certificate', 'Build MacOS (Universal)'); - const secrets = [...new Set(step.match(/secrets\.[A-Z_]+/g) ?? [])]; + const step = + sliceBetween(workflow, 'Import macOS signing certificate', 'Build MacOS (Universal)') + + sliceBetween(workflow, '\n sign-windows:', '\n generate-update-feed:'); + const secrets = [...new Set(step.match(/secrets\.[A-Z_]+/g) ?? [])].filter((s) => s !== 'secrets.GITHUB_TOKEN'); assert.ok(secrets.length > 0, 'the signing step reads no secrets at all'); for (const secret of secrets) { const name = secret.slice('secrets.'.length);