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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
8 changes: 8 additions & 0 deletions .github/workflows/release-unsigned.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
66 changes: 66 additions & 0 deletions docs/BUNDLE_SIZE.md
Original file line number Diff line number Diff line change
@@ -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 <ScriptCut.app> \
--dmg <candidate.dmg> \
--output bundle-size-report.json

node scripts/measure-bundle-size.js \
--app <new/ScriptCut.app> \
--dmg <new/candidate.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.
6 changes: 6 additions & 0 deletions docs/RELEASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-<releaseTag>-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
Expand Down
8 changes: 8 additions & 0 deletions docs/RELEASE_QA.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -76,6 +77,13 @@ 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. 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
deterministic or simulated MPS compatibility branch; they must not claim
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions scripts/check-packaged-runtime.js
Original file line number Diff line number Diff line change
Expand Up @@ -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`);
Expand Down
8 changes: 8 additions & 0 deletions scripts/check-public-release.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
6 changes: 6 additions & 0 deletions scripts/check-release-workflow.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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') } = {}) {
Expand All @@ -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');
Expand Down
Loading