diff --git a/.claude/skills/qa-suggest/SKILL.md b/.claude/skills/qa-suggest/SKILL.md index 8b449649..85b2c2f1 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 @@ -144,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. @@ -154,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 c5e476d4..bc32cd1d 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: 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..a95688e0 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "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 .", @@ -25,10 +26,12 @@ "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", "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 b80f9ca3..4cf8a15a 100644 --- a/packages/rangelink-vscode-extension/TESTING.md +++ b/packages/rangelink-vscode-extension/TESTING.md @@ -34,24 +34,23 @@ 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 | + ```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 + re-run affected TCs] - K --> J - J -- Yes --> L[Tag release + 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 ``` --- @@ -278,9 +277,9 @@ 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 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 d176bb44..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$'", @@ -982,6 +985,5 @@ "vscode": "^1.49.0" }, "icon": "icon.png", - "nextTargetVersion": "Unreleased", "pricing": "Free" } 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..60bc4701 --- /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.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.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.bak '/^> \[!IMPORTANT\]/,/^$/d' "$README" && rm -f "${README}.bak" +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/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/packages/rangelink-vscode-extension/scripts/lock-version.sh b/packages/rangelink-vscode-extension/scripts/lock-version.sh new file mode 100755 index 00000000..0ab710c0 --- /dev/null +++ b/packages/rangelink-vscode-extension/scripts/lock-version.sh @@ -0,0 +1,127 @@ +#!/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 + 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 + +# --- 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.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" && rm -f "${VERSIONED_INSTRUCTIONS}.bak" + + 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/packages/rangelink-vscode-extension/scripts/start-release.sh b/packages/rangelink-vscode-extension/scripts/start-release.sh new file mode 100755 index 00000000..57f0876c --- /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 [[ -n "$INSERT_LINE" ]] && [[ "$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 [[ -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. +> 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/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" ]] +} diff --git a/tests/shell/generate-qa-test-plan.bats b/tests/shell/generate-qa-test-plan.bats index 9e136913..207c64c9 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' @@ -203,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' @@ -222,30 +186,31 @@ 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 ──────────────────────────────────────────────────────────────── -@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 +223,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" 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" +} 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" ]] +}