Skip to content

ci(win): sign the Windows installer via SignPath (free for OSS) - #283

Closed
EtienneLescot wants to merge 2 commits into
mainfrom
ci/windows-code-signing
Closed

ci(win): sign the Windows installer via SignPath (free for OSS)#283
EtienneLescot wants to merge 2 commits into
mainfrom
ci/windows-code-signing

Conversation

@EtienneLescot

@EtienneLescot EtienneLescot commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator

Pivoted from Azure Trusted Signing — see Why not Azure below.

Why

The NSIS installer published on GitHub releases is unsigned: there is no signtool, certificate or signing configuration anywhere in the repo, while macOS has had a full sign + notarize + verify path for a while.

The cost is not just "Unknown publisher" in the UAC prompt. SmartScreen keys reputation to the signing identity for a signed binary, and to the file hash for an unsigned one. Every release therefore starts from zero: users who had stopped seeing "Windows protected your PC" on 1.8.0 meet the full interstitial again on 1.9.0, and again on 1.10.0. Signed, that reputation accumulates across versions instead of resetting.

The Store package is unaffected either way — Microsoft re-signs it during certification — so this PR touches only the NSIS job.

Why not Azure

The first version of this PR used Azure Trusted Signing. It is not reachable here. Microsoft's current docs state:

Public Trust certificates are available to organizations in the United States, Canada, the European Union, [...]. Individual developers must be located in the United States or Canada.

The maintainer is in France, so the individual path is closed, and the EU organization path needs a registered legal entity with a business identifier. SignPath Foundation issues OV certificates free of charge to OSI-licensed projects, which OpenScreen qualifies for — and it costs nothing rather than $9.99/month.

What changes

SignPath signs out of band, not inside electron-builder, so the pipeline shape differs from the Azure version:

  1. Build produces an unsigned installer, exactly as today.
  2. It is uploaded as a workflow artifact with one day of retention.
  3. SignPath pulls it by artifact id from the run, signs it on its own HSM, returns the file.
  4. The signed file is swapped back over the build output.
  5. The signature is verified, then the existing upload step runs untouched.

Step 4 is what keeps the blast radius small: the published openscreen-windows artifact keeps its name, path and shape whether or not signing ran, so the release publisher needs no change. Step 3 is why the workflow now grants actions: read — SignPath reads the run's artifacts with the job's own GITHUB_TOKEN.

Because SignPath fetches from the workflow run rather than accepting an upload, a signature attests the binary came from this repository's CI on tagged source, not from someone's laptop.

A release can block on a human. Foundation release policies require manual approval of every signing request. The wait is raised to 3600 s; the action's 600 s default expires while the approver is still reading the notification mail.

Unchanged from the Azure version

Gated on the secrets being present, mirroring the macOS job's steps.signing.outputs.enabled idiom:

  • no secrets → build succeeds, unsigned installer. This is the state on merge, and on forks.
  • all four → signed, then verified.
  • some but not all → job fails. A half-configured signer is a misnamed secret, not a decision, and the quiet alternative is publishing unsigned believing you signed.

Verify installer signature asserts on the artifact rather than on the pipeline having run, because a signing step that no-ops still exits 0. It requires Get-AuthenticodeSignature to report Valid and a timestamp to be present — without one, the signature stops validating the day the certificate expires, retroactively invalidating every release already in users' hands.

Verification

The gate is branchy, so it is tested by extracting the real script out of build.yml — including the list of secret names from its own required=(...) array, so the test cannot drift from what CI runs:

gate requires 4 secrets: SIGNPATH_API_TOKEN SIGNPATH_ORGANIZATION_ID SIGNPATH_PROJECT_SLUG SIGNPATH_SIGNING_POLICY_SLUG

PASS  no secrets -> unsigned build, no failure  (exit=0 enabled=false)
PASS  all secrets -> signing enabled            (exit=0 enabled=true)
PASS  one secret missing -> hard failure        (exit=1 enabled=false)

Action inputs were checked against action.yml at tag v2 rather than main.

Also adds CODE_SIGNING_POLICY.md

The Foundation's conditions require a published code signing policy crediting SignPath and listing team roles — it is an onboarding prerequisite, not documentation for its own sake. It states the single-maintainer reality, that the key never leaves SignPath's HSM, and that an OV certificate means SmartScreen reputation still builds over time rather than vanishing at once.

It carries one TODO for the privacy policy URL, which the conditions also require and which I could not source from the repo.

Merging this does not sign anything yet

It is inert until the SignPath onboarding completes and the four secrets exist. Merging now means the first release built afterwards is signed automatically, with no further code change.

Summary by CodeRabbit

  • New Features

    • Windows installers can now be digitally signed through SignPath.
    • Signed installers are verified for authenticity and timestamping before release.
    • Unsigned Windows builds remain available when signing is not configured.
    • Incomplete signing configuration is rejected to prevent invalid releases.
  • Documentation

    • Added guidance for Windows and macOS signing coverage, access controls, SmartScreen behavior, privacy, tampering reports, and credential rotation.

SmartScreen keys an installer's reputation to the signing identity when it is
signed and to the file hash when it is not, so the unsigned NSIS installer
restarts from zero reputation at every release: users who had stopped seeing
the "Windows protected your PC" interstitial on one version meet it again on
the next, forever.

Signing is gated on the secrets existing, like the macOS job, so this is inert
until an Azure Trusted Signing account is configured — builds keep producing an
unsigned installer meanwhile, and forks are unaffected. A partial configuration
fails the job instead, since it is a typo rather than a choice and the quiet
alternative is publishing unsigned.

The key stays in Microsoft's HSM, so nothing is imported on the runner and no
certificate material lives in a secret. Arguments go through a bash array
because publisherName must match the certificate subject exactly and legal
names contain spaces.

Only the NSIS job changes; the Store package is re-signed by Microsoft during
certification and is left alone.
@coderabbitai

coderabbitai Bot commented Aug 5, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The Windows workflow now supports optional SignPath signing. It rejects partial configuration, preserves unsigned builds when secrets are absent, exchanges installer artifacts for signing, and verifies Authenticode signatures and timestamps. Documentation defines the signing process and policy.

Changes

Windows installer signing

Layer / File(s) Summary
SignPath configuration and release guidance
.github/workflows/build.yml, technical-documentation/engineering/release-and-secrets.md
The workflow validates all four SignPath secrets and grants Actions API read access. The documentation describes credentials, permissions, HSM storage, unsigned fallback, approval timeouts, SmartScreen effects, and token rotation.
Artifact submission and signed installer replacement
.github/workflows/build.yml
The workflow uploads unsigned installers, submits them to SignPath, waits for approval for up to one hour, and replaces them with the signed artifact.
Signature verification and signing policy
.github/workflows/build.yml, CODE_SIGNING_POLICY.md
The workflow requires a valid Authenticode signature and timestamp. The policy documents signing coverage, roles, access controls, guarantees, privacy disclosures, and tampering reports.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Suggested reviewers: siddharthvaddem

Sequence Diagram(s)

sequenceDiagram
  participant GitHub Actions
  participant Windows Build
  participant SignPath
  participant Authenticode Verification
  GitHub Actions->>Windows Build: Produce unsigned installer
  GitHub Actions->>SignPath: Submit installer artifact
  SignPath-->>GitHub Actions: Return signed installer artifact
  GitHub Actions->>Windows Build: Replace installer
  GitHub Actions->>Authenticode Verification: Validate signature and timestamp
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the main change: SignPath-based Windows installer signing in CI.
Description check ✅ Passed The description thoroughly covers the motivation, implementation, conditional behavior, testing, platform impact, and release implications.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch ci/windows-code-signing

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Azure Artifact Signing is not reachable here: Microsoft restricts individual
developer identity validation to the United States and Canada, and the EU path
requires a registered legal entity. SignPath Foundation issues OV certificates
free of charge to OSI-licensed projects, which OpenScreen qualifies for.

The shape of the integration changes with it. SignPath signs out of band rather
than inside electron-builder: the build produces an unsigned installer, uploads
it as a short-lived workflow artifact, and SignPath pulls it by artifact id,
signs it on its own HSM and hands the file back. It is swapped over the build
output so the published openscreen-windows artifact keeps its name and shape
whether or not signing ran, and the release publisher is unaffected. Hence the
new actions: read permission — SignPath reads the run's artifacts with the job's
own GITHUB_TOKEN.

Foundation release policies require a human to approve each request, so the wait
is raised to an hour; the action's 600 s default expires while the approver is
still reading the notification mail.

The gate and the signature verification are unchanged from the Azure version:
inert with no secrets, hard failure on a partial configuration, and an assertion
on the artifact itself since a signing step that no-ops still exits 0.

Adds CODE_SIGNING_POLICY.md, which the Foundation's conditions require to be
published and to credit SignPath. It leaves one TODO for the privacy policy URL.
@EtienneLescot EtienneLescot changed the title ci(win): sign the Windows installer via Azure Trusted Signing ci(win): sign the Windows installer via SignPath (free for OSS) Aug 5, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@CODE_SIGNING_POLICY.md`:
- Around line 77-79: Replace the TODO placeholder in the policy document with
the maintained public privacy-policy URL, reusing the same URL declared for the
Microsoft Store submission. Ensure the URL is presented as an explicit
privacy-policy reference before SignPath onboarding or renewal.
- Around line 40-51: The code-signing policy must require signing workflows to
run for an immutable tag matching the requested release tag. Update the release
gate in build.yml to allow signing only when github.ref equals
refs/tags/${input_release_tag}, or explicitly revise the policy to exclude
manual-dispatch releases from tagged-source attestations.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 812bf86c-bb2a-4b7a-8e42-6ea6dafeb280

📥 Commits

Reviewing files that changed from the base of the PR and between fd2c9fb and e453dca.

📒 Files selected for processing (3)
  • .github/workflows/build.yml
  • CODE_SIGNING_POLICY.md
  • technical-documentation/engineering/release-and-secrets.md
🚧 Files skipped from review as they are similar to previous changes (1)
  • technical-documentation/engineering/release-and-secrets.md

Comment thread CODE_SIGNING_POLICY.md
Comment on lines +40 to +51
1. A tagged release triggers the `Build Electron App` workflow on GitHub Actions.
2. The workflow builds the installer from the tagged source and uploads it as a
workflow artifact.
3. SignPath retrieves that artifact directly from the workflow run, verifying it
came from this repository's CI rather than from an uploaded file.
4. The maintainer approves the signing request in the SignPath dashboard.
5. The signed installer is returned to the workflow, its signature is verified,
and it is published as the release artifact.

Because the artifact is pulled from the workflow run rather than submitted by
hand, a signature attests that the binary was built by this repository's CI from
tagged source.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect workflow triggers and any ref checks around the signing step.
sed -n '1,175p' .github/workflows/build.yml
rg -n -C5 'workflow_dispatch|refs/tags|github\.ref|Sign installer via SignPath' .github/workflows

Repository: getopenscreen/openscreen

Length of output: 29484


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the macOS signing job, Windows Store signing job, and release-job gating for ref/tag checks.
sed -n '170,250p' .github/workflows/build.yml
sed -n '250,400p' .github/workflows/build.yml
sed -n '400,510p' .github/workflows/build.yml
sed -n '570,620p' .github/workflows/build.yml

# Verify local YAML event expression forms do not enforce ref tag constraints besides the publish/release step.
python3 - <<'PY'
from pathlib import Path
p = Path('.github/workflows/build.yml')
text = p.read_text()
events = []
for line in text.splitlines():
    line = line.strip()
    if line.startswith(('push:', 'release:', 'workflow_dispatch:', 'schedule:', 'pull_request:')):
        events.append(line)
print("workflow event declarations in build.yml:")
for e in events:
    print("-", e)
print("contains refs/tags check:", "refs/tags" in text)
print("macOS signing step names:", [line.strip() for line in text.splitlines() if "Signing" in line or "notarize" in line.lower() or "SignPath" in line])
PY

Repository: getopenscreen/openscreen

Length of output: 19954


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Confirm manual dispatch has no release-specific trigger constraint and inspect Linux build context.
sed -n '1,15p' .github/workflows/build.yml
sed -n '200,240p' .github/workflows/build.yml
sed -n '500,585p' .github/workflows/build.yml

Repository: getopenscreen/openscreen

Length of output: 5168


Require an immutable tag before signing.

build.yml runs from any manual dispatch to build-windows and build-macos, and those jobs sign when secrets are present. The release gate accepts workflow_dispatch with any release_tag, even if that tag has no refs/tags/ ref. Gate signing on github.ref == "refs/tags/${input_release_tag}" or amend the policy to state that manual-dispatch releases are not tagged-source attestations.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@CODE_SIGNING_POLICY.md` around lines 40 - 51, The code-signing policy must
require signing workflows to run for an immutable tag matching the requested
release tag. Update the release gate in build.yml to allow signing only when
github.ref equals refs/tags/${input_release_tag}, or explicitly revise the
policy to exclude manual-dispatch releases from tagged-source attestations.

Comment thread CODE_SIGNING_POLICY.md
Comment on lines +77 to +79
<!-- TODO(maintainer): link the published privacy policy URL here — the SignPath
Foundation conditions require this page to reference it, and the same URL is
already declared in the Microsoft Store submission. -->

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Replace the required privacy-policy placeholder.

This TODO leaves the required privacy-policy link absent. The text states that the SignPath Foundation requires this reference. Add the maintained public URL before using this policy for SignPath onboarding or renewal.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@CODE_SIGNING_POLICY.md` around lines 77 - 79, Replace the TODO placeholder in
the policy document with the maintained public privacy-policy URL, reusing the
same URL declared for the Microsoft Store submission. Ensure the URL is
presented as an explicit privacy-policy reference before SignPath onboarding or
renewal.

@EtienneLescot

Copy link
Copy Markdown
Collaborator Author

Superseded: distribution strategy changed rather than the signing setup.

Verified on the actual CI artifacts that both the NSIS installer and the AppX come out unsigned — the Microsoft signature is applied during Store certification and only ever exists on the copy hosted in the Store:

AppX (CI artifact) : NotSigned
EXE  (NSIS)        : NotSigned

So rather than buying a certificate to smooth the GitHub .exe, the Store becomes the recommended install path (Microsoft-signed, no SmartScreen, free — the developer account already covers it) and the .exe stays as a documented fallback. Replaced by a PR doing that.

Paying for a certificate stays open as a later, optional call once install traffic shows how much the fallback path actually matters.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant