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
82 changes: 0 additions & 82 deletions .github/workflows/netlify-ops.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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'
Expand Down
59 changes: 34 additions & 25 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
coderabbitai[bot] marked this conversation as resolved.

build-flatpak:
name: Build Flatpak
Expand Down
31 changes: 31 additions & 0 deletions desktop/src/main/__tests__/updater.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ vi.mock("electron-updater", () => ({
vi.mock("electron", () => ({
app: {
isPackaged: true,
isQuitting: false,
getPath: () => "/tmp/devsy-test",
getVersion: () => "1.0.0",
},
Expand All @@ -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.
Expand Down Expand Up @@ -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)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
})

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")
Expand Down
24 changes: 14 additions & 10 deletions desktop/src/main/updater.ts
Original file line number Diff line number Diff line change
Expand Up @@ -270,10 +270,21 @@ async function getUpdater() {
return autoUpdater
}

async function runUpdateCheck(
autoUpdater: NonNullable<Awaited<ReturnType<typeof getUpdater>>>,
): Promise<void> {
try {
await autoUpdater.checkForUpdates()
} catch (err) {
if (err instanceof Error && classifyError(err) === "channel-missing") return
throw err
}
}

export async function checkForUpdates(): Promise<void> {
const autoUpdater = await getUpdater()
if (!autoUpdater) return
await autoUpdater.checkForUpdates()
await runUpdateCheck(autoUpdater)
}

export async function checkForUpdatesWithChannel(channel: ReleaseChannel): Promise<void> {
Expand All @@ -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<void> {
Expand All @@ -304,5 +307,6 @@ export async function downloadUpdate(): Promise<void> {
export async function installUpdate(): Promise<void> {
const autoUpdater = await getUpdater()
if (!autoUpdater || typeof autoUpdater.quitAndInstall !== "function") return
;(app as typeof app & { isQuitting?: boolean }).isQuitting = true
autoUpdater.quitAndInstall()
}
11 changes: 11 additions & 0 deletions desktop/src/renderer/src/lib/__mocks__/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down
Loading