From 699d093d7aca9f40bd3dca05f689982a485f6a69 Mon Sep 17 00:00:00 2001 From: Joey Stanford Date: Thu, 13 Aug 2026 17:14:31 -0600 Subject: [PATCH 1/3] fix(ci): harden cut-release inputs and ruleset check pins Pin required merge-queue checks to GitHub Actions (integration_id 15368), pass cut-release bump/skip flags via env (no shell interpolation), reject --auto with an explicit bump, and add subprocess coverage for those paths. --- .github/rulesets/main-merge-queue.json | 10 +-- .github/workflows/cut-release.yaml | 28 ++++---- docs/ci-cd.md | 8 ++- docs/release-process.md | 3 +- scripts/release.sh | 16 +++++ scripts/release.test.mjs | 92 ++++++++++++++++++++++++++ 6 files changed, 136 insertions(+), 21 deletions(-) diff --git a/.github/rulesets/main-merge-queue.json b/.github/rulesets/main-merge-queue.json index 2419a1031..2c2e72b22 100644 --- a/.github/rulesets/main-merge-queue.json +++ b/.github/rulesets/main-merge-queue.json @@ -39,11 +39,11 @@ "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" } + { "context": "Build & Test", "integration_id": 15368 }, + { "context": "Coverage (renderer-ui)", "integration_id": 15368 }, + { "context": "Coverage (renderer-logic)", "integration_id": 15368 }, + { "context": "Coverage (main)", "integration_id": 15368 }, + { "context": "Merge coverage", "integration_id": 15368 } ] } }, diff --git a/.github/workflows/cut-release.yaml b/.github/workflows/cut-release.yaml index 14da02825..ec330abf4 100644 --- a/.github/workflows/cut-release.yaml +++ b/.github/workflows/cut-release.yaml @@ -87,12 +87,15 @@ jobs: - name: Preview version bump id: preview + env: + BUMP_INPUT: ${{ inputs.bump }} + DRY_RUN_INPUT: ${{ inputs.dry_run }} + SKIP_DEP_UPDATE_INPUT: ${{ inputs.skip_dep_update }} run: | set -euo pipefail LAST_TAG=$(git describe --tags --abbrev=0) CURRENT=$(node -p "require('./package.json').version") - BUMP_INPUT='${{ inputs.bump }}' - case "$BUMP_INPUT" in + case "${BUMP_INPUT}" in auto|'') JSON=$(node scripts/detectReleaseBump.mjs --since "$LAST_TAG" --current "$CURRENT") BUMP=$(node -e "process.stdout.write(JSON.parse(process.argv[1]).bump)" "$JSON") @@ -116,7 +119,7 @@ jobs: SUBJECTS=$(git log "$LAST_TAG"..HEAD --oneline | wc -l | tr -d ' ') MODE=exact else - echo "::error::Invalid bump input: $BUMP_INPUT" + echo "::error::Invalid bump input: ${BUMP_INPUT}" exit 1 fi ;; @@ -132,8 +135,8 @@ jobs: echo "| Detected / chosen bump | \`$BUMP\` |" echo "| Next version | **\`v$NEXT\`** |" echo "| Commits since tag | $SUBJECTS |" - echo "| dry_run | \`${{ inputs.dry_run }}\` |" - echo "| skip_dep_update | \`${{ inputs.skip_dep_update }}\` |" + echo "| dry_run | \`$DRY_RUN_INPUT\` |" + echo "| skip_dep_update | \`$SKIP_DEP_UPDATE_INPUT\` |" } >> "$GITHUB_STEP_SUMMARY" { echo "bump=$BUMP" @@ -189,23 +192,24 @@ jobs: if: ${{ inputs.dry_run != true && inputs.dry_run != 'true' }} env: MESH_CLIENT_RELEASE_YES: '1' + RELEASE_BUMP: ${{ steps.preview.outputs.bump }} + SKIP_DEP_UPDATE: ${{ inputs.skip_dep_update }} run: | set -euo pipefail - BUMP='${{ steps.preview.outputs.bump }}' EXTRA=() - if [ '${{ inputs.skip_dep_update }}' = 'true' ]; then + if [ "${SKIP_DEP_UPDATE}" = 'true' ]; then EXTRA+=(--skip-dep-update) fi - case "$BUMP" in + case "${RELEASE_BUMP}" in patch|minor|major) - pnpm run release -- "$BUMP" "${EXTRA[@]}" + pnpm run release -- "${RELEASE_BUMP}" "${EXTRA[@]}" ;; *) # Exact X.Y.Z from preview (auto resolves to patch|minor|major already) - if [[ "$BUMP" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then - pnpm run release -- "$BUMP" "${EXTRA[@]}" + if [[ "${RELEASE_BUMP}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + pnpm run release -- "${RELEASE_BUMP}" "${EXTRA[@]}" else - echo "::error::Unexpected bump from preview: $BUMP" + echo "::error::Unexpected bump from preview: ${RELEASE_BUMP}" exit 1 fi ;; diff --git a/docs/ci-cd.md b/docs/ci-cd.md index 76836c9ab..25db8b026 100644 --- a/docs/ci-cd.md +++ b/docs/ci-cd.md @@ -8,8 +8,8 @@ Mesh-Client uses GitHub Actions for continuous integration and deployment. | Workflow | Trigger | Purpose | | --------------------------- | -------------------------------------------- | ------------------------------------------------------------------------------- | -| `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 | +| `ci.yaml` | Push/PR/`merge_group`/`workflow_dispatch` | Lint, typecheck, build, Flatpak manifest validation | +| `tests.yaml` | Push/PR/`merge_group`/`workflow_dispatch` | 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 | @@ -310,6 +310,8 @@ Only checks that report on every PR and every `merge_group` run are required: | `Coverage (main)` | `tests.yaml` | | `Merge coverage` | `tests.yaml` | +Each required check is pinned with `integration_id` **15368** (GitHub Actions) in [`.github/rulesets/main-merge-queue.json`](../.github/rulesets/main-merge-queue.json). + **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) @@ -348,7 +350,7 @@ All PRs (and merge-queue groups) for `main` must pass the **required check names - 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) +- 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 2f13fc350..79cfa702f 100644 --- a/docs/release-process.md +++ b/docs/release-process.md @@ -47,7 +47,8 @@ Documentation deploys separately: [`docs.yml`](../.github/workflows/docs.yml) ru 1. (Optional) Run once with **dry_run** checked to confirm the computed version in the job summary (`feat(scope):` → minor, etc.). 2. Re-run with dry_run unchecked. Default bump is **auto**; override with `patch` / `minor` / `major` / exact `X.Y.Z` when needed. 3. **skip_dep_update** defaults to **true** — bump dependencies in a normal PR via `pnpm run update` before cutting. -4. Wait for `release.yaml` + `flatpak.yaml` to attach draft artifacts, then **Publish** on GitHub. +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. diff --git a/scripts/release.sh b/scripts/release.sh index 044b99f0a..319935dbb 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -304,6 +304,12 @@ if [ "$FINISH_ONLY" = true ] && { [ -n "$VERSION_TYPE" ] || [ "$AUTO_DETECT" = t exit 1 fi +if [ "$AUTO_DETECT" = true ] && [ -n "$VERSION_TYPE" ]; then + print_error "--auto cannot be combined with patch|minor|major|x.x.x." + 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 @@ -315,6 +321,16 @@ if [ "$FINISH_ONLY" = false ] && [ -z "$VERSION_TYPE" ] && [ "$AUTO_DETECT" = fa AUTO_DETECT=true fi +# Test hook: dump parsed flags and exit before git/network side effects. +if [ "${MESH_CLIENT_RELEASE_PARSE_ONLY:-}" = "1" ]; then + printf 'RELEASE_YES=%s\n' "$RELEASE_YES" + printf 'SKIP_DEP_UPDATE=%s\n' "$SKIP_DEP_UPDATE" + printf 'FINISH_ONLY=%s\n' "$FINISH_ONLY" + printf 'AUTO_DETECT=%s\n' "$AUTO_DETECT" + printf 'VERSION_TYPE=%s\n' "$VERSION_TYPE" + exit 0 +fi + # 2. Ensure we are on the main branch print_header "Checking git status..." CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD) diff --git a/scripts/release.test.mjs b/scripts/release.test.mjs index b8b4a5636..de388c168 100644 --- a/scripts/release.test.mjs +++ b/scripts/release.test.mjs @@ -1,5 +1,7 @@ // @vitest-environment node +import { spawnSync } from 'node:child_process'; import fs from 'node:fs'; +import os from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; @@ -119,4 +121,94 @@ describe('release.sh full-suite gate', () => { it.each(REQUIRED_PNPM_CHECKS)('invokes pnpm run %s', (scriptName) => { expect(script).toContain(`pnpm run ${scriptName}`); }); + + it('rejects --auto combined with an explicit bump (text contract)', () => { + expect(script).toMatch(/--auto cannot be combined with patch\|minor\|major/); + }); +}); + +describe('release.sh argv subprocess', () => { + it('rejects --auto patch before side effects', () => { + const r = spawnSync('bash', [RELEASE_SH, '--auto', 'patch'], { encoding: 'utf8' }); + expect(r.status).toBe(1); + expect(`${r.stdout}${r.stderr}`).toMatch(/--auto cannot be combined/); + }); + + 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' }, + }); + expect(r.status).toBe(0); + expect(r.stdout).toMatch(/^RELEASE_YES=true$/m); + expect(r.stdout).toMatch(/^AUTO_DETECT=true$/m); + }); + + 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' }, + }); + expect(r.status).toBe(0); + expect(r.stdout).toMatch(/^SKIP_DEP_UPDATE=true$/m); + expect(r.stdout).toMatch(/^VERSION_TYPE=patch$/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 +set -e +case "$*" in + *'rev-parse --abbrev-ref HEAD'*|*rev-parse*abbrev-ref*) + echo main + exit 0 + ;; + *pull*) + exit 0 + ;; + *'describe --tags'*|*describe*) + echo v9.9.9 + exit 0 + ;; + *log*) + echo "deadbeef feat: stub commit" + exit 0 + ;; + *) + exit 0 + ;; +esac +`, + { 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 }, + ); + + 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/); + }); }); From 431e41b515ea1c201698c292e99c1268790d52e8 Mon Sep 17 00:00:00 2001 From: Joey Stanford Date: Thu, 13 Aug 2026 17:26:39 -0600 Subject: [PATCH 2/3] fix(ci): require one approving review before merge queue Stop unreviewed PRs from entering the queue; keep the canonical ruleset JSON and docs aligned with the live main ruleset. --- .github/rulesets/main-merge-queue.json | 2 +- docs/ci-cd.md | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/.github/rulesets/main-merge-queue.json b/.github/rulesets/main-merge-queue.json index 2c2e72b22..3857788ae 100644 --- a/.github/rulesets/main-merge-queue.json +++ b/.github/rulesets/main-merge-queue.json @@ -25,7 +25,7 @@ { "type": "pull_request", "parameters": { - "required_approving_review_count": 0, + "required_approving_review_count": 1, "dismiss_stale_reviews_on_push": false, "require_code_owner_review": false, "require_last_push_approval": false, diff --git a/docs/ci-cd.md b/docs/ci-cd.md index 25db8b026..0636378c0 100644 --- a/docs/ci-cd.md +++ b/docs/ci-cd.md @@ -294,9 +294,10 @@ Note: The test results artifact upload step is automatically skipped when runnin `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` +2. Requires **at least one approving review** before the merge queue +3. Requires a **merge queue** +4. Requires **strict** status checks (must pass on the merge group / up-to-date tip) +5. Blocks force-pushes and branch deletion on `main` ### Required check names (always-on) From 75d6db0776c1b998e5f01f32a26fa2cfe90cbbda Mon Sep 17 00:00:00 2001 From: Joey Stanford Date: Thu, 13 Aug 2026 17:28:51 -0600 Subject: [PATCH 3/3] chore(ci): format main merge-queue ruleset JSON --- .github/rulesets/main-merge-queue.json | 25 ++++++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/.github/rulesets/main-merge-queue.json b/.github/rulesets/main-merge-queue.json index 3857788ae..e3b4324a6 100644 --- a/.github/rulesets/main-merge-queue.json +++ b/.github/rulesets/main-merge-queue.json @@ -39,11 +39,26 @@ "strict_required_status_checks_policy": true, "do_not_enforce_on_create": false, "required_status_checks": [ - { "context": "Build & Test", "integration_id": 15368 }, - { "context": "Coverage (renderer-ui)", "integration_id": 15368 }, - { "context": "Coverage (renderer-logic)", "integration_id": 15368 }, - { "context": "Coverage (main)", "integration_id": 15368 }, - { "context": "Merge coverage", "integration_id": 15368 } + { + "context": "Build & Test", + "integration_id": 15368 + }, + { + "context": "Coverage (renderer-ui)", + "integration_id": 15368 + }, + { + "context": "Coverage (renderer-logic)", + "integration_id": 15368 + }, + { + "context": "Coverage (main)", + "integration_id": 15368 + }, + { + "context": "Merge coverage", + "integration_id": 15368 + } ] } },