diff --git a/.changeset/update-feed-off-github.md b/.changeset/update-feed-off-github.md new file mode 100644 index 00000000..a079f557 --- /dev/null +++ b/.changeset/update-feed-off-github.md @@ -0,0 +1,5 @@ +--- +"@sapiom/harness-desktop": patch +--- + +Studio now checks for updates against a GitHub Pages feed instead of github.com's release pages, so a shared office/VPN IP no longer gets "GitHub is rate-limiting this network" on update checks. diff --git a/.github/workflows/desktop-release.yml b/.github/workflows/desktop-release.yml index 73fa8a77..ca31404f 100644 --- a/.github/workflows/desktop-release.yml +++ b/.github/workflows/desktop-release.yml @@ -658,3 +658,81 @@ jobs: Something not working? [Open an issue](https://github.com/sapiom/sapiom-js/issues) and tell us which platform you're on — it helps a lot. + + # The update FEED lives on GitHub Pages, not in the release. + # + # Installed apps use electron-updater's `generic` provider pointed at + # https://sapiom.github.io/sapiom-js/desktop/ (electron-builder.yml). The + # github provider needed ~3 unauthenticated github.com requests per check and + # GitHub throttles those per IP, so one office NAT full of Studios was a 429 + # for everybody. Pages has no such limit; the installers themselves stay on + # the release, so each manifest's `url:` is rewritten from a bare filename + # to the absolute release-asset link before it is published. + # + # AFTER the release step, on purpose: a manifest that advertises assets which + # are not attached yet is a 404 on every client that checks in the gap. + # + # The `gh-pages` branch is updated incrementally (clone, copy, commit), so a + # beta release — which publishes only `beta*.yml` — leaves `latest*.yml` + # untouched, and vice versa. A whole-site Pages deploy would wipe the other + # channel every time. + # + # PREREQUISITE (one-time, repo settings): Pages → Source "Deploy from a + # branch" → `gh-pages` / `/ (root)`. Until that is on, this step still + # pushes, but the URL 404s and installed apps report "no release published". + - name: Publish update manifests to the GitHub Pages feed + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ github.ref_name }} + run: | + set -euo pipefail + assets="https://github.com/${GITHUB_REPOSITORY}/releases/download/${TAG}" + feed="$(mktemp -d)" + remote="https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" + if git clone --quiet --depth 1 --branch gh-pages "$remote" "$feed" 2>/dev/null; then + echo "updating existing gh-pages" + else + git -C "$feed" init --quiet -b gh-pages + git -C "$feed" remote add origin "$remote" + # Pages serves a Jekyll build by default, which drops nothing we ship + # but adds a build step and its failure modes. Opt out. + touch "$feed/.nojekyll" + echo "creating gh-pages" + fi + mkdir -p "$feed/desktop" + shopt -s nullglob globstar + published=() + # `**` also matches zero directories, so the mirrored `beta*.yml` written + # to dist-artifacts/ itself is included. + for manifest in dist-artifacts/**/latest*.yml dist-artifacts/**/beta*.yml; do + base="$(basename "$manifest")" + # `url:` (files[]) and `path:` (legacy single-file field) both name a bare + # asset filename; make each an absolute link into this release. Asset + # names are space-free by construction (see the name-match step above), + # so the token after the key IS the whole filename. + sed -E "s#^([[:space:]]*(-[[:space:]]+)?(url|path):[[:space:]]+)([^[:space:]]+)\$#\1${assets}/\4#" \ + "$manifest" > "$feed/desktop/$base" + published+=("$base") + done + if [ ${#published[@]} -eq 0 ]; then + echo "::error::no manifests to publish — the feed would not learn about ${TAG}" + exit 1 + fi + # Every rewritten url must be an absolute release link, or a client would + # resolve it against the Pages origin and 404 on download. + if grep -hE '^[[:space:]]*(-[[:space:]]+)?(url|path):' "$feed"/desktop/*.yml | grep -vF "$assets/"; then + echo "::error::a manifest url was not rewritten to an absolute release-asset link" + exit 1 + fi + cd "$feed" + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add -A + if git diff --cached --quiet; then + echo "feed already up to date for ${TAG}" + exit 0 + fi + git commit --quiet -m "desktop: publish update feed for ${TAG} (${published[*]})" + git push --quiet origin gh-pages + echo "published to gh-pages/desktop: ${published[*]}" + cat desktop/*.yml diff --git a/packages/harness-desktop/CLAUDE.md b/packages/harness-desktop/CLAUDE.md index ee3a8b71..185a258b 100644 --- a/packages/harness-desktop/CLAUDE.md +++ b/packages/harness-desktop/CLAUDE.md @@ -246,6 +246,20 @@ Things that will bite you: publish provider, no `latest*.yml` is generated and no `resources/app-update.yml` is baked in — the app then runs perfectly and never updates. The `update-config` smoke check exists for that, and it also cross-checks the *baked* channel against the one the app resolves at runtime. +- **The provider is `generic`, fed from GitHub Pages — do not switch it back to `github`.** The + github provider makes ~3 unauthenticated requests to github.com's *web* frontend per check + (`releases.atom`, `/releases/latest`, the manifest), and GitHub throttles those per IP. One office + NAT with a few Studios checking at launch was a 429 for everyone behind it, and no client-side + coalescing fixes a shared-IP limit. Now the app fetches one `latest*.yml`/`beta*.yml` from + `https://sapiom.github.io/sapiom-js/desktop/` (no such limit), and the release job's "Publish + update manifests" step writes those files to the `gh-pages` branch with every `url:` rewritten to + an absolute `/releases/download//…` link — the installers themselves stay on the GitHub + Release, and github.com is touched only when there is something to download. The branch is + updated incrementally, so a beta release (which publishes only `beta*.yml`) leaves the stable + manifests alone. **Pages must be enabled in repo settings** (Source: deploy from branch, + `gh-pages`, `/`) — the step pushes regardless, but until then the URL 404s and every install + reports "no release has been published on this channel yet". Installs built before this change + still read GitHub directly until they update once. - **Don't derive the channel in a workflow.** `pack.mjs` imports `resolveUpdateChannel` from the built `dist/` and passes the flag itself, so the release workflow, the PR smoke job and a local `pnpm dist` cannot disagree. diff --git a/packages/harness-desktop/electron-builder.yml b/packages/harness-desktop/electron-builder.yml index ae5d2925..ca741d02 100644 --- a/packages/harness-desktop/electron-builder.yml +++ b/packages/harness-desktop/electron-builder.yml @@ -30,11 +30,23 @@ directories: # Without it the build succeeds, the app runs, and updates silently never happen. # The packaged `update-config` smoke check exists to catch exactly that. # -# The repo is public, so the app needs no token to read releases. +# `generic`, NOT `github`. The github provider makes ~3 unauthenticated requests +# to github.com's web frontend per check (releases.atom, /releases/latest, the +# manifest), and GitHub throttles non-browser clients per IP — so an office or +# VPN NAT with a handful of Studios each checking at launch gets 429'd for +# everyone behind it, and nothing client-side can fix a shared-IP limit. The +# generic provider fetches ONE file from a host we control (GitHub Pages, +# published by the release job's "Publish update manifests" step), whose +# `url:`s are absolute links to the release assets — so github.com is only +# touched when there is actually something to download. +# +# `useMultipleRangeRequest: false` for the same reason the github provider +# hardcodes it: release assets are served from S3, which rejects multi-range +# requests, and differential downloads would otherwise fail on every update. publish: - provider: github - owner: sapiom - repo: sapiom-js + provider: generic + url: https://sapiom.github.io/sapiom-js/desktop/ + useMultipleRangeRequest: false # Default file globbing packs production node_modules + package.json. We add # our built output explicitly and keep source/maps out of the bundle. diff --git a/packages/harness-desktop/scripts/pack.mjs b/packages/harness-desktop/scripts/pack.mjs index 3b17e168..4c677d1b 100644 --- a/packages/harness-desktop/scripts/pack.mjs +++ b/packages/harness-desktop/scripts/pack.mjs @@ -124,13 +124,12 @@ const electronBuilder = join( ".bin", isWindows ? "electron-builder.cmd" : "electron-builder", ); -// `--publish never`, explicitly. electron-builder.yml declares a github publish +// `--publish never`, explicitly. electron-builder.yml declares a publish // provider — it has to, or no auto-update metadata is generated at all — and // electron-builder's DEFAULT policy publishes when it detects CI plus a tag. That -// is precisely our release condition, so left alone it would try to create the -// GitHub release itself, in parallel with the workflow's own release job: two -// writers on one release, and a build job that would need a write-scoped token it -// currently does not have. +// is precisely our release condition, so left alone it would try to upload the +// artifacts itself (to the generic feed URL, which it cannot write to), in +// parallel with the workflow's own release job. // // Generating the metadata and uploading it are separate concerns; we want the // first and not the second. It comes BEFORE the passthrough args so a caller who diff --git a/packages/harness-desktop/src/main/smoke.ts b/packages/harness-desktop/src/main/smoke.ts index b270faab..2d5db2fb 100644 --- a/packages/harness-desktop/src/main/smoke.ts +++ b/packages/harness-desktop/src/main/smoke.ts @@ -986,16 +986,19 @@ async function checkUpdateConfig(): Promise { raw.match(new RegExp(`^${key}:\\s*(\\S+)`, "m"))?.[1]; const provider = field("provider"); - const owner = field("owner"); - const repo = field("repo"); - if (provider !== "github") { + // The dumper may quote a URL; the check is about the value, not the quoting. + const feedUrl = field("url")?.replace(/^['"]|['"]$/g, ""); + // `generic` is deliberate — see the publish block in electron-builder.yml. The + // github provider is what put every Studio behind one office NAT into a 429. + if (provider !== "generic") { throw new Error( - `app-update.yml provider is "${provider ?? "(none)"}", expected "github"`, + `app-update.yml provider is "${provider ?? "(none)"}", expected "generic"`, ); } - if (!owner || !repo) { + if (!feedUrl || !/^https:\/\/\S+\/$/.test(feedUrl)) { throw new Error( - `app-update.yml names no owner/repo (owner="${owner}", repo="${repo}")`, + `app-update.yml feed url is "${feedUrl ?? "(none)"}" — expected an https URL ending in "/" ` + + `(electron-updater resolves the channel file against it)`, ); } @@ -1030,7 +1033,7 @@ async function checkUpdateConfig(): Promise { // Report the channel so a CI log answers "which channel did this artifact ship // on?" without anyone having to reason about the tag. return ( - `${kind} → ${provider}:${owner}/${repo}, channel "${channel}"` + + `${kind} → ${provider} feed ${feedUrl}, channel "${channel}"` + `${overridden ? " (env override)" : ""} (v${app.getVersion()})` ); } diff --git a/packages/harness-desktop/src/main/update-policy.test.ts b/packages/harness-desktop/src/main/update-policy.test.ts index 93478073..d6885a21 100644 --- a/packages/harness-desktop/src/main/update-policy.test.ts +++ b/packages/harness-desktop/src/main/update-policy.test.ts @@ -226,6 +226,18 @@ describe("classifyUpdateError", () => { } }); + it("reads the generic provider's missing channel file as no-release", () => { + // Verbatim shape from GenericProvider.getLatestVersion on a 404: the feed + // simply has nothing for this channel yet (e.g. beta before any beta ships). + const missing = + 'Cannot find channel "beta-mac.yml" update info: HttpError: 404 Not Found\n' + + '"method: GET url: https://sapiom.github.io/sapiom-js/desktop/beta-mac.yml?noCache=1abc"\n' + + " at createHttpError (…/electron-updater/…)"; + const { kind, summary } = classifyUpdateError(missing); + expect(kind).toBe("no-release"); + expect(summary).not.toMatch(/404|HttpError/); + }); + it("still reads a 429 that GitHubProvider wrapped as a no-release message", () => { // Verbatim shape from electron-updater: getLatestTagName wraps ANY failure // of /releases/latest in its own "ensure a production release exists" diff --git a/packages/harness-desktop/src/main/update-policy.ts b/packages/harness-desktop/src/main/update-policy.ts index 928e033b..a11f172a 100644 --- a/packages/harness-desktop/src/main/update-policy.ts +++ b/packages/harness-desktop/src/main/update-policy.ts @@ -17,10 +17,11 @@ export interface ChannelDecision { /** The channel file the updater reads: `latest*.yml` or `beta*.yml`. */ channel: UpdateChannel; /** - * Whether pre-release GitHub Releases are considered at all. The GitHub - * provider filters pre-releases out of the release feed *before* looking for a - * channel file, so a beta install needs this on or it never sees the beta - * release the channel name points at. + * Whether pre-release builds are considered at all. Inert under the `generic` + * provider the app ships with (the channel FILE is the whole decision there), + * but kept honest: electron-updater's GitHub provider filters pre-releases out + * of its feed *before* looking for a channel file, so anyone pointing the app + * back at GitHub needs this on or a beta install never sees a beta. */ allowPrerelease: boolean; /** @@ -235,7 +236,14 @@ export function classifyUpdateError(raw: string): { kind: UpdateErrorKind; summa summary: "GitHub is rate-limiting this network — it clears on its own; try again in a while", }; } - if (/unable to find latest version|ensure a production release exists|no published versions/i.test(collapsed)) { + // The generic provider's 404 ("Cannot find channel \"beta-mac.yml\" update + // info") is the same normal state as GitHub's "no published versions": the + // feed has nothing for this channel yet. + if ( + /unable to find latest version|ensure a production release exists|no published versions|cannot find channel/i.test( + collapsed, + ) + ) { return { kind: "no-release", summary: "no release has been published on this channel yet" }; } if (/ENOTFOUND|EAI_AGAIN|ECONNREFUSED|ETIMEDOUT|ENETUNREACH|net::ERR/i.test(collapsed)) {