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
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,9 @@ jobs:
- name: Test public release metadata and documentation
run: npm run smoke:public-release

- name: Test canonical release identity
run: npm run smoke:release-identity

- name: Test public release workflow structure
run: npm run smoke:release-workflow

Expand Down
14 changes: 13 additions & 1 deletion docs/RELEASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ This guide is for preparing a desktop release from the repository.

## Current Release Status

Phase 3B.5C adds the controlled public ad-hoc-signed macOS arm64 distribution path. This guide describes how qualifying public alphas are produced; it does not assert that every historical alpha already has the self-contained runtime. Inspect each release's notes and manifest. The workflow creates no tag or GitHub Release unless explicitly dispatched with publication gates. Apple Developer membership, Developer ID signing, and notarization are optional future enhancements, not prerequisites for the public ad-hoc alpha path. Source development is still supported with:
The release system uses an explicit identity contract while preserving the controlled public ad-hoc-signed macOS arm64 distribution path. This guide describes how qualifying public alphas are produced; it does not assert that every historical alpha already has the self-contained runtime. Inspect each release's notes and manifest. The workflow creates no tag or GitHub Release unless explicitly dispatched with publication gates. Apple Developer membership, Developer ID signing, and notarization are optional future enhancements, not prerequisites for the public ad-hoc alpha path. Source development is still supported with:

```bash
npm run setup
Expand All @@ -14,6 +14,18 @@ npm run dev

That starts the local backend, frontend, and Electron desktop app.

## Current release identity

The current product version is `0.1.0`. The supported public release channel is alpha only; the current public prerelease is `v0.1.0-alpha.3`.

The installed application and the public release are intentionally separate identities:

- `package.json.version`, Electron `app.getVersion()`, `CFBundleShortVersionString`, and the renderer product version all represent `productVersion` (`0.1.0`).
- The GitHub tag and public DMG filename represent `releaseTag` (`v0.1.0-alpha.<n>` and `ScriptCut-v0.1.0-alpha.<n>-arm64.dmg`).
- `sourceCommit` is the full 40-character Git SHA recorded in release metadata. Artifact identity is the exact filename, byte count, and SHA-256.

The prerelease tag must not be placed in the package version or macOS short version. `CFBundleVersion` remains the exact Electron Builder-emitted value recorded from each native candidate; the current release contract does not add a separate build counter. Beta, RC, and stable validators/publication are not currently supported; their naming can remain future-compatible.

## Release Checklist

Run these checks before creating a release:
Expand Down
6 changes: 6 additions & 0 deletions electron/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,12 @@ function createWindow({ hidden = false } = {}) {
}

app.whenReady().then(async () => {
if (process.env.SCRIPTCUT_IDENTITY_SMOKE === '1') {
console.log(`SCRIPTCUT_IDENTITY_SMOKE_RESULT=${JSON.stringify({ version: app.getVersion(), packaged: app.isPackaged, electron: process.versions.electron })}`);
app.exit(0);
return;
}

const runtimeMode = selectRuntimeMode({
isDev,
packaged: app.isPackaged,
Expand Down
10 changes: 5 additions & 5 deletions frontend/src/components/HomeScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -644,7 +644,7 @@ function getSetupGuidance(row: SystemCheck, isElectron: boolean) {
if (row.label === 'Desktop app') {
return {
message: 'Use the installed ScriptCut desktop app for native file access, autosave, and direct exports.',
link: RELEASE_LINKS.latestRelease,
link: RELEASE_LINKS.releases,
linkLabel: 'Download desktop release',
};
}
Expand All @@ -653,7 +653,7 @@ function getSetupGuidance(row: SystemCheck, isElectron: boolean) {
if (isElectron) {
return {
message: 'The ScriptCut desktop app includes its local editing runtime. Restart ScriptCut. If the problem continues, reinstall the official ScriptCut DMG.',
link: RELEASE_LINKS.latestRelease,
link: RELEASE_LINKS.releases,
linkLabel: 'Reinstall official DMG',
};
}
Expand Down Expand Up @@ -681,14 +681,14 @@ function getSetupGuidance(row: SystemCheck, isElectron: boolean) {
if (isElectron) {
return {
message: 'Desktop releases include the video export engine. Reinstall the official ScriptCut DMG if this component is missing.',
link: RELEASE_LINKS.latestRelease,
link: RELEASE_LINKS.releases,
linkLabel: 'Reinstall official DMG',
};
}
return {
message: 'Desktop releases include FFmpeg for export. Source builds can install FFmpeg manually.',
command: 'brew install ffmpeg',
link: RELEASE_LINKS.latestRelease,
link: RELEASE_LINKS.releases,
linkLabel: 'Get desktop release',
};
}
Expand All @@ -703,7 +703,7 @@ function getSetupGuidance(row: SystemCheck, isElectron: boolean) {
if (isElectron) {
return {
message: 'Baseline Whisper transcription is included in this desktop release. Restart ScriptCut; if it remains unavailable, reinstall the official ScriptCut DMG.',
link: RELEASE_LINKS.latestRelease,
link: RELEASE_LINKS.releases,
linkLabel: 'Reinstall official DMG',
};
}
Expand Down
8 changes: 4 additions & 4 deletions frontend/src/components/SettingsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { useState, useEffect, useCallback } from 'react';
import type { AIProvider } from '../types/project';
import { useEditorStore } from '../store/editorStore';
import { Bot, Cloud, Brain, RefreshCw, Route, ShieldCheck, Copy, CheckCircle2, AlertCircle, Download, ExternalLink, MonitorCheck } from 'lucide-react';
import { RELEASE_LINKS, SCRIPTCUT_VERSION } from '../utils/releaseInfo';
import { RELEASE_LINKS, SCRIPTCUT_PRODUCT_VERSION } from '../utils/releaseInfo';
import { buildSupportReport } from '../utils/supportReport';
import { getAIModeLabel, isLocalAIEndpoint } from '../utils/settingsUx';

Expand Down Expand Up @@ -164,7 +164,7 @@ export default function SettingsPanel() {
window.electronAPI?.getAppInfo?.().catch(() => undefined),
]);
const report = buildSupportReport({
fallbackVersion: SCRIPTCUT_VERSION,
fallbackVersion: SCRIPTCUT_PRODUCT_VERSION,
app,
runtime,
jobs: recentJobs?.jobs || [],
Expand Down Expand Up @@ -215,15 +215,15 @@ export default function SettingsPanel() {
ScriptCut desktop
</div>
<p className="mt-1 text-[11px] leading-relaxed text-editor-text-muted">
Version {SCRIPTCUT_VERSION}. Desktop releases are the recommended user path because they provide native file access, autosave, and bundled export tools.
Version {SCRIPTCUT_PRODUCT_VERSION}. Desktop releases are the recommended user path because they provide native file access, autosave, and bundled export tools.
</p>
</div>
<span className="rounded bg-editor-bg px-2 py-1 text-[10px] text-editor-text-muted">
{window.electronAPI ? 'Desktop' : 'Browser'}
</span>
</div>
<div className="grid grid-cols-2 gap-2">
<ReleaseLink href={RELEASE_LINKS.latestRelease} icon={<Download className="h-3.5 w-3.5" />} label="Latest release" />
<ReleaseLink href={RELEASE_LINKS.releases} icon={<Download className="h-3.5 w-3.5" />} label="Release downloads" />
<ReleaseLink href={RELEASE_LINKS.installGuide} icon={<ExternalLink className="h-3.5 w-3.5" />} label="Install guide" />
<ReleaseLink href={RELEASE_LINKS.troubleshooting} icon={<ExternalLink className="h-3.5 w-3.5" />} label="Fix setup" />
<ReleaseLink href={RELEASE_LINKS.issues} icon={<ExternalLink className="h-3.5 w-3.5" />} label="Report issue" />
Expand Down
3 changes: 1 addition & 2 deletions frontend/src/utils/releaseInfo.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
export const SCRIPTCUT_VERSION = '0.1.0-alpha.2';
export const SCRIPTCUT_PRODUCT_VERSION = __SCRIPTCUT_PRODUCT_VERSION__;

export const RELEASE_LINKS = {
latestRelease: 'https://github.com/FernandoAbishai/ScriptCut/releases/latest',
releases: 'https://github.com/FernandoAbishai/ScriptCut/releases',
issues: 'https://github.com/FernandoAbishai/ScriptCut/issues',
bugReport: 'https://github.com/FernandoAbishai/ScriptCut/issues/new?template=bug_report.yml',
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/vite-env.d.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
/// <reference types="vite/client" />

declare const __SCRIPTCUT_PRODUCT_VERSION__: string;

interface ElectronAPI {
openFile: (options?: Record<string, unknown>) => Promise<string | null>;
openDirectory: (options?: Record<string, unknown>) => Promise<string | null>;
Expand Down
8 changes: 8 additions & 0 deletions frontend/vite.config.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,17 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { createRequire } from 'node:module';

const require = createRequire(import.meta.url);
const { readProductVersion } = require('../scripts/release-identity');
const productVersion = readProductVersion();

export default defineConfig({
plugins: [react()],
base: './',
define: {
__SCRIPTCUT_PRODUCT_VERSION__: JSON.stringify(productVersion),
},
server: {
port: 5173,
strictPort: true,
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
"smoke:packaged-transcription": "node scripts/smoke-packaged-transcription.js --arch arm64",
"smoke:packaged-optional-capabilities": "node scripts/smoke-packaged-optional-capabilities.js --arch arm64",
"smoke:release-metadata": "node scripts/smoke-release-metadata.js",
"smoke:release-identity": "node scripts/smoke-release-identity.js",
"release:public:prepare": "node scripts/prepare-public-release.js",
"check:public-release": "node scripts/check-public-release.js",
"smoke:public-release": "node scripts/smoke-public-release.js",
Expand Down
10 changes: 8 additions & 2 deletions scripts/check-public-release.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ const path = require('path');
const { spawnSync } = require('child_process');
const { checksumFile } = require('./release-alpha');
const { verifyApp } = require('./check-macos-launchability');
const { formatPublicArtifactFilename, validateAlphaReleaseTag } = require('./release-identity');

const root = path.join(__dirname, '..');
const forbiddenNames = new Set([
Expand Down Expand Up @@ -74,7 +75,12 @@ function validateNotes(notes) {

function validateManifest(manifest, { allowPendingAttestation = false } = {}) {
assert(manifest.schema === 'scriptcut.release.v2', 'schema must be scriptcut.release.v2');
assert(/^v\d+\.\d+\.\d+-alpha\.[1-9]\d*$/.test(manifest.releaseTag), 'release tag format is invalid');
let tagInfo;
try {
tagInfo = validateAlphaReleaseTag(manifest.releaseTag, manifest.version);
} catch (error) {
fail(error instanceof Error ? error.message : String(error));
}
assert(manifest.prerelease === true && manifest.channel === 'ad-hoc-public-alpha', 'public prerelease semantics are missing');
assert(manifest.platform === 'darwin' && manifest.architecture === 'arm64', 'public target must be darwin arm64');
assert(manifest.distribution?.mode === 'ad-hoc-public-alpha', 'distribution mode is not ad-hoc-public-alpha');
Expand All @@ -90,7 +96,7 @@ function validateManifest(manifest, { allowPendingAttestation = false } = {}) {
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');
assert(manifest.artifact.filename === `ScriptCut-${manifest.releaseTag}-arm64.dmg`, 'public artifact filename must include the release tag');
assert(manifest.artifact.filename === formatPublicArtifactFilename(tagInfo.releaseTag, tagInfo.productVersion, 'arm64'), 'public artifact filename must include the release tag');
assert(manifest.provenance?.provider === 'github-artifact-attestation-sigstore', 'provenance provider is incorrect');
assert(manifest.provenance.repository === 'FernandoAbishai/ScriptCut', 'provenance repository is incorrect');
assert(manifest.provenance.workflow === '.github/workflows/release-unsigned.yml', 'provenance workflow is incorrect');
Expand Down
47 changes: 14 additions & 33 deletions scripts/prepare-public-release.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,13 @@ const fs = require('fs');
const path = require('path');
const { spawnSync } = require('child_process');
const { checksumFile } = require('./release-alpha');
const {
alphaSuffix,
formatPublicArtifactFilename,
highestAlphaSuffix,
readProductVersion,
validateAlphaReleaseTag,
} = require('./release-identity');

const root = path.join(__dirname, '..');
const packagePath = path.join(root, 'package.json');
Expand Down Expand Up @@ -32,7 +39,7 @@ function readJson(filePath) {
}

function packageVersion() {
return readJson(packagePath).version;
return readProductVersion(packagePath);
}

function currentGitCommit() {
Expand All @@ -55,35 +62,7 @@ function existingTagsFromGit() {
return result.stdout.split(/\r?\n/).map((tag) => tag.trim()).filter(Boolean);
}

function alphaTagPattern(version) {
return new RegExp(`^v${version.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}-alpha\\.(\\d+)$`);
}

function alphaSuffix(tag, version) {
const match = alphaTagPattern(version).exec(tag);
return match ? Number(match[1]) : null;
}

function highestAlphaSuffix(tags, version) {
return tags.reduce((highest, tag) => {
const suffix = alphaSuffix(tag, version);
return suffix === null ? highest : Math.max(highest, suffix);
}, 0);
}

function validateReleaseTag(tag, version, existingTags = []) {
if (typeof tag !== 'string' || !tag) throw new Error('release_tag is required');
const suffix = alphaSuffix(tag, version);
if (suffix === null || suffix < 1) {
throw new Error(`release tag must match v${version}-alpha.<positive integer>`);
}
if (existingTags.includes(tag)) throw new Error(`release tag already exists: ${tag}`);
const highest = highestAlphaSuffix(existingTags, version);
if (suffix <= highest) {
throw new Error(`release alpha suffix ${suffix} must be greater than existing highest suffix ${highest}`);
}
return { tag, suffix, highestExistingSuffix: highest };
}
const validateReleaseTag = validateAlphaReleaseTag;

function readExistingTags(filePath) {
if (!filePath) return existingTagsFromGit();
Expand Down Expand Up @@ -240,9 +219,11 @@ Attestation establishes build provenance; it does not prove that the software is

async function preparePublicRelease(options = {}) {
const pkg = readJson(packagePath);
const productVersion = readProductVersion(packagePath);
if (pkg.version !== productVersion) fail('package version does not match canonical productVersion');
const tag = options.tag || optionValue('--tag');
const existingTags = options.existingTags || readExistingTags(optionValue('--existing-tags-file'));
const tagInfo = validateReleaseTag(tag, pkg.version, existingTags);
const tagInfo = validateReleaseTag(tag, productVersion, existingTags);
const candidateDir = path.resolve(options.candidateDir || optionValue('--candidate-dir') || path.join(root, 'dist', 'release-candidate'));
const outputDir = path.resolve(options.outputDir || optionValue('--output-dir') || path.join(root, 'dist', 'public-release'));
const candidateManifestPath = path.join(candidateDir, 'release-manifest.json');
Expand All @@ -260,7 +241,7 @@ async function preparePublicRelease(options = {}) {
fs.rmSync(path.join(outputDir, name), { force: true });
}

const publicFilename = `ScriptCut-${tag}-arm64.dmg`;
const publicFilename = formatPublicArtifactFilename(tagInfo.releaseTag, productVersion, 'arm64');
const publicDmgPath = path.join(outputDir, publicFilename);
if (!options.preserveOutput || !fs.existsSync(publicDmgPath)) {
fs.copyFileSync(sourceDmg, publicDmgPath);
Expand All @@ -284,7 +265,7 @@ async function preparePublicRelease(options = {}) {
bundle: options.dmgAttestationBundle || optionValue('--dmg-attestation-bundle') || `${publicFilename}.sigstore.json`,
}
: null;
const manifest = publicManifest({ pkg, tag, commit, artifact, candidate: candidateManifest, dmgAttestation: attestation });
const manifest = publicManifest({ pkg, tag: tagInfo.releaseTag, commit, artifact, candidate: candidateManifest, dmgAttestation: attestation });
fs.writeFileSync(path.join(outputDir, 'release-manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`, 'utf8');
fs.writeFileSync(path.join(outputDir, 'RELEASE_NOTES.md'), publicNotes(manifest), 'utf8');
fs.writeFileSync(path.join(outputDir, 'SHA256SUMS.txt'), `${artifact.sha256} ${artifact.filename}\n`, 'utf8');
Expand Down
9 changes: 7 additions & 2 deletions scripts/release-alpha.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ const fs = require('fs');
const path = require('path');
const { spawnSync } = require('child_process');
const { inspectPackage } = require('./check-packaged-runtime');
const { formatCandidateArtifactFilename, readProductVersion } = require('./release-identity');

const root = path.join(__dirname, '..');
const distDir = path.join(root, 'dist');
Expand All @@ -13,7 +14,10 @@ const packageOutputDir = path.join(releaseDir, 'app');
const releaseMetadataDir = releaseDir;

function readPackage() {
return JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
const productVersion = readProductVersion();
if (pkg.version !== productVersion) throw new Error('package version does not match canonical productVersion');
return { ...pkg, version: productVersion };
}

function runStep(name, command, args, options = {}) {
Expand Down Expand Up @@ -83,7 +87,7 @@ function findFiles(directory, predicate, found = []) {
}

function candidateOutputs(pkg) {
const expectedDmg = `ScriptCut-${pkg.version}-arm64.dmg`;
const expectedDmg = formatCandidateArtifactFilename(pkg.version, 'arm64');
const dmgs = findFiles(packageOutputDir, (filePath, entry) => entry.isFile() && path.basename(filePath) === expectedDmg);
if (dmgs.length !== 1) throw new Error(`Expected exactly one current candidate DMG ${expectedDmg}; found ${dmgs.length}.`);
const apps = findFiles(packageOutputDir, (filePath, entry) => entry.isDirectory() && entry.name === 'ScriptCut.app');
Expand Down Expand Up @@ -275,6 +279,7 @@ async function main() {
const outputs = candidateOutputs(pkg);
const packageInfo = inspectPackage(outputs.appPath);
runPackagedGate('Packaged runtime gate', 'scripts/check-packaged-runtime.js', ['--arch', 'arm64', '--app', outputs.appPath], env);
runPackagedGate('Packaged release identity gate', 'scripts/smoke-packaged-release-identity.js', ['--app', outputs.appPath], env);
runPackagedGate('Packaged FFmpeg gate', 'scripts/check-packaged-ffmpeg.js', ['--arch', 'arm64', '--app', outputs.appPath], env);
runPackagedGate('Packaged backend gate', 'scripts/smoke-packaged-backend.js', ['--arch', 'arm64', '--app', outputs.appPath], env);
runPackagedGate('Electron-like packaged backend startup gate', 'scripts/check-packaged-electron-backend.js', ['--app', outputs.appPath], env);
Expand Down
Loading