Skip to content

Release Infrastructure

Ghost’s release infrastructure turns source code into installable desktop applications and a browser extension, then distributes them to users with automatic updates. Think of it as a factory assembly line — code goes in one end, and signed, packaged, auto-updating applications come out the other end for macOS (two architectures), Windows, and Chrome.

The entire pipeline runs on GitLab CI/CD, building natively on macOS runners (because macOS apps must be built on macOS) and publishing artifacts to GitLab’s Generic Packages registry for distribution within the organization.


PlatformFormatInstall ScopeCI Runner TagsStatus
macOS ARM64 (Apple Silicon)DMG installer + .app.tar.gz (for updater) + EdDSA signaturePer-user (no admin)[macos, arm64]Active
macOS x64 (Intel)DMG installer + .app.tar.gz (for updater) + EdDSA signaturePer-user (no admin)[macos, x64]Active
Windows x64NSIS installerCurrent user (no admin required)Not yet configuredPlanned
Chrome/Firefox ExtensionCRX package + updates.xml manifestPer-browser[macos] (any Node.js runner)Active

Why two macOS builds: Apple Silicon Macs (M1, M2, M3, M4) use the ARM64 architecture, while older Intel Macs use x86_64. Each needs its own native binary for optimal performance. Ghost also supports building a universal binary (both architectures combined via lipo) through the Makefile, though the CI pipeline currently builds them separately.


The pipeline has two stages. The build stage runs three jobs in parallel (one per platform), and the release stage runs after all builds complete to package everything together.

What this diagram shows: The three build jobs run simultaneously on separate CI runners — the two macOS builds need architecture-specific runners (ARM64 and x64), while the extension can build on any runner with Node.js. Once all three complete, the release job collects their artifacts, uploads everything to GitLab’s package registry, generates the auto-updater manifest file (latest.json), and creates a GitLab Release with download links.

Each macOS build follows these steps:

  1. Build the Go sidecar — The Go backend is compiled as a standalone binary:

    CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -ldflags="-s -w" -o ghost-engine-aarch64-apple-darwin
    • CGO_ENABLED=0 ensures a pure Go binary with no C dependencies (important for cross-compilation and reproducibility)
    • -ldflags="-s -w" strips debug symbols and DWARF information, reducing binary size by ~30%
    • The output filename follows Tauri’s naming convention: ghost-engine-{rust-target-triple}
  2. Build the frontend — React + TypeScript is compiled by Vite into static HTML/CSS/JS:

    npm ci && npm run build

    The output goes to frontend/dist/, which the Go binary embeds via go:embed for production builds.

  3. Build the Tauri app — The Rust shell wraps the Go sidecar and frontend into a native macOS application:

    npx tauri build --target aarch64-apple-darwin

    This produces:

    • A .dmg installer (drag-to-Applications disk image)
    • A .app.tar.gz compressed bundle (used by the auto-updater)
    • A .app.tar.gz.sig EdDSA signature file (used to verify update integrity)

CI artifacts are retained for 30 days and passed to the release job.

  1. Build with Vite — Two separate builds:

    • Main build: service worker + popup as ES modules (vite build)
    • Content script build: IIFE format because Chrome content scripts cannot use ES modules (vite build --config vite.content.config.ts)
    • HTML/CSS files are copied to the output directory
  2. Pack as CRX — The built extension is signed and packed into a .crx file:

    npx crx3 pack ./dist -o ghost-extension.crx -p $EXTENSION_PEM_KEY

    The .pem key file determines the extension’s unique ID and signs the package so Chrome knows it hasn’t been tampered with.

  3. Upload and generate manifest — The CRX is uploaded to GitLab Generic Packages, and an updates.xml manifest is generated for Chrome enterprise distribution. This XML file tells Chrome where to find updates and what version is current, enabling automatic extension updates without the Chrome Web Store.

After all builds complete, the release job:

  1. Reads EdDSA signatures from the .sig files produced by the macOS builds
  2. Uploads platform bundles to GitLab Generic Packages as Ghost_aarch64.app.tar.gz and Ghost_x64.app.tar.gz, plus all DMG files
  3. Generates latest.json — the Tauri updater manifest containing:
    • Version number and publication date
    • Release notes (extracted from the git tag annotation, first 50 lines)
    • Per-platform download URLs pointing to the Generic Packages registry
    • Per-platform EdDSA signatures for verification
  4. Uploads latest.json to a fixed URL path (.../ghost-releases/latest/latest.json) — each release overwrites the previous one, so the updater always finds the latest version at the same URL
  5. Creates a GitLab Release with the version tag and asset links to all downloadable files

All uploads use curl --fail with a deploy token for authentication, ensuring upload failures are caught immediately.


Ghost uses Tauri’s built-in updater plugin, which checks for updates by fetching latest.json from the GitLab package registry. The entire flow is:

  1. Check — 5 seconds after the app launches, Ghost fetches latest.json (30-second timeout)
  2. Compare — If the version in latest.json is newer than the current version, an update is available
  3. Notify — A toast notification appears: “Update available — Ghost v{version} is ready to install”
  4. Download — The user clicks “Update Now” in Settings → About, and the update bundle downloads with a progress bar (0-100%)
  5. Verify — The downloaded bundle’s EdDSA signature is checked against the public key embedded in the app
  6. Install — The update is applied (platform-specific installer runs)
  7. Relaunch — The app automatically restarts with the new version

The Settings → About panel shows the complete update lifecycle:

StateUI
CheckingSpinning icon on the “Check for Updates” button
Up to date”You’re on the latest version” message
Update availableVersion comparison card with release notes, “Update Now” and “Later” buttons
DownloadingProgress bar with percentage
ErrorError message with retry option
InstalledApp will relaunch automatically
PropertyValue
AlgorithmEdDSA (Ed25519) — fast, secure, deterministic signatures
Key generationnpx tauri signer generate -w ~/.tauri/ghost.key
Public key locationEmbedded in tauri.conf.json under plugins.updater.pubkey
Private key locationCI/CD variable TAURI_SIGNING_PRIVATE_KEY (never stored in code)
Windows install mode"passive" — shows a progress bar but requires no user interaction
https://gitlab.hepsiburada.com/api/v4/projects/{PROJECT_ID}/packages/generic/ghost-releases/latest/latest.json

This URL is configured in tauri.conf.json and points to the GitLab Generic Packages registry. The latest.json file at this URL always contains the most recent release, with per-platform entries for darwin-aarch64 and darwin-x86_64 (Windows entries will be added when Windows CI builds are implemented).


These variables must be configured in GitLab → Settings → CI/CD → Variables before the pipeline will work:

VariablePurposeHow to generate
DEPLOY_TOKENGitLab deploy token with read_package_registry and write_package_registry scopes. Used to upload and download release artifacts.GitLab → Settings → Repository → Deploy Tokens
TAURI_SIGNING_PRIVATE_KEYEdDSA private key for signing update bundles. Tauri reads this environment variable automatically during npx tauri build.npx tauri signer generate -w ~/.tauri/ghost.key — save the private key output
TAURI_SIGNING_PRIVATE_KEY_PASSWORDPassword protecting the EdDSA private key. Also read automatically by Tauri.Set during npx tauri signer generate
EXTENSION_PEM_KEYPath to the .pem key file used to sign the Chrome extension CRX package. This key determines the extension’s unique ID.openssl genrsa 2048 > extension.pem
EXTENSION_IDThe Chrome extension’s unique ID (32-character lowercase string derived from the .pem key). Used in updates.xml for Chrome’s extension update system.Derived from the .pem key — install the extension once in developer mode to see the ID

The Makefile provides local build commands for development. These are the same operations the CI runs, but invokable from the developer’s machine.

TargetDescription
make buildBuild the standalone Go binary with the frontend embedded. Output: dist/ghost. Runs go mod tidy, builds frontend first, then compiles Go with -ldflags "-s -w".
make devRun in development mode with Go’s race detector enabled. Uses -tags dev which skips frontend embedding (loads from Vite dev server instead).
make testRun all Go tests with race detection, coverage, and no test caching (-count=1).
make lintRun golangci-lint across the entire codebase.
make vetRun go vet for static analysis.

These build the Go backend as a Tauri sidecar binary with the correct filename for each platform:

TargetOutput fileNotes
make sidecar-darwin-arm64ghost-engine-aarch64-apple-darwinProduction build, embeds frontend
make sidecar-darwin-amd64ghost-engine-x86_64-apple-darwinProduction build, embeds frontend
make sidecar-windowsghost-engine-x86_64-pc-windows-msvc.exeProduction build, embeds frontend
make sidecar-dev-darwin-arm64Same filenameDev build (-tags dev), no frontend embedding
make sidecar-dev-darwin-amd64Same filenameDev build (-tags dev), no frontend embedding
make sidecar-dev-windowsSame filenameDev build (-tags dev), no frontend embedding

Dev vs production sidecars: Dev sidecars use -tags dev which replaces the go:embed directive with an empty filesystem. This means the Go binary doesn’t embed the frontend — instead, the Tauri dev server loads the frontend from Vite’s dev server at localhost:5173. This enables hot module replacement (instant UI updates without rebuilding Go).

TargetDescription
make desktopBuild the Tauri desktop app for the current platform (auto-detects architecture).
make desktop-devRun the Tauri desktop app in development mode with hot reload.
make desktop-macos-arm64Build for Apple Silicon specifically.
make desktop-macos-amd64Build for Intel Mac specifically.
make desktop-macos-universalBuild a universal binary (ARM64 + x64 combined via lipo).
TargetDescription
make cleanRemove dist/, frontend/dist/, coverage files, and sidecar binaries.
make nukeEmergency cleanup: kills all Ghost-related processes (ghost-engine, ghost-desktop, Cargo/Tauri build processes, WebDriverAgent builds), disables system proxy on macOS, removes the SQLite database, and checks that ports 4545/5565/1420 are free. Use this when things are stuck.
make coverRun tests with coverage and generate an HTML coverage report.
make fmtFormat all Go files with gofmt -s (simplify) and organize imports with goimports.

Ghost currently has version strings in multiple locations that must be kept in sync:

LocationCurrent ValueWhat uses it
frontend/src-tauri/tauri.conf.jsonversion0.1.0Tauri app version, updater comparison
frontend/vite.config.ts__APP_VERSION__0.1.0Frontend display, Sentry release tag
.gitlab-ci.ymlGHOST_VERSION0.1.0CI package naming, release tag
frontend/src-tauri/Cargo.tomlversion0.1.0Rust crate version
cmd/ghost/main.go → Sentry releaseghost@0.1.0Backend error tracking
extension/manifest.jsonversion1.0.0Chrome extension version (independent)

The extension version (1.0.0) is intentionally different from the app version (0.1.0) because the extension has its own release lifecycle.


When Ghost runs as a desktop app, the Tauri Rust shell launches the Go backend as a “sidecar” subprocess. Here’s how they communicate:

  1. Tauri spawns ghost-engine --sidecar
  2. The Go binary detects the --sidecar flag and picks ephemeral ports (instead of using the configured defaults)
  3. Go prints a single JSON line to stdout:
    {"api_port": 52341, "proxy_port": 52342, "token": "a1b2c3..."}
  4. Tauri reads this JSON within a 15-second timeout
  5. Tauri navigates the WebView to http://localhost:{api_port} (production) or http://localhost:5173 (development)
  6. The authentication token is passed to the frontend, which uses it for all API calls and WebSocket connections

macOS PATH enrichment: When running as a sidecar, the Go binary enriches its PATH environment variable with common macOS tool locations (/opt/homebrew/bin, /usr/local/bin, ~/go/bin, ~/.cargo/bin, ~/.local/bin). This is necessary because macOS GUI applications don’t inherit the user’s shell PATH, which means tools like frida, adb, xcrun, and security scanners wouldn’t be found otherwise.


The Tauri shell includes a safety net that prevents internet loss if the Go sidecar crashes while system proxy is enabled. Every 3 seconds, the Tauri app polls the sidecar’s health endpoint. If 3 consecutive polls fail (9 seconds of downtime), and the system proxy was configured to route through Ghost, Tauri natively disables the system proxy using platform-specific code:

  • macOS: Clears the proxy address first, then disables the proxy state (4 networksetup commands per network service)
  • Windows: Sets ProxyEnable=0 in the registry at HKCU\Software\Microsoft\Windows\Internet Settings
  • Linux: No-op (system proxy is not managed on Linux)

This ensures users never get stranded without internet access, even if Ghost crashes unexpectedly.