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
78 changes: 77 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ on:
paths:
- .github/workflows/release.yml
- windows/packaging/**
- windows/runner/Runner.rc
- macos/Runner/Info.plist
- tools/release_metadata.dart

concurrency:
Expand Down Expand Up @@ -156,12 +158,49 @@ jobs:
windows\packaging\installer.iss
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }

# What a user does with it: install (per user, silently), start the app
# and see it stay up, then uninstall and see it gone.
- name: Install, launch and uninstall (Windows)
if: matrix.platform == 'windows'
shell: pwsh
run: |
$setup = Get-ChildItem dist\*-windows-setup.exe | Select-Object -First 1
Start-Process $setup.FullName -ArgumentList '/VERYSILENT', '/SUPPRESSMSGBOXES', '/NORESTART', '/CURRENTUSER' -Wait
$dir = Join-Path $env:LOCALAPPDATA 'Programs\PinBench'
$exe = Join-Path $dir 'pinbench.exe'
if (-not (Test-Path $exe)) { Write-Error "the installer did not put pinbench.exe in $dir"; exit 1 }
$app = Start-Process $exe -PassThru
Start-Sleep -Seconds 15
if ($app.HasExited) { Write-Error "the installed app exited within 15 s (code $($app.ExitCode))"; exit 1 }
Stop-Process -Id $app.Id -Force
Start-Sleep -Seconds 2
Start-Process (Join-Path $dir 'unins000.exe') -ArgumentList '/VERYSILENT', '/SUPPRESSMSGBOXES', '/NORESTART' -Wait
Start-Sleep -Seconds 3
if (Test-Path $exe) { Write-Error 'the uninstaller left pinbench.exe behind'; exit 1 }

- name: Package (Linux tarball)
if: matrix.platform == 'linux'
run: |
mkdir -p dist
tar -czf "dist/pinbench-$VERSION-linux.tar.gz" -C build/linux/x64/release/bundle .

# Unpacked from the tarball as a user would, and started on a virtual
# display (Mesa's software GL stands in for a GPU) to see it stay up.
- name: Launch from the tarball (Linux)
if: matrix.platform == 'linux'
run: |
sudo apt-get install -y xvfb libgl1-mesa-dri
mkdir -p "$RUNNER_TEMP/app"
tar -xzf "dist/pinbench-$VERSION-linux.tar.gz" -C "$RUNNER_TEMP/app"
xvfb-run -a "$RUNNER_TEMP/app/pinbench" > "$RUNNER_TEMP/app.log" 2>&1 & pid=$!
sleep 15
if ! kill -0 "$pid" 2>/dev/null; then
cat "$RUNNER_TEMP/app.log"
echo "::error::the app exited within 15 s of launch"
exit 1
fi
kill "$pid"

- uses: actions/upload-artifact@v7
with:
name: pinbench-${{ matrix.platform }}
Expand Down Expand Up @@ -204,6 +243,39 @@ jobs:
path: dist
merge-multiple: true

# The app installs an update only with a valid signature: Sparkle checks
# the .dmg's Ed25519 one against Info.plist's SUPublicEDKey, WinSparkle
# the installer's DSA one against Runner.rc's DSAPub. Each signature is
# verified here against those embedded keys, so a release signed with
# the wrong key fails now instead of every user's next update.
- name: Sign the updates
env:
ED_KEY: ${{ secrets.SPARKLE_ED_PRIVATE_KEY }}
DSA_KEY: ${{ secrets.WINSPARKLE_DSA_PRIVATE_KEY }}
run: |
if [ -z "$ED_KEY" ] || [ -z "$DSA_KEY" ]; then
echo "::error::SPARKLE_ED_PRIVATE_KEY and WINSPARKLE_DSA_PRIVATE_KEY must be set: the app rejects unsigned updates"
exit 1
fi
keys="$RUNNER_TEMP/keys" && mkdir -m 700 "$keys"
printf '%s\n' "$ED_KEY" > "$keys/ed.pem"
printf '%s\n' "$DSA_KEY" > "$keys/dsa.pem"
dmg="dist/pinbench-$VERSION-macos.dmg"
exe="dist/pinbench-$VERSION-windows-setup.exe"

openssl pkeyutl -sign -inkey "$keys/ed.pem" -rawin -in "$dmg" -out "$keys/ed.sig"
openssl dgst -sha1 -binary < "$exe" | openssl dgst -sha1 -sign "$keys/dsa.pem" -out "$keys/dsa.sig"

# The app's public keys, as it carries them.
ed_public=$(sed -n '/<key>SUPublicEDKey<\/key>/{n;s/.*<string>\(.*\)<\/string>.*/\1/p;}' macos/Runner/Info.plist)
{ printf '\x30\x2a\x30\x05\x06\x03\x2b\x65\x70\x03\x21\x00'; printf '%s' "$ed_public" | base64 -d; } > "$keys/ed_public.der"
openssl pkeyutl -verify -pubin -keyform DER -inkey "$keys/ed_public.der" -rawin -in "$dmg" -sigfile "$keys/ed.sig"
openssl dgst -sha1 -binary < "$exe" | openssl dgst -sha1 -verify windows/runner/resources/winsparkle_dsa_public.pem -signature "$keys/dsa.sig"

echo "ED_SIGNATURE=$(base64 -w0 < "$keys/ed.sig")" >> "$GITHUB_ENV"
echo "DSA_SIGNATURE=$(base64 -w0 < "$keys/dsa.sig")" >> "$GITHUB_ENV"
rm -rf "$keys"

- name: Write the updater's files and the checksums
run: |
tag="v$VERSION"
Expand All @@ -217,10 +289,14 @@ jobs:
--out dist \
--asset "macos=dist/pinbench-$VERSION-macos.dmg" \
--asset "windows=dist/pinbench-$VERSION-windows-setup.exe" \
--asset "linux=dist/pinbench-$VERSION-linux.tar.gz"
--asset "linux=dist/pinbench-$VERSION-linux.tar.gz" \
--ed-signature "macos=$ED_SIGNATURE" \
--dsa-signature "windows=$DSA_SIGNATURE"
(cd dist && sha256sum pinbench-* > SHA256SUMS.txt)
ls -la dist
cat dist/SHA256SUMS.txt
grep -q 'sparkle:edSignature=' dist/appcast-macos.xml
grep -q 'sparkle:dsaSignature=' dist/appcast-windows.xml

- name: Keep the trial's files
if: github.event_name == 'pull_request'
Expand Down
10 changes: 9 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -201,4 +201,12 @@ installer and a `.tar.gz`, writes the updater's appcasts and `latest.json`
(`tools/release_metadata.dart`) and `SHA256SUMS.txt`, and makes a **draft**
release. Publishing the draft is what the updater and the download page see.
A tag `vX.Y.Z` gets build number `X*10000 + Y*100 + Z`, so tags must only go
up. The builds are not code-signed yet.
up. The builds are not code-signed yet, but every update is: the app installs
one only with a valid signature from the release keys, an Ed25519 key for
Sparkle on macOS (public half in `macos/Runner/Info.plist`) and a DSA key for
WinSparkle on Windows (public half in `windows/runner/resources/`). The
private halves are the `SPARKLE_ED_PRIVATE_KEY` and
`WINSPARKLE_DSA_PRIVATE_KEY` secrets, with a backup kept offline. **Never
replace them** once a release is out: an installed app accepts only updates
signed with the key it shipped with, so a new key strands every user on the
version they have.
5 changes: 5 additions & 0 deletions macos/Runner/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -44,5 +44,10 @@
<false/>
<key>GOOGLE_ANALYTICS_DEFAULT_ALLOW_AD_PERSONALIZATION_SIGNALS</key>
<false/>
<!-- Sparkle accepts an update only with a valid Ed25519 signature from
this key's private half (the SPARKLE_ED_PRIVATE_KEY secret, which
.github/workflows/release.yml signs each release with). -->
<key>SUPublicEDKey</key>
<string>jqMWn4Bb3aiIhESqJSLgTYG42nx6nAvWt6ONYS5KLpM=</string>
</dict>
</plist>
26 changes: 26 additions & 0 deletions test/tools/release_metadata_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,26 @@ void main() {
asset: unsigned,
);
expect(output, isNot(contains('edSignature')));
expect(output, isNot(contains('dsaSignature')));
});

test("carries WinSparkle's DSA signature for the Windows feed", () {
const windows = AssetInfo(
fileName: 'app-setup.exe',
url: 'https://example.test/app-setup.exe',
sha256: 'beef',
size: 1,
dsaSignature: 'ZHNhLXNpZw==',
);
final output = buildAppcast(
version: '0.4.0',
buildNumber: 4000,
notesUrl: 'https://example.test/tag/v0.4.0',
publishedAt: DateTime.utc(2026, 8, 13, 12),
asset: windows,
);
expect(output, contains('sparkle:dsaSignature="ZHNhLXNpZw=="'));
expect(output, isNot(contains('edSignature')));
});

test('escapes a URL containing an ampersand', () {
Expand Down Expand Up @@ -102,10 +122,13 @@ void main() {
'linux=dist/a.tar.gz',
'--ed-signature',
'macos=sig',
'--dsa-signature',
'windows=dsa',
]);

expect(options.assets, {'macos': 'dist/a.dmg', 'linux': 'dist/a.tar.gz'});
expect(options.edSignatures, {'macos': 'sig'});
expect(options.dsaSignatures, {'windows': 'dsa'});
// The trailing slash would otherwise produce `…/v0.4.0//a.dmg`.
expect(options.baseUrl, 'https://example.test/v0.4.0');
});
Expand All @@ -126,8 +149,11 @@ void main() {
'2026-08-13T12:00:00Z',
'--ed-signature',
'macos=',
'--dsa-signature',
'windows=',
]);
expect(options.edSignatures, isEmpty);
expect(options.dsaSignatures, isEmpty);
});
});

Expand Down
30 changes: 23 additions & 7 deletions tools/release_metadata.dart
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@
// --asset macos=dist/App-macos.dmg \
// --asset windows=dist/App-windows-setup.exe \
// --asset linux=dist/App-linux.tar.gz \
// --ed-signature macos=<base64>
// --ed-signature macos=<base64> \
// --dsa-signature windows=<base64>
//
// Written in Dart rather than as a shell heredoc because it has to hash
// files, XML-escape strings and emit two different formats — and because a
Expand Down Expand Up @@ -48,6 +49,7 @@ Future<void> generateReleaseMetadata(List<String> args) async {
sha256: sha256.convert(bytes).toString(),
size: bytes.length,
edSignature: options.edSignatures[entry.key],
dsaSignature: options.dsaSignatures[entry.key],
);
}

Expand Down Expand Up @@ -100,11 +102,15 @@ class const AssetInfo({
required final String sha256,
required final int size,

/// Sparkle's EdDSA signature over the file, from `sign_update`. Null when
/// no signing key was available — Sparkle then falls back to verifying the
/// update's Developer ID against the running app's, which is weaker but is
/// still a real check, and is better than refusing to publish.
/// Sparkle's EdDSA (Ed25519) signature over the file, which the macOS app
/// checks against the SUPublicEDKey in its Info.plist: with that key set,
/// Sparkle refuses an update without a valid one.
final String? edSignature,

/// WinSparkle's DSA signature (over the file's SHA-1), which the Windows
/// app checks against the DSAPub resource in Runner.rc. WinSparkle 0.8,
/// which auto_updater bundles, verifies DSA, not EdDSA.
final String? dsaSignature,
});

/// A single-item Sparkle appcast.
Expand All @@ -121,7 +127,10 @@ String buildAppcast({
required DateTime publishedAt,
required AssetInfo asset,
}) {
final signature = asset.edSignature;
final signatures = [
if (asset.edSignature case final ed?) 'sparkle:edSignature="${_xml(ed)}"',
if (asset.dsaSignature case final dsa?) 'sparkle:dsaSignature="${_xml(dsa)}"',
].map((attribute) => '\n $attribute').join();
return '''
<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle">
Expand All @@ -138,7 +147,7 @@ String buildAppcast({
<sparkle:releaseNotesLink>${_xml(notesUrl)}</sparkle:releaseNotesLink>
<enclosure url="${_xml(asset.url)}"
length="${asset.size}"
type="application/octet-stream"${signature == null ? '' : '\n sparkle:edSignature="${_xml(signature)}"'} />
type="application/octet-stream"$signatures />
</item>
</channel>
</rss>
Expand Down Expand Up @@ -185,11 +194,13 @@ class const ReleaseOptions({
required final String outDir,
required final Map<String, String> assets,
required final Map<String, String> edSignatures,
required final Map<String, String> dsaSignatures,
}) {
factory parse(List<String> args) {
final single = <String, String>{};
final assets = <String, String>{};
final signatures = <String, String>{};
final dsaSignatures = <String, String>{};

for (var i = 0; i < args.length; i += 2) {
final name = args[i].replaceFirst(RegExp('^--'), '');
Expand All @@ -205,6 +216,10 @@ class const ReleaseOptions({
// flag — treat it as absent rather than emitting an empty
// signature attribute, which Sparkle rejects the whole item over.
if (signature.isNotEmpty) signatures[value.substring(0, split)] = signature;
case 'dsa-signature':
final split = value.indexOf('=');
final signature = value.substring(split + 1);
if (signature.isNotEmpty) dsaSignatures[value.substring(0, split)] = signature;
default:
single[name] = value;
}
Expand All @@ -220,6 +235,7 @@ class const ReleaseOptions({
outDir: single['out'] ?? 'dist',
assets: assets,
edSignatures: signatures,
dsaSignatures: dsaSignatures,
);
}
}
11 changes: 11 additions & 0 deletions windows/runner/Runner.rc
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,17 @@ END
// remains consistent on all systems.
IDI_APP_ICON ICON "resources\\app_icon.ico"

/////////////////////////////////////////////////////////////////////////////
//
// WinSparkle
//

// The public half of the update-signing DSA key: WinSparkle installs an
// update only with a valid signature from the private half (the
// WINSPARKLE_DSA_PRIVATE_KEY secret, which .github/workflows/release.yml
// signs each release with).
DSAPub DSAPEM "resources\\winsparkle_dsa_public.pem"


/////////////////////////////////////////////////////////////////////////////
//
Expand Down
36 changes: 36 additions & 0 deletions windows/runner/resources/winsparkle_dsa_public.pem
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
-----BEGIN PUBLIC KEY-----
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
KELw1aS55bBPk0SDv5kC4fdjMtFwZg==
-----END PUBLIC KEY-----
Loading