diff --git a/.ai/contexts/build-release.md b/.ai/contexts/build-release.md index 692e3707b..15ea19d32 100644 --- a/.ai/contexts/build-release.md +++ b/.ai/contexts/build-release.md @@ -1,12 +1,33 @@ # Build and release workflow -`.github/workflows/build.yml` uses one event-dependent `build` matrix. PRs -include Windows, Linux x64 and Linux arm64; version-tag pushes also include -macOS. The JSON arrays in `matrix.include` must keep the non-mac entries in -sync. Each matrix job has `timeout-minutes: 60`, including macOS. +`.github/workflows/build.yml` runs on version-tag pushes, manual dispatches +and packaging-related PRs to `main`. PRs and dispatches include Windows, +Linux x64 and Linux arm64. Only a `push` whose ref starts with `refs/tags/v` +adds macOS; dispatching on a tag ref still uses the three-job matrix. The +JSON arrays in `matrix.include` must keep the non-mac entries in sync. -`publish` keeps `needs: build` and the default success gate. A failed or -cancelled macOS tag build blocks automatic publication; no partial-publish or +The PR path filter covers `package.json`, `package-lock.json`, the requested +root `postinstall.js` path, `scripts/**` and the build workflow itself. The +actual postinstall is `scripts/postinstall.js`; `scripts/**` also covers the +`scripts/after-pack.js` hook and icon generators. `build/**` covers icons, macOS +entitlements and the DMG background. The builder configuration is inline in +`package.json`; there is no separate electron-builder config. Dependencies are +covered by the lockfile. The filter is packaging only: application source +(`*.js`, `src/**`, `workers/**`, `public/**`, `harnesses/**`) and `CHANGELOG.md` +are deliberately absent, so a code PR builds no installers. A packaging break +caused by source surfaces at the tag, where strict publish blocks it (accepted +risk, maintainer decision). + +Each matrix job retains `timeout-minutes: 60`. These are parallel jobs, not +dependent stages: the budget must fit installation/native rebuilds, both +viewer bundles and packaging, including macOS arm64+x64 DMG/ZIP and Windows +x64+arm64 NSIS. Retaining the existing hour avoids cutting the tag-build +budget without measured runner timings; it is not a measured duration. +`publish`, the only downstream job (`needs: build`), has a 10-minute bound. + +`publish` requires a version-tag **push** and keeps `needs: build` and the +default success gate. A manual dispatch never publishes, even on a tag ref. +A failed or cancelled macOS tag build blocks automatic publication; no partial-publish or `continue-on-error` path exists. Recovery and manifest consistency requirements are in [releasing.md](../../docs/releasing.md). @@ -17,13 +38,42 @@ module rebuild between architectures applies only to the first target type; multiple targets in one multi-architecture invocation can reuse modules from the wrong architecture (issue #18). -`test/build-workflow.test.js` parses the actual workflow using `js-yaml` and -evaluates its expression for a PR and a tag push. It checks the exact matrices, -timeout, triggers, failure propagation configuration and strict publish -dependency. It evaluates the expression as JavaScript, not with the Actions -expression engine, so it is a configuration guard only: the proof that Actions -accepts the expression is a PR run's job list (no mac job), and the tag branch -is first seen at the next tag. It does not verify runner queue behavior either; +Both workflows group PR runs per PR number (`pr-`) and give every other +run its own group (`github.run_id`), and cancel older runs only for `pull_request`. +Tag pushes, main pushes and dispatches never cancel in-progress runs. Every +upload explicitly retains PR artifacts for 3 days and other artifacts for +14 days. Tag artifacts feed `publish` through `actions/download-artifact` in +the same run, so the 14-day retention does not change that dependency. + +`test.yml` has no path filter. PRs run Node 20/Linux and Node 22/Linux+Windows; +main pushes run Node 22/Linux+Windows. Matrix keys remain `node-version`, then +`os`, with no job-name override, retaining contexts such as +`test (22, ubuntu-latest)`. No step depends on a Node 20 or Windows/Node 20 +cell. Linux real-bwrap enforcement stays on every Linux test cell; patch +coverage stays on PR Node 22/Linux only. Lint and e2e still run for every PR +and main push; changelog stays PR-only. Test jobs have 25 minutes; lint, +changelog and e2e have 10 each. + +The required-check list is documented in [releasing.md](../../docs/releasing.md). +Build is not a required check. JB owns the external settings update that removes +`test (20, windows-2022)` from both ruleset 17240725 (`main-protection`) and the +classic branch protection on `main` (`required_status_checks.contexts`); both +must be updated together whenever the test matrix changes. Changing these +workflows does not update those settings. +This CI-only change takes the `no-changelog` PR label. + +To obtain an installer for any branch, dispatch the build and download that +run's artifacts; see [testing-a-pr.md](../../docs/testing-a-pr.md#obtaining-a-pr-installer). +`task test-pr` runs sources and has no artifact dependency. + +`test/build-workflow.test.js` parses both actual workflows using `js-yaml` +and evaluates their JSON matrix literals for PRs, pushes and dispatches, +including dispatch on a tag. It checks matrices, timeouts, triggers, retention, +concurrency, unchanged Test gates, failure propagation and strict publish +dependency. It evaluates expressions as JavaScript, not with the Actions +expression engine, so it is a configuration guard only. An Actions run's job +list must verify expression acceptance and context names; the full release +remains a tag-run check. It does not verify runner queue behavior either; `timeout-minutes` bounds a hung build on a runner, not time spent queued. Trade-off: the macOS-only "Test postinstall helpers" step now runs on tags diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a02494def..51cd8e2aa 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -3,8 +3,20 @@ name: Build & Release on: push: tags: ['v*'] + workflow_dispatch: pull_request: branches: [main] + paths: + - package.json + - package-lock.json + - postinstall.js + - scripts/** + - .github/workflows/build.yml + - build/** + +concurrency: + group: ${{ github.workflow }}-${{ github.event_name == 'pull_request' && format('pr-{0}', github.event.pull_request.number) || github.run_id }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} permissions: contents: read @@ -17,7 +29,7 @@ jobs: # see .ai/contexts/build-release.md matrix: include: >- - ${{ fromJSON(github.event_name != 'pull_request' && + ${{ fromJSON(github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') && '[{"os":"macos-14","platform":"mac"},{"os":"windows-2022","platform":"win"},{"os":"ubuntu-latest","platform":"linux","arch":"x64"},{"os":"ubuntu-24.04-arm","platform":"linux","arch":"arm64"}]' || '[{"os":"windows-2022","platform":"win"},{"os":"ubuntu-latest","platform":"linux","arch":"x64"},{"os":"ubuntu-24.04-arm","platform":"linux","arch":"arm64"}]') }} @@ -84,11 +96,13 @@ jobs: dist/*.pacman dist/latest*.yml if-no-files-found: ignore + retention-days: ${{ github.event_name == 'pull_request' && 3 || 14 }} publish: needs: build - if: startsWith(github.ref, 'refs/tags/v') + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') runs-on: ubuntu-latest + timeout-minutes: 10 permissions: contents: write steps: diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e7344ad7b..9cdfad74d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -6,6 +6,10 @@ on: push: branches: [main] +concurrency: + group: ${{ github.workflow }}-${{ github.event_name == 'pull_request' && format('pr-{0}', github.event.pull_request.number) || github.run_id }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: lint: # Separate job, not an npm pre-hook: `npm run coverage` (below) has no @@ -13,6 +17,7 @@ jobs: # ran in CI. A dedicated job can't be skipped by a future script rename — # it shows up as its own entry in the checks list. runs-on: ubuntu-latest + timeout-minutes: 10 steps: - uses: actions/checkout@v4 @@ -34,6 +39,7 @@ jobs: # see docs/changelog.md ("The CI check") if: github.event_name == 'pull_request' runs-on: ubuntu-latest + timeout-minutes: 10 steps: - uses: actions/checkout@v4 @@ -49,7 +55,7 @@ jobs: e2e: # see docs/e2e.md runs-on: ubuntu-latest - timeout-minutes: 20 + timeout-minutes: 10 steps: - uses: actions/checkout@v4 @@ -90,16 +96,19 @@ jobs: path: | ${{ runner.temp }}/switchboard-e2e-results/ e2e/playwright-report/ - retention-days: 14 + retention-days: ${{ github.event_name == 'pull_request' && 3 || 14 }} test: runs-on: ${{ matrix.os }} + timeout-minutes: 25 strategy: fail-fast: false matrix: - node-version: [20, 22] - os: [ubuntu-latest, windows-2022] + include: >- + ${{ fromJSON(github.event_name == 'pull_request' && + '[{"node-version":20,"os":"ubuntu-latest"},{"node-version":22,"os":"ubuntu-latest"},{"node-version":22,"os":"windows-2022"}]' || + '[{"node-version":22,"os":"ubuntu-latest"},{"node-version":22,"os":"windows-2022"}]') }} steps: - uses: actions/checkout@v4 diff --git a/docs/e2e.md b/docs/e2e.md index a0270e14c..d8fba85ba 100644 --- a/docs/e2e.md +++ b/docs/e2e.md @@ -31,7 +31,8 @@ Electron's ABI. `postinstall` swallows a failed rebuild, and without that check a failed rebuild would show up as an app that does not start. When a journey fails, the job uploads `switchboard-e2e-results/` under the runner's temporary directory (a `trace.zip` and a `failure.png` for each failed journey) and the -HTML report as the `e2e-results` artifact. +HTML report as the `e2e-results` artifact, retained for 3 days on PRs and +14 days on main pushes. The job has a 10-minute timeout. Open a trace with `npx playwright show-trace trace.zip`. It has the DOM and a screenshot for each step. diff --git a/docs/releasing.md b/docs/releasing.md index 57086d78c..0a063662e 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -23,10 +23,16 @@ request first, because `main` is protected. `test/changelog.test.js` fails when `CHANGELOG.md` has no section for the version in `package.json`, so a bump without its section does not pass CI. - The `main-protection` ruleset requires the checks `lint`, - `test (20, ubuntu-latest)`, `test (20, windows-2022)`, `test (22, ubuntu-latest)`, - `test (22, windows-2022)`, `changelog` and `e2e`, and no approving review, so - `gh pr merge --auto --squash` can be armed as soon as the checks run. + The required PR checks for this workflow are `lint`, + `test (20, ubuntu-latest)`, `test (22, ubuntu-latest)`, + `test (22, windows-2022)`, `changelog` and `e2e`. Build is not required. + JB must remove the obsolete `test (20, windows-2022)` context from both + places that require it: ruleset 17240725 (`main-protection`) and the classic + branch protection on `main` (`required_status_checks.contexts`). Both must + be updated together whenever the test matrix changes; workflow changes do + not change repository settings. The documented ruleset requires + no approving review, so `gh pr merge --auto --squash` can be armed once the + required checks run. CI-only PRs use the `no-changelog` label. 2. **Run the app before tagging.** The tag is what publishes, and nothing between the merge and the tag looks at the assembled whole; the test suite @@ -45,12 +51,20 @@ request first, because `main` is protected. 4. **Wait for the build.** The tag starts `.github/workflows/build.yml`: macOS (macos-14), Windows (windows-2022), and Linux x64 and arm64 on separate - runners. Pull requests build only Windows and both Linux architectures; - macOS remains part of every tag build. Each build job has a 60-minute timeout. + runners. Packaging-related pull requests and manual dispatches build only + Windows and both Linux architectures; macOS remains part of every tag push + build. A dispatch never publishes, even on a tag ref. For a PR installer, + see [Testing a PR live](testing-a-pr.md#obtaining-a-pr-installer). + Each build job retains its 60-minute timeout for native rebuilds and + multi-architecture packaging; publish has a 10-minute timeout. The publish job keeps `needs: build`: all matrix entries must succeed before it runs. If macOS fails, is cancelled or times out, nothing is published automatically, even when Windows and Linux succeed. + Tag artifacts are downloaded by publish in the same run and retained for + 14 days (PR artifacts: 3 days). Only older runs of the same PR are cancelled + by its newer runs; tag and main pushes never cancel in-progress runs. + To recover, re-run the failed macOS job in the tag's Actions run (or re-run failed jobs) and let the dependent publish job run once every build succeeds. Alternatively, create the release draft by hand using only artifacts from diff --git a/docs/testing-a-pr.md b/docs/testing-a-pr.md index 4819c93d1..42c30db84 100644 --- a/docs/testing-a-pr.md +++ b/docs/testing-a-pr.md @@ -10,6 +10,28 @@ The default mode keeps your real HOME and transcript history. `ISOLATED` takes `1` for fixture mode or `0` for the default mode; unset or empty also selects the default. Other values produce a usage error before any git command. +## Obtaining a PR installer + +PR installers are built only when a PR to `main` changes packaging files: +`package.json`, `package-lock.json`, `postinstall.js`, `scripts/**`, `build/**` +or `.github/workflows/build.yml`. Shipped source such as `main.js` or `public/` +does not trigger a build. Other PRs have no automatic build artifacts. Build is +not a required check. To build a PR branch on demand: + +```bash +gh workflow run build.yml --repo devsuitup/switchboard --ref +gh run list --repo devsuitup/switchboard --workflow build.yml --branch --event workflow_dispatch --limit 5 +gh run watch --repo devsuitup/switchboard --exit-status +gh run download --repo devsuitup/switchboard --dir .work-files/pr-installer +``` + +Select the dispatched run for the intended commit from the list. Dispatches +build Windows, Linux x64 and Linux arm64, and never publish a release, even +when dispatched on a tag ref. macOS installers are built on version-tag pushes. +PR artifacts expire after 3 days; dispatch and tag artifacts after 14 days. +Dispatch again if an artifact has expired. Installing a downloaded installer +can replace the installed app; the source task below runs an isolated instance. + ## From source, not a build Electron receives the absolute worktree path and `--no-sandbox` to run the @@ -29,7 +51,7 @@ the launch with an error. It rebuilds even when a bundle already exists, so the file viewers use the PR's current inputs. A failed bundle build stops the launch and reports the worktree path and build error. -The task does not package the app or rebuild native modules, which other +The task does not download CI artifacts, package the app or rebuild native modules, which other instances may have loaded (see [Development](development.md#building-and-replacing-while-an-installed-copy-runs)). diff --git a/test/build-workflow.test.js b/test/build-workflow.test.js index f73267cf7..835297d9b 100644 --- a/test/build-workflow.test.js +++ b/test/build-workflow.test.js @@ -15,17 +15,20 @@ const nonMac = [ { os: 'ubuntu-24.04-arm', platform: 'linux', arch: 'arm64' }, ]; -function matrixFor(eventName) { - const include = workflow.jobs.build.strategy.matrix.include; - if (Array.isArray(include)) return include; - const expression = include.match(/^\$\{\{\s*([\s\S]*?)\s*\}\}$/); - assert.ok(expression, 'matrix.include must be an array or an Actions expression'); +function evaluate(value, eventName, ref = 'refs/tags/v1.0.0', headRef = '', extra = {}) { + const expression = value.match(/^\$\{\{\s*([\s\S]*?)\s*\}\}$/); return vm.runInNewContext(expression[1], { - github: { event_name: eventName }, + github: { event_name: eventName, ref, head_ref: headRef, workflow: 'Workflow', ...extra }, fromJSON: JSON.parse, + format: (template, ...args) => template.replace(/\{(\d+)\}/g, (_, i) => args[i]), + startsWith: (value, prefix) => value.startsWith(prefix), }, { filename: workflowPath, timeout: 1000 }); } +function matrixFor(eventName, ref) { + return evaluate(workflow.jobs.build.strategy.matrix.include, eventName, ref); +} + test('pull requests build exactly Windows and both Linux architectures', () => { assert.deepEqual(matrixFor('pull_request'), nonMac); }); @@ -38,6 +41,13 @@ test('tag pushes also build macOS on the existing runner', () => { assert.equal(workflow.jobs.build.if, undefined, 'tag builds cannot skip macOS'); }); +test('dispatch builds the PR matrix even when dispatched on a tag ref', () => { + for (const ref of ['refs/heads/main', 'refs/tags/v1.0.0']) { + assert.deepEqual(matrixFor('workflow_dispatch', ref), nonMac); + } + assert.deepEqual(matrixFor('push', 'refs/heads/main'), nonMac); +}); + test('macOS has a finite job timeout of at most 60 minutes', () => { assert.ok(matrixFor('push').some(({ platform }) => platform === 'mac')); const timeout = workflow.jobs.build['timeout-minutes']; @@ -50,7 +60,16 @@ test('publish waits for every build and retains the default success gate on tags const needs = Array.isArray(publish.needs) ? publish.needs : [publish.needs]; assert.deepEqual(needs.toSorted(), Object.keys(buildJobs).toSorted()); assert.deepEqual(needs, ['build']); - assert.equal(publish.if, "startsWith(github.ref, 'refs/tags/v')"); + assert.equal(publish.if, "github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')"); +}); + +test('dispatch never publishes, including on a tag ref', () => { + const condition = '${{ ' + workflow.jobs.publish.if + ' }}'; + for (const event of ['workflow_dispatch', 'pull_request', 'push']) { + for (const ref of ['refs/heads/main', 'refs/tags/v1.0.0']) { + assert.equal(evaluate(condition, event, ref), event === 'push' && ref.startsWith('refs/tags/v'), `${event}: ${ref}`); + } + } }); test('build failures remain failures on PRs and tags', () => { @@ -65,6 +84,89 @@ test('build failures remain failures on PRs and tags', () => { test('the workflow retains main PR and version-tag triggers', () => { assert.deepEqual(workflow.on, { - push: { tags: ['v*'] }, pull_request: { branches: ['main'] }, + push: { tags: ['v*'] }, workflow_dispatch: null, + pull_request: { branches: ['main'], paths: [ + 'package.json', 'package-lock.json', 'postinstall.js', 'scripts/**', + '.github/workflows/build.yml', 'build/**', + ] }, }); }); + +const testWorkflow = yaml.load(fs.readFileSync(path.join(__dirname, '..', '.github', 'workflows', 'test.yml'), 'utf8')); +const workflows = { Build: workflow, Test: testWorkflow }; + +test('every artifact has explicit three-day PR and fourteen-day other retention', () => { + for (const [name, config] of Object.entries(workflows)) { + const uploads = Object.values(config.jobs).flatMap(job => job.steps).filter(step => step.uses?.startsWith('actions/upload-artifact@')); + assert.ok(uploads.length, name); + for (const step of uploads) { + assert.equal(step.with['retention-days'], "${{ github.event_name == 'pull_request' && 3 || 14 }}", name); + for (const event of ['pull_request', 'push', 'workflow_dispatch']) { + assert.equal(evaluate(step.with['retention-days'], event), event === 'pull_request' ? 3 : 14); + } + } + } +}); + +test('both workflows group PRs by number and give every other run its own group', () => { + for (const [name, config] of Object.entries(workflows)) { + assert.deepEqual(config.concurrency, { + group: "${{ github.workflow }}-${{ github.event_name == 'pull_request' && format('pr-{0}', github.event.pull_request.number) || github.run_id }}", + 'cancel-in-progress': "${{ github.event_name == 'pull_request' }}", + }, name); + const group = (event, extra) => config.concurrency.group.replace(/\$\{\{([\s\S]*?)\}\}/g, + (_, body) => evaluate('${{' + body + '}}', event, 'refs/heads/main', 'same-branch', extra)); + const fork = (number) => ({ event: { pull_request: { number } }, run_id: 1 }); + assert.equal(group('pull_request', fork(7)), 'Workflow-pr-7', name); + assert.equal(group('pull_request', fork(7)), group('pull_request', { ...fork(7), run_id: 2 }), name); + assert.notEqual(group('pull_request', fork(7)), group('pull_request', fork(8)), `${name}: same head branch name`); + for (const event of ['push', 'workflow_dispatch']) { + assert.notEqual(group(event, { run_id: 1 }), group(event, { run_id: 2 }), `${name}: ${event}`); + assert.equal(group(event, { run_id: 5 }), 'Workflow-5', name); + } + for (const event of ['pull_request', 'push', 'workflow_dispatch']) { + assert.equal(evaluate(config.concurrency['cancel-in-progress'], event), event === 'pull_request', name); + } + } +}); + +test('jobs have bounded timeouts with the full packaging budget retained', () => { + for (const [name, config] of Object.entries(workflows)) { + for (const [id, job] of Object.entries(config.jobs)) { + assert.equal(job['timeout-minutes'], id === 'build' ? 60 : id === 'test' ? 25 : 10, `${name}: ${id}`); + } + } +}); + +test('Test uses three PR cells and two main-push cells with stable context names', () => { + const cells = [ + { 'node-version': 22, os: 'ubuntu-latest' }, + { 'node-version': 22, os: 'windows-2022' }, + ]; + const matrix = testWorkflow.jobs.test.strategy.matrix; + assert.deepEqual(Object.keys(matrix), ['include']); + assert.equal(typeof matrix.include, 'string'); + assert.deepEqual(evaluate(matrix.include, 'pull_request'), [{ 'node-version': 20, os: 'ubuntu-latest' }, ...cells]); + assert.deepEqual(evaluate(matrix.include, 'push'), cells); + for (const event of ['pull_request', 'push']) { + for (const cell of evaluate(matrix.include, event)) { + assert.deepEqual(Object.keys(cell), ['node-version', 'os'], `${event}: ${JSON.stringify(cell)}`); + } + } + assert.equal(testWorkflow.jobs.test.name, undefined); + assert.equal(testWorkflow.jobs.test['runs-on'], '${{ matrix.os }}'); +}); + +test('Test keeps its triggers, real-bwrap, patch coverage, lint and e2e conditions', () => { + assert.deepEqual(testWorkflow.on, { pull_request: { branches: ['main'] }, push: { branches: ['main'] } }); + for (const id of ['lint', 'e2e', 'test']) assert.equal(testWorkflow.jobs[id].if, undefined, id); + assert.equal(testWorkflow.jobs.changelog.if, "github.event_name == 'pull_request'"); + const steps = testWorkflow.jobs.test.steps; + const bwrap = steps.find(step => step.name === 'Enable the real-sandbox tests'); + assert.equal(bwrap.if, "runner.os == 'Linux'"); + assert.match(bwrap.run, /SWITCHBOARD_REQUIRE_REAL_BWRAP=1/); + assert.equal(steps.find(step => step.name?.startsWith('Patch coverage gate')).if, + "matrix.node-version == 22 && matrix.os == 'ubuntu-latest' && github.event_name == 'pull_request'"); + assert.equal(steps.find(step => step.run === 'npm run coverage').if, undefined); + assert.equal(testWorkflow.jobs.e2e.steps.find(step => step.uses?.startsWith('actions/upload-artifact@')).if, 'failure()'); +});