From b9a4d2e9785c6dcca5da4ac58f1b1cbe438d0861 Mon Sep 17 00:00:00 2001 From: Raphael Vigee Date: Mon, 31 Aug 2026 12:55:25 +0200 Subject: [PATCH 1/4] =?UTF-8?q?feat(docs):=20add=20release=20channels=20?= =?UTF-8?q?=E2=80=94=20nightly=20(default)=20and=20stable?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce the concept of a release channel: the stream a heph release comes from. Two channels ship: `nightly` (from hephbuild/heph-artifacts-v1, the default and what the site showed until now) and `stable` (from hephbuild/heph), which has no release published yet. Site: - `website/src/releaseChannels.ts` is the single source of truth: channel metadata, the repository each publishes to, and `DEFAULT_RELEASE_CHANNEL`. Making another channel the default is a one-line change there. - A channel selector is drawn on top of every code block carrying a version or release URL, and the choice is shared page-wide (context in `Root`) and persisted in localStorage. `?channel=` pins a channel for a single visit. - Docs blocks use `` in place of hardcoded heph-artifacts-v1 download URLs, so they follow the selected channel. This also fixes blocks titled `ci.hephconfig`, whose placeholders were never substituted (the old wrapper matched the exact title `.hephconfig`). - Landing chrome keeps quoting the default channel. - A channel with no release yet (stable, today) says so instead of rendering the generic "could not resolve the version" error. Installer: `HEPH_CHANNEL=nightly|stable` picks the repository; unknown values fail with a clear message. Docs: new reference page "Release channels", linked from getting started and the `version` key; plugin skill references updated in the same change. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_017fcBWHbsCSTCSopBkF8W8t --- .claude-plugin/marketplace.json | 2 +- .../heph-expert/.claude-plugin/plugin.json | 2 +- .../heph-expert/skills/heph/references/cli.md | 8 + .../skills/heph/references/configuration.md | 2 +- plugins/heph-go/.claude-plugin/plugin.json | 2 +- .../skills/heph-go/references/go-plugin.md | 6 +- website/docs/getting-started.md | 6 +- website/docs/guides/ci.md | 2 +- website/docs/plugins/devenv.md | 2 +- website/docs/plugins/gha.md | 6 +- website/docs/plugins/go.md | 6 +- website/docs/plugins/oci.md | 2 +- website/docs/reference/configuration.md | 10 +- website/docs/reference/release-channels.md | 70 ++++++++ website/sidebars.ts | 2 +- .../src/components/ReleaseChannelSelector.tsx | 43 +++++ website/src/components/landing/Nav.tsx | 1 + website/src/css/custom.css | 77 +++++++++ website/src/hooks/useLatestVersion.ts | 69 ++++++-- website/src/hooks/useReleaseChannel.tsx | 73 +++++++++ website/src/releaseChannels.ts | 82 ++++++++++ website/src/theme/CodeBlock/index.tsx | 151 +++++++++++------- website/src/theme/Root.tsx | 11 +- website/static/install.sh | 14 +- 24 files changed, 551 insertions(+), 98 deletions(-) create mode 100644 website/docs/reference/release-channels.md create mode 100644 website/src/components/ReleaseChannelSelector.tsx create mode 100644 website/src/hooks/useReleaseChannel.tsx create mode 100644 website/src/releaseChannels.ts diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 9bb48ab..f027f05 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/claude-code-marketplace.json", "name": "heph-marketplace", - "version": "0.1.3", + "version": "0.1.4", "description": "Claude Code plugins for the heph build system, co-located with the docs.", "owner": { "name": "hephbuild", diff --git a/plugins/heph-expert/.claude-plugin/plugin.json b/plugins/heph-expert/.claude-plugin/plugin.json index 6461d4a..9beed19 100644 --- a/plugins/heph-expert/.claude-plugin/plugin.json +++ b/plugins/heph-expert/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/claude-code-plugin.json", "name": "heph-expert", - "version": "0.1.2", + "version": "0.1.3", "description": "Expert assistance for the heph build system: author BUILD files, debug caching and sandbox issues, wire up CI, and explain the target graph.", "author": { "name": "hephbuild" diff --git a/plugins/heph-expert/skills/heph/references/cli.md b/plugins/heph-expert/skills/heph/references/cli.md index 94acaa8..1c50d8f 100644 --- a/plugins/heph-expert/skills/heph/references/cli.md +++ b/plugins/heph-expert/skills/heph/references/cli.md @@ -104,3 +104,11 @@ Print the heph version string and exit. ```bash curl -fsSL https://hephbuild.github.io/install.sh | sh ``` + +Releases come from a **release channel**: `nightly` (default, from +`hephbuild/heph-artifacts-v1`) or `stable` (from `hephbuild/heph`). Pick one with +`HEPH_CHANNEL`, and a tag within it with `HEPH_VERSION`: + +```bash +HEPH_CHANNEL=stable HEPH_VERSION=v1.2.3 curl -fsSL https://hephbuild.github.io/install.sh | sh +``` diff --git a/plugins/heph-expert/skills/heph/references/configuration.md b/plugins/heph-expert/skills/heph/references/configuration.md index 26a1e10..ef26d23 100644 --- a/plugins/heph-expert/skills/heph/references/configuration.md +++ b/plugins/heph-expert/skills/heph/references/configuration.md @@ -15,7 +15,7 @@ plugins: | Key | Type | Default | Description | |---|---|---|---| -| `version` | semver string | — | Pins the heph release that runs this workspace, so every machine/CI job resolves the same toolchain. Set it once at the top. | +| `version` | semver string | — | Pins the heph release that runs this workspace, so every machine/CI job resolves the same toolchain. Set it once at the top. The tag names a release in a channel: `nightly` (default, `hephbuild/heph-artifacts-v1`) or `stable` (`hephbuild/heph`). | | `versionFlavour` | string | `""` (std) | Which release flavour self-upgrade downloads: `""` for the default stripped "std" build, or `debug` for the unstripped build (symbolicated backtraces). | | `plugins` | list of plugin entries | `[]` | Plugins to register. Each entry sets exactly one of `builtin`, `path`, or `url`, plus an optional `options` map. | | `homeDir` | path | unset | Where heph keeps its home and cache. | diff --git a/plugins/heph-go/.claude-plugin/plugin.json b/plugins/heph-go/.claude-plugin/plugin.json index a84e317..bb96542 100644 --- a/plugins/heph-go/.claude-plugin/plugin.json +++ b/plugins/heph-go/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/claude-code-plugin.json", "name": "heph-go", - "version": "0.1.3", + "version": "0.1.4", "description": "Set up and maintain Go in a heph workspace correctly: enable the go provider and drivers, wire generated code (go_src / go_codegen_root / go_codegen_deps) and test fixtures (go_test_data), and keep :build/:test green.", "author": { "name": "hephbuild" diff --git a/plugins/heph-go/skills/heph-go/references/go-plugin.md b/plugins/heph-go/skills/heph-go/references/go-plugin.md index 8bda8c3..539ef6b 100644 --- a/plugins/heph-go/skills/heph-go/references/go-plugin.md +++ b/plugins/heph-go/skills/heph-go/references/go-plugin.md @@ -29,9 +29,13 @@ You should not interact with these drivers directly; they are internal plumbing. The Go plugin is an **external plugin** (not compiled into the heph binary). A single `plugins:` entry loads the provider and all four drivers: +The URL points at a release in a channel — `nightly` +(`hephbuild/heph-artifacts-v1`, the default) or `stable` (`hephbuild/heph`). +Keep it on the same channel as the `version:` pin. + ```yaml title=".hephconfig" plugins: - - url: https://github.com/hephbuild/heph-artifacts-v1/releases/download//heph-go-plugin.json + - url: https://github.com/hephbuild/heph-artifacts-v1/releases/download/v/heph-go-plugin.json options: gotool: "1.27.0" # required — pinned version, "host", or a target address skip: [] # optional diff --git a/website/docs/getting-started.md b/website/docs/getting-started.md index f2a6fea..27b8920 100644 --- a/website/docs/getting-started.md +++ b/website/docs/getting-started.md @@ -9,7 +9,7 @@ description: Install heph and write your first .hephconfig. Install heph: ```bash title="terminal" -curl -fsSL https://hephbuild.github.io/install.sh | sh +curl -fsSL https://hephbuild.github.io/install.sh | sh ``` Then drop a `.hephconfig` at the root of your repository. Pin the version so @@ -19,5 +19,9 @@ every machine and CI run resolves the same toolchain — byte for byte: version: ``` +The version above comes from a [release channel](/docs/reference/release-channels) +— `nightly` by default. Switch the selector on any code block to read the page +for the other channel. + From here, enable the [plugins](/docs/plugins) that you require and get building! A good plugin to get started is [buildfile](/docs/plugins/buildfile). diff --git a/website/docs/guides/ci.md b/website/docs/guides/ci.md index 02cc396..d4bd416 100644 --- a/website/docs/guides/ci.md +++ b/website/docs/guides/ci.md @@ -70,7 +70,7 @@ from a `ci.hephconfig` overlay so it only activates in CI: ```yaml title="ci.hephconfig" plugins: - - url: https://github.com/hephbuild/heph-artifacts-v1/releases/download/v/heph-gha-plugin.json + - url: /heph-gha-plugin.json ``` ```yaml title=".github/workflows/build.yml" diff --git a/website/docs/plugins/devenv.md b/website/docs/plugins/devenv.md index b32c283..9d518ff 100644 --- a/website/docs/plugins/devenv.md +++ b/website/docs/plugins/devenv.md @@ -26,7 +26,7 @@ via the `bin` option below). ```yaml title=".hephconfig" plugins: - - url: https://github.com/hephbuild/heph-artifacts-v1/releases/download/v/heph-devenv-plugin.json + - url: /heph-devenv-plugin.json checksum: sha256: # optional; pin from heph-devenv-plugin.json.sha256 ``` diff --git a/website/docs/plugins/gha.md b/website/docs/plugins/gha.md index 11344dc..861f559 100644 --- a/website/docs/plugins/gha.md +++ b/website/docs/plugins/gha.md @@ -46,7 +46,7 @@ The GHA plugin is an **external plugin** — it ships as a shared library ```yaml title=".hephconfig" plugins: - - url: https://github.com/hephbuild/heph-artifacts-v1/releases/download/v/heph-gha-plugin.json + - url: /heph-gha-plugin.json checksum: sha256: # optional; pin from heph-gha-plugin.json.sha256 ``` @@ -60,7 +60,7 @@ a profile overlay so local runs are unaffected: ```yaml title="ci.hephconfig" plugins: - - url: https://github.com/hephbuild/heph-artifacts-v1/releases/download/v/heph-gha-plugin.json + - url: /heph-gha-plugin.json checksum: sha256: ``` @@ -104,7 +104,7 @@ message is emitted. The step summary is always written regardless. ```yaml title="ci.hephconfig" plugins: - - url: https://github.com/hephbuild/heph-artifacts-v1/releases/download/v/heph-gha-plugin.json + - url: /heph-gha-plugin.json options: refreshSecs: 30 # optional summaryPath: "" # optional diff --git a/website/docs/plugins/go.md b/website/docs/plugins/go.md index 5034552..c17f2d6 100644 --- a/website/docs/plugins/go.md +++ b/website/docs/plugins/go.md @@ -34,7 +34,7 @@ Use `url:` to have heph fetch and cache the plugin automatically: ```yaml title=".hephconfig" plugins: - - url: https://github.com/hephbuild/heph-artifacts-v1/releases/download/v/heph-go-plugin.json + - url: /heph-go-plugin.json checksum: sha256: # optional; pin from heph-go-plugin.json.sha256 ``` @@ -47,7 +47,7 @@ for details. ```yaml title=".hephconfig" plugins: - - url: https://github.com/hephbuild/heph-artifacts-v1/releases/download/v/heph-go-plugin.json + - url: /heph-go-plugin.json checksum: sha256: # optional options: gotool: "1.27.0" # required — pinned version, "host", or a target address @@ -119,7 +119,7 @@ Each pattern is matched against the workspace-relative path of the directory. ```yaml title=".hephconfig" plugins: - - url: https://github.com/hephbuild/heph-artifacts-v1/releases/download/v/heph-go-plugin.json + - url: /heph-go-plugin.json options: gotool: "1.27.0" skip: diff --git a/website/docs/plugins/oci.md b/website/docs/plugins/oci.md index 42f6aba..dc5abe2 100644 --- a/website/docs/plugins/oci.md +++ b/website/docs/plugins/oci.md @@ -30,7 +30,7 @@ binary. It ships as a shared library (cdylib) with a manifest file ```yaml title=".hephconfig" plugins: - - url: https://github.com/hephbuild/heph-artifacts-v1/releases/download/v/heph-oci-plugin.json + - url: /heph-oci-plugin.json checksum: sha256: # optional; pin from heph-oci-plugin.json.sha256 ``` diff --git a/website/docs/reference/configuration.md b/website/docs/reference/configuration.md index 1eda6e0..fdc43e0 100644 --- a/website/docs/reference/configuration.md +++ b/website/docs/reference/configuration.md @@ -24,7 +24,9 @@ version: v1.2.3 ``` `version` pins the heph release for this workspace so every machine and CI job -runs the same binary. When the running binary differs from the pin, heph +runs the same binary. The tag names a release in a +[release channel](/docs/reference/release-channels) — `nightly` by default, +`stable` for slower-moving pins. When the running binary differs from the pin, heph automatically downloads the pinned release and re-execs into it on startup — the rest of the run is served by the pinned version. The downloaded binary is cached in `~/.heph/versions//` and reused on subsequent runs. @@ -67,7 +69,7 @@ Every key below is optional. | Key | Type | Default | Description | |-------------|-------------------------------|---------|-------------| -| `version` | string | unset | Pins the heph release for this workspace. When set, heph automatically downloads and re-execs into the pinned version on startup. See [Pinning the version](#pinning-the-version). | +| `version` | string | unset | Pins the heph release for this workspace. When set, heph automatically downloads and re-execs into the pinned version on startup. See [Pinning the version](#pinning-the-version) and [Release channels](/docs/reference/release-channels). | | `versionFlavour` | string | `""` (std) | Selects which release flavour self-upgrade downloads: `""` for std, or `debug` for the unstripped build. See [Pinning a release flavour](#pinning-a-release-flavour). | | `plugins` | list of plugin entries | `[]` | Plugins to register. Each entry sets exactly one of `builtin`, `path`, or `url`, plus an optional `options` map and, for `url:` entries, an optional `checksum`. | | `homeDir` | path | unset | Where heph keeps its home and cache. | @@ -137,7 +139,7 @@ plugins: - path: ./path/to/my-plugin.json # Remote manifest — downloaded and cached automatically - - url: https://github.com/hephbuild/heph-artifacts-v1/releases/download//heph-go-plugin.json + - url: /heph-go-plugin.json ``` `path:` and `url:` plugins are supported on Unix only. @@ -150,7 +152,7 @@ trusting anything it declares — a mismatch is a hard error. ```yaml title=".hephconfig" plugins: - - url: https://github.com/hephbuild/heph-artifacts-v1/releases/download//heph-go-plugin.json + - url: /heph-go-plugin.json checksum: sha256: ``` diff --git a/website/docs/reference/release-channels.md b/website/docs/reference/release-channels.md new file mode 100644 index 0000000..7a3a8a6 --- /dev/null +++ b/website/docs/reference/release-channels.md @@ -0,0 +1,70 @@ +--- +title: "Release channels" +sidebar_position: 4 +description: Where heph releases come from — nightly and stable — and how to pick one. +--- + +# Release channels + +A **release channel** is the stream a heph release comes from: the `heph` +binary, the plugin manifests published beside it, and their checksums. Every +channel publishes the same asset names — only the cadence and the repository +differ. + +| Channel | Cadence | Releases | +|-----------|---------|----------| +| `nightly` | Cut from every change on main. Newest features, fastest moving. **Default.** | [hephbuild/heph-artifacts-v1](https://github.com/hephbuild/heph-artifacts-v1/releases/latest) | +| `stable` | Tagged releases. Fewer, slower, vetted. | [hephbuild/heph](https://github.com/hephbuild/heph/releases/latest) | + +:::note +`nightly` is the default everywhere today — the installer, and every version and +URL these docs show. Pick `stable` when you want a slower-moving pin. +::: + +## Reading the docs on a channel + +Every code block that carries a version or a plugin URL has a channel selector +above it. Pick a channel and the whole page rewrites: the `version:` pin, the +plugin manifest URLs, and the install command all switch to that channel. The +choice sticks across pages. + +:::tip +`?channel=stable` on any docs URL pins the page to that channel for the visit, +without changing your saved choice — handy for sharing a link that reads the way +you meant it. +::: + +## Installing from a channel + +The installer takes the channel in `HEPH_CHANNEL`: + +```bash title="terminal" +HEPH_CHANNEL=stable curl -fsSL https://hephbuild.github.io/install.sh | sh +``` + +Omit it for `nightly`. `HEPH_VERSION` pins a tag within the channel: + +```bash title="terminal" +HEPH_CHANNEL=stable HEPH_VERSION=v1.2.3 curl -fsSL https://hephbuild.github.io/install.sh | sh +``` + +## Pinning plugins from a channel + +A `url:` plugin entry names the release it comes from, so it carries the channel +in its URL — keep it on the same channel as the version you pinned: + +```yaml title=".hephconfig" +version: +plugins: + - url: /heph-go-plugin.json +``` + +See [Configuration](/docs/reference/configuration#pinning-the-version) for the +`version` key and [Pinning manifests with checksums](/docs/reference/configuration#pinning-manifests-with-checksums) +for locking a manifest to a digest. + +## Switching channels + +Nothing is stateful about a channel: change the `version:` pin and the plugin +URLs in `.hephconfig`, and the next run downloads and re-execs into the release +you named. Binaries are cached per tag, so switching back is instant. diff --git a/website/sidebars.ts b/website/sidebars.ts index 7bcf2d6..d60c9ea 100644 --- a/website/sidebars.ts +++ b/website/sidebars.ts @@ -59,7 +59,7 @@ const sidebars: SidebarsConfig = { type: 'category', label: 'Reference', collapsible: false, - items: ['reference/configuration', 'reference/addresses', 'reference/cli'], + items: ['reference/configuration', 'reference/release-channels', 'reference/addresses', 'reference/cli'], }, ], }; diff --git a/website/src/components/ReleaseChannelSelector.tsx b/website/src/components/ReleaseChannelSelector.tsx new file mode 100644 index 0000000..022b13b --- /dev/null +++ b/website/src/components/ReleaseChannelSelector.tsx @@ -0,0 +1,43 @@ +import type { ReactNode } from 'react'; +import { Tooltip } from '@heph/uikit'; +import { useReleaseChannel } from '../hooks/useReleaseChannel'; +import { + DEFAULT_RELEASE_CHANNEL, + RELEASE_CHANNELS, + RELEASE_CHANNEL_IDS, +} from '../releaseChannels'; + +/** + * Segmented control sitting on top of a code block whose contents depend on the + * release channel — the version pin, plugin manifest URLs, the installer. + * Picking a channel switches every such block on the page at once. + */ +export function ReleaseChannelSelector(): ReactNode { + const { channel, setChannel } = useReleaseChannel(); + + return ( +
+ channel +
+ {RELEASE_CHANNEL_IDS.map((id) => { + const c = RELEASE_CHANNELS[id]; + const selected = id === channel; + return ( + + + + ); + })} +
+
+ ); +} diff --git a/website/src/components/landing/Nav.tsx b/website/src/components/landing/Nav.tsx index 2e38ea7..40c98d8 100644 --- a/website/src/components/landing/Nav.tsx +++ b/website/src/components/landing/Nav.tsx @@ -12,6 +12,7 @@ const STATUS_TAIL = [ /** Technical status strip + minimal mono nav. */ export function Nav() { + // Marketing chrome always quotes the default release channel. const { version } = useLatestVersion(); const status = [{ label: version ? `v${version}` : 'v…' }, ...STATUS_TAIL]; return ( diff --git a/website/src/css/custom.css b/website/src/css/custom.css index 3b68b19..4560270 100644 --- a/website/src/css/custom.css +++ b/website/src/css/custom.css @@ -754,3 +754,80 @@ body:has(.theme-doc-markdown) .navbar__brand::after { display: none; } } + +/* ========================================================================== + Release-channel bar — sits on top of a code block whose contents depend on + the channel (the `version:` pin, plugin manifest URLs, the installer). Same + language as the code well's title band: light surface, hairline frame, mono. + Rendered by src/components/ReleaseChannelSelector.tsx. + ========================================================================== */ +.theme-doc-markdown.markdown .hephChannelBlock { + margin: 18px 0; +} +/* The well below the bar joins it — the block's own vertical margin is carried + by the wrapper instead. */ +.theme-doc-markdown.markdown .hephChannelBlock .theme-code-block, +.theme-doc-markdown.markdown .hephChannelBlock div[class*='codeBlockContainer'] { + margin: 0; +} +.theme-doc-markdown.markdown .hephChannelBlock .theme-admonition { + margin: 12px 0; +} +.hephChannelBar { + display: flex; + align-items: center; + gap: 10px; + height: 34px; + padding: 0 10px; + background: var(--bg-1); + border: 1px solid var(--hair); + border-bottom: 0; + font-family: var(--font-mono); +} +.hephChannelBar__label { + font-size: 10.5px; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--faint); +} +.hephChannelBar__group { + display: flex; + margin-left: auto; + border: 1px solid var(--hair-2); + background: var(--paper); +} +.hephChannelBar__option { + display: flex; + align-items: center; + gap: 6px; + height: 22px; + padding: 0 10px; + border: 0; + background: transparent; + font-family: inherit; + font-size: 11px; + letter-spacing: 0.04em; + color: var(--muted); + cursor: pointer; +} +.hephChannelBar__option + .hephChannelBar__option { + border-left: 1px solid var(--hair-2); +} +.hephChannelBar__option:hover { + color: var(--ink); +} +.hephChannelBar__option--on { + background: var(--ac-soft); + color: var(--ac); +} +/* Marks which channel a reader gets when they have not picked one. */ +.hephChannelBar__default { + font-size: 9px; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--faint); +} +.hephChannelBar__option--on .hephChannelBar__default { + color: var(--ac); + opacity: 0.7; +} diff --git a/website/src/hooks/useLatestVersion.ts b/website/src/hooks/useLatestVersion.ts index 95d1c13..db63e24 100644 --- a/website/src/hooks/useLatestVersion.ts +++ b/website/src/hooks/useLatestVersion.ts @@ -1,39 +1,78 @@ import { useEffect, useState } from 'react'; +import { + DEFAULT_RELEASE_CHANNEL, + releasesApiUrl, + releasesPageUrl, + type ReleaseChannelId, +} from '../releaseChannels'; -// The latest released heph version, resolved from the GitHub releases API -// (`tag_name` of the latest release). -const RELEASES_API_URL = 'https://api.github.com/repos/hephbuild/heph-artifacts-v1/releases/latest'; const FALLBACK_VERSION = '?.?.?'; -// Human-facing releases page, surfaced when resolution fails so readers can look -// the version up themselves. -export const RELEASES_PAGE_URL = 'https://github.com/hephbuild/heph-artifacts-v1/releases/latest'; +// Resolved versions, kept per channel for the life of the page so flipping the +// channel selector back and forth doesn't re-hit the (rate-limited) API. +const cache = new Map(); + +/** + * Human-facing releases page of the default channel, surfaced when resolution + * fails so readers can look the version up themselves. + */ +export const RELEASES_PAGE_URL = releasesPageUrl(DEFAULT_RELEASE_CHANNEL); export interface LatestVersionState { /** Resolved version, or `null` while still loading or after an error. */ version: string | null; loading: boolean; - /** `true` when resolution failed (offline, rate-limited, etc.). */ + /** `true` when resolution failed (offline, rate-limited, nothing published). */ error: boolean; + /** + * `true` when the channel has no release at all — GitHub answered 404 rather + * than failing. Worth saying out loud: it is a property of the channel, not a + * hiccup the reader should retry. + */ + empty: boolean; } -export function useLatestVersion(): LatestVersionState { - const [version, setVersion] = useState(null); +/** + * The latest released heph version on `channel`, resolved from the GitHub + * releases API (`tag_name` of the latest release, with any leading `v` cut). + */ +export function useLatestVersion( + channel: ReleaseChannelId = DEFAULT_RELEASE_CHANNEL, +): LatestVersionState { + const [version, setVersion] = useState(cache.get(channel) ?? null); const [error, setError] = useState(false); + const [empty, setEmpty] = useState(false); useEffect(() => { + const cached = cache.get(channel); + if (cached) { + setVersion(cached); + setError(false); + setEmpty(false); + return undefined; + } + const controller = new AbortController(); + setVersion(null); + setError(false); + setEmpty(false); (async () => { try { - const res = await fetch(RELEASES_API_URL, { + const res = await fetch(releasesApiUrl(channel), { signal: controller.signal, headers: { Accept: 'application/vnd.github+json' }, }); + if (res.status === 404) { + setEmpty(true); + setError(true); + return; + } if (!res.ok) throw new Error(`GitHub API ${res.status}`); const data: { tag_name?: string } = await res.json(); - const tag = data.tag_name?.replace(/^v/, ''); - setVersion(tag || FALLBACK_VERSION); + const tag = data.tag_name?.replace(/^v/, '') || FALLBACK_VERSION; + cache.set(channel, tag); + setVersion(tag); } catch (err) { if ((err as Error).name === 'AbortError') return; setError(true); @@ -41,7 +80,9 @@ export function useLatestVersion(): LatestVersionState { })(); return () => controller.abort(); - }, []); + }, [channel]); - return { version, loading: version === null && !error, error }; + return { + version, loading: version === null && !error, error, empty, + }; } diff --git a/website/src/hooks/useReleaseChannel.tsx b/website/src/hooks/useReleaseChannel.tsx new file mode 100644 index 0000000..dbaa16a --- /dev/null +++ b/website/src/hooks/useReleaseChannel.tsx @@ -0,0 +1,73 @@ +import { + createContext, useContext, useEffect, useMemo, useState, type ReactNode, +} from 'react'; +import { + DEFAULT_RELEASE_CHANNEL, + isReleaseChannelId, + type ReleaseChannelId, +} from '../releaseChannels'; + +// A reader who picked a channel on one page expects the next page to keep it. +const STORAGE_KEY = 'heph.releaseChannel'; + +interface ReleaseChannelContextValue { + channel: ReleaseChannelId; + setChannel: (channel: ReleaseChannelId) => void; +} + +const ReleaseChannelContext = createContext({ + channel: DEFAULT_RELEASE_CHANNEL, + setChannel: () => {}, +}); + +/** + * Holds the channel the reader is browsing in. Every `.hephconfig` sample on + * the page renders from the same one, so switching the selector on one block + * switches them all. + * + * The initial state is the default channel on both server and client — the + * stored choice is applied in an effect, after hydration, so the markup matches + * what was prerendered. + */ +export function ReleaseChannelProvider({ children }: { children: ReactNode }): ReactNode { + const [channel, setChannel] = useState(DEFAULT_RELEASE_CHANNEL); + + useEffect(() => { + // `?channel=stable` pins the channel for this visit — a shareable link that + // shows a page as another channel reads it. It wins over the stored choice + // and is deliberately not persisted. + const pinned = new URLSearchParams(window.location.search).get('channel'); + if (isReleaseChannelId(pinned)) { + setChannel(pinned); + return; + } + try { + const stored = window.localStorage.getItem(STORAGE_KEY); + if (isReleaseChannelId(stored)) setChannel(stored); + } catch { + // Private mode / storage disabled — the default channel is fine. + } + }, []); + + const value = useMemo(() => ({ + channel, + setChannel: (next) => { + setChannel(next); + try { + window.localStorage.setItem(STORAGE_KEY, next); + } catch { + // Not persisting is survivable; the page still switches. + } + }, + }), [channel]); + + return ( + + {children} + + ); +} + +export function useReleaseChannel(): ReleaseChannelContextValue { + return useContext(ReleaseChannelContext); +} diff --git a/website/src/releaseChannels.ts b/website/src/releaseChannels.ts new file mode 100644 index 0000000..1c50a34 --- /dev/null +++ b/website/src/releaseChannels.ts @@ -0,0 +1,82 @@ +// Release channels — where a heph release (the binary and the plugin manifests +// that ship with it) is published. +// +// Everything the site renders about versions goes through here: the version in +// the nav strip, the `version:` pin in `.hephconfig` samples, and the plugin +// `url:` entries in the docs. Flipping which channel a reader gets by default +// is a one-line change to DEFAULT_RELEASE_CHANNEL below. + +export type ReleaseChannelId = 'nightly' | 'stable'; + +export interface ReleaseChannel { + id: ReleaseChannelId; + /** Short label — what the channel selector shows. */ + label: string; + /** One line, user-facing: what a reader gets by picking this channel. */ + description: string; + /** `owner/name` of the GitHub repository the channel's releases live in. */ + repo: string; +} + +export const RELEASE_CHANNELS: Record = { + nightly: { + id: 'nightly', + label: 'Nightly', + description: 'Cut from every change on main. Newest features, fastest moving.', + repo: 'hephbuild/heph-artifacts-v1', + }, + stable: { + id: 'stable', + label: 'Stable', + description: 'Tagged releases. Fewer, slower, vetted.', + repo: 'hephbuild/heph', + }, +}; + +/** Order the channels are offered in. */ +export const RELEASE_CHANNEL_IDS: ReleaseChannelId[] = ['nightly', 'stable']; + +/** + * The channel a reader gets until they pick another one — every version the + * site shows without an explicit channel comes from here. Change this constant + * (and nothing else) to make another channel the default. + */ +export const DEFAULT_RELEASE_CHANNEL: ReleaseChannelId = 'nightly'; + +export function isReleaseChannelId(value: unknown): value is ReleaseChannelId { + return typeof value === 'string' && value in RELEASE_CHANNELS; +} + +export function releaseChannel(id: ReleaseChannelId): ReleaseChannel { + return RELEASE_CHANNELS[id]; +} + +/** GitHub API endpoint resolving the channel's latest release. */ +export function releasesApiUrl(id: ReleaseChannelId): string { + return `https://api.github.com/repos/${RELEASE_CHANNELS[id].repo}/releases/latest`; +} + +/** Human-facing releases page for the channel. */ +export function releasesPageUrl(id: ReleaseChannelId): string { + return `https://github.com/${RELEASE_CHANNELS[id].repo}/releases/latest`; +} + +/** + * Base URL the assets of the release tagged `tag` hang off — plugin manifests, + * checksums, binaries. + */ +export function releaseAssetsUrlForTag(id: ReleaseChannelId, tag: string): string { + return `https://github.com/${RELEASE_CHANNELS[id].repo}/releases/download/${tag}`; +} + +/** + * Same, for a bare version (no leading `v`) as returned by useLatestVersion. + */ +export function releaseAssetsUrl(id: ReleaseChannelId, version: string): string { + return releaseAssetsUrlForTag(id, `v${encodeURIComponent(version)}`); +} + +/** Same, resolved by GitHub to whatever the channel's latest release is. */ +export function releaseAssetsUrlLatest(id: ReleaseChannelId): string { + return `https://github.com/${RELEASE_CHANNELS[id].repo}/releases/latest/download`; +} diff --git a/website/src/theme/CodeBlock/index.tsx b/website/src/theme/CodeBlock/index.tsx index 3bc9e88..d6f2692 100644 --- a/website/src/theme/CodeBlock/index.tsx +++ b/website/src/theme/CodeBlock/index.tsx @@ -3,78 +3,109 @@ import CodeBlock from '@theme-original/CodeBlock'; import Admonition from '@theme/Admonition'; import type CodeBlockType from '@theme/CodeBlock'; import type { WrapperProps } from '@docusaurus/types'; -import { useLatestVersion, RELEASES_PAGE_URL } from '../../hooks/useLatestVersion'; +import { useLatestVersion } from '../../hooks/useLatestVersion'; +import { useReleaseChannel } from '../../hooks/useReleaseChannel'; +import { ReleaseChannelSelector } from '../../components/ReleaseChannelSelector'; +import { + DEFAULT_RELEASE_CHANNEL, + RELEASE_CHANNELS, + releaseAssetsUrl, + releaseAssetsUrlForTag, + releaseAssetsUrlLatest, + releasesPageUrl, +} from '../../releaseChannels'; type Props = WrapperProps; // Shown in place of the version when resolution fails — the reader swaps it out. const VERSION_PLACEHOLDER = ''; -// Language arrives as the `language` prop (JSX) or a `language-xxx` className -// (markdown code fence). -function resolveLanguage(props: Props): string | undefined { - if (typeof props.language === 'string') return props.language; - if (typeof props.className === 'string') { - const m = props.className.match(/language-(\w+)/); - if (m) return m[1]; - } - return undefined; -} - -// Title can arrive parsed (`title` prop, JSX usage) or raw inside the code-fence -// metastring (`title="..."`, markdown usage). Resolve both. -function resolveTitle(props: Props): string | undefined { - if (typeof props.title === 'string') return props.title; - if (typeof props.metastring === 'string') { - const m = props.metastring.match(/title="([^"]*)"|title='([^']*)'/); - if (m) return m[1] ?? m[2]; - } - return undefined; -} +// Placeholders a doc block can carry. Any of them turns the block into a +// channel-aware one: it gets the channel selector and is rewritten for the +// channel the reader is on. +// +// the bare version -> 1.2.3 +// the same, URL-encoded -> 1.2.3 +// assets base URL -> https://github.com//releases/download/v1.2.3 +// installer env prefix -> "" on the default channel, +// `HEPH_CHANNEL= ` otherwise +const PLACEHOLDERS = //; +const VERSION_PLACEHOLDERS = //; /** - * Wraps the theme `CodeBlock`: for a yaml block titled `.hephconfig`, substitutes - * the latest released heph version into the source — `` becomes the - * raw version and `` its URL-encoded form. While loading it - * falls back to `latest`; if resolution fails it renders an error notice above - * the block and shows `` as a placeholder. Other blocks pass through. + * Wraps the theme `CodeBlock`. A block containing any `` placeholder is + * rendered for the reader's [release channel](/docs/reference/release-channels): + * a selector is drawn above it and the placeholders are substituted with that + * channel's latest version and asset URLs. While loading, the version falls back + * to `latest`; if resolution fails the block renders `` and an error + * notice pointing at the channel's releases page. Other blocks pass through. */ export default function CodeBlockWrapper(props: Props): ReactNode { - const { version, error } = useLatestVersion(); + const { channel } = useReleaseChannel(); + const { version, error, empty } = useLatestVersion(channel); const { children } = props; - if ( - resolveLanguage(props) === 'yaml' - && resolveTitle(props) === '.hephconfig' - && typeof children === 'string' - ) { - const v = error ? VERSION_PLACEHOLDER : (version ?? 'latest'); - const code = children - .replace(//g, v) - .replace(//g, encodeURIComponent(v)); - return ( - <> - {error && ( - -

- Open the - {' '} - - releases page - - {' '} - and replace - {' '} - {VERSION_PLACEHOLDER} - {' '} - below with the latest tag. -

-
- )} - {code} - - ); + if (typeof children !== 'string' || !PLACEHOLDERS.test(children)) { + return ; } - return ; + const v = error ? VERSION_PLACEHOLDER : (version ?? 'latest'); + // Until the version lands, point at GitHub's `latest` alias — a URL that + // actually resolves — rather than at a tag spelled out of a placeholder. + let assetsUrl: string; + if (error) assetsUrl = releaseAssetsUrlForTag(channel, VERSION_PLACEHOLDER); + else if (version === null) assetsUrl = releaseAssetsUrlLatest(channel); + else assetsUrl = releaseAssetsUrl(channel, version); + + const installEnv = channel === DEFAULT_RELEASE_CHANNEL ? '' : `HEPH_CHANNEL=${channel} `; + const code = children + .replace(//g, assetsUrl) + .replace(//g, v) + .replace(//g, encodeURIComponent(v)) + .replace(//g, installEnv); + + return ( +
+ + {error && empty && VERSION_PLACEHOLDERS.test(children) && ( + +

+ The + {' '} + {channel} + {' '} + channel has not published a release. Stay on + {' '} + {DEFAULT_RELEASE_CHANNEL} + {' '} + until it does — the + {' '} + + releases page + + {' '} + is where the first one will show up. +

+
+ )} + {error && !empty && VERSION_PLACEHOLDERS.test(children) && ( + +

+ Open the + {' '} + + releases page + + {' '} + and replace + {' '} + {VERSION_PLACEHOLDER} + {' '} + below with the latest tag. +

+
+ )} + {code} +
+ ); } diff --git a/website/src/theme/Root.tsx b/website/src/theme/Root.tsx index 3325687..9b020e9 100644 --- a/website/src/theme/Root.tsx +++ b/website/src/theme/Root.tsx @@ -1,5 +1,6 @@ import type { ReactNode } from 'react'; import { UIKitProvider } from '@heph/uikit'; +import { ReleaseChannelProvider } from '../hooks/useReleaseChannel'; // Self-hosted IBM Plex (no Google Fonts CDN). Webpack emits the woff2 as // separate cached assets and only the used weights are fetched. Weights mirror @@ -20,8 +21,14 @@ import '@heph/uikit/style.css'; /** * Docusaurus swizzles `Root` around the entire app (both SSR and hydration), * so this is where the uikit's antd theme provider and Blueprint tokens get - * mounted once for every page — landing and docs alike. + * mounted once for every page — landing and docs alike. The release-channel + * provider lives here too, so every version-bearing code block on a page shares + * one channel and one selection. */ export default function Root({ children }: { children: ReactNode }): ReactNode { - return {children}; + return ( + + {children} + + ); } diff --git a/website/static/install.sh b/website/static/install.sh index 3a85ffb..a4cc253 100644 --- a/website/static/install.sh +++ b/website/static/install.sh @@ -7,11 +7,21 @@ # HEPH_BIN_NAME installed binary name (default: heph) # HEPH_BIN_DIR install directory (default: $HOME/.local/bin) # HEPH_VERSION release tag to install (default: latest) +# HEPH_CHANNEL release channel (default: nightly; or stable) # HEPH_NO_MODIFY_PATH=1 skip writing to shell rc files set -eu -REPO="hephbuild/heph-artifacts-v1" +CHANNEL="${HEPH_CHANNEL:-nightly}" +case "$CHANNEL" in + nightly) REPO="hephbuild/heph-artifacts-v1" ;; + stable) REPO="hephbuild/heph" ;; + *) + printf 'error: unknown release channel: %s (want: nightly or stable)\n' "$CHANNEL" >&2 + exit 1 + ;; +esac + BIN_NAME="${HEPH_BIN_NAME:-heph}" BIN_DIR="${HEPH_BIN_DIR:-$HOME/.local/bin}" VERSION="${HEPH_VERSION:-latest}" @@ -75,7 +85,7 @@ fi # ---- install ---------------------------------------------------------------- -info "${BOLD}Installing heph${RESET} (${OS}/${ARCH}, ${VERSION})" +info "${BOLD}Installing heph${RESET} (${OS}/${ARCH}, ${CHANNEL}, ${VERSION})" TMP="$(mktemp "${TMPDIR:-/tmp}/heph.XXXXXX")" trap 'rm -f "$TMP"' EXIT INT TERM From c9456304d0d49f96c7171f988851e0d6b8024ffb Mon Sep 17 00:00:00 2001 From: Raphael Vigee Date: Mon, 31 Aug 2026 12:57:11 +0200 Subject: [PATCH 2/4] refactor(docs): rename the nightly release channel to dev MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same channel, same repository (hephbuild/heph-artifacts-v1) — only the id and label change: `nightly` -> `dev`, `Nightly` -> `Dev`. Covers the channel model, the installer's HEPH_CHANNEL values, the docs, and the plugin skill references. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_017fcBWHbsCSTCSopBkF8W8t --- plugins/heph-expert/skills/heph/references/cli.md | 2 +- .../skills/heph/references/configuration.md | 2 +- .../heph-go/skills/heph-go/references/go-plugin.md | 2 +- website/docs/getting-started.md | 2 +- website/docs/reference/configuration.md | 2 +- website/docs/reference/release-channels.md | 8 ++++---- website/src/releaseChannels.ts | 12 ++++++------ website/static/install.sh | 8 ++++---- 8 files changed, 19 insertions(+), 19 deletions(-) diff --git a/plugins/heph-expert/skills/heph/references/cli.md b/plugins/heph-expert/skills/heph/references/cli.md index 1c50d8f..897ecf6 100644 --- a/plugins/heph-expert/skills/heph/references/cli.md +++ b/plugins/heph-expert/skills/heph/references/cli.md @@ -105,7 +105,7 @@ Print the heph version string and exit. curl -fsSL https://hephbuild.github.io/install.sh | sh ``` -Releases come from a **release channel**: `nightly` (default, from +Releases come from a **release channel**: `dev` (default, from `hephbuild/heph-artifacts-v1`) or `stable` (from `hephbuild/heph`). Pick one with `HEPH_CHANNEL`, and a tag within it with `HEPH_VERSION`: diff --git a/plugins/heph-expert/skills/heph/references/configuration.md b/plugins/heph-expert/skills/heph/references/configuration.md index ef26d23..6f9c666 100644 --- a/plugins/heph-expert/skills/heph/references/configuration.md +++ b/plugins/heph-expert/skills/heph/references/configuration.md @@ -15,7 +15,7 @@ plugins: | Key | Type | Default | Description | |---|---|---|---| -| `version` | semver string | — | Pins the heph release that runs this workspace, so every machine/CI job resolves the same toolchain. Set it once at the top. The tag names a release in a channel: `nightly` (default, `hephbuild/heph-artifacts-v1`) or `stable` (`hephbuild/heph`). | +| `version` | semver string | — | Pins the heph release that runs this workspace, so every machine/CI job resolves the same toolchain. Set it once at the top. The tag names a release in a channel: `dev` (default, `hephbuild/heph-artifacts-v1`) or `stable` (`hephbuild/heph`). | | `versionFlavour` | string | `""` (std) | Which release flavour self-upgrade downloads: `""` for the default stripped "std" build, or `debug` for the unstripped build (symbolicated backtraces). | | `plugins` | list of plugin entries | `[]` | Plugins to register. Each entry sets exactly one of `builtin`, `path`, or `url`, plus an optional `options` map. | | `homeDir` | path | unset | Where heph keeps its home and cache. | diff --git a/plugins/heph-go/skills/heph-go/references/go-plugin.md b/plugins/heph-go/skills/heph-go/references/go-plugin.md index 539ef6b..b30eafe 100644 --- a/plugins/heph-go/skills/heph-go/references/go-plugin.md +++ b/plugins/heph-go/skills/heph-go/references/go-plugin.md @@ -29,7 +29,7 @@ You should not interact with these drivers directly; they are internal plumbing. The Go plugin is an **external plugin** (not compiled into the heph binary). A single `plugins:` entry loads the provider and all four drivers: -The URL points at a release in a channel — `nightly` +The URL points at a release in a channel — `dev` (`hephbuild/heph-artifacts-v1`, the default) or `stable` (`hephbuild/heph`). Keep it on the same channel as the `version:` pin. diff --git a/website/docs/getting-started.md b/website/docs/getting-started.md index 27b8920..d8d3943 100644 --- a/website/docs/getting-started.md +++ b/website/docs/getting-started.md @@ -20,7 +20,7 @@ version: ``` The version above comes from a [release channel](/docs/reference/release-channels) -— `nightly` by default. Switch the selector on any code block to read the page +— `dev` by default. Switch the selector on any code block to read the page for the other channel. From here, enable the [plugins](/docs/plugins) that you require and get building! diff --git a/website/docs/reference/configuration.md b/website/docs/reference/configuration.md index fdc43e0..867e736 100644 --- a/website/docs/reference/configuration.md +++ b/website/docs/reference/configuration.md @@ -25,7 +25,7 @@ version: v1.2.3 `version` pins the heph release for this workspace so every machine and CI job runs the same binary. The tag names a release in a -[release channel](/docs/reference/release-channels) — `nightly` by default, +[release channel](/docs/reference/release-channels) — `dev` by default, `stable` for slower-moving pins. When the running binary differs from the pin, heph automatically downloads the pinned release and re-execs into it on startup — the rest of the run is served by the pinned version. The downloaded binary is diff --git a/website/docs/reference/release-channels.md b/website/docs/reference/release-channels.md index 7a3a8a6..47171ac 100644 --- a/website/docs/reference/release-channels.md +++ b/website/docs/reference/release-channels.md @@ -1,7 +1,7 @@ --- title: "Release channels" sidebar_position: 4 -description: Where heph releases come from — nightly and stable — and how to pick one. +description: Where heph releases come from — dev and stable — and how to pick one. --- # Release channels @@ -13,11 +13,11 @@ differ. | Channel | Cadence | Releases | |-----------|---------|----------| -| `nightly` | Cut from every change on main. Newest features, fastest moving. **Default.** | [hephbuild/heph-artifacts-v1](https://github.com/hephbuild/heph-artifacts-v1/releases/latest) | +| `dev` | Cut from every change on main. Newest features, fastest moving. **Default.** | [hephbuild/heph-artifacts-v1](https://github.com/hephbuild/heph-artifacts-v1/releases/latest) | | `stable` | Tagged releases. Fewer, slower, vetted. | [hephbuild/heph](https://github.com/hephbuild/heph/releases/latest) | :::note -`nightly` is the default everywhere today — the installer, and every version and +`dev` is the default everywhere today — the installer, and every version and URL these docs show. Pick `stable` when you want a slower-moving pin. ::: @@ -42,7 +42,7 @@ The installer takes the channel in `HEPH_CHANNEL`: HEPH_CHANNEL=stable curl -fsSL https://hephbuild.github.io/install.sh | sh ``` -Omit it for `nightly`. `HEPH_VERSION` pins a tag within the channel: +Omit it for `dev`. `HEPH_VERSION` pins a tag within the channel: ```bash title="terminal" HEPH_CHANNEL=stable HEPH_VERSION=v1.2.3 curl -fsSL https://hephbuild.github.io/install.sh | sh diff --git a/website/src/releaseChannels.ts b/website/src/releaseChannels.ts index 1c50a34..717136d 100644 --- a/website/src/releaseChannels.ts +++ b/website/src/releaseChannels.ts @@ -6,7 +6,7 @@ // `url:` entries in the docs. Flipping which channel a reader gets by default // is a one-line change to DEFAULT_RELEASE_CHANNEL below. -export type ReleaseChannelId = 'nightly' | 'stable'; +export type ReleaseChannelId = 'dev' | 'stable'; export interface ReleaseChannel { id: ReleaseChannelId; @@ -19,9 +19,9 @@ export interface ReleaseChannel { } export const RELEASE_CHANNELS: Record = { - nightly: { - id: 'nightly', - label: 'Nightly', + dev: { + id: 'dev', + label: 'Dev', description: 'Cut from every change on main. Newest features, fastest moving.', repo: 'hephbuild/heph-artifacts-v1', }, @@ -34,14 +34,14 @@ export const RELEASE_CHANNELS: Record = { }; /** Order the channels are offered in. */ -export const RELEASE_CHANNEL_IDS: ReleaseChannelId[] = ['nightly', 'stable']; +export const RELEASE_CHANNEL_IDS: ReleaseChannelId[] = ['dev', 'stable']; /** * The channel a reader gets until they pick another one — every version the * site shows without an explicit channel comes from here. Change this constant * (and nothing else) to make another channel the default. */ -export const DEFAULT_RELEASE_CHANNEL: ReleaseChannelId = 'nightly'; +export const DEFAULT_RELEASE_CHANNEL: ReleaseChannelId = 'dev'; export function isReleaseChannelId(value: unknown): value is ReleaseChannelId { return typeof value === 'string' && value in RELEASE_CHANNELS; diff --git a/website/static/install.sh b/website/static/install.sh index a4cc253..c182f7c 100644 --- a/website/static/install.sh +++ b/website/static/install.sh @@ -7,17 +7,17 @@ # HEPH_BIN_NAME installed binary name (default: heph) # HEPH_BIN_DIR install directory (default: $HOME/.local/bin) # HEPH_VERSION release tag to install (default: latest) -# HEPH_CHANNEL release channel (default: nightly; or stable) +# HEPH_CHANNEL release channel (default: dev; or stable) # HEPH_NO_MODIFY_PATH=1 skip writing to shell rc files set -eu -CHANNEL="${HEPH_CHANNEL:-nightly}" +CHANNEL="${HEPH_CHANNEL:-dev}" case "$CHANNEL" in - nightly) REPO="hephbuild/heph-artifacts-v1" ;; + dev) REPO="hephbuild/heph-artifacts-v1" ;; stable) REPO="hephbuild/heph" ;; *) - printf 'error: unknown release channel: %s (want: nightly or stable)\n' "$CHANNEL" >&2 + printf 'error: unknown release channel: %s (want: dev or stable)\n' "$CHANNEL" >&2 exit 1 ;; esac From 7881575d186f9b6b2dbf47faaa85d234b8dab1da Mon Sep 17 00:00:00 2001 From: Raphael Vigee Date: Mon, 31 Aug 2026 13:19:55 +0200 Subject: [PATCH 3/4] fix(docs): resolve each release channel once, not once per component MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The version lookup hit the GitHub API from every component that asked for it, and remembered only successes. A docs page with three channel-aware code blocks therefore fired one request per block, and flipping the channel selector fired another round every time — measured against the dev server, 39 requests on load and 429 after twenty flips. Unauthenticated the API allows 60 per hour per IP, so a reader could exhaust their quota on a single page and get 403s where the version belongs. Lookups are now shared per channel: callers that arrive while a request is in the air join it instead of starting a second one, a resolved version and an empty channel are remembered for the life of the page, and a failure is remembered for a minute so a flurry of clicks cannot retry it sixty times. The same page is now 1 request on load and 2 after twenty flips — one per channel, ever. Switching channel mid-flight drops the pending answer rather than aborting it: the request is shared, and its result is still worth caching for whoever asks next. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_017fcBWHbsCSTCSopBkF8W8t --- website/src/hooks/useLatestVersion.ts | 137 ++++++++++++++++++-------- 1 file changed, 95 insertions(+), 42 deletions(-) diff --git a/website/src/hooks/useLatestVersion.ts b/website/src/hooks/useLatestVersion.ts index db63e24..76e647c 100644 --- a/website/src/hooks/useLatestVersion.ts +++ b/website/src/hooks/useLatestVersion.ts @@ -8,9 +8,13 @@ import { const FALLBACK_VERSION = '?.?.?'; -// Resolved versions, kept per channel for the life of the page so flipping the -// channel selector back and forth doesn't re-hit the (rate-limited) API. -const cache = new Map(); +/** + * How long a failed lookup is remembered before another one is allowed. The + * GitHub API gives 60 unauthenticated requests an hour per IP, so retrying a + * failure on every channel flip is the fastest way to stay broken; a settled + * success and an empty channel are remembered for the life of the page. + */ +const RETRY_AFTER_MS = 60_000; /** * Human-facing releases page of the default channel, surfaced when resolution @@ -32,6 +36,79 @@ export interface LatestVersionState { empty: boolean; } +const PENDING: LatestVersionState = { + version: null, loading: true, error: false, empty: false, +}; + +// Settled lookups, and the ones still in the air. Both are keyed by channel and +// module-scoped, so every component on the page — the nav strip and each code +// block — shares one request per channel, and flipping the channel selector +// back and forth replays what is already known instead of asking again. +const settled = new Map(); +const inFlight = new Map>(); + +/** The remembered result for `channel`, unless it was a failure that has aged out. */ +function peek(channel: ReleaseChannelId): LatestVersionState | undefined { + const hit = settled.get(channel); + if (!hit) return undefined; + if (hit.until !== Infinity && hit.until < Date.now()) { + settled.delete(channel); + return undefined; + } + return hit.state; +} + +/** Resolves a channel, never rejecting — failure is a state, not an exception. */ +async function fetchChannel(channel: ReleaseChannelId): Promise { + try { + const res = await fetch(releasesApiUrl(channel), { + headers: { Accept: 'application/vnd.github+json' }, + }); + if (res.status === 404) { + return { + version: null, loading: false, error: true, empty: true, + }; + } + if (!res.ok) throw new Error(`GitHub API ${res.status}`); + const data: { tag_name?: string } = await res.json(); + return { + version: data.tag_name?.replace(/^v/, '') || FALLBACK_VERSION, + loading: false, + error: false, + empty: false, + }; + } catch { + return { + version: null, loading: false, error: true, empty: false, + }; + } +} + +/** + * One lookup per channel. Callers that arrive while a request is in the air + * join it rather than starting a second one — deliberately not abortable, since + * the component that started it is not the only one waiting on it. + */ +function resolveChannel(channel: ReleaseChannelId): Promise { + const known = peek(channel); + if (known) return Promise.resolve(known); + + const pending = inFlight.get(channel); + if (pending) return pending; + + const request = fetchChannel(channel).then((state) => { + // A version and an empty channel are facts; a failure is worth retrying, + // but not before the reader has stopped clicking. + const until = state.error && !state.empty ? Date.now() + RETRY_AFTER_MS : Infinity; + settled.set(channel, { state, until }); + inFlight.delete(channel); + return state; + }); + + inFlight.set(channel, request); + return request; +} + /** * The latest released heph version on `channel`, resolved from the GitHub * releases API (`tag_name` of the latest release, with any leading `v` cut). @@ -39,50 +116,26 @@ export interface LatestVersionState { export function useLatestVersion( channel: ReleaseChannelId = DEFAULT_RELEASE_CHANNEL, ): LatestVersionState { - const [version, setVersion] = useState(cache.get(channel) ?? null); - const [error, setError] = useState(false); - const [empty, setEmpty] = useState(false); + const [state, setState] = useState(() => peek(channel) ?? PENDING); useEffect(() => { - const cached = cache.get(channel); - if (cached) { - setVersion(cached); - setError(false); - setEmpty(false); + const known = peek(channel); + if (known) { + setState(known); return undefined; } - const controller = new AbortController(); - setVersion(null); - setError(false); - setEmpty(false); - - (async () => { - try { - const res = await fetch(releasesApiUrl(channel), { - signal: controller.signal, - headers: { Accept: 'application/vnd.github+json' }, - }); - if (res.status === 404) { - setEmpty(true); - setError(true); - return; - } - if (!res.ok) throw new Error(`GitHub API ${res.status}`); - const data: { tag_name?: string } = await res.json(); - const tag = data.tag_name?.replace(/^v/, '') || FALLBACK_VERSION; - cache.set(channel, tag); - setVersion(tag); - } catch (err) { - if ((err as Error).name === 'AbortError') return; - setError(true); - } - })(); - - return () => controller.abort(); + // Switching away mid-flight drops the answer on the floor rather than + // cancelling it: the request is shared, and its result is still worth + // caching for whoever asks next. + let live = true; + setState(PENDING); + resolveChannel(channel).then((next) => { + if (live) setState(next); + }); + + return () => { live = false; }; }, [channel]); - return { - version, loading: version === null && !error, error, empty, - }; + return state; } From 238a0ddc5d727f95bc0918ba1aa55d74917890a7 Mon Sep 17 00:00:00 2001 From: Raphael Vigee Date: Mon, 31 Aug 2026 13:25:45 +0200 Subject: [PATCH 4/4] fix(docs): log why a channel version failed to resolve MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fetchChannel deliberately never rejects — the promise is shared by every component waiting on that channel and is cached once it settles, so throwing would poison the entry for all of them. Discarding the reason along with the exception was not deliberate: the usual cause is a 403 from the API's 60-requests-per-hour-per-IP limit, which is invisible from the page and had to be read out of response headers by hand. The failure is now reported once per channel, with the rate-limit case named explicitly instead of surfacing as a bare status code. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_017fcBWHbsCSTCSopBkF8W8t --- website/src/hooks/useLatestVersion.ts | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/website/src/hooks/useLatestVersion.ts b/website/src/hooks/useLatestVersion.ts index 76e647c..b9a4d62 100644 --- a/website/src/hooks/useLatestVersion.ts +++ b/website/src/hooks/useLatestVersion.ts @@ -58,7 +58,14 @@ function peek(channel: ReleaseChannelId): LatestVersionState | undefined { return hit.state; } -/** Resolves a channel, never rejecting — failure is a state, not an exception. */ +/** + * Resolves a channel, never rejecting — failure is a state, not an exception: + * the promise is shared by every waiting component and cached once it settles, + * so throwing here would poison the entry for all of them. The reason is not + * swallowed with it; a version that will not resolve is worth one line in the + * console, since the usual cause (`403`, the 60-per-hour unauthenticated limit + * the API applies per IP) is invisible from the page otherwise. + */ async function fetchChannel(channel: ReleaseChannelId): Promise { try { const res = await fetch(releasesApiUrl(channel), { @@ -69,7 +76,12 @@ async function fetchChannel(channel: ReleaseChannelId): Promise