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
59 changes: 44 additions & 15 deletions .github/workflows/build-terminal.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ on:
default: false
type: boolean
sign_dry_run:
description: Sign Windows packages during a dry run when signing credentials are available.
description: Sign Windows and macOS packages during a dry run when signing credentials are available.
required: false
default: false
type: boolean
Expand Down Expand Up @@ -355,6 +355,9 @@ jobs:
- name: Validate macOS scripts and metadata
run: pwsh -NoLogo -NoProfile -File scripts/Test-MacOsPackagingMetadata.ps1

- name: Test macOS code-signing topology
run: pwsh -NoLogo -NoProfile -File scripts/Test-MacOsCodeSigning.ps1

macos-native-aot:
name: macOS NativeAOT ${{ matrix.rid }}
runs-on: macos-14
Expand Down Expand Up @@ -483,34 +486,51 @@ jobs:
}

if ($missing.Count -gt 0) {
Add-Content -LiteralPath $env:GITHUB_OUTPUT -Value 'should_sign=false'
Write-Host "::notice::Skipping macOS signing and notarization because these secrets are unavailable: $($missing -join ', ')"
exit 0
throw "Missing Apple signing/notarization secrets: $($missing -join ', ')"
}

Add-Content -LiteralPath $env:GITHUB_OUTPUT -Value 'should_sign=true'

- name: Import Developer ID certificate
if: steps.signing-mode.outputs.should_sign == 'true'
id: import_certificate
shell: pwsh
env:
APPLE_APP_DEV_ID_APP_CERTIFICATE: ${{ secrets.APPLE_APP_DEV_ID_APP_CERTIFICATE }}
APPLE_APP_DEV_ID_APP_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_APP_DEV_ID_APP_CERTIFICATE_PASSWORD }}
run: |
$ErrorActionPreference = 'Stop'
$PSNativeCommandUseErrorActionPreference = $true
$keychain = Join-Path $env:RUNNER_TEMP 'macos-signing.keychain-db'
$keychainPassword = (& uuidgen)
$certificatePath = Join-Path $env:RUNNER_TEMP 'apple-certificate.p12'

[System.IO.File]::WriteAllBytes($certificatePath, [Convert]::FromBase64String($env:APPLE_APP_DEV_ID_APP_CERTIFICATE))

& security create-keychain -p $keychainPassword $keychain
& security set-keychain-settings -lut 21600 $keychain
& security unlock-keychain -p $keychainPassword $keychain
& security import $certificatePath -k $keychain -P $env:APPLE_APP_DEV_ID_APP_CERTIFICATE_PASSWORD `
-T /usr/bin/codesign -T /usr/bin/security
& security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k $keychainPassword $keychain
& security list-keychain -d user -s $keychain login.keychain-db
Remove-Item -LiteralPath $certificatePath -Force
try {
[System.IO.File]::WriteAllBytes($certificatePath, [Convert]::FromBase64String($env:APPLE_APP_DEV_ID_APP_CERTIFICATE))

& security create-keychain -p $keychainPassword $keychain
& security set-keychain-settings -lut 21600 $keychain
& security unlock-keychain -p $keychainPassword $keychain
& security import $certificatePath -k $keychain -P $env:APPLE_APP_DEV_ID_APP_CERTIFICATE_PASSWORD `
-T /usr/bin/codesign -T /usr/bin/security
& security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k $keychainPassword $keychain
& security list-keychains -d user -s $keychain login.keychain-db

$identities = (& security find-identity -v -p codesigning $keychain) -join "`n"
Write-Host $identities
$identityMatches = [regex]::Matches(
$identities,
'(?m)^\s*\d+\)\s+([0-9A-Fa-f]{40})\s+"Developer ID Application:'
)
if ($identityMatches.Count -ne 1) {
throw "Expected exactly one Developer ID Application identity, found $($identityMatches.Count)."
}
Add-Content -LiteralPath $env:GITHUB_OUTPUT `
-Value "identity=$($identityMatches[0].Groups[1].Value)"
}
finally {
Remove-Item -LiteralPath $certificatePath -Force -ErrorAction SilentlyContinue
}

- name: Sign, package, and notarize
if: steps.signing-mode.outputs.should_sign == 'true'
Expand All @@ -522,7 +542,7 @@ jobs:
"${{ needs.release-metadata.outputs.release_version }}"
artifacts/macos-packages
artifacts/macos-signed-packages
"Developer ID Application: Devolutions inc."
"${{ steps.import_certificate.outputs.identity }}"

- name: Package without notarization (dry run / forked PR)
if: steps.signing-mode.outputs.should_sign != 'true'
Expand All @@ -544,6 +564,15 @@ jobs:
path: artifacts/macos-signed-packages
if-no-files-found: error

- name: Delete temporary signing keychain
if: always() && steps.signing-mode.outputs.should_sign == 'true'
shell: pwsh
run: |
$keychain = Join-Path $env:RUNNER_TEMP 'macos-signing.keychain-db'
if (Test-Path -LiteralPath $keychain) {
& security delete-keychain $keychain
}

linux-packages:
name: Linux packages ${{ matrix.rid }}
runs-on: ubuntu-24.04
Expand Down
7 changes: 4 additions & 3 deletions docs/release.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,9 +71,10 @@ The staged `Devolutions Terminal.app` contains `Devolutions.Terminal`, `dt`,
`dt-pty-host`, `libghostty-vt.dylib`, Skia, HarfBuzz, `Info.plist`, and an
ad-hoc signed icon. This gate (`Build-MacOsPackage.ps1`) produces an unsigned,
unnotarized zip for fast local iteration; the CI release pipeline additionally
runs `scripts/Release-MacOsPackage.ps1` to sign the bundle with a Developer ID
identity, notarize it, and produce a signed `.dmg`. Homebrew packaging is not
part of this gate.
runs `scripts/Release-MacOsPackage.ps1` to sign every Mach-O with a Developer ID
identity, submit the app as a ZIP for notarization, staple and assess it, and
produce a separately signed, notarized, and stapled `.dmg`. Homebrew packaging
is not part of this gate.

Linux package formats:

Expand Down
1 change: 1 addition & 0 deletions macos/package.env
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
# the version from Directory.Build.props or the release pipeline.
PACKAGE_NAME=devolutions-terminal
APP_ID=com.devolutions.Terminal
APPLE_TEAM_ID=N592S9ASDB
DISPLAY_NAME="Devolutions Terminal"
BUNDLE_NAME="Devolutions Terminal.app"
EXECUTABLE_NAME=Devolutions.Terminal
Expand Down
2 changes: 1 addition & 1 deletion scripts/Build-MacOsDmg.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ try {

if ($Identity) {
Invoke-Native -FilePath codesign -ArgumentList '--force', '--timestamp', '--sign', $Identity, $dmgPath
Invoke-Native -FilePath codesign -ArgumentList '--verify', '--verbose=2', $dmgPath
Assert-MacOsCodeSignature -Path $dmgPath -TeamIdentifier $metadata.APPLE_TEAM_ID -RequireTimestamp
}

$manifest = Get-Sha256Manifest -WorkingDirectory $OutputDir -Path @("$base.dmg")
Expand Down
34 changes: 34 additions & 0 deletions scripts/MacOsPackagingCommon.psm1
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,39 @@ function Invoke-Native {
}
}

function Assert-MacOsCodeSignature {
<#
.SYNOPSIS
Verifies a code signature and optionally requires Developer ID team,
secure timestamp, and Hardened Runtime metadata.
#>
param(
[Parameter(Mandatory)]
[string]$Path,
[string]$TeamIdentifier,
[switch]$RequireTimestamp,
[switch]$RequireHardenedRuntime
)

Invoke-Native -FilePath codesign -ArgumentList '--verify', '--strict', '--verbose=2', $Path

$details = (& codesign '--display' '--verbose=4' $Path 2>&1 | Out-String)
if ($LASTEXITCODE -ne 0) {
throw "Unable to inspect the code signature for $Path."
}
Write-Host $details.TrimEnd()

if ($TeamIdentifier -and $details -notmatch "(?m)^TeamIdentifier=$([regex]::Escape($TeamIdentifier))\s*$") {
throw "$Path is not signed by Apple team $TeamIdentifier."
}
if ($RequireTimestamp -and $details -notmatch '(?m)^Timestamp=.+$') {
throw "$Path does not have a secure signing timestamp."
}
if ($RequireHardenedRuntime -and $details -notmatch '(?m)^CodeDirectory .*\bflags=.*\bruntime\b') {
throw "$Path does not enable the Hardened Runtime."
}
}

function Get-Sha256Manifest {
<#
.SYNOPSIS
Expand Down Expand Up @@ -212,5 +245,6 @@ Export-ModuleMember -Function `
Assert-MacOsVersion, `
Get-MacOsExpectedArch, `
Invoke-Native, `
Assert-MacOsCodeSignature, `
Get-Sha256Manifest, `
Set-MacOsReproducibleTimestamps
103 changes: 86 additions & 17 deletions scripts/Notarize-MacOsPackage.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@
#Requires -Version 7
<#
.SYNOPSIS
Submits a macOS .app bundle or .dmg to Apple's notary service and
staples the resulting ticket. Authentication uses the Devolutions release
Apple ID and its app-specific password from APPLE_BOT_PASSWORD.
Submits a macOS .app bundle or .dmg to Apple's notary service, staples
the resulting ticket, and verifies Gatekeeper acceptance. App bundles
are submitted in a temporary ZIP because notarytool accepts archives
and disk images, not raw directory bundles.

.PARAMETER TargetPath
Path to the .app bundle or .dmg to notarize and staple.
Expand All @@ -25,27 +26,95 @@ Assert-Darwin -Message 'Notarization requires Darwin.'
if (-not (Test-Path -LiteralPath $TargetPath)) {
throw "Path not found: $TargetPath"
}
Assert-Command -Name 'xcrun'
Assert-Command -Name 'ditto', 'spctl', 'xcrun'

if ([string]::IsNullOrWhiteSpace($env:APPLE_BOT_PASSWORD)) {
throw 'APPLE_BOT_PASSWORD is required.'
}

$profile = 'DEVOLUTIONS_TERMINAL'
Invoke-Native -FilePath xcrun -ArgumentList @(
'notarytool', 'store-credentials', $profile,
$metadata = Import-MacOsPackageEnv -Path (Join-Path (Split-Path -Parent $PSScriptRoot) 'macos/package.env')
$resolvedTarget = (Resolve-Path -LiteralPath $TargetPath).ProviderPath
$target = Get-Item -LiteralPath $resolvedTarget
$submissionPath = $resolvedTarget
$temporaryArchive = $null

if ($target.PSIsContainer) {
if (-not $target.Name.EndsWith('.app', [StringComparison]::OrdinalIgnoreCase)) {
throw "Unsupported notarization directory: $resolvedTarget"
}
$temporaryArchive = Join-Path ([System.IO.Path]::GetTempPath()) "notary-$([guid]::NewGuid().ToString('N')).zip"
Invoke-Native -FilePath ditto -ArgumentList @(
'-c', '-k', '--keepParent', '--norsrc', '--noextattr', '--noacl',
$resolvedTarget, $temporaryArchive
)
$submissionPath = $temporaryArchive
}
elseif ($target.Extension -ne '.dmg') {
throw "Unsupported notarization file: $resolvedTarget"
}

$credentials = @(
'--apple-id', 'bot@devolutions.net',
'--team-id', 'N592S9ASDB',
'--team-id', $metadata.APPLE_TEAM_ID,
'--password', $env:APPLE_BOT_PASSWORD
)

Invoke-Native -FilePath xcrun -ArgumentList @(
'notarytool', 'submit', $TargetPath,
'--keychain-profile', $profile,
'--wait',
'--timeout', '30m'
)
try {
$notaryOutput = & xcrun notarytool submit $submissionPath @credentials `
--wait --timeout 30m --output-format json
$notaryExitCode = $LASTEXITCODE
$notaryJson = $notaryOutput -join "`n"
if ($notaryJson) {
Write-Host $notaryJson
}

$notaryResult = $null
try {
$notaryResult = $notaryJson | ConvertFrom-Json
}
catch {
if ($notaryExitCode -eq 0) {
throw "notarytool returned invalid JSON: $notaryJson"
}
}

Invoke-Native -FilePath xcrun -ArgumentList 'stapler', 'staple', $TargetPath
Invoke-Native -FilePath xcrun -ArgumentList 'stapler', 'validate', $TargetPath
Write-Host "Notarized and stapled $TargetPath"
$status = if ($null -ne $notaryResult -and $notaryResult.PSObject.Properties['status']) {
[string]$notaryResult.status
}
else {
'unknown'
}
$submissionId = if ($null -ne $notaryResult -and $notaryResult.PSObject.Properties['id']) {
[string]$notaryResult.id
}
else {
$null
}

if ($notaryExitCode -ne 0 -or $status -ne 'Accepted') {
if (-not [string]::IsNullOrWhiteSpace($submissionId)) {
& xcrun notarytool log $submissionId @credentials
}
throw "Notarization failed for $resolvedTarget (exit code $notaryExitCode, status $status)."
}

Invoke-Native -FilePath xcrun -ArgumentList 'stapler', 'staple', $resolvedTarget
Invoke-Native -FilePath xcrun -ArgumentList 'stapler', 'validate', $resolvedTarget

if ($target.PSIsContainer) {
Invoke-Native -FilePath spctl -ArgumentList '--assess', '--type', 'execute', '--verbose=4', $resolvedTarget
}
else {
Invoke-Native -FilePath spctl -ArgumentList @(
'--assess', '--type', 'open', '--context', 'context:primary-signature',
'--verbose=4', $resolvedTarget
)
}

Write-Host "Notarized, stapled, and assessed $resolvedTarget"
}
finally {
if ($temporaryArchive) {
Remove-Item -LiteralPath $temporaryArchive -Force -ErrorAction SilentlyContinue
}
}
Loading
Loading