diff --git a/.github/rulesets/main-merge-queue.json b/.github/rulesets/main-merge-queue.json new file mode 100644 index 000000000..103567ad2 --- /dev/null +++ b/.github/rulesets/main-merge-queue.json @@ -0,0 +1,68 @@ +{ + "name": "main merge queue", + "target": "branch", + "enforcement": "active", + "conditions": { + "ref_name": { + "include": ["refs/heads/main"], + "exclude": [] + } + }, + "bypass_actors": [ + { + "actor_id": 5, + "actor_type": "RepositoryRole", + "bypass_mode": "always" + }, + { + "actor_id": 15368, + "actor_type": "Integration", + "bypass_mode": "always" + } + ], + "rules": [ + { + "type": "deletion" + }, + { + "type": "non_fast_forward" + }, + { + "type": "pull_request", + "parameters": { + "required_approving_review_count": 0, + "dismiss_stale_reviews": false, + "require_code_owner_review": false, + "require_last_push_approval": false, + "required_review_thread_resolution": false, + "allowed_merge_methods": ["squash", "rebase"] + } + }, + { + "type": "required_status_checks", + "parameters": { + "strict_required_status_checks_policy": true, + "do_not_enforce_on_create": false, + "required_status_checks": [ + { "context": "Build & Test" }, + { "context": "Coverage (renderer-ui)" }, + { "context": "Coverage (renderer-logic)" }, + { "context": "Coverage (main)" }, + { "context": "Merge coverage" } + ] + } + }, + { + "type": "merge_queue", + "parameters": { + "merge_method": "SQUASH", + "max_entries_to_build": 5, + "min_entries_to_merge": 1, + "max_entries_to_merge": 5, + "min_entries_to_merge_wait_minutes": 0, + "check_response_timeout_minutes": 90, + "grouping_strategy": "ALLGREEN" + } + } + ] +} diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 966502ba4..b64581405 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -11,6 +11,8 @@ on: branches: [main] pull_request: branches: [main] + # Merge queue temporary refs (required when main uses a merge queue ruleset). + merge_group: workflow_dispatch: jobs: diff --git a/.github/workflows/cut-release.yaml b/.github/workflows/cut-release.yaml new file mode 100644 index 000000000..0737ac213 --- /dev/null +++ b/.github/workflows/cut-release.yaml @@ -0,0 +1,124 @@ +# Optional maintainer path: cut a release from Actions. +# Prefer local `pnpm run release --yes` when practical — full preflight needs +# cargo, flatpak-node-generator, actionlint, and several minutes of Vitest. +# +# Requires repository secret RELEASE_PUSH_TOKEN (PAT / GitHub App) with +# contents:write + workflows:write so the tag push can trigger release.yaml / +# flatpak.yaml. Default GITHUB_TOKEN will not re-trigger those workflows. +# +# Does NOT publish the draft GitHub Release — review artifacts, then Publish. + +name: Cut release + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + +on: + workflow_dispatch: + inputs: + bump: + description: 'Version bump (auto, patch, minor, major, or exact X.Y.Z)' + required: false + default: auto + type: string + skip_dep_update: + description: 'Skip pnpm update/dedupe (use when lockfile is already current)' + required: false + default: false + type: boolean + +concurrency: + group: cut-release + cancel-in-progress: false + +jobs: + release: + name: Cut release (--yes) + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Require RELEASE_PUSH_TOKEN + env: + RELEASE_PUSH_TOKEN: ${{ secrets.RELEASE_PUSH_TOKEN }} + run: | + if [ -z "${RELEASE_PUSH_TOKEN}" ]; then + echo '::error::Set repository secret RELEASE_PUSH_TOKEN (contents+workflows).' + echo 'Default GITHUB_TOKEN cannot trigger release.yaml / flatpak.yaml on tag push.' + echo 'Prefer local: pnpm run release --yes' + exit 1 + fi + + - name: Checkout main + uses: actions/checkout@v6 + with: + ref: main + fetch-depth: 0 + token: ${{ secrets.RELEASE_PUSH_TOKEN }} + persist-credentials: true + + - name: Setup pnpm + uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 + + - name: Setup Node + uses: actions/setup-node@v6 + with: + node-version: '22' + cache: 'pnpm' + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Install actionlint + run: pnpm run setup:actionlint + + - name: Install yamllint + run: pip install yamllint + + - name: Install flatpak-node-generator + run: | + FBTOOLS=git+https://github.com/flatpak/flatpak-builder-tools + # Keep in sync with scripts/flatpakPnpmStoreVersion.mjs FLATPAK_NODE_GENERATOR_COMMIT. + pip3 install --force-reinstall --no-cache-dir \ + "${FBTOOLS}@b97a6e66f3fa46efad54738168e34e61b2b8c6f4#subdirectory=node" + + - name: Install Linux build deps (sidecar / Flatpak checks) + run: sudo apt-get update && sudo apt-get install -y libdbus-1-dev pkg-config + + - name: Setup Rust + uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 + + - name: Add actionlint to PATH + run: echo "${{ github.workspace }}/.githooks/bin" >> "$GITHUB_PATH" + + - name: Configure git identity + run: | + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + + - name: Run release (MESH_CLIENT_RELEASE_YES) + env: + MESH_CLIENT_RELEASE_YES: '1' + run: | + set -euo pipefail + BUMP='${{ inputs.bump }}' + EXTRA=() + if [ '${{ inputs.skip_dep_update }}' = 'true' ]; then + EXTRA+=(--skip-dep-update) + fi + case "$BUMP" in + auto|'') + pnpm run release -- --auto "${EXTRA[@]}" + ;; + patch|minor|major) + pnpm run release -- "$BUMP" "${EXTRA[@]}" + ;; + *) + if [[ "$BUMP" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + pnpm run release -- "$BUMP" "${EXTRA[@]}" + else + echo "::error::Invalid bump input: $BUMP" + exit 1 + fi + ;; + esac diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index dc51568db..33cc2f0a4 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -8,6 +8,8 @@ on: branches: [main] pull_request: branches: [main] + # Merge queue temporary refs (required when main uses a merge queue ruleset). + merge_group: workflow_dispatch: jobs: diff --git a/.yamllint b/.yamllint index d4f387bc8..beaf48872 100644 --- a/.yamllint +++ b/.yamllint @@ -9,3 +9,4 @@ ignore: | pnpm-lock.yaml node_modules/ .rsstack/ + .github/rulesets/ diff --git a/docs/ci-cd.md b/docs/ci-cd.md index 8315a0176..6fbfd5e5d 100644 --- a/docs/ci-cd.md +++ b/docs/ci-cd.md @@ -8,13 +8,14 @@ Mesh-Client uses GitHub Actions for continuous integration and deployment. | Workflow | Trigger | Purpose | | --------------------------- | -------------------------------------------- | ------------------------------------------------------------------------------- | -| `ci.yaml` | Push/PR to `main` | Lint, typecheck, build, Flatpak manifest validation | -| `tests.yaml` | Push/PR to `main` | Vitest coverage + merge; Reticulum sidecar `llvm-cov` when sidecar paths change | +| `ci.yaml` | Push/PR/`merge_group` to `main` | Lint, typecheck, build, Flatpak manifest validation | +| `tests.yaml` | Push/PR/`merge_group` to `main` | Vitest coverage + merge; Reticulum sidecar `llvm-cov` when sidecar paths change | | `e2e.yaml` | Daily on `main` + manual `workflow_dispatch` | Playwright Electron E2E (unpackaged build, 3-OS; not a PR gate) | | `build.yaml` | Manual `workflow_dispatch` | Native 3-OS packaging smoke build (+ schema compare vs last official) | | `reticulum-sidecar.yaml` | Path-filtered push/PR to `main` | Sidecar fmt + Clippy (ubuntu); multi-OS matrix build/test | | `release.yaml` | Version tags (`v*`) | Build & publish releases (AppImage/deb/rpm) | | `flatpak.yaml` | Version tags (`v*`), manual | Build Flatpak (+ schema compare vs last official); publish to release on tags | +| `cut-release.yaml` | Manual `workflow_dispatch` | Optional Actions-driven `pnpm run release --yes` (needs `RELEASE_PUSH_TOKEN`) | | `docs.yml` | Push to `main` | Deploy MkDocs to GitHub Pages | | `third-party-licenses.yaml` | Path-filtered push to `main` + dispatch | Regenerate `docs/third-party-licenses.md` after dependency changes | @@ -22,7 +23,7 @@ Mesh-Client uses GitHub Actions for continuous integration and deployment. ## CI Build (`ci.yaml`) -Runs on every push and pull request to `main` (and `workflow_dispatch`): +Runs on every push, pull request, and merge-queue `merge_group` for `main` (and `workflow_dispatch`): 1. Checkout code 2. Setup pnpm @@ -45,7 +46,7 @@ All blocking steps must pass before a PR can be merged. ## Tests (`tests.yaml`) -Runs on every push and pull request to `main`: +Runs on every push, pull request, and merge-queue `merge_group` for `main`: 1. Checkout code, setup pnpm + Node 22, install dependencies 2. **Parallel matrix** — coverage per Vitest project (`renderer-ui`, `renderer-logic`, `main`) with blob reporter (`VITEST_COVERAGE_SHARD=1` skips per-shard threshold checks) @@ -273,16 +274,80 @@ Note: The test results artifact upload step is automatically skipped when runnin --- -## Required Status Checks +## Pipeline status (issue #378) + +| Area | Status | +| ------------------------------------------------ | ------------------------------------------------------------------- | +| PR lint / typecheck / build / tests | Done (`ci.yaml`, `tests.yaml`) | +| CodeQL / CodeRabbit | Done (CodeQL **default setup** — PR/push/schedule; not merge-queue) | +| Tag → draft multi-OS + Flatpak + packaging smoke | Done (`release.yaml`, `flatpak.yaml`, `build.yaml`) | +| `pnpm run release` preflight + bump/tag | Done (`scripts/release.sh`; `--yes` for non-interactive) | +| Manual draft **Publish** on GitHub | Intentional (human review of artifacts) | +| Dep bumps | Manual (`pnpm run update`; Dependabot PRs disabled) | +| Merge queue + required status checks | Repository ruleset on `main` (see below) | +| E2E | Daily / `workflow_dispatch` only — **not** a merge gate | + +--- + +## Merge queue and rulesets + +`main` is protected by a **repository ruleset** (not classic branch protection) that: + +1. Requires a pull request before merging +2. Requires a **merge queue** +3. Requires **strict** status checks (must pass on the merge group / up-to-date tip) +4. Blocks force-pushes and branch deletion on `main` + +### Required check names (always-on) + +Only checks that report on every PR and every `merge_group` run are required: + +| Check name | Workflow | +| --------------------------- | ------------ | +| `Build & Test` | `ci.yaml` | +| `Coverage (renderer-ui)` | `tests.yaml` | +| `Coverage (renderer-logic)` | `tests.yaml` | +| `Coverage (main)` | `tests.yaml` | +| `Merge coverage` | `tests.yaml` | + +**Do not** add these as required (they skip or are not PR/`merge_group` gates and would stall the queue): + +- `Reticulum sidecar coverage` (path-filtered) +- `fmt + clippy` / sidecar build matrix (`reticulum-sidecar.yaml`, path-filtered) +- CodeQL `Analyze (*)` — **default setup does not run on `merge_group`**; CodeQL still runs on PRs/pushes. Requiring it would hang the merge queue until advanced setup + `merge_group` exists. +- E2E, packaging smoke, Flatpak, release jobs -All PRs to `main` must pass: +`ci.yaml` and `tests.yaml` both listen for `merge_group` so the queue’s temporary ref re-runs the same gates. + +### Applying / updating the ruleset + +Canonical JSON lives at [`.github/rulesets/main-merge-queue.json`](../.github/rulesets/main-merge-queue.json). + +```bash +# Create (first time) +gh api repos/Colorado-Mesh/mesh-client/rulesets \ + --method POST \ + --input .github/rulesets/main-merge-queue.json + +# Update (after noting the ruleset id from `gh api .../rulesets`) +gh api repos/Colorado-Mesh/mesh-client/rulesets/RULESET_ID \ + --method PUT \ + --input .github/rulesets/main-merge-queue.json +``` + +Bypass actors: repository **Admin** role (`actor_id` 5) and the **GitHub Actions** app (`Integration` 15368) for `third-party-licenses.yaml` pushes. + +**Rollout:** merge the PR that adds `merge_group` triggers to `ci.yaml` / `tests.yaml` **before** flipping this ruleset to `enforcement: active`. Enabling the queue without those triggers leaves required checks pending forever. + +--- + +## Required Status Checks -- Lint (`pnpm run lint`) -- Typecheck (`pnpm run typecheck`) -- Build (`pnpm run build`) -- Tests with coverage (`pnpm run test:coverage` — same as CI; `locale-quality.test.ts` runs `check:i18n` as part of the Vitest suite) +All PRs (and merge-queue groups) for `main` must pass the **required check names** listed above. Those jobs cover: -Branch protection is configured to require these checks before merging. +- Lint, format, markdown, licenses, actionlint, yamllint (`pnpm run lint` and related steps in `ci.yaml`) +- Typecheck and build (`pnpm run typecheck`, `pnpm run build`) +- Tests with coverage (`pnpm run test:coverage` merge — `locale-quality.test.ts` runs `check:i18n` as part of the Vitest suite) --- diff --git a/docs/release-process.md b/docs/release-process.md index c9c3ca392..176132cf4 100644 --- a/docs/release-process.md +++ b/docs/release-process.md @@ -57,19 +57,30 @@ The release script (`scripts/release.sh`) is the supported maintainer path. It: ```bash git checkout main git pull origin main -pnpm run release # auto-detect bump from commits since last tag -pnpm run release minor # force minor -pnpm run release 5.21.0 # force exact version -pnpm run release --auto # explicit auto-detect -pnpm run release --finish # complete a mid-release after package.json was already bumped +pnpm run release # auto-detect bump from commits since last tag +pnpm run release minor # force minor +pnpm run release 5.21.0 # force exact version +pnpm run release --auto # explicit auto-detect +pnpm run release --finish # complete a mid-release after package.json was already bumped +pnpm run release -- --yes # non-interactive (skip both confirmation prompts) +pnpm run release -- --yes --skip-dep-update patch # CI-style: no pnpm update +MESH_CLIENT_RELEASE_YES=1 pnpm run release # same as --yes (avoids pnpm's own -y) ``` -The script prompts twice (start pre-flight, then confirm after checks pass). **Expect several minutes** for the full validation chain. +The script prompts twice by default (start pre-flight, then confirm after checks pass). Pass **`-- --yes`** after `pnpm run release` (or set `MESH_CLIENT_RELEASE_YES=1`) to skip those prompts — useful for automation. Use `--` so pnpm does not swallow `-y`/`--yes`. **Expect several minutes** for the full validation chain. **Full suite only:** Release must never use `test:staged`, `test:changed`, or `vitest related`. Pre-commit may run a staged subset for speed; release matches PR CI by running the unrestricted `pnpm run test:run` (`vitest run`) and does not soft-skip actionlint/yamllint when those tools are missing. If pre-flight fails, fix the issue on `main` and run `pnpm run release` again — do not tag manually until checks pass. +### Optional: cut release from Actions + +[`cut-release.yaml`](../.github/workflows/cut-release.yaml) is a **manual** `workflow_dispatch` that runs `pnpm run release --yes` on `ubuntu-latest`. Prefer local `pnpm run release --yes` for day-to-day cuts (full preflight is heavy and needs `cargo`, Flatpak tooling, etc.). + +**Required secret:** `RELEASE_PUSH_TOKEN` — a fine-grained PAT (or GitHub App installation token) with **contents: write** and **workflows: write**. Do **not** use the default `GITHUB_TOKEN`: pushes authenticated with it will **not** trigger `release.yaml` / `flatpak.yaml` on the new tag (GitHub recursion guard). + +The workflow never publishes the GitHub Release draft — maintainers still review artifacts and click **Publish**. + ### Mid-release MetaInfo failure If `package.json` was already bumped but the Flatpak MetaInfo `` entry is wrong/corrupt (or the release commit was blocked by `check:flatpak`): diff --git a/scripts/release.sh b/scripts/release.sh index 785318e58..602116837 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -86,6 +86,35 @@ sync_metainfo_release() { node scripts/prepend-metainfo-release.mjs "$version" "$today" } +# Non-interactive confirmations: --yes / -y or MESH_CLIENT_RELEASE_YES=1|true. +confirm_or_yes() { + local prompt="$1" + if [ "${RELEASE_YES}" = true ]; then + print_warning "Non-interactive (--yes): ${prompt} → yes" + return 0 + fi + echo "" + echo -e "${BOLD}${prompt}${NC} [y/N]" + local reply + read -r reply + if [ "$reply" != "y" ] && [ "$reply" != "Y" ]; then + return 1 + fi + return 0 +} + +print_release_usage() { + echo "Usage: pnpm run release [patch|minor|major|x.x.x|--auto|--finish] [--yes] [--skip-dep-update]" + echo " pnpm run release # Auto-detect from commits" + echo " pnpm run release --auto # Explicit auto-detect" + echo " pnpm run release minor # Force minor release" + echo " pnpm run release 2.0.0 # Force specific version" + echo " pnpm run release --finish # Complete mid-release (no re-bump)" + echo " pnpm run release -- --yes # Skip confirmation prompts ( -- so pnpm keeps -y )" + echo " pnpm run release -- --skip-dep-update # Skip pnpm update/dedupe" + echo " MESH_CLIENT_RELEASE_YES=1 pnpm run release # Same as --yes" +} + commit_tag_and_push_release() { local new_version="$1" git add package.json pnpm-lock.yaml org.coloradomesh.MeshClient.yml @@ -145,10 +174,7 @@ finish_pending_release() { generate_release_notes "$last_tag" "$new_version" - echo "" - echo -e "${BOLD}package.json is already at $clean_version. Commit, tag $new_version, and push?${NC} [y/N]" - read -r FINAL_CONFIRM - if [ "$FINAL_CONFIRM" != "y" ] && [ "$FINAL_CONFIRM" != "Y" ]; then + if ! confirm_or_yes "package.json is already at $clean_version. Commit, tag $new_version, and push?"; then print_warning "Release finish cancelled." exit 0 fi @@ -262,29 +288,64 @@ detect_version_bump() { fi } -# 1. Check if a version argument was provided or auto-detect / finish +# 1. Parse version / finish / non-interactive flags (order-independent). VERSION_TYPE="" AUTO_DETECT=false FINISH_ONLY=false +SKIP_DEP_UPDATE=false +RELEASE_YES=false +if [ "${MESH_CLIENT_RELEASE_YES:-}" = "1" ] || [ "${MESH_CLIENT_RELEASE_YES:-}" = "true" ]; then + RELEASE_YES=true +fi + +POSITIONAL_COUNT=0 +for arg in "$@"; do + case "$arg" in + --yes | -y) + RELEASE_YES=true + ;; + --skip-dep-update) + SKIP_DEP_UPDATE=true + ;; + --finish) + FINISH_ONLY=true + ;; + --auto) + AUTO_DETECT=true + ;; + patch | minor | major) + VERSION_TYPE="$arg" + POSITIONAL_COUNT=$((POSITIONAL_COUNT + 1)) + ;; + *) + if [[ "$arg" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + VERSION_TYPE="$arg" + POSITIONAL_COUNT=$((POSITIONAL_COUNT + 1)) + else + print_release_usage + exit 1 + fi + ;; + esac +done -if [ "${1:-}" = "--finish" ]; then - FINISH_ONLY=true -elif [ -z "$1" ] || [ "$1" = "--auto" ]; then - AUTO_DETECT=true -elif [ "$1" = "patch" ] || [ "$1" = "minor" ] || [ "$1" = "major" ]; then - VERSION_TYPE="$1" -elif [[ "$1" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then - VERSION_TYPE="$1" -else - echo "Usage: pnpm run release [patch|minor|major|x.x.x|--auto|--finish]" - echo " pnpm run release # Auto-detect from commits" - echo " pnpm run release --auto # Explicit auto-detect" - echo " pnpm run release minor # Force minor release" - echo " pnpm run release 2.0.0 # Force specific version" - echo " pnpm run release --finish # Complete mid-release (no re-bump)" +if [ "$FINISH_ONLY" = true ] && { [ -n "$VERSION_TYPE" ] || [ "$AUTO_DETECT" = true ]; }; then + print_error "--finish cannot be combined with a version bump argument." + print_release_usage exit 1 fi +if [ "$POSITIONAL_COUNT" -gt 1 ]; then + print_error "Specify at most one of patch|minor|major|x.x.x." + print_release_usage + exit 1 +fi + +if [ "$FINISH_ONLY" = false ] && [ -z "$VERSION_TYPE" ] && [ "$AUTO_DETECT" = false ]; then + # No bump arg and no --auto → same as historical bare `pnpm run release`. + AUTO_DETECT=true +fi + # 2. Ensure we are on the main branch print_header "Checking git status..." CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD) @@ -301,13 +362,17 @@ fi git pull origin main -# 3. Update dependencies -print_header "Updating dependencies..." -pnpm update +# 3. Update dependencies (optional skip for CI cut-release / already-updated trees) +if [ "$SKIP_DEP_UPDATE" = true ]; then + print_warning "Skipping pnpm update/dedupe (--skip-dep-update)." +else + print_header "Updating dependencies..." + pnpm update -# Ensure lockfile is deduped after update -print_header "Deduplicating dependencies..." -pnpm dedupe + # Ensure lockfile is deduped after update + print_header "Deduplicating dependencies..." + pnpm dedupe +fi print_header "Syncing Flatpak Electron vendored archives..." node scripts/sync-flatpak-electron.mjs @@ -394,11 +459,7 @@ else echo -e "${GREEN} -> This is a patch release${NC}" fi -echo "" -echo -e "${BOLD}Continue with pre-flight validation?${NC} [y/N]" -read -r CONFIRM - -if [ "$CONFIRM" != "y" ] && [ "$CONFIRM" != "Y" ]; then +if ! confirm_or_yes "Continue with pre-flight validation?"; then print_warning "Release cancelled." exit 0 fi @@ -621,11 +682,7 @@ fi print_success "All pre-flight checks passed!" -echo "" -echo -e "${BOLD}All validations passed. Proceed with actual release?${NC} [y/N]" -read -r FINAL_CONFIRM - -if [ "$FINAL_CONFIRM" != "y" ] && [ "$FINAL_CONFIRM" != "Y" ]; then +if ! confirm_or_yes "All validations passed. Proceed with actual release?"; then print_warning "Release cancelled after successful validation." exit 0 fi diff --git a/scripts/release.test.mjs b/scripts/release.test.mjs index 0ea8db737..68d7e352f 100644 --- a/scripts/release.test.mjs +++ b/scripts/release.test.mjs @@ -71,7 +71,8 @@ describe('release.sh full-suite gate', () => { }); it('supports --finish to complete a mid-release without re-bumping', () => { - expect(script).toMatch(/\[ "\$\{1:-\}" = "--finish" \]/); + expect(script).toMatch(/--finish\)/); + expect(script).toMatch(/FINISH_ONLY=true/); expect(script).toMatch(/finish_pending_release/); expect(script).toMatch(/pnpm run release --finish/); // Finish path must not re-enter full preflight / update. @@ -82,6 +83,25 @@ describe('release.sh full-suite gate', () => { expect(finishBody).not.toMatch(/pnpm run test:run/); }); + it('supports --yes / MESH_CLIENT_RELEASE_YES to skip confirmation prompts', () => { + expect(script).toMatch(/confirm_or_yes/); + expect(script).toMatch(/--yes \| -y\)/); + expect(script).toMatch(/MESH_CLIENT_RELEASE_YES/); + expect(script).toMatch(/RELEASE_YES=true/); + // All interactive confirms go through confirm_or_yes (no bare read -r for y/N). + expect(script).not.toMatch(/Continue with pre-flight validation\?\$\{NC\} \[y\/N\]/); + expect(script).toMatch(/confirm_or_yes "Continue with pre-flight validation\?"/); + expect(script).toMatch( + /confirm_or_yes "All validations passed\. Proceed with actual release\?"/, + ); + }); + + it('supports --skip-dep-update to skip pnpm update/dedupe', () => { + expect(script).toMatch(/--skip-dep-update\)/); + expect(script).toMatch(/SKIP_DEP_UPDATE=true/); + expect(script).toMatch(/Skipping pnpm update\/dedupe/); + }); + it('requires actionlint and yamllint (no soft-skip)', () => { expect(script).toMatch(/actionlint not found/); expect(script).toMatch(/yamllint not found/);