mirror of
https://github.com/BradGroux/veritas-kanban.git
synced 2026-10-10 13:47:53 +00:00
fix: gate macOS release uploads on native candidate evidence
This commit is contained in:
parent
ceb6fab810
commit
bbcb2aa093
14 changed files with 350 additions and 23 deletions
4
.github/ISSUE_TEMPLATE/bug_report.md
vendored
4
.github/ISSUE_TEMPLATE/bug_report.md
vendored
|
|
@ -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.
|
||||
|
|
|
|||
4
.github/PULL_REQUEST_TEMPLATE.md
vendored
4
.github/PULL_REQUEST_TEMPLATE.md
vendored
|
|
@ -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
|
||||
|
|
|
|||
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
40
.github/workflows/desktop-release.yml
vendored
40
.github/workflows/desktop-release.yml
vendored
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 <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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
174
scripts/validate-release-native.test.mjs
Normal file
174
scripts/validate-release-native.test.mjs
Normal file
|
|
@ -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/);
|
||||
});
|
||||
|
|
@ -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<version> tag and GitHub release.
|
||||
--repo <owner/repo> GitHub repository for --github. Defaults to package.json repository.
|
||||
--skip-build-output Skip local dist artifact checks.
|
||||
--native-evidence <file> Candidate-bound packaged macOS evidence report.
|
||||
--native-app <path> 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)) {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue