Skip to content
Merged
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
160 changes: 159 additions & 1 deletion .github/workflows/code-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Comment thread
charlesvien marked this conversation as resolved.
# 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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -545,6 +617,7 @@ jobs:
runs-on: ubuntu-latest
permissions:
contents: write
id-token: write
steps:
- name: Get app token
id: app-token
Expand All @@ -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
Expand Down Expand Up @@ -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"
10 changes: 6 additions & 4 deletions apps/code/electron-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
},
};

Expand Down
56 changes: 56 additions & 0 deletions apps/code/scripts/build-releases-feed.mjs
Original file line number Diff line number Diff line change
@@ -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 <output-json>");
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();
}
37 changes: 37 additions & 0 deletions apps/code/scripts/inject-release-notes.mjs
Original file line number Diff line number Diff line change
@@ -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 <notes-md> <channel-yml> [...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();
}
4 changes: 2 additions & 2 deletions apps/code/src/main/di/container.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -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);
Expand Down
4 changes: 2 additions & 2 deletions apps/code/src/main/trpc/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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";
Expand Down Expand Up @@ -86,7 +86,7 @@ export const trpcRouter = router({
fs: fsRouter,
git: gitRouter,
githubIntegration: githubIntegrationRouter,
githubReleases: githubReleasesRouter,
releaseFeed: releaseFeedRouter,
handoff: handoffRouter,
integration: integrationRouter,
linearIntegration: linearIntegrationRouter,
Expand Down
8 changes: 5 additions & 3 deletions docs/UPDATES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading
Loading