feat(#815): add /gsd-update --next to install the @next RC channel (#839)

Adds an opt-in --next (alias --rc) flag to /gsd-update targeting the @next RC dist-tag (ADR #660), with a {latest,next} allowlist enforced at three layers, channel-aware version check + banner, and byte-for-byte unchanged default @latest behavior.

Closes #815
This commit is contained in:
Tom Boucher
2026-06-07 20:22:07 -04:00
committed by GitHub
parent 5b4880522b
commit 40d48c0508
12 changed files with 249 additions and 20 deletions

View File

@@ -37,18 +37,64 @@ const CHECK_REASON = Object.freeze({
const SEMVER_RE = /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/;
// #815: the one RC channel ADR #660 sanctions, plus the stable default.
// An allowlist (not a free string) keeps a typo from silently resolving
// `npm view` to an empty or foreign dist-tag.
const ALLOWED_TAGS = Object.freeze(['latest', 'next']);
/**
* Build the `npm view` args for a dist-tag. `latest` keeps the bare package
* spec so the default invocation is byte-for-byte identical to before tag
* support existed (#815); any other allowlisted tag appends `@<tag>` so
* `npm view @opengsd/gsd-core@next version` resolves the RC channel (#660).
*/
function buildViewArgs(tag = 'latest') {
if (!ALLOWED_TAGS.includes(tag)) {
throw new RangeError(`invalid dist-tag '${tag}'; allowed: ${ALLOWED_TAGS.join(', ')}`);
}
const spec = tag === 'latest' ? PACKAGE_NAME : `${PACKAGE_NAME}@${tag}`;
return ['view', spec, 'version'];
}
/**
* Resolve the requested dist-tag from argv. Defaults to `latest` (no flag =>
* no behavior change). Restricted to ALLOWED_TAGS so a typo can't silently
* resolve to an empty/foreign tag (#815 alternative 1).
*/
function resolveTag(argv) {
let val;
const eq = argv.find((a) => typeof a === 'string' && a.startsWith('--tag='));
if (eq !== undefined) {
val = eq.slice('--tag='.length);
} else {
const i = argv.indexOf('--tag');
if (i === -1) return 'latest';
val = argv[i + 1];
}
if (!val || !ALLOWED_TAGS.includes(val)) {
throw new RangeError(
`invalid --tag '${val || ''}'; allowed: ${ALLOWED_TAGS.join(', ')}`,
);
}
return val;
}
/**
* Pure-ish: takes an injected spawn function so tests don't actually run npm.
* In production, defaults to execNpm() from the shell-projection seam.
*/
function checkLatestVersion(opts = {}) {
const tag = opts.tag || 'latest';
if (!ALLOWED_TAGS.includes(tag)) {
throw new RangeError(`invalid dist-tag '${tag}'; allowed: ${ALLOWED_TAGS.join(', ')}`);
}
// Default path routes through the shell-projection seam (execNpm owns the
// Windows shell-flag policy and timeout default). The injection point
// remains spawnSync-shaped for test compatibility — the adapter below
// translates { exitCode } → { status } so the consumer logic is unchanged.
// Bounded at 15s so a hung registry doesn't block /gsd-update (#2993 CR).
const defaultSpawn = () => {
const r = execNpm(['view', PACKAGE_NAME, 'version'], { timeout: 15_000 });
const r = execNpm(buildViewArgs(tag), { timeout: 15_000 });
return {
status: r.exitCode,
stdout: r.stdout,
@@ -90,8 +136,16 @@ function checkLatestVersion(opts = {}) {
}
function main() {
const json = process.argv.includes('--json');
const r = checkLatestVersion();
const argv = process.argv.slice(2);
const json = argv.includes('--json');
let tag;
try {
tag = resolveTag(argv);
} catch (e) {
process.stderr.write(`check-latest-version: ${e.message}\n`);
return 2;
}
const r = checkLatestVersion({ tag });
if (json) {
process.stdout.write(JSON.stringify(r) + '\n');
} else if (r.ok) {
@@ -104,4 +158,4 @@ function main() {
if (require.main === module) runMain(main);
module.exports = { checkLatestVersion, CHECK_REASON, PACKAGE_NAME };
module.exports = { checkLatestVersion, CHECK_REASON, PACKAGE_NAME, ALLOWED_TAGS, buildViewArgs, resolveTag };

View File

@@ -550,11 +550,12 @@ Usage: `/gsd:help --full`
Usage: `/gsd:help debug`
Usage: `/gsd:help --brief debug`
**`/gsd:update [--sync] [--reapply]`**
**`/gsd:update [--sync] [--reapply] [--next | --rc]`**
Update GSD to latest version with changelog preview.
- `--sync` — sync managed GSD skills across runtime roots (replaces the former `gsd-sync-skills`)
- `--reapply` — reapply local modifications after an update (replaces the former `gsd-reapply-patches`)
- `--next` (alias `--rc`) — install/refresh from the `@next` RC dist-tag instead of `@latest` (ADR #660); omit for the stable channel
- Shows installed vs latest version comparison
- Displays changelog entries for versions you've missed

View File

@@ -75,6 +75,25 @@ If multiple runtime installs are detected and the invoking runtime cannot be det
**If VERSION file missing (version resolves to `0.0.0`):** report the installed version as Unknown and proceed to install (treated as `0.0.0` for comparison).
</step>
<step name="parse_update_channel">
Determine the release channel from `$ARGUMENTS`. This selects which npm dist-tag the entire update flow targets — `latest` (stable) by default, or `next` (the RC channel established by ADR #660) when the user opts in with `--next`/`--rc`:
```bash
case " $ARGUMENTS " in
*" --next "*|*" --rc "*)
TAG="next"
CHANNEL_LABEL="next (RC)"
;;
*)
TAG="latest"
CHANNEL_LABEL="latest (stable)"
;;
esac
```
`TAG` is restricted to `latest`/`next` by `check-latest-version.cjs` (it rejects any other value with exit 2), so no arbitrary dist-tag can leak through. Omitting `--next`/`--rc` reproduces the prior behavior exactly: `TAG=latest`.
</step>
<step name="check_latest_version">
Check npm for latest version via the deterministic script. **Do NOT run `npm view` or `npm search` directly** — the package name must come from the script, not from a free choice at execution time. (#2992: LLM-driven prescriptions of npm package names produced wrong-package queries; moving the package name into a script constant closes that gap.)
@@ -91,7 +110,7 @@ if [ -z "$GSD_DIR" ]; then
LATEST_VERSION=""
LATEST_REASON="no_install_detected"
else
LATEST_RESULT="$(node "$GSD_DIR/gsd-core/bin/check-latest-version.cjs" --json 2>/dev/null)"
LATEST_RESULT="$(node "$GSD_DIR/gsd-core/bin/check-latest-version.cjs" --json --tag "$TAG" 2>/dev/null)"
LATEST_STATUS=$?
# #2993 CR: when node is missing or the script doesn't exist, LATEST_RESULT
# is empty and piping it to `jq` produces a parse error on stderr while
@@ -114,7 +133,7 @@ fi
```text
Couldn't check for updates (reason: {LATEST_REASON}, exit: {LATEST_STATUS}).
To update manually: `npx -y --package=@opengsd/gsd-core@latest -- gsd-core --global`
To update manually: `npx -y --package=@opengsd/gsd-core@{TAG} -- gsd-core --global`
```
Exit.
@@ -123,6 +142,14 @@ Exit.
<step name="compare_versions">
Compare installed vs latest:
**Only when `TAG=next`** (the user passed `--next`/`--rc`), prepend a channel banner so they know they are leaving the stable line — add this line immediately after the `**Latest:**` line in whichever output block renders:
**Channel:** {CHANNEL_LABEL}
On the default stable channel (`TAG=latest`), do NOT add a channel line — the output must match the prior stable behavior exactly.
When `TAG=next`, the "latest" value is the release candidate published under `@next` (e.g. `1.4.0-rc.1`). Apply standard semver precedence for prereleases (`1.4.0-rc.1` is newer than `1.3.1` but older than the final `1.4.0`). Do NOT treat an `-rc.N` suffix as a dev install or as "behind" — offer it as an available update.
**If installed == latest:**
```
## GSD Update
@@ -327,17 +354,17 @@ RUNTIME_FLAG="--$TARGET_RUNTIME"
**If LOCAL install:**
```bash
npx -y --package=@opengsd/gsd-core@latest -- gsd-core "$RUNTIME_FLAG" --local
npx -y --package=@opengsd/gsd-core@"$TAG" -- gsd-core "$RUNTIME_FLAG" --local
```
**If GLOBAL install:**
```bash
npx -y --package=@opengsd/gsd-core@latest -- gsd-core "$RUNTIME_FLAG" --global
npx -y --package=@opengsd/gsd-core@"$TAG" -- gsd-core "$RUNTIME_FLAG" --global
```
**If UNKNOWN install:**
```bash
npx -y --package=@opengsd/gsd-core@latest -- gsd-core --claude --global
npx -y --package=@opengsd/gsd-core@"$TAG" -- gsd-core --claude --global
```
Capture output. If install fails, show error and exit.