From bbcb2aa0932fb0cc15e05a3d4cce9aab5fedb7f2 Mon Sep 17 00:00:00 2001 From: Brad Groux <3053586+BradGroux@users.noreply.github.com> Date: Fri, 4 Sep 2026 02:32:04 -0500 Subject: [PATCH] fix: gate macOS release uploads on native candidate evidence --- .github/ISSUE_TEMPLATE/bug_report.md | 4 + .github/PULL_REQUEST_TEMPLATE.md | 4 + .github/workflows/ci.yml | 2 + .github/workflows/desktop-release.yml | 40 +++++- AGENTS.md | 3 +- CONTRIBUTING.md | 8 +- README.md | 4 +- desktop/package.json | 2 +- docs/DESKTOP-RELEASE.md | 24 +++- docs/FEATURES.md | 2 +- docs/V6-GA-CHECKLIST.md | 11 +- package.json | 1 + scripts/validate-release-native.test.mjs | 174 +++++++++++++++++++++++ scripts/validate-release.mjs | 94 +++++++++++- 14 files changed, 350 insertions(+), 23 deletions(-) create mode 100644 scripts/validate-release-native.test.mjs diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 99eae88b..2b1cfec7 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -36,6 +36,10 @@ If applicable, add screenshots to help explain your problem. - **Browser:** [e.g., Chrome 120, Safari 17] - **Veritas Kanban version:** [e.g., 4.0.1] +## Desktop verification boundary + +For desktop UI reports, identify what was actually tested: browser, packaged macOS candidate, or installed macOS app. Include the app build/commit, package version, theme, window size, and screenshot or native evidence path when available. Browser screenshots or a simulated desktop bridge do not prove packaged or installed behavior. Record blocked verification explicitly; it is not a passing result. + ## Additional Context Add any other context about the problem here. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index fd819ed2..9b75168e 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -40,6 +40,10 @@ modes, and why broader gates are or are not required. # Add test commands here ``` +**Desktop and release evidence (when relevant):** + +State each boundary as passed, failed, blocked, pending, or not applicable, with its evidence: browser; packaged macOS candidate; installed app; signing/notarization; refreshed documentation images/GIFs; publication. Include the native report path, candidate commit, package version/digest, tested themes, and window sizes. A browser-only or source-preflight pass cannot close a packaged/installed requirement. A manual exception cannot mark a failed native matrix passed. + ## Checklist - [ ] This PR contains one coherent, independently shippable behavior diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 14387c94..2c13884f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -89,6 +89,8 @@ jobs: scripts/check-tracked-ignore.test.mjs scripts/select-ci-test-scope.test.mjs scripts/ci-pr-label-state.test.mjs + scripts/validate-release-native.test.mjs + scripts/native-ui/contract.test.mjs - name: Guard delivery cadence run: node scripts/check-delivery-cadence.mjs diff --git a/.github/workflows/desktop-release.yml b/.github/workflows/desktop-release.yml index fe51e835..ccaae0f2 100644 --- a/.github/workflows/desktop-release.yml +++ b/.github/workflows/desktop-release.yml @@ -33,6 +33,8 @@ jobs: runs-on: macos-15 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 @@ -47,7 +49,7 @@ jobs: GH_TOKEN: ${{ github.token }} run: | version="${GITHUB_REF_NAME#v}" - pnpm validate:release -- --version "${version}" --github --skip-build-output + pnpm validate:release -- --version "${version}" --github --skip-build-output --source-only - name: Verify signing and notarization secrets are configured id: notarization-secrets @@ -137,7 +139,6 @@ jobs: - name: Build, sign, notarize, and stage macOS artifacts working-directory: desktop env: - GH_TOKEN: ${{ github.token }} CSC_LINK: ${{ secrets.MACOS_CSC_LINK }} CSC_KEY_PASSWORD: ${{ secrets.MACOS_CSC_KEY_PASSWORD }} APPLE_API_KEY: ${{ steps.notary-key.outputs.key_path }} @@ -146,7 +147,7 @@ jobs: APPLE_ID: ${{ secrets.APPLE_ID }} APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} - run: node ./node_modules/electron-builder/cli.js --mac dmg zip --publish always + run: node ./node_modules/electron-builder/cli.js --mac dmg zip --publish never - name: Finalize notarized macOS release assets env: @@ -158,13 +159,46 @@ jobs: APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} run: node scripts/finalize-macos-release-assets.mjs + - name: Verify the distribution app in the native macOS matrix + id: native-ui + run: | + set -euo pipefail + version="$(node -p "require('./desktop/package.json').version")" + release_dir="${GITHUB_WORKSPACE}/desktop/release" + zip="${release_dir}/Veritas-Kanban-${version}-mac-arm64.zip" + dmg="${release_dir}/Veritas-Kanban-${version}-mac-arm64.dmg" + shasum -a 256 "${zip}" "${dmg}" "${zip}.blockmap" "${dmg}.blockmap" "${release_dir}/latest-mac.yml" > "${RUNNER_TEMP}/native-distribution.sha256" + candidate_dir="$(mktemp -d "${RUNNER_TEMP}/vk-distribution.XXXXXX")" + ditto -x -k "${zip}" "${candidate_dir}" + candidate_app="${candidate_dir}/veritas-kanban.app" + codesign --verify --deep --strict "${candidate_app}" + spctl --assess --type execute "${candidate_app}" + echo "app_path=${candidate_app}" >> "${GITHUB_OUTPUT}" + node scripts/native-ui/run.mjs "${candidate_app}" "${RUNNER_TEMP}/native-ui-evidence" + node scripts/native-ui/verify.mjs "${RUNNER_TEMP}/native-ui-evidence/evidence.json" "${candidate_app}" + + - name: Retain native macOS evidence even on failure + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: native-macos-ui-evidence + path: | + ${{ runner.temp }}/native-ui-evidence + ${{ runner.temp }}/native-distribution.sha256 + if-no-files-found: warn + retention-days: 14 + - name: Upload macOS release assets working-directory: desktop env: GH_TOKEN: ${{ github.token }} + VERIFIED_NATIVE_APP: ${{ steps.native-ui.outputs.app_path }} run: | set -euo pipefail + shasum -a 256 -c "${RUNNER_TEMP}/native-distribution.sha256" + node ../scripts/validate-release.mjs --native-evidence "${RUNNER_TEMP}/native-ui-evidence/evidence.json" --native-app "${VERIFIED_NATIVE_APP}" --github + version="$(node -p "require('./package.json').version")" tag="v${version}" dmg="release/Veritas-Kanban-${version}-mac-arm64.dmg" diff --git a/AGENTS.md b/AGENTS.md index 442571c3..ca26046d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -109,8 +109,7 @@ Do not run `npm install`, `yarn`, or `bun install`. If lockfile conflicts arise, - Prefer compact, natural paragraphs over bullet-per-sentence formatting. Use lists only for genuinely parallel items. Keep rendered prose blocks concise so they do not become walls of text on GitHub's release index. -- Run `pnpm validate:release -- --version X.Y.Z`; the post-publication `--github` form also - requires the published GitHub body to match the reviewed file exactly. +- Run `pnpm validate:release -- --version X.Y.Z --native-evidence /absolute/path/evidence.json --native-app /absolute/path/veritas-kanban.app` against the clean packaged candidate; the post-publication `--github` form also requires the published GitHub body to match the reviewed file exactly. `--source-only` is a separate source preflight, not release acceptance; `--skip-build-output` does not bypass native evidence. - After publication, inspect both the releases index and tag page. Raw Markdown validation does not replace a rendered-format check. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4f9a1002..3e428090 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -332,13 +332,15 @@ Follow the existing conventions in `.eslintrc.*`, `.prettierrc`, and `tsconfig.j pnpm test:load:smoke ``` -- **Release readiness** checks workspace versions, changelog, README badge, build outputs, and optional GitHub tag/release state: +- **Release readiness** checks workspace versions, changelog, README badge, build outputs, candidate-bound packaged macOS evidence, and optional GitHub tag/release state: ```bash - pnpm validate:release - pnpm validate:release -- --github + pnpm validate:release -- --native-evidence /absolute/path/evidence.json --native-app /absolute/path/veritas-kanban.app + pnpm validate:release -- --native-evidence /absolute/path/evidence.json --native-app /absolute/path/veritas-kanban.app --github ``` + Use `--source-only` for source preflight before packaging. It cannot establish release acceptance. See [desktop release verification](docs/DESKTOP-RELEASE.md#native-gate-before-macos-upload) for evidence capture and the remaining signing, installation, media, and publication gates. + - Write tests for new features and bug fixes. - Ensure existing tests pass before submitting. diff --git a/README.md b/README.md index b9b42aea..5e47949a 100644 --- a/README.md +++ b/README.md @@ -841,9 +841,11 @@ pnpm lint:budget # ESLint with current warning budget pnpm test # Canonical unit gate (server, web, CLI, MCP) pnpm test:e2e # E2E tests (Playwright) pnpm test:load:smoke # k6 API smoke test -pnpm validate:release # Release readiness checks +pnpm validate:release -- --source-only # Source preflight, not release acceptance ``` +Packaged macOS release validation also requires candidate-bound native evidence. See [desktop release verification](docs/DESKTOP-RELEASE.md#native-gate-before-macos-upload). + --- ## 📚 Documentation diff --git a/desktop/package.json b/desktop/package.json index def9224c..6321237d 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -20,7 +20,7 @@ "package:linux:unsigned": "node ../scripts/prepare-desktop-release.mjs && node ../scripts/run-desktop-builder.mjs --linux AppImage deb rpm --x64 --publish never", "package:win:dir": "node ../scripts/prepare-desktop-release.mjs && node ../scripts/run-desktop-builder.mjs --win dir --x64 --publish never", "package:win:unsigned": "node ../scripts/prepare-desktop-release.mjs && node ../scripts/run-desktop-builder.mjs --win nsis zip --x64 --publish never", - "release:mac": "node ../scripts/prepare-desktop-release.mjs && node ../scripts/run-desktop-builder.mjs --mac dmg zip --publish always", + "release:mac": "node ../scripts/prepare-desktop-release.mjs && node ../scripts/run-desktop-builder.mjs --mac dmg zip --publish never", "release:linux": "node ../scripts/prepare-desktop-release.mjs && node ../scripts/run-desktop-builder.mjs --linux AppImage deb rpm --x64 --publish always", "release:win": "node ../scripts/prepare-desktop-release.mjs && node ../scripts/run-desktop-builder.mjs --win nsis zip --x64 --publish always", "typecheck": "tsc --noEmit", diff --git a/docs/DESKTOP-RELEASE.md b/docs/DESKTOP-RELEASE.md index a1811732..a74d6a06 100644 --- a/docs/DESKTOP-RELEASE.md +++ b/docs/DESKTOP-RELEASE.md @@ -29,8 +29,7 @@ metadata for PR validation. `desktop:package:linux:unsigned` creates preview x64 AppImage, deb, and rpm artifacts. `desktop:package:windows:unsigned` creates preview x64 NSIS installer and ZIP artifacts. -`desktop:release:mac` expects Apple signing and notarization credentials and -publishes update metadata through electron-builder. `desktop:release:linux` is +`desktop:release:mac` expects Apple signing and notarization credentials and stages artifacts with publishing disabled. It does not upload a release; the `Desktop Release` workflow owns the verified upload path. `desktop:release:linux` is reserved for post-GA Linux preview validation until checksum, provenance, install, and update policy requirements are promoted. `desktop:release:windows` expects a Windows code-signing certificate available to electron-builder before @@ -74,6 +73,22 @@ first verifies that the live GitHub body exactly matches `docs/releases/vX.Y.Z.md`; a mismatch stops the workflow before signing or packaging. +### Native gate before macOS upload + +The signed workflow uses `--publish never`, finalizes the distribution assets, then extracts the actual release ZIP into a temporary directory. Signature and Gatekeeper checks precede the packaged macOS matrix. The runner records the exact app digest, commit, version, native window dimensions, 144 states, and six injected fault probes. Native screenshots and distribution checksums are retained as workflow artifacts even when a check fails. + +Immediately before `gh release upload`, the workflow rechecks all staged distribution checksums and invokes full release validation against the extracted app and its native report. Missing, stale, failed, incomplete, wrong-commit, changed-package, or modified-screenshot evidence stops upload. A dirty candidate checkout also fails. The destination release tag must resolve to this same candidate commit, including annotated-tag peeling; manually dispatching a later commit with the same package version cannot replace that release's assets. Retaining diagnostic artifacts does not turn a failed matrix into a passing release. + +Full local release validation now requires explicit candidate paths: + +```bash +pnpm validate:release -- --native-evidence /absolute/path/evidence.json --native-app /absolute/path/veritas-kanban.app +``` + +For source and published-body checks before a candidate exists, use `--source-only --skip-build-output` (plus `--github` when checking the published body). Its success message is explicitly source preflight, never release acceptance. `--skip-build-output` alone does not bypass native evidence. + +Browser verification, packaged candidate verification, installed-app verification, signing/notarization, documentation-media review, and publication are separate results. Report the exact boundary and evidence for each; pending or blocked work remains pending or blocked. Documentation media freshness is still tracked separately in #1388 and #1387 and is not established by this native upload gate. + ## Homebrew Cask The supported packaged install path is the dedicated BradGroux tap: @@ -192,7 +207,7 @@ policy is tracked in - Apply `ci:full` to the release-candidate pull request, then download the uploaded DMG/ZIP/update metadata from its `Desktop Artifacts` run. - Edit `docs/releases/vX.Y.Z.md`, run - `pnpm validate:release -- --version X.Y.Z`, and publish that exact file with + `pnpm validate:release -- --version X.Y.Z --native-evidence /absolute/path/evidence.json --native-app /absolute/path/veritas-kanban.app`, and publish that exact file with `gh release create --notes-file` or `gh release edit --notes-file`. Do not hand-author or repair the live body separately. - Use one logical source line per prose paragraph and let GitHub wrap it to the @@ -222,8 +237,7 @@ policy is tracked in - For a Homebrew upgrade, confirm `open -a` followed by `pnpm desktop:wait:ready -- --expected-version ` tolerates normal startup delay and proves the packaged server owns `3001`. -- Confirm `pnpm validate:release` passes and verifies root/shared/server/web, - CLI, MCP, and desktop package versions plus required v6 release docs. +- Confirm full `pnpm validate:release` passes with `--native-evidence` and `--native-app`, verifying candidate-bound macOS evidence, root/shared/server/web/CLI/MCP/desktop package versions, and required v6 release docs. A source-only preflight is not this gate. ## Smoke Tests diff --git a/docs/FEATURES.md b/docs/FEATURES.md index 21d90a6f..95933cc8 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -2338,7 +2338,7 @@ Production-ready deployment and development tooling. - **Fast pull-request jobs** — Source-policy selection, lint and warning budget, typecheck, production build, dependency audit, CodeQL, and gitleaks - **Milestone jobs** — Workspace tests, critical-path coverage, Playwright, desktop artifacts, load checks, and Docker contracts run for `ci:full`, scheduled, or manual milestones - **Scheduled QA** — Weekly and manually triggered Playwright and k6 gates run outside the fast PR path -- **Release validation** — `pnpm validate:release` checks root/shared/server/web/CLI/MCP/desktop versions, the release-major document set, built artifacts, and optional GitHub tag/release state +- **Release validation** — `pnpm validate:release` requires `--native-evidence` and `--native-app` for the clean packaged macOS candidate and checks root/shared/server/web/CLI/MCP/desktop versions, the release-major document set, built artifacts, and optional GitHub tag/release state. `--source-only` reports source preflight, not release acceptance. - **pnpm caching** — Dependency cache for faster CI runs ### Development diff --git a/docs/V6-GA-CHECKLIST.md b/docs/V6-GA-CHECKLIST.md index 44bd6e72..bdbf0080 100644 --- a/docs/V6-GA-CHECKLIST.md +++ b/docs/V6-GA-CHECKLIST.md @@ -178,10 +178,12 @@ pnpm desktop:dev:fresh pnpm desktop:smoke:mac:local pnpm desktop:package:mac:unsigned pnpm test:release-format -pnpm validate:release -- --version 6.1.6 --skip-build-output -pnpm validate:release -- --version 6.1.6 --docker-build +pnpm validate:release -- --version 6.1.6 --skip-build-output --source-only +pnpm validate:release -- --version 6.1.6 --native-evidence /absolute/path/evidence.json --native-app /absolute/path/veritas-kanban.app --docker-build ``` +Source preflight is not release acceptance. Full validation requires the exact clean candidate's fresh native matrix and retained screenshots; signing, installed-app, documentation-media, and publication evidence remain separate gates. See [desktop release verification](DESKTOP-RELEASE.md#native-gate-before-macos-upload). + Mount and inspect the unsigned DMG and ZIP, exercise the visible native single-instance/reopen/clean-close/quit lifecycle with an isolated profile, and run the production image as its non-root user against an isolated volume. @@ -248,8 +250,9 @@ profile. Exact evidence is recorded in the release candidate evidence packet. `docs/releases/vX.Y.Z.md`, use one full-width Markdown line per paragraph or list item, reject blockquotes and overlong prose blocks, and are compared with GitHub during post-publication validation. Run - `pnpm test:release-format`, `pnpm validate:release`, and the - post-publication `pnpm validate:release -- --github` check. + `pnpm test:release-format` and `pnpm validate:release` with the candidate's + `--native-evidence` and `--native-app` paths, then repeat with `--github` + for the post-publication check. ## Provider Certification diff --git a/package.json b/package.json index 1c02b61a..53075c80 100644 --- a/package.json +++ b/package.json @@ -60,6 +60,7 @@ "test:load": "k6 run load-tests/k6/smoke.js && k6 run load-tests/k6/read-load.js && k6 run load-tests/k6/write-load.js && k6 run load-tests/k6/mixed-load.js && k6 run load-tests/k6/ws-stress.js && k6 run load-tests/k6/v5-remote-mix.js", "smoke:cli-mcp": "node scripts/smoke-cli-mcp-compat.mjs", "test:release-format": "node --test scripts/validate-release-format.test.mjs", + "test:release-native": "node --test scripts/validate-release-native.test.mjs scripts/native-ui/contract.test.mjs", "validate:release": "node scripts/validate-release.mjs", "desktop:ui:mac": "node scripts/native-ui/run.mjs", "desktop:ui:verify": "node scripts/native-ui/verify.mjs", diff --git a/scripts/validate-release-native.test.mjs b/scripts/validate-release-native.test.mjs new file mode 100644 index 00000000..53a74ec9 --- /dev/null +++ b/scripts/validate-release-native.test.mjs @@ -0,0 +1,174 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; +import { packageDigest, schema } from './native-ui/contract.mjs'; +import { remoteTagCommit } from './validate-release.mjs'; + +const root = path.resolve(import.meta.dirname, '..'); +function validate(args, env = process.env) { + const result = spawnSync( + process.execPath, + ['scripts/validate-release.mjs', '--skip-build-output', ...args], + { + cwd: root, + encoding: 'utf8', + env, + } + ); + assert.ifError(result.error); + return { code: result.status, output: result.stdout + result.stderr }; +} + +test('full release validation cannot bypass absent evidence by skipping builds', () => { + const result = validate([]); + assert.equal(result.code, 1); + assert.match( + result.output, + /FAIL Packaged macOS evidence.*Both --native-evidence and --native-app are required/ + ); + assert.doesNotMatch(result.output, /Release validation passed/); +}); + +test('destination release tags resolve to their commit, not an annotated tag object', () => { + const candidate = 'a'.repeat(40); + const different = 'b'.repeat(40); + const tag = 'v6.1.6'; + const ref = `refs/tags/${tag}`; + assert.equal(remoteTagCommit(`${candidate}\t${ref}\n`, tag), candidate); + assert.equal(remoteTagCommit(`${different}\t${ref}\n${candidate}\t${ref}^{}\n`, tag), candidate); + assert.notEqual(remoteTagCommit(`${different}\t${ref}\n`, tag), candidate); + assert.notEqual( + remoteTagCommit(`${candidate}\t${ref}\n${different}\t${ref}^{}\n`, tag), + candidate + ); + assert.equal(remoteTagCommit('', tag), undefined); + assert.equal(remoteTagCommit(`${candidate}\trefs/tags/v0.0.0\n`, tag), undefined); + assert.equal(remoteTagCommit(`invalid\t${ref}\n`, tag), undefined); + assert.equal(remoteTagCommit(`${candidate}\t${ref}\n${candidate}\t${ref}\n`, tag), undefined); +}); + +test( + 'GitHub validation rejects a destination tag bound to a different commit', + { skip: process.platform === 'win32' }, + async (t) => { + const directory = await mkdtemp(path.join(os.tmpdir(), 'native-release-tag-test-')); + t.after(() => rm(directory, { recursive: true, force: true })); + const version = JSON.parse(await readFile(path.join(root, 'package.json'), 'utf8')).version; + const releaseBody = await readFile(path.join(root, `docs/releases/v${version}.md`), 'utf8'); + const candidate = 'a'.repeat(40); + const tag = `v${version}`; + await writeFile( + path.join(directory, 'git'), + `#!${process.execPath} +const args = process.argv.slice(2); +if (args[0] === 'tag') console.log(${JSON.stringify(tag)}); +else if (args[0] === 'rev-parse') console.log(${JSON.stringify(candidate)}); +else if (args[0] === 'ls-remote') { + if (!args.includes(${JSON.stringify(`refs/tags/${tag}^{}`)})) process.exit(2); + console.log('b'.repeat(40) + '\\t' + ${JSON.stringify(`refs/tags/${tag}`)}); + console.log(process.env.TEST_RELEASE_COMMIT + '\\t' + ${JSON.stringify(`refs/tags/${tag}^{}`)}); +} else process.exit(2); +`, + { mode: 0o755 } + ); + await writeFile( + path.join(directory, 'gh'), + `#!${process.execPath} +console.log(${JSON.stringify(JSON.stringify({ tagName: tag, isDraft: false, body: releaseBody }))}); +`, + { mode: 0o755 } + ); + const env = { + ...process.env, + PATH: `${directory}${path.delimiter}${process.env.PATH}`, + TEST_RELEASE_COMMIT: candidate, + }; + const matching = validate(['--source-only', '--github'], env); + assert.equal(matching.code, 0, matching.output); + const mismatched = validate(['--source-only', '--github'], { + ...env, + TEST_RELEASE_COMMIT: 'c'.repeat(40), + }); + assert.equal(mismatched.code, 1, mismatched.output); + assert.match(mismatched.output, /FAIL Origin release tag matches candidate/); + } +); + +test('source-only preflight is explicitly not release acceptance and cannot consume native evidence', () => { + const result = validate(['--source-only']); + assert.equal(result.code, 0, result.output); + assert.match(result.output, /Source preflight passed/); + assert.doesNotMatch(result.output, /Release validation passed/); + const ambiguous = validate(['--source-only', '--native-app', '/not-a-candidate']); + assert.equal(ambiguous.code, 1); + assert.match(ambiguous.output, /FAIL Source-only preflight does not consume candidate evidence/); +}); + +test('full validator delegates to candidate, freshness, and matrix validation', async (t) => { + const directory = await mkdtemp(path.join(os.tmpdir(), 'native-release-test-')); + t.after(() => rm(directory, { recursive: true, force: true })); + const app = path.join(directory, 'fixture.app'); + const evidence = path.join(directory, 'evidence.json'); + await mkdir(app); + await writeFile(path.join(app, 'fixture.txt'), 'not a release app'); + const version = JSON.parse(await readFile(path.join(root, 'package.json'), 'utf8')).version; + await writeFile( + evidence, + JSON.stringify({ + schema, + status: 'passed', + boundary: 'packaged-macos', + dirty: false, + commit: '0'.repeat(40), + version, + completedAt: '2020-01-01T00:00:00Z', + packageDigest: await packageDigest(app), + identity: { packaged: true, platform: 'darwin', buildIdentity: '0'.repeat(40), version }, + entries: [], + seededFailures: [], + }) + ); + const result = validate(['--native-evidence', evidence, '--native-app', app]); + assert.equal(result.code, 1); + for (const reason of [ + 'candidate commit mismatch', + 'missing or stale native evidence', + 'incomplete native matrix', + ]) + assert(result.output.includes(reason), reason); +}); + +test('macOS publication stages without upload and verifies the distribution before upload', async () => { + const workflow = await readFile(path.join(root, '.github/workflows/desktop-release.yml'), 'utf8'); + const desktop = JSON.parse(await readFile(path.join(root, 'desktop/package.json'), 'utf8')); + assert.match(desktop.scripts['release:mac'], /--publish never$/); + assert.doesNotMatch(workflow, /--publish (?:always|onTag)/); + const steps = workflow.split(/(?=^ {6}- (?:name:|uses:))/m); + const native = steps.find((step) => step.includes('id: native-ui')); + const upload = steps.find((step) => step.includes('name: Upload macOS release assets')); + const retention = steps.find((step) => step.includes('name: Retain native macOS evidence')); + assert(native && upload && retention); + assert( + workflow.indexOf('node scripts/finalize-macos-release-assets.mjs') < workflow.indexOf(native) + ); + assert(workflow.indexOf(native) < workflow.indexOf(upload)); + assert.match(native, /ditto -x -k "\$\{zip\}" "\$\{candidate_dir\}"/); + assert.match(native, /codesign --verify --deep --strict/); + assert.match(native, /spctl --assess --type execute/); + assert.match(native, /node scripts\/native-ui\/run.mjs/); + assert.match(native, /node scripts\/native-ui\/verify.mjs/); + assert.match(native, /native-distribution.sha256/); + assert.match(retention, /if: always\(\)/); + assert.match(retention, /native-ui-evidence/); + assert.doesNotMatch(native + upload, /continue-on-error|if:|\|\| true|--source-only/); + const checksum = upload.indexOf('shasum -a 256 -c'); + const verification = upload.indexOf('node ../scripts/validate-release.mjs --native-evidence'); + const publication = upload.indexOf('gh release upload'); + assert(checksum >= 0 && verification > checksum && publication > verification); + assert.match(upload, /--native-app "\$\{VERIFIED_NATIVE_APP\}"/); + assert.match(upload, /--native-app "\$\{VERIFIED_NATIVE_APP\}" --github/); + assert.match(workflow, /fetch-depth: 0/); +}); diff --git a/scripts/validate-release.mjs b/scripts/validate-release.mjs index 882e0681..c5a91da9 100644 --- a/scripts/validate-release.mjs +++ b/scripts/validate-release.mjs @@ -4,6 +4,7 @@ import { constants } from 'node:fs'; import { spawnSync } from 'node:child_process'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; +import { verifyNativeEvidence } from './native-ui/verify.mjs'; const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); @@ -39,6 +40,7 @@ const requiredScripts = [ 'test:load', 'test:load:smoke', 'test:release-format', + 'test:release-native', 'test:unit', 'typecheck', ]; @@ -155,6 +157,9 @@ Options: --github Validate v tag and GitHub release. --repo GitHub repository for --github. Defaults to package.json repository. --skip-build-output Skip local dist artifact checks. + --native-evidence Candidate-bound packaged macOS evidence report. + --native-app Exact .app verified by that report. + --source-only Source preflight only; never release acceptance. --docker-build Build the production Docker image as part of validation. --help Show this help text. `); @@ -167,6 +172,9 @@ function parseArgs(argv) { repo: undefined, skipBuildOutput: false, version: undefined, + nativeEvidence: undefined, + nativeApp: undefined, + sourceOnly: false, }; for (let index = 0; index < argv.length; index += 1) { @@ -191,6 +199,20 @@ function parseArgs(argv) { continue; } + if (arg === '--source-only') { + options.sourceOnly = true; + continue; + } + if (arg === '--native-evidence' || arg === '--native-app') { + const value = argv[++index]; + if (!value || value.startsWith('--')) { + fail('CLI options', `${arg} requires a path`); + continue; + } + options[arg === '--native-evidence' ? 'nativeEvidence' : 'nativeApp'] = value; + continue; + } + if (arg === '--docker-build') { options.dockerBuild = true; continue; @@ -422,6 +444,19 @@ function printableDetail(detail) { return detail ? ` - ${detail}` : ''; } +export function remoteTagCommit(output, tagName) { + const ref = `refs/tags/${tagName}`; + const records = output + .trim() + .split('\n') + .map((line) => line.trim().split(/\s+/)); + const direct = records.filter(([, name]) => name === ref); + const peeled = records.filter(([, name]) => name === `${ref}^{}`); + if (direct.length !== 1 || peeled.length > 1) return undefined; + const commit = (peeled[0] ?? direct[0])[0]; + return /^[a-f0-9]{40}$/.test(commit) ? commit : undefined; +} + async function main() { const options = parseArgs(process.argv.slice(2)); const packages = []; @@ -435,6 +470,39 @@ async function main() { const rootPackage = packages.find((pkg) => pkg.label === 'root').json; const expectedVersion = options.version ?? rootPackage.version; + if (options.sourceOnly) { + check( + 'Source-only preflight does not consume candidate evidence', + !options.nativeEvidence && !options.nativeApp, + 'Remove --source-only to validate a packaged candidate' + ); + skip('Packaged macOS evidence', 'source-only preflight is not release acceptance'); + } else if (!options.nativeEvidence || !options.nativeApp) { + fail( + 'Packaged macOS evidence', + 'Both --native-evidence and --native-app are required; --skip-build-output does not bypass this gate' + ); + } else { + const head = run('git', ['rev-parse', 'HEAD']); + const tree = run('git', ['status', '--porcelain']); + check( + 'Candidate checkout is clean', + tree.ok && tree.stdout === '', + 'Native release evidence requires a clean candidate checkout' + ); + try { + if (!head.ok) throw new Error('Cannot resolve candidate commit'); + const errors = await verifyNativeEvidence({ + evidencePath: path.resolve(options.nativeEvidence), + appPath: path.resolve(options.nativeApp), + commit: head.stdout, + version: expectedVersion, + }); + check('Packaged macOS evidence', errors.length === 0, errors.join('; ') || head.stdout); + } catch (error) { + fail('Packaged macOS evidence', error.message); + } + } const requiredReleaseDocs = releaseDocsForVersion(expectedVersion); const releaseBodyFile = `docs/releases/v${expectedVersion}.md`; const releaseBodyExists = await exists(releaseBodyFile); @@ -565,12 +633,26 @@ async function main() { localTag.ok && localTag.stdout ? tagName : localTag.stderr || 'not found' ); - const remoteTag = run('git', ['ls-remote', '--tags', 'origin', `refs/tags/${tagName}`]); + const remoteTag = run('git', [ + 'ls-remote', + '--tags', + 'origin', + `refs/tags/${tagName}`, + `refs/tags/${tagName}^{}`, + ]); check( `Origin git tag exists: ${tagName}`, remoteTag.ok && remoteTag.stdout.includes(`refs/tags/${tagName}`), remoteTag.ok && remoteTag.stdout ? 'origin' : remoteTag.stderr || 'not found' ); + const candidateHead = run('git', ['rev-parse', 'HEAD']); + check( + `Origin release tag matches candidate: ${tagName}`, + candidateHead.ok && + remoteTag.ok && + remoteTagCommit(remoteTag.stdout, tagName) === candidateHead.stdout, + 'The destination tag must resolve to the verified checkout commit' + ); if (repo) { const release = run('gh', [ @@ -625,7 +707,9 @@ async function main() { skip: 'SKIP', }; - console.log(`\nRelease validation for ${expectedVersion}\n`); + console.log( + `\n${options.sourceOnly ? 'Source preflight' : 'Release validation'} for ${expectedVersion}\n` + ); for (const item of checks) { console.log(`${labels[item.status]} ${item.name}${printableDetail(item.detail)}`); @@ -637,7 +721,11 @@ async function main() { process.exit(1); } - console.log('\nRelease validation passed.'); + console.log( + options.sourceOnly + ? '\nSource preflight passed. Packaged, installed, signing, documentation-media, and publication acceptance are not established.' + : '\nRelease validation passed. Installed-app, signing, documentation-media, and publication acceptance require their separate evidence.' + ); } if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {