Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit 26706bb

Browse files
committedJun 5, 2025·
chore: appcast generation
1 parent 5072e24 commit 26706bb

File tree

9 files changed

+301
-19
lines changed

9 files changed

+301
-19
lines changed
 

‎.github/workflows/release.yaml

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,9 @@ jobs:
6868
service_account: ${{ secrets.GCP_SERVICE_ACCOUNT }}
6969
token_format: "access_token"
7070

71+
- name: Install gcloud
72+
uses: google-github-actions/setup-gcloud@77e7a554d41e2ee56fc945c52dfd3f33d12def9a # 2.1.4
73+
7174
- name: Install wix
7275
shell: pwsh
7376
run: |
@@ -120,6 +123,43 @@ jobs:
120123
env:
121124
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
122125

126+
- name: Update appcast
127+
if: startsWith(github.ref, 'refs/tags/')
128+
shell: pwsh
129+
$ErrorActionPreference = "Stop"
130+
131+
$keyPath = Join-Path $env:TEMP "appcast-key.pem"
132+
$key = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($env:APPCAST_SIGNATURE_KEY_BASE64))
133+
Set-Content -Path $keyPath -Value $key
134+
135+
$oldAppCastPath = Join-Path $env:TEMP "appcast.old.xml"
136+
& gsutil cp $env:APPCAST_GCS_URI $oldAppCastPath
137+
if ($LASTEXITCODE -ne 0) { throw "Failed to download appcast" }
138+
139+
$newAppCastPath = Join-Path $env:TEMP "appcast.new.xml"
140+
$newAppCastSignaturePath = $newAppCastPath + ".signature"
141+
& ./scripts/Update-AppCast.ps1 `
142+
-tag "${{ github.ref_name }}" `
143+
-version "${{ steps.version.outputs.VERSION }}" `
144+
-channel stable `
145+
-x64Path "${{ steps.release.outputs.X64_OUTPUT_PATH }}" `
146+
-arm64Path "${{ steps.release.outputs.ARM64_OUTPUT_PATH }}" `
147+
-keyPath $keyPath `
148+
-inputAppCastPath $oldAppCastPath `
149+
-outputAppCastPath $newAppCastPath `
150+
-outputAppCastSignaturePath $newAppCastSignaturePath
151+
if ($LASTEXITCODE -ne 0) { throw "Failed to generate new appcast" }
152+
153+
& gsutil cp $newAppCastPath $env:APPCAST_GCS_URI
154+
if ($LASTEXITCODE -ne 0) { throw "Failed to upload new appcast" }
155+
& gsutil cp $newAppCastSignaturePath $env:APPCAST_SIGNATURE_GCS_URI
156+
if ($LASTEXITCODE -ne 0) { throw "Failed to upload new appcast signature" }
157+
env:
158+
APPCAST_GCS_URI: gs://releases.coder.com/coder-desktop/windows/appcast.xml
159+
APPCAST_SIGNATURE_GCS_URI: gs://releases.coder.com/coder-desktop/windows/appcast.xml.signature
160+
APPCAST_SIGNATURE_KEY_BASE64: ${{ secrets.APPCAST_SIGNATURE_KEY_BASE64 }}
161+
GCLOUD_ACCESS_TOKEN: ${{ steps.gcloud_auth.outputs.access_token }}
162+
123163
winget:
124164
runs-on: depot-windows-latest
125165
needs: release
@@ -177,7 +217,6 @@ jobs:
177217
# to GitHub and then making a PR in a different repo.
178218
WINGET_GH_TOKEN: ${{ secrets.CDRCI_GITHUB_TOKEN }}
179219

180-
181220
- name: Comment on PR
182221
run: |
183222
# wait 30 seconds

‎.gitignore

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -411,3 +411,12 @@ publish
411411
*.wixmdb
412412
*.wixprj
413413
*.wixproj
414+
415+
appcast.xml
416+
appcast.xml.signature
417+
*.key
418+
*.key.*
419+
*.pem
420+
*.pem.*
421+
*.pub
422+
*.pub.*

‎App/Assets/changelog.css

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
77
Changes:
88
- Removed @media queries in favor of requiring `[data-theme]` attributes
9+
on the body themselves
10+
- Overrides `--bgColor-default` to transparent
911
*/
1012

1113
.markdown-body {
@@ -19,8 +21,10 @@
1921
--base-text-weight-semibold: 600;
2022
--fontStack-monospace: ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace;
2123
--fgColor-accent: Highlight;
24+
25+
--bgColor-default: transparent !important;
2226
}
23-
.markdown-body[data-theme="dark"] {
27+
body[data-theme="dark"] .markdown-body {
2428
/* dark */
2529
color-scheme: dark;
2630
--focus-outlineColor: #1f6feb;
@@ -74,7 +78,7 @@
7478
--color-prettylights-syntax-meta-diff-range: #d2a8ff;
7579
--color-prettylights-syntax-sublimelinter-gutter-mark: #3d444d;
7680
}
77-
.markdown-body[data-theme="light"] {
81+
body[data-theme=""] .markdown-body {
7882
/* light */
7983
color-scheme: light;
8084
--focus-outlineColor: #0969da;

‎App/Services/UpdateController.cs

Lines changed: 13 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
using System;
22
using System.Collections.Generic;
33
using System.ComponentModel.DataAnnotations;
4+
using System.Runtime.InteropServices;
45
using System.Threading;
56
using System.Threading.Tasks;
67
using Coder.Desktop.App.ViewModels;
@@ -43,7 +44,7 @@ public class UpdaterConfig
4344
public bool EnableUpdater { get; set; } = true;
4445
//[Required] public string UpdateAppCastUrl { get; set; } = "https://releases.coder.com/coder-desktop/windows/appcast.xml";
4546
[Required] public string UpdateAppCastUrl { get; set; } = "http://localhost:8000/appcast.xml";
46-
[Required] public string UpdatePublicKeyBase64 { get; set; } = "Uxc0ir6j3GMhkL5D1O/W3lsD4BNk5puwM9hohNfm32k=";
47+
[Required] public string UpdatePublicKeyBase64 { get; set; } = "NNWN4c+3PmMuAf2G1ERLlu0EwhzHfSiUugOt120hrH8=";
4748
public UpdateChannel? ForcedUpdateChannel { get; set; } = null;
4849
}
4950

@@ -83,8 +84,7 @@ public SparkleUpdateController(ILogger<SparkleUpdateController> logger, IOptions
8384
// Swift's Sparkle does not support verifying app cast signatures yet,
8485
// but we use this functionality on Windows for added security against
8586
// malicious release notes.
86-
// TODO: REENABLE STRICT CHECKING
87-
var checker = new Ed25519Checker(SecurityMode.Unsafe,
87+
var checker = new Ed25519Checker(SecurityMode.Strict,
8888
publicKey: _config.UpdatePublicKeyBase64,
8989
readFileBeingVerifiedInChunks: true);
9090

@@ -93,7 +93,7 @@ public SparkleUpdateController(ILogger<SparkleUpdateController> logger, IOptions
9393
// TODO: custom Configuration for persistence, could just specify
9494
// our own save path with JSONConfiguration TBH
9595
LogWriter = new CoderSparkleLogger(logger),
96-
AppCastHelper = new CoderSparkleAppCastHelper(logger, _config.ForcedUpdateChannel),
96+
AppCastHelper = new CoderSparkleAppCastHelper(_config.ForcedUpdateChannel),
9797
UIFactory = uiFactory,
9898
UseNotificationToast = uiFactory.CanShowToastMessages(),
9999
RelaunchAfterUpdate = true,
@@ -103,7 +103,7 @@ public SparkleUpdateController(ILogger<SparkleUpdateController> logger, IOptions
103103

104104
// TODO: user preference for automatic checking. Remember to
105105
// StopLoop/StartLoop if it changes.
106-
#if !DEBUG || true
106+
#if !DEBUG
107107
_ = _sparkle.StartLoop(true, UpdateCheckInterval);
108108
#endif
109109
}
@@ -157,22 +157,20 @@ public void PrintMessage(string message, params object[]? arguments)
157157
}
158158
}
159159

160-
public class CoderSparkleAppCastHelper : AppCastHelper
160+
public class CoderSparkleAppCastHelper(UpdateChannel? forcedChannel) : AppCastHelper
161161
{
162-
private readonly UpdateChannel? _forcedChannel;
163-
164-
public CoderSparkleAppCastHelper(ILogger<SparkleUpdateController> logger, UpdateChannel? forcedChannel) : base()
165-
{
166-
_forcedChannel = forcedChannel;
167-
}
162+
// This might return some other OS if the user compiled the app for some
163+
// different arch, but the end result is the same: no updates will be found
164+
// for that arch.
165+
private static string CurrentOperatingSystem => $"win-{RuntimeInformation.ProcessArchitecture.ToString().ToLowerInvariant()}";
168166

169167
public override List<AppCastItem> FilterUpdates(List<AppCastItem> items)
170168
{
171169
items = base.FilterUpdates(items);
172170

173-
// TODO: factor in user choice too once we have a settings page
174-
var channel = _forcedChannel ?? UpdateChannel.Stable;
175-
return items.FindAll(i => i.Channel != null && i.Channel == channel.ChannelName());
171+
// TODO: factor in user channel choice too once we have a settings page
172+
var channel = forcedChannel ?? UpdateChannel.Stable;
173+
return items.FindAll(i => i.Channel == channel.ChannelName() && i.OperatingSystem == CurrentOperatingSystem);
176174
}
177175
}
178176

‎App/ViewModels/UpdaterUpdateAvailableViewModel.cs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -189,7 +189,6 @@ public async Task Changelog_Loaded(object sender, RoutedEventArgs e)
189189
settings.IsStatusBarEnabled = false;
190190

191191
// Hijack navigation to prevent links opening in the web view.
192-
// TODO: block new windows as well
193192
webView.CoreWebView2.NavigationStarting += (_, e) =>
194193
{
195194
// webView.NavigateToString uses data URIs, so allow those to work.
@@ -203,6 +202,14 @@ public async Task Changelog_Loaded(object sender, RoutedEventArgs e)
203202
if (Uri.TryCreate(e.Uri, UriKind.Absolute, out var uri) && uri is { Scheme: "http" or "https" })
204203
Process.Start(new ProcessStartInfo(e.Uri) { UseShellExecute = true });
205204
};
205+
webView.CoreWebView2.NewWindowRequested += (_, e) =>
206+
{
207+
// Prevent new windows from being launched (e.g. target="_blank").
208+
e.Handled = true;
209+
// Launch HTTP or HTTPS URLs in the default browser.
210+
if (Uri.TryCreate(e.Uri, UriKind.Absolute, out var uri) && uri is { Scheme: "http" or "https" })
211+
Process.Start(new ProcessStartInfo(e.Uri) { UseShellExecute = true });
212+
};
206213

207214
var html = await ChangelogHtml(CurrentItem);
208215
webView.NavigateToString(html);

‎scripts/Create-AppCastSigningKey.ps1

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
# This is mostly just here for reference.
2+
#
3+
# Usage: Create-AppCastSigningKey.ps1 -outputKeyPath <path>
4+
param (
5+
[Parameter(Mandatory = $true)]
6+
[string] $outputKeyPath
7+
)
8+
9+
$ErrorActionPreference = "Stop"
10+
11+
& openssl.exe genpkey -algorithm ed25519 -out $outputKeyPath
12+
if ($LASTEXITCODE -ne 0) { throw "Failed to generate ED25519 private key" }
13+
14+
# Export the public key in DER format
15+
$pubKeyDerPath = "$outputKeyPath.pub.der"
16+
& openssl.exe pkey -in $outputKeyPath -pubout -outform DER -out $pubKeyDerPath
17+
if ($LASTEXITCODE -ne 0) { throw "Failed to export ED25519 public key" }
18+
19+
# Remove the DER header to get the actual key bytes
20+
$pubBytes = [System.IO.File]::ReadAllBytes($pubKeyDerPath)[-32..-1]
21+
Remove-Item $pubKeyDerPath
22+
23+
# Base64 encode and print
24+
Write-Output "NetSparkle formatted public key:"
25+
Write-Output ([Convert]::ToBase64String($pubBytes))
26+
Write-Output ""
27+
Write-Output "Private key written to $outputKeyPath"

‎scripts/Get-Mutagen.ps1

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ param (
55
[string] $arch
66
)
77

8+
$ErrorActionPreference = "Stop"
9+
810
function Download-File([string] $url, [string] $outputPath, [string] $etagFile) {
911
Write-Host "Downloading '$url' to '$outputPath'"
1012
# We use `curl.exe` here because `Invoke-WebRequest` is notoriously slow.

‎scripts/Get-WindowsAppSdk.ps1

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ param (
55
[string] $arch
66
)
77

8+
$ErrorActionPreference = "Stop"
9+
810
function Download-File([string] $url, [string] $outputPath, [string] $etagFile) {
911
Write-Host "Downloading '$url' to '$outputPath'"
1012
# We use `curl.exe` here because `Invoke-WebRequest` is notoriously slow.

‎scripts/Update-AppCast.ps1

Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
# Updates appcast.xml and appcast.xml.signature for a given release.
2+
#
3+
# Requires openssl.exe. You can install it via winget:
4+
# winget install ShiningLight.OpenSSL.Light
5+
#
6+
# Usage: Update-AppCast.ps1
7+
# -tag <tag>
8+
# -version <version>
9+
# -channel <stable|preview>
10+
# -x64Path <path>
11+
# -arm64Path <path>
12+
# -keyPath <path>
13+
# -inputAppCastPath <path>
14+
# -outputAppCastPath <path>
15+
# -outputAppCastSignaturePath <path>
16+
param (
17+
[Parameter(Mandatory = $true)]
18+
[string] $tag,
19+
20+
[Parameter(Mandatory = $true)]
21+
[string] $version,
22+
23+
[Parameter(Mandatory = $true)]
24+
[ValidateSet('stable', 'preview')]
25+
[string] $channel,
26+
27+
[Parameter(Mandatory = $false)]
28+
[string] $pubDate = (Get-Date).ToUniversalTime().ToString("ddd, dd MMM yyyy HH:mm:ss +0000"),
29+
30+
[Parameter(Mandatory = $true)]
31+
[ValidateScript({ Test-Path $_ })]
32+
[string] $x64Path,
33+
34+
[Parameter(Mandatory = $true)]
35+
[ValidateScript({ Test-Path $_ })]
36+
[string] $arm64Path,
37+
38+
[Parameter(Mandatory = $true)]
39+
[ValidateScript({ Test-Path $_ })]
40+
[string] $keyPath,
41+
42+
[Parameter(Mandatory = $false)]
43+
[ValidateScript({ Test-Path $_ })]
44+
[string] $inputAppCastPath = "appcast.xml",
45+
46+
[Parameter(Mandatory = $false)]
47+
[string] $outputAppCastPath = "appcast.xml",
48+
49+
[Parameter(Mandatory = $false)]
50+
[string] $outputAppCastSignaturePath = "appcast.xml.signature"
51+
)
52+
53+
$ErrorActionPreference = "Stop"
54+
55+
$repo = "coder/coder-desktop-windows"
56+
57+
function Get-Ed25519Signature {
58+
param (
59+
[Parameter(Mandatory = $true)]
60+
[ValidateScript({ Test-Path $_ })]
61+
[string] $path
62+
)
63+
64+
# Use a temporary file. We can't just pipe directly because PowerShell
65+
# operates with strings for third party commands.
66+
$tempPath = Join-Path $env:TEMP "coder-desktop-temp.bin"
67+
& openssl.exe pkeyutl -sign -inkey $keyPath -rawin -in $path -out $tempPath
68+
if ($LASTEXITCODE -ne 0) { throw "Failed to sign file: $path" }
69+
$signature = [Convert]::ToBase64String([System.IO.File]::ReadAllBytes($tempPath))
70+
Remove-Item -Force $tempPath
71+
return $signature
72+
}
73+
74+
# Retrieve the release notes from the GitHub releases API
75+
$releaseNotesMarkdown = & gh.exe release view $tag `
76+
--json body `
77+
--jq ".body"
78+
if ($LASTEXITCODE -ne 0) { throw "Failed to retrieve release notes markdown" }
79+
$releaseNotesMarkdown = $releaseNotesMarkdown -replace "`r`n", "`n"
80+
$releaseNotesMarkdownPath = Join-Path $env:TEMP "coder-desktop-release-notes.md"
81+
Set-Content -Path $releaseNotesMarkdownPath -Value $releaseNotesMarkdown -Encoding UTF8
82+
83+
Write-Output "---- Release Notes Markdown -----"
84+
Get-Content $releaseNotesMarkdownPath
85+
Write-Output "---- End of Release Notes Markdown ----"
86+
Write-Output ""
87+
88+
# Convert the release notes markdown to HTML using the GitHub API to match
89+
# GitHub's formatting
90+
$releaseNotesHtmlPath = Join-Path $env:TEMP "coder-desktop-release-notes.html"
91+
& gh.exe api `
92+
--method POST `
93+
-H "Accept: application/vnd.github+json" `
94+
-H "X-GitHub-Api-Version: 2022-11-28" `
95+
/markdown `
96+
-F "text=@$releaseNotesMarkdownPath" `
97+
-F "mode=gfm" `
98+
-F "context=$repo" `
99+
> $releaseNotesHtmlPath
100+
if ($LASTEXITCODE -ne 0) { throw "Failed to convert release notes markdown to HTML" }
101+
102+
Write-Output "---- Release Notes HTML -----"
103+
Get-Content $releaseNotesHtmlPath
104+
Write-Output "---- End of Release Notes HTML ----"
105+
Write-Output ""
106+
107+
[xml] $appCast = Get-Content $inputAppCastPath
108+
109+
# Set up namespace manager for sparkle: prefix
110+
$nsManager = New-Object System.Xml.XmlNamespaceManager($appCast.NameTable)
111+
$nsManager.AddNamespace("sparkle", "http://www.andymatuschak.org/xml-namespaces/sparkle")
112+
113+
# Find the matching channel item
114+
$channelItem = $appCast.SelectSingleNode("//item[sparkle:channel='$channel']", $nsManager)
115+
if ($null -eq $channelItem) {
116+
throw "Could not find channel item for channel: $channel"
117+
}
118+
119+
# Update the item properties
120+
$channelItem.title = $tag
121+
$channelItem.pubDate = $pubDate
122+
$channelItem.SelectSingleNode("sparkle:version", $nsManager).InnerText = $version
123+
$channelItem.SelectSingleNode("sparkle:shortVersionString", $nsManager).InnerText = $version
124+
$channelItem.SelectSingleNode("sparkle:fullReleaseNotesLink", $nsManager).InnerText = "https://github.com/$repo/releases"
125+
126+
# Set description with proper line breaks
127+
$descriptionNode = $channelItem.SelectSingleNode("description")
128+
$descriptionNode.InnerXml = "" # Clear existing content
129+
$cdata = $appCast.CreateCDataSection([System.IO.File]::ReadAllText($releaseNotesHtmlPath))
130+
$descriptionNode.AppendChild($cdata) | Out-Null
131+
132+
# Remove existing enclosures
133+
$existingEnclosures = $channelItem.SelectNodes("enclosure")
134+
foreach ($enclosure in $existingEnclosures) {
135+
$channelItem.RemoveChild($enclosure) | Out-Null
136+
}
137+
138+
# Add new enclosures
139+
$enclosures = @(
140+
@{
141+
path = $x64Path
142+
os = "win-x64"
143+
},
144+
@{
145+
path = $arm64Path
146+
os = "win-arm64"
147+
}
148+
)
149+
foreach ($enclosure in $enclosures) {
150+
$fileName = Split-Path $enclosure.path -Leaf
151+
$url = "https://github.com/$repo/releases/download/$tag/$fileName"
152+
$fileSize = (Get-Item $enclosure.path).Length
153+
$signature = Get-Ed25519Signature $enclosure.path
154+
155+
$newEnclosure = $appCast.CreateElement("enclosure")
156+
$newEnclosure.SetAttribute("url", $url)
157+
$newEnclosure.SetAttribute("type", "application/x-msdos-program")
158+
$newEnclosure.SetAttribute("length", $fileSize)
159+
160+
# Set namespaced attributes
161+
$sparkleNs = $nsManager.LookupNamespace("sparkle")
162+
$attrs = @{
163+
"os" = $enclosure.os
164+
"version" = $version
165+
"shortVersionString" = $version
166+
"criticalUpdate" = "false"
167+
"edSignature" = $signature # NetSparkle prefers edSignature over signature
168+
}
169+
foreach ($key in $attrs.Keys) {
170+
$attr = $appCast.CreateAttribute("sparkle", $key, $sparkleNs)
171+
$attr.Value = $attrs[$key]
172+
$newEnclosure.Attributes.Append($attr) | Out-Null
173+
}
174+
175+
$channelItem.AppendChild($newEnclosure) | Out-Null
176+
}
177+
178+
# Save the updated XML. Convert CRLF to LF since CRLF seems to break NetSparkle
179+
$appCast.Save($outputAppCastPath)
180+
$content = [System.IO.File]::ReadAllText($outputAppCastPath)
181+
$content = $content -replace "`r`n", "`n"
182+
[System.IO.File]::WriteAllText($outputAppCastPath, $content)
183+
184+
Write-Output "---- Updated appcast -----"
185+
Get-Content $outputAppCastPath
186+
Write-Output "---- End of updated appcast ----"
187+
Write-Output ""
188+
189+
# Generate the signature for the appcast itself
190+
$appCastSignature = Get-Ed25519Signature $outputAppCastPath
191+
[System.IO.File]::WriteAllText($outputAppCastSignaturePath, $appCastSignature)
192+
Write-Output "---- Updated appcast signature -----"
193+
Get-Content $outputAppCastSignaturePath
194+
Write-Output "---- End of updated appcast signature ----"

0 commit comments

Comments
 (0)
Please sign in to comment.