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
31 changes: 17 additions & 14 deletions .claude/skills/release/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,11 @@ description: Cut a new Gravity release — pick the version, land the version-bu
A release is a signed `v*` tag on `main`. Pushing that tag runs
`.github/workflows/release.yml` on a GitHub-hosted macOS ARM64 runner, which
builds the desktop app, packages `gravityd`, and creates the GitHub release.
Signing, notarization, updater artifacts, and the R2 mirror are optional; see
Signing, notarization and updater artifacts are configurable; see
`docs/public-builds.md` for the required variables and secrets. Verify that
configuration before releasing. For an existing distribution, preserve the
updater signing pair and download endpoint so installed clients still update.
updater signing pair. When changing download endpoints, retain a compatibility
bridge for every endpoint embedded in existing installations.
Publishing `latest.json` makes configured running apps offer the update.

## The rule that governs everything here
Expand Down Expand Up @@ -75,7 +76,8 @@ re-check the manifests in that commit first:
git show <sha>:Cargo.toml | sed -n '6p'
```

A pushed tag publishes to GitHub releases and any configured mirror.
A pushed tag uploads all assets to a draft GitHub release, then publishes it as
latest. Existing R2 objects remain available, but new releases do not upload there.

## 4. Watch the run

Expand Down Expand Up @@ -107,7 +109,9 @@ gh release view v0.12.0 --json name,url,assets

Expect the DMG and `gravityd` tarball. With updater signing enabled, also expect
`.app.tar.gz`, its `.sig`, and `latest.json`. Fetch the configured manifest and
confirm its version and artifact URL. If R2 is enabled, check that mirror too.
confirm its version and GitHub artifact URL. Check the legacy manifest bridge
and website download redirect too, and verify updater signatures against the
existing public key. Keep the bridge for dormant installations.

Also check the release notes: the workflow appends a Gatekeeper/`xattr`
warning when notarization was unavailable. That is expected for builds without
Expand All @@ -116,18 +120,17 @@ Apple credentials; official notarized distributions should treat it as a failure
## Dry runs

`workflow_dispatch` on `release.yml` builds from `tauri.conf.json`'s version
and only uploads artifacts to the run — no tag, no R2, no GitHub release. Use
and only uploads artifacts to the run — no tag or GitHub release. Use
it to prove a build change before tagging.

## When a release goes wrong

Versioned R2 objects are immutable and `latest.json` is `no-cache`, so the
recovery is always forward: fix, bump to the next patch, tag again. Do not
delete or move a published tag — apps may already have the manifest. If a bad
`latest.json` is live and the fix will take a while, re-put the previous
version's manifest to stop the rollout:
Recovery is normally forward: fix, bump to the next patch, and tag again. Do not
delete or move a published tag or replace its signed archives — apps may already
have the manifest. If a bad release is latest, select the previous verified
GitHub release as latest to stop new clients discovering it. Point the legacy
bridge at that same version-specific manifest if needed. This stops future
update offers; it does not downgrade installed apps.

```bash
pnpm --dir apps/marketing exec wrangler r2 object put "$R2_BUCKET/desktop/gravity/latest.json" \
--file latest.json --content-type application/json --cache-control no-cache --remote
```
See `apps/marketing/README.md` for the bridge activation and rollback procedure.
Do not remove the old R2 custom domain or historical artifacts during recovery.
39 changes: 3 additions & 36 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,6 @@ jobs:
build:
name: macOS (aarch64)
runs-on: macos-15
env:
RELEASE_BASE_URL: ${{ vars.RELEASE_BASE_URL }}
R2_BUCKET: ${{ vars.R2_BUCKET }}
steps:
- uses: actions/checkout@v4

Expand Down Expand Up @@ -149,9 +146,6 @@ jobs:
exit 1
fi
endpoint="https://github.com/$GITHUB_REPOSITORY/releases/latest/download/latest.json"
if [[ -n "$RELEASE_BASE_URL" ]]; then
endpoint="${RELEASE_BASE_URL%/}/desktop/gravity/latest.json"
fi
config="$(jq -cn --arg key "$UPDATER_PUBLIC_KEY" --arg endpoint "$endpoint" \
'{plugins: {updater: {pubkey: $key, endpoints: [$endpoint]}}, bundle: {createUpdaterArtifacts: true}}')"
echo "GRAVITY_RELEASE_CONFIG=$config" >> "$GITHUB_ENV"
Expand Down Expand Up @@ -194,9 +188,6 @@ jobs:
run: |
archive="gravity-desktop-$VERSION-aarch64.app.tar.gz"
url="https://github.com/$GITHUB_REPOSITORY/releases/download/$GITHUB_REF_NAME/$archive"
if [[ -n "$RELEASE_BASE_URL" ]]; then
url="${RELEASE_BASE_URL%/}/desktop/gravity/$VERSION/$archive"
fi
jq -n \
--arg version "$VERSION" \
--arg pub_date "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
Expand Down Expand Up @@ -232,32 +223,6 @@ jobs:
path: dist/*
if-no-files-found: error

# Optional mirror; GitHub release assets are the default distribution.
- name: Publish to R2
if: github.ref_type == 'tag' && env.R2_BUCKET != '' && env.RELEASE_BASE_URL != ''
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
VERSION: ${{ steps.version.outputs.version }}
run: |
put() {
pnpm --dir apps/marketing exec wrangler r2 object put "$R2_BUCKET/$1" --file "$GITHUB_WORKSPACE/$2" \
--content-type "$3" --cache-control "$4" --remote
}
immutable="public, max-age=31536000, immutable"
pnpm --dir apps/marketing install --frozen-lockfile
if [[ "$UPDATER_ENABLED" == "true" ]]; then
put "desktop/gravity/$VERSION/gravity-desktop-$VERSION-aarch64.app.tar.gz" \
"dist/gravity-desktop-$VERSION-aarch64.app.tar.gz" application/gzip "$immutable"
fi
put "desktop/gravity/$VERSION/gravity-desktop-$VERSION-aarch64.dmg" \
"dist/gravity-desktop-$VERSION-aarch64.dmg" application/x-apple-diskimage "$immutable"
put "gravityd/$VERSION/gravityd-$VERSION-aarch64-apple-darwin.tar.gz" \
"dist/gravityd-$VERSION-aarch64-apple-darwin.tar.gz" application/gzip "$immutable"
if [[ "$UPDATER_ENABLED" == "true" ]]; then
put "desktop/gravity/latest.json" dist/latest.json application/json "no-cache"
fi

- name: Publish GitHub release
if: github.ref_type == 'tag'
env:
Expand Down Expand Up @@ -305,4 +270,6 @@ jobs:
gh release create "$GITHUB_REF_NAME" dist/* \
--title "Gravity $VERSION" \
--notes-file notes.md \
--generate-notes
--generate-notes --draft
# Expose latest.json only after every release asset has uploaded.
gh release edit "$GITHUB_REF_NAME" --draft=false --latest
4 changes: 2 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ resolver = "2"
members = ["crates/bus", "crates/gravityd"]

[workspace.package]
version = "0.12.3"
version = "0.12.4"
edition = "2021"
license = "MIT"

Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "gravity-desktop",
"version": "0.12.3",
"version": "0.12.4",
"private": true,
"license": "MIT",
"type": "module",
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion apps/desktop/src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "gravity-desktop"
version = "0.12.3"
version = "0.12.4"
description = "Gravity desktop client"
license = "MIT"
edition = "2021"
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src-tauri/tauri.conf.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Gravity",
"version": "0.12.3",
"version": "0.12.4",
"identifier": "in.mikolajczuk.gravity",
"build": {
"beforeDevCommand": "pnpm dev",
Expand Down
42 changes: 42 additions & 0 deletions apps/marketing/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,45 @@ Wrangler. Set `vars.RELEASE_MANIFEST_URL` to your HTTPS updater manifest to enab
`/download/latest`; it returns 503 without configuration. Add a `routes` entry
with your custom domain if desired. Review the canonical URLs, sitemap, and
branding when forking.

## Legacy updater bridge

`wrangler.updater-bridge.jsonc` builds a separate, dependency-free compatibility
Worker. It serves only GET/HEAD `/desktop/gravity/latest.json`, returning the
configured upstream manifest without a redirect or content changes. Other paths
and methods are rejected. Upstream errors/invalid manifests return 502; responses
are not cached. The bridge does not forward caller headers or query parameters.

The default upstream is empty and there are no production routes in source.
Keep deployment configuration specific to your distribution. From this directory:

```sh
pnpm exec wrangler deploy --config wrangler.updater-bridge.jsonc --dry-run
pnpm exec wrangler deploy --config wrangler.updater-bridge.jsonc \
--var "RELEASE_MANIFEST_URL:https://github.com/OWNER/REPO/releases/latest/download/latest.json"
```

Verify the Worker on its `workers.dev` URL first. Publish and verify the transition
release's GitHub assets, manifest and updater signature before attaching the exact
legacy manifest route. Do not replace the R2 custom domain or route the entire
host: existing versioned downloads must continue reaching their original objects.
Attach a route with a trailing `*` to include requests with query parameters; the
handler still rejects every pathname except the exact manifest path:

```sh
pnpm exec wrangler deploy --config wrangler.updater-bridge.jsonc \
--var "RELEASE_MANIFEST_URL:https://github.com/OWNER/REPO/releases/latest/download/latest.json" \
--route "downloads.example.com/desktop/gravity/latest.json*"
```

Set the marketing Worker's runtime **and automatic-build** `RELEASE_MANIFEST_URL`
to the GitHub latest manifest. Preserve the explicit `--var` deploy argument and
the empty committed default. Test an existing updater's check/download/signature
verification through the legacy URL, and the transition client's GitHub endpoint.
Keep the original signing keys and the bridge for users who upgrade much later.

For rollback, repoint the bridge to a verified version-specific GitHub manifest,
or remove only its route to expose the retained R2 manifest again. Keep a private
copy of the prior route/settings/manifest before activation. Removing a route or
repointing a manifest affects future checks; it does not downgrade installed apps.
Never replace a published version's signed archives to roll back.
11 changes: 11 additions & 0 deletions apps/marketing/src/updater-bridge.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { proxyUpdateManifest } from "./updater-proxy";

interface Env {
readonly RELEASE_MANIFEST_URL?: string;
}

export default {
fetch(request: Request, env: Env): Promise<Response> {
return proxyUpdateManifest(request, env.RELEASE_MANIFEST_URL);
},
} satisfies ExportedHandler<Env>;
86 changes: 86 additions & 0 deletions apps/marketing/src/updater-proxy.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { afterEach, describe, expect, it, vi } from "vitest";

import { proxyUpdateManifest } from "./updater-proxy";

const upstream = "https://github.com/example/app/releases/latest/download/latest.json";
const legacy = "https://updates.example/desktop/gravity/latest.json";
const body = JSON.stringify({
version: "0.12.4",
platforms: {
"darwin-aarch64": {
url: "https://github.com/example/app/releases/download/v0.12.4/gravity.app.tar.gz",
signature: "original-signature",
},
},
});

afterEach(() => {
vi.unstubAllGlobals();
});

describe("legacy update manifest bridge", () => {
it("preserves manifest bytes and does not forward caller credentials or query parameters", async () => {
const fetch = vi.fn<typeof globalThis.fetch>().mockResolvedValue(new Response(body));
vi.stubGlobal("fetch", fetch);
const response = await proxyUpdateManifest(
new Request(`${legacy}?old-client=true`, {
headers: { authorization: "private-client-token" },
}),
upstream,
);

expect(response.status).toBe(200);
expect(await response.text()).toBe(body);
expect(response.headers.get("content-type")).toBe("application/json");
expect(response.headers.get("cache-control")).toBe("no-store");
expect(fetch).toHaveBeenCalledExactlyOnceWith(upstream, { redirect: "follow" });
});

it("supports HEAD without a response body", async () => {
vi.stubGlobal("fetch", vi.fn<typeof globalThis.fetch>().mockResolvedValue(new Response(body)));
const response = await proxyUpdateManifest(new Request(legacy, { method: "HEAD" }), upstream);
expect(response.status).toBe(200);
expect(await response.text()).toBe("");
});

it.each([404, 403, 500])("fails without caching an upstream HTTP %i", async (status) => {
vi.stubGlobal(
"fetch",
vi.fn<typeof globalThis.fetch>().mockResolvedValue(new Response("unavailable", { status })),
);
const response = await proxyUpdateManifest(new Request(legacy), upstream);
expect(response.status).toBe(502);
expect(response.headers.get("cache-control")).toBe("no-store");
});

it.each([
"<html>error</html>",
"{}",
'{"platforms":{"darwin-aarch64":{"url":"bad.app.tar.gz"}}}',
])("rejects invalid upstream manifests", async (invalid) => {
vi.stubGlobal(
"fetch",
vi.fn<typeof globalThis.fetch>().mockResolvedValue(new Response(invalid)),
);
expect((await proxyUpdateManifest(new Request(legacy), upstream)).status).toBe(502);
});

it("handles an upstream network failure", async () => {
vi.stubGlobal(
"fetch",
vi.fn<typeof globalThis.fetch>().mockRejectedValue(new Error("offline")),
);
expect((await proxyUpdateManifest(new Request(legacy), upstream)).status).toBe(502);
});

it("does not fetch for other paths, unsupported methods or missing configuration", async () => {
const fetch = vi.fn<typeof globalThis.fetch>();
vi.stubGlobal("fetch", fetch);
expect((await proxyUpdateManifest(new Request(`${legacy}/other`), upstream)).status).toBe(404);
expect(
(await proxyUpdateManifest(new Request(legacy, { method: "POST" }), upstream)).status,
).toBe(405);
expect((await proxyUpdateManifest(new Request(legacy), "")).status).toBe(503);
expect(fetch).not.toHaveBeenCalled();
});
});
44 changes: 44 additions & 0 deletions apps/marketing/src/updater-proxy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { resolveMacDownloadUrl } from "./release";

const MANIFEST_PATH = "/desktop/gravity/latest.json";

function unavailable(): Response {
return new Response("The update manifest is temporarily unavailable.", {
status: 502,
headers: { "cache-control": "no-store" },
});
}

export async function proxyUpdateManifest(
request: Request,
manifestUrl: string | undefined,
): Promise<Response> {
if (new URL(request.url).pathname !== MANIFEST_PATH) {
return new Response("Not found", { status: 404 });
}
if (request.method !== "GET" && request.method !== "HEAD") {
return new Response("Method not allowed", { status: 405, headers: { allow: "GET, HEAD" } });
}
if (!manifestUrl?.trim()) {
return new Response("Updates are not configured.", { status: 503 });
}

try {
// Never forward caller cookies, authorization, query parameters or validators.
const upstream = await fetch(manifestUrl, { redirect: "follow" });
if (!upstream.ok) {
return unavailable();
}
const body = await upstream.text();
const manifest: unknown = JSON.parse(body);
if (resolveMacDownloadUrl(manifest) === null) {
return unavailable();
}
// Preserve the manifest bytes, especially the signed archive's signature.
return new Response(request.method === "HEAD" ? null : body, {
headers: { "content-type": "application/json", "cache-control": "no-store" },
});
} catch {
return unavailable();
}
}
2 changes: 1 addition & 1 deletion apps/marketing/tsconfig.worker.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,5 @@
"lib": ["ES2022"],
"types": ["@cloudflare/workers-types"]
},
"include": ["src/release.ts", "src/worker.ts"]
"include": ["src/release.ts", "src/worker.ts", "src/updater-bridge.ts", "src/updater-proxy.ts"]
}
Loading
Loading