Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 21 additions & 6 deletions .github/rulesets/main-merge-queue.json
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -39,11 +39,26 @@
"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
}
]
}
},
Expand Down
28 changes: 16 additions & 12 deletions .github/workflows/cut-release.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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
;;
Expand All @@ -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"
Expand Down Expand Up @@ -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
;;
Expand Down
15 changes: 9 additions & 6 deletions docs/ci-cd.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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)

Expand All @@ -310,6 +311,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)
Expand Down Expand Up @@ -348,7 +351,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)

---

Expand Down
3 changes: 2 additions & 1 deletion docs/release-process.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
16 changes: 16 additions & 0 deletions scripts/release.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down
92 changes: 92 additions & 0 deletions scripts/release.test.mjs
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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/);
});
});