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:
5
.changeset/update-next-rc-channel.md
Normal file
5
.changeset/update-next-rc-channel.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
type: Added
|
||||||
|
pr: 839
|
||||||
|
---
|
||||||
|
Added `/gsd-update --next` (alias `--rc`) to install or refresh from the `@next` RC dist-tag (ADR #660). A new `parse_update_channel` workflow step resolves the channel from `$ARGUMENTS`; the version check and all three npx install invocations thread `$TAG` instead of hardcoding `@latest`. When `--next` is used the version-comparison output gains a `Channel: next (RC)` banner so the user knows they are leaving the stable line; omitting the flag keeps `@latest` behavior byte-for-byte unchanged. `check-latest-version.cjs` gains `ALLOWED_TAGS`, `buildViewArgs`, and `resolveTag` exports, with an allowlist guard (enforced at both the CLI and function boundary) that rejects any dist-tag other than `latest`/`next`. (#815)
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
name: gsd:update
|
name: gsd:update
|
||||||
description: Update GSD to latest version with changelog display
|
description: Update GSD to latest version with changelog display
|
||||||
argument-hint: "[--sync | --reapply]"
|
argument-hint: "[--sync | --reapply | --next | --rc]"
|
||||||
allowed-tools:
|
allowed-tools:
|
||||||
- Read
|
- Read
|
||||||
- Write
|
- Write
|
||||||
@@ -31,6 +31,7 @@ Routes to the update workflow which handles:
|
|||||||
<flags>
|
<flags>
|
||||||
- **--sync**: Sync managed GSD skills across runtime roots so multi-runtime users stay aligned after an update. Runs the sync-skills workflow (--from, --to, --dry-run, --apply flags supported).
|
- **--sync**: Sync managed GSD skills across runtime roots so multi-runtime users stay aligned after an update. Runs the sync-skills workflow (--from, --to, --dry-run, --apply flags supported).
|
||||||
- **--reapply**: Reapply local modifications after a GSD update. Uses three-way comparison (pristine baseline, user-modified backup, newly installed version) to merge user customizations back. Runs the reapply-patches workflow.
|
- **--reapply**: Reapply local modifications after a GSD update. Uses three-way comparison (pristine baseline, user-modified backup, newly installed version) to merge user customizations back. Runs the reapply-patches workflow.
|
||||||
|
- **--next** (alias **--rc**): Target the `@next` RC dist-tag instead of `@latest` so you can install or refresh a release candidate (e.g. `1.4.0-rc.1`) through the normal update flow — scope/runtime detection, changelog preview, custom-file backup, and cache clearing all still apply. Omitting it keeps targeting `@latest` (no change). See ADR #660 for the RC channel.
|
||||||
- **(no flag)**: Standard update — check for new version, show changelog, install.
|
- **(no flag)**: Standard update — check for new version, show changelog, install.
|
||||||
</flags>
|
</flags>
|
||||||
|
|
||||||
@@ -38,7 +39,7 @@ Routes to the update workflow which handles:
|
|||||||
Parse the first token of $ARGUMENTS:
|
Parse the first token of $ARGUMENTS:
|
||||||
- If it is `--sync`: strip the flag, execute the sync-skills workflow (passing remaining args for --from/--to/--dry-run/--apply).
|
- If it is `--sync`: strip the flag, execute the sync-skills workflow (passing remaining args for --from/--to/--dry-run/--apply).
|
||||||
- If it is `--reapply`: strip the flag, execute the reapply-patches workflow.
|
- If it is `--reapply`: strip the flag, execute the reapply-patches workflow.
|
||||||
- Otherwise: execute the update workflow end-to-end.
|
- Otherwise (including `--next` / `--rc`): execute the update workflow end-to-end, passing `$ARGUMENTS` through so the workflow's parse_update_channel step can select the release channel.
|
||||||
|
|
||||||
</process>
|
</process>
|
||||||
|
|
||||||
|
|||||||
@@ -1148,11 +1148,13 @@ Update GSD with changelog preview, and optionally sync skills or reapply local p
|
|||||||
|------|-------------|
|
|------|-------------|
|
||||||
| `--sync` | Sync skills from the GSD registry after updating |
|
| `--sync` | Sync skills from the GSD registry after updating |
|
||||||
| `--reapply` | Restore local modifications (patches) after updating |
|
| `--reapply` | Restore local modifications (patches) after updating |
|
||||||
|
| `--next` / `--rc` | Target the `@next` RC dist-tag instead of `@latest` (installs or refreshes a release candidate, e.g. `1.4.0-rc.1`; see ADR #660) |
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
/gsd-update # Check for updates and install
|
/gsd-update # Check for updates and install
|
||||||
/gsd-update --sync # Update and sync skills
|
/gsd-update --sync # Update and sync skills
|
||||||
/gsd-update --reapply # Update and reapply local patches
|
/gsd-update --reapply # Update and reapply local patches
|
||||||
|
/gsd-update --next # Install from the @next RC dist-tag
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -904,6 +904,7 @@ continues. Drift detection cannot fail verification.
|
|||||||
- REQ-UPDATE-03: System MUST be runtime-aware and target the correct directory
|
- REQ-UPDATE-03: System MUST be runtime-aware and target the correct directory
|
||||||
- REQ-UPDATE-04: System MUST back up locally modified files to `gsd-local-patches/`
|
- REQ-UPDATE-04: System MUST back up locally modified files to `gsd-local-patches/`
|
||||||
- REQ-UPDATE-05: `/gsd-update --reapply` MUST restore local modifications after update
|
- REQ-UPDATE-05: `/gsd-update --reapply` MUST restore local modifications after update
|
||||||
|
- REQ-UPDATE-06: `/gsd-update --next` (alias `--rc`) MUST target the `@next` RC dist-tag for version check and install; omitting the flag MUST keep `@latest` behavior unchanged (ADR #660)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -830,6 +830,18 @@ Set `commit_docs: false` during `/gsd-new-project` or via `/gsd-settings`. Add `
|
|||||||
|
|
||||||
Since v1.17, the installer backs up locally modified files to `gsd-local-patches/`. Run `/gsd-update --reapply` to merge your changes back.
|
Since v1.17, the installer backs up locally modified files to `gsd-local-patches/`. Run `/gsd-update --reapply` to merge your changes back.
|
||||||
|
|
||||||
|
### Install or Refresh a Release Candidate
|
||||||
|
|
||||||
|
To install or refresh GSD from the `@next` RC dist-tag (the pre-release channel established by ADR #660), run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
/gsd-update --next
|
||||||
|
# or equivalently:
|
||||||
|
/gsd-update --rc
|
||||||
|
```
|
||||||
|
|
||||||
|
The same scope/runtime detection, changelog preview, custom-file backup, and cache clearing apply. Omitting `--next`/`--rc` keeps targeting `@latest` (stable channel, no change). Only the `@latest` and `@next` channels are supported — no arbitrary dist-tag can be passed.
|
||||||
|
|
||||||
### Cannot Update via npm
|
### Cannot Update via npm
|
||||||
|
|
||||||
See [docs/manual-update.md](manual-update.md) for a step-by-step manual update procedure.
|
See [docs/manual-update.md](manual-update.md) for a step-by-step manual update procedure.
|
||||||
|
|||||||
@@ -35,14 +35,34 @@ Restart your runtime after the update to pick up new commands and agents.
|
|||||||
|------|--------------|
|
|------|--------------|
|
||||||
| `--sync` | After updating, sync skills from the GSD registry |
|
| `--sync` | After updating, sync skills from the GSD registry |
|
||||||
| `--reapply` | After updating, merge locally modified GSD files back in from `gsd-local-patches/` |
|
| `--reapply` | After updating, merge locally modified GSD files back in from `gsd-local-patches/` |
|
||||||
|
| `--next` / `--rc` | Target the `@next` RC dist-tag instead of `@latest` (installs or refreshes a release candidate; see ADR #660) |
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
/gsd-update --sync # Update and sync skills
|
/gsd-update --sync # Update and sync skills
|
||||||
/gsd-update --reapply # Update and reapply local patches
|
/gsd-update --reapply # Update and reapply local patches
|
||||||
|
/gsd-update --next # Install from the @next RC dist-tag
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Install or refresh a release candidate
|
||||||
|
|
||||||
|
GSD publishes release candidates on the `@next` npm dist-tag (established by ADR #660). To install or refresh from that channel:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
/gsd-update --next
|
||||||
|
# or equivalently:
|
||||||
|
/gsd-update --rc
|
||||||
|
```
|
||||||
|
|
||||||
|
The full update flow applies — scope/runtime detection, changelog preview, custom-file backup, and cache clearing all run normally. The only difference is that `check-latest-version.cjs` resolves the `@next` tag and npx installs from `@opengsd/gsd-core@next`.
|
||||||
|
|
||||||
|
Only `latest` and `next` are supported channels; no arbitrary dist-tag can be passed (the script enforces an allowlist and exits with code 2 on an invalid tag).
|
||||||
|
|
||||||
|
Omitting `--next`/`--rc` keeps targeting `@latest` (stable channel, no change in behavior).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Reviewing the changelog before updating
|
## Reviewing the changelog before updating
|
||||||
|
|
||||||
`/gsd-update` always shows the changelog diff between your installed version and the latest *before* it asks for confirmation. You do not need to visit GitHub separately. The output looks like:
|
`/gsd-update` always shows the changelog diff between your installed version and the latest *before* it asks for confirmation. You do not need to visit GitHub separately. The output looks like:
|
||||||
|
|||||||
@@ -37,18 +37,64 @@ const CHECK_REASON = Object.freeze({
|
|||||||
|
|
||||||
const SEMVER_RE = /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/;
|
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.
|
* Pure-ish: takes an injected spawn function so tests don't actually run npm.
|
||||||
* In production, defaults to execNpm() from the shell-projection seam.
|
* In production, defaults to execNpm() from the shell-projection seam.
|
||||||
*/
|
*/
|
||||||
function checkLatestVersion(opts = {}) {
|
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
|
// Default path routes through the shell-projection seam (execNpm owns the
|
||||||
// Windows shell-flag policy and timeout default). The injection point
|
// Windows shell-flag policy and timeout default). The injection point
|
||||||
// remains spawnSync-shaped for test compatibility — the adapter below
|
// remains spawnSync-shaped for test compatibility — the adapter below
|
||||||
// translates { exitCode } → { status } so the consumer logic is unchanged.
|
// translates { exitCode } → { status } so the consumer logic is unchanged.
|
||||||
// Bounded at 15s so a hung registry doesn't block /gsd-update (#2993 CR).
|
// Bounded at 15s so a hung registry doesn't block /gsd-update (#2993 CR).
|
||||||
const defaultSpawn = () => {
|
const defaultSpawn = () => {
|
||||||
const r = execNpm(['view', PACKAGE_NAME, 'version'], { timeout: 15_000 });
|
const r = execNpm(buildViewArgs(tag), { timeout: 15_000 });
|
||||||
return {
|
return {
|
||||||
status: r.exitCode,
|
status: r.exitCode,
|
||||||
stdout: r.stdout,
|
stdout: r.stdout,
|
||||||
@@ -90,8 +136,16 @@ function checkLatestVersion(opts = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function main() {
|
function main() {
|
||||||
const json = process.argv.includes('--json');
|
const argv = process.argv.slice(2);
|
||||||
const r = checkLatestVersion();
|
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) {
|
if (json) {
|
||||||
process.stdout.write(JSON.stringify(r) + '\n');
|
process.stdout.write(JSON.stringify(r) + '\n');
|
||||||
} else if (r.ok) {
|
} else if (r.ok) {
|
||||||
@@ -104,4 +158,4 @@ function main() {
|
|||||||
|
|
||||||
if (require.main === module) runMain(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 };
|
||||||
|
|||||||
@@ -550,11 +550,12 @@ Usage: `/gsd:help --full`
|
|||||||
Usage: `/gsd:help debug`
|
Usage: `/gsd:help debug`
|
||||||
Usage: `/gsd:help --brief debug`
|
Usage: `/gsd:help --brief debug`
|
||||||
|
|
||||||
**`/gsd:update [--sync] [--reapply]`**
|
**`/gsd:update [--sync] [--reapply] [--next | --rc]`**
|
||||||
Update GSD to latest version with changelog preview.
|
Update GSD to latest version with changelog preview.
|
||||||
|
|
||||||
- `--sync` — sync managed GSD skills across runtime roots (replaces the former `gsd-sync-skills`)
|
- `--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`)
|
- `--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
|
- Shows installed vs latest version comparison
|
||||||
- Displays changelog entries for versions you've missed
|
- Displays changelog entries for versions you've missed
|
||||||
|
|||||||
@@ -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).
|
**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>
|
||||||
|
|
||||||
|
<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">
|
<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.)
|
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_VERSION=""
|
||||||
LATEST_REASON="no_install_detected"
|
LATEST_REASON="no_install_detected"
|
||||||
else
|
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=$?
|
LATEST_STATUS=$?
|
||||||
# #2993 CR: when node is missing or the script doesn't exist, LATEST_RESULT
|
# #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
|
# is empty and piping it to `jq` produces a parse error on stderr while
|
||||||
@@ -114,7 +133,7 @@ fi
|
|||||||
```text
|
```text
|
||||||
Couldn't check for updates (reason: {LATEST_REASON}, exit: {LATEST_STATUS}).
|
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.
|
Exit.
|
||||||
@@ -123,6 +142,14 @@ Exit.
|
|||||||
<step name="compare_versions">
|
<step name="compare_versions">
|
||||||
Compare installed vs latest:
|
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:**
|
**If installed == latest:**
|
||||||
```
|
```
|
||||||
## GSD Update
|
## GSD Update
|
||||||
@@ -327,17 +354,17 @@ RUNTIME_FLAG="--$TARGET_RUNTIME"
|
|||||||
|
|
||||||
**If LOCAL install:**
|
**If LOCAL install:**
|
||||||
```bash
|
```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:**
|
**If GLOBAL install:**
|
||||||
```bash
|
```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:**
|
**If UNKNOWN install:**
|
||||||
```bash
|
```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.
|
Capture output. If install fails, show error and exit.
|
||||||
|
|||||||
@@ -93,3 +93,62 @@ describe('Bug #2992: error paths', () => {
|
|||||||
assert.deepEqual(r, { ok: true, version: '1.40.0-rc.1', reason: CHECK_REASON.OK });
|
assert.deepEqual(r, { ok: true, version: '1.40.0-rc.1', reason: CHECK_REASON.OK });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Issue #815: --next dist-tag support', () => {
|
||||||
|
const { buildViewArgs, resolveTag, ALLOWED_TAGS } = require(
|
||||||
|
path.join(ROOT, 'gsd-core', 'bin', 'check-latest-version.cjs'),
|
||||||
|
);
|
||||||
|
|
||||||
|
test('ALLOWED_TAGS is the sanctioned channel allowlist (latest, next)', () => {
|
||||||
|
assert.deepEqual([...ALLOWED_TAGS].sort(), ['latest', 'next']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildViewArgs() defaults to the bare latest spec (byte-for-byte unchanged)', () => {
|
||||||
|
assert.deepEqual(buildViewArgs(), ['view', '@opengsd/gsd-core', 'version']);
|
||||||
|
assert.deepEqual(buildViewArgs('latest'), ['view', '@opengsd/gsd-core', 'version']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildViewArgs("next") targets the @next dist-tag', () => {
|
||||||
|
assert.deepEqual(buildViewArgs('next'), ['view', '@opengsd/gsd-core@next', 'version']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resolveTag defaults to latest when no --tag flag', () => {
|
||||||
|
assert.equal(resolveTag(['--json']), 'latest');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resolveTag reads --tag next', () => {
|
||||||
|
assert.equal(resolveTag(['--json', '--tag', 'next']), 'next');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resolveTag rejects an unknown tag (typo guard)', () => {
|
||||||
|
assert.throws(() => resolveTag(['--tag', 'nightly']), /invalid --tag 'nightly'/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resolveTag rejects --tag with no value', () => {
|
||||||
|
assert.throws(() => resolveTag(['--tag']), /invalid --tag ''/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('checkLatestVersion accepts an RC under the next tag', () => {
|
||||||
|
const r = checkLatestVersion({ tag: 'next', spawn: () => ({ status: 0, stdout: '1.4.0-rc.1\n', stderr: '' }) });
|
||||||
|
assert.deepEqual(r, { ok: true, version: '1.4.0-rc.1', reason: CHECK_REASON.OK });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildViewArgs rejects a tag outside the allowlist (exported-API guard)', () => {
|
||||||
|
assert.throws(() => buildViewArgs('nightly'), /invalid dist-tag 'nightly'/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('checkLatestVersion rejects an out-of-allowlist tag even with an injected spawn', () => {
|
||||||
|
assert.throws(
|
||||||
|
() => checkLatestVersion({ tag: 'nightly', spawn: () => ({ status: 0, stdout: '9.9.9\n', stderr: '' }) }),
|
||||||
|
/invalid dist-tag 'nightly'/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resolveTag handles the --tag=next equals form', () => {
|
||||||
|
assert.equal(resolveTag(['--json', '--tag=next']), 'next');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resolveTag rejects an unknown --tag=value equals form (no silent fallback)', () => {
|
||||||
|
assert.throws(() => resolveTag(['--tag=nightly']), /invalid --tag 'nightly'/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -4,18 +4,20 @@
|
|||||||
// Regression guard for bug #3130.
|
// Regression guard for bug #3130.
|
||||||
//
|
//
|
||||||
// Two failure modes were observed with the pre-fix npx invocation form:
|
// Two failure modes were observed with the pre-fix npx invocation form:
|
||||||
// 1. Cache-stale: bare `npx -y @opengsd/gsd-core@latest` hits npx's local
|
// 1. Cache-stale: bare `npx -y @opengsd/gsd-core@<tag>` hits npx's local
|
||||||
// cache and may pull an older version instead of @latest.
|
// cache and may pull an older version instead of the target tag.
|
||||||
// 2. Token-routing: Bash-tool wrappers misroute the `@` token in
|
// 2. Token-routing: Bash-tool wrappers misroute the `@` token in
|
||||||
// `@opengsd/gsd-core@latest`, causing npm to error with
|
// `@opengsd/gsd-core@<tag>`, causing npm to error with
|
||||||
// "Unknown command: @opengsd/gsd-core@latest".
|
// "Unknown command: @opengsd/gsd-core@<tag>".
|
||||||
//
|
//
|
||||||
// The robust form is:
|
// The robust form is:
|
||||||
// npx -y --package=@opengsd/gsd-core@latest -- gsd-core $ARGS
|
// npx -y --package=@opengsd/gsd-core@"$TAG" -- gsd-core $ARGS
|
||||||
//
|
//
|
||||||
// `--package=` forces a fresh registry fetch, bypassing the npx cache.
|
// `--package=` forces a fresh registry fetch, bypassing the npx cache.
|
||||||
// `--` clearly delineates npx flags from the run-command, preventing
|
// `--` clearly delineates npx flags from the run-command, preventing
|
||||||
// Bash-tool @-token misrouting.
|
// Bash-tool @-token misrouting.
|
||||||
|
// `$TAG` is a shell variable (latest by default, next under --next/--rc),
|
||||||
|
// set by the parse_update_channel step (#815).
|
||||||
|
|
||||||
const { test } = require('node:test');
|
const { test } = require('node:test');
|
||||||
const assert = require('node:assert/strict');
|
const assert = require('node:assert/strict');
|
||||||
@@ -28,9 +30,9 @@ const UPDATE_WF = path.join(ROOT, 'gsd-core', 'workflows', 'update.md');
|
|||||||
const src = fs.readFileSync(UPDATE_WF, 'utf8');
|
const src = fs.readFileSync(UPDATE_WF, 'utf8');
|
||||||
|
|
||||||
test('bug #3130: update.md contains no bare npx invocations (cache-stale form)', () => {
|
test('bug #3130: update.md contains no bare npx invocations (cache-stale form)', () => {
|
||||||
// Any occurrence of `npx -y @opengsd/gsd-core@latest` without `--package=`
|
// Any occurrence of `npx -y @opengsd/gsd-core@<something>` without `--package=`
|
||||||
// is the stale form that triggers the two failure modes.
|
// is the stale form that triggers the two failure modes.
|
||||||
const stale = (src.match(/npx -y @opengsd\/gsd-core@latest[^\n]*/g) || []);
|
const stale = (src.match(/npx -y @opengsd\/gsd-core@\S+[^\n]*/g) || []);
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
stale,
|
stale,
|
||||||
[],
|
[],
|
||||||
@@ -40,7 +42,8 @@ test('bug #3130: update.md contains no bare npx invocations (cache-stale form)',
|
|||||||
|
|
||||||
test('bug #3130: update.md has >=3 robust npx invocations (--package= + -- separator)', () => {
|
test('bug #3130: update.md has >=3 robust npx invocations (--package= + -- separator)', () => {
|
||||||
// Three sibling invocations: local, global, and unknown/fallback.
|
// Three sibling invocations: local, global, and unknown/fallback.
|
||||||
const robust = (src.match(/npx -y --package=@opengsd\/gsd-core@latest -- gsd-core/g) || []);
|
// The tag is now a $TAG variable (latest by default, next under --next/--rc).
|
||||||
|
const robust = (src.match(/npx -y --package=@opengsd\/gsd-core@\S+ -- gsd-core/g) || []);
|
||||||
assert.ok(
|
assert.ok(
|
||||||
robust.length >= 3,
|
robust.length >= 3,
|
||||||
`Expected >=3 robust npx invocations in update.md, found ${robust.length}`,
|
`Expected >=3 robust npx invocations in update.md, found ${robust.length}`,
|
||||||
|
|||||||
44
tests/issue-815-update-next-channel.test.cjs
Normal file
44
tests/issue-815-update-next-channel.test.cjs
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
'use strict';
|
||||||
|
// allow-test-rule: reads product workflow/command markdown to verify the --next RC channel contract — not a source-grep test
|
||||||
|
|
||||||
|
// Issue #815: `/gsd-update --next` (alias `--rc`) must thread the @next dist-tag
|
||||||
|
// through the whole update flow (version check + install) while leaving the
|
||||||
|
// default @latest path unchanged.
|
||||||
|
|
||||||
|
const { test } = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
|
||||||
|
const ROOT = path.join(__dirname, '..');
|
||||||
|
const WF = fs.readFileSync(path.join(ROOT, 'gsd-core', 'workflows', 'update.md'), 'utf8');
|
||||||
|
const CMD = fs.readFileSync(path.join(ROOT, 'commands', 'gsd', 'update.md'), 'utf8');
|
||||||
|
|
||||||
|
test('issue #815: workflow parses --next/--rc into a TAG channel', () => {
|
||||||
|
assert.match(WF, /--next/);
|
||||||
|
assert.match(WF, /--rc/);
|
||||||
|
assert.match(WF, /TAG="next"/);
|
||||||
|
assert.match(WF, /TAG="latest"/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('issue #815: version check threads the tag through check-latest-version.cjs', () => {
|
||||||
|
// The script path is double-quoted in the shell command, so the line is:
|
||||||
|
// node "$GSD_DIR/gsd-core/bin/check-latest-version.cjs" --json --tag "$TAG"
|
||||||
|
// The closing " on the script path sits between .cjs and --json.
|
||||||
|
assert.match(WF, /check-latest-version\.cjs"? --json --tag "\$TAG"/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('issue #815: install uses the selected tag, not a hardcoded @latest', () => {
|
||||||
|
const robust = WF.match(/npx -y --package=@opengsd\/gsd-core@"\$TAG" -- gsd-core/g) || [];
|
||||||
|
assert.ok(robust.length >= 3, `expected >=3 tag-parameterized npx invocations, found ${robust.length}`);
|
||||||
|
assert.doesNotMatch(WF, /--package=@opengsd\/gsd-core@latest -- gsd-core/,
|
||||||
|
'install lines must not hardcode @latest once --next exists');
|
||||||
|
assert.doesNotMatch(WF, /--package=@opengsd\/gsd-core@(?:latest|next|beta|canary|rc) -- gsd-core/,
|
||||||
|
'install lines must use the $TAG variable, never a hardcoded dist-tag literal');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('issue #815: command documents --next/--rc and routes it to the update workflow', () => {
|
||||||
|
assert.match(CMD, /--next/);
|
||||||
|
assert.match(CMD, /--rc/);
|
||||||
|
assert.match(CMD, /argument-hint:.*--next/);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user