diff --git a/.github/workflows/website-deploy.yml b/.github/workflows/website-deploy.yml index 796c5dc62..99e837edf 100644 --- a/.github/workflows/website-deploy.yml +++ b/.github/workflows/website-deploy.yml @@ -49,6 +49,10 @@ jobs: SUPABASE_URL: ${{ secrets.SUPABASE_URL }} SUPABASE_ANON_KEY: ${{ secrets.SUPABASE_ANON_KEY }} WAITLIST_SHEET_ENDPOINT: ${{ secrets.WAITLIST_SHEET_ENDPOINT }} + # Lets src/_data/release.js resolve the latest installer URLs without + # hitting the unauthenticated per-IP GitHub API rate limit on shared + # Actions runners. The build fails if these URLs can't be resolved. + GITHUB_TOKEN: ${{ github.token }} - name: Deploy to Cloudflare Pages uses: cloudflare/wrangler-action@v3 diff --git a/website/lib/release-assets.js b/website/lib/release-assets.js new file mode 100644 index 000000000..928f5f30a --- /dev/null +++ b/website/lib/release-assets.js @@ -0,0 +1,14 @@ +// Pure asset-picking logic for src/_data/release.js, kept outside _data +// because Eleventy treats named exports in data files as data keys (which +// silently breaks the default export's value). Also imported by tests. +export function pickInstallerUrls(assets) { + const find = (re) => { + const a = (assets || []).find((a) => re.test(a.name)); + return a ? a.browser_download_url : ""; + }; + return { + dmgUrl: find(/\.dmg$/), + winX64Url: find(/_x64.*\.msi$/), + winArm64Url: find(/_arm64.*\.msi$/), + }; +} diff --git a/website/src/_data/release.js b/website/src/_data/release.js new file mode 100644 index 000000000..3d14871d4 --- /dev/null +++ b/website/src/_data/release.js @@ -0,0 +1,65 @@ +// Latest-release installer URLs, resolved at BUILD time (async data file, +// same pattern as changelog.js). NOTE: no named exports here — Eleventy +// treats named exports in data files as data keys and drops the default. +// +// Why this exists: the download gate used to depend entirely on a runtime +// fetch of api.github.com from the visitor's browser. Unauthenticated GitHub +// API calls are limited to 60/hour PER IP, so visitors behind shared IPs +// (offices, VPNs, mobile carrier NAT) got no installer URL and the download +// button could never enable, even with a valid invite code. These build-time +// URLs are baked into the page as the guaranteed fallback: the runtime fetch +// (when it works) upgrades them to the very latest release; when it fails, +// the baked link still downloads the app directly (at worst one release +// behind this deploy, and the app self-updates on first launch). +// +// Unlike changelog.js (cosmetic, fails soft), this fails CLOSED in CI: +// deploying a page with empty fallbacks would silently reintroduce the +// dead-button bug, so we throw and let the deploy fail visibly instead. +import { pickInstallerUrls } from "../../lib/release-assets.js"; + +const RELEASES_API = + "https://api.github.com/repos/gethouston/houston/releases/latest"; + +async function fetchLatestAssets() { + const headers = { + Accept: "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + "User-Agent": "houston-website-build", + }; + // In GitHub Actions the workflow token avoids the shared-runner-IP limit. + if (process.env.GITHUB_TOKEN) { + headers.Authorization = `Bearer ${process.env.GITHUB_TOKEN}`; + } + let lastErr; + for (let attempt = 1; attempt <= 3; attempt++) { + try { + const res = await fetch(RELEASES_API, { headers }); + if (!res.ok) throw new Error(`GitHub API ${res.status}`); + const data = await res.json(); + return data.assets || []; + } catch (err) { + lastErr = err; + if (attempt < 3) await new Promise((r) => setTimeout(r, 2000 * attempt)); + } + } + throw lastErr; +} + +export default async function () { + try { + const urls = pickInstallerUrls(await fetchLatestAssets()); + for (const [key, url] of Object.entries(urls)) { + if (!url) throw new Error(`latest release is missing asset for ${key}`); + } + console.log(`[release] baked installer fallbacks: ${urls.dmgUrl}`); + return urls; + } catch (err) { + // CI: never ship a build without working download fallbacks. + if (process.env.CI) { + throw new Error(`[release] could not resolve installer URLs: ${err}`); + } + // Local/offline builds: warn and fall back to runtime-fetch-only behavior. + console.warn(`[release] offline? baking empty fallbacks: ${err}`); + return { dmgUrl: "", winX64Url: "", winArm64Url: "" }; + } +} diff --git a/website/src/_includes/early-access/app.njk b/website/src/_includes/early-access/app.njk index 825519aeb..adf3aee05 100644 --- a/website/src/_includes/early-access/app.njk +++ b/website/src/_includes/early-access/app.njk @@ -34,7 +34,11 @@ var winBtn = document.getElementById('winBtn'); // Resolve the real installer URLs from the latest GitHub release (same source - // as the home page). On failure we fall back to the site, and log it. + // as the home page). On failure we fall back to the build-time baked links + // (src/_data/release.js), so the download still works when the unauthenticated + // GitHub API is rate-limited (shared office/VPN/mobile IPs) or blocked. + var BAKED_DMG = '{{ release.dmgUrl }}'; + var BAKED_WIN = '{{ release.winX64Url }}'; var dmgUrl = null, winUrl = null; fetch('https://api.github.com/repos/gethouston/houston/releases/latest') .then(function (r) { return r.json(); }) @@ -88,6 +92,6 @@ rpc('mark_download', { p_token: token, p_os: os }); if (url) window.location.href = url; } - macBtn.addEventListener('click', function (e) { e.preventDefault(); go(dmgUrl || 'https://gethouston.ai/', 'mac'); }); - winBtn.addEventListener('click', function (e) { e.preventDefault(); go(winUrl || 'https://gethouston.ai/', 'windows'); }); + macBtn.addEventListener('click', function (e) { e.preventDefault(); go(dmgUrl || BAKED_DMG || 'https://gethouston.ai/', 'mac'); }); + winBtn.addEventListener('click', function (e) { e.preventDefault(); go(winUrl || BAKED_WIN || 'https://gethouston.ai/', 'windows'); }); diff --git a/website/src/index.html b/website/src/index.html index e9afd4662..1eb65dce4 100644 --- a/website/src/index.html +++ b/website/src/index.html @@ -2242,7 +2242,7 @@
If you were invited, paste your code and download the app.
- +If you were invited, paste your code and pick your Windows version.
- +