diff --git a/.github/rulesets/main-merge-queue.json b/.github/rulesets/main-merge-queue.json index e3b4324a6..6b5e8f4f2 100644 --- a/.github/rulesets/main-merge-queue.json +++ b/.github/rulesets/main-merge-queue.json @@ -26,9 +26,9 @@ "type": "pull_request", "parameters": { "required_approving_review_count": 1, - "dismiss_stale_reviews_on_push": false, + "dismiss_stale_reviews_on_push": true, "require_code_owner_review": false, - "require_last_push_approval": false, + "require_last_push_approval": true, "required_review_thread_resolution": false, "allowed_merge_methods": ["squash", "rebase"] } diff --git a/.github/workflows/cut-release.yaml b/.github/workflows/cut-release.yaml index ec330abf4..d7af69f33 100644 --- a/.github/workflows/cut-release.yaml +++ b/.github/workflows/cut-release.yaml @@ -147,9 +147,11 @@ jobs: - name: Stop after dry-run preview if: ${{ inputs.dry_run == true || inputs.dry_run == 'true' }} + env: + NEXT_VERSION: ${{ steps.preview.outputs.next }} run: | echo "dry_run=true — not bumping or tagging." - echo "Re-run with dry_run unchecked to cut v${{ steps.preview.outputs.next }}." + echo "Re-run with dry_run unchecked to cut v${NEXT_VERSION}." - name: Install actionlint if: ${{ inputs.dry_run != true && inputs.dry_run != 'true' }} @@ -180,7 +182,9 @@ jobs: - name: Add actionlint to PATH if: ${{ inputs.dry_run != true && inputs.dry_run != 'true' }} - run: echo "${{ github.workspace }}/.githooks/bin" >> "$GITHUB_PATH" + env: + HOOKS_BIN: ${{ github.workspace }}/.githooks/bin + run: echo "${HOOKS_BIN}" >> "$GITHUB_PATH" - name: Configure git identity if: ${{ inputs.dry_run != true && inputs.dry_run != 'true' }} @@ -192,6 +196,8 @@ jobs: if: ${{ inputs.dry_run != true && inputs.dry_run != 'true' }} env: MESH_CLIENT_RELEASE_YES: '1' + # Never inherit a repo/org PARSE_ONLY var — that would green-exit with no tag. + MESH_CLIENT_RELEASE_PARSE_ONLY: '' RELEASE_BUMP: ${{ steps.preview.outputs.bump }} SKIP_DEP_UPDATE: ${{ inputs.skip_dep_update }} run: | diff --git a/.github/workflows/third-party-licenses.yaml b/.github/workflows/third-party-licenses.yaml index 579d258c7..2b3964b7e 100644 --- a/.github/workflows/third-party-licenses.yaml +++ b/.github/workflows/third-party-licenses.yaml @@ -17,12 +17,23 @@ on: - .github/workflows/third-party-licenses.yaml workflow_dispatch: +# Coalesce overlapping regenerations (path-filtered pushes can stack) so we do not +# leave multiple stale license PRs from canceled/superseded runs. +concurrency: + group: third-party-licenses + cancel-in-progress: true + jobs: update: name: Update third-party licenses runs-on: ubuntu-latest steps: + # persist-credentials: false avoids Duplicate Authorization when CPR + # configures its own http.*.extraheader (actions/checkout@v6 default helper). - uses: actions/checkout@v6 + with: + ref: main + persist-credentials: false - name: Setup pnpm uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 @@ -42,12 +53,36 @@ jobs: - name: Generate third-party licenses run: pnpm run docs:licenses + - name: Detect license doc changes + id: licenses_diff + run: | + if git diff --quiet -- docs/third-party-licenses.md; then + echo 'changed=false' >> "$GITHUB_OUTPUT" + else + echo 'changed=true' >> "$GITHUB_OUTPUT" + fi + + - name: Require RELEASE_PUSH_TOKEN for PR checks + if: steps.licenses_diff.outputs.changed == 'true' + env: + RELEASE_PUSH_TOKEN: ${{ secrets.RELEASE_PUSH_TOKEN }} + run: | + if [ -z "${RELEASE_PUSH_TOKEN}" ]; then + echo '::error::Set repository secret RELEASE_PUSH_TOKEN (admin PAT: contents+workflows+pull_requests).' + echo 'Default GITHUB_TOKEN PRs do not auto-run required checks under the merge-queue ruleset.' + exit 1 + fi + # Open a PR instead of pushing to main — the merge-queue ruleset blocks # direct pushes, and GitHub Actions cannot be added as a bypass actor # on this org (“must be part of the ruleset source or owner organization”). + # RELEASE_PUSH_TOKEN (not GITHUB_TOKEN) so pull_request workflows run. + # Unique branch per run avoids thrashing one tip under concurrent main pushes. - name: Create pull request + if: steps.licenses_diff.outputs.changed == 'true' uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e with: + token: ${{ secrets.RELEASE_PUSH_TOKEN }} commit-message: 'docs: update third-party licenses' title: 'docs: update third-party licenses' body: | @@ -55,7 +90,8 @@ jobs: Opened automatically by `third-party-licenses.yaml` (direct pushes to `main` are blocked by the merge-queue ruleset). - branch: chore/third-party-licenses + base: main + branch: chore/third-party-licenses-${{ github.run_id }} delete-branch: true add-paths: | docs/third-party-licenses.md diff --git a/docs/ci-cd.md b/docs/ci-cd.md index 0636378c0..c397800c8 100644 --- a/docs/ci-cd.md +++ b/docs/ci-cd.md @@ -17,7 +17,7 @@ Mesh-Client uses GitHub Actions for continuous integration and deployment. | `flatpak.yaml` | Version tags (`v*`), manual | Build Flatpak (+ schema compare vs last official); publish to release on tags | | `cut-release.yaml` | Manual `workflow_dispatch` | **Primary** release cut in Actions (needs admin `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 | +| `third-party-licenses.yaml` | Path-filtered push to `main` + dispatch | Regenerate licenses doc and open a PR (needs `RELEASE_PUSH_TOKEN`) | --- @@ -140,14 +140,14 @@ On **version tag pushes**, a `publish` job waits for the Electron `prepare-githu After merges to `main` that change `package.json`, `pnpm-lock.yaml`, the generator script, or this workflow (and on `workflow_dispatch`): -1. Checkout code +1. Checkout with `persist-credentials: false` (avoids Duplicate Authorization with create-pull-request) 2. Setup pnpm + Node 22 3. Install dependencies (`pnpm install --frozen-lockfile`) 4. Audit licenses (`pnpm run check:licenses`) 5. Regenerate `docs/third-party-licenses.md` (`pnpm run docs:licenses`) -6. Open a PR via `peter-evans/create-pull-request` when the file changed (branch `chore/third-party-licenses`) +6. Open a PR via `peter-evans/create-pull-request` when the file changed (branch `chore/third-party-licenses-`) -Direct pushes to `main` are blocked by the merge-queue ruleset, and GitHub Actions cannot bypass it on this org — hence the PR flow. +**Secret:** reuse **`RELEASE_PUSH_TOKEN`** (admin PAT with **contents**, **workflows**, and **pull requests** write). Default `GITHUB_TOKEN` PRs do not auto-run required checks, so they cannot enter the merge queue cleanly. Direct pushes to `main` remain blocked by the merge-queue ruleset. --- @@ -326,14 +326,16 @@ Each required check is pinned with `integration_id` **15368** (GitHub Actions) i - **Repository admins** (`RepositoryRole` id 5) — emergency hotfixes and local `pnpm run release` (direct push of bump commit + tag to `main`) -GitHub Actions **cannot** be added as a bypass actor on this organization (“must be part of the ruleset source or owner organization”). [`third-party-licenses.yaml`](../.github/workflows/third-party-licenses.yaml) therefore opens a PR instead of pushing to `main`. +GitHub Actions **cannot** be added as a bypass actor on this organization (“must be part of the ruleset source or owner organization”). [`third-party-licenses.yaml`](../.github/workflows/third-party-licenses.yaml) therefore opens a PR (via `RELEASE_PUSH_TOKEN`) instead of pushing to `main`. + +**Pull request gate:** one approving review, **dismiss stale reviews on push**, and **require last push approval** so an approved PR cannot enter the merge queue after unreviewed follow-up commits. ### Applying / updating the ruleset -Canonical JSON lives at [`.github/rulesets/main-merge-queue.json`](../.github/rulesets/main-merge-queue.json) (live ruleset id **20821455**). +Canonical JSON lives at [`.github/rulesets/main-merge-queue.json`](../.github/rulesets/main-merge-queue.json) (live ruleset id **20821455**). Vitest contract: `scripts/main-merge-queue-ruleset.test.mjs` (pinned checks + review gates). After changing the JSON, **PUT the live ruleset** or drift will remain until someone syncs: ```bash -# Update +# Update live ruleset from canonical JSON gh api repos/Colorado-Mesh/mesh-client/rulesets/20821455 \ --method PUT \ --input .github/rulesets/main-merge-queue.json @@ -341,7 +343,7 @@ gh api repos/Colorado-Mesh/mesh-client/rulesets/20821455 \ `gh api --input` can hit HTTP/2 content-length issues on create; if that fails, POST the JSON body with Python `urllib` (same payload). -**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. +The ruleset is already **active** with `merge_group` triggers on `ci.yaml` / `tests.yaml`. Keep those triggers if you ever recreate the ruleset — enabling the queue without them leaves required checks pending forever. --- diff --git a/docs/release-process.md b/docs/release-process.md index 79cfa702f..663f607e0 100644 --- a/docs/release-process.md +++ b/docs/release-process.md @@ -50,9 +50,9 @@ Documentation deploys separately: [`docs.yml`](../.github/workflows/docs.yml) ru 4. The workflow sets `MESH_CLIENT_RELEASE_YES=1` (non-interactive). Locally use `pnpm run release -- --yes` or the same env var. 5. Wait for `release.yaml` + `flatpak.yaml` to attach draft artifacts, then **Publish** on GitHub. -**Secret:** `RELEASE_PUSH_TOKEN` — fine-grained PAT (or GitHub App installation token) owned by a repo **admin** (so the merge-queue ruleset bypass applies), with **contents: write** and **workflows: write**. Plain `GITHUB_TOKEN` cannot trigger tag workflows and cannot push past the ruleset. +**Secret:** `RELEASE_PUSH_TOKEN` — fine-grained PAT (or GitHub App installation token) owned by a repo **admin** (so the merge-queue ruleset bypass applies), with **contents: write**, **workflows: write**, and **pull requests: write** (also used by `third-party-licenses.yaml` so bot PRs run required checks). Plain `GITHUB_TOKEN` cannot trigger tag workflows, cannot push past the ruleset, and cannot open check-running license PRs. -Version detection lives in [`scripts/detectReleaseBump.mjs`](../scripts/detectReleaseBump.mjs) (handles scoped Conventional Commits such as `feat(rrc): …`). +Version detection lives in [`scripts/detectReleaseBump.mjs`](../scripts/detectReleaseBump.mjs) (handles scoped Conventional Commits such as `feat(rrc): …`). Do not set `MESH_CLIENT_RELEASE_PARSE_ONLY` in Actions (test-only hook; Cut release clears it). --- @@ -81,9 +81,10 @@ pnpm run release --finish # complete a mid-release after 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) +# Invalid: --auto cannot be combined with patch|minor|major|x.x.x ``` -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. +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`. **`--auto` plus an explicit bump is rejected.** **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. @@ -235,13 +236,13 @@ When artifacts and notes look correct: ## Version naming -Follow [Semantic Versioning](https://semver.org/): +Follow [Semantic Versioning](https://semver.org/). Auto-detect is implemented in [`scripts/detectReleaseBump.mjs`](../scripts/detectReleaseBump.mjs) (called from `release.sh` / Cut release): -- **Major (X.0.0):** Breaking changes (`BREAKING CHANGE:` footer or `feat!:` / `fix!:`) -- **Minor (0.X.0):** New features (`feat:`), backward compatible +- **Major (X.0.0):** `type!:` / `type(scope)!:`, or a line-anchored `BREAKING CHANGE:` / `BREAKING-CHANGE:` footer in a commit body +- **Minor (0.X.0):** New features (`feat:` or `feat(scope):`), backward compatible - **Patch (0.0.X):** Fixes and other conventional commits without `feat:` -`release.sh` applies these rules when auto-detecting the bump. +Release notes “Breaking Changes” use the same subject bang + footer rules (not subject-only). --- diff --git a/package.json b/package.json index c776a3966..df430f93d 100644 --- a/package.json +++ b/package.json @@ -142,12 +142,12 @@ "update": "bash scripts/update.sh" }, "dependencies": { - "@bufbuild/protobuf": "^2.13.0", + "@bufbuild/protobuf": "^2.14.0", "@meshtastic/protobufs": "npm:@jsr/meshtastic__protobufs@^2.7.26", "@stoprocent/noble": "^2.7.1", "@xterm/addon-fit": "^0.11.0", "@xterm/xterm": "^6.0.0", - "@zip.js/zip.js": "^2.8.43", + "@zip.js/zip.js": "^2.8.47", "builder-util-runtime": "9.7.0", "dompurify": "^3.4.13", "electron-updater": "^6.8.9", @@ -229,7 +229,7 @@ "vite": "^8.2.1", "vitest": "^4.1.10", "vitest-axe": "1.0.0-pre.5", - "zustand": "^5.0.14" + "zustand": "^5.0.15" }, "packageManager": "pnpm@11.21.0+sha512.521705bce689924eac72f5a3587122f362689ef6571e55ba80076fd637c11132ecffada26fad4ea79c485bfddbfd3d5a2a5b05805a77e893de71ec8a6cca3bb1", "engines": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cb08a50cf..e92d40fd7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -39,8 +39,8 @@ importers: .: dependencies: '@bufbuild/protobuf': - specifier: ^2.13.0 - version: 2.13.0 + specifier: ^2.14.0 + version: 2.14.0 '@meshtastic/protobufs': specifier: npm:@jsr/meshtastic__protobufs@^2.7.26 version: '@jsr/meshtastic__protobufs@2.7.26' @@ -54,8 +54,8 @@ importers: specifier: ^6.0.0 version: 6.0.0 '@zip.js/zip.js': - specifier: ^2.8.43 - version: 2.8.43 + specifier: ^2.8.47 + version: 2.8.47 builder-util-runtime: specifier: 9.7.0 version: 9.7.0(supports-color@8.1.1) @@ -295,8 +295,8 @@ importers: specifier: 1.0.0-pre.5 version: 1.0.0-pre.5(vitest@4.1.10) zustand: - specifier: ^5.0.14 - version: 5.0.14(@types/react@19.2.18)(immer@11.1.16)(react@19.2.8)(use-sync-external-store@1.6.0(react@19.2.8)) + specifier: ^5.0.15 + version: 5.0.15(@types/react@19.2.18)(immer@11.1.16)(react@19.2.8)(use-sync-external-store@1.6.0(react@19.2.8)) packages: @@ -404,8 +404,8 @@ packages: resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==} hasBin: true - '@bufbuild/protobuf@2.13.0': - resolution: {integrity: sha512-acq7c49vxfm1ggJ95P70TX7ABDM0vxr1SYD3BB0o0jnBLB4OAqeHyKuN+cD3w80gXEDQ2zxHpR6CUeA+O/aU9g==} + '@bufbuild/protobuf@2.14.0': + resolution: {integrity: sha512-C3UGsiCwSprE2NKIIFA3hCDlpXTMCAXRZuEVp88L1GY36Y41+rYL5fryE+nOFhp4p4JPQvdV8PQ4DWgHgeTE+w==} '@csstools/color-helpers@6.1.0': resolution: {integrity: sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==} @@ -1476,8 +1476,8 @@ packages: '@yarnpkg/lockfile@1.1.0': resolution: {integrity: sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ==} - '@zip.js/zip.js@2.8.43': - resolution: {integrity: sha512-LesnlKQyNaCm3NWd7TwMLYYRVShxaeItzReGGRiwxRhsad5PhIF2Vxl+tOWoajjK7xiukZtqmJf5ckkE/OVpYw==} + '@zip.js/zip.js@2.8.47': + resolution: {integrity: sha512-zQzidM6FD1teXUXL3Xy7g+b7k3jybQArLRVi2mIPt74JtxBVVPheDCW5q6nhEr4Pq+myXHoC9GsHIgxGQ6T8ow==} engines: {bun: '>=0.7.0', deno: '>=1.0.0', node: '>=18.0.0'} abbrev@4.0.0: @@ -3391,8 +3391,8 @@ packages: resolution: {integrity: sha512-8VOpLHFrOQlAH+qA0ZzuGRlALRA6/LVh8QJldbrC4DY0hXoMP0l4Acq8TzFC018HztWiRqyCEj2aTWY2UvnJUg==} engines: {node: ^18 || ^20 || >= 21} - node-addon-api@8.9.1: - resolution: {integrity: sha512-4eUQWVPCUUUiBjLnHS3cXWeC6ryoPUc0U3rP7IuzapoGbzMqd/r6KKO0clr0b+snQhsrueFEhCZDdK+LK7hxKg==} + node-addon-api@8.9.2: + resolution: {integrity: sha512-VijLXbi3UACN69I0JVXJsX4tjACjNoQDgv2gTF6sx2wWEi8tkSg2eX8p5gSIFi8z2+DL3oHmY6OyKce38SDolg==} engines: {node: ^18 || ^20 || >= 21} node-api-version@0.2.1: @@ -4629,8 +4629,8 @@ packages: zod@4.4.3: resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} - zustand@5.0.14: - resolution: {integrity: sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g==} + zustand@5.0.15: + resolution: {integrity: sha512-MpSEjRiBkA9crSYeOUH32rJC7SVqAbm0Fqcqge/bUi2PPoLcBWKOsG+C8mevmpr8TwXHBVkChbbJiyvkE+i/3A==} engines: {node: '>=12.20.0'} peerDependencies: '@types/react': '>=18.0.0' @@ -4786,7 +4786,7 @@ snapshots: dependencies: css-tree: 3.2.1 - '@bufbuild/protobuf@2.13.0': {} + '@bufbuild/protobuf@2.14.0': {} '@csstools/color-helpers@6.1.0': {} @@ -5062,7 +5062,7 @@ snapshots: '@jsr/meshtastic__core@2.6.6(patch_hash=93604a080fa754cbde3dc78ef46ee50c2996c34d6d85720acacecfd6ebd7c652)(buffer@6.0.3)': dependencies: - '@bufbuild/protobuf': 2.13.0 + '@bufbuild/protobuf': 2.14.0 '@jsr/meshtastic__protobufs': 2.7.26 crc: 4.3.2(buffer@6.0.3) ste-simple-events: 3.0.11 @@ -5072,7 +5072,7 @@ snapshots: '@jsr/meshtastic__protobufs@2.7.26': dependencies: - '@bufbuild/protobuf': 2.13.0 + '@bufbuild/protobuf': 2.14.0 '@jsr/meshtastic__transport-http@0.2.1(buffer@6.0.3)': dependencies: @@ -5367,7 +5367,7 @@ snapshots: dependencies: async: 3.2.6 debug: 4.4.3(patch_hash=cf37fa96f5df733456b16c82c9e1c9054a92f6216692aa33d5e2e1e208888e37)(supports-color@8.1.1) - node-addon-api: 8.9.1 + node-addon-api: 8.9.2 node-gyp-build: 4.8.4 patch-package: 8.0.1 serialport: 12.0.0(supports-color@8.1.1) @@ -5380,7 +5380,7 @@ snapshots: '@stoprocent/noble@2.7.1(supports-color@8.1.1)': dependencies: debug: 4.4.3(patch_hash=cf37fa96f5df733456b16c82c9e1c9054a92f6216692aa33d5e2e1e208888e37)(supports-color@8.1.1) - node-addon-api: 8.9.1 + node-addon-api: 8.9.2 node-gyp-build: 4.8.4 patch-package: 8.0.1 optionalDependencies: @@ -5787,7 +5787,7 @@ snapshots: '@yarnpkg/lockfile@1.1.0': {} - '@zip.js/zip.js@2.8.43': {} + '@zip.js/zip.js@2.8.47': {} abbrev@4.0.0: {} @@ -8012,7 +8012,7 @@ snapshots: node-addon-api@8.3.0: {} - node-addon-api@8.9.1: {} + node-addon-api@8.9.2: {} node-api-version@0.2.1: dependencies: @@ -9044,7 +9044,7 @@ snapshots: usb@2.18.0(patch_hash=6b746e2d49b9b006a88aec5bed7a13c629d7f5ba7b40e9f1e039136754c32533): dependencies: '@types/w3c-web-usb': 1.0.14 - node-addon-api: 8.9.1 + node-addon-api: 8.9.2 node-gyp-build: 4.8.4 optional: true @@ -9320,7 +9320,7 @@ snapshots: zod@4.4.3: {} - zustand@5.0.14(@types/react@19.2.18)(immer@11.1.16)(react@19.2.8)(use-sync-external-store@1.6.0(react@19.2.8)): + zustand@5.0.15(@types/react@19.2.18)(immer@11.1.16)(react@19.2.8)(use-sync-external-store@1.6.0(react@19.2.8)): optionalDependencies: '@types/react': 19.2.18 immer: 11.1.16 diff --git a/scripts/detectReleaseBump.mjs b/scripts/detectReleaseBump.mjs index 94f46922a..b81a32da6 100644 --- a/scripts/detectReleaseBump.mjs +++ b/scripts/detectReleaseBump.mjs @@ -21,6 +21,29 @@ const RELEASE_TYPES = new Set([ 'ci', ]); +/** + * Conventional Commits footer tokens (space or hyphen) at the start of a line. + * Unanchored substring match is avoided so docs/examples in bodies do not force major. + * @param {string} bodiesJoined + * @returns {boolean} + */ +export function bodyHasBreakingChange(bodiesJoined) { + return /(?:^|\n)[ \t]*BREAKING[- ]CHANGE[ \t]*:/m.test(bodiesJoined); +} + +/** + * True when the subject is a supported conventional type with a breaking bang + * (`feat!:`, `fix(scope)!:`, …). Unsupported types (`revert!:`, `wip!:`) are false. + * Accepts optional leading `* ` from release-note bullet formatting. + * @param {string} subject + * @returns {boolean} + */ +export function isSupportedBreakingSubject(subject) { + const trimmed = subject.trim().replace(/^\*\s+/, ''); + const parsed = parseConventionalSubject(trimmed); + return parsed?.breakingBang === true; +} + /** * Parse Conventional Commit type / breaking bang from a subject line. * @param {string} subject @@ -55,7 +78,7 @@ export function parseConventionalSubject(subject) { * @returns {ReleaseBump} */ export function detectReleaseBump(subjects, bodiesJoined = '') { - let hasBreaking = bodiesJoined.includes('BREAKING CHANGE:'); + let hasBreaking = bodyHasBreakingChange(bodiesJoined); let hasFeat = false; let hasOther = false; diff --git a/scripts/detectReleaseBump.test.mjs b/scripts/detectReleaseBump.test.mjs index dd368dcf5..4bfc951b4 100644 --- a/scripts/detectReleaseBump.test.mjs +++ b/scripts/detectReleaseBump.test.mjs @@ -1,7 +1,9 @@ // @vitest-environment node import { describe, expect, it } from 'vitest'; import { + bodyHasBreakingChange, detectReleaseBump, + isSupportedBreakingSubject, parseConventionalSubject, previewNextVersion, } from './detectReleaseBump.mjs'; @@ -16,11 +18,49 @@ describe('parseConventionalSubject', () => { type: 'fix', breakingBang: true, }); + expect(parseConventionalSubject('Feat: Case')).toEqual({ + type: 'feat', + breakingBang: false, + }); expect(parseConventionalSubject('not conventional')).toBeNull(); + expect(parseConventionalSubject('feat(unclosed: missing paren')).toBeNull(); + expect(parseConventionalSubject('feat!(scope): bang before scope')).toBeNull(); + }); +}); + +describe('bodyHasBreakingChange', () => { + it('matches line-anchored BREAKING CHANGE and BREAKING-CHANGE', () => { + expect(bodyHasBreakingChange('BREAKING CHANGE: renamed\n')).toBe(true); + expect(bodyHasBreakingChange(' BREAKING-CHANGE: renamed\n')).toBe(true); + expect(bodyHasBreakingChange('subject\n\nBREAKING CHANGE: x')).toBe(true); + }); + + it('ignores unanchored substring mentions', () => { + expect( + bodyHasBreakingChange('See docs: never put BREAKING CHANGE: in examples casually\n'), + ).toBe(false); + expect(bodyHasBreakingChange('mentions BREAKING CHANGE: mid-sentence')).toBe(false); + }); +}); + +describe('isSupportedBreakingSubject', () => { + it('accepts supported type!: / type(scope)!: including note bullets', () => { + expect(isSupportedBreakingSubject('feat!: remove legacy')).toBe(true); + expect(isSupportedBreakingSubject('* fix(api)!: drop field')).toBe(true); + }); + + it('rejects unsupported type!: subjects (not reported as breaking)', () => { + expect(isSupportedBreakingSubject('revert!: undo deploy')).toBe(false); + expect(isSupportedBreakingSubject('* wip!: unfinished')).toBe(false); + expect(isSupportedBreakingSubject('feat: not breaking')).toBe(false); }); }); describe('detectReleaseBump', () => { + it('does not treat unsupported type!: as major', () => { + expect(detectReleaseBump(['revert!: undo', 'wip!: scratch'])).toBe('patch'); + }); + it('treats scoped feat(scope): as minor (squash-merge titles)', () => { expect( detectReleaseBump([ @@ -31,6 +71,10 @@ describe('detectReleaseBump', () => { ).toBe('minor'); }); + it('treats unscoped feat: as minor', () => { + expect(detectReleaseBump(['feat: add thing', 'fix: nudge'])).toBe('minor'); + }); + it('does not miss feat when only scoped feats exist (historical bash bug)', () => { // Old bash regex ^feat[[:space:]]*: matched zero of these → wrongly patch. expect( @@ -63,6 +107,19 @@ describe('detectReleaseBump', () => { ).toBe('major'); }); + it('detects BREAKING-CHANGE hyphen footer', () => { + expect(detectReleaseBump(['chore: prep'], 'BREAKING-CHANGE: drop flag\n')).toBe('major'); + }); + + it('does not major on unanchored BREAKING CHANGE mention', () => { + expect( + detectReleaseBump( + ['docs: explain footers'], + 'Do not confuse with inline BREAKING CHANGE: examples in prose.\n', + ), + ).toBe('patch'); + }); + it('ignores body bullet lines that look like commits (subjects-only)', () => { expect(detectReleaseBump(['chore: release prep'])).toBe('patch'); }); @@ -70,6 +127,10 @@ describe('detectReleaseBump', () => { it('defaults to patch when no conventional subjects', () => { expect(detectReleaseBump(['Merge branch main', 'WIP'])).toBe('patch'); }); + + it('defaults to patch for empty subject list', () => { + expect(detectReleaseBump([])).toBe('patch'); + }); }); describe('previewNextVersion', () => { @@ -82,4 +143,9 @@ describe('previewNextVersion', () => { it('accepts exact versions', () => { expect(previewNextVersion('5.27.1', '5.30.0')).toBe('5.30.0'); }); + + it('rejects invalid current / bump', () => { + expect(() => previewNextVersion('nope', 'patch')).toThrow(/Invalid current version/); + expect(() => previewNextVersion('1.2.3', 'weird')).toThrow(/Invalid bump/); + }); }); diff --git a/scripts/main-merge-queue-ruleset.test.mjs b/scripts/main-merge-queue-ruleset.test.mjs new file mode 100644 index 000000000..a6a7ed66b --- /dev/null +++ b/scripts/main-merge-queue-ruleset.test.mjs @@ -0,0 +1,55 @@ +// @vitest-environment node +/** + * Contract tests for the canonical main merge-queue ruleset JSON. + * Live ruleset id 20821455 must be updated via gh api PUT after changing this file. + */ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +const ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), '..'); +const RULESET_PATH = path.join(ROOT, '.github', 'rulesets', 'main-merge-queue.json'); + +const REQUIRED_CONTEXTS = [ + 'Build & Test', + 'Coverage (renderer-ui)', + 'Coverage (renderer-logic)', + 'Coverage (main)', + 'Merge coverage', +]; + +/** GitHub Actions app id used to pin required checks. */ +const GITHUB_ACTIONS_INTEGRATION_ID = 15368; + +describe('main-merge-queue.json contract', () => { + const ruleset = JSON.parse(fs.readFileSync(RULESET_PATH, 'utf8')); + + it('requires one approving review and re-approval after new pushes', () => { + const pr = ruleset.rules.find((r) => r.type === 'pull_request'); + expect(pr?.parameters?.required_approving_review_count).toBe(1); + expect(pr?.parameters?.dismiss_stale_reviews_on_push).toBe(true); + expect(pr?.parameters?.require_last_push_approval).toBe(true); + }); + + it('pins all required checks to GitHub Actions integration_id 15368', () => { + const status = ruleset.rules.find((r) => r.type === 'required_status_checks'); + const checks = status?.parameters?.required_status_checks ?? []; + expect(checks.map((c) => c.context).sort()).toEqual([...REQUIRED_CONTEXTS].sort()); + for (const check of checks) { + expect(check.integration_id).toBe(GITHUB_ACTIONS_INTEGRATION_ID); + } + }); + + it('keeps admin-only bypass (no Actions integration bypass)', () => { + expect(ruleset.bypass_actors).toEqual([ + { actor_id: 5, actor_type: 'RepositoryRole', bypass_mode: 'always' }, + ]); + }); + + it('enables merge_queue with ALLGREEN', () => { + const mq = ruleset.rules.find((r) => r.type === 'merge_queue'); + expect(mq?.parameters?.grouping_strategy).toBe('ALLGREEN'); + expect(ruleset.enforcement).toBe('active'); + }); +}); diff --git a/scripts/release.sh b/scripts/release.sh index 319935dbb..9c2e0c72f 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -231,8 +231,27 @@ EOF echo "" echo "### Breaking Changes" - if printf '%s\n' "$commit_logs" | grep -qE "(BREAKING CHANGE:|^\* [^:]+!:)"; then - printf '%s\n' "$commit_logs" | grep -E "(BREAKING CHANGE:|^\* [^:]+!:)" | sed 's/^/* /' + # Supported type!: / type(scope)!: subjects (via detectReleaseBump.mjs) plus + # line-anchored BREAKING CHANGE / BREAKING-CHANGE footers. + local commit_bodies breaking_lines + commit_bodies=$(git log "$last_tag"..HEAD --pretty=format:"%B" 2> /dev/null || true) + breaking_lines=$( + { + printf '%s\n' "$commit_logs" | node --input-type=module -e " + import { isSupportedBreakingSubject } from './scripts/detectReleaseBump.mjs'; + let s = ''; + process.stdin.on('data', (d) => { s += d; }); + process.stdin.on('end', () => { + for (const line of s.split('\\n')) { + if (line && isSupportedBreakingSubject(line)) process.stdout.write(line + '\\n'); + } + }); + " || true + printf '%s\n' "$commit_bodies" | grep -E '^[ \t]*BREAKING[- ]CHANGE[ \t]*:' || true + } | sed '/^$/d' + ) + if [ -n "$breaking_lines" ]; then + printf '%s\n' "$breaking_lines" | sed 's/^/* /' | sed 's/^\* \* /* /' else echo "*(None)*" fi @@ -251,10 +270,11 @@ EOF # titles like feat(rrc): … count as minor — the old bash regex missed scopes. detect_version_bump() { local last_tag="$1" - local current + local current json current=$(read_package_version) - node scripts/detectReleaseBump.mjs --since "$last_tag" --current "$current" \ - | node -e "let s='';process.stdin.on('data',d=>s+=d);process.stdin.on('end',()=>{const j=JSON.parse(s);process.stdout.write(j.bump);});" + # Avoid a pipe so detector non-zero exits are not masked (no pipefail required here). + json=$(node scripts/detectReleaseBump.mjs --since "$last_tag" --current "$current") || return 1 + node -e "process.stdout.write(JSON.parse(process.argv[1]).bump)" "$json" } # 1. Parse version / finish / non-interactive flags (order-independent). @@ -322,7 +342,14 @@ if [ "$FINISH_ONLY" = false ] && [ -z "$VERSION_TYPE" ] && [ "$AUTO_DETECT" = fa fi # Test hook: dump parsed flags and exit before git/network side effects. +# Block under GitHub Actions unless MESH_CLIENT_ALLOW_PARSE_ONLY_IN_CI=1 (unit tests). +# A repo/org Actions variable left at PARSE_ONLY=1 would otherwise green-succeed Cut release. if [ "${MESH_CLIENT_RELEASE_PARSE_ONLY:-}" = "1" ]; then + if [ "${GITHUB_ACTIONS:-}" = "true" ] && [ "${MESH_CLIENT_ALLOW_PARSE_ONLY_IN_CI:-}" != "1" ]; then + print_error "MESH_CLIENT_RELEASE_PARSE_ONLY is a local/test hook and cannot run under GitHub Actions." + print_error "Unset the variable (Cut release clears it) or set MESH_CLIENT_ALLOW_PARSE_ONLY_IN_CI=1 for tests." + exit 1 + fi printf 'RELEASE_YES=%s\n' "$RELEASE_YES" printf 'SKIP_DEP_UPDATE=%s\n' "$SKIP_DEP_UPDATE" printf 'FINISH_ONLY=%s\n' "$FINISH_ONLY" diff --git a/scripts/release.test.mjs b/scripts/release.test.mjs index de388c168..711d646f8 100644 --- a/scripts/release.test.mjs +++ b/scripts/release.test.mjs @@ -107,8 +107,11 @@ describe('release.sh full-suite gate', () => { it('delegates conventional bump detection to detectReleaseBump.mjs (scoped feats)', () => { expect(script).toMatch(/detectReleaseBump\.mjs/); expect(script).toMatch(/detect_version_bump/); + expect(script).toMatch(/isSupportedBreakingSubject/); // Historical bug: unscoped-only feat: regex missed feat(scope): expect(script).not.toMatch(/\^feat\[\[:space:\]\]\*:/); + // Notes must not use the naive type!: grep (false positives for revert!: etc.) + expect(script).not.toMatch(/\^\\\* \[\^:\]\+!:/); }); it('requires actionlint and yamllint (no soft-skip)', () => { @@ -137,8 +140,13 @@ describe('release.sh argv subprocess', () => { it('PARSE_ONLY: --yes enables RELEASE_YES without prompts', () => { const r = spawnSync('bash', [RELEASE_SH, '--yes', '--auto'], { encoding: 'utf8', - env: { ...process.env, MESH_CLIENT_RELEASE_PARSE_ONLY: '1' }, + env: { + ...process.env, + MESH_CLIENT_RELEASE_PARSE_ONLY: '1', + MESH_CLIENT_ALLOW_PARSE_ONLY_IN_CI: '1', + }, }); + expect(r.error).toBeUndefined(); expect(r.status).toBe(0); expect(r.stdout).toMatch(/^RELEASE_YES=true$/m); expect(r.stdout).toMatch(/^AUTO_DETECT=true$/m); @@ -147,21 +155,57 @@ describe('release.sh argv subprocess', () => { it('PARSE_ONLY: --skip-dep-update sets SKIP_DEP_UPDATE', () => { const r = spawnSync('bash', [RELEASE_SH, '--yes', '--skip-dep-update', 'patch'], { encoding: 'utf8', - env: { ...process.env, MESH_CLIENT_RELEASE_PARSE_ONLY: '1' }, + env: { + ...process.env, + MESH_CLIENT_RELEASE_PARSE_ONLY: '1', + MESH_CLIENT_ALLOW_PARSE_ONLY_IN_CI: '1', + }, }); + expect(r.error).toBeUndefined(); expect(r.status).toBe(0); expect(r.stdout).toMatch(/^SKIP_DEP_UPDATE=true$/m); expect(r.stdout).toMatch(/^VERSION_TYPE=patch$/m); }); + it('PARSE_ONLY: rejects under GitHub Actions without allow flag', () => { + const r = spawnSync('bash', [RELEASE_SH, '--yes', '--auto'], { + encoding: 'utf8', + env: { + ...process.env, + MESH_CLIENT_RELEASE_PARSE_ONLY: '1', + GITHUB_ACTIONS: 'true', + MESH_CLIENT_ALLOW_PARSE_ONLY_IN_CI: '', + }, + }); + expect(r.error).toBeUndefined(); + expect(r.status).toBe(1); + expect(`${r.stdout}${r.stderr}`).toMatch(/cannot run under GitHub Actions/); + }); + + it('PARSE_ONLY: MESH_CLIENT_RELEASE_YES without --yes', () => { + const r = spawnSync('bash', [RELEASE_SH, '--auto'], { + encoding: 'utf8', + env: { + ...process.env, + MESH_CLIENT_RELEASE_PARSE_ONLY: '1', + MESH_CLIENT_ALLOW_PARSE_ONLY_IN_CI: '1', + MESH_CLIENT_RELEASE_YES: '1', + }, + }); + expect(r.error).toBeUndefined(); + expect(r.status).toBe(0); + expect(r.stdout).toMatch(/^RELEASE_YES=true$/m); + }); + it('skip-dep-update does not run pnpm update/dedupe (stubbed git + pnpm)', () => { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'mesh-release-argv-')); - const bin = path.join(tmp, 'bin'); - const pnpmLog = path.join(tmp, 'pnpm.log'); - fs.mkdirSync(bin); - fs.writeFileSync( - path.join(bin, 'git'), - `#!/usr/bin/env bash + try { + const bin = path.join(tmp, 'bin'); + const pnpmLog = path.join(tmp, 'pnpm.log'); + fs.mkdirSync(bin); + fs.writeFileSync( + path.join(bin, 'git'), + `#!/usr/bin/env bash set -e case "$*" in *'rev-parse --abbrev-ref HEAD'*|*rev-parse*abbrev-ref*) @@ -184,31 +228,34 @@ case "$*" in ;; esac `, - { mode: 0o755 }, - ); - fs.writeFileSync( - path.join(bin, 'pnpm'), - `#!/usr/bin/env bash + { mode: 0o755 }, + ); + fs.writeFileSync( + path.join(bin, 'pnpm'), + `#!/usr/bin/env bash printf '%s\\n' "$*" >> ${JSON.stringify(pnpmLog)} # Fail after recording so preflight cannot mutate the repo. exit 1 `, - { mode: 0o755 }, - ); + { mode: 0o755 }, + ); - const r = spawnSync('bash', [RELEASE_SH, '--yes', '--skip-dep-update', 'patch'], { - cwd: ROOT, - encoding: 'utf8', - env: { - ...process.env, - PATH: `${bin}${path.delimiter}${process.env.PATH ?? ''}`, - MESH_CLIENT_RELEASE_YES: '1', - }, - }); - expect(r.status).not.toBe(0); - const log = fs.existsSync(pnpmLog) ? fs.readFileSync(pnpmLog, 'utf8') : ''; - expect(log).not.toMatch(/(^|\n)update(\s|$)/); - expect(log).not.toMatch(/(^|\n)dedupe(\s|$)/); - expect(`${r.stdout}${r.stderr}`).toMatch(/Skipping pnpm update\/dedupe/); + const r = spawnSync('bash', [RELEASE_SH, '--yes', '--skip-dep-update', 'patch'], { + cwd: ROOT, + encoding: 'utf8', + env: { + ...process.env, + PATH: `${bin}${path.delimiter}${process.env.PATH ?? ''}`, + MESH_CLIENT_RELEASE_YES: '1', + }, + }); + expect(r.status).not.toBe(0); + const log = fs.existsSync(pnpmLog) ? fs.readFileSync(pnpmLog, 'utf8') : ''; + expect(log).not.toMatch(/(^|\n)update(\s|$)/); + expect(log).not.toMatch(/(^|\n)dedupe(\s|$)/); + expect(`${r.stdout}${r.stderr}`).toMatch(/Skipping pnpm update\/dedupe/); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } }); });