Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/update-feed-off-github.md
Original file line number Diff line number Diff line change
@@ -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.
78 changes: 78 additions & 0 deletions .github/workflows/desktop-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
14 changes: 14 additions & 0 deletions packages/harness-desktop/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<tag>/…` 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.
Expand Down
20 changes: 16 additions & 4 deletions packages/harness-desktop/electron-builder.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
9 changes: 4 additions & 5 deletions packages/harness-desktop/scripts/pack.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 10 additions & 7 deletions packages/harness-desktop/src/main/smoke.ts
Original file line number Diff line number Diff line change
Expand Up @@ -986,16 +986,19 @@ async function checkUpdateConfig(): Promise<string> {
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)`,
);
}

Expand Down Expand Up @@ -1030,7 +1033,7 @@ async function checkUpdateConfig(): Promise<string> {
// 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()})`
);
}
Expand Down
12 changes: 12 additions & 0 deletions packages/harness-desktop/src/main/update-policy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
18 changes: 13 additions & 5 deletions packages/harness-desktop/src/main/update-policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
/**
Expand Down Expand Up @@ -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)) {
Expand Down
Loading