From c456d2f48ca70f19df9f3902288a5ca13e533dad Mon Sep 17 00:00:00 2001 From: Charles Ouimet Date: Tue, 26 May 2026 14:27:33 -0400 Subject: [PATCH 1/7] =?UTF-8?q?[refactor]=20Remove=20nextTargetVersion=20?= =?UTF-8?q?=E2=80=94=20convention=20is=20embedded=20in=20the=20scripts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit nextTargetVersion was a boolean disguised as a string: the SemVer code path in the QA generators is dead now that lock-version.sh will handle version lock-in. Dropped the field from package.json and hardcoded the Unreleased convention directly into generate-qa-test-plan.sh and generate-release-testing-instructions.sh. Updated qa-suggest SKILL.md to match. Also dropped the boilerplate Phase 0 section (environment setup) from the generated release testing instructions — every contributor already has Node.js/pnpm/gh configured. BATS tests: deleted 4 SemVer-path tests (forward compat is no longer needed), removed the field from 11 fixture JSONs. --- .claude/skills/qa-suggest/SKILL.md | 5 +- .../rangelink-vscode-extension/TESTING.md | 7 ++- .../rangelink-vscode-extension/package.json | 1 - .../scripts/generate-qa-test-plan.sh | 21 ++----- .../generate-release-testing-instructions.sh | 56 +++-------------- tests/shell/generate-qa-test-plan.bats | 61 ++++--------------- ...generate-release-testing-instructions.bats | 49 +++------------ 7 files changed, 41 insertions(+), 159 deletions(-) diff --git a/.claude/skills/qa-suggest/SKILL.md b/.claude/skills/qa-suggest/SKILL.md index 8b449649..4b7a61f6 100644 --- a/.claude/skills/qa-suggest/SKILL.md +++ b/.claude/skills/qa-suggest/SKILL.md @@ -13,7 +13,7 @@ Suggest new test cases for the current QA cycle by diffing the CHANGELOG against ## Step 1: Discovery -Read the extension package.json to get the version context: +Read the extension package.json to get the published version: ```text Read packages/rangelink-vscode-extension/package.json @@ -21,10 +21,9 @@ Read packages/rangelink-vscode-extension/package.json Extract: -- `nextTargetVersion` — the upcoming release version (`"Unreleased"` during trunk-based development, or a SemVer like `"1.1.0"` once locked in) - `version` — the last published version (e.g., `1.0.0`) -**If `nextTargetVersion` is not set**, STOP: "Set `nextTargetVersion` in `packages/rangelink-vscode-extension/package.json` (e.g., `"Unreleased"`) before running `/qa-suggest`." +During trunk-based development the QA artifacts use the "Unreleased" placeholder (e.g., `qa-test-cases-unreleased.yaml`) — this convention is embedded in the QA tooling, not read from a config field. The `version` field in package.json always holds the last published SemVer. ## Step 2: Locate QA YAMLs diff --git a/packages/rangelink-vscode-extension/TESTING.md b/packages/rangelink-vscode-extension/TESTING.md index b80f9ca3..7855ea9e 100644 --- a/packages/rangelink-vscode-extension/TESTING.md +++ b/packages/rangelink-vscode-extension/TESTING.md @@ -34,6 +34,8 @@ All `test:release*` commands accept `--label ` (include TCs with QA YAML la ### Release QA Cycle (once per release) +QA happens in Unreleased mode — `finalize-release` runs only after QA passes. This keeps the version unlocked during testing so bugs found during the QA pass can be fixed without re-finalizing. + ```mermaid flowchart TD Z[generate:release-testing-instructions] -.->|generates guide| A @@ -49,9 +51,10 @@ flowchart TD I1 --> I2[Open terminals + bind] I2 --> I3[Terminal-dependent TCs] I4 --> J{All TCs pass?} - J -- No --> K[Fix + re-run affected TCs] + J -- No --> K[Fix bugs + re-run affected TCs] K --> J - J -- Yes --> L[Tag release + publish] + J -- Yes --> L[finalize-release X.Y.Z] + L --> M[build VSIX + publish] ``` --- diff --git a/packages/rangelink-vscode-extension/package.json b/packages/rangelink-vscode-extension/package.json index d176bb44..4a98a0b4 100644 --- a/packages/rangelink-vscode-extension/package.json +++ b/packages/rangelink-vscode-extension/package.json @@ -982,6 +982,5 @@ "vscode": "^1.49.0" }, "icon": "icon.png", - "nextTargetVersion": "Unreleased", "pricing": "Free" } diff --git a/packages/rangelink-vscode-extension/scripts/generate-qa-test-plan.sh b/packages/rangelink-vscode-extension/scripts/generate-qa-test-plan.sh index ee670801..49154bbf 100755 --- a/packages/rangelink-vscode-extension/scripts/generate-qa-test-plan.sh +++ b/packages/rangelink-vscode-extension/scripts/generate-qa-test-plan.sh @@ -6,10 +6,10 @@ set -euo pipefail # Creates a new QA test plan YAML for the next release cycle by carrying forward # all test cases from the previous plan with statuses reset to pending. # -# Reads nextTargetVersion from package.json to name the output file. # Reads version (last published) to document the scope in the header. # -# Filename: qa-test-cases-v.yaml, or qa-test-cases-unreleased.yaml when nextTargetVersion is "Unreleased". +# Filename is always qa-test-cases-unreleased.yaml during trunk-based development — +# the version is deferred until lock-version.sh locks it in at QA time. # Always regenerates: header is freshly emitted, body is carried forward from the highest-sorted existing yaml. # # Requires: jq @@ -20,27 +20,14 @@ REPO_ROOT="$(git -C "$PACKAGE_DIR" rev-parse --show-toplevel)" PACKAGE_JSON="$PACKAGE_DIR/package.json" QA_DIR="$PACKAGE_DIR/qa" -NEXT_VERSION=$(jq -r '.nextTargetVersion // empty' "$PACKAGE_JSON") -if [[ -z "$NEXT_VERSION" ]]; then - echo "Error: nextTargetVersion not set in $PACKAGE_JSON — update it before running generate:qa-test-plan" >&2 - exit 1 -fi - PUBLISHED_VERSION=$(jq -r '.version // empty' "$PACKAGE_JSON") if [[ -z "$PUBLISHED_VERSION" ]]; then echo "Error: version not set in $PACKAGE_JSON" >&2 exit 1 fi -# Version-aware filename + label. "Unreleased" is the placeholder used during -# trunk-based development before finalize-release locks in a SemVer. -if [[ "$NEXT_VERSION" == "Unreleased" ]]; then - NEXT_LABEL="Unreleased" - BASE_NAME="qa-test-cases-unreleased" -else - NEXT_LABEL="v${NEXT_VERSION}" - BASE_NAME="qa-test-cases-v${NEXT_VERSION}" -fi +NEXT_LABEL="Unreleased" +BASE_NAME="qa-test-cases-unreleased" OUTPUT_FILE="$QA_DIR/${BASE_NAME}.yaml" # Prefer the existing target file as the carry-forward source so in-progress diff --git a/packages/rangelink-vscode-extension/scripts/generate-release-testing-instructions.sh b/packages/rangelink-vscode-extension/scripts/generate-release-testing-instructions.sh index d44c866b..cd937a9b 100755 --- a/packages/rangelink-vscode-extension/scripts/generate-release-testing-instructions.sh +++ b/packages/rangelink-vscode-extension/scripts/generate-release-testing-instructions.sh @@ -8,9 +8,9 @@ set -euo pipefail # # Usage: ./scripts/generate-release-testing-instructions.sh # -# Filename: qa/release-testing-instructions-v.md +# Filename: qa/release-testing-instructions-unreleased.md # -# Requires: jq, nextTargetVersion set in package.json +# Requires: jq # Optional: gh CLI (authenticated) RED='\033[0;31m' @@ -26,12 +26,6 @@ QA_DIR="$PACKAGE_DIR/qa" # --- Prerequisites --- -NEXT_VERSION=$(jq -r '.nextTargetVersion // empty' "$PACKAGE_JSON") -if [[ -z "$NEXT_VERSION" ]]; then - echo -e "${RED}Error: nextTargetVersion not set in package.json — update it before running this script${NC}" >&2 - exit 1 -fi - PUBLISHED_VERSION=$(jq -r '.version // empty' "$PACKAGE_JSON") WARNINGS="" @@ -42,19 +36,12 @@ elif ! gh auth status &>/dev/null; then WARNINGS+=" ${YELLOW}Warning: gh CLI not authenticated — Phase 2 (GitHub QA Issues) requires auth${NC}\n" fi -# Version-aware filename + label. "Unreleased" is the placeholder used during -# trunk-based development before finalize-release locks in a SemVer. -if [[ "$NEXT_VERSION" == "Unreleased" ]]; then - NEXT_LABEL="Unreleased" - BASE_NAME="release-testing-instructions-unreleased" - QA_YAML_FILENAME="qa-test-cases-unreleased.yaml" - QA_CHECKLIST_SLUG="unreleased" -else - NEXT_LABEL="v${NEXT_VERSION}" - BASE_NAME="release-testing-instructions-v${NEXT_VERSION}" - QA_YAML_FILENAME="qa-test-cases-v${NEXT_VERSION}.yaml" - QA_CHECKLIST_SLUG="v${NEXT_VERSION}" -fi +# Filename is always release-testing-instructions-unreleased.md during trunk-based +# development — lock-version.sh renames and fixup-references it when locking a version. +NEXT_LABEL="Unreleased" +BASE_NAME="release-testing-instructions-unreleased" +QA_YAML_FILENAME="qa-test-cases-unreleased.yaml" +QA_CHECKLIST_SLUG="unreleased" echo -e "${GREEN}Generating release testing instructions for ${NEXT_LABEL}${NC}" @@ -85,33 +72,6 @@ Work through each phase in order — later phases depend on earlier ones complet --- -## Phase 0: Prerequisites - -Ensure your environment is ready before starting the release testing cycle. -All commands in this guide run from the **monorepo root** (where \`pnpm-workspace.yaml\` lives). - -### Node.js / pnpm - -\`\`\`bash -source ~/.zshrc && nvm use && npm run enable-pnpm -node --version && pnpm --version -\`\`\` - -### Verify nextTargetVersion - -\`\`\`bash -jq -r '.nextTargetVersion' packages/rangelink-vscode-extension/package.json -# Should print: ${NEXT_VERSION} -\`\`\` - -### gh CLI (required for Phase 2) - -\`\`\`bash -gh auth status -\`\`\` - ---- - ## Phase 1: Generate QA Test Plan Create or carry forward the QA test plan YAML for ${NEXT_LABEL}. diff --git a/tests/shell/generate-qa-test-plan.bats b/tests/shell/generate-qa-test-plan.bats index 9e136913..8b35db1a 100644 --- a/tests/shell/generate-qa-test-plan.bats +++ b/tests/shell/generate-qa-test-plan.bats @@ -49,14 +49,13 @@ test_cases: EOF } -# ── Filename: Unreleased vs SemVer ───────────────────────────────────────────── +# ── Filename ─────────────────────────────────────────────────────────────────── -@test "Unreleased nextTargetVersion produces qa-test-cases-unreleased.yaml" { +@test "produces qa-test-cases-unreleased.yaml" { setup_fixture write_package_json <<'EOF' { - "version": "1.0.0", - "nextTargetVersion": "Unreleased" + "version": "1.0.0" } EOF write_yaml "qa-test-cases-v1.0.0.yaml" < <(minimal_previous_yaml) @@ -66,20 +65,6 @@ EOF [[ -f "$FIXTURE_ROOT/qa/qa-test-cases-unreleased.yaml" ]] } -@test "SemVer nextTargetVersion produces v-prefixed yaml (forward compat with finalize)" { - setup_fixture - write_package_json <<'EOF' -{ - "version": "1.0.0", - "nextTargetVersion": "2.0.0" -} -EOF - write_yaml "qa-test-cases-v1.0.0.yaml" < <(minimal_previous_yaml) - - run "$SCRIPT" - [[ "$status" -eq 0 ]] - [[ -f "$FIXTURE_ROOT/qa/qa-test-cases-v2.0.0.yaml" ]] -} # ── Header rendering ─────────────────────────────────────────────────────────── @@ -87,8 +72,7 @@ EOF setup_fixture write_package_json <<'EOF' { - "version": "1.0.0", - "nextTargetVersion": "Unreleased" + "version": "1.0.0" } EOF write_yaml "qa-test-cases-v1.0.0.yaml" < <(minimal_previous_yaml) @@ -102,22 +86,6 @@ EOF ! grep -q "vUnreleased" "$FIXTURE_ROOT/qa/qa-test-cases-unreleased.yaml" } -@test "SemVer: header keeps the v prefix on the right" { - setup_fixture - write_package_json <<'EOF' -{ - "version": "1.0.0", - "nextTargetVersion": "2.0.0" -} -EOF - write_yaml "qa-test-cases-v1.0.0.yaml" < <(minimal_previous_yaml) - - run "$SCRIPT" - [[ "$status" -eq 0 ]] - local header - header=$(head -1 "$FIXTURE_ROOT/qa/qa-test-cases-v2.0.0.yaml") - [[ "$header" == "# RangeLink QA Test Cases — v1.0.0 → v2.0.0" ]] -} # ── Idempotent refresh-in-place (early-exit dropped) ─────────────────────────── @@ -125,8 +93,7 @@ EOF setup_fixture write_package_json <<'EOF' { - "version": "1.0.0", - "nextTargetVersion": "Unreleased" + "version": "1.0.0" } EOF # Pre-existing unreleased.yaml acts as PREVIOUS_YAML for itself. @@ -153,8 +120,7 @@ EOF setup_fixture write_package_json <<'EOF' { - "version": "1.0.0", - "nextTargetVersion": "Unreleased" + "version": "1.0.0" } EOF write_yaml "qa-test-cases-v1.0.0.yaml" < <(minimal_previous_yaml) @@ -176,8 +142,7 @@ EOF setup_fixture write_package_json <<'EOF' { - "version": "1.0.0", - "nextTargetVersion": "Unreleased" + "version": "1.0.0" } EOF write_yaml "qa-test-cases-v1.0.0.yaml" <<'EOF' @@ -228,24 +193,25 @@ EOF # ── Error paths ──────────────────────────────────────────────────────────────── -@test "missing nextTargetVersion still errors" { +@test "works without nextTargetVersion field (convention is embedded in the script)" { setup_fixture write_package_json <<'EOF' { "version": "1.0.0" } EOF + write_yaml "qa-test-cases-v1.0.0.yaml" < <(minimal_previous_yaml) run "$SCRIPT" - [[ "$status" -eq 1 ]] - [[ "$output" =~ "nextTargetVersion not set" ]] + [[ "$status" -eq 0 ]] + [[ -f "$FIXTURE_ROOT/qa/qa-test-cases-unreleased.yaml" ]] } @test "missing version still errors" { setup_fixture write_package_json <<'EOF' { - "nextTargetVersion": "Unreleased" + } EOF @@ -258,8 +224,7 @@ EOF setup_fixture write_package_json <<'EOF' { - "version": "1.0.0", - "nextTargetVersion": "Unreleased" + "version": "1.0.0" } EOF diff --git a/tests/shell/generate-release-testing-instructions.bats b/tests/shell/generate-release-testing-instructions.bats index 58e107c5..c27dc5bc 100644 --- a/tests/shell/generate-release-testing-instructions.bats +++ b/tests/shell/generate-release-testing-instructions.bats @@ -31,14 +31,13 @@ ENDOFSTUB } } -# ── Output filename: Unreleased vs SemVer ────────────────────────────────────── +# ── Output filename ───────────────────────────────────────────────────────────── -@test "Unreleased nextTargetVersion produces release-testing-instructions-unreleased.md" { +@test "produces release-testing-instructions-unreleased.md" { setup_fixture write_package_json <<'EOF' { - "version": "1.0.0", - "nextTargetVersion": "Unreleased" + "version": "1.0.0" } EOF @@ -48,19 +47,6 @@ EOF [[ ! -f "$FIXTURE_ROOT/qa/release-testing-instructions-vUnreleased.md" ]] } -@test "SemVer nextTargetVersion produces v-prefixed markdown (forward compat with finalize)" { - setup_fixture - write_package_json <<'EOF' -{ - "version": "1.0.0", - "nextTargetVersion": "2.0.0" -} -EOF - - run "$SCRIPT" - [[ "$status" -eq 0 ]] - [[ -f "$FIXTURE_ROOT/qa/release-testing-instructions-v2.0.0.md" ]] -} # ── Internal yaml reference ──────────────────────────────────────────────────── @@ -68,8 +54,7 @@ EOF setup_fixture write_package_json <<'EOF' { - "version": "1.0.0", - "nextTargetVersion": "Unreleased" + "version": "1.0.0" } EOF @@ -81,20 +66,6 @@ EOF ! grep -q "qa-test-cases-vUnreleased.yaml" "$out" } -@test "SemVer: emitted markdown references qa-test-cases-v.yaml" { - setup_fixture - write_package_json <<'EOF' -{ - "version": "1.0.0", - "nextTargetVersion": "2.0.0" -} -EOF - - run "$SCRIPT" - [[ "$status" -eq 0 ]] - local out="$FIXTURE_ROOT/qa/release-testing-instructions-v2.0.0.md" - grep -q "qa/qa-test-cases-v2.0.0.yaml" "$out" -} # ── Header rendering ─────────────────────────────────────────────────────────── @@ -102,8 +73,7 @@ EOF setup_fixture write_package_json <<'EOF' { - "version": "1.0.0", - "nextTargetVersion": "Unreleased" + "version": "1.0.0" } EOF @@ -117,7 +87,7 @@ EOF # ── Error paths ──────────────────────────────────────────────────────────────── -@test "missing nextTargetVersion still errors" { +@test "works without nextTargetVersion field (convention is embedded in the script)" { setup_fixture write_package_json <<'EOF' { @@ -126,8 +96,8 @@ EOF EOF run "$SCRIPT" - [[ "$status" -eq 1 ]] - [[ "$output" =~ "nextTargetVersion not set" ]] + [[ "$status" -eq 0 ]] + [[ -f "$FIXTURE_ROOT/qa/release-testing-instructions-unreleased.md" ]] } # ── Early-exit (kept for this script per A006 scope) ─────────────────────────── @@ -136,8 +106,7 @@ EOF setup_fixture write_package_json <<'EOF' { - "version": "1.0.0", - "nextTargetVersion": "Unreleased" + "version": "1.0.0" } EOF echo "preexisting content" > "$FIXTURE_ROOT/qa/release-testing-instructions-unreleased.md" From 0fbe948805900694a308c3f1877fa0f79d3b1fee Mon Sep 17 00:00:00 2001 From: Charles Ouimet Date: Tue, 26 May 2026 16:46:02 -0400 Subject: [PATCH 2/7] =?UTF-8?q?[feat]=20Add=20lock-version.sh=20=E2=80=94?= =?UTF-8?q?=20soft-lock=20a=20SemVer=20for=20QA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Idempotent script that transitions from the deferred "Unreleased" version to a concrete SemVer. Takes an X.Y.Z argument and validates it, then renames qa-test-cases-unreleased.yaml → qa-test-cases-vX.Y.Z.yaml, bumps package.json .version, and regenerates versioned release testing instructions with internal references fixup'd. Detects the already-locked state (.version matches the target + versioned YAML exists) and prints a summary instead of re-running steps, so it's safe to re-run after adding bug-fix TCs during QA. 11 BATS tests cover the happy path, idempotency, error paths, and YAML content preservation. --- .../scripts/lock-version.sh | 123 ++++++++ tests/shell/lock-version.bats | 264 ++++++++++++++++++ 2 files changed, 387 insertions(+) create mode 100755 packages/rangelink-vscode-extension/scripts/lock-version.sh create mode 100644 tests/shell/lock-version.bats diff --git a/packages/rangelink-vscode-extension/scripts/lock-version.sh b/packages/rangelink-vscode-extension/scripts/lock-version.sh new file mode 100755 index 00000000..80566b4c --- /dev/null +++ b/packages/rangelink-vscode-extension/scripts/lock-version.sh @@ -0,0 +1,123 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Usage: ./scripts/lock-version.sh X.Y.Z +# +# Soft-locks the deferred "Unreleased" version to a SemVer so QA can begin +# against a concrete version number. +# +# Steps: +# 1. Rename qa-test-cases-unreleased.yaml → qa-test-cases-vX.Y.Z.yaml +# 2. Bump package.json .version → X.Y.Z +# 3. Regenerate versioned release testing instructions +# +# Idempotent — safe to re-run after adding bug-fix TCs to the versioned YAML. +# On re-run, prints a summary and exits cleanly. +# +# Requires: jq + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' + +VERSION="${1:-}" +if [[ -z "$VERSION" ]]; then + echo -e "${RED}Usage: $0 ${NC}" >&2 + echo "Example: $0 2.0.0" >&2 + exit 1 +fi + +if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo -e "${RED}Error: version must be SemVer (X.Y.Z), got '$VERSION'${NC}" >&2 + exit 1 +fi + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PACKAGE_DIR="$(dirname "$SCRIPT_DIR")" +REPO_ROOT="$(git -C "$PACKAGE_DIR" rev-parse --show-toplevel)" +PACKAGE_JSON="$PACKAGE_DIR/package.json" +QA_DIR="$PACKAGE_DIR/qa" + +# --- Validation --- + +if ! git -C "$REPO_ROOT" diff-index --quiet HEAD --; then + echo -e "${RED}Error: working tree is dirty. Commit or stash changes first.${NC}" >&2 + exit 1 +fi + +UNRELEASED_YAML="$QA_DIR/qa-test-cases-unreleased.yaml" +VERSIONED_YAML="$QA_DIR/qa-test-cases-v${VERSION}.yaml" + +PUBLISHED_VERSION=$(jq -r '.version // empty' "$PACKAGE_JSON") +if [[ -z "$PUBLISHED_VERSION" ]]; then + echo -e "${RED}Error: version not set in $PACKAGE_JSON${NC}" >&2 + exit 1 +fi + +# --- Idempotency: detect already-locked state --- + +if [[ "$PUBLISHED_VERSION" == "$VERSION" ]] && [[ -f "$VERSIONED_YAML" ]]; then + echo -e "${GREEN}Already locked at v${VERSION}. Nothing to do.${NC}" + exit 0 +fi + +# --- Prerequisites --- + +if [[ ! -f "$UNRELEASED_YAML" ]]; then + echo -e "${RED}Error: $UNRELEASED_YAML not found — nothing to lock.${NC}" >&2 + exit 1 +fi + +# --- Step 1: Rename QA YAML --- + +if [[ -f "$VERSIONED_YAML" ]]; then + echo -e "${YELLOW}Step 1: ${VERSIONED_YAML} already exists — skipped.${NC}" +else + git -C "$REPO_ROOT" mv "$UNRELEASED_YAML" "$VERSIONED_YAML" + echo -e "${GREEN}Step 1: Renamed qa-test-cases-unreleased.yaml → qa-test-cases-v${VERSION}.yaml${NC}" +fi + +# --- Step 2: Bump .version --- + +CURRENT_VERSION=$(jq -r '.version // empty' "$PACKAGE_JSON") +if [[ "$CURRENT_VERSION" == "$VERSION" ]]; then + echo -e "${YELLOW}Step 2: .version already ${VERSION} — skipped.${NC}" +else + jq --arg v "$VERSION" '.version = $v' "$PACKAGE_JSON" > "$PACKAGE_JSON.tmp" + mv "$PACKAGE_JSON.tmp" "$PACKAGE_JSON" + echo -e "${GREEN}Step 2: Bumped .version ${CURRENT_VERSION} → ${VERSION}${NC}" +fi + +# --- Step 3: Regenerate versioned release testing instructions --- + +VERSIONED_INSTRUCTIONS="$QA_DIR/release-testing-instructions-v${VERSION}.md" +UNRELEASED_INSTRUCTIONS="$QA_DIR/release-testing-instructions-unreleased.md" + +if [[ -f "$VERSIONED_INSTRUCTIONS" ]]; then + echo -e "${YELLOW}Step 3: ${VERSIONED_INSTRUCTIONS} already exists — skipped.${NC}" +else + # Delete unreleased instructions to bypass the script's early-exit. + rm -f "$UNRELEASED_INSTRUCTIONS" + + "$SCRIPT_DIR/generate-release-testing-instructions.sh" + + if [[ ! -f "$UNRELEASED_INSTRUCTIONS" ]]; then + echo -e "${RED}Error: generate-release-testing-instructions.sh did not produce expected output.${NC}" >&2 + exit 1 + fi + + mv "$UNRELEASED_INSTRUCTIONS" "$VERSIONED_INSTRUCTIONS" + + # Fixup internal references from unreleased → versioned. + sed -i '' \ + -e "s/qa-test-cases-unreleased\.yaml/qa-test-cases-v${VERSION}.yaml/g" \ + -e "s/qa-checklist-unreleased/qa-checklist-v${VERSION}/g" \ + -e "s/ → Unreleased/ → v${VERSION}/g" \ + "$VERSIONED_INSTRUCTIONS" + + echo -e "${GREEN}Step 3: Generated release-testing-instructions-v${VERSION}.md${NC}" +fi + +echo "" +echo -e "${GREEN}Locked at v${VERSION}. QA can now begin against a concrete version.${NC}" diff --git a/tests/shell/lock-version.bats b/tests/shell/lock-version.bats new file mode 100644 index 00000000..5e070e32 --- /dev/null +++ b/tests/shell/lock-version.bats @@ -0,0 +1,264 @@ +#!/usr/bin/env bats + +load test_helper + +REAL_SCRIPT="$PROJECT_ROOT/packages/rangelink-vscode-extension/scripts/lock-version.sh" + +setup_fixture() { + FIXTURE_ROOT="$TEST_TEMP_DIR" + mkdir -p "$FIXTURE_ROOT/scripts" + mkdir -p "$FIXTURE_ROOT/qa" + + cp "$REAL_SCRIPT" "$FIXTURE_ROOT/scripts/lock-version.sh" + SCRIPT="$FIXTURE_ROOT/scripts/lock-version.sh" + + stub_dir + make_stub "git" <<'ENDOFSTUB' +case "$*" in + *--show-toplevel*) echo "${FIXTURE_ROOT_FOR_GIT:-$TEST_TEMP_DIR}" ;; + *diff-index*) exit 0 ;; + *"mv"*) shift 3; mv "$@" ;; + *) exit 0 ;; +esac +ENDOFSTUB + # Let the git stub resolve $FIXTURE_ROOT at call time via env. + export FIXTURE_ROOT_FOR_GIT="$FIXTURE_ROOT" + + # Stub npx so the prettier formatting step does not require node_modules. + make_passive_stub "npx" + + # Stub gh so the auth-status warning path does not depend on local gh state. + make_passive_stub "gh" + + # Stub generate-release-testing-instructions.sh to produce a dummy output file. + cat > "$FIXTURE_ROOT/scripts/generate-release-testing-instructions.sh" <<'STUBEOF' +#!/usr/bin/env bash +QA_DIR="$(dirname "$(dirname "${BASH_SOURCE[0]}")")/qa" +cat > "$QA_DIR/release-testing-instructions-unreleased.md" <<'EOF' +# Release Testing Instructions: RangeLink VS Code Extension Unreleased + +**Scope:** Changes from v1.0.0 → Unreleased + +## Phase 1: Generate QA Test Plan + +This creates `qa/qa-test-cases-unreleased.yaml`. + +## Phase 5: Manual QA Pass + +The generated checklist is at `qa/output/qa-checklist-unreleased-.md`. +EOF +STUBEOF + chmod +x "$FIXTURE_ROOT/scripts/generate-release-testing-instructions.sh" + + write_package_json() { + cat > "$FIXTURE_ROOT/package.json" + } + + write_yaml() { + cat > "$FIXTURE_ROOT/qa/$1" + } +} + +# ── Happy path ────────────────────────────────────────────────────────────────── + +@test "renames YAML, bumps version, generates versioned instructions" { + setup_fixture + write_package_json <<'EOF' +{ + "version": "1.0.0" +} +EOF + cat > "$FIXTURE_ROOT/qa/qa-test-cases-unreleased.yaml" <<'EOF' +test_cases: + - id: foo-001 + scenario: 'Existing' + automated: true +EOF + + run "$SCRIPT" 2.0.0 + [[ "$status" -eq 0 ]] + + # YAML renamed. + [[ ! -f "$FIXTURE_ROOT/qa/qa-test-cases-unreleased.yaml" ]] + [[ -f "$FIXTURE_ROOT/qa/qa-test-cases-v2.0.0.yaml" ]] + + # Version bumped. + local ver + ver=$(jq -r '.version' "$FIXTURE_ROOT/package.json") + [[ "$ver" == "2.0.0" ]] + + # Versioned instructions generated. + [[ -f "$FIXTURE_ROOT/qa/release-testing-instructions-v2.0.0.md" ]] + [[ ! -f "$FIXTURE_ROOT/qa/release-testing-instructions-unreleased.md" ]] +} + +@test "versioned instructions have internal references fixup'd" { + setup_fixture + write_package_json <<'EOF' +{ + "version": "1.0.0" +} +EOF + cat > "$FIXTURE_ROOT/qa/qa-test-cases-unreleased.yaml" <<'EOF' +test_cases: + - id: foo-001 + scenario: 'Existing' + automated: true +EOF + + run "$SCRIPT" 2.0.0 + [[ "$status" -eq 0 ]] + + local out="$FIXTURE_ROOT/qa/release-testing-instructions-v2.0.0.md" + # Internal refs updated from unreleased → versioned. + grep -q "qa-test-cases-v2.0.0.yaml" "$out" + grep -q "qa-checklist-v2.0.0" "$out" + grep -q "v1.0.0 → v2.0.0" "$out" + # No stray unreleased refs in filenames/slugs. + ! grep -q "qa-test-cases-unreleased.yaml" "$out" + ! grep -q "qa-checklist-unreleased" "$out" +} + +# ── Idempotency ───────────────────────────────────────────────────────────────── + +@test "re-run prints already-locked message and exits cleanly" { + setup_fixture + write_package_json <<'EOF' +{ + "version": "2.0.0" +} +EOF + cat > "$FIXTURE_ROOT/qa/qa-test-cases-v2.0.0.yaml" <<'EOF' +test_cases: + - id: foo-001 + scenario: 'Existing' + automated: true +EOF + + run "$SCRIPT" 2.0.0 + [[ "$status" -eq 0 ]] + [[ "$output" =~ "Already locked at v2.0.0" ]] +} + +@test "re-run after partial completion picks up remaining steps" { + setup_fixture + write_package_json <<'EOF' +{ + "version": "1.0.0" +} +EOF + cat > "$FIXTURE_ROOT/qa/qa-test-cases-unreleased.yaml" <<'EOF' +test_cases: + - id: foo-001 + scenario: 'Existing' + automated: true +EOF + + # Simulate partial state: version already bumped but YAML not renamed. + # (This shouldn't happen in practice but tests the guard). + # Actually, let's test: versioned YAML exists (manually created) but .version not bumped. + # This is a pathological case — the script should handle it gracefully. + + # Pre-create the versioned YAML (simulating partial previous run). + cp "$FIXTURE_ROOT/qa/qa-test-cases-unreleased.yaml" \ + "$FIXTURE_ROOT/qa/qa-test-cases-v2.0.0.yaml" + + run "$SCRIPT" 2.0.0 + [[ "$status" -eq 0 ]] + + # Should bump version (the missing step). + local ver + ver=$(jq -r '.version' "$FIXTURE_ROOT/package.json") + [[ "$ver" == "2.0.0" ]] +} + +# ── Error paths ───────────────────────────────────────────────────────────────── + +@test "missing version argument exits 1" { + setup_fixture + run "$SCRIPT" + [[ "$status" -eq 1 ]] + [[ "$output" =~ "Usage" ]] +} + +@test "invalid version (not SemVer) exits 1" { + setup_fixture + run "$SCRIPT" "not-a-version" + [[ "$status" -eq 1 ]] + [[ "$output" =~ "SemVer" ]] +} + +@test "version with only major.minor exits 1" { + setup_fixture + run "$SCRIPT" "2.0" + [[ "$status" -eq 1 ]] + [[ "$output" =~ "SemVer" ]] +} + +@test "version with pre-release suffix exits 1" { + setup_fixture + run "$SCRIPT" "2.0.0-rc1" + [[ "$status" -eq 1 ]] + [[ "$output" =~ "SemVer" ]] +} + +@test "no qa-test-cases-unreleased.yaml exits 1" { + setup_fixture + write_package_json <<'EOF' +{ + "version": "1.0.0" +} +EOF + # No YAML file written. + + run "$SCRIPT" 2.0.0 + [[ "$status" -eq 1 ]] + [[ "$output" =~ "not found" ]] +} + +@test "missing .version in package.json exits 1" { + setup_fixture + write_package_json <<'EOF' +{ +} +EOF + cat > "$FIXTURE_ROOT/qa/qa-test-cases-unreleased.yaml" <<'EOF' +test_cases: + - id: foo-001 + automated: true +EOF + + run "$SCRIPT" 2.0.0 + [[ "$status" -eq 1 ]] + [[ "$output" =~ "version not set" ]] +} + +# ── YAML content preservation ────────────────────────────────────────────────── + +@test "renamed YAML preserves original content" { + setup_fixture + write_package_json <<'EOF' +{ + "version": "1.0.0" +} +EOF + cat > "$FIXTURE_ROOT/qa/qa-test-cases-unreleased.yaml" <<'EOF' +test_cases: + - id: foo-001 + scenario: 'Original scenario text' + automated: true + - id: foo-002 + scenario: 'Another scenario' + automated: false + non_automatable_reason: 'platform-specific' +EOF + + run "$SCRIPT" 2.0.0 + [[ "$status" -eq 0 ]] + + local content + content=$(cat "$FIXTURE_ROOT/qa/qa-test-cases-v2.0.0.yaml") + grep -q "scenario: 'Original scenario text'" <<< "$content" + grep -q "scenario: 'Another scenario'" <<< "$content" + grep -q "non_automatable_reason: 'platform-specific'" <<< "$content" +} From 7366fdf4cb13e14e0ba474b1c55707e9cb9c556f Mon Sep 17 00:00:00 2001 From: Charles Ouimet Date: Tue, 26 May 2026 16:57:05 -0400 Subject: [PATCH 3/7] =?UTF-8?q?[feat]=20Add=20finalize-release.sh=20?= =?UTF-8?q?=E2=80=94=20hard-finalize=20a=20release=20for=20publishing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One-way-door script that finalizes a release after QA passes. Reads .version from package.json (no argument — the version was already locked by lock-version.sh), validates prerequisites (versioned YAML exists, CHANGELOG has [Unreleased] section, README has Unreleased markers), then: replaces ## [Unreleased] → ## [X.Y.Z] - YYYY-MM-DD in CHANGELOG, strips all Unreleased markers from README, removes the [!IMPORTANT] banner block, formats both files with prettier, and runs generate-publishing-instructions.sh to produce the publishing guide. 7 BATS tests cover the happy path, content preservation, and error paths (missing .version, dirty tree, no versioned YAML, no [Unreleased] section, no markers). --- .../scripts/finalize-release.sh | 95 +++++++ tests/shell/finalize-release.bats | 253 ++++++++++++++++++ 2 files changed, 348 insertions(+) create mode 100755 packages/rangelink-vscode-extension/scripts/finalize-release.sh create mode 100644 tests/shell/finalize-release.bats diff --git a/packages/rangelink-vscode-extension/scripts/finalize-release.sh b/packages/rangelink-vscode-extension/scripts/finalize-release.sh new file mode 100755 index 00000000..42c9a9ce --- /dev/null +++ b/packages/rangelink-vscode-extension/scripts/finalize-release.sh @@ -0,0 +1,95 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Usage: ./scripts/finalize-release.sh +# +# Hard-finalizes the release. Reads .version from package.json. One-way door — +# run once when QA is clean and you're ready to ship. +# +# Steps: +# 1. Finalize CHANGELOG: ## [Unreleased] → ## [X.Y.Z] - YYYY-MM-DD +# 2. Strip Unreleased markers from README +# 3. Remove [!IMPORTANT] banner from README +# 4. Generate publishing instructions +# +# Requires: jq + +RED='\033[0;31m' +GREEN='\033[0;32m' +NC='\033[0m' + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PACKAGE_DIR="$(dirname "$SCRIPT_DIR")" +REPO_ROOT="$(git -C "$PACKAGE_DIR" rev-parse --show-toplevel)" +PACKAGE_JSON="$PACKAGE_DIR/package.json" +QA_DIR="$PACKAGE_DIR/qa" +README="$PACKAGE_DIR/README.md" +CHANGELOG="$PACKAGE_DIR/CHANGELOG.md" + +# --- Read .version --- + +VERSION=$(jq -r '.version // empty' "$PACKAGE_JSON") +if [[ -z "$VERSION" ]]; then + echo -e "${RED}Error: .version not set in $PACKAGE_JSON${NC}" >&2 + exit 1 +fi + +if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo -e "${RED}Error: .version must be SemVer (X.Y.Z), got '$VERSION'${NC}" >&2 + exit 1 +fi + +# --- Working tree must be clean --- + +if ! git -C "$REPO_ROOT" diff-index --quiet HEAD --; then + echo -e "${RED}Error: working tree is dirty. Commit or stash changes first.${NC}" >&2 + exit 1 +fi + +# --- Prerequisites --- + +VERSIONED_YAML="$QA_DIR/qa-test-cases-v${VERSION}.yaml" +if [[ ! -f "$VERSIONED_YAML" ]]; then + echo -e "${RED}Error: $VERSIONED_YAML not found. Run lock-version.sh first.${NC}" >&2 + exit 1 +fi + +if ! grep -q '^## \[Unreleased\]$' "$CHANGELOG"; then + echo -e "${RED}Error: '## [Unreleased]' section not found in $CHANGELOG${NC}" >&2 + exit 1 +fi + +if ! grep -q 'Unreleased' "$README"; then + echo -e "${RED}Error: no Unreleased markers found in $README${NC}" >&2 + exit 1 +fi + +# --- Step 1: Finalize CHANGELOG --- + +TODAY=$(date -u +"%Y-%m-%d") +sed -i '' "s/^## \[Unreleased\]$/## [${VERSION}] - ${TODAY}/" "$CHANGELOG" +echo -e "${GREEN}Step 1: Finalized CHANGELOG — [Unreleased] → [${VERSION}] - ${TODAY}${NC}" + +# --- Step 2: Strip README Unreleased markers --- + +sed -i '' 's/Unreleased<\/sup>//g' "$README" +echo -e "${GREEN}Step 2: Stripped Unreleased markers from README${NC}" + +# --- Step 3: Strip README > [!IMPORTANT] banner --- + +sed -i '' '/^> \[!IMPORTANT\]/,/^$/d' "$README" +echo -e "${GREEN}Step 3: Removed [!IMPORTANT] banner from README${NC}" + +# --- Step 4: Format with prettier --- + +cd "$REPO_ROOT" +npx prettier --write "$README" "$CHANGELOG" > /dev/null 2>&1 +cd "$PACKAGE_DIR" + +# --- Step 5: Generate publishing instructions --- + +"$SCRIPT_DIR/generate-publishing-instructions.sh" +echo -e "${GREEN}Step 4: Generated publishing instructions${NC}" + +echo "" +echo -e "${GREEN}Release v${VERSION} finalized. Publishing instructions are ready.${NC}" diff --git a/tests/shell/finalize-release.bats b/tests/shell/finalize-release.bats new file mode 100644 index 00000000..dc244882 --- /dev/null +++ b/tests/shell/finalize-release.bats @@ -0,0 +1,253 @@ +#!/usr/bin/env bats + +load test_helper + +REAL_SCRIPT="$PROJECT_ROOT/packages/rangelink-vscode-extension/scripts/finalize-release.sh" + +setup_fixture() { + FIXTURE_ROOT="$TEST_TEMP_DIR" + mkdir -p "$FIXTURE_ROOT/scripts" + mkdir -p "$FIXTURE_ROOT/qa" + + cp "$REAL_SCRIPT" "$FIXTURE_ROOT/scripts/finalize-release.sh" + SCRIPT="$FIXTURE_ROOT/scripts/finalize-release.sh" + + stub_dir + make_stub "git" <<'ENDOFSTUB' +case "$*" in + *--show-toplevel*) echo "${FIXTURE_ROOT_FOR_GIT:-$TEST_TEMP_DIR}" ;; + *diff-index*) exit 0 ;; + *) exit 0 ;; +esac +ENDOFSTUB + export FIXTURE_ROOT_FOR_GIT="$FIXTURE_ROOT" + + make_passive_stub "npx" + + # Stub generate-publishing-instructions.sh to produce a dummy output file. + cat > "$FIXTURE_ROOT/scripts/generate-publishing-instructions.sh" <<'STUBEOF' +#!/usr/bin/env bash +VERSION=$(jq -r '.version // empty' "$(dirname "$(dirname "${BASH_SOURCE[0]}")")/package.json") +OUTPUT_DIR="$(dirname "$(dirname "${BASH_SOURCE[0]}")")/publishing-instructions" +mkdir -p "$OUTPUT_DIR" +cat > "$OUTPUT_DIR/publish-vscode-extension-v${VERSION}.md" <<'EOF' +# Publishing Instructions: RangeLink VS Code Extension +EOF +STUBEOF + chmod +x "$FIXTURE_ROOT/scripts/generate-publishing-instructions.sh" + + write_package_json() { + cat > "$FIXTURE_ROOT/package.json" + } + + write_changelog() { + cat > "$FIXTURE_ROOT/CHANGELOG.md" + } + + write_readme() { + cat > "$FIXTURE_ROOT/README.md" + } +} + +# ── Happy path ────────────────────────────────────────────────────────────────── + +@test "finalizes CHANGELOG, strips README markers, removes banner, generates publishing instructions" { + setup_fixture + write_package_json <<'EOF' +{ + "version": "2.0.0" +} +EOF + cat > "$FIXTURE_ROOT/qa/qa-test-cases-v2.0.0.yaml" <<'EOF' +test_cases: + - id: foo-001 +EOF + write_changelog <<'EOF' +# Changelog + +## [Unreleased] + +### Added + +- New feature +EOF + write_readme <<'EOF' +# My Extension + +> [!IMPORTANT] +> This has Unreleased features. + +## Features + +- Feature A Unreleased +- Feature B Unreleased +EOF + + run "$SCRIPT" + [[ "$status" -eq 0 ]] + + # CHANGELOG: [Unreleased] → [2.0.0] - YYYY-MM-DD. + [[ ! "$(grep -c '^## \[Unreleased\]$' "$FIXTURE_ROOT/CHANGELOG.md" || true)" -gt 0 ]] + grep -qE '^## \[2\.0\.0\] - [0-9]{4}-[0-9]{2}-[0-9]{2}$' "$FIXTURE_ROOT/CHANGELOG.md" + + # README: no Unreleased markers remain. + ! grep -q 'Unreleased' "$FIXTURE_ROOT/README.md" + + # README: [!IMPORTANT] banner removed. + ! grep -q '\[!IMPORTANT\]' "$FIXTURE_ROOT/README.md" + + # Publishing instructions generated. + [[ -f "$FIXTURE_ROOT/publishing-instructions/publish-vscode-extension-v2.0.0.md" ]] +} + +@test "README preserves non-banner content after stripping" { + setup_fixture + write_package_json <<'EOF' +{ + "version": "2.0.0" +} +EOF + cat > "$FIXTURE_ROOT/qa/qa-test-cases-v2.0.0.yaml" <<'EOF' +test_cases: + - id: foo-001 +EOF + write_changelog <<'EOF' +## [Unreleased] +EOF + write_readme <<'EOF' +# My Extension + +> [!IMPORTANT] +> This has Unreleased features. + +## Why RangeLink? + +Important content here. + +## Features + +- Feature A Unreleased +- Feature B +EOF + + run "$SCRIPT" + [[ "$status" -eq 0 ]] + + # Section headers preserved. + grep -q '^## Why RangeLink?$' "$FIXTURE_ROOT/README.md" + grep -q '^## Features$' "$FIXTURE_ROOT/README.md" + + # Non-unreleased content preserved. + grep -q 'Important content here' "$FIXTURE_ROOT/README.md" + grep -q 'Feature B' "$FIXTURE_ROOT/README.md" + + # Feature A no longer has the unreleased marker. + grep -q 'Feature A' "$FIXTURE_ROOT/README.md" + ! grep -q 'Unreleased' "$FIXTURE_ROOT/README.md" +} + +# ── Error paths ───────────────────────────────────────────────────────────────── + +@test "missing .version in package.json exits 1" { + setup_fixture + write_package_json <<'EOF' +{ +} +EOF + + run "$SCRIPT" + [[ "$status" -eq 1 ]] + [[ "$output" =~ "version not set" ]] +} + +@test "dirty working tree exits 1" { + setup_fixture + write_package_json <<'EOF' +{ + "version": "2.0.0" +} +EOF + cat > "$FIXTURE_ROOT/qa/qa-test-cases-v2.0.0.yaml" <<'EOF' +test_cases: [] +EOF + + # Override git stub to simulate dirty tree. + cat > "$STUB_DIR/git" <<'ENDOFSTUB' +#!/usr/bin/env bash +case "$*" in + *--show-toplevel*) echo "${FIXTURE_ROOT_FOR_GIT:-$TEST_TEMP_DIR}" ;; + *diff-index*) exit 1 ;; + *) exit 0 ;; +esac +ENDOFSTUB + + run "$SCRIPT" + [[ "$status" -eq 1 ]] + [[ "$output" =~ "dirty" ]] +} + +@test "no versioned YAML exits 1" { + setup_fixture + write_package_json <<'EOF' +{ + "version": "2.0.0" +} +EOF + # No qa-test-cases-v2.0.0.yaml written. + + run "$SCRIPT" + [[ "$status" -eq 1 ]] + [[ "$output" =~ "not found" ]] +} + +@test "no [Unreleased] in CHANGELOG exits 1" { + setup_fixture + write_package_json <<'EOF' +{ + "version": "2.0.0" +} +EOF + cat > "$FIXTURE_ROOT/qa/qa-test-cases-v2.0.0.yaml" <<'EOF' +test_cases: [] +EOF + write_changelog <<'EOF' +## [1.0.0] + +### Added + +- Old feature +EOF + write_readme <<'EOF' +# My Extension + +Unreleased +EOF + + run "$SCRIPT" + [[ "$status" -eq 1 ]] + [[ "$output" =~ "Unreleased" ]] +} + +@test "no Unreleased markers in README exits 1" { + setup_fixture + write_package_json <<'EOF' +{ + "version": "2.0.0" +} +EOF + cat > "$FIXTURE_ROOT/qa/qa-test-cases-v2.0.0.yaml" <<'EOF' +test_cases: [] +EOF + write_changelog <<'EOF' +## [Unreleased] +EOF + write_readme <<'EOF' +# My Extension + +Clean readme with no markers. +EOF + + run "$SCRIPT" + [[ "$status" -eq 1 ]] + [[ "$output" =~ "no Unreleased markers" ]] +} From bbb8f45104b69a0825ee662d08f552f5f837413f Mon Sep 17 00:00:00 2001 From: Charles Ouimet Date: Tue, 26 May 2026 17:37:21 -0400 Subject: [PATCH 4/7] [feat] Add start-release.sh, pnpm scripts, and update release docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Idempotent script that starts the next development cycle after a release. Reads .version from package.json, copies the versioned QA YAML → qa-test-cases-unreleased.yaml (skip if exists), prepends a fresh ## [Unreleased] header with empty Added/Changed/Fixed sections to CHANGELOG (skip if already present), and re-adds the > [!IMPORTANT] banner before the first ## header in README (skip if already present). Uses head/tail splitting with a fallback for the INSERT_LINE=1 edge case since macOS head -n 0 is illegal. 8 BATS tests cover the happy path, idempotency, partial re-run, YAML content preservation, CHANGELOG structure, banner content, and error paths. Added pnpm scripts for all three release scripts: lock-version, finalize-release, and start-release in the child package.json (alphabetically ordered), with delegating root-level scripts (e.g. pnpm lock-version:vscode-extension X.Y.Z). Updated all documentation references to use root-level pnpm commands instead of direct .sh script names, removed redundant -- separators (pnpm no longer needs them), and eliminated all cd commands from RELEASE-STRATEGY.md examples in favor of root-level pnpm scripts. TESTING.md: rewrote the Release QA Cycle section — consolidated the prose list into the release scripts reference table placed before the Mermaid diagram, replaced the nextTargetVersion filename-mirror sentence with the deferred-version convention, and mapped all commands to pnpm scripts. RELEASE-STRATEGY.md: deleted the "Stripping Markers at Release Time" section (duplicates what the scripts document), updated the High-Level Process with a nested numbered list under Prepare, rewrote Examples 2 & 3 using root-level pnpm scripts, removed the "Release v0.1.1 (Full Process)" Quick Reference, and marked "Release verification scripts" and "Pre-commit hooks" as done in Future Enhancements. --- docs/RELEASE-STRATEGY.md | 119 +++----- package.json | 3 + .../rangelink-vscode-extension/TESTING.md | 34 +-- .../rangelink-vscode-extension/package.json | 3 + .../scripts/start-release.sh | 144 +++++++++ tests/shell/start-release.bats | 287 ++++++++++++++++++ 6 files changed, 489 insertions(+), 101 deletions(-) create mode 100755 packages/rangelink-vscode-extension/scripts/start-release.sh create mode 100644 tests/shell/start-release.bats diff --git a/docs/RELEASE-STRATEGY.md b/docs/RELEASE-STRATEGY.md index c5e476d4..c61a60cb 100644 --- a/docs/RELEASE-STRATEGY.md +++ b/docs/RELEASE-STRATEGY.md @@ -125,16 +125,6 @@ When documenting a feature that hasn't been released yet, add `Unreleased [!IMPORTANT]` admonition about unreleased features) -2. Strip all `Unreleased` markers from the README -3. Verify no markers remain: `grep -r 'Unreleased' packages/` - -The publishing script (`generate-publishing-instructions.sh`) also checks for leftover markers and blocks publishing if any are found. This serves as a safety net in case manual stripping is forgotten. - --- ## Release Workflow @@ -143,11 +133,15 @@ The publishing script (`generate-publishing-instructions.sh`) also checks for le Releasing a package involves these phases: -1. **Prepare** - Bump version, update CHANGELOG, strip unreleased markers, commit changes +1. **Prepare** + 1. `pnpm lock-version:vscode-extension X.Y.Z` — soft-lock the version for QA + 2. Run QA pass (manual + automated TCs) + 3. `pnpm finalize-release:vscode-extension` — hard-finalize: bumps `.version`, updates CHANGELOG, strips README markers, generates publishing instructions 2. **Build & Test** - Package and validate locally 3. **Publish** - Deploy to marketplace(s) and create GitHub release 4. **Tag** - Create annotated git tag following [tagging convention](#tagging-convention) 5. **Verify** - Confirm publication and test installation +6. **Next cycle** — `pnpm start-release:vscode-extension` to begin the next development cycle ### Package-Specific Instructions @@ -326,44 +320,41 @@ git push origin vscode-extension-v0.1.0 ### Example 2: Second Release (v0.1.1) ```bash -# 1. Update version in package.json to 0.1.1 -# 2. Update CHANGELOG.md -git add packages/rangelink-vscode-extension/package.json -git add packages/rangelink-vscode-extension/CHANGELOG.md -git commit -m "chore(vscode-ext): bump version to 0.1.1" - -# 3. Build, test, package -pnpm clean && pnpm install && pnpm -r compile && pnpm -r test -cd packages/rangelink-vscode-extension -pnpm package - -# 4. Test locally -pnpm install-local:vscode - -# 5. Publish to marketplace -pnpm publish - -# 6. Tag and push -git tag -a vscode-extension-v0.1.1 -m "Release vscode-extension v0.1.1 - -Changes: -- Documentation improvements -- ESLint configuration fixes -" +# 1. Prepare: lock version, run QA, then finalize +pnpm lock-version:vscode-extension 0.1.1 +# ... QA pass ... +pnpm finalize-release:vscode-extension + +# 2. Build, test, package locally +pnpm package:vscode-extension +pnpm install-local:vscode-extension:both + +# 3. Publish to marketplace +pnpm publish:vscode-extension:vsix + +# 4. Tag and push +git tag -a vscode-extension-v0.1.1 -m "Release vscode-extension v0.1.1" git push origin vscode-extension-v0.1.1 -# 7. Create GitHub release with CHANGELOG content +# 5. Start next development cycle +pnpm start-release:vscode-extension + +# 6. Create GitHub release with CHANGELOG content ``` ### Example 3: Major Version Bump (v1.0.0) ```bash -# When ready for 1.0.0 (stable API, feature-complete) -# Same process, but version becomes 1.0.0 -git tag -a vscode-extension-v1.0.0 -m "Release vscode-extension v1.0.0 - -First stable release with complete feature set and stable API. -" +# Same process as Example 2, with the new version number. +pnpm lock-version:vscode-extension 1.0.0 +# ... QA pass ... +pnpm finalize-release:vscode-extension +pnpm package:vscode-extension +pnpm install-local:vscode-extension:both +pnpm publish:vscode-extension:vsix +git tag -a vscode-extension-v1.0.0 -m "Release vscode-extension v1.0.0" +git push origin vscode-extension-v1.0.0 +pnpm start-release:vscode-extension ``` ### Example 4: Multiple Package Release @@ -427,15 +418,8 @@ These items are planned but not yet implemented: - Automated CHANGELOG generation - Better monorepo version coordination -- [ ] **Pre-commit hooks** - - Enforce clean working tree before tagging - - Validate version numbers match across package.json and CHANGELOG - - Prevent accidental dirty releases - -- [ ] **Release verification scripts** - - Automated checks before publishing - - Version number validation - - CHANGELOG completeness check +- [x] **Pre-commit hooks** — working tree cleanliness enforced by the release scripts; remaining hook work: prevent commits that introduce version/CHANGELOG mismatches. +- [x] **Release verification scripts** — `pnpm lock-version:vscode-extension`, `pnpm finalize-release:vscode-extension`, and `pnpm generate:publish-instructions:vscode-extension` validate the working tree, version numbers, CHANGELOG, and unreleased markers before allowing each phase to proceed. --- @@ -457,36 +441,7 @@ git tag -a vscode-extension-v0.1.0 ff52f9a -m "Release vscode-extension v0.1.0" git push origin vscode-extension-v0.1.0 ``` -### Release v0.1.1 (Full Process) - -```bash -# 1. Version bump -cd packages/rangelink-vscode-extension -# Edit package.json: "version": "0.1.1" -# Edit CHANGELOG.md -git add package.json CHANGELOG.md -git commit -m "chore(vscode-ext): bump version to 0.1.1" - -# 2. Build and test -cd ../.. -pnpm clean && pnpm install && pnpm -r compile && pnpm -r test - -# 3. Package and test locally -cd packages/rangelink-vscode-extension -pnpm package -pnpm install-local:vscode - -# 4. Publish -pnpm publish - -# 5. Tag and release -cd ../.. -git tag -a vscode-extension-v0.1.1 -m "Release vscode-extension v0.1.1" -git push origin vscode-extension-v0.1.1 -# Then create GitHub release -``` - --- -**Last Updated:** 2025-11-02 -**Status:** Active - Manual release process in use +**Last Updated:** 2026-05-26 +**Status:** Active — automated via `pnpm lock-version:vscode-extension` → `pnpm finalize-release:vscode-extension` → `pnpm start-release:vscode-extension` diff --git a/package.json b/package.json index aeaff1aa..0414f4d3 100644 --- a/package.json +++ b/package.json @@ -16,11 +16,13 @@ "fix": "pnpm lint:fix && pnpm format:fix", "format": "prettier --check .", "format:fix": "prettier --write .", + "finalize-release:vscode-extension": "pnpm --filter rangelink-vscode-extension run finalize-release", "generate:publish-instructions:vscode-extension": "pnpm --filter rangelink-vscode-extension run generate:publish-instructions", "generate:qa-issue:vscode-extension": "pnpm --filter rangelink-vscode-extension run generate:qa-issue", "generate:qa-test-plan:vscode-extension": "pnpm --filter rangelink-vscode-extension run generate:qa-test-plan", "generate:release-testing-instructions:vscode-extension": "pnpm --filter rangelink-vscode-extension run generate:release-testing-instructions", "install-local:vscode-extension:both": "pnpm --filter rangelink-vscode-extension install-local", + "lock-version:vscode-extension": "pnpm --filter rangelink-vscode-extension run lock-version", "install-local:vscode-extension:cursor": "pnpm --filter rangelink-vscode-extension install-local:cursor", "install-local:vscode-extension:vscode": "pnpm --filter rangelink-vscode-extension install-local:vscode", "lint": "eslint .", @@ -29,6 +31,7 @@ "package:vscode-extension": "pnpm --filter rangelink-vscode-extension clean && pnpm --filter rangelink-vscode-extension compile && pnpm --filter rangelink-vscode-extension test && pnpm --filter rangelink-vscode-extension package", "package:vscode-extension:withInstall:both": "pnpm package:vscode-extension && pnpm install-local:vscode-extension:both", "publish:vscode-extension:vsix": "pnpm --filter rangelink-vscode-extension publish:vsix", + "start-release:vscode-extension": "pnpm --filter rangelink-vscode-extension run start-release", "test": "pnpm -r test", "test:bats": "bats tests/shell/", "test:release": "pnpm --filter rangelink-vscode-extension test:release", diff --git a/packages/rangelink-vscode-extension/TESTING.md b/packages/rangelink-vscode-extension/TESTING.md index 7855ea9e..cb7497a2 100644 --- a/packages/rangelink-vscode-extension/TESTING.md +++ b/packages/rangelink-vscode-extension/TESTING.md @@ -34,27 +34,23 @@ All `test:release*` commands accept `--label ` (include TCs with QA YAML la ### Release QA Cycle (once per release) -QA happens in Unreleased mode — `finalize-release` runs only after QA passes. This keeps the version unlocked during testing so bugs found during the QA pass can be fixed without re-finalizing. +| Script | When | Re-runnable? | What it does | +|--------|------|-------------|--------------| +| `pnpm lock-version:vscode-extension X.Y.Z` | Ready to start QA | Yes (idempotent) | Renames QA YAML → versioned, bumps `.version`, regenerates instructions | +| `pnpm finalize-release:vscode-extension` | QA passed, ready to ship | No (one-way door) | Finalizes CHANGELOG, strips README markers/banner, generates publishing instructions | +| `pnpm start-release:vscode-extension` | After publish, starting next cycle | Yes (idempotent) | Copies versioned YAML → unreleased, adds `[Unreleased]` CHANGELOG header, re-adds README banner | ```mermaid flowchart TD - Z[generate:release-testing-instructions] -.->|generates guide| A - A[nextTargetVersion: Unreleased] --> B[generate:qa-test-plan] - B --> C[/qa-suggest in Claude Code/] - C --> D[Review + append new TCs] - D --> E[Commit YAML] - E --> F[generate:qa-issue] - F --> G[Single GitHub issue with grep commands per section] - G --> H[package:vscode-extension:withInstall:both] - H --> I[Manual QA pass — launch editor with fixture workspace] - I --> I1[Ready-now TCs — no setup needed] - I1 --> I2[Open terminals + bind] - I2 --> I3[Terminal-dependent TCs] - I4 --> J{All TCs pass?} - J -- No --> K[Fix bugs + re-run affected TCs] - K --> J - J -- Yes --> L[finalize-release X.Y.Z] - L --> M[build VSIX + publish] + A[Version: Unreleased (deferred)] --> B[lock-version.sh X.Y.Z] + B --> C[QA pass — manual + automated TCs] + C --> D{All TCs pass?} + D -- No --> E[Fix bugs] + E --> C + D -- Yes --> F[finalize-release.sh] + F --> G[build VSIX + publish] + G --> H[start-release.sh] + H --> A ``` --- @@ -283,7 +279,7 @@ qa/qa-test-cases-unreleased.yaml During trunk-based development the file is `qa/qa-test-cases-unreleased.yaml`. At release time `finalize-release` renames it to `qa/qa-test-cases-v.yaml`. -The filename mirrors `nextTargetVersion` from `package.json` (`"Unreleased"` during development). It is parsed automatically by the `generate-qa-issue` script — no extra flags needed. One file per release — Git tracks history across versions. +The filename is always `qa-test-cases-unreleased.yaml` during trunk-based development — the version is deferred until `pnpm lock-version:vscode-extension` locks it in at QA time. It is parsed automatically by the `generate-qa-issue` script — no extra flags needed. One file per release — Git tracks history across versions. New QA YAML files are created by `pnpm generate:qa-test-plan`. The script carries forward all TCs from the most recent YAML, resets `status:` fields to `pending`, and preserves `automated:` flags. diff --git a/packages/rangelink-vscode-extension/package.json b/packages/rangelink-vscode-extension/package.json index 4a98a0b4..6ef4d01a 100644 --- a/packages/rangelink-vscode-extension/package.json +++ b/packages/rangelink-vscode-extension/package.json @@ -36,6 +36,7 @@ "clean:all": "pnpm clean && rm -rf node_modules .eslintcache *.log .vscode-test", "compile": "pnpm compile:deps && pnpm generate-version:all && node esbuild.config.js", "compile:deps": "cd ../barebone-logger && pnpm clean && pnpm compile && cd ../barebone-logger-testing && pnpm clean && pnpm compile && cd ../rangelink-core-ts && pnpm clean && pnpm compile", + "finalize-release": "./scripts/finalize-release.sh", "generate-version": "node scripts/generate-version.js --copy-to out", "generate-version:all": "node scripts/generate-version.js --copy-to out,dist", "generate:publish-instructions": "./scripts/generate-publishing-instructions.sh", @@ -45,8 +46,10 @@ "install-local": "./scripts/install-local.sh", "install-local:cursor": "./scripts/install-local.sh cursor", "install-local:vscode": "./scripts/install-local.sh vscode", + "lock-version": "./scripts/lock-version.sh", "package": "rm -rf *.vsix && ../../scripts/sync-assets.sh && rm -f rangelink-vscode-extension-*.vsix && vsce package --no-dependencies", "publish:vsix": "./scripts/publish-from-vsix.sh", + "start-release": "./scripts/start-release.sh", "test": "jest --coverage", "test:coverage": "jest --coverage --coverageReporters=text --coverageReporters=text-summary --coverageReporters=html", "test:fast": "jest --coverage --testPathIgnorePatterns '/src/__integration-tests__/' '\\.integration\\.test\\.ts$'", diff --git a/packages/rangelink-vscode-extension/scripts/start-release.sh b/packages/rangelink-vscode-extension/scripts/start-release.sh new file mode 100755 index 00000000..dbb4a970 --- /dev/null +++ b/packages/rangelink-vscode-extension/scripts/start-release.sh @@ -0,0 +1,144 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Usage: ./scripts/start-release.sh +# +# Starts the next development cycle after a release. Reads .version from +# package.json to find the just-shipped versioned YAML. Idempotent — safe +# to re-run. +# +# Steps: +# 1. Copy versioned YAML → qa-test-cases-unreleased.yaml +# 2. Prepend [Unreleased] header with empty sections to CHANGELOG +# 3. Re-add [!IMPORTANT] banner to README +# +# Requires: jq + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PACKAGE_DIR="$(dirname "$SCRIPT_DIR")" +REPO_ROOT="$(git -C "$PACKAGE_DIR" rev-parse --show-toplevel)" +PACKAGE_JSON="$PACKAGE_DIR/package.json" +QA_DIR="$PACKAGE_DIR/qa" +README="$PACKAGE_DIR/README.md" +CHANGELOG="$PACKAGE_DIR/CHANGELOG.md" + +# --- Read .version --- + +VERSION=$(jq -r '.version // empty' "$PACKAGE_JSON") +if [[ -z "$VERSION" ]]; then + echo -e "${RED}Error: .version not set in $PACKAGE_JSON${NC}" >&2 + exit 1 +fi + +if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo -e "${RED}Error: .version must be SemVer (X.Y.Z), got '$VERSION'${NC}" >&2 + exit 1 +fi + +# --- Prerequisites --- + +VERSIONED_YAML="$QA_DIR/qa-test-cases-v${VERSION}.yaml" +if [[ ! -f "$VERSIONED_YAML" ]]; then + echo -e "${RED}Error: $VERSIONED_YAML not found. Run finalize-release.sh first.${NC}" >&2 + exit 1 +fi + +CHANGED=false + +# --- Step 1: Copy YAML --- + +UNRELEASED_YAML="$QA_DIR/qa-test-cases-unreleased.yaml" +if [[ -f "$UNRELEASED_YAML" ]]; then + echo -e "${YELLOW}Step 1: $UNRELEASED_YAML already exists — skipped.${NC}" +else + cp "$VERSIONED_YAML" "$UNRELEASED_YAML" + echo -e "${GREEN}Step 1: Copied qa-test-cases-v${VERSION}.yaml → qa-test-cases-unreleased.yaml${NC}" + CHANGED=true +fi + +# --- Step 2: Prepend [Unreleased] header to CHANGELOG --- + +if grep -q '^## \[Unreleased\]$' "$CHANGELOG"; then + echo -e "${YELLOW}Step 2: [Unreleased] section already exists in CHANGELOG — skipped.${NC}" +else + INSERT_LINE=$(grep -n '^## \[' "$CHANGELOG" | head -1 | cut -d: -f1) + if [[ "$INSERT_LINE" -eq 1 ]]; then + cat > "$CHANGELOG.tmp" <<'HEADER' +## [Unreleased] + +### Added + +### Changed + +### Fixed + +HEADER + cat "$CHANGELOG" >> "$CHANGELOG.tmp" + else + head -n $((INSERT_LINE - 1)) "$CHANGELOG" > "$CHANGELOG.tmp" + cat >> "$CHANGELOG.tmp" <<'HEADER' +## [Unreleased] + +### Added + +### Changed + +### Fixed + +HEADER + tail -n +"$INSERT_LINE" "$CHANGELOG" >> "$CHANGELOG.tmp" + fi + mv "$CHANGELOG.tmp" "$CHANGELOG" + echo -e "${GREEN}Step 2: Prepended [Unreleased] header to CHANGELOG${NC}" + CHANGED=true +fi + +# --- Step 3: Re-add [!IMPORTANT] banner to README --- + +if grep -q '\[!IMPORTANT\]' "$README"; then + echo -e "${YELLOW}Step 3: [!IMPORTANT] banner already exists in README — skipped.${NC}" +else + INSERT_LINE=$(grep -n '^## ' "$README" | head -1 | cut -d: -f1) + if [[ "$INSERT_LINE" -eq 1 ]]; then + cat > "$README.tmp" <<'BANNER' +> [!IMPORTANT] +> This documentation is for the `main` branch and may include unreleased features marked with Unreleased. +> Install the latest published version from the [VS Code Marketplace](https://marketplace.visualstudio.com/items?itemName=couimet.rangelink-vscode-extension) or [Open VSX Registry](https://open-vsx.org/extension/couimet/rangelink-vscode-extension) (Cursor) for currently available features. + +BANNER + cat "$README" >> "$README.tmp" + else + head -n $((INSERT_LINE - 1)) "$README" > "$README.tmp" + cat >> "$README.tmp" <<'BANNER' +> [!IMPORTANT] +> This documentation is for the `main` branch and may include unreleased features marked with Unreleased. +> Install the latest published version from the [VS Code Marketplace](https://marketplace.visualstudio.com/items?itemName=couimet.rangelink-vscode-extension) or [Open VSX Registry](https://open-vsx.org/extension/couimet/rangelink-vscode-extension) (Cursor) for currently available features. + +BANNER + tail -n +"$INSERT_LINE" "$README" >> "$README.tmp" + fi + mv "$README.tmp" "$README" + echo -e "${GREEN}Step 3: Added [!IMPORTANT] banner to README${NC}" + CHANGED=true +fi + +# --- Format with prettier --- + +cd "$REPO_ROOT" +npx prettier --write "$README" "$CHANGELOG" > /dev/null 2>&1 +cd "$PACKAGE_DIR" + +# --- Summary --- + +if [[ "$CHANGED" == "true" ]]; then + echo "" + echo -e "${GREEN}Next development cycle started. Begin adding features to the [Unreleased] section.${NC}" +else + echo "" + echo -e "${GREEN}Already in development cycle. Nothing to do.${NC}" +fi diff --git a/tests/shell/start-release.bats b/tests/shell/start-release.bats new file mode 100644 index 00000000..1eb8b618 --- /dev/null +++ b/tests/shell/start-release.bats @@ -0,0 +1,287 @@ +#!/usr/bin/env bats + +load test_helper + +REAL_SCRIPT="$PROJECT_ROOT/packages/rangelink-vscode-extension/scripts/start-release.sh" + +setup_fixture() { + FIXTURE_ROOT="$TEST_TEMP_DIR" + mkdir -p "$FIXTURE_ROOT/scripts" + mkdir -p "$FIXTURE_ROOT/qa" + + cp "$REAL_SCRIPT" "$FIXTURE_ROOT/scripts/start-release.sh" + SCRIPT="$FIXTURE_ROOT/scripts/start-release.sh" + + stub_dir + make_stub "git" <<'ENDOFSTUB' +case "$*" in + *--show-toplevel*) echo "${FIXTURE_ROOT_FOR_GIT:-$TEST_TEMP_DIR}" ;; + *) exit 0 ;; +esac +ENDOFSTUB + export FIXTURE_ROOT_FOR_GIT="$FIXTURE_ROOT" + + make_passive_stub "npx" + + write_package_json() { + cat > "$FIXTURE_ROOT/package.json" + } + + write_changelog() { + cat > "$FIXTURE_ROOT/CHANGELOG.md" + } + + write_readme() { + cat > "$FIXTURE_ROOT/README.md" + } +} + +# ── Happy path ────────────────────────────────────────────────────────────────── + +@test "copies YAML, adds CHANGELOG header, adds README banner" { + setup_fixture + write_package_json <<'EOF' +{ + "version": "2.0.0" +} +EOF + cat > "$FIXTURE_ROOT/qa/qa-test-cases-v2.0.0.yaml" <<'EOF' +test_cases: + - id: foo-001 + scenario: 'Test scenario' + automated: true +EOF + write_changelog <<'EOF' +# Changelog + +## [2.0.0] - 2026-05-26 + +### Added + +- New feature +EOF + write_readme <<'EOF' +# My Extension + +> **Tagline here.** + +## Why RangeLink? + +Important content. +EOF + + run "$SCRIPT" + [[ "$status" -eq 0 ]] + + # YAML copied. + [[ -f "$FIXTURE_ROOT/qa/qa-test-cases-unreleased.yaml" ]] + + # CHANGELOG: [Unreleased] header prepended before [2.0.0]. + grep -q '^## \[Unreleased\]$' "$FIXTURE_ROOT/CHANGELOG.md" + grep -q '^## \[2\.0\.0\]' "$FIXTURE_ROOT/CHANGELOG.md" + # [Unreleased] appears before [2.0.0]. + local unreleased_line version_line + unreleased_line=$(grep -n '^## \[Unreleased\]$' "$FIXTURE_ROOT/CHANGELOG.md" | cut -d: -f1) + version_line=$(grep -n '^## \[2\.0\.0\]' "$FIXTURE_ROOT/CHANGELOG.md" | cut -d: -f1) + [[ "$unreleased_line" -lt "$version_line" ]] + + # README: banner added. + grep -q '\[!IMPORTANT\]' "$FIXTURE_ROOT/README.md" + # Banner appears before first ## header. + local banner_line first_section_line + banner_line=$(grep -n '\[!IMPORTANT\]' "$FIXTURE_ROOT/README.md" | cut -d: -f1) + first_section_line=$(grep -n '^## ' "$FIXTURE_ROOT/README.md" | head -1 | cut -d: -f1) + [[ "$banner_line" -lt "$first_section_line" ]] +} + +@test "YAML copy preserves all TCs from versioned file" { + setup_fixture + write_package_json <<'EOF' +{ + "version": "2.0.0" +} +EOF + cat > "$FIXTURE_ROOT/qa/qa-test-cases-v2.0.0.yaml" <<'EOF' +test_cases: + - id: foo-001 + scenario: 'Original scenario' + automated: true + - id: foo-002 + scenario: 'Another scenario' + automated: false + non_automatable_reason: 'platform-specific' +EOF + write_changelog <<'EOF' +## [2.0.0] - 2026-05-26 +EOF + write_readme <<'EOF' +## Why RangeLink? +EOF + + run "$SCRIPT" + [[ "$status" -eq 0 ]] + + local copied + copied=$(cat "$FIXTURE_ROOT/qa/qa-test-cases-unreleased.yaml") + grep -q "scenario: 'Original scenario'" <<< "$copied" + grep -q "scenario: 'Another scenario'" <<< "$copied" + grep -q "non_automatable_reason: 'platform-specific'" <<< "$copied" +} + +@test "CHANGELOG: [Unreleased] has empty sections and existing content preserved below" { + setup_fixture + write_package_json <<'EOF' +{ + "version": "2.0.0" +} +EOF + cat > "$FIXTURE_ROOT/qa/qa-test-cases-v2.0.0.yaml" <<'EOF' +test_cases: [] +EOF + write_changelog <<'EOF' +# Changelog + +## [2.0.0] - 2026-05-26 + +### Added + +- Existing feature +EOF + write_readme <<'EOF' +## Why RangeLink? +EOF + + run "$SCRIPT" + [[ "$status" -eq 0 ]] + + # Empty sections present. + grep -q '^### Added$' "$FIXTURE_ROOT/CHANGELOG.md" + grep -q '^### Changed$' "$FIXTURE_ROOT/CHANGELOG.md" + grep -q '^### Fixed$' "$FIXTURE_ROOT/CHANGELOG.md" + + # Existing content preserved. + grep -q 'Existing feature' "$FIXTURE_ROOT/CHANGELOG.md" + grep -q '^## \[2\.0\.0\]' "$FIXTURE_ROOT/CHANGELOG.md" +} + +@test "README: banner contains full text and unreleased markers" { + setup_fixture + write_package_json <<'EOF' +{ + "version": "2.0.0" +} +EOF + cat > "$FIXTURE_ROOT/qa/qa-test-cases-v2.0.0.yaml" <<'EOF' +test_cases: [] +EOF + write_changelog <<'EOF' +## [2.0.0] - 2026-05-26 +EOF + write_readme <<'EOF' +## Why RangeLink? +EOF + + run "$SCRIPT" + [[ "$status" -eq 0 ]] + + # Full banner content. + grep -q '> \[!IMPORTANT\]' "$FIXTURE_ROOT/README.md" + grep -q 'Unreleased' "$FIXTURE_ROOT/README.md" + grep -q 'VS Code Marketplace' "$FIXTURE_ROOT/README.md" + grep -q 'Open VSX Registry' "$FIXTURE_ROOT/README.md" +} + +# ── Idempotency ───────────────────────────────────────────────────────────────── + +@test "re-run prints nothing-to-do message and exits cleanly" { + setup_fixture + write_package_json <<'EOF' +{ + "version": "2.0.0" +} +EOF + cat > "$FIXTURE_ROOT/qa/qa-test-cases-v2.0.0.yaml" <<'EOF' +test_cases: [] +EOF + # Simulate already-started state: unreleased YAML, CHANGELOG header, README banner. + cp "$FIXTURE_ROOT/qa/qa-test-cases-v2.0.0.yaml" \ + "$FIXTURE_ROOT/qa/qa-test-cases-unreleased.yaml" + write_changelog <<'EOF' +## [Unreleased] + +### Added + +### Changed + +### Fixed + +## [2.0.0] - 2026-05-26 +EOF + write_readme <<'EOF' +> [!IMPORTANT] +> Banner content here. + +## Why RangeLink? +EOF + + run "$SCRIPT" + [[ "$status" -eq 0 ]] + [[ "$output" =~ "Nothing to do" ]] +} + +@test "re-run after partial completion picks up remaining steps" { + setup_fixture + write_package_json <<'EOF' +{ + "version": "2.0.0" +} +EOF + cat > "$FIXTURE_ROOT/qa/qa-test-cases-v2.0.0.yaml" <<'EOF' +test_cases: + - id: foo-001 +EOF + # Partial state: unreleased YAML exists but CHANGELOG and README not updated. + cp "$FIXTURE_ROOT/qa/qa-test-cases-v2.0.0.yaml" \ + "$FIXTURE_ROOT/qa/qa-test-cases-unreleased.yaml" + write_changelog <<'EOF' +## [2.0.0] - 2026-05-26 +EOF + write_readme <<'EOF' +## Why RangeLink? +EOF + + run "$SCRIPT" + [[ "$status" -eq 0 ]] + + # Should have added CHANGELOG header and README banner (the missing steps). + grep -q '^## \[Unreleased\]$' "$FIXTURE_ROOT/CHANGELOG.md" + grep -q '\[!IMPORTANT\]' "$FIXTURE_ROOT/README.md" +} + +# ── Error paths ───────────────────────────────────────────────────────────────── + +@test "missing .version in package.json exits 1" { + setup_fixture + write_package_json <<'EOF' +{ +} +EOF + + run "$SCRIPT" + [[ "$status" -eq 1 ]] + [[ "$output" =~ "version not set" ]] +} + +@test "no versioned YAML matching .version exits 1" { + setup_fixture + write_package_json <<'EOF' +{ + "version": "2.0.0" +} +EOF + # No qa-test-cases-v2.0.0.yaml written. + + run "$SCRIPT" + [[ "$status" -eq 1 ]] + [[ "$output" =~ "not found" ]] +} From c55ec379d6149b64394707da36e4adc0f1e997ba Mon Sep 17 00:00:00 2001 From: Charles Ouimet Date: Tue, 26 May 2026 17:42:01 -0400 Subject: [PATCH 5/7] Ran formatting --- package.json | 4 ++-- packages/rangelink-vscode-extension/TESTING.md | 10 +++++----- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/package.json b/package.json index 0414f4d3..a95688e0 100644 --- a/package.json +++ b/package.json @@ -13,20 +13,20 @@ "clean:all": "pnpm -r clean:all", "compile": "pnpm clean && find . -name '*.tsbuildinfo' -delete && pnpm -r --workspace-concurrency=1 compile", "enable-pnpm": "corepack enable", + "finalize-release:vscode-extension": "pnpm --filter rangelink-vscode-extension run finalize-release", "fix": "pnpm lint:fix && pnpm format:fix", "format": "prettier --check .", "format:fix": "prettier --write .", - "finalize-release:vscode-extension": "pnpm --filter rangelink-vscode-extension run finalize-release", "generate:publish-instructions:vscode-extension": "pnpm --filter rangelink-vscode-extension run generate:publish-instructions", "generate:qa-issue:vscode-extension": "pnpm --filter rangelink-vscode-extension run generate:qa-issue", "generate:qa-test-plan:vscode-extension": "pnpm --filter rangelink-vscode-extension run generate:qa-test-plan", "generate:release-testing-instructions:vscode-extension": "pnpm --filter rangelink-vscode-extension run generate:release-testing-instructions", "install-local:vscode-extension:both": "pnpm --filter rangelink-vscode-extension install-local", - "lock-version:vscode-extension": "pnpm --filter rangelink-vscode-extension run lock-version", "install-local:vscode-extension:cursor": "pnpm --filter rangelink-vscode-extension install-local:cursor", "install-local:vscode-extension:vscode": "pnpm --filter rangelink-vscode-extension install-local:vscode", "lint": "eslint .", "lint:fix": "eslint . --fix", + "lock-version:vscode-extension": "pnpm --filter rangelink-vscode-extension run lock-version", "package:prepare": "pnpm clean:all && pnpm install", "package:vscode-extension": "pnpm --filter rangelink-vscode-extension clean && pnpm --filter rangelink-vscode-extension compile && pnpm --filter rangelink-vscode-extension test && pnpm --filter rangelink-vscode-extension package", "package:vscode-extension:withInstall:both": "pnpm package:vscode-extension && pnpm install-local:vscode-extension:both", diff --git a/packages/rangelink-vscode-extension/TESTING.md b/packages/rangelink-vscode-extension/TESTING.md index cb7497a2..6edcdad6 100644 --- a/packages/rangelink-vscode-extension/TESTING.md +++ b/packages/rangelink-vscode-extension/TESTING.md @@ -34,11 +34,11 @@ All `test:release*` commands accept `--label ` (include TCs with QA YAML la ### Release QA Cycle (once per release) -| Script | When | Re-runnable? | What it does | -|--------|------|-------------|--------------| -| `pnpm lock-version:vscode-extension X.Y.Z` | Ready to start QA | Yes (idempotent) | Renames QA YAML → versioned, bumps `.version`, regenerates instructions | -| `pnpm finalize-release:vscode-extension` | QA passed, ready to ship | No (one-way door) | Finalizes CHANGELOG, strips README markers/banner, generates publishing instructions | -| `pnpm start-release:vscode-extension` | After publish, starting next cycle | Yes (idempotent) | Copies versioned YAML → unreleased, adds `[Unreleased]` CHANGELOG header, re-adds README banner | +| Script | When | Re-runnable? | What it does | +| ------------------------------------------ | ---------------------------------- | ----------------- | ----------------------------------------------------------------------------------------------- | +| `pnpm lock-version:vscode-extension X.Y.Z` | Ready to start QA | Yes (idempotent) | Renames QA YAML → versioned, bumps `.version`, regenerates instructions | +| `pnpm finalize-release:vscode-extension` | QA passed, ready to ship | No (one-way door) | Finalizes CHANGELOG, strips README markers/banner, generates publishing instructions | +| `pnpm start-release:vscode-extension` | After publish, starting next cycle | Yes (idempotent) | Copies versioned YAML → unreleased, adds `[Unreleased]` CHANGELOG header, re-adds README banner | ```mermaid flowchart TD From f7e608a18624a26664323a7a864de5f4f2983590 Mon Sep 17 00:00:00 2001 From: Charles Ouimet Date: Wed, 27 May 2026 07:08:51 -0400 Subject: [PATCH 6/7] Rebase on origin/main --- tests/shell/generate-qa-test-plan.bats | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/tests/shell/generate-qa-test-plan.bats b/tests/shell/generate-qa-test-plan.bats index 8b35db1a..207c64c9 100644 --- a/tests/shell/generate-qa-test-plan.bats +++ b/tests/shell/generate-qa-test-plan.bats @@ -168,8 +168,7 @@ EOF setup_fixture write_package_json <<'EOF' { - "version": "1.10.0", - "nextTargetVersion": "2.0.0" + "version": "1.10.0" } EOF write_yaml "qa-test-cases-v1.9.0.yaml" <<'EOF' @@ -187,8 +186,8 @@ EOF run "$SCRIPT" [[ "$status" -eq 0 ]] - grep -q "scenario: 'from newer version'" "$FIXTURE_ROOT/qa/qa-test-cases-v2.0.0.yaml" - ! grep -q "scenario: 'from older version'" "$FIXTURE_ROOT/qa/qa-test-cases-v2.0.0.yaml" + grep -q "scenario: 'from newer version'" "$FIXTURE_ROOT/qa/qa-test-cases-unreleased.yaml" + ! grep -q "scenario: 'from older version'" "$FIXTURE_ROOT/qa/qa-test-cases-unreleased.yaml" } # ── Error paths ──────────────────────────────────────────────────────────────── From 1c7e1cc052a5c147b8d50dd3d5c326ea753d59f6 Mon Sep 17 00:00:00 2001 From: Charles Ouimet Date: Wed, 27 May 2026 07:44:24 -0400 Subject: [PATCH 7/7] [PR feedback] Fix docs ownership errors, sed portability, and script guards Corrected several places that attributed .version bumping and YAML renaming to finalize-release when lock-version performs those steps. Replaced BSD-only sed -i '' with portable sed -i.bak + rm .bak in finalize-release.sh and lock-version.sh. Added INSERT_LINE guard and partial-run recovery to start-release.sh and lock-version.sh. Benefits: - Scripts work on both macOS (BSD sed) and Ubuntu CI (GNU sed) - Docs accurately reflect which script owns each release step - Lock and start scripts handle edge cases (empty grep results, partial prior runs) - SKILL.md no longer references the removed nextTargetVersion field Ref: https://github.com/couimet/rangeLink/pull/606#pullrequestreview-4371920981 --- .claude/skills/qa-suggest/SKILL.md | 14 +------------- docs/RELEASE-STRATEGY.md | 2 +- packages/rangelink-vscode-extension/TESTING.md | 2 +- .../scripts/finalize-release.sh | 6 +++--- .../scripts/lock-version.sh | 8 ++++++-- .../scripts/start-release.sh | 4 ++-- 6 files changed, 14 insertions(+), 22 deletions(-) diff --git a/.claude/skills/qa-suggest/SKILL.md b/.claude/skills/qa-suggest/SKILL.md index 4b7a61f6..85b2c2f1 100644 --- a/.claude/skills/qa-suggest/SKILL.md +++ b/.claude/skills/qa-suggest/SKILL.md @@ -143,9 +143,7 @@ Create a scratchpad file for the report. Use the `/scratchpad` conventions: 1. Determine the issue context from the current git branch (e.g., `issues/382` → issue ID `382`) 2. Find the next available sequence number in `.claude-work/issues//scratchpads/` -3. Write the scratchpad. Choose the filename based on `nextTargetVersion`: - - If `nextTargetVersion` is `"Unreleased"`: `.claude-work/issues//scratchpads/NNNN-qa-suggest.txt` - - If `nextTargetVersion` is a locked SemVer (e.g., `"2.0.0"`): `.claude-work/issues//scratchpads/NNNN-qa-suggest-v.txt` +3. Write the scratchpad to `.claude-work/issues//scratchpads/NNNN-qa-suggest.txt` If no issue context can be determined, use `.claude-work/scratchpads/` instead. @@ -153,20 +151,10 @@ The scratchpad should contain these sections in order: ### Header -If `nextTargetVersion` is `"Unreleased"`, use this header: - ```text # QA Suggest — v → Unreleased ``` -If `nextTargetVersion` is a locked SemVer (e.g., `"2.0.0"`), use this header instead: - -```text -# QA Suggest — v → v -``` - -Then continue with the shared body: - ## What to do next 1. Review the suggested TCs below — edit descriptions, remove irrelevant ones diff --git a/docs/RELEASE-STRATEGY.md b/docs/RELEASE-STRATEGY.md index c61a60cb..bc32cd1d 100644 --- a/docs/RELEASE-STRATEGY.md +++ b/docs/RELEASE-STRATEGY.md @@ -136,7 +136,7 @@ Releasing a package involves these phases: 1. **Prepare** 1. `pnpm lock-version:vscode-extension X.Y.Z` — soft-lock the version for QA 2. Run QA pass (manual + automated TCs) - 3. `pnpm finalize-release:vscode-extension` — hard-finalize: bumps `.version`, updates CHANGELOG, strips README markers, generates publishing instructions + 3. `pnpm finalize-release:vscode-extension` — hard-finalize: updates CHANGELOG, strips README markers, generates publishing instructions 2. **Build & Test** - Package and validate locally 3. **Publish** - Deploy to marketplace(s) and create GitHub release 4. **Tag** - Create annotated git tag following [tagging convention](#tagging-convention) diff --git a/packages/rangelink-vscode-extension/TESTING.md b/packages/rangelink-vscode-extension/TESTING.md index 6edcdad6..4cf8a15a 100644 --- a/packages/rangelink-vscode-extension/TESTING.md +++ b/packages/rangelink-vscode-extension/TESTING.md @@ -277,7 +277,7 @@ The QA test plan is a version-scoped YAML file that tracks both automated and ma qa/qa-test-cases-unreleased.yaml ``` -During trunk-based development the file is `qa/qa-test-cases-unreleased.yaml`. At release time `finalize-release` renames it to `qa/qa-test-cases-v.yaml`. +During trunk-based development the file is `qa/qa-test-cases-unreleased.yaml`. At release time `pnpm lock-version:vscode-extension` renames it to `qa/qa-test-cases-v.yaml`. The filename is always `qa-test-cases-unreleased.yaml` during trunk-based development — the version is deferred until `pnpm lock-version:vscode-extension` locks it in at QA time. It is parsed automatically by the `generate-qa-issue` script — no extra flags needed. One file per release — Git tracks history across versions. diff --git a/packages/rangelink-vscode-extension/scripts/finalize-release.sh b/packages/rangelink-vscode-extension/scripts/finalize-release.sh index 42c9a9ce..60bc4701 100755 --- a/packages/rangelink-vscode-extension/scripts/finalize-release.sh +++ b/packages/rangelink-vscode-extension/scripts/finalize-release.sh @@ -67,17 +67,17 @@ fi # --- Step 1: Finalize CHANGELOG --- TODAY=$(date -u +"%Y-%m-%d") -sed -i '' "s/^## \[Unreleased\]$/## [${VERSION}] - ${TODAY}/" "$CHANGELOG" +sed -i.bak "s/^## \[Unreleased\]$/## [${VERSION}] - ${TODAY}/" "$CHANGELOG" && rm -f "${CHANGELOG}.bak" echo -e "${GREEN}Step 1: Finalized CHANGELOG — [Unreleased] → [${VERSION}] - ${TODAY}${NC}" # --- Step 2: Strip README Unreleased markers --- -sed -i '' 's/Unreleased<\/sup>//g' "$README" +sed -i.bak 's/Unreleased<\/sup>//g' "$README" && rm -f "${README}.bak" echo -e "${GREEN}Step 2: Stripped Unreleased markers from README${NC}" # --- Step 3: Strip README > [!IMPORTANT] banner --- -sed -i '' '/^> \[!IMPORTANT\]/,/^$/d' "$README" +sed -i.bak '/^> \[!IMPORTANT\]/,/^$/d' "$README" && rm -f "${README}.bak" echo -e "${GREEN}Step 3: Removed [!IMPORTANT] banner from README${NC}" # --- Step 4: Format with prettier --- diff --git a/packages/rangelink-vscode-extension/scripts/lock-version.sh b/packages/rangelink-vscode-extension/scripts/lock-version.sh index 80566b4c..0ab710c0 100755 --- a/packages/rangelink-vscode-extension/scripts/lock-version.sh +++ b/packages/rangelink-vscode-extension/scripts/lock-version.sh @@ -65,6 +65,10 @@ fi # --- Prerequisites --- if [[ ! -f "$UNRELEASED_YAML" ]]; then + if compgen -G "${QA_DIR}/qa-test-cases-v*.yaml" > /dev/null; then + echo -e "${GREEN}A versioned YAML already exists — prior run completed.${NC}" + exit 0 + fi echo -e "${RED}Error: $UNRELEASED_YAML not found — nothing to lock.${NC}" >&2 exit 1 fi @@ -110,11 +114,11 @@ else mv "$UNRELEASED_INSTRUCTIONS" "$VERSIONED_INSTRUCTIONS" # Fixup internal references from unreleased → versioned. - sed -i '' \ + sed -i.bak \ -e "s/qa-test-cases-unreleased\.yaml/qa-test-cases-v${VERSION}.yaml/g" \ -e "s/qa-checklist-unreleased/qa-checklist-v${VERSION}/g" \ -e "s/ → Unreleased/ → v${VERSION}/g" \ - "$VERSIONED_INSTRUCTIONS" + "$VERSIONED_INSTRUCTIONS" && rm -f "${VERSIONED_INSTRUCTIONS}.bak" echo -e "${GREEN}Step 3: Generated release-testing-instructions-v${VERSION}.md${NC}" fi diff --git a/packages/rangelink-vscode-extension/scripts/start-release.sh b/packages/rangelink-vscode-extension/scripts/start-release.sh index dbb4a970..57f0876c 100755 --- a/packages/rangelink-vscode-extension/scripts/start-release.sh +++ b/packages/rangelink-vscode-extension/scripts/start-release.sh @@ -67,7 +67,7 @@ if grep -q '^## \[Unreleased\]$' "$CHANGELOG"; then echo -e "${YELLOW}Step 2: [Unreleased] section already exists in CHANGELOG — skipped.${NC}" else INSERT_LINE=$(grep -n '^## \[' "$CHANGELOG" | head -1 | cut -d: -f1) - if [[ "$INSERT_LINE" -eq 1 ]]; then + if [[ -n "$INSERT_LINE" ]] && [[ "$INSERT_LINE" -eq 1 ]]; then cat > "$CHANGELOG.tmp" <<'HEADER' ## [Unreleased] @@ -104,7 +104,7 @@ if grep -q '\[!IMPORTANT\]' "$README"; then echo -e "${YELLOW}Step 3: [!IMPORTANT] banner already exists in README — skipped.${NC}" else INSERT_LINE=$(grep -n '^## ' "$README" | head -1 | cut -d: -f1) - if [[ "$INSERT_LINE" -eq 1 ]]; then + if [[ -n "$INSERT_LINE" ]] && [[ "$INSERT_LINE" -eq 1 ]]; then cat > "$README.tmp" <<'BANNER' > [!IMPORTANT] > This documentation is for the `main` branch and may include unreleased features marked with Unreleased.