From 0b1a2c90278c81599526709a7a0abeb5889a07b0 Mon Sep 17 00:00:00 2001 From: elkaix Date: Sun, 23 Aug 2026 12:18:03 -0400 Subject: [PATCH 1/2] ci: make a manual desktop run rehearse the signing gates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A manual run already built both platforms with --publish never, but every signing gate was tag-gated, so the rehearsal skipped exactly the steps worth rehearsing. The credential check, the notarization and staple checks, and the Windows signature verification all first executed on the tag that depended on them — which is how 0.2.1 shipped two failed builds. Ungate the four verification steps. Everything that touches the releases repository stays behind the tag: the on-main precondition, the token mints, the draft creation, and the asset uploads. A manual run is now the full release path minus publication, with the installers attached to the run. A test locks the arrangement in, because re-adding one of those `if:` lines would quietly restore the hole rather than break anything visible: restoring the gate on the Windows credential check fails it. --- .github/workflows/desktop-release.yml | 13 +++++++------ .../tests/desktop-release-workflow.spec.ts | 18 ++++++++++++++++++ 2 files changed, 25 insertions(+), 6 deletions(-) diff --git a/.github/workflows/desktop-release.yml b/.github/workflows/desktop-release.yml index 23d0da60..38343510 100644 --- a/.github/workflows/desktop-release.yml +++ b/.github/workflows/desktop-release.yml @@ -12,6 +12,11 @@ concurrency: group: desktop-release-${{ github.ref }} cancel-in-progress: false +# A manual run is a release rehearsal: both platforms build and go through the +# same signing gates a tag does, with nothing published and the installers +# attached to the run instead. Only the release-repo steps stay tag-gated, so a +# credential that has expired or a configuration Electron Builder no longer +# accepts surfaces on demand rather than on the tag that needed it. jobs: # Create one draft before either platform uploads. It stays private until both # jobs and the final downloaded-asset validation succeed. @@ -182,8 +187,7 @@ jobs: # A tag build that quietly produces an unsigned app is worse than a failed # one: macOS rejects unsigned updates, so it ships a release users cannot # install or update from. Manual runs stay free to build unsigned. - - name: Require signing for tagged releases - if: startsWith(github.ref, 'refs/tags/desktop-v') + - name: Require signing working-directory: apps/desktop run: node --import tsx scripts/assert-release-signing.ts @@ -206,7 +210,6 @@ jobs: node --import tsx apps/desktop/scripts/verify-update-manifest.ts mac apps/desktop/dist "$DESKTOP_VERSION" - name: Verify macOS signatures and notarization - if: startsWith(github.ref, 'refs/tags/desktop-v') shell: bash run: | set -euo pipefail @@ -347,8 +350,7 @@ jobs: if [ -n "$value" ]; then printf '%s<<%s\n%s\n%s\n' "$name" "$delimiter" "$value" "$delimiter" >> "$GITHUB_ENV"; fi done - - name: Require signing for tagged Windows releases - if: startsWith(github.ref, 'refs/tags/desktop-v') + - name: Require signing working-directory: apps/desktop run: node --import tsx scripts/assert-windows-release-signing.ts @@ -366,7 +368,6 @@ jobs: node --import tsx apps/desktop/scripts/verify-update-manifest.ts win apps/desktop/dist "$DESKTOP_VERSION" - name: Verify Windows release signatures - if: startsWith(github.ref, 'refs/tags/desktop-v') working-directory: apps/desktop run: node --import tsx scripts/verify-windows-signatures.ts diff --git a/apps/desktop/tests/desktop-release-workflow.spec.ts b/apps/desktop/tests/desktop-release-workflow.spec.ts index 9f335ea7..ef4adf68 100644 --- a/apps/desktop/tests/desktop-release-workflow.spec.ts +++ b/apps/desktop/tests/desktop-release-workflow.spec.ts @@ -41,6 +41,24 @@ describe('desktop release workflow', () => { expect(workflow).toContain("if: startsWith(github.ref, 'refs/tags/desktop-v')\n shell: bash\n env:\n GH_TOKEN:") }) + it('runs every signing gate on a manual rehearsal, not only on a tag', () => { + // A manual run is the only chance to prove the signing path before the tag + // that depends on it. Gating these behind the tag would make a rehearsal + // pass while the credentials it was meant to exercise were already broken. + const rehearsed = [ + 'scripts/assert-release-signing.ts', + 'scripts/assert-windows-release-signing.ts', + 'scripts/verify-windows-signatures.ts', + 'xcrun stapler validate', + ] + for (const step of rehearsed) { + const stepIndex = workflow.indexOf(step) + expect(stepIndex).toBeGreaterThan(-1) + const precedingStep = workflow.lastIndexOf(' - name:', stepIndex) + expect(workflow.slice(precedingStep, stepIndex)).not.toContain('refs/tags/desktop-v') + } + }) + it('downloads and revalidates uploaded assets before publishing', () => { expect(workflow).toContain('gh release download "$RELEASE_TAG"') expect(workflow).toContain('scripts/verify-update-manifest.ts mac release-assets') From 91f88dcf1fb154c12a8bd70416bdb460178df8e0 Mon Sep 17 00:00:00 2001 From: elkaix Date: Sun, 23 Aug 2026 12:30:10 -0400 Subject: [PATCH 2/2] ci: address review on the rehearsal gates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two comments, both correct. The macOS note still said manual runs "stay free to build unsigned", which this change had just made false, and the Windows note described unsigned artifacts as an outcome rather than a failure. Both now describe what the run actually does. The test only looked for the tag condition, so `if: github.event_name == 'workflow_dispatch'` would satisfy it while skipping the tag path — the same hole entered from the other side. It now rejects any condition on those steps, and that scenario fails it. --- .github/workflows/desktop-release.yml | 11 ++++++----- apps/desktop/tests/desktop-release-workflow.spec.ts | 6 +++++- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/.github/workflows/desktop-release.yml b/.github/workflows/desktop-release.yml index 38343510..09e23fbd 100644 --- a/.github/workflows/desktop-release.yml +++ b/.github/workflows/desktop-release.yml @@ -184,9 +184,10 @@ jobs: echo 'No macOS signing certificate configured; building unsigned.' fi - # A tag build that quietly produces an unsigned app is worse than a failed + # A build that quietly produces an unsigned app is worse than a failed # one: macOS rejects unsigned updates, so it ships a release users cannot - # install or update from. Manual runs stay free to build unsigned. + # install or update from. Manual runs are held to the same bar, which is + # what makes them a rehearsal rather than a smoke test. - name: Require signing working-directory: apps/desktop run: node --import tsx scripts/assert-release-signing.ts @@ -316,9 +317,9 @@ jobs: working-directory: apps/desktop run: node --import tsx scripts/stage-runtime.ts - # Only non-empty WIN_CSC_* signing secrets are exported. Without them, - # Windows artifacts are unsigned and installers trigger a SmartScreen - # warning on first run. + # Only non-empty WIN_CSC_* signing secrets are exported. Without them the + # artifacts are unsigned and installers trigger a SmartScreen warning, so + # the credential check below fails the run rather than shipping one. - name: Resolve Windows signing credentials shell: bash env: diff --git a/apps/desktop/tests/desktop-release-workflow.spec.ts b/apps/desktop/tests/desktop-release-workflow.spec.ts index ef4adf68..043df82f 100644 --- a/apps/desktop/tests/desktop-release-workflow.spec.ts +++ b/apps/desktop/tests/desktop-release-workflow.spec.ts @@ -55,7 +55,11 @@ describe('desktop release workflow', () => { const stepIndex = workflow.indexOf(step) expect(stepIndex).toBeGreaterThan(-1) const precedingStep = workflow.lastIndexOf(' - name:', stepIndex) - expect(workflow.slice(precedingStep, stepIndex)).not.toContain('refs/tags/desktop-v') + // Any condition at all, not just a tag test, would skip one trigger: + // `github.event_name == 'workflow_dispatch'` reintroduces the same hole + // from the other side. These steps must be unconditional. + const declaration = workflow.slice(precedingStep, stepIndex) + expect(declaration).not.toMatch(/^\s+if:/mu) } })