diff --git a/.github/workflows/code-release.yml b/.github/workflows/code-release.yml index 26b2597b90..b714439a99 100644 --- a/.github/workflows/code-release.yml +++ b/.github/workflows/code-release.yml @@ -237,6 +237,25 @@ jobs: apps/code/out/*-mac.zip \ apps/code/out/*.blockmap + - name: Configure AWS credentials for the update feed + uses: aws-actions/configure-aws-credentials@254c19bd240aabef8777f48595e9d2d7b972184b # v6.2.1 + with: + role-to-assume: ${{ secrets.AWS_DESKTOP_APP_RELEASES_ROLE_ARN }} + aws-region: us-east-1 + mask-aws-account-id: true + unset-current-credentials: true + + # Binaries carry the version in their filename, so they are immutable. + # Channel files are uploaded last by finalize-release; updaters only see + # this release once those land. + - name: Upload artifacts to the S3 update feed + working-directory: apps/code/out + run: | + for f in *.dmg *-mac.zip *.blockmap; do + aws s3 cp "$f" "s3://posthog-desktop-app-releases-prod-us/stable/$f" \ + --cache-control "public, max-age=31536000, immutable" + done + # Hash the same files uploaded above; finalize-release turns these into # the download tables in the release notes. - name: Compute artifact checksums @@ -376,6 +395,35 @@ jobs: $files = $items | Select-Object -ExpandProperty FullName gh release upload "v$env:APP_VERSION" --repo PostHog/code --clobber @files + - name: Configure AWS credentials for the update feed + uses: aws-actions/configure-aws-credentials@254c19bd240aabef8777f48595e9d2d7b972184b # v6.2.1 + with: + role-to-assume: ${{ secrets.AWS_DESKTOP_APP_RELEASES_ROLE_ARN }} + aws-region: us-east-1 + mask-aws-account-id: true + unset-current-credentials: true + + # latest.yml is NOT uploaded here: finalize-release injects release notes + # and publishes it last, which is what flips the S3 feed to this version. + - name: Upload artifacts to the S3 update feed + shell: pwsh + run: | + $out = "apps/code/out" + $items = @() + $items += Get-ChildItem $out -File -Filter "*.exe" + $items += Get-ChildItem $out -File -Filter "*.blockmap" + foreach ($f in $items) { + aws s3 cp $f.FullName "s3://posthog-desktop-app-releases-prod-us/stable/$($f.Name)" --cache-control "public, max-age=31536000, immutable" + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + } + + - name: Upload windows manifest artifact + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: win-manifest + path: apps/code/out/latest.yml + retention-days: 1 + # Hash the same files uploaded above (minus latest.yml); finalize-release # turns these into the download tables in the release notes. - name: Compute artifact checksums @@ -522,6 +570,30 @@ jobs: apps/code/out/*.deb \ apps/code/out/*.rpm + - name: Configure AWS credentials for the update feed + uses: aws-actions/configure-aws-credentials@254c19bd240aabef8777f48595e9d2d7b972184b # v6.2.1 + with: + role-to-assume: ${{ secrets.AWS_DESKTOP_APP_RELEASES_ROLE_ARN }} + aws-region: us-east-1 + mask-aws-account-id: true + unset-current-credentials: true + + # No Linux auto-updater consumes latest-linux*.yml today (updates are + # macOS and Windows only), so the manifests can ship with the binaries + # instead of waiting for the finalize flip. + - name: Upload artifacts to the S3 update feed + working-directory: apps/code/out + run: | + for f in *.AppImage *.deb *.rpm; do + aws s3 cp "$f" "s3://posthog-desktop-app-releases-prod-us/stable/$f" \ + --cache-control "public, max-age=31536000, immutable" + done + for f in latest-linux*.yml; do + [ -e "$f" ] || continue + aws s3 cp "$f" "s3://posthog-desktop-app-releases-prod-us/stable/$f" \ + --cache-control "no-cache" + done + # Hash the same files uploaded above; finalize-release turns these into # the download tables in the release notes. - name: Compute artifact checksums @@ -545,6 +617,7 @@ jobs: runs-on: ubuntu-latest permissions: contents: write + id-token: write steps: - name: Get app token id: app-token @@ -559,12 +632,14 @@ jobs: TAG_VERSION="${GITHUB_REF#refs/tags/v}" echo "version=$TAG_VERSION" >> "$GITHUB_OUTPUT" - - name: Checkout merge script + - name: Checkout release scripts uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: sparse-checkout: | apps/code/scripts/merge-mac-manifests.mjs apps/code/scripts/generate-release-download-tables.mjs + apps/code/scripts/build-releases-feed.mjs + apps/code/scripts/inject-release-notes.mjs sparse-checkout-cone-mode: false - name: Setup Node.js @@ -618,3 +693,86 @@ jobs: printf '\n' >> /tmp/release-notes.md node apps/code/scripts/generate-release-download-tables.mjs "$APP_VERSION" /tmp/checksums >> /tmp/release-notes.md gh release edit "v$APP_VERSION" --repo PostHog/code --draft=false --notes-file /tmp/release-notes.md + + - name: Download windows manifest + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 + with: + name: win-manifest + path: /tmp/win-manifest + + # Built after the GitHub release is published so the new release (and its + # generated notes) is included; the in-app release notes read this file. + - name: Build releases feed manifest + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + run: node apps/code/scripts/build-releases-feed.mjs /tmp/releases.json + + # The generic provider fetches nothing from GitHub, so the channel files + # must carry the notes for electron-updater's UpdateInfo.releaseNotes. + - name: Inject release notes into channel files + run: | + node apps/code/scripts/inject-release-notes.mjs /tmp/release-notes.md \ + /tmp/latest-mac.yml /tmp/win-manifest/latest.yml + + - name: Configure AWS credentials for the update feed + uses: aws-actions/configure-aws-credentials@254c19bd240aabef8777f48595e9d2d7b972184b # v6.2.1 + with: + role-to-assume: ${{ secrets.AWS_DESKTOP_APP_RELEASES_ROLE_ARN }} + aws-region: us-east-1 + mask-aws-account-id: true + unset-current-credentials: true + + # The channel-file upload is the S3 publish flip: updaters only see the + # new version once these land, after every platform's binaries are up. + # Stage all files first, then flip with server-side copies, so a failed + # runner upload can never leave the feed advertising a mixed release + # (e.g. macOS on the new version while Windows still serves the old one). + # Copies preserve the staged objects' cache-control metadata. + - name: Publish channel files to the S3 update feed + env: + APP_VERSION: ${{ steps.version.outputs.version }} + run: | + FEED="s3://posthog-desktop-app-releases-prod-us/stable" + STAGING="$FEED/.staging/v$APP_VERSION" + + aws s3 cp /tmp/latest-mac.yml "$STAGING/latest-mac.yml" --cache-control "no-cache" + aws s3 cp /tmp/win-manifest/latest.yml "$STAGING/latest.yml" --cache-control "no-cache" + aws s3 cp /tmp/releases.json "$STAGING/releases.json" --cache-control "no-cache" + + aws s3 cp "$STAGING/latest-mac.yml" "$FEED/latest-mac.yml" + aws s3 cp "$STAGING/latest.yml" "$FEED/latest.yml" + aws s3 cp "$STAGING/releases.json" "$FEED/releases.json" + + aws s3 rm --recursive "$STAGING/" || echo "staging cleanup failed; harmless" + + # The little markdown table in the run summary: public CloudFront URLs for + # everything this release published to the feed. + - name: Write release summary + env: + APP_VERSION: ${{ steps.version.outputs.version }} + run: | + BASE="https://desktop-releases.posthog.com/stable" + V="$APP_VERSION" + { + echo "## v$V on the update feed" + echo "" + echo "| Artifact | URL |" + echo "| --- | --- |" + for f in \ + "PostHog-Code-$V-arm64-mac.dmg" \ + "PostHog-Code-$V-arm64-mac.zip" \ + "PostHog-Code-$V-x64-mac.dmg" \ + "PostHog-Code-$V-x64-mac.zip" \ + "PostHog-Code-$V-x64-win.exe" \ + "PostHog-Code-$V-arm64-linux.AppImage" \ + "PostHog-Code-$V-arm64-linux.deb" \ + "PostHog-Code-$V-arm64-linux.rpm" \ + "PostHog-Code-$V-x64-linux.AppImage" \ + "PostHog-Code-$V-x64-linux.deb" \ + "PostHog-Code-$V-x64-linux.rpm" \ + "latest-mac.yml" \ + "latest.yml" \ + "releases.json"; do + echo "| \`$f\` | $BASE/$f |" + done + } >> "$GITHUB_STEP_SUMMARY" diff --git a/apps/code/electron-builder.ts b/apps/code/electron-builder.ts index 398a904b6d..70bd5102ad 100644 --- a/apps/code/electron-builder.ts +++ b/apps/code/electron-builder.ts @@ -132,11 +132,13 @@ const config: Configuration = { packageName: "posthog-code", }, + // Installs built from this config poll the CloudFront-fronted update feed + // (the S3 bucket is private; reads go through the CDN). Installs built before + // the feed moved poll GitHub Releases on PostHog/code, so CI dual-publishes + // there until that fleet drains. publish: { - provider: "github", - owner: "PostHog", - repo: "code", - releaseType: "draft", + provider: "generic", + url: "https://desktop-releases.posthog.com/stable", }, }; diff --git a/apps/code/scripts/build-releases-feed.mjs b/apps/code/scripts/build-releases-feed.mjs new file mode 100644 index 0000000000..d8e1cee996 --- /dev/null +++ b/apps/code/scripts/build-releases-feed.mjs @@ -0,0 +1,56 @@ +#!/usr/bin/env node +import { realpathSync, writeFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; + +const RELEASES_API_URL = + "https://api.github.com/repos/PostHog/code/releases?per_page=30"; + +export function toFeedReleases(apiReleases) { + return apiReleases + .filter((release) => !release.draft) + .map((release) => ({ + version: release.tag_name.replace(/^v/, ""), + name: + release.name && release.name.length > 0 + ? release.name + : release.tag_name, + notes: release.body ?? "", + date: release.published_at, + isPrerelease: release.prerelease, + htmlUrl: release.html_url, + })); +} + +async function main() { + const [, , outputPath] = process.argv; + + if (!outputPath) { + console.error("Usage: build-releases-feed.mjs "); + process.exit(1); + } + + const headers = { Accept: "application/vnd.github+json" }; + if (process.env.GH_TOKEN) { + headers.Authorization = `Bearer ${process.env.GH_TOKEN}`; + } + + const response = await fetch(RELEASES_API_URL, { headers }); + if (!response.ok) { + throw new Error(`GitHub releases fetch failed: ${response.status}`); + } + + const releases = toFeedReleases(await response.json()); + writeFileSync( + outputPath, + `${JSON.stringify({ releases }, null, 2)}\n`, + "utf8", + ); + console.log(`Wrote ${releases.length} releases -> ${outputPath}`); +} + +if ( + process.argv[1] && + realpathSync(process.argv[1]) === fileURLToPath(import.meta.url) +) { + await main(); +} diff --git a/apps/code/scripts/inject-release-notes.mjs b/apps/code/scripts/inject-release-notes.mjs new file mode 100644 index 0000000000..89804d4732 --- /dev/null +++ b/apps/code/scripts/inject-release-notes.mjs @@ -0,0 +1,37 @@ +#!/usr/bin/env node +import { readFileSync, realpathSync, writeFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { parse, stringify } from "yaml"; + +export function withReleaseNotes(manifest, notes) { + return { ...manifest, releaseNotes: notes }; +} + +function main() { + const [, , notesPath, ...manifestPaths] = process.argv; + + if (!notesPath || manifestPaths.length === 0) { + console.error( + "Usage: inject-release-notes.mjs [...channel-yml]", + ); + process.exit(1); + } + + const notes = readFileSync(notesPath, "utf8").trim(); + for (const manifestPath of manifestPaths) { + const manifest = parse(readFileSync(manifestPath, "utf8")); + writeFileSync( + manifestPath, + stringify(withReleaseNotes(manifest, notes)), + "utf8", + ); + console.log(`Injected release notes -> ${manifestPath}`); + } +} + +if ( + process.argv[1] && + realpathSync(process.argv[1]) === fileURLToPath(import.meta.url) +) { + main(); +} diff --git a/apps/code/src/main/di/container.ts b/apps/code/src/main/di/container.ts index e9f84c43ad..04c245598f 100644 --- a/apps/code/src/main/di/container.ts +++ b/apps/code/src/main/di/container.ts @@ -169,7 +169,6 @@ import type { ExternalAppsPreferences } from "@posthog/workspace-server/services import { foldersModule } from "@posthog/workspace-server/services/folders/folders.module"; import { GitService } from "@posthog/workspace-server/services/git/service"; import { TaskPrStatusService } from "@posthog/workspace-server/services/git/task-pr-status"; -import { githubReleasesModule } from "@posthog/workspace-server/services/github-releases/github-releases.module"; import { HANDOFF_GIT_GATEWAY, HANDOFF_LOG_GATEWAY, @@ -198,6 +197,7 @@ import { POSTHOG_PLUGIN_SERVICE } from "@posthog/workspace-server/services/posth import { posthogPluginModule } from "@posthog/workspace-server/services/posthog-plugin/posthog-plugin.module"; import { PROCESS_TRACKING_SERVICE } from "@posthog/workspace-server/services/process-tracking/identifiers"; import { processTrackingModule } from "@posthog/workspace-server/services/process-tracking/process-tracking.module"; +import { releaseFeedModule } from "@posthog/workspace-server/services/release-feed/release-feed.module"; import { SECURE_STORE_SERVICE } from "@posthog/workspace-server/services/secure-store/identifiers"; import { shellModule } from "@posthog/workspace-server/services/shell/shell.module"; import { skillsModule } from "@posthog/workspace-server/services/skills/skills.module"; @@ -653,7 +653,7 @@ container.load(posthogPluginModule); container.bind(MAIN_POSTHOG_PLUGIN_SERVICE).toService(POSTHOG_PLUGIN_SERVICE); container.load(skillsModule); container.load(skillsMarketplaceModule); -container.load(githubReleasesModule); +container.load(releaseFeedModule); container.load(onboardingImportModule); container.load(localMcpModule); container.load(mcpRelayModule); diff --git a/apps/code/src/main/trpc/router.ts b/apps/code/src/main/trpc/router.ts index a8bcb3623d..362a9de753 100644 --- a/apps/code/src/main/trpc/router.ts +++ b/apps/code/src/main/trpc/router.ts @@ -24,7 +24,6 @@ import { foldersRouter } from "@posthog/host-router/routers/folders.router"; import { fsRouter } from "@posthog/host-router/routers/fs.router"; import { gitRouter } from "@posthog/host-router/routers/git.router"; import { githubIntegrationRouter } from "@posthog/host-router/routers/github-integration.router"; -import { githubReleasesRouter } from "@posthog/host-router/routers/github-releases.router"; import { handoffRouter } from "@posthog/host-router/routers/handoff.router"; import { integrationRouter } from "@posthog/host-router/routers/integration.router"; import { linearIntegrationRouter } from "@posthog/host-router/routers/linear-integration.router"; @@ -41,6 +40,7 @@ import { osRouter } from "@posthog/host-router/routers/os.router"; import { piSessionRouter } from "@posthog/host-router/routers/pi-session.router"; import { processTrackingRouter } from "@posthog/host-router/routers/process-tracking.router"; import { provisioningRouter } from "@posthog/host-router/routers/provisioning.router"; +import { releaseFeedRouter } from "@posthog/host-router/routers/release-feed.router"; import { secureStoreRouter } from "@posthog/host-router/routers/secure-store.router"; import { shellRouter } from "@posthog/host-router/routers/shell.router"; import { skillsRouter } from "@posthog/host-router/routers/skills.router"; @@ -86,7 +86,7 @@ export const trpcRouter = router({ fs: fsRouter, git: gitRouter, githubIntegration: githubIntegrationRouter, - githubReleases: githubReleasesRouter, + releaseFeed: releaseFeedRouter, handoff: handoffRouter, integration: integrationRouter, linearIntegration: linearIntegrationRouter, diff --git a/docs/UPDATES.md b/docs/UPDATES.md index e9fda10359..12b93751ff 100644 --- a/docs/UPDATES.md +++ b/docs/UPDATES.md @@ -13,15 +13,17 @@ The version in `apps/code/package.json` is set to `0.0.0-dev` - this is intentio ## Auto-Update Mechanism -PostHog uses [electron-updater](https://www.electron.build/auto-update) (the npm package, not the built-in Electron autoUpdater). On startup the app checks for updates against the GitHub Release for PostHog/code. +PostHog uses [electron-updater](https://www.electron.build/auto-update) (the npm package, not the built-in Electron autoUpdater) with the generic provider. On startup the app checks for updates against the update feed at `https://desktop-releases.posthog.com/stable`, baked into `app-update.yml` inside the app bundle at package time. -electron-builder generates and uploads a `latest-mac.yml` (macOS) or `latest.yml` (Windows) manifest to the GitHub Release during CI. The path to these manifests is baked into `app-update.yml` inside the app bundle at package time. +Release CI uploads the binaries and blockmaps to the feed from each platform job, then the finalize job uploads the channel files (`latest-mac.yml`, `latest.yml`) last. Updaters only see a release once the channel files change, so that upload is the publish step. The finalize job also injects the generated release notes into the channel files (the generic provider fetches nothing from GitHub, so `UpdateInfo.releaseNotes` comes from the manifest) and publishes `releases.json`, which powers the in-app release notes and What's New history. + +**Dual publish**: installs built before the feed moved to S3 poll GitHub Releases on PostHog/code, so CI keeps uploading the same artifacts and manifests there until that fleet drains. The GitHub release also remains the human-facing changelog and download page. **macOS**: DMG + zip artifacts are uploaded; the merged `latest-mac.yml` covers both arm64 and x64 so the correct build is selected per architecture. **Windows**: A single NSIS installer is shipped and updated through electron-updater via `latest.yml`. The legacy Squirrel.Windows installer is no longer built; anyone still on an old Squirrel install must reinstall once via the NSIS installer to keep receiving updates. -**Linux**: No auto-update. AppImage, deb and rpm packages are manual downloads from the GitHub Release. +**Linux**: No auto-update. AppImage, deb and rpm packages are manual downloads from the GitHub Release, also mirrored to the S3 feed. ## How It Works diff --git a/packages/host-router/src/router.ts b/packages/host-router/src/router.ts index 17d323b1eb..fe5934f569 100644 --- a/packages/host-router/src/router.ts +++ b/packages/host-router/src/router.ts @@ -23,7 +23,6 @@ import { foldersRouter } from "./routers/folders.router"; import { fsRouter } from "./routers/fs.router"; import { gitRouter } from "./routers/git.router"; import { githubIntegrationRouter } from "./routers/github-integration.router"; -import { githubReleasesRouter } from "./routers/github-releases.router"; import { handoffRouter } from "./routers/handoff.router"; import { integrationRouter } from "./routers/integration.router"; import { linearIntegrationRouter } from "./routers/linear-integration.router"; @@ -40,6 +39,7 @@ import { osRouter } from "./routers/os.router"; import { piSessionRouter } from "./routers/pi-session.router"; import { processTrackingRouter } from "./routers/process-tracking.router"; import { provisioningRouter } from "./routers/provisioning.router"; +import { releaseFeedRouter } from "./routers/release-feed.router"; import { secureStoreRouter } from "./routers/secure-store.router"; import { shellRouter } from "./routers/shell.router"; import { skillsRouter } from "./routers/skills.router"; @@ -79,7 +79,7 @@ export const hostRouter = router({ handoff: handoffRouter, integration: integrationRouter, githubIntegration: githubIntegrationRouter, - githubReleases: githubReleasesRouter, + releaseFeed: releaseFeedRouter, linearIntegration: linearIntegrationRouter, llmGateway: llmGatewayRouter, localMcp: localMcpRouter, diff --git a/packages/host-router/src/routers/github-releases.router.ts b/packages/host-router/src/routers/github-releases.router.ts deleted file mode 100644 index 8414391e6e..0000000000 --- a/packages/host-router/src/routers/github-releases.router.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { publicProcedure, router } from "@posthog/host-trpc/trpc"; -import type { GitHubReleasesService } from "@posthog/workspace-server/services/github-releases/github-releases"; -import { GITHUB_RELEASES_SERVICE } from "@posthog/workspace-server/services/github-releases/identifiers"; -import { - listReleasesInput, - listReleasesOutput, -} from "@posthog/workspace-server/services/github-releases/schemas"; - -export const githubReleasesRouter = router({ - list: publicProcedure - .input(listReleasesInput) - .output(listReleasesOutput) - .query(({ ctx, input }) => - ctx.container - .get(GITHUB_RELEASES_SERVICE) - .listReleases(input?.expectVersion), - ), -}); diff --git a/packages/host-router/src/routers/release-feed.router.ts b/packages/host-router/src/routers/release-feed.router.ts new file mode 100644 index 0000000000..a2b7fb10e6 --- /dev/null +++ b/packages/host-router/src/routers/release-feed.router.ts @@ -0,0 +1,18 @@ +import { publicProcedure, router } from "@posthog/host-trpc/trpc"; +import { RELEASE_FEED_SERVICE } from "@posthog/workspace-server/services/release-feed/identifiers"; +import type { ReleaseFeedService } from "@posthog/workspace-server/services/release-feed/release-feed"; +import { + listReleasesInput, + listReleasesOutput, +} from "@posthog/workspace-server/services/release-feed/schemas"; + +export const releaseFeedRouter = router({ + list: publicProcedure + .input(listReleasesInput) + .output(listReleasesOutput) + .query(({ ctx, input }) => + ctx.container + .get(RELEASE_FEED_SERVICE) + .listReleases(input?.expectVersion), + ), +}); diff --git a/packages/ui/src/features/updates/UpdateAvailableModal.tsx b/packages/ui/src/features/updates/UpdateAvailableModal.tsx index f027846191..b455622ce2 100644 --- a/packages/ui/src/features/updates/UpdateAvailableModal.tsx +++ b/packages/ui/src/features/updates/UpdateAvailableModal.tsx @@ -66,7 +66,7 @@ export function UpdateAvailableModal() { const prefetchForActiveUpdate = useHasActiveUpdate(); const targetVersion = version ?? availableVersion; const { data: releasesData, isPending: isPendingReleases } = useQuery({ - ...hostTRPC.githubReleases.list.queryOptions( + ...hostTRPC.releaseFeed.list.queryOptions( targetVersion ? { expectVersion: targetVersion } : undefined, ), enabled: isOpen || prefetchForActiveUpdate, diff --git a/packages/ui/src/features/updates/WhatsNewModal.tsx b/packages/ui/src/features/updates/WhatsNewModal.tsx index a155d69204..79991f242f 100644 --- a/packages/ui/src/features/updates/WhatsNewModal.tsx +++ b/packages/ui/src/features/updates/WhatsNewModal.tsx @@ -56,7 +56,7 @@ export function WhatsNewModal() { isPending, isError: isReleasesError, } = useQuery({ - ...hostTRPC.githubReleases.list.queryOptions( + ...hostTRPC.releaseFeed.list.queryOptions( currentVersion ? { expectVersion: currentVersion } : undefined, ), enabled: (isOpen || prefetchForActiveUpdate) && !!currentVersion, diff --git a/packages/workspace-server/src/services/github-releases/github-releases.module.ts b/packages/workspace-server/src/services/github-releases/github-releases.module.ts deleted file mode 100644 index 14c9af347a..0000000000 --- a/packages/workspace-server/src/services/github-releases/github-releases.module.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { ContainerModule } from "inversify"; -import { GitHubReleasesService } from "./github-releases"; -import { GITHUB_RELEASES_SERVICE } from "./identifiers"; - -export const githubReleasesModule = new ContainerModule(({ bind }) => { - bind(GITHUB_RELEASES_SERVICE).to(GitHubReleasesService).inSingletonScope(); -}); diff --git a/packages/workspace-server/src/services/github-releases/identifiers.ts b/packages/workspace-server/src/services/github-releases/identifiers.ts deleted file mode 100644 index 7ee3c1a88c..0000000000 --- a/packages/workspace-server/src/services/github-releases/identifiers.ts +++ /dev/null @@ -1,3 +0,0 @@ -export const GITHUB_RELEASES_SERVICE = Symbol.for( - "posthog.workspace.githubReleasesService", -); diff --git a/packages/workspace-server/src/services/release-feed/identifiers.ts b/packages/workspace-server/src/services/release-feed/identifiers.ts new file mode 100644 index 0000000000..8eca6bf5fd --- /dev/null +++ b/packages/workspace-server/src/services/release-feed/identifiers.ts @@ -0,0 +1,3 @@ +export const RELEASE_FEED_SERVICE = Symbol.for( + "posthog.workspace.releaseFeedService", +); diff --git a/packages/workspace-server/src/services/release-feed/release-feed.module.ts b/packages/workspace-server/src/services/release-feed/release-feed.module.ts new file mode 100644 index 0000000000..66d78a5949 --- /dev/null +++ b/packages/workspace-server/src/services/release-feed/release-feed.module.ts @@ -0,0 +1,7 @@ +import { ContainerModule } from "inversify"; +import { RELEASE_FEED_SERVICE } from "./identifiers"; +import { ReleaseFeedService } from "./release-feed"; + +export const releaseFeedModule = new ContainerModule(({ bind }) => { + bind(RELEASE_FEED_SERVICE).to(ReleaseFeedService).inSingletonScope(); +}); diff --git a/packages/workspace-server/src/services/github-releases/github-releases.test.ts b/packages/workspace-server/src/services/release-feed/release-feed.test.ts similarity index 69% rename from packages/workspace-server/src/services/github-releases/github-releases.test.ts rename to packages/workspace-server/src/services/release-feed/release-feed.test.ts index 1c9bb21c03..dc81038868 100644 --- a/packages/workspace-server/src/services/github-releases/github-releases.test.ts +++ b/packages/workspace-server/src/services/release-feed/release-feed.test.ts @@ -1,44 +1,35 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { GitHubReleasesService } from "./github-releases"; - -const sampleReleases = [ - { - tag_name: "v1.2.0", - name: "v1.2.0", - body: "## Notes\n- thing", - draft: false, - prerelease: false, - published_at: "2026-06-20T00:00:00Z", - html_url: "https://github.com/PostHog/code/releases/tag/v1.2.0", - }, - { - tag_name: "v1.1.0", - name: "", - body: null, - draft: false, - prerelease: true, - published_at: "2026-06-10T00:00:00Z", - html_url: "https://github.com/PostHog/code/releases/tag/v1.1.0", - }, - { - tag_name: "v1.3.0-draft", - name: "draft", - body: "x", - draft: true, - prerelease: false, - published_at: null, - html_url: "https://github.com/PostHog/code/releases/tag/v1.3.0-draft", - }, -]; - -describe("GitHubReleasesService", () => { +import { ReleaseFeedService } from "./release-feed"; + +const sampleFeed = { + releases: [ + { + version: "1.2.0", + name: "v1.2.0", + notes: "## Notes\n- thing", + date: "2026-06-20T00:00:00Z", + isPrerelease: false, + htmlUrl: "https://github.com/PostHog/code/releases/tag/v1.2.0", + }, + { + version: "1.1.0", + name: "v1.1.0", + notes: "", + date: "2026-06-10T00:00:00Z", + isPrerelease: true, + htmlUrl: "https://github.com/PostHog/code/releases/tag/v1.1.0", + }, + ], +}; + +describe("ReleaseFeedService", () => { let fetchMock: ReturnType; beforeEach(() => { fetchMock = vi.fn(async () => ({ ok: true, status: 200, - json: async () => sampleReleases, + json: async () => sampleFeed, })); vi.stubGlobal("fetch", fetchMock); }); @@ -47,26 +38,22 @@ describe("GitHubReleasesService", () => { vi.unstubAllGlobals(); }); - it("maps releases, strips the v prefix and drops drafts", async () => { - const service = new GitHubReleasesService(); + it("returns the parsed feed", async () => { + const service = new ReleaseFeedService(); const { releases } = await service.listReleases(); expect(releases).toHaveLength(2); - expect(releases[0]).toEqual({ - version: "1.2.0", - name: "v1.2.0", - notes: "## Notes\n- thing", - date: "2026-06-20T00:00:00Z", - isPrerelease: false, - htmlUrl: "https://github.com/PostHog/code/releases/tag/v1.2.0", - }); - // empty name falls back to the tag; null body becomes an empty string - expect(releases[1]).toMatchObject({ - version: "1.1.0", - name: "v1.1.0", - notes: "", - isPrerelease: true, + expect(releases[0]).toEqual(sampleFeed.releases[0]); + }); + + it("rejects a malformed feed payload", async () => { + fetchMock.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ releases: [{ version: 123 }] }), }); + const service = new ReleaseFeedService(); + await expect(service.listReleases()).rejects.toThrow(); }); it.each([ @@ -76,7 +63,7 @@ describe("GitHubReleasesService", () => { ])( "a fresh cache is a hit for expectVersion $expectVersion only when it contains it ($expectedFetches fetches)", async ({ expectVersion, expectedFetches }) => { - const service = new GitHubReleasesService(); + const service = new ReleaseFeedService(); await service.listReleases(); await service.listReleases(expectVersion); expect(fetchMock).toHaveBeenCalledTimes(expectedFetches); @@ -84,24 +71,25 @@ describe("GitHubReleasesService", () => { ); it("caches the refetched list once it contains the expected version", async () => { - const service = new GitHubReleasesService(); + const service = new ReleaseFeedService(); await service.listReleases(); fetchMock.mockResolvedValueOnce({ ok: true, status: 200, - json: async () => [ - { - tag_name: "v1.3.0", - name: "v1.3.0", - body: "new", - draft: false, - prerelease: false, - published_at: "2026-06-30T00:00:00Z", - html_url: "https://github.com/PostHog/code/releases/tag/v1.3.0", - }, - ...sampleReleases, - ], + json: async () => ({ + releases: [ + { + version: "1.3.0", + name: "v1.3.0", + notes: "new", + date: "2026-06-30T00:00:00Z", + isPrerelease: false, + htmlUrl: "https://github.com/PostHog/code/releases/tag/v1.3.0", + }, + ...sampleFeed.releases, + ], + }), }); const second = await service.listReleases("1.3.0"); expect(second.releases[0].version).toBe("1.3.0"); @@ -112,7 +100,7 @@ describe("GitHubReleasesService", () => { }); it("dedupes concurrent cache misses into a single fetch", async () => { - const service = new GitHubReleasesService(); + const service = new ReleaseFeedService(); const [first, second] = await Promise.all([ service.listReleases(), service.listReleases("1.3.0"), @@ -124,7 +112,7 @@ describe("GitHubReleasesService", () => { it("concurrent callers both reject when the shared fetch fails, and inFlight is cleared so subsequent calls retry", async () => { fetchMock.mockRejectedValueOnce(new Error("network error")); - const service = new GitHubReleasesService(); + const service = new ReleaseFeedService(); const [result1, result2] = await Promise.allSettled([ service.listReleases(), @@ -140,7 +128,7 @@ describe("GitHubReleasesService", () => { it("waits out a cooldown before refetching a still-missing version", async () => { const nowSpy = vi.spyOn(Date, "now").mockReturnValue(0); - const service = new GitHubReleasesService(); + const service = new ReleaseFeedService(); await service.listReleases("9.9.9"); await service.listReleases("9.9.9"); expect(fetchMock).toHaveBeenCalledTimes(1); @@ -153,7 +141,7 @@ describe("GitHubReleasesService", () => { it("serves stale cache when a version-miss refetch fails, without retrying within the cooldown", async () => { const nowSpy = vi.spyOn(Date, "now").mockReturnValue(0); - const service = new GitHubReleasesService(); + const service = new ReleaseFeedService(); const first = await service.listReleases(); fetchMock.mockResolvedValueOnce({ ok: false, status: 500 }); @@ -169,13 +157,13 @@ describe("GitHubReleasesService", () => { it("throws on non-ok responses", async () => { fetchMock.mockResolvedValueOnce({ ok: false, status: 503 }); - const service = new GitHubReleasesService(); + const service = new ReleaseFeedService(); await expect(service.listReleases()).rejects.toThrow(); }); it("serves stale cache when a later refetch fails", async () => { const nowSpy = vi.spyOn(Date, "now").mockReturnValue(0); - const service = new GitHubReleasesService(); + const service = new ReleaseFeedService(); const first = await service.listReleases(); nowSpy.mockReturnValue(11 * 60_000); diff --git a/packages/workspace-server/src/services/github-releases/github-releases.ts b/packages/workspace-server/src/services/release-feed/release-feed.ts similarity index 75% rename from packages/workspace-server/src/services/github-releases/github-releases.ts rename to packages/workspace-server/src/services/release-feed/release-feed.ts index e11ff96a74..fcbb4d346d 100644 --- a/packages/workspace-server/src/services/github-releases/github-releases.ts +++ b/packages/workspace-server/src/services/release-feed/release-feed.ts @@ -1,14 +1,14 @@ import { injectable } from "inversify"; -import { githubReleasesApiResponse, type ListReleasesOutput } from "./schemas"; +import { type ListReleasesOutput, listReleasesOutput } from "./schemas"; -const RELEASES_URL = - "https://api.github.com/repos/PostHog/code/releases?per_page=30"; +const RELEASES_FEED_URL = + "https://desktop-releases.posthog.com/stable/releases.json"; const CACHE_TTL_MS = 10 * 60_000; const MISSING_VERSION_RETRY_MS = 60_000; const FETCH_TIMEOUT_MS = 10_000; @injectable() -export class GitHubReleasesService { +export class ReleaseFeedService { private cache: { fetchedAt: number; data: ListReleasesOutput } | null = null; private missingVersionRefetchNotBefore = 0; private inFlight: Promise | null = null; @@ -45,30 +45,15 @@ export class GitHubReleasesService { } private async fetchAndCacheReleases(): Promise { - const response = await fetch(RELEASES_URL, { - headers: { Accept: "application/vnd.github+json" }, + const response = await fetch(RELEASES_FEED_URL, { + headers: { Accept: "application/json" }, signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), }); if (!response.ok) { - throw new Error(`GitHub releases fetch failed: ${response.status}`); + throw new Error(`Release feed fetch failed: ${response.status}`); } - const parsed = githubReleasesApiResponse.parse(await response.json()); - const releases = parsed - .filter((release) => !release.draft) - .map((release) => ({ - version: release.tag_name.replace(/^v/, ""), - name: - release.name && release.name.length > 0 - ? release.name - : release.tag_name, - notes: release.body ?? "", - date: release.published_at, - isPrerelease: release.prerelease, - htmlUrl: release.html_url, - })); - - const data: ListReleasesOutput = { releases }; + const data = listReleasesOutput.parse(await response.json()); this.cache = { fetchedAt: Date.now(), data }; return data; } diff --git a/packages/workspace-server/src/services/github-releases/schemas.ts b/packages/workspace-server/src/services/release-feed/schemas.ts similarity index 62% rename from packages/workspace-server/src/services/github-releases/schemas.ts rename to packages/workspace-server/src/services/release-feed/schemas.ts index b1d5576b68..72d4ba21e6 100644 --- a/packages/workspace-server/src/services/github-releases/schemas.ts +++ b/packages/workspace-server/src/services/release-feed/schemas.ts @@ -1,17 +1,5 @@ import { z } from "zod"; -export const githubReleaseApiItem = z.object({ - tag_name: z.string(), - name: z.string().nullable(), - body: z.string().nullable(), - draft: z.boolean(), - prerelease: z.boolean(), - published_at: z.string().nullable(), - html_url: z.string(), -}); - -export const githubReleasesApiResponse = z.array(githubReleaseApiItem); - export const releaseItem = z.object({ version: z.string(), name: z.string(),