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 @@

Enterprise

We're on a closed beta

If you were invited, paste your code and download the app.

- +
Download for Mac or join our waiting list to get access @@ -2267,7 +2267,7 @@

We're on a closed beta

We're on a closed beta

If you were invited, paste your code and pick your Windows version.

- +
Windows (x64 / Intel / AMD) Windows (ARM64 / Surface, Snapdragon) @@ -2615,31 +2615,51 @@

We're on a closed beta

// Download gate modal (function() { var CODE = '{{ env.downloadCode }}'; + // Build-time fallback installer URLs (src/_data/release.js), baked at + // deploy. The runtime lookup below upgrades them to the newest release + // when it works; when it fails (GitHub rate-limits unauthenticated API + // calls per IP, so shared office/VPN/mobile IPs hit this constantly) the + // baked links keep the download working. Worst case the visitor gets the + // release current at deploy time and the app self-updates on first launch. + var BAKED = { + dmg: '{{ release.dmgUrl }}', + winX64: '{{ release.winX64Url }}', + winArm64: '{{ release.winArm64Url }}' + }; + var LIVE = { dmg: null, winX64: null, winArm64: null }; + function dlUrl(kind) { return LIVE[kind] || BAKED[kind] || ''; } + function linkSource(kind) { return LIVE[kind] ? 'live' : (BAKED[kind] ? 'baked' : 'none'); } + function codeMatches(value) { return value.trim().toLowerCase() === CODE.trim().toLowerCase(); } + var overlay = document.getElementById('dl-overlay'); var input = document.getElementById('dl-code'); var btn = document.getElementById('dl-btn'); var waitlistLink = document.getElementById('dl-waitlist'); var skipDetails = document.getElementById('dl-skip'); var xLink = document.getElementById('dl-x-link'); - var dmgUrl = null; - var winX64Url = null; - var winArm64Url = null; var unlockedFired = false; var winUnlockedFired = false; var lastSource = 'unknown'; - // Fetch latest release artifact URLs (Mac DMG + Windows MSIs) + // Fetch latest release artifact URLs (Mac DMG + Windows MSIs). Best + // effort: on failure we warn and the baked fallbacks carry the download. + // Either way, re-validate both gates when the answer lands so a code + // pasted before this resolves still unlocks (the old bug: validation only + // ran on keystrokes, so a slow or failed lookup left a correct code + // locked forever). fetch('https://api.github.com/repos/gethouston/houston/releases/latest') .then(function(r) { return r.json(); }) .then(function(data) { var assets = data.assets || []; var dmg = assets.find(function(a) { return a.name.endsWith('.dmg'); }); - if (dmg) dmgUrl = dmg.browser_download_url; + if (dmg) LIVE.dmg = dmg.browser_download_url; var winX64 = assets.find(function(a) { return /_x64.*\.msi$/.test(a.name); }); - if (winX64) winX64Url = winX64.browser_download_url; + if (winX64) LIVE.winX64 = winX64.browser_download_url; var winArm64 = assets.find(function(a) { return /_arm64.*\.msi$/.test(a.name); }); - if (winArm64) winArm64Url = winArm64.browser_download_url; - }); + if (winArm64) LIVE.winArm64 = winArm64.browser_download_url; + }) + .catch(function(err) { console.warn('release lookup failed, using baked fallback links:', err); }) + .then(function() { validateMac(); validateWin(); }); // Open modal document.querySelectorAll('[data-dl-trigger]').forEach(function(el) { @@ -2665,22 +2685,28 @@

We're on a closed beta

if (e.key === 'Escape') overlay.classList.remove('open'); }); - // Validate code on input - input.addEventListener('input', function() { - var match = input.value.trim() === CODE; + // Gate rule: a correct code is the only condition the USER controls, so + // it alone fires `download_unlocked` (keeps the funnel honest even if a + // URL is somehow missing). The href is live-or-baked, and this re-runs + // when the release lookup lands, not just on keystrokes. + function validateMac() { + var match = codeMatches(input.value); + var url = dlUrl('dmg'); input.classList.toggle('valid', match); - if (match && dmgUrl) { - btn.href = dmgUrl; + if (match && !unlockedFired) { + unlockedFired = true; + track('download_unlocked', { source: lastSource, link_source: linkSource('dmg') }); + } + if (match && url) { + btn.href = url; btn.classList.remove('btn-disabled'); - if (!unlockedFired) { - unlockedFired = true; - track('download_unlocked', { source: lastSource }); - } } else { + if (match) console.warn('download gate: code accepted but no installer URL available'); btn.removeAttribute('href'); btn.classList.add('btn-disabled'); } - }); + } + input.addEventListener('input', validateMac); // Track actual download btn.addEventListener('click', function(e) { @@ -2688,7 +2714,7 @@

We're on a closed beta

e.preventDefault(); return; } - track('download_started', { source: lastSource, dmg_url: dmgUrl || '' }); + track('download_started', { source: lastSource, dmg_url: dlUrl('dmg'), link_source: linkSource('dmg') }); }); // Track waitlist click @@ -2713,11 +2739,24 @@

We're on a closed beta

btn.href = url; btn.classList.remove('btn-disabled'); } else { + if (match) console.warn('download gate: code accepted but no installer URL available'); btn.removeAttribute('href'); btn.classList.add('btn-disabled'); } } + function validateWin() { + if (!winInput) return; + var match = codeMatches(winInput.value); + winInput.classList.toggle('valid', match); + setWinButton(winX64Btn, dlUrl('winX64'), match); + setWinButton(winArm64Btn, dlUrl('winArm64'), match); + if (match && !winUnlockedFired) { + winUnlockedFired = true; + track('windows_download_unlocked', { source: winLastSource, link_source: linkSource('winX64') }); + } + } + if (winOverlay) { document.querySelectorAll('[data-dl-windows-trigger]').forEach(function(el) { el.addEventListener('click', function(e) { @@ -2739,18 +2778,10 @@

We're on a closed beta

if (e.key === 'Escape') winOverlay.classList.remove('open'); }); - // Validate code → unlock both Windows download buttons + // Validate code → unlock both Windows download buttons (same gate rule + // and live-or-baked URLs as the Mac modal; re-run when the lookup lands) if (winInput) { - winInput.addEventListener('input', function() { - var match = winInput.value.trim() === CODE; - winInput.classList.toggle('valid', match); - setWinButton(winX64Btn, winX64Url, match); - setWinButton(winArm64Btn, winArm64Url, match); - if (match && !winUnlockedFired) { - winUnlockedFired = true; - track('windows_download_unlocked', { source: winLastSource }); - } - }); + winInput.addEventListener('input', validateWin); } // Track actual downloads @@ -2760,7 +2791,7 @@

We're on a closed beta

e.preventDefault(); return; } - track('windows_download_started', { source: winLastSource, arch: 'x64', msi_url: winX64Url || '' }); + track('windows_download_started', { source: winLastSource, arch: 'x64', msi_url: dlUrl('winX64'), link_source: linkSource('winX64') }); }); } if (winArm64Btn) { @@ -2769,7 +2800,7 @@

We're on a closed beta

e.preventDefault(); return; } - track('windows_download_started', { source: winLastSource, arch: 'arm64', msi_url: winArm64Url || '' }); + track('windows_download_started', { source: winLastSource, arch: 'arm64', msi_url: dlUrl('winArm64'), link_source: linkSource('winArm64') }); }); }