Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -123,11 +123,18 @@ jobs:
needs: preflight
runs-on: windows-latest
env:
# Marks this as a release build. The signer and the notarization hook both
# read it and refuse to produce an unsigned artifact, rather than warning
# and carrying on as they do for developer builds.
TRANSTRACK_RELEASE_CHANNEL: public
TRANSTRACK_SIGN_MODE: ${{ needs.preflight.outputs.windows_mode }}
ESIGNER_USERNAME: ${{ secrets.ESIGNER_USERNAME }}
ESIGNER_PASSWORD: ${{ secrets.ESIGNER_PASSWORD }}
ESIGNER_CREDENTIAL_ID: ${{ secrets.ESIGNER_CREDENTIAL_ID }}
ESIGNER_TOTP_SECRET: ${{ secrets.ESIGNER_TOTP_SECRET }}
# CodeSignTool is not present on the runner image; the step below installs
# it here. Overridable for self-hosted runners that pre-provision it.
ESIGNER_TOOL_PATH: ${{ vars.ESIGNER_TOOL_PATH || 'D:\CodeSignTool\CodeSignTool.bat' }}
CSC_LINK: ${{ secrets.CSC_LINK }}
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
steps:
Expand All @@ -138,6 +145,35 @@ jobs:
node-version: '22'
cache: 'npm'

# Without this the eSigner path cannot work: the signer resolves
# ESIGNER_TOOL_PATH and the runner image has no CodeSignTool on it, so the
# build failed part-way through with "not found: undefined".
- name: Install SSL.com CodeSignTool
if: needs.preflight.outputs.windows_mode == 'ssl_esigner'
shell: pwsh
run: |
$dest = Split-Path -Parent $env:ESIGNER_TOOL_PATH
if (Test-Path $env:ESIGNER_TOOL_PATH) {
Write-Host "CodeSignTool already present at $env:ESIGNER_TOOL_PATH"
exit 0
}
if (-not $env:ESIGNER_TOOL_URL) {
Write-Host "::error::ssl_esigner mode selected but CodeSignTool is not installed and no ESIGNER_TOOL_URL repository variable is set."
Write-Host "::error::Set the ESIGNER_TOOL_URL variable to the CodeSignTool zip from your SSL.com dashboard, or set ESIGNER_TOOL_PATH on a self-hosted runner that already has it."
exit 1
}
New-Item -ItemType Directory -Force -Path $dest | Out-Null
$zip = Join-Path $env:RUNNER_TEMP 'codesigntool.zip'
Invoke-WebRequest -Uri $env:ESIGNER_TOOL_URL -OutFile $zip
Expand-Archive -Path $zip -DestinationPath $dest -Force
if (-not (Test-Path $env:ESIGNER_TOOL_PATH)) {
Write-Host "::error::CodeSignTool was extracted to $dest but $env:ESIGNER_TOOL_PATH does not exist. Check the archive layout and adjust the ESIGNER_TOOL_PATH variable."
Get-ChildItem -Recurse $dest | Select-Object -First 40 | ForEach-Object { Write-Host $_.FullName }
exit 1
}
env:
ESIGNER_TOOL_URL: ${{ vars.ESIGNER_TOOL_URL }}

- name: Install npm dependencies
run: npm ci

Expand All @@ -147,6 +183,16 @@ jobs:
- name: Build & sign Windows installer (electron-builder)
run: npm run dist:win:enterprise

# electron-builder reports success whether or not the hook signed
# anything, so the artifact is inspected rather than assumed. This reads
# the PE certificate table and asks Windows for the trust verdict.
- name: Verify the installer is actually signed
shell: pwsh
run: |
$exe = Get-ChildItem release/enterprise/*.exe | Select-Object -First 1
if (-not $exe) { Write-Host "::error::No installer produced"; exit 1 }
node scripts/verify-artifact-signature.mjs $exe.FullName

- name: Upload installer
uses: actions/upload-artifact@v7
with:
Expand All @@ -159,9 +205,14 @@ jobs:
needs: preflight
runs-on: macos-latest
env:
# See build-windows: makes an un-notarized artifact a build failure rather
# than a warning that scrolls past.
TRANSTRACK_RELEASE_CHANNEL: public
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_PASSWORD: ${{ secrets.APPLE_APP_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
# electron-builder accepts either a path or base64 content here, and a
# secret can only hold the latter.
CSC_LINK: ${{ secrets.APPLE_CERT_BASE64 }}
CSC_KEY_PASSWORD: ${{ secrets.APPLE_CERT_PASSWORD }}
steps:
Expand All @@ -181,6 +232,18 @@ jobs:
- name: Build, sign & notarize macOS DMG
run: npm run dist:mac:enterprise

# Gatekeeper's own verdict, rather than trusting that the afterSign hook
# ran. `source=Notarized Developer ID` is the string that matters.
- name: Verify the app is signed and notarized
run: |
app=$(find release/enterprise -maxdepth 3 -name '*.app' | head -n 1)
if [ -z "$app" ]; then echo "::error::No .app produced"; exit 1; fi
codesign -dv --verbose=4 "$app" 2>&1 | sed 's/^/ /'
if ! spctl -a -vv "$app" 2>&1 | tee /dev/stderr | grep -q 'source=Notarized Developer ID'; then
echo "::error::$app is not notarized (spctl did not report a Notarized Developer ID source)"
exit 1
fi

- name: Upload DMG
uses: actions/upload-artifact@v7
with:
Expand Down
50 changes: 50 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,53 @@ This project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Fixed — release signing

- **A release build can no longer emit an unsigned artifact.** Both
`sign-win.cjs` and `notarize.cjs` warned and returned whenever a credential
was missing. That is right for a developer build and wrong for a release: the
build went green, the warning scrolled past in electron-builder's output, and
nobody found out until a customer's machine refused the download. When
`TRANSTRACK_RELEASE_CHANNEL=public` (set by `release.yml` on both build jobs),
or `TRANSTRACK_REQUIRE_SIGNING` / `TRANSTRACK_REQUIRE_NOTARIZATION` is set,
the build now fails and names the missing variable.
- **The release gate now inspects the installer instead of its filename.**
"Code-signed Windows installer present" was satisfied by any file matching the
expected name. `scripts/verify-artifact-signature.mjs` reads the artifact: the
OS verdict via `Get-AuthenticodeSignature` on Windows, the PE Attribute
Certificate Table elsewhere, with the weaker assurance labelled as such rather
than overstated. A catalog-only signature is rejected — Windows calls it
valid, but it lives outside the file and so cannot reach the receiving site.
- **`pfx` mode works in CI.** `CSC_LINK` was treated strictly as a filesystem
path, but a certificate in a CI secret is base64 bytes. Base64 content is now
written to a temporary file with owner-only permissions and removed in a
`finally` block.
- **`ssl_esigner` mode works in CI.** The workflow never set
`ESIGNER_TOOL_PATH` and never installed CodeSignTool, so the mode could not
have signed anything. Both are now handled, and a missing `ESIGNER_TOOL_URL`
fails the job rather than yielding an unsigned build.
- **Notarization diagnoses the likely credential mistake.** Apple's own term is
"app-specific password", and `docs/DEPLOYMENT_PRODUCTION.md` said
`APPLE_APP_SPECIFIC_PASSWORD`, while the hook reads `APPLE_APP_PASSWORD`. The
doc is corrected, and the hook now says so by name when it finds the longer
spelling set.
- **`tests/signWin.test.cjs` never awaited its async cases**, so every
`assert.rejects` counted as a pass without running. The harness is now
async-aware, and the suite covers mode selection, fail-closed behaviour, and
certificate materialisation. New: `tests/notarize.test.cjs` and
`tests/artifactSignature.test.mjs`, the latter parsing synthetic PE images on
every platform and, on Windows, checking a genuinely signed binary and a
catalog-signed one.

### Changed — code signing guidance

- **EV is no longer recommended by default.** The guidance to buy EV rested on
it granting immediate SmartScreen reputation; Microsoft removed that
behaviour, and OV now gives the same first-download experience. The docs now
recommend Azure Artifact Signing (~$10/month) or an OV certificate with cloud
HSM signing, and note that since June 2023 all code signing keys — OV
included — must live in hardware, so a copyable `.pfx` is no longer issuable.

### Fixed

- **Support bundle log tail no longer loses a race with log rotation.**
Expand Down Expand Up @@ -34,6 +81,9 @@ This project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
pipeline, chart filing, migration safety, and support bundles — including
the adversarial one that matters: plant a patient name in free text, export
a default bundle, and search the file for it.
- **Release authenticity added to the validation package** as TT-R146 and
TT-R147, SDS §17, R-028, and OQ-146/147 — the last of which has the receiving
site verify the installer's signature themselves before installing it.
- **Risk register extended** with R-020 to R-027 (missed notification
deadline, duplicate or misfiled chart document, bundle PHI leakage, notice
altered after filing, template missing a statutory element, stale
Expand Down
Loading
Loading