diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index adbc130d..484d89e6 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -30,6 +30,8 @@ jobs:
     permissions:
       # To upload assets to the release
       contents: write
+      # for GCP auth
+      id-token: write
     steps:
       - name: Checkout
         uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
@@ -46,6 +48,17 @@ jobs:
       - name: Setup Nix
         uses: ./.github/actions/nix-devshell
 
+      - name: Authenticate to Google Cloud
+        id: gcloud_auth
+        uses: google-github-actions/auth@71f986410dfbc7added4569d411d040a91dc6935 # v2.1.8
+        with:
+          workload_identity_provider: ${{ secrets.GCP_WORKLOAD_ID_PROVIDER }}
+          service_account: ${{ secrets.GCP_SERVICE_ACCOUNT }}
+          token_format: "access_token"
+
+      - name: Setup GCloud SDK
+        uses: google-github-actions/setup-gcloud@77e7a554d41e2ee56fc945c52dfd3f33d12def9a # v2.1.4
+
       - name: Build
         env:
           APPLE_DEVELOPER_ID_PKCS12_B64: ${{ secrets.APPLE_DEVELOPER_ID_PKCS12_B64 }}
@@ -76,6 +89,22 @@ jobs:
           GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
           RELEASE_TAG: ${{ github.event_name == 'release' && github.event.release.tag_name || 'preview' }}
 
+      - name: Update Appcast
+        if: ${{ !inputs.dryrun }}
+        run: |
+          gsutil cp "gs://releases.coder.com/coder-desktop/mac/appcast.xml" ./oldappcast.xml
+          pushd scripts/update-appcast
+          swift run update-appcast \
+            -i ../../oldappcast.xml \
+            -s "$out"/Coder-Desktop.pkg.sig \
+            -v "$(../version.sh)" \
+            -o ../../appcast.xml \
+            -d "$VERSION_DESCRIPTION"
+          popd
+          gsutil -h "Cache-Control:no-cache,max-age=0" cp ./appcast.xml "gs://releases.coder.com/coder-desktop/mac/appcast.xml"
+        env:
+          VERSION_DESCRIPTION: ${{ github.event_name == 'release' && github.event.release.body || '' }}
+
   update-cask:
     name: Update homebrew-coder cask
     runs-on: ${{ github.repository_owner == 'coder' && 'depot-macos-latest' || 'macos-latest'}}
diff --git a/flake.nix b/flake.nix
index ab3ab0a1..10af339f 100644
--- a/flake.nix
+++ b/flake.nix
@@ -59,6 +59,14 @@
                 xcpretty
                 zizmor
               ];
+              shellHook = ''
+                # Copied from https://github.com/ghostty-org/ghostty/blob/c4088f0c73af1c153c743fc006637cc76c1ee127/nix/devShell.nix#L189-L199
+                # We want to rely on the system Xcode tools in CI!
+                unset SDKROOT
+                unset DEVELOPER_DIR
+                # We need to remove the nix "xcrun" from the PATH.
+                export PATH=$(echo "$PATH" | awk -v RS=: -v ORS=: '$0 !~ /xcrun/ || $0 == "/usr/bin" {print}' | sed 's/:$//')
+              '';
             };
 
             default = pkgs.mkShellNoCC {
diff --git a/scripts/update-appcast/Package.swift b/scripts/update-appcast/Package.swift
index 6f12df29..aa6a53e0 100644
--- a/scripts/update-appcast/Package.swift
+++ b/scripts/update-appcast/Package.swift
@@ -6,7 +6,7 @@ import PackageDescription
 let package = Package(
     name: "update-appcast",
     platforms: [
-        .macOS(.v15),
+        .macOS(.v14),
     ],
     dependencies: [
         .package(url: "https://github.com/apple/swift-argument-parser", from: "1.3.0"),
diff --git a/scripts/update-appcast/Sources/main.swift b/scripts/update-appcast/Sources/main.swift
index 27cd7109..d546003f 100644
--- a/scripts/update-appcast/Sources/main.swift
+++ b/scripts/update-appcast/Sources/main.swift
@@ -68,7 +68,7 @@ struct UpdateAppcast: AsyncParsableCommand {
         }
 
         let xmlData = try Data(contentsOf: URL(fileURLWithPath: input))
-        let doc = try XMLDocument(data: xmlData, options: .nodePrettyPrint)
+        let doc = try XMLDocument(data: xmlData, options: [.nodePrettyPrint, .nodePreserveAll])
 
         guard let channelElem = try doc.nodes(forXPath: "/rss/channel").first as? XMLElement else {
             throw RuntimeError("<channel> element not found in appcast.")
@@ -98,7 +98,7 @@ struct UpdateAppcast: AsyncParsableCommand {
             item.addChild(XMLElement(name: "title", stringValue: "Preview"))
         }
 
-        if let description {
+        if let description, !description.isEmpty {
             let description = description.replacingOccurrences(of: #"\r\n"#, with: "\n")
             let descriptionDoc: Document
             do {
@@ -143,7 +143,7 @@ struct UpdateAppcast: AsyncParsableCommand {
 
         channelElem.insertChild(item, at: insertionIndex)
 
-        let outputStr = doc.xmlString(options: [.nodePrettyPrint]) + "\n"
+        let outputStr = doc.xmlString(options: [.nodePrettyPrint, .nodePreserveAll]) + "\n"
         try outputStr.write(to: URL(fileURLWithPath: output), atomically: true, encoding: .utf8)
     }