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..897ecf6 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**: `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`: + +```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..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. | +| `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/.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..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,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 — `dev` +(`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..d8d3943 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) +— `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! 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..867e736 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) — `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 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..47171ac --- /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 — dev 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 | +|-----------|---------|----------| +| `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 +`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. +::: + +## 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 `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 +``` + +## 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..b9a4d62 100644 --- a/website/src/hooks/useLatestVersion.ts +++ b/website/src/hooks/useLatestVersion.ts @@ -1,47 +1,155 @@ 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'; +/** + * 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 + * 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; +} + +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: + * 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), { + headers: { Accept: 'application/vnd.github+json' }, + }); + if (res.status === 404) { + return { + version: null, loading: false, error: true, empty: true, + }; + } + if (!res.ok) { + const limited = res.status === 403 && res.headers.get('x-ratelimit-remaining') === '0'; + throw new Error(limited + ? `GitHub API rate limit reached (${res.headers.get('x-ratelimit-limit') ?? '60'} requests/hour per IP)` + : `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 (err) { + // eslint-disable-next-line no-console + console.warn(`[release-channels] ${channel}: ${(err as Error).message}`); + return { + version: null, loading: false, error: true, empty: false, + }; + } } -export function useLatestVersion(): LatestVersionState { - const [version, setVersion] = useState(null); - const [error, setError] = useState(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). + */ +export function useLatestVersion( + channel: ReleaseChannelId = DEFAULT_RELEASE_CHANNEL, +): LatestVersionState { + const [state, setState] = useState(() => peek(channel) ?? PENDING); useEffect(() => { - const controller = new AbortController(); - - (async () => { - try { - const res = await fetch(RELEASES_API_URL, { - signal: controller.signal, - headers: { Accept: 'application/vnd.github+json' }, - }); - 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); - } catch (err) { - if ((err as Error).name === 'AbortError') return; - setError(true); - } - })(); - - return () => controller.abort(); - }, []); - - return { version, loading: version === null && !error, error }; + const known = peek(channel); + if (known) { + setState(known); + return undefined; + } + + // 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 state; } 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..717136d --- /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 = 'dev' | '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 = { + dev: { + id: 'dev', + label: 'Dev', + 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[] = ['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 = 'dev'; + +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..c182f7c 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: dev; or stable) # HEPH_NO_MODIFY_PATH=1 skip writing to shell rc files set -eu -REPO="hephbuild/heph-artifacts-v1" +CHANNEL="${HEPH_CHANNEL:-dev}" +case "$CHANNEL" in + dev) REPO="hephbuild/heph-artifacts-v1" ;; + stable) REPO="hephbuild/heph" ;; + *) + printf 'error: unknown release channel: %s (want: dev 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