diff --git a/.github/workflows/release-stable.yml b/.github/workflows/release-stable.yml index 9a97f18b..231e59b2 100644 --- a/.github/workflows/release-stable.yml +++ b/.github/workflows/release-stable.yml @@ -208,6 +208,7 @@ jobs: ARM_SIG=$(cat updater-aarch64/*.app.tar.gz.sig) ARM_TARBALL=$(ls updater-aarch64/*.app.tar.gz | xargs basename) + ARM_DMG=$(ls dmg-aarch64/*.dmg | xargs basename) cat > stable-latest.json << EOF { @@ -217,7 +218,8 @@ jobs: "platforms": { "darwin-aarch64": { "signature": "${ARM_SIG}", - "url": "https://github.com/${REPO}/releases/download/${TAG}/${ARM_TARBALL}" + "url": "https://github.com/${REPO}/releases/download/${TAG}/${ARM_TARBALL}", + "dmg_url": "https://github.com/${REPO}/releases/download/${TAG}/${ARM_DMG}" } } } @@ -253,6 +255,11 @@ jobs: steps: - uses: actions/checkout@v4 + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + - name: Build release history page env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -263,6 +270,9 @@ jobs: curl -fsSL "${PAGES_URL}/alpha/latest.json" -o _site/alpha/latest.json || echo '{}' > _site/alpha/latest.json gh release download --repo ${{ github.repository }} "${{ needs.version.outputs.tag }}" --pattern "stable-latest.json" --output _site/stable/latest.json || echo '{}' > _site/stable/latest.json + bun scripts/build-release-download-page.ts --latest-json _site/stable/latest.json --releases-json _site/releases.json --output-file _site/stable/download/index.html + mkdir -p _site/download + cp _site/stable/download/index.html _site/download/index.html cp _site/alpha/latest.json _site/latest.json cp _site/alpha/latest.json _site/latest-canary.json diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bd4e400b..d50d8763 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -291,6 +291,11 @@ jobs: steps: - uses: actions/checkout@v4 + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + - name: Build release history page env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -301,6 +306,9 @@ jobs: gh release download --repo ${{ github.repository }} "${{ needs.version.outputs.tag }}" --pattern "alpha-latest.json" --output _site/alpha/latest.json || echo '{}' > _site/alpha/latest.json curl -fsSL "${PAGES_URL}/stable/latest.json" -o _site/stable/latest.json || echo '{}' > _site/stable/latest.json + bun scripts/build-release-download-page.ts --latest-json _site/stable/latest.json --releases-json _site/releases.json --output-file _site/stable/download/index.html + mkdir -p _site/download + cp _site/stable/download/index.html _site/download/index.html cp _site/alpha/latest.json _site/latest.json cp _site/alpha/latest.json _site/latest-canary.json diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 30024a63..43a06049 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -798,10 +798,11 @@ push stable-vYYYY.M.D tag → pnpm install, stamp version, pnpm build, tauri build --target aarch64-apple-darwin → upload signed .app.tar.gz + .sig and .dmg artifacts → release job: - → generate stable-latest.json + → generate stable-latest.json with both updater tarball and current stable DMG URLs → publish GitHub release Tolaria YYYY.M.D → pages job: → publish stable/latest.json + → publish stable/download/ and download/ as permanent redirect URLs for the latest stable DMG → preserve alpha/latest.json → deploy to gh-pages ``` diff --git a/scripts/build-release-download-page.ts b/scripts/build-release-download-page.ts new file mode 100644 index 00000000..c6f14814 --- /dev/null +++ b/scripts/build-release-download-page.ts @@ -0,0 +1,39 @@ +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { dirname, resolve } from 'node:path' + +import { + buildStableDownloadRedirectPage, + resolveStableDmgUrl, +} from '../src/utils/releaseDownloadPage' + +function getArg(flag: string): string { + const index = process.argv.indexOf(flag) + const value = index >= 0 ? process.argv[index + 1] : null + + if (!value) { + throw new Error(`Missing required argument: ${flag}`) + } + + return value +} + +function readLatestReleasePayload(filePath: string): unknown { + try { + return JSON.parse(readFileSync(filePath, 'utf8')) + } catch { + return {} + } +} + +const latestJsonPath = resolve(getArg('--latest-json')) +const releasesJsonPath = resolve(getArg('--releases-json')) +const outputFilePath = resolve(getArg('--output-file')) +const latestPayload = readLatestReleasePayload(latestJsonPath) +const releasesPayload = readLatestReleasePayload(releasesJsonPath) +const dmgUrl = resolveStableDmgUrl(latestPayload, releasesPayload) +const html = buildStableDownloadRedirectPage(dmgUrl) + +mkdirSync(dirname(outputFilePath), { recursive: true }) +writeFileSync(outputFilePath, html) + +console.log(`Stable download page written to ${outputFilePath}`) diff --git a/src/utils/releaseDownloadPage.test.ts b/src/utils/releaseDownloadPage.test.ts new file mode 100644 index 00000000..510f9204 --- /dev/null +++ b/src/utils/releaseDownloadPage.test.ts @@ -0,0 +1,87 @@ +import { + buildStableDownloadRedirectPage, + extractStableDmgUrl, + extractStableDmgUrlFromReleases, + resolveStableDmgUrl, +} from './releaseDownloadPage' + +describe('extractStableDmgUrl', () => { + it('returns the stable dmg url when present', () => { + expect( + extractStableDmgUrl({ + platforms: { + 'darwin-aarch64': { + dmg_url: 'https://example.com/Tolaria.dmg', + }, + }, + }), + ).toBe('https://example.com/Tolaria.dmg') + }) + + it('returns null when the stable dmg url is missing', () => { + expect( + extractStableDmgUrl({ + platforms: { + 'darwin-aarch64': { + url: 'https://example.com/Tolaria.app.tar.gz', + }, + }, + }), + ).toBeNull() + }) +}) + +describe('buildStableDownloadRedirectPage', () => { + it('builds a redirect page when a stable dmg exists', () => { + const html = buildStableDownloadRedirectPage('https://example.com/Tolaria.dmg') + + expect(html).toContain('Tolaria Stable Download') + expect(html).toContain('window.location.replace("https://example.com/Tolaria.dmg");') + expect(html).toContain('Download latest stable DMG') + expect(html).toContain('meta http-equiv="refresh"') + }) + + it('builds a fallback page when no stable dmg exists yet', () => { + const html = buildStableDownloadRedirectPage(null) + + expect(html).toContain('Tolaria Stable Download Unavailable') + expect(html).toContain('View release history') + expect(html).toContain('https://refactoringhq.github.io/tolaria/') + expect(html).not.toContain('window.location.replace(') + }) +}) + +describe('resolveStableDmgUrl', () => { + it('falls back to the latest stable github release dmg when latest.json has no dmg url', () => { + const latestPayload = { + platforms: { + 'darwin-aarch64': { + url: 'https://example.com/Tolaria.app.tar.gz', + }, + }, + } + const releasesPayload = [ + { + prerelease: true, + assets: [ + { + name: 'Tolaria-alpha.dmg', + browser_download_url: 'https://example.com/alpha.dmg', + }, + ], + }, + { + prerelease: false, + assets: [ + { + name: 'Tolaria.dmg', + browser_download_url: 'https://example.com/stable.dmg', + }, + ], + }, + ] + + expect(extractStableDmgUrlFromReleases(releasesPayload)).toBe('https://example.com/stable.dmg') + expect(resolveStableDmgUrl(latestPayload, releasesPayload)).toBe('https://example.com/stable.dmg') + }) +}) diff --git a/src/utils/releaseDownloadPage.ts b/src/utils/releaseDownloadPage.ts new file mode 100644 index 00000000..5866d5a0 --- /dev/null +++ b/src/utils/releaseDownloadPage.ts @@ -0,0 +1,211 @@ +const RELEASE_HISTORY_URL = 'https://refactoringhq.github.io/tolaria/' + +type PlatformPayload = { + dmg_url?: unknown +} + +type LatestReleasePayload = { + platforms?: Record +} + +type ReleaseAssetPayload = { + browser_download_url?: unknown + name?: unknown +} + +type GitHubReleasePayload = { + assets?: ReleaseAssetPayload[] + draft?: unknown + prerelease?: unknown +} + +type DownloadPageContent = { + buttonLabel: string + destinationUrl: string + helperText: string + message: string + shouldRedirect: boolean + title: string +} + +const REDIRECT_PAGE_STYLES = ` + :root { + color-scheme: light; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + } + + * { + box-sizing: border-box; + } + + body { + margin: 0; + min-height: 100vh; + display: grid; + place-items: center; + padding: 24px; + background: #f7f6f3; + color: #37352f; + } + + main { + width: min(100%, 460px); + background: #ffffff; + border: 1px solid #e9e9e7; + border-radius: 16px; + padding: 24px; + box-shadow: 0 16px 40px rgba(15, 23, 42, 0.08); + } + + h1 { + margin: 0 0 12px; + font-size: 1.5rem; + line-height: 1.2; + } + + p { + margin: 0 0 12px; + line-height: 1.5; + } + + a { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 44px; + padding: 0 16px; + border-radius: 10px; + background: #155dff; + color: #ffffff; + text-decoration: none; + font-weight: 600; + } + + a:hover, + a:focus-visible { + background: #1248cc; + } +` + +function escapeHtml(value: string): string { + return value + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') +} + +export function extractStableDmgUrl(payload: unknown): string | null { + if (!payload || typeof payload !== 'object') return null + const { platforms } = payload as LatestReleasePayload + if (!platforms || typeof platforms !== 'object') return null + + const dmgUrl = platforms['darwin-aarch64']?.dmg_url + if (typeof dmgUrl !== 'string') return null + + const trimmedUrl = dmgUrl.trim() + return trimmedUrl.length > 0 ? trimmedUrl : null +} + +function isPublicStableRelease(release: GitHubReleasePayload): boolean { + return release.draft !== true && release.prerelease !== true +} + +function extractAssetDmgUrl(asset: ReleaseAssetPayload): string | null { + if (typeof asset.name !== 'string' || !asset.name.endsWith('.dmg')) return null + if (typeof asset.browser_download_url !== 'string') return null + + const trimmedUrl = asset.browser_download_url.trim() + return trimmedUrl.length > 0 ? trimmedUrl : null +} + +function findReleaseDmgUrl(release: GitHubReleasePayload): string | null { + if (!isPublicStableRelease(release)) return null + if (!Array.isArray(release.assets)) return null + + for (const asset of release.assets) { + const dmgUrl = extractAssetDmgUrl(asset) + if (dmgUrl !== null) return dmgUrl + } + + return null +} + +export function extractStableDmgUrlFromReleases(payload: unknown): string | null { + if (!Array.isArray(payload)) return null + + for (const release of payload) { + if (!release || typeof release !== 'object') continue + + const dmgUrl = findReleaseDmgUrl(release as GitHubReleasePayload) + if (dmgUrl !== null) return dmgUrl + } + + return null +} + +export function resolveStableDmgUrl( + latestPayload: unknown, + releasesPayload: unknown, +): string | null { + return extractStableDmgUrl(latestPayload) ?? extractStableDmgUrlFromReleases(releasesPayload) +} + +function buildStableDownloadPageContent(dmgUrl: string | null): DownloadPageContent { + if (dmgUrl !== null) { + return { + buttonLabel: 'Download latest stable DMG', + destinationUrl: dmgUrl, + helperText: 'If the download does not start automatically, use the button below.', + message: 'Redirecting to the latest stable Tolaria DMG.', + shouldRedirect: true, + title: 'Tolaria Stable Download', + } + } + + return { + buttonLabel: 'View release history', + destinationUrl: RELEASE_HISTORY_URL, + helperText: 'Use the button below to check the latest release history.', + message: 'No stable Tolaria DMG is available yet.', + shouldRedirect: false, + title: 'Tolaria Stable Download Unavailable', + } +} + +function buildRedirectMarkup(destinationUrl: string, shouldRedirect: boolean): string { + if (!shouldRedirect) return '' + + const escapedDestinationUrl = escapeHtml(destinationUrl) + return ` + + ` +} + +export function buildStableDownloadRedirectPage(dmgUrl: string | null): string { + const page = buildStableDownloadPageContent(dmgUrl) + const escapedDestinationUrl = escapeHtml(page.destinationUrl) + const redirectMarkup = buildRedirectMarkup(page.destinationUrl, page.shouldRedirect) + + return ` + + + + + ${page.title}${redirectMarkup} + + + +
+

${page.title}

+

${page.message}

+

${page.helperText}

+ ${page.buttonLabel} +
+ + +` +} diff --git a/tsconfig.node.json b/tsconfig.node.json index 8a67f62f..15683c96 100644 --- a/tsconfig.node.json +++ b/tsconfig.node.json @@ -22,5 +22,5 @@ "noFallthroughCasesInSwitch": true, "noUncheckedSideEffectImports": true }, - "include": ["vite.config.ts"] + "include": ["vite.config.ts", "scripts/**/*.ts"] }