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
5 changes: 5 additions & 0 deletions .changeset/fresh-updates-observe.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"hunkdiff": patch
---

Add an opt-in, privacy-preserving cached endpoint for curl-install release checks, rate-limit automatic checks, and retain direct GitHub fallback and analytics opt-outs.
56 changes: 56 additions & 0 deletions .github/workflows/release-proxy.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
name: Release proxy

on:
pull_request:
paths:
- .github/workflows/release-proxy.yml
- workers/release-proxy/**
push:
branches:
- main
paths:
- .github/workflows/release-proxy.yml
- workers/release-proxy/**
workflow_dispatch:

concurrency:
group: release-proxy-${{ github.ref }}
cancel-in-progress: true

permissions:
contents: read

jobs:
check:
name: Check Worker
runs-on: ubuntu-latest
defaults:
run:
working-directory: workers/release-proxy
steps:
- name: Check out repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

- name: Set up Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version: 1.3.14

- name: Set up Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 22
cache: npm
cache-dependency-path: workers/release-proxy/package-lock.json

- name: Install dependencies
run: npm ci

- name: Test
run: npm test

- name: Typecheck
run: npm run typecheck

- name: Verify deployment bundle
run: npx wrangler deploy --dry-run
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ Hunk is a review-first terminal diff viewer for agent-authored changesets, built

## Install

The default installation method on macOS and Linux downloads a standalone binary and installs it into `~/.hunk`. It checks the archive against the release checksum when both `SHA256SUMS` and a supported checksum tool are available, and warns otherwise:
The default installation method on macOS and Linux downloads a standalone binary and installs it into `~/.hunk`. It checks the archive against the release checksum when both `SHA256SUMS` and a supported checksum tool are available, and warns otherwise. Release discovery stays direct to GitHub while Hunk's anonymous aggregate endpoint is evaluated; set `HUNK_ENABLE_RELEASE_PROXY=1` to opt into testing it:

```bash
curl -fsSL https://hunk.dev/install.sh | sh
Expand Down
54 changes: 49 additions & 5 deletions install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@
# HUNK_NO_MODIFY_PATH set to 1 to leave shell startup files alone
# HUNK_ALLOW_CONFLICTING_INSTALLS
# set to 1 to install alongside another Hunk
# HUNK_ENABLE_RELEASE_PROXY
# set to 1 to test Hunk's aggregate release endpoint
# HUNK_DISABLE_ANALYTICS
# set to 1 to resolve releases directly from GitHub
# DO_NOT_TRACK set to 1 to resolve releases directly from GitHub
#
# macOS and Linux only. On Windows, install with `npm install -g hunkdiff`.
#
Expand All @@ -30,6 +35,7 @@
set -eu

REPO="modem-dev/hunk"
RELEASE_PROXY="https://updates.hunk.dev/v1/curl/latest"
RELEASES_API="https://api.github.com/repos/${REPO}/releases/latest"
DOWNLOAD_BASE="https://github.com/${REPO}/releases/download"

Expand Down Expand Up @@ -71,6 +77,11 @@ Environment:
HUNK_NO_MODIFY_PATH set to 1 for --no-modify-path
HUNK_ALLOW_CONFLICTING_INSTALLS
set to 1 for --force
HUNK_ENABLE_RELEASE_PROXY
set to 1 to test Hunk's aggregate release endpoint
HUNK_DISABLE_ANALYTICS
set to 1 to bypass Hunk's aggregate release endpoint
DO_NOT_TRACK set to 1 to bypass Hunk's aggregate release endpoint

macOS and Linux only. On Windows, install with `npm install -g hunkdiff`.
EOF
Expand Down Expand Up @@ -128,12 +139,32 @@ download() {
fi
}

# Print one URL's body, returning non-zero when the server refuses it.
# Print one metadata URL's body with one bounded attempt.
fetch() {
if [ "$downloader" = "curl" ]; then
curl -fsSL "$1"
curl -fsSL --max-time 5 "$1"
else
wget -q -O - "$1"
wget -q -t 1 -T 5 -O - "$1"
fi
}

# Resolve through Hunk's observable release endpoint without sending an installation identifier.
# This attempt is bounded so a stalled proxy yields promptly to the direct GitHub fallback.
fetch_release_proxy() {
current_header=""
[ -n "${1:-}" ] && current_header="X-Hunk-Current-Version: $1"
if [ "$downloader" = "curl" ]; then
if [ -n "$current_header" ]; then
curl -fsSL --max-time 5 -H "X-Hunk-Request-Source: install" -H "$current_header" "$RELEASE_PROXY"
else
curl -fsSL --max-time 5 -H "X-Hunk-Request-Source: install" "$RELEASE_PROXY"
fi
else
if [ -n "$current_header" ]; then
wget -q -t 1 -T 5 --header="X-Hunk-Request-Source: install" --header="$current_header" -O - "$RELEASE_PROXY"
else
wget -q -t 1 -T 5 --header="X-Hunk-Request-Source: install" -O - "$RELEASE_PROXY"
fi
fi
}

Expand Down Expand Up @@ -400,9 +431,22 @@ main() {

if [ -z "$version" ]; then
info "Resolving the newest Hunk release..."
release_current=""
if [ -n "${HUNK_INSTALL_DIR:-}" ]; then
release_current="$(installed_version "${HUNK_INSTALL_DIR%/}/hunk")"
elif [ -n "${HOME:-}" ]; then
release_current="$(installed_version "${HOME}/.hunk/bin/hunk")"
fi
# Parsed with sed rather than jq so the installer needs nothing but a shell and a downloader.
version="$(fetch "$RELEASES_API" | sed -n 's/.*"tag_name"[[:space:]]*:[[:space:]]*"v\{0,1\}\([^"]*\)".*/\1/p' | head -n 1)"
[ -n "$version" ] || fail "Could not resolve the newest Hunk release from ${RELEASES_API}."
if [ "${HUNK_ENABLE_RELEASE_PROXY:-0}" = "1" ] && [ "${HUNK_DISABLE_ANALYTICS:-0}" != "1" ] && [ "${DO_NOT_TRACK:-0}" != "1" ]; then
proxy_payload="$(fetch_release_proxy "$release_current" 2>/dev/null)" || proxy_payload=""
version="$(printf '%s\n' "$proxy_payload" | sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n 1)"
printf '%s\n' "$version" | grep -q '^[0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*$' || version=""
fi
if [ -z "$version" ]; then
version="$(fetch "$RELEASES_API" | sed -n 's/.*"tag_name"[[:space:]]*:[[:space:]]*"v\{0,1\}\([^"]*\)".*/\1/p' | head -n 1)"
fi
[ -n "$version" ] || fail "Could not resolve the newest Hunk release from Hunk or ${RELEASES_API}."
fi

home_dir="${HOME:-}"
Expand Down
2 changes: 1 addition & 1 deletion packages/hunk/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ Hunk is a review-first terminal diff viewer for agent-authored changesets, built

## Install

The default installation method on macOS and Linux downloads a standalone binary and installs it into `~/.hunk`. It checks the archive against the release checksum when both `SHA256SUMS` and a supported checksum tool are available, and warns otherwise:
The default installation method on macOS and Linux downloads a standalone binary and installs it into `~/.hunk`. It checks the archive against the release checksum when both `SHA256SUMS` and a supported checksum tool are available, and warns otherwise. Release discovery stays direct to GitHub while Hunk's anonymous aggregate endpoint is evaluated; set `HUNK_ENABLE_RELEASE_PROXY=1` to opt into testing it:

```bash
curl -fsSL https://hunk.dev/install.sh | sh
Expand Down
78 changes: 72 additions & 6 deletions packages/hunk/src/core/install/latestRelease.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,32 +38,98 @@ describe("release channel lookups", () => {
expect(requested).toEqual(["https://formulae.brew.sh/api/formula/hunk.json"]);
});

test("reads the newest GitHub release tag for curl installer installs", async () => {
test("reads curl release metadata through the first-party endpoint", async () => {
const requested: string[] = [];
const accepts: unknown[] = [];
const headers: Headers[] = [];

await expect(
fetchChannelVersions("curl", {
env: { HUNK_ENABLE_RELEASE_PROXY: "1" },
requestSource: "startup",
currentVersion: "1.3.0",
fetchImpl: async (input, init) => {
requested.push(String(input));
accepts.push(new Headers(init?.headers).get("accept"));
headers.push(new Headers(init?.headers));
return jsonResponse({ version: "1.4.0" });
},
}),
).resolves.toEqual({ latest: "1.4.0" });
expect(requested).toEqual(["https://updates.hunk.dev/v1/curl/latest"]);
expect(headers[0]?.get("x-hunk-request-source")).toBe("startup");
expect(headers[0]?.get("x-hunk-current-version")).toBe("1.3.0");
});

test("falls back to GitHub when the first-party endpoint fails or is invalid", async () => {
for (const proxyResponse of [jsonResponse({}, 503), jsonResponse({ version: "invalid" })]) {
const requested: string[] = [];
const accepts: Array<string | null> = [];
await expect(
fetchChannelVersions("curl", {
env: { HUNK_ENABLE_RELEASE_PROXY: "1" },
fetchImpl: async (input, init) => {
requested.push(String(input));
accepts.push(new Headers(init?.headers).get("accept"));
return requested.length === 1
? proxyResponse.clone()
: jsonResponse({ tag_name: "v1.4.0" });
},
}),
).resolves.toEqual({ latest: "1.4.0" });
expect(requested).toEqual([
"https://updates.hunk.dev/v1/curl/latest",
"https://api.github.com/repos/modem-dev/hunk/releases/latest",
]);
expect(accepts).toEqual([null, "application/vnd.github+json"]);
}
});

test("uses GitHub directly unless first-party release testing is enabled", async () => {
const requested: string[] = [];
await expect(
fetchChannelVersions("curl", {
env: {},
fetchImpl: async (input) => {
requested.push(String(input));
return jsonResponse({ tag_name: "v1.4.0" });
},
}),
).resolves.toEqual({ latest: "1.4.0" });
expect(requested).toEqual(["https://api.github.com/repos/modem-dev/hunk/releases/latest"]);
expect(accepts).toEqual(["application/vnd.github+json"]);
});

test("drops a GitHub release tag that is not a stable version", async () => {
test("bypasses first-party analytics when either opt-out is set", async () => {
for (const env of [
{ HUNK_ENABLE_RELEASE_PROXY: "1", HUNK_DISABLE_ANALYTICS: "1" },
{ HUNK_ENABLE_RELEASE_PROXY: "1", DO_NOT_TRACK: "1" },
]) {
const requested: string[] = [];
await expect(
fetchChannelVersions("curl", {
env,
fetchImpl: async (input) => {
requested.push(String(input));
return jsonResponse({ tag_name: "v1.4.0" });
},
}),
).resolves.toEqual({ latest: "1.4.0" });
expect(requested).toEqual(["https://api.github.com/repos/modem-dev/hunk/releases/latest"]);
}
});

test("drops curl release metadata that is not a stable version", async () => {
await expect(
fetchChannelVersions("curl", {
fetchImpl: async () => jsonResponse({ tag_name: "v1.4.0-beta.1" }),
env: { HUNK_ENABLE_RELEASE_PROXY: "1" },
fetchImpl: async (input) =>
String(input).includes("updates.hunk.dev")
? jsonResponse({ version: "1.4.0-beta.1" })
: jsonResponse({ tag_name: "v1.4.0-beta.1" }),
}),
).resolves.toEqual({ latest: undefined });

await expect(
fetchChannelVersions("curl", {
env: {},
fetchImpl: async () => jsonResponse({ name: "1.4.0" }),
}),
).resolves.toEqual({ latest: undefined });
Expand Down
45 changes: 41 additions & 4 deletions packages/hunk/src/core/install/latestRelease.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,17 @@ import { isPrereleaseVersion, isStableVersion } from "../run/version";

const NPM_DIST_TAGS_URL = "https://registry.npmjs.org/-/package/hunkdiff/dist-tags";
const HOMEBREW_FORMULA_URL = "https://formulae.brew.sh/api/formula/hunk.json";
const HUNK_CURL_RELEASE_URL = "https://updates.hunk.dev/v1/curl/latest";
const GITHUB_LATEST_RELEASE_URL = "https://api.github.com/repos/modem-dev/hunk/releases/latest";
const DEFAULT_RELEASE_FETCH_TIMEOUT_MS = 5_000;
const ENABLE_RELEASE_PROXY_ENV = "HUNK_ENABLE_RELEASE_PROXY";
const DISABLE_ANALYTICS_ENV = "HUNK_DISABLE_ANALYTICS";
const DO_NOT_TRACK_ENV = "DO_NOT_TRACK";

export type FetchImpl = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;

export type UpdateChannel = "latest" | "beta";
export type ReleaseRequestSource = "startup" | "update-check" | "update";

/** Versions one install source currently publishes, after validation. */
export interface ChannelVersions {
Expand All @@ -29,6 +34,9 @@ export interface ChannelVersions {
export interface ReleaseLookupDeps {
fetchImpl?: FetchImpl;
fetchTimeoutMs?: number;
env?: NodeJS.ProcessEnv;
requestSource?: ReleaseRequestSource;
currentVersion?: string;
}

/** Build one fetch timeout signal for a release lookup, if supported by the runtime. */
Expand Down Expand Up @@ -117,16 +125,45 @@ export async function fetchHomebrewChannelVersions(
return { latest: stable && isStableVersion(stable) ? stable : undefined };
}

/** Return whether this process explicitly opts into the first-party release proxy. */
function releaseProxyEnabled(env: NodeJS.ProcessEnv | undefined) {
return (
env?.[ENABLE_RELEASE_PROXY_ENV] === "1" &&
env[DISABLE_ANALYTICS_ENV] !== "1" &&
env[DO_NOT_TRACK_ENV] !== "1"
);
}

/** Build bounded headers for the first-party curl release endpoint. */
function curlReleaseHeaders(deps: ReleaseLookupDeps) {
const headers: Record<string, string> = {};
if (deps.requestSource) {
headers["X-Hunk-Request-Source"] = deps.requestSource;
}
if (deps.currentVersion) {
headers["X-Hunk-Current-Version"] = deps.currentVersion;
}
return headers;
}

/**
* Fetch the version of the newest GitHub release the curl installer downloads from.
* Fetch the stable release published for curl installs.
*
* `releases/latest` never points at a prerelease, so a curl install only ever hears about
* `latest`. Release tags are spelled `v1.2.3` and versions are not, so the prefix is stripped
* before validation.
* The opt-in first-party endpoint supplies aggregate release-check observability and normalized
* metadata while it is evaluated before general rollout. Every other client and every endpoint
* failure uses GitHub directly so the proxy can never make update discovery less reliable.
*/
export async function fetchCurlChannelVersions(
deps: ReleaseLookupDeps = {},
): Promise<ChannelVersions> {
if (releaseProxyEnabled(deps.env)) {
const proxyPayload = await fetchJson(HUNK_CURL_RELEASE_URL, deps, curlReleaseHeaders(deps));
const proxyVersion = readStringField(proxyPayload, "version");
if (proxyVersion && isStableVersion(proxyVersion)) {
return { latest: proxyVersion };
}
}

const payload = await fetchJson(GITHUB_LATEST_RELEASE_URL, deps, {
Accept: "application/vnd.github+json",
});
Expand Down
Loading
Loading