From 52dc32b41d1c72d76249830144a8d67bfce35437 Mon Sep 17 00:00:00 2001 From: FernandoM33 Date: Thu, 13 Aug 2026 22:40:42 -0700 Subject: [PATCH 1/2] release: add bundle size attribution evidence --- .github/workflows/ci.yml | 4 + .github/workflows/release-unsigned.yml | 8 + CHANGELOG.md | 1 + docs/BUNDLE_SIZE.md | 66 +++ docs/RELEASE.md | 6 + docs/RELEASE_QA.md | 7 + docs/agent/COMMANDS.md | 16 + docs/agent/DECISIONS.md | 11 + docs/agent/ISSUE-TRACKER.md | 6 + docs/agent/PROJECT.md | 17 + package.json | 1 + scripts/check-packaged-runtime.js | 1 + scripts/check-public-release.js | 8 + scripts/check-release-workflow.js | 6 + scripts/measure-bundle-size.js | 534 +++++++++++++++++++++++++ scripts/prepare-public-release.js | 1 + scripts/release-alpha.js | 22 +- scripts/smoke-bundle-size.js | 140 +++++++ scripts/smoke-public-release.js | 2 + scripts/smoke-release-metadata.js | 5 + 20 files changed, 860 insertions(+), 2 deletions(-) create mode 100644 docs/BUNDLE_SIZE.md create mode 100644 docs/agent/COMMANDS.md create mode 100644 docs/agent/DECISIONS.md create mode 100644 docs/agent/ISSUE-TRACKER.md create mode 100644 docs/agent/PROJECT.md create mode 100644 scripts/measure-bundle-size.js create mode 100644 scripts/smoke-bundle-size.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 35995be..3fbe511 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -64,6 +64,9 @@ jobs: - name: Test release metadata contract run: npm run smoke:release-metadata + - name: Test bundle-size measurement contract + run: npm run smoke:bundle-size + - name: Test canonical release identity run: npm run smoke:release-identity @@ -205,3 +208,4 @@ jobs: dist/release-candidate/SHA256SUMS.txt dist/release-candidate/release-manifest.json dist/release-candidate/RELEASE_NOTES.md + dist/release-candidate/bundle-size-report.json diff --git a/.github/workflows/release-unsigned.yml b/.github/workflows/release-unsigned.yml index 0f1a649..05475cb 100644 --- a/.github/workflows/release-unsigned.yml +++ b/.github/workflows/release-unsigned.yml @@ -216,6 +216,14 @@ jobs: dist/public-release/RELEASE_NOTES.md dist/public-release/*.sigstore.json + - name: Upload bundle-size evidence + uses: actions/upload-artifact@v4 + with: + name: scriptcut-${{ inputs.release_tag }}-bundle-size-evidence + if-no-files-found: error + retention-days: 30 + path: dist/release-candidate/bundle-size-report.json + clean-runner-verify: name: Verify bundle on clean native arm64 runner needs: build diff --git a/CHANGELOG.md b/CHANGELOG.md index 84abc78..8e7576b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### Added - Canonical release-note and post-publication closure verification for the public alpha workflow. +- Machine-readable bundle-size attribution and release size evidence for candidate and public workflow review. ### Changed diff --git a/docs/BUNDLE_SIZE.md b/docs/BUNDLE_SIZE.md new file mode 100644 index 0000000..5b1b8ac --- /dev/null +++ b/docs/BUNDLE_SIZE.md @@ -0,0 +1,66 @@ +# Bundle-size measurement + +ScriptCut release candidates produce `dist/release-candidate/bundle-size-report.json` +with schema `scriptcut.bundle-size.v1`. It is maintainer evidence for comparing +the exact packaged `.app` and DMG; it is not a creator-facing artifact or an +optimization recommendation. + +## Canonical measurement + +The canonical uncompressed value is logical file bytes: the sum of +`fs.lstatSync(path).size` for regular files while recursively walking the +`.app`. Symlink targets are never followed or recursively counted. A symlink +may be recorded diagnostically, but its target is counted only at its real +directory entry. This avoids double-counting macOS Framework `Versions/Current` +layouts. Filesystem allocated blocks and Finder-reported sizes are not the +machine contract. + +Hard links are diagnosed by device and inode when the platform exposes them. +`logicalBytes` remains the cross-run comparison metric; `uniqueInodeBytes` is +diagnostic and must not be used as a release threshold. + +The DMG value is the exact `fs.statSync(dmgPath).size`. The report's +`compressionRatio` is `dmgBytes / appLogicalBytes`; it is a ratio, not a +percentage. A compressed DMG is a different representation, so its bytes are +not expected to reconcile with the `.app` bytes. + +## Attribution boundaries + +Primary categories are disjoint and must reconcile exactly to +`appLogicalBytes`: Electron Frameworks, macOS executables, `app.asar`, backend, +`Resources/bin`, portable Python, the Python core pack, manifests, licenses and +notices, other Resources, and other app Contents. Runtime roots come from +`Contents/Resources/manifests/runtime-manifest.json` using the same safe +relative-path validation as the packaged runtime contract. + +Python distribution and Torch breakdowns are secondary diagnostics. They use +installed `*.dist-info/RECORD` files, assign a claimed file to only one +deterministic owner, and record ownership conflicts. Shared files, generated +caches, namespace packages, and unowned files mean distribution totals may not +reconcile to the core pack; the report exposes attributed and unattributed +bytes separately. + +## Usage and comparison + +```bash +node scripts/measure-bundle-size.js \ + --app \ + --dmg \ + --output bundle-size-report.json + +node scripts/measure-bundle-size.js \ + --app \ + --dmg \ + --output new-bundle-size-report.json \ + --baseline previous-bundle-size-report.json +``` + +Baseline comparison is informational. It reports byte and percentage deltas +for app bytes, DMG bytes, and categories where present. It rejects an +incompatible schema, platform, or architecture, but it does not fail when a +size increases and implements no budget or regression threshold. + +The baseline Whisper model is app-managed and external to the package: +`embeddedModelWeights` is `false`, and `baselineModelExpectedBytes` comes from +the model manifest. The model is not downloaded or added to app totals during +measurement. diff --git a/docs/RELEASE.md b/docs/RELEASE.md index 272d583..71043fa 100644 --- a/docs/RELEASE.md +++ b/docs/RELEASE.md @@ -195,6 +195,12 @@ The public release remains exactly these six assets. Closure evidence is uploaded to GitHub Actions under a separate evidence artifact with operational retention; it is not added to the GitHub Release. +Candidate builds also write `bundle-size-report.json` with deterministic +logical-byte attribution. The candidate artifact includes that report. Public +workflow runs retain it in a separate `scriptcut--bundle-size-evidence` +Actions artifact; it is not staged under `dist/public-release`, added to the +six public assets, or a publication blocker. See [Bundle-size measurement](./BUNDLE_SIZE.md). + The public release manifest uses schema `scriptcut.release.v2` and records the ad-hoc structural signature, Apple Developer ID, and notarization truth, bundled runtime/core/FFmpeg/model provenance, final DMG SHA-256, release tag, source commit, and the DMG attestation reference. The DMG and manifest are each attested with the official GitHub artifact-attestation action; the workflow artifact bundles are retained for independent verification. ## Signing And Notarization diff --git a/docs/RELEASE_QA.md b/docs/RELEASE_QA.md index a0d8463..3377b3a 100644 --- a/docs/RELEASE_QA.md +++ b/docs/RELEASE_QA.md @@ -46,6 +46,7 @@ section and never mutates the changelog. | Deterministic model-manager smoke | SOURCE / PR | `ubuntu-latest` | Managed model storage, resume, cancellation, integrity, redirect policy, and path safety against a local fixture server | Yes for PR CI | No | Loopback fixture only; no real model download | No | | Native candidate build | CANDIDATE | `macos-14` arm64 | The requested commit produces the self-contained native macOS arm64 candidate | Yes before public qualification | No | Package prerequisites use the network; real model is optional for a candidate invocation | No | | Packaged candidate gates | CANDIDATE | `macos-14` arm64 | Packaged runtime, bundled backend, Electron-like startup, renderer transport/CSP, optional-capability isolation, FFmpeg, transcription, icon, DMG, metadata, identity, and signing readiness | Yes for a candidate | No | No model download unless the candidate is invoked with `--real-model` | No | +| Bundle-size evidence | CANDIDATE / PUBLIC DRY-RUN | Native candidate runner | Exact `.app` logical bytes, DMG bytes, disjoint category attribution, and trend evidence | Yes as maintainer evidence | No | No model download; no optimization or size budget | No | | Candidate real-model gate | CANDIDATE | `macos-14` arm64 | First acquisition, verified model, real transcription, offline reuse, repair, and reacquisition when explicitly requested | No for every candidate | Yes: `--real-model` or an implementation change to real-model orchestration | Downloads the real model when enabled; hosted execution is CPU-only | No | | Public build, stage, and attestation | PUBLIC DRY-RUN | `macos-14` arm64 | One built candidate is transformed into the exact public bundle, with checksums, manifest, DMG and manifest attestations, and no rebuild after staging | Yes for a public dry-run or publication | `workflow_dispatch`; `publish=false` is the non-publishing mode | Network required for actions, GitHub attestations, and `real_model=true` validation | Attestation and workflow-artifact records only; `publish=false` cannot create a tag, release, commit, or main mutation | | Transferred public bundle checksum, manifest, DMG, and attestation verification | CLEAN ARTIFACT | `macos-14` arm64 | The exact transferred public bundle still has the expected shape, bytes, provenance, and native DMG validity | Yes for a public dry-run or publication | No once the public workflow is run | Network required for GitHub attestation verification; no model download | No | @@ -76,6 +77,12 @@ The native candidate command is: npm run release:rc:arm64 ``` +After the packaged runtime and DMG gates pass, the candidate writes +`dist/release-candidate/bundle-size-report.json`. The public workflow keeps +the exact six-file public bundle and uploads the detailed report separately as +Actions evidence. Bundle-size measurement is informational and is not a +publication blocker. + Use `npm run release:rc:arm64 -- --real-model` only when the extended model gate is required. Hosted runners may prove the CPU real-model baseline and a deterministic or simulated MPS compatibility branch; they must not claim diff --git a/docs/agent/COMMANDS.md b/docs/agent/COMMANDS.md new file mode 100644 index 0000000..07e6d3e --- /dev/null +++ b/docs/agent/COMMANDS.md @@ -0,0 +1,16 @@ +# Engineering commands + +| Command | Scope | Status on context setup | +| --- | --- | --- | +| `npm run smoke:bundle-size` | Deterministic bundle-size fixture contract | verified during this change | +| `npm run smoke:release-metadata` | Candidate manifest and artifact fixture | verified baseline | +| `npm run smoke:public-release` | Public manifest/notes/workflow fixture | verified baseline | +| `npm run smoke:release-workflow` | Workflow structure and permission contract | verified baseline | +| `npm run smoke:runtime-contract` | Packaged runtime path contract | verified baseline | +| `npm run lint` | Frontend lint | run as delivery validation | +| `npm run build:frontend` | Frontend production build | run as delivery validation | +| `npm run release:rc:arm64` | Native macOS arm64 candidate | requires native arm64 and network prerequisites | + +The authoritative candidate output is under `dist/release-candidate/`. +Publication is owned by `.github/workflows/release-unsigned.yml`; do not +publish or create tags from local validation. diff --git a/docs/agent/DECISIONS.md b/docs/agent/DECISIONS.md new file mode 100644 index 0000000..834fc13 --- /dev/null +++ b/docs/agent/DECISIONS.md @@ -0,0 +1,11 @@ +# Engineering decisions + +- `productVersion` remains `0.1.0`; public identity remains `v0.1.0-alpha.`. +- `scriptcut.release.v1` is the candidate manifest and `scriptcut.release.v2` + is the public manifest; bundle size is an optional concise field, not a new + manifest schema. +- Bundle-size `logicalBytes` uses regular-file `lstat` sizes and skips symlink + targets. It is measurement evidence only: no optimization, budget, or + publication gate is derived from it. +- The baseline Whisper model is external/app-managed and is never embedded in + the package or counted in bundle totals. diff --git a/docs/agent/ISSUE-TRACKER.md b/docs/agent/ISSUE-TRACKER.md new file mode 100644 index 0000000..bbd364b --- /dev/null +++ b/docs/agent/ISSUE-TRACKER.md @@ -0,0 +1,6 @@ +# Issue tracking + +No repository issue tracker or project-board integration is configured in the +checkout. Release work is reviewed through the Git branch and draft PR, with +GitHub Actions providing candidate/public evidence and the release workflow +remaining the publication authority. diff --git a/docs/agent/PROJECT.md b/docs/agent/PROJECT.md new file mode 100644 index 0000000..4566244 --- /dev/null +++ b/docs/agent/PROJECT.md @@ -0,0 +1,17 @@ +# ScriptCut engineering context + +ScriptCut is a local-first Electron desktop video editor with a React/Vite +frontend, a FastAPI backend, bundled FFmpeg, and an optional app-managed +Whisper model. Release packaging is macOS arm64, ad-hoc signed, and +self-contained for the current alpha path. + +Key boundaries: + +- `frontend/`: renderer UI and frontend build/lint. +- `backend/`: local media/transcription services and Python smoke tests. +- `electron/`: desktop process and runtime contract. +- `scripts/`: release orchestration, packaged gates, and deterministic smokes. +- `.github/workflows/`: source CI, candidate build, and public release authority. + +The release candidate is maintainer evidence, not a public release. The public +workflow owns the exact six GitHub Release assets and publication gates. diff --git a/package.json b/package.json index bd73e35..aef9788 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "smoke:packaged-optional-capabilities": "node scripts/smoke-packaged-optional-capabilities.js --arch arm64", "smoke:release-metadata": "node scripts/smoke-release-metadata.js", "smoke:release-notes": "node scripts/smoke-release-notes.js", + "smoke:bundle-size": "node scripts/smoke-bundle-size.js", "smoke:published-release": "node scripts/smoke-published-release.js", "smoke:release-identity": "node scripts/smoke-release-identity.js", "release:public:prepare": "node scripts/prepare-public-release.js", diff --git a/scripts/check-packaged-runtime.js b/scripts/check-packaged-runtime.js index 8a38310..bf258ec 100644 --- a/scripts/check-packaged-runtime.js +++ b/scripts/check-packaged-runtime.js @@ -207,6 +207,7 @@ function inspectPackage(appPath) { const backendSize = directorySize(backendRoot); const binSize = directorySize(path.join(resourcesPath, 'bin')); const appSize = directorySize(appPath); + console.log('Size diagnostics are non-canonical; use bundle-size-report.json for logical-byte release evidence.'); console.log(`Packaged app: mac-arm64 (${appSize} bytes)`); console.log(`Portable Python: ${runtimeSize} bytes`); console.log(`Core pack: ${coreSize} bytes`); diff --git a/scripts/check-public-release.js b/scripts/check-public-release.js index fce64d8..b3219fe 100644 --- a/scripts/check-public-release.js +++ b/scripts/check-public-release.js @@ -93,6 +93,14 @@ function validateManifest(manifest, { allowPendingAttestation = false } = {}) { assert(manifest.runtime?.mode === 'packaged-bundled' && manifest.runtime?.pythonSource === 'bundled', 'runtime must be packaged-bundled'); assert(manifest.runtime?.target?.platform === 'darwin' && manifest.runtime?.target?.arch === 'arm64', 'runtime target must be darwin arm64'); assert(manifest.model?.embedded === false, 'model weights must not be embedded'); + if (manifest.bundleSize) { + assert(manifest.bundleSize.schema === 'scriptcut.bundle-size.v1', 'bundle-size summary schema is invalid'); + assert(Number.isInteger(manifest.bundleSize.appLogicalBytes) && manifest.bundleSize.appLogicalBytes > 0, 'bundle-size app logical bytes are invalid'); + assert(Number.isInteger(manifest.bundleSize.dmgBytes) && manifest.bundleSize.dmgBytes > 0, 'bundle-size DMG bytes are invalid'); + assert(typeof manifest.bundleSize.compressionRatio === 'number', 'bundle-size compression ratio is invalid'); + assert(typeof manifest.bundleSize.largestPrimaryCategory?.name === 'string', 'bundle-size largest category is invalid'); + assert(Number.isInteger(manifest.bundleSize.largestPrimaryCategory.logicalBytes), 'bundle-size largest category bytes are invalid'); + } assert(/^[0-9a-f]{40}$/.test(manifest.commit), 'manifest commit must be a full SHA-1'); assert(/^[0-9a-f]{64}$/.test(manifest.artifact?.sha256), 'artifact SHA-256 is missing'); assert(Number.isInteger(manifest.artifact?.bytes) && manifest.artifact.bytes > 0, 'artifact byte count is missing'); diff --git a/scripts/check-release-workflow.js b/scripts/check-release-workflow.js index 1a908ea..3cd3dee 100644 --- a/scripts/check-release-workflow.js +++ b/scripts/check-release-workflow.js @@ -50,6 +50,7 @@ function validateSourceCiGates(text) { const requiredCommands = [ 'npm run smoke:release-identity', 'npm run smoke:release-metadata', + 'npm run smoke:bundle-size', 'npm run smoke:release-notes', 'npm run smoke:published-release', 'npm run smoke:public-release', @@ -107,6 +108,7 @@ function validateReleaseArgumentSeparation(text) { assert(/if \(useGpu\) transcriptionArgs\.push\('--use-gpu'\)/.test(text), 'packaged transcription must receive --use-gpu independently'); assert(!/realModel \? \['--real-model', '--use-gpu'\]/.test(text), 'release orchestrator must not couple --real-model with --use-gpu'); assert(/npm run release:icons/.test(text) || /'npm', \['run', 'release:icons'\]/.test(text), 'release orchestrator must generate and verify canonical macOS icons before packaging'); + assert(/measureBundleSize/.test(text) && /bundle-size-report\.json/.test(text), 'release orchestrator must generate bundle-size evidence'); } function validateWorkflowText(text, { candidateWorkflowText = fs.readFileSync(ciWorkflowPath, 'utf8') } = {}) { @@ -132,6 +134,10 @@ function validateWorkflowText(text, { candidateWorkflowText = fs.readFileSync(ci assert(!/contents:\s+write/.test(build), 'build must not have contents write'); assert(/actions\/attest@v4/.test(build), 'build attestation action is missing'); assert(/actions\/upload-artifact@v4/.test(build), 'build workflow artifact upload is missing'); + assert(/bundle-size-report\.json/.test(build), 'candidate/public workflow size evidence is missing'); + assert(/scriptcut-\$\{\{ inputs\.release_tag \}\}-bundle-size-evidence/.test(build), 'public size evidence artifact naming is missing'); + assert(!/dist\/public-release\/bundle-size-report\.json/.test(build), 'size evidence must remain outside dist/public-release'); + assert(/dist\/release-candidate\/bundle-size-report\.json/.test(candidateWorkflowText), 'candidate artifact must include bundle-size-report.json'); assert(/-arm64-dry-run/.test(build), 'dry-run artifact naming is missing'); assert(/-arm64-public/.test(build), 'public artifact naming is missing'); assert(/npm run release:rc:arm64 -- --real-model/.test(build), 'public hosted real-model validation must invoke --real-model'); diff --git a/scripts/measure-bundle-size.js b/scripts/measure-bundle-size.js new file mode 100644 index 0000000..762105a --- /dev/null +++ b/scripts/measure-bundle-size.js @@ -0,0 +1,534 @@ +#!/usr/bin/env node + +const fs = require('fs'); +const path = require('path'); +const { spawnSync } = require('child_process'); +const { validateRuntimeManifest, resolveResourcePath } = require('../electron/runtime-contract'); +const { readModelManifest } = require('./model-artifacts'); +const { readProductVersion } = require('./release-identity'); + +const SCHEMA = 'scriptcut.bundle-size.v1'; +const TOP_FILE_LIMIT = 30; +const TOP_DIRECTORY_LIMIT = 30; +const root = path.join(__dirname, '..'); + +function fail(message) { + throw new Error(`Bundle-size measurement failed: ${message}`); +} + +function assert(condition, message) { + if (!condition) fail(message); +} + +function optionValue(name) { + const index = process.argv.indexOf(name); + return index >= 0 ? process.argv[index + 1] : undefined; +} + +function readJson(filePath, label) { + try { + return JSON.parse(fs.readFileSync(filePath, 'utf8')); + } catch (error) { + fail(`${label} could not be read as JSON: ${error.message}`); + } +} + +function currentGitCommit() { + const result = spawnSync('git', ['rev-parse', 'HEAD'], { + cwd: root, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }); + if (result.status !== 0) fail('could not determine the current commit'); + return result.stdout.trim(); +} + +function lstat(filePath, label) { + try { + return fs.lstatSync(filePath); + } catch (error) { + fail(`${label} could not be inspected: ${error.message}`); + } +} + +function assertDirectory(directory, label) { + assert(fs.existsSync(directory) && lstat(directory, label).isDirectory(), `${label} is missing or not a directory: ${directory}`); +} + +function assertRegularFile(filePath, label) { + assert(fs.existsSync(filePath) && lstat(filePath, label).isFile(), `${label} is missing or not a regular file: ${filePath}`); +} + +function relativePath(appPath, filePath) { + const relative = path.relative(appPath, filePath).split(path.sep).join('/'); + assert(relative && relative !== '..' && !relative.startsWith('../'), `path escaped app root: ${filePath}`); + return relative; +} + +function isWithin(filePath, directory) { + const relative = path.relative(directory, filePath); + return relative === '' || (!path.isAbsolute(relative) && relative !== '..' && !relative.startsWith(`..${path.sep}`)); +} + +function parseCsvLine(line) { + const values = []; + let value = ''; + let quoted = false; + for (let index = 0; index < line.length; index += 1) { + const character = line[index]; + if (quoted && character === '"' && line[index + 1] === '"') { + value += '"'; + index += 1; + } else if (character === '"') { + quoted = !quoted; + } else if (character === ',' && !quoted) { + values.push(value); + value = ''; + } else { + value += character; + } + } + values.push(value); + return values; +} + +function normalizeRecordPath(value) { + const normalized = value.replace(/\\/g, '/'); + if (!normalized || normalized.startsWith('/') || normalized.split('/').includes('..')) return null; + return path.posix.normalize(normalized); +} + +function walkTree(directory, appPath, files = [], symlinks = [], directories = new Map()) { + const entries = fs.readdirSync(directory, { withFileTypes: true }).sort((left, right) => left.name.localeCompare(right.name)); + for (const entry of entries) { + const entryPath = path.join(directory, entry.name); + const relative = relativePath(appPath, entryPath); + if (entry.isSymbolicLink()) { + const stat = lstat(entryPath, entryPath); + symlinks.push({ relativePath: relative, logicalBytes: stat.size }); + continue; + } + if (entry.isDirectory()) { + const depth = relative.split('/').length; + if (depth <= 4) directories.set(relative, { relativePath: relative, logicalBytes: 0, fileCount: 0 }); + walkTree(entryPath, appPath, files, symlinks, directories); + continue; + } + const stat = lstat(entryPath, entryPath); + if (!stat.isFile()) continue; + const file = { + absolutePath: entryPath, + relativePath: relative, + logicalBytes: stat.size, + dev: Number.isInteger(stat.dev) ? stat.dev : null, + ino: Number.isInteger(stat.ino) ? stat.ino : null, + }; + files.push(file); + const parts = relative.split('/'); + for (let count = 1; count < parts.length && count <= 4; count += 1) { + const directoryPath = parts.slice(0, count).join('/'); + const record = directories.get(directoryPath) || { relativePath: directoryPath, logicalBytes: 0, fileCount: 0 }; + record.logicalBytes += stat.size; + record.fileCount += 1; + directories.set(directoryPath, record); + } + } + return { files, symlinks, directories }; +} + +function hardLinkAccounting(files) { + const available = files.every((file) => file.dev !== null && file.ino !== null); + if (!available) { + return { + available: false, + logicalBytes: files.reduce((total, file) => total + file.logicalBytes, 0), + uniqueInodeBytes: null, + duplicateRegularFileCount: null, + }; + } + const seen = new Map(); + let uniqueInodeBytes = 0; + let duplicateRegularFileCount = 0; + for (const file of files) { + const key = `${file.dev}:${file.ino}`; + if (seen.has(key)) duplicateRegularFileCount += 1; + else { + seen.set(key, file.logicalBytes); + uniqueInodeBytes += file.logicalBytes; + } + } + return { + available: true, + logicalBytes: files.reduce((total, file) => total + file.logicalBytes, 0), + uniqueInodeBytes, + duplicateRegularFileCount, + }; +} + +function categoryForFile(file, roots) { + const resourceRoots = roots.resourceRoots; + if (isWithin(file.absolutePath, roots.frameworksRoot)) return 'electronFrameworks'; + if (isWithin(file.absolutePath, roots.macosRoot)) return 'macosExecutables'; + if (file.absolutePath === roots.appAsarPath) return 'appAsar'; + if (isWithin(file.absolutePath, roots.backendRoot)) return 'backend'; + if (isWithin(file.absolutePath, roots.ffmpegBinRoot)) return 'ffmpegBin'; + if (isWithin(file.absolutePath, roots.pythonCorePackRoot)) return 'pythonCorePack'; + if (isWithin(file.absolutePath, roots.portablePythonRoot)) return 'portablePython'; + if (isWithin(file.absolutePath, roots.manifestsRoot)) return 'manifests'; + if (roots.licenseRoots.some((licenseRoot) => isWithin(file.absolutePath, licenseRoot))) return 'licensesAndNotices'; + if (isWithin(file.absolutePath, resourceRoots)) return 'otherResources'; + return 'otherAppContents'; +} + +function primaryCategories(files, roots) { + const totals = new Map([ + ['electronFrameworks', { name: 'electronFrameworks', logicalBytes: 0, fileCount: 0 }], + ['macosExecutables', { name: 'macosExecutables', logicalBytes: 0, fileCount: 0 }], + ['appAsar', { name: 'appAsar', logicalBytes: 0, fileCount: 0 }], + ['backend', { name: 'backend', logicalBytes: 0, fileCount: 0 }], + ['ffmpegBin', { name: 'ffmpegBin', logicalBytes: 0, fileCount: 0 }], + ['portablePython', { name: 'portablePython', logicalBytes: 0, fileCount: 0 }], + ['pythonCorePack', { name: 'pythonCorePack', logicalBytes: 0, fileCount: 0 }], + ['manifests', { name: 'manifests', logicalBytes: 0, fileCount: 0 }], + ['licensesAndNotices', { name: 'licensesAndNotices', logicalBytes: 0, fileCount: 0 }], + ['otherResources', { name: 'otherResources', logicalBytes: 0, fileCount: 0 }], + ['otherAppContents', { name: 'otherAppContents', logicalBytes: 0, fileCount: 0 }], + ]); + for (const file of files) { + const category = totals.get(categoryForFile(file, roots)); + category.logicalBytes += file.logicalBytes; + category.fileCount += 1; + } + return [...totals.values()].sort((left, right) => right.logicalBytes - left.logicalBytes || left.name.localeCompare(right.name)); +} + +function distributionMetadata(distInfoPath) { + const metadataPath = path.join(distInfoPath, 'METADATA'); + let name; + let version; + if (fs.existsSync(metadataPath)) { + const metadata = fs.readFileSync(metadataPath, 'utf8'); + name = metadata.match(/^Name:\s*(.+)$/mi)?.[1]?.trim(); + version = metadata.match(/^Version:\s*(.+)$/mi)?.[1]?.trim(); + } + const directoryName = path.basename(distInfoPath).replace(/\.dist-info$/, ''); + const separator = directoryName.lastIndexOf('-'); + return { + name: name || (separator > 0 ? directoryName.slice(0, separator) : directoryName), + version: version || (separator > 0 ? directoryName.slice(separator + 1) : 'unknown'), + }; +} + +function findDistInfos(directory, found = []) { + for (const entry of fs.readdirSync(directory, { withFileTypes: true }).sort((left, right) => left.name.localeCompare(right.name))) { + const entryPath = path.join(directory, entry.name); + if (entry.isSymbolicLink()) continue; + if (!entry.isDirectory()) continue; + if (entry.name.endsWith('.dist-info')) { + found.push(entryPath); + continue; + } + findDistInfos(entryPath, found); + } + return found; +} + +function distributionAttribution(coreRoot, coreFiles) { + const byRelativePath = new Map(coreFiles.map((file) => [path.relative(coreRoot, file.absolutePath).split(path.sep).join('/'), file])); + const claims = new Map(); + const distributions = []; + for (const distInfoPath of findDistInfos(coreRoot)) { + const metadata = distributionMetadata(distInfoPath); + const id = path.relative(coreRoot, distInfoPath).split(path.sep).join('/'); + const recordPath = path.join(distInfoPath, 'RECORD'); + const claimedPaths = new Set(); + if (fs.existsSync(recordPath)) { + for (const line of fs.readFileSync(recordPath, 'utf8').split(/\r?\n/).filter(Boolean)) { + const record = parseCsvLine(line); + const recordRelative = normalizeRecordPath(record[0] || ''); + if (!recordRelative || !byRelativePath.has(recordRelative)) continue; + claimedPaths.add(recordRelative); + const owners = claims.get(recordRelative) || []; + if (!owners.some((owner) => owner.id === id)) owners.push({ id, name: metadata.name, version: metadata.version }); + claims.set(recordRelative, owners); + } + } + distributions.push({ id, ...metadata, claimedPaths }); + } + + const ownersByPath = new Map(); + const conflicts = []; + for (const [recordRelative, owners] of claims.entries()) { + const sortedOwners = [...owners].sort((left, right) => left.id.localeCompare(right.id)); + ownersByPath.set(recordRelative, sortedOwners[0].id); + if (sortedOwners.length > 1) { + conflicts.push({ + relativePath: recordRelative, + owner: sortedOwners[0].name, + distributions: sortedOwners.map((owner) => ({ name: owner.name, version: owner.version })), + }); + } + } + + const entries = distributions.map((distribution) => { + const ownedFiles = [...distribution.claimedPaths] + .filter((recordRelative) => ownersByPath.get(recordRelative) === distribution.id) + .map((recordRelative) => byRelativePath.get(recordRelative)); + return { + name: distribution.name, + version: distribution.version, + logicalBytes: ownedFiles.reduce((total, file) => total + file.logicalBytes, 0), + fileCount: ownedFiles.length, + files: ownedFiles, + }; + }).sort((left, right) => right.logicalBytes - left.logicalBytes || left.name.localeCompare(right.name) || left.version.localeCompare(right.version)); + + const attributedDistributionBytes = entries.reduce((total, entry) => total + entry.logicalBytes, 0); + const coreLogicalBytes = coreFiles.reduce((total, file) => total + file.logicalBytes, 0); + return { + topDistributions: entries.map(({ files, ...entry }) => entry), + attributedDistributionBytes, + unattributedCorePackBytes: coreLogicalBytes - attributedDistributionBytes, + ownershipConflicts: conflicts.sort((left, right) => left.relativePath.localeCompare(right.relativePath)), + distributionFiles: new Map(entries.map((entry) => [`${entry.name}\u0000${entry.version}`, entry.files])), + }; +} + +function torchAttribution(distributionResult, coreRoot) { + const torchEntry = distributionResult.topDistributions.find((entry) => entry.name.toLowerCase() === 'torch'); + const torchFiles = torchEntry + ? (distributionResult.distributionFiles.get(`${torchEntry.name}\u0000${torchEntry.version}`) || []) + : []; + const largestFiles = torchFiles + .map((file) => ({ relativePath: path.relative(coreRoot, file.absolutePath).split(path.sep).join('/'), logicalBytes: file.logicalBytes })) + .sort((left, right) => right.logicalBytes - left.logicalBytes || left.relativePath.localeCompare(right.relativePath)) + .slice(0, 10); + const libBytes = torchFiles + .filter((file) => isWithin(file.absolutePath, path.join(coreRoot, 'torch', 'lib'))) + .reduce((total, file) => total + file.logicalBytes, 0); + const totalBytes = torchFiles.reduce((total, file) => total + file.logicalBytes, 0); + return { + present: Boolean(torchEntry), + torchTotal: totalBytes, + torchLib: libBytes, + torchPythonSourceOrPackageRemainder: totalBytes - libBytes, + largestTorchFiles: largestFiles, + }; +} + +function percentDelta(current, baseline) { + return baseline === 0 ? null : (current - baseline) / baseline * 100; +} + +function compareReports(current, baselinePath) { + const baseline = readJson(baselinePath, 'baseline report'); + assert(baseline.schema === SCHEMA, `baseline schema must be ${SCHEMA}`); + assert(baseline.platform === current.platform, `baseline platform ${baseline.platform} does not match ${current.platform}`); + assert(baseline.architecture === current.architecture, `baseline architecture ${baseline.architecture} does not match ${current.architecture}`); + const metric = (key) => ({ + baseline: baseline[key], + current: current[key], + delta: current[key] - baseline[key], + percentDelta: percentDelta(current[key], baseline[key]), + }); + const baselineCategories = new Map((baseline.primaryCategories || []).map((category) => [category.name, category.logicalBytes])); + const currentCategories = new Map((current.primaryCategories || []).map((category) => [category.name, category.logicalBytes])); + const categoryNames = [...new Set([...baselineCategories.keys(), ...currentCategories.keys()])].sort(); + return { + baseline: { + schema: baseline.schema, + productVersion: baseline.productVersion, + commit: baseline.commit, + platform: baseline.platform, + architecture: baseline.architecture, + }, + appLogicalBytes: metric('appLogicalBytes'), + dmgBytes: metric('dmgBytes'), + primaryCategories: categoryNames.map((name) => { + const baselineBytes = baselineCategories.get(name) || 0; + const currentBytes = currentCategories.get(name) || 0; + return { name, baseline: baselineBytes, current: currentBytes, delta: currentBytes - baselineBytes, percentDelta: percentDelta(currentBytes, baselineBytes) }; + }), + }; +} + +function formatMiB(bytes) { + return `${(bytes / 1024 / 1024).toFixed(2)} MiB`; +} + +function appendGitHubSummary(report) { + const summaryPath = process.env.GITHUB_STEP_SUMMARY; + if (!summaryPath) return; + const categories = report.primaryCategories.slice(0, 5).map((category) => `| ${category.name} | ${category.logicalBytes} bytes (${formatMiB(category.logicalBytes)}) |`).join('\n'); + const largestCategory = report.summary.largestPrimaryCategory; + const largestDistribution = report.summary.largestPythonDistribution; + fs.appendFileSync(summaryPath, [ + '## ScriptCut bundle-size evidence', + '', + `- App logical size: ${report.appLogicalBytes} bytes (${formatMiB(report.appLogicalBytes)})`, + `- DMG size: ${report.dmgBytes} bytes (${formatMiB(report.dmgBytes)})`, + `- Compression ratio (DMG bytes / app logical bytes): ${report.compressionRatio}`, + `- Largest primary category: ${largestCategory.name} (${largestCategory.logicalBytes} bytes / ${formatMiB(largestCategory.logicalBytes)})`, + `- Largest Python distribution: ${largestDistribution ? `${largestDistribution.name} ${largestDistribution.version} (${largestDistribution.logicalBytes} bytes / ${formatMiB(largestDistribution.logicalBytes)})` : 'none'}`, + '', + '| Top primary category | Logical bytes |', + '| --- | ---: |', + categories, + '', + ].join('\n')); +} + +function measureBundleSize(options) { + const appPath = path.resolve(options.appPath); + const dmgPath = path.resolve(options.dmgPath); + assertDirectory(appPath, 'ScriptCut.app'); + assertRegularFile(dmgPath, 'candidate DMG'); + + const contentsPath = path.join(appPath, 'Contents'); + const resourcesPath = path.join(contentsPath, 'Resources'); + const frameworksRoot = path.join(contentsPath, 'Frameworks'); + const macosRoot = path.join(contentsPath, 'MacOS'); + const manifestPath = path.join(resourcesPath, 'manifests', 'runtime-manifest.json'); + const modelManifestPath = path.join(resourcesPath, 'manifests', 'model-manifest.json'); + assertDirectory(contentsPath, 'Contents'); + assertDirectory(resourcesPath, 'Contents/Resources'); + assertDirectory(frameworksRoot, 'Contents/Frameworks'); + assertDirectory(macosRoot, 'Contents/MacOS'); + assertRegularFile(manifestPath, 'runtime manifest'); + assertRegularFile(modelManifestPath, 'model manifest'); + + let normalized; + try { + normalized = validateRuntimeManifest(readJson(manifestPath, 'runtime manifest')); + } catch (error) { + fail(error.message); + } + const backendRoot = resolveResourcePath(resourcesPath, normalized.backend.root, 'backend root'); + const pythonCorePackRoot = resolveResourcePath(resourcesPath, normalized.packs.core, 'core pack root'); + const pythonExecutablePath = resolveResourcePath(resourcesPath, normalized.python.executable, 'Python executable'); + const portablePythonRoot = path.dirname(path.dirname(pythonExecutablePath)); + const ffmpegBinRoot = path.join(resourcesPath, 'bin'); + const manifestsRoot = path.join(resourcesPath, 'manifests'); + const appAsarPath = path.join(resourcesPath, 'app.asar'); + for (const [directory, label] of [ + [backendRoot, 'backend root'], + [pythonCorePackRoot, 'core pack root'], + [portablePythonRoot, 'portable Python root'], + [ffmpegBinRoot, 'FFmpeg/bin root'], + [manifestsRoot, 'manifests root'], + ]) assertDirectory(directory, label); + assertRegularFile(appAsarPath, 'Contents/Resources/app.asar'); + assert(isWithin(backendRoot, resourcesPath) && isWithin(pythonCorePackRoot, resourcesPath) && isWithin(portablePythonRoot, resourcesPath), 'runtime manifest path escapes Resources'); + + const licenseRoots = [ + path.join(resourcesPath, 'LICENSE'), + path.join(resourcesPath, 'THIRD_PARTY_NOTICES.md'), + path.join(resourcesPath, 'ACKNOWLEDGEMENTS.md'), + path.join(resourcesPath, 'LICENSES'), + ].filter((candidate) => fs.existsSync(candidate)); + const roots = { + frameworksRoot, + macosRoot, + appAsarPath, + backendRoot, + ffmpegBinRoot, + portablePythonRoot, + pythonCorePackRoot, + manifestsRoot, + licenseRoots, + resourceRoots: resourcesPath, + }; + + const walked = walkTree(appPath, appPath); + const categoryList = primaryCategories(walked.files, roots); + const appLogicalBytes = walked.files.reduce((total, file) => total + file.logicalBytes, 0); + const categoryLogicalBytes = categoryList.reduce((total, category) => total + category.logicalBytes, 0); + assert(categoryLogicalBytes === appLogicalBytes, `primary categories reconcile to ${categoryLogicalBytes}, expected ${appLogicalBytes}`); + const coreFiles = walked.files.filter((file) => isWithin(file.absolutePath, pythonCorePackRoot)); + const distributionResult = distributionAttribution(pythonCorePackRoot, coreFiles); + const torch = torchAttribution(distributionResult, pythonCorePackRoot); + const largestFiles = walked.files + .map((file) => ({ relativePath: file.relativePath, logicalBytes: file.logicalBytes, primaryCategory: categoryForFile(file, roots) })) + .sort((left, right) => right.logicalBytes - left.logicalBytes || left.relativePath.localeCompare(right.relativePath)) + .slice(0, TOP_FILE_LIMIT); + const directoryHotspots = [...walked.directories.values()] + .map((directory) => ({ ...directory, depth: directory.relativePath.split('/').length })) + .sort((left, right) => right.logicalBytes - left.logicalBytes || left.relativePath.localeCompare(right.relativePath)) + .slice(0, TOP_DIRECTORY_LIMIT); + const modelManifest = readModelManifest(modelManifestPath); + const report = { + schema: SCHEMA, + productVersion: options.productVersion || readProductVersion(), + commit: options.commit || currentGitCommit(), + platform: normalized.target.platform, + architecture: normalized.target.arch, + generatedAt: options.generatedAt || new Date().toISOString(), + appLogicalBytes, + appUniqueInodeBytes: hardLinkAccounting(walked.files).uniqueInodeBytes, + dmgBytes: fs.statSync(dmgPath).size, + compressionRatio: fs.statSync(dmgPath).size / appLogicalBytes, + embeddedModelWeights: false, + baselineModelExpectedBytes: modelManifest.expectedBytes, + runtimePaths: { + portablePython: relativePath(appPath, portablePythonRoot), + pythonCorePack: relativePath(appPath, pythonCorePackRoot), + backend: relativePath(appPath, backendRoot), + ffmpegBin: relativePath(appPath, ffmpegBinRoot), + manifests: relativePath(appPath, manifestsRoot), + }, + primaryCategories: categoryList, + attributedDistributionBytes: distributionResult.attributedDistributionBytes, + unattributedCorePackBytes: distributionResult.unattributedCorePackBytes, + topDistributions: distributionResult.topDistributions, + distributionOwnershipConflicts: distributionResult.ownershipConflicts, + torch, + largestFiles, + directoryHotspots, + hardLinkAccounting: hardLinkAccounting(walked.files), + symlinkDiagnostics: { + symlinkCount: walked.symlinks.length, + symlinkLogicalBytes: walked.symlinks.reduce((total, symlink) => total + symlink.logicalBytes, 0), + }, + }; + report.summary = { + largestPrimaryCategory: categoryList[0] || null, + largestPythonDistribution: report.topDistributions[0] || null, + largestFile: report.largestFiles[0] || null, + }; + if (options.baselinePath) report.comparison = compareReports(report, path.resolve(options.baselinePath)); + if (options.outputPath) { + fs.mkdirSync(path.dirname(path.resolve(options.outputPath)), { recursive: true }); + fs.writeFileSync(path.resolve(options.outputPath), `${JSON.stringify(report, null, 2)}\n`, 'utf8'); + } + appendGitHubSummary(report); + return report; +} + +function main() { + const appPath = optionValue('--app'); + const dmgPath = optionValue('--dmg'); + const outputPath = optionValue('--output'); + assert(appPath && dmgPath && outputPath, 'usage: node scripts/measure-bundle-size.js --app --dmg --output '); + const report = measureBundleSize({ appPath, dmgPath, outputPath, baselinePath: optionValue('--baseline') }); + console.log(`Bundle-size report: ${path.relative(root, path.resolve(outputPath))}`); + console.log(`App logical size: ${report.appLogicalBytes} bytes (${formatMiB(report.appLogicalBytes)})`); + console.log(`DMG size: ${report.dmgBytes} bytes (${formatMiB(report.dmgBytes)})`); + console.log(`Compression ratio: ${report.compressionRatio}`); + console.log(`Largest primary category: ${report.summary.largestPrimaryCategory.name}`); +} + +if (require.main === module) { + try { + main(); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); + } +} + +module.exports = { + SCHEMA, + measureBundleSize, + hardLinkAccounting, + parseCsvLine, +}; diff --git a/scripts/prepare-public-release.js b/scripts/prepare-public-release.js index 65b55f8..9587cf8 100644 --- a/scripts/prepare-public-release.js +++ b/scripts/prepare-public-release.js @@ -132,6 +132,7 @@ function publicManifest({ pkg, tag, commit, artifact, candidate, dmgAttestation manifestSha256: candidate.model.manifestSha256, embedded: false, }, + ...(candidate.bundleSize ? { bundleSize: candidate.bundleSize } : {}), provenance: { provider: 'github-artifact-attestation-sigstore', repository: 'FernandoAbishai/ScriptCut', diff --git a/scripts/release-alpha.js b/scripts/release-alpha.js index d7fd41a..2fbfb37 100644 --- a/scripts/release-alpha.js +++ b/scripts/release-alpha.js @@ -5,6 +5,7 @@ const fs = require('fs'); const path = require('path'); const { spawnSync } = require('child_process'); const { inspectPackage } = require('./check-packaged-runtime'); +const { measureBundleSize } = require('./measure-bundle-size'); const { formatCandidateArtifactFilename, readProductVersion } = require('./release-identity'); const root = path.join(__dirname, '..'); @@ -137,7 +138,7 @@ function readProvenance() { }; } -async function createReleaseManifest(pkg, artifact, checksums, provenance) { +async function createReleaseManifest(pkg, artifact, checksums, provenance, bundleSize) { const manifest = { schema: 'scriptcut.release.v1', productName: pkg.build?.productName || pkg.name, @@ -178,6 +179,16 @@ async function createReleaseManifest(pkg, artifact, checksums, provenance) { manifestSha256: await checksumFile(provenance.modelManifestPath), embedded: false, }, + bundleSize: { + schema: bundleSize.schema, + appLogicalBytes: bundleSize.appLogicalBytes, + dmgBytes: bundleSize.dmgBytes, + compressionRatio: bundleSize.compressionRatio, + largestPrimaryCategory: { + name: bundleSize.summary.largestPrimaryCategory.name, + logicalBytes: bundleSize.summary.largestPrimaryCategory.logicalBytes, + }, + }, codeSignature: { type: 'ad-hoc', structurallyValid: true, @@ -222,6 +233,7 @@ Creators do not need to install Python, run pip, download FFmpeg, configure PATH - ${artifact.filename} - SHA256SUMS.txt - release-manifest.json +- bundle-size-report.json (maintainer measurement evidence) Artifact SHA-256: \`${checksums.sha256}\` Runtime mode: \`${manifest.runtime.mode}\`; Python source: \`${manifest.runtime.pythonSource}\`; target: \`${manifest.runtime.target.platform}-${manifest.runtime.target.arch}\`. @@ -309,7 +321,13 @@ async function main() { }; const checksums = await writeChecksums(artifact); const provenance = readProvenance(); - const { manifestPath, manifest } = await createReleaseManifest(pkg, artifact, checksums, provenance); + const bundleSizePath = path.join(releaseMetadataDir, 'bundle-size-report.json'); + const bundleSize = measureBundleSize({ + appPath: outputs.appPath, + dmgPath: outputs.dmgPath, + outputPath: bundleSizePath, + }); + const { manifestPath, manifest } = await createReleaseManifest(pkg, artifact, checksums, provenance, bundleSize); const notesPath = writeReleaseNotes(pkg, artifact, checksums, manifest); runStep('Release metadata smoke', 'node', ['scripts/smoke-release-metadata.js', '--dir', releaseMetadataDir], { env }); diff --git a/scripts/smoke-bundle-size.js b/scripts/smoke-bundle-size.js new file mode 100644 index 0000000..fde81a0 --- /dev/null +++ b/scripts/smoke-bundle-size.js @@ -0,0 +1,140 @@ +#!/usr/bin/env node + +const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { SCHEMA, measureBundleSize } = require('./measure-bundle-size'); + +function write(filePath, content) { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, content); +} + +function manifest() { + return { + schema: 'scriptcut.runtime.v1', + target: { platform: process.platform === 'darwin' ? 'darwin' : 'linux', arch: process.arch === 'arm64' ? 'arm64' : 'x64' }, + python: { version: '3.11.15', executable: 'runtime/python/darwin-arm64/3.11.15+20260807/bin/python3.11' }, + backend: { root: 'backend' }, + packs: { core: 'runtime/packs/core/darwin-arm64' }, + security: { tokenRequired: true }, + }; +} + +function makeFixture(tempRoot) { + const app = path.join(tempRoot, 'ScriptCut.app'); + const contents = path.join(app, 'Contents'); + const resources = path.join(contents, 'Resources'); + const pythonRoot = path.join(resources, 'runtime', 'python', 'darwin-arm64', '3.11.15+20260807'); + const coreRoot = path.join(resources, 'runtime', 'packs', 'core', 'darwin-arm64'); + const shared = path.join(coreRoot, 'shared.py'); + fs.mkdirSync(path.join(contents, 'Frameworks', 'Fixture.framework', 'Versions', 'A'), { recursive: true }); + write(path.join(contents, 'Frameworks', 'Fixture.framework', 'Versions', 'A', 'large.bin'), Buffer.alloc(48, 1)); + fs.symlinkSync('A', path.join(contents, 'Frameworks', 'Fixture.framework', 'Versions', 'Current')); + write(path.join(contents, 'MacOS', 'ScriptCut'), Buffer.alloc(20, 2)); + write(path.join(resources, 'app.asar'), Buffer.alloc(32, 3)); + write(path.join(resources, 'backend', 'main.py'), Buffer.alloc(18, 4)); + write(path.join(resources, 'bin', 'ffmpeg'), Buffer.alloc(22, 5)); + write(path.join(resources, 'LICENSE'), 'license'); + write(path.join(resources, 'manifests', 'runtime-manifest.json'), `${JSON.stringify(manifest())}\n`); + write(path.join(resources, 'manifests', 'model-manifest.json'), `${JSON.stringify({ + schema: 'scriptcut.model.v1', + id: 'whisper-base', + engine: 'whisper', + model: 'base', + revision: 'ed3a0b6b1c0edf879ad9b11b1af5a0e6ab5db9205f891f668f8b0e6c6326e34e', + filename: 'base.pt', + sourceUrl: 'https://openaipublic.azureedge.net/main/whisper/models/base.pt', + sha256: 'ed3a0b6b1c0edf879ad9b11b1af5a0e6ab5db9205f891f668f8b0e6c6326e34e', + expectedBytes: 1234, + license: 'MIT', + sourceProject: 'openai/whisper', + codeVersion: '20250625', + })}\n`); + write(path.join(pythonRoot, 'bin', 'python3.11'), Buffer.alloc(16, 6)); + fs.symlinkSync('python3.11', path.join(pythonRoot, 'bin', 'python3')); + write(path.join(coreRoot, 'torch', 'lib', 'libtorch.dylib'), Buffer.alloc(60, 7)); + write(path.join(coreRoot, 'torch', '__init__.py'), Buffer.alloc(14, 8)); + write(shared, Buffer.alloc(11, 9)); + write(path.join(coreRoot, 'unowned.dat'), Buffer.alloc(9, 10)); + const distA = path.join(coreRoot, 'alpha_pkg-1.0.dist-info'); + const distB = path.join(coreRoot, 'beta_pkg-2.0.dist-info'); + const distTorch = path.join(coreRoot, 'torch-3.0.dist-info'); + write(path.join(distA, 'METADATA'), 'Name: alpha-pkg\nVersion: 1.0\n'); + write(path.join(distB, 'METADATA'), 'Name: beta-pkg\nVersion: 2.0\n'); + write(path.join(distA, 'RECORD'), 'shared.py,,11\n'); + write(path.join(distB, 'RECORD'), 'shared.py,,11\n'); + write(path.join(distTorch, 'METADATA'), 'Name: torch\nVersion: 3.0\n'); + write(path.join(distTorch, 'RECORD'), 'torch/lib/libtorch.dylib,,60\ntorch/__init__.py,,14\n'); + const dmg = path.join(tempRoot, 'candidate.dmg'); + write(dmg, Buffer.alloc(100, 11)); + return { app, dmg, resources, coreRoot, shared }; +} + +function expectFailure(callback, label) { + assert.throws(callback, /Bundle-size measurement failed/, label); +} + +function main() { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'scriptcut-bundle-size-')); + try { + const fixture = makeFixture(tempRoot); + const baselinePath = path.join(tempRoot, 'baseline.json'); + const report = measureBundleSize({ + appPath: fixture.app, + dmgPath: fixture.dmg, + outputPath: baselinePath, + productVersion: '0.1.0', + commit: 'a'.repeat(40), + generatedAt: '2026-01-01T00:00:00.000Z', + }); + assert.strictEqual(report.schema, SCHEMA); + assert.strictEqual(report.appLogicalBytes, report.primaryCategories.reduce((sum, category) => sum + category.logicalBytes, 0)); + assert(report.symlinkDiagnostics.symlinkCount >= 2); + assert(!/(\/Users\/|\/private\/|\/tmp\/|RUNNER_TEMP)/.test(JSON.stringify(report))); + assert(report.largestFiles.every((file, index, files) => index === 0 || files[index - 1].logicalBytes >= file.logicalBytes)); + assert(report.topDistributions.every((distribution) => distribution.logicalBytes >= 0)); + assert(report.distributionOwnershipConflicts.some((conflict) => conflict.relativePath === 'shared.py')); + assert.strictEqual(report.unattributedCorePackBytes, report.primaryCategories.find((category) => category.name === 'pythonCorePack').logicalBytes - report.attributedDistributionBytes); + assert.strictEqual(report.torch.torchTotal, 74); + assert.strictEqual(report.torch.torchLib, 60); + + fs.appendFileSync(path.join(fixture.resources, 'backend', 'larger.py'), Buffer.alloc(13, 12)); + const comparisonPath = path.join(tempRoot, 'comparison.json'); + const comparison = measureBundleSize({ + appPath: fixture.app, + dmgPath: fixture.dmg, + outputPath: comparisonPath, + productVersion: '0.1.0', + commit: 'c'.repeat(40), + generatedAt: '2026-01-02T00:00:00.000Z', + baselinePath, + }); + assert(comparison.comparison.appLogicalBytes.delta > 0); + assert(comparison.comparison.appLogicalBytes.percentDelta > 0); + assert.strictEqual(comparison.comparison.baseline.schema, SCHEMA); + assert.strictEqual(JSON.parse(fs.readFileSync(comparisonPath, 'utf8')).schema, SCHEMA); + + const wrongArchitecturePath = path.join(tempRoot, 'wrong-architecture.json'); + fs.writeFileSync(wrongArchitecturePath, JSON.stringify({ ...report, architecture: report.architecture === 'arm64' ? 'x64' : 'arm64' })); + expectFailure(() => measureBundleSize({ appPath: fixture.app, dmgPath: fixture.dmg, productVersion: '0.1.0', commit: 'd'.repeat(40), baselinePath: wrongArchitecturePath }), 'architecture mismatch baseline'); + + const missingPath = path.join(fixture.resources, 'bin'); + fs.rmSync(missingPath, { recursive: true }); + expectFailure(() => measureBundleSize({ appPath: fixture.app, dmgPath: fixture.dmg, productVersion: '0.1.0', commit: 'e'.repeat(40) }), 'missing required package path'); + fs.mkdirSync(missingPath, { recursive: true }); + write(path.join(missingPath, 'ffmpeg'), Buffer.alloc(22, 5)); + + console.log('Bundle-size measurement schema, logical-byte walk, symlink handling, category reconciliation, distribution accounting, hotspots, and baseline comparison tests passed.'); + } finally { + fs.rmSync(tempRoot, { recursive: true, force: true }); + } +} + +try { + main(); +} catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); +} diff --git a/scripts/smoke-public-release.js b/scripts/smoke-public-release.js index 1797656..6c9b9c5 100644 --- a/scripts/smoke-public-release.js +++ b/scripts/smoke-public-release.js @@ -58,6 +58,7 @@ function writeFixture(rootDir) { codeSignature: { type: 'ad-hoc', structurallyValid: true, hardenedRuntime: false }, ffmpeg: { platform: 'darwin', architecture: 'arm64', manifestSha256: '3'.repeat(64) }, model: { id: 'whisper-base', revision: '4'.repeat(64), expectedBytes: 10, sha256: '4'.repeat(64), manifestSha256: '5'.repeat(64), embedded: false }, + bundleSize: { schema: 'scriptcut.bundle-size.v1', appLogicalBytes: 100, dmgBytes: 80, compressionRatio: 0.8, largestPrimaryCategory: { name: 'electronFrameworks', logicalBytes: 60 } }, }, null, 2)}\n`, 'utf8'); return { candidateDir, outputDir }; } @@ -85,6 +86,7 @@ async function main() { const manifest = JSON.parse(fs.readFileSync(result.manifestPath, 'utf8')); const notes = fs.readFileSync(path.join(fixture.outputDir, 'RELEASE_NOTES.md'), 'utf8'); validateManifest(manifest); + assert(manifest.bundleSize?.schema === 'scriptcut.bundle-size.v1', 'bundle-size summary was not propagated to public manifest'); validateNotes(notes); assert(/--signer-workflow[\s\S]*--source-digest/.test(notes), 'generated RELEASE_NOTES omit constrained attestation verification'); assert(!/--signer-repo/.test(notes), 'generated RELEASE_NOTES contain deprecated signer-repo constraint'); diff --git a/scripts/smoke-release-metadata.js b/scripts/smoke-release-metadata.js index 2638423..6db479e 100644 --- a/scripts/smoke-release-metadata.js +++ b/scripts/smoke-release-metadata.js @@ -49,6 +49,7 @@ function createSourceFixture() { notarized: false, codeSignature: { type: 'ad-hoc', structurallyValid: true, hardenedRuntime: false }, model: { embedded: false, manifestSha256: '1'.repeat(64) }, + bundleSize: { schema: 'scriptcut.bundle-size.v1', appLogicalBytes: 100, dmgBytes: 80, compressionRatio: 0.8, largestPrimaryCategory: { name: 'electronFrameworks', logicalBytes: 60 } }, runtime: { mode: 'packaged-bundled', pythonSource: 'bundled', @@ -115,6 +116,10 @@ async function validateReleaseDirectory(releaseDir) { assert(manifest.codeSignature?.type === 'ad-hoc' && manifest.codeSignature?.structurallyValid === true, 'candidate ad-hoc signature metadata is missing'); assert(manifest.codeSignature?.hardenedRuntime === false, 'candidate metadata must record Hardened Runtime disabled'); assert(manifest.model?.embedded === false, 'modelEmbedded must be false'); + if (manifest.bundleSize) { + assert(manifest.bundleSize.schema === 'scriptcut.bundle-size.v1', 'bundle-size summary schema must be scriptcut.bundle-size.v1'); + assert(Number.isInteger(manifest.bundleSize.appLogicalBytes) && Number.isInteger(manifest.bundleSize.dmgBytes), 'bundle-size summary byte values must be integers'); + } assert(manifest.runtime?.mode === 'packaged-bundled', 'runtime mode must be packaged-bundled'); assert(manifest.runtime?.pythonSource === 'bundled', 'Python source must be bundled'); assert(manifest.runtime?.target?.platform === 'darwin' && manifest.runtime?.target?.arch === 'arm64', 'runtime target must be darwin-arm64'); From 2933d33a8d1985432973d9d02b1c557b04ed9c59 Mon Sep 17 00:00:00 2001 From: FernandoM33 Date: Thu, 13 Aug 2026 23:23:03 -0700 Subject: [PATCH 2/2] docs: remove temporary agent context --- docs/RELEASE_QA.md | 5 +++-- docs/agent/COMMANDS.md | 16 ---------------- docs/agent/DECISIONS.md | 11 ----------- docs/agent/ISSUE-TRACKER.md | 6 ------ docs/agent/PROJECT.md | 17 ----------------- 5 files changed, 3 insertions(+), 52 deletions(-) delete mode 100644 docs/agent/COMMANDS.md delete mode 100644 docs/agent/DECISIONS.md delete mode 100644 docs/agent/ISSUE-TRACKER.md delete mode 100644 docs/agent/PROJECT.md diff --git a/docs/RELEASE_QA.md b/docs/RELEASE_QA.md index 3377b3a..28cf780 100644 --- a/docs/RELEASE_QA.md +++ b/docs/RELEASE_QA.md @@ -80,8 +80,9 @@ npm run release:rc:arm64 After the packaged runtime and DMG gates pass, the candidate writes `dist/release-candidate/bundle-size-report.json`. The public workflow keeps the exact six-file public bundle and uploads the detailed report separately as -Actions evidence. Bundle-size measurement is informational and is not a -publication blocker. +Actions evidence. Successful evidence generation is part of candidate +integrity; the measured size is informational only. Size increases do not fail +publication, and no size budget or threshold is enforced. Use `npm run release:rc:arm64 -- --real-model` only when the extended model gate is required. Hosted runners may prove the CPU real-model baseline and a diff --git a/docs/agent/COMMANDS.md b/docs/agent/COMMANDS.md deleted file mode 100644 index 07e6d3e..0000000 --- a/docs/agent/COMMANDS.md +++ /dev/null @@ -1,16 +0,0 @@ -# Engineering commands - -| Command | Scope | Status on context setup | -| --- | --- | --- | -| `npm run smoke:bundle-size` | Deterministic bundle-size fixture contract | verified during this change | -| `npm run smoke:release-metadata` | Candidate manifest and artifact fixture | verified baseline | -| `npm run smoke:public-release` | Public manifest/notes/workflow fixture | verified baseline | -| `npm run smoke:release-workflow` | Workflow structure and permission contract | verified baseline | -| `npm run smoke:runtime-contract` | Packaged runtime path contract | verified baseline | -| `npm run lint` | Frontend lint | run as delivery validation | -| `npm run build:frontend` | Frontend production build | run as delivery validation | -| `npm run release:rc:arm64` | Native macOS arm64 candidate | requires native arm64 and network prerequisites | - -The authoritative candidate output is under `dist/release-candidate/`. -Publication is owned by `.github/workflows/release-unsigned.yml`; do not -publish or create tags from local validation. diff --git a/docs/agent/DECISIONS.md b/docs/agent/DECISIONS.md deleted file mode 100644 index 834fc13..0000000 --- a/docs/agent/DECISIONS.md +++ /dev/null @@ -1,11 +0,0 @@ -# Engineering decisions - -- `productVersion` remains `0.1.0`; public identity remains `v0.1.0-alpha.`. -- `scriptcut.release.v1` is the candidate manifest and `scriptcut.release.v2` - is the public manifest; bundle size is an optional concise field, not a new - manifest schema. -- Bundle-size `logicalBytes` uses regular-file `lstat` sizes and skips symlink - targets. It is measurement evidence only: no optimization, budget, or - publication gate is derived from it. -- The baseline Whisper model is external/app-managed and is never embedded in - the package or counted in bundle totals. diff --git a/docs/agent/ISSUE-TRACKER.md b/docs/agent/ISSUE-TRACKER.md deleted file mode 100644 index bbd364b..0000000 --- a/docs/agent/ISSUE-TRACKER.md +++ /dev/null @@ -1,6 +0,0 @@ -# Issue tracking - -No repository issue tracker or project-board integration is configured in the -checkout. Release work is reviewed through the Git branch and draft PR, with -GitHub Actions providing candidate/public evidence and the release workflow -remaining the publication authority. diff --git a/docs/agent/PROJECT.md b/docs/agent/PROJECT.md deleted file mode 100644 index 4566244..0000000 --- a/docs/agent/PROJECT.md +++ /dev/null @@ -1,17 +0,0 @@ -# ScriptCut engineering context - -ScriptCut is a local-first Electron desktop video editor with a React/Vite -frontend, a FastAPI backend, bundled FFmpeg, and an optional app-managed -Whisper model. Release packaging is macOS arm64, ad-hoc signed, and -self-contained for the current alpha path. - -Key boundaries: - -- `frontend/`: renderer UI and frontend build/lint. -- `backend/`: local media/transcription services and Python smoke tests. -- `electron/`: desktop process and runtime contract. -- `scripts/`: release orchestration, packaged gates, and deterministic smokes. -- `.github/workflows/`: source CI, candidate build, and public release authority. - -The release candidate is maintainer evidence, not a public release. The public -workflow owns the exact six GitHub Release assets and publication gates.