diff --git a/.github/workflows/netlify-ops.yml b/.github/workflows/netlify-ops.yml index 950d5ba18..3a3638bae 100644 --- a/.github/workflows/netlify-ops.yml +++ b/.github/workflows/netlify-ops.yml @@ -9,8 +9,6 @@ on: type: choice options: - restore-devsy-sh - - find-dl-site - - add-dl-custom-domain - find-images-site - add-images-custom-domain @@ -59,86 +57,6 @@ jobs: echo "devsy.sh HTTP status: $STATUS" [ "$STATUS" = "200" ] && echo "SUCCESS: devsy.sh is back online" || echo "WARNING: Got $STATUS" - find-dl-site: - name: Find or Create dl.devsy.sh Netlify Site - if: inputs.operation == 'find-dl-site' - runs-on: ubuntu-latest - env: - NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }} - steps: - - name: Find or create dl.devsy.sh site and output ID - run: | - echo "Listing Netlify sites..." - SITES=$(curl -s -H "Authorization: Bearer $NETLIFY_AUTH_TOKEN" \ - "https://api.netlify.com/api/v1/sites?per_page=100") - - DL_SITE_ID=$(echo "$SITES" | \ - jq -r '.[] | select(.custom_domain == "dl.devsy.sh" or .name == "dl-devsy-sh") | .id' | head -1) - - if [ -n "$DL_SITE_ID" ] && [ "$DL_SITE_ID" != "null" ]; then - DL_SITE_NAME=$(echo "$SITES" | jq -r ".[] | select(.id == \"$DL_SITE_ID\") | .name") - echo "Found existing site: $DL_SITE_NAME" - else - echo "Site not found. Listing all sites for reference:" - echo "$SITES" | jq '.[] | {id, name, custom_domain, url}' | head -100 - echo "" - echo "Creating dl-devsy-sh..." - CREATE_RESP=$(curl -s -X POST \ - -H "Authorization: Bearer $NETLIFY_AUTH_TOKEN" \ - -H "Content-Type: application/json" \ - -d '{"name": "dl-devsy-sh"}' \ - "https://api.netlify.com/api/v1/sites") - DL_SITE_ID=$(echo "$CREATE_RESP" | jq -r '.id') - echo "Created: $(echo "$CREATE_RESP" | jq '{id, name, url}')" - fi - - echo "" - echo "========================================" - echo "NETLIFY_DL_SITE_ID = $DL_SITE_ID" - echo "========================================" - echo "Run: gh secret set NETLIFY_DL_SITE_ID --org devsy-org --visibility all --body \"$DL_SITE_ID\"" - - add-dl-custom-domain: - name: Add dl.devsy.sh Custom Domain to dl-devsy-sh Site - if: inputs.operation == 'add-dl-custom-domain' - runs-on: ubuntu-latest - env: - NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }} - DL_SITE_ID: a6c8ca4e-62c1-4c75-95ba-1abcd2af540f - steps: - - name: Add custom domain and report DNS instructions - run: | - echo "Adding dl.devsy.sh as custom domain to site $DL_SITE_ID..." - RESPONSE=$(curl -s -w "\n%{http_code}" -X PUT \ - -H "Authorization: Bearer $NETLIFY_AUTH_TOKEN" \ - -H "Content-Type: application/json" \ - -d '{"custom_domain": "dl.devsy.sh"}' \ - "https://api.netlify.com/api/v1/sites/$DL_SITE_ID") - - HTTP_CODE=$(echo "$RESPONSE" | tail -1) - BODY=$(echo "$RESPONSE" | sed '$d') - - if [ "$HTTP_CODE" != "200" ] && [ "$HTTP_CODE" != "201" ]; then - echo "ERROR: Custom domain update failed (HTTP $HTTP_CODE)" - echo "$BODY" - exit 1 - fi - - echo "Custom domain set (HTTP $HTTP_CODE)" - NETLIFY_SUBDOMAIN=$(echo "$BODY" | jq -r '.subdomain // .name') - echo "" - echo "========================================" - echo "CUSTOM DOMAIN CONFIGURED: dl.devsy.sh" - echo "========================================" - echo "" - echo "CEO DNS action required — add this DNS record:" - echo " Type: CNAME" - echo " Name: dl" - echo " Value: ${NETLIFY_SUBDOMAIN}.netlify.app" - echo "" - echo "Full site info:" - echo "$BODY" | jq '{id, name, custom_domain, subdomain, url, ssl_url}' - find-images-site: name: Find or Create images.devsy.sh Netlify Site if: inputs.operation == 'find-images-site' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 133c5735a..6be6e48aa 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -288,34 +288,43 @@ jobs: pattern: update-metadata-* path: metadata/ - - name: prepare publish dir - run: mkdir -p publish-dir/desktop - - - name: merge macOS metadata from separate arch builds - run: go run hack/merge_mac_metadata/main.go metadata/ publish-dir/desktop/ - - - name: overlay non-mac metadata files (stable release) - if: ${{ !github.event.release.prerelease }} - run: | - find metadata/ -name 'latest-linux.yml' -exec cp {} publish-dir/desktop/ \; - find metadata/ -name 'latest.yml' -exec cp {} publish-dir/desktop/ \; - find metadata/ -name 'beta-linux.yml' -exec cp {} publish-dir/desktop/ \; - find metadata/ -name 'beta.yml' -exec cp {} publish-dir/desktop/ \; - - - name: overlay non-mac metadata files (prerelease) - if: ${{ github.event.release.prerelease }} + - name: assemble this channel's manifests + env: + IS_PRERELEASE: ${{ github.event.release.prerelease }} run: | - find metadata/ -name 'beta-linux.yml' -exec cp {} publish-dir/desktop/ \; - find metadata/ -name 'beta.yml' -exec cp {} publish-dir/desktop/ \; + set -euo pipefail + mkdir -p upload + go run hack/merge_mac_metadata/main.go metadata/ upload/ + if [ "$IS_PRERELEASE" = "true" ]; then + rm -f upload/latest*.yml + find metadata/ \( -name 'beta.yml' -o -name 'beta-linux.yml' \) \ + -exec cp {} upload/ \; + else + rm -f upload/beta*.yml + find metadata/ \( -name 'latest.yml' -o -name 'latest-linux.yml' \) \ + -exec cp {} upload/ \; + fi + ls -la upload/ - - name: deploy to Netlify (dl.devsy.sh) - uses: nwtgck/actions-netlify@d22a32a27c918fe470bbc562e984f80ec48c2668 # v4 - with: - publish-dir: ./publish-dir - production-deploy: true + - name: upload manifests to R2 (dl.devsy.sh) env: - NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }} - NETLIFY_SITE_ID: ${{ secrets.NETLIFY_DL_SITE_ID }} + AWS_ACCESS_KEY_ID: ${{ secrets.DEVSY_R2_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.DEVSY_R2_SECRET_ACCESS_KEY }} + AWS_DEFAULT_REGION: auto + R2_S3_API: ${{ secrets.DEVSY_R2_S3_API }} + run: | + set -euo pipefail + bucket="devsy" + rest="${R2_S3_API#*://}" + endpoint="${R2_S3_API%%://*}://${rest%%/*}" + for f in upload/*.yml; do + name=$(basename "$f") + aws s3 cp "$f" "s3://${bucket}/desktop/${name}" \ + --endpoint-url "$endpoint" \ + --content-type "text/yaml" \ + --cache-control "public, max-age=0, must-revalidate" + echo "uploaded desktop/${name}" + done build-flatpak: name: Build Flatpak diff --git a/desktop/src/main/__tests__/updater.test.ts b/desktop/src/main/__tests__/updater.test.ts index ceb59a38f..a7e8d0bb2 100644 --- a/desktop/src/main/__tests__/updater.test.ts +++ b/desktop/src/main/__tests__/updater.test.ts @@ -27,6 +27,7 @@ vi.mock("electron-updater", () => ({ vi.mock("electron", () => ({ app: { isPackaged: true, + isQuitting: false, getPath: () => "/tmp/devsy-test", getVersion: () => "1.0.0", }, @@ -39,6 +40,7 @@ describe("updater", () => { electronUpdaterMock.autoUpdater.handlers.clear() electronUpdaterMock.autoUpdater.checkForUpdates.mockClear() electronUpdaterMock.autoUpdater.downloadUpdate.mockClear() + electronUpdaterMock.autoUpdater.quitAndInstall.mockReset() vi.resetModules() // Restore isPackaged on every test so an early throw in one test // cannot silently flip later tests into the dev-mode branch. @@ -89,6 +91,35 @@ describe("updater", () => { expect(electronUpdaterMock.autoUpdater.downloadUpdate).not.toHaveBeenCalled() }) + it("sets app.isQuitting before quitAndInstall so the window can close", async () => { + const electron = await import("electron") + ;(electron.app as { isQuitting: boolean }).isQuitting = false + let quittingWhenInstalled: boolean | undefined + electronUpdaterMock.autoUpdater.quitAndInstall.mockImplementation(() => { + quittingWhenInstalled = (electron.app as { isQuitting: boolean }).isQuitting + }) + const { installUpdate } = await import("../updater.js") + await installUpdate() + expect(quittingWhenInstalled).toBe(true) + expect(electronUpdaterMock.autoUpdater.quitAndInstall).toHaveBeenCalledTimes(1) + }) + + it("swallows a channel-missing rejection from check_for_updates", async () => { + electronUpdaterMock.autoUpdater.checkForUpdates.mockRejectedValueOnce( + new Error('Cannot find channel "latest-mac.yml" update info: HttpError: 404'), + ) + const { checkForUpdates } = await import("../updater.js") + await expect(checkForUpdates()).resolves.toBeUndefined() + }) + + it("propagates non-channel-missing errors from check_for_updates", async () => { + electronUpdaterMock.autoUpdater.checkForUpdates.mockRejectedValueOnce( + new Error("net::ERR_INTERNET_DISCONNECTED"), + ) + const { checkForUpdates } = await import("../updater.js") + await expect(checkForUpdates()).rejects.toThrow("ERR_INTERNET_DISCONNECTED") + }) + it("does not fire a native dialog on update-downloaded", async () => { const electron = await import("electron") const { initAutoUpdater } = await import("../updater.js") diff --git a/desktop/src/main/updater.ts b/desktop/src/main/updater.ts index 8e100a1da..b828288ee 100644 --- a/desktop/src/main/updater.ts +++ b/desktop/src/main/updater.ts @@ -270,10 +270,21 @@ async function getUpdater() { return autoUpdater } +async function runUpdateCheck( + autoUpdater: NonNullable>>, +): Promise { + try { + await autoUpdater.checkForUpdates() + } catch (err) { + if (err instanceof Error && classifyError(err) === "channel-missing") return + throw err + } +} + export async function checkForUpdates(): Promise { const autoUpdater = await getUpdater() if (!autoUpdater) return - await autoUpdater.checkForUpdates() + await runUpdateCheck(autoUpdater) } export async function checkForUpdatesWithChannel(channel: ReleaseChannel): Promise { @@ -284,15 +295,7 @@ export async function checkForUpdatesWithChannel(channel: ReleaseChannel): Promi if (!autoUpdater) return autoUpdater.allowPrerelease = channel === "beta" autoUpdater.channel = channel === "beta" ? "beta" : "latest" - try { - await autoUpdater.checkForUpdates() - } catch (err) { - // A missing channel manifest is surfaced via the 'error' event as a - // friendly not-available status; don't let the rejected promise bubble - // up and trigger a channel rollback in the IPC caller. - if (err instanceof Error && classifyError(err) === "channel-missing") return - throw err - } + await runUpdateCheck(autoUpdater) } export async function downloadUpdate(): Promise { @@ -304,5 +307,6 @@ export async function downloadUpdate(): Promise { export async function installUpdate(): Promise { const autoUpdater = await getUpdater() if (!autoUpdater || typeof autoUpdater.quitAndInstall !== "function") return + ;(app as typeof app & { isQuitting?: boolean }).isQuitting = true autoUpdater.quitAndInstall() } diff --git a/desktop/src/renderer/src/lib/__mocks__/setup.ts b/desktop/src/renderer/src/lib/__mocks__/setup.ts index ad48ac696..ef543cbb6 100644 --- a/desktop/src/renderer/src/lib/__mocks__/setup.ts +++ b/desktop/src/renderer/src/lib/__mocks__/setup.ts @@ -3,6 +3,17 @@ * Provides browser API polyfills that jsdom doesn't fully cover. */ +import { afterAll } from "vitest" + +// bits-ui's body scroll lock schedules a ~24ms setTimeout to reset the body +// style after a Dialog/Sheet unmounts. If a test file finishes before that +// timer fires, jsdom is torn down and the callback throws +// "ReferenceError: document is not defined". Drain any pending cleanup before +// teardown, while document still exists. +afterAll(async () => { + await new Promise((resolve) => setTimeout(resolve, 30)) +}) + // Node 22+ has a built-in localStorage that requires --localstorage-file. // Override with a simple in-memory implementation for tests. if (