From 59c70f0923421085f50b686ae1ebe808e9e55928 Mon Sep 17 00:00:00 2001 From: FernandoM33 Date: Thu, 13 Aug 2026 12:43:21 -0700 Subject: [PATCH 1/2] fix: establish canonical release identity --- .github/workflows/ci.yml | 3 + docs/RELEASE.md | 14 ++- docs/agent/COMMANDS.md | 19 ++++ docs/agent/DECISIONS.md | 8 ++ docs/agent/ISSUE-TRACKER.md | 5 + docs/agent/PROJECT.md | 20 ++++ electron/main.js | 6 ++ frontend/src/components/HomeScreen.tsx | 10 +- frontend/src/components/SettingsPanel.tsx | 8 +- frontend/src/utils/releaseInfo.ts | 3 +- frontend/src/vite-env.d.ts | 2 + frontend/vite.config.ts | 8 ++ package.json | 1 + scripts/check-public-release.js | 10 +- scripts/prepare-public-release.js | 47 +++------- scripts/release-alpha.js | 9 +- scripts/release-identity.js | 102 +++++++++++++++++++++ scripts/smoke-packaged-release-identity.js | 81 ++++++++++++++++ scripts/smoke-public-release.js | 13 +-- scripts/smoke-release-identity.js | 87 ++++++++++++++++++ scripts/smoke-release-metadata.js | 4 + 21 files changed, 405 insertions(+), 55 deletions(-) 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/release-identity.js create mode 100644 scripts/smoke-packaged-release-identity.js create mode 100644 scripts/smoke-release-identity.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9cda679..9cb9e6e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/docs/RELEASE.md b/docs/RELEASE.md index 62c10ee..6ef4710 100644 --- a/docs/RELEASE.md +++ b/docs/RELEASE.md @@ -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: +Phase 4B establishes the explicit release 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 @@ -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 in this implementation 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.` and `ScriptCut-v0.1.0-alpha.-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; Phase 4B does not add a new 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: diff --git a/docs/agent/COMMANDS.md b/docs/agent/COMMANDS.md new file mode 100644 index 0000000..805880f --- /dev/null +++ b/docs/agent/COMMANDS.md @@ -0,0 +1,19 @@ +# ScriptCut Commands + +Commands are run from `/Users/fm/ScriptCut`. + +| Command | Scope | Status on Phase 4B baseline | +|---|---|---| +| `npm run smoke:release-identity` | canonical package/renderer/tag contract | added by Phase 4B; targeted verification | +| `npm run smoke:release-metadata` | candidate manifest/checksum/provenance | verified passing before edits | +| `npm run smoke:public-release` | public manifest/tag/notes/docs fixtures | verified passing before edits | +| `npm run smoke:release-workflow` | guarded workflow structure/permissions | verified passing before edits | +| `npm run smoke:runtime-contract` | packaged/runtime contract source checks | verified passing before edits | +| `npm run smoke:renderer-policy` | built renderer CSP/asset policy | verified passing before edits | +| `npm run lint` | frontend ESLint | verified passing before edits | +| `npm run build:frontend` | TypeScript/Vite renderer build | required after source edits | +| `npm run smoke:backend` | backend Python smoke suite | required after source edits | +| `python -m compileall -q backend` | Python syntax compilation | required after source edits | +| `npm run release:rc:arm64` | native arm64 candidate; packages and runs release gates | Phase 4B candidate evidence; `real_model=false` | + +The native candidate requires macOS arm64, packaged runtime inputs, and local release build dependencies. It is configured for `--publish never`; Phase 4B does not publish, tag, or merge. diff --git a/docs/agent/DECISIONS.md b/docs/agent/DECISIONS.md new file mode 100644 index 0000000..78727f9 --- /dev/null +++ b/docs/agent/DECISIONS.md @@ -0,0 +1,8 @@ +# ScriptCut Decisions + +- Preserve the split between `productVersion` and public alpha `releaseTag`; do not append prerelease strings to package versions. +- Keep the public release channel alpha-only for this phase. Beta, RC, and stable lifecycle work belongs to later release phases. +- Source renderer identity comes from the root `package.json` through Vite build-time substitution; it must not fetch GitHub or parse package metadata at runtime. +- Candidate metadata remains internal and untagged. Public artifact names derive from a validated alpha release tag. +- `CFBundleShortVersionString` must equal `productVersion`. Do not invent a new `CFBundleVersion` counter until native evidence demonstrates a concrete consumer need. +- Do not change public release workflow permissions, publication gates, signing strategy, or product functionality in Phase 4B. diff --git a/docs/agent/ISSUE-TRACKER.md b/docs/agent/ISSUE-TRACKER.md new file mode 100644 index 0000000..00fae84 --- /dev/null +++ b/docs/agent/ISSUE-TRACKER.md @@ -0,0 +1,5 @@ +# Issue Tracker Context + +No repository-local issue tracker or issue ID was supplied for this task. Delivery is governed by the attached Phase 4B release identity contract and the requested branch/draft-PR boundary. + +GitHub delivery convention for this task: branch `release/release-identity-contract`, one draft PR, no merge, no tag, and no release publication. diff --git a/docs/agent/PROJECT.md b/docs/agent/PROJECT.md new file mode 100644 index 0000000..27ab5f2 --- /dev/null +++ b/docs/agent/PROJECT.md @@ -0,0 +1,20 @@ +# ScriptCut Engineering Context + +ScriptCut is an Electron desktop video editor with a React/Vite renderer and a FastAPI local backend. The creator workflow is local-first: the Electron main process starts the backend, exposes a narrow context-isolated preload API, and packages bundled runtime resources for native macOS candidates. + +## Architecture map + +- `electron/`: main process, preload bridge, backend startup, runtime contract. +- `frontend/`: React/Vite renderer and user-facing editor/support UI. +- `backend/`: local FastAPI services and Python smoke tests. +- `scripts/`: packaging, provenance, release metadata, and smoke gates. +- `.github/workflows/`: normal CI plus guarded native arm64 release workflow. +- `docs/`: product, installation, QA, and release contracts. + +## Trust boundaries + +Renderer code is sandboxed with context isolation, no Node integration, and a constrained preload bridge. Release publication is workflow-dispatch-only and separate from candidate builds. Do not place credentials in manifests or broaden candidate work into publication/Production without explicit authorization. + +## Release identity terms + +`productVersion` is the core package/app version (`0.1.0`). The public alpha `releaseTag` is derived separately (`v0.1.0-alpha.N`). Candidate manifests intentionally have no public tag. diff --git a/electron/main.js b/electron/main.js index 45537d2..6d4f274 100644 --- a/electron/main.js +++ b/electron/main.js @@ -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, diff --git a/frontend/src/components/HomeScreen.tsx b/frontend/src/components/HomeScreen.tsx index 780833c..ee643c0 100644 --- a/frontend/src/components/HomeScreen.tsx +++ b/frontend/src/components/HomeScreen.tsx @@ -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', }; } @@ -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', }; } @@ -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', }; } @@ -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', }; } diff --git a/frontend/src/components/SettingsPanel.tsx b/frontend/src/components/SettingsPanel.tsx index d7cf89d..198d527 100644 --- a/frontend/src/components/SettingsPanel.tsx +++ b/frontend/src/components/SettingsPanel.tsx @@ -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'; @@ -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 || [], @@ -215,7 +215,7 @@ export default function SettingsPanel() { ScriptCut desktop

- 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.

@@ -223,7 +223,7 @@ export default function SettingsPanel() {
- } label="Latest release" /> + } label="Release downloads" /> } label="Install guide" /> } label="Fix setup" /> } label="Report issue" /> diff --git a/frontend/src/utils/releaseInfo.ts b/frontend/src/utils/releaseInfo.ts index 9c1e438..3156706 100644 --- a/frontend/src/utils/releaseInfo.ts +++ b/frontend/src/utils/releaseInfo.ts @@ -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', diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts index 314c174..483612b 100644 --- a/frontend/src/vite-env.d.ts +++ b/frontend/src/vite-env.d.ts @@ -1,5 +1,7 @@ /// +declare const __SCRIPTCUT_PRODUCT_VERSION__: string; + interface ElectronAPI { openFile: (options?: Record) => Promise; openDirectory: (options?: Record) => Promise; diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index bc167f2..a477fed 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -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, diff --git a/package.json b/package.json index a98262a..22b17a8 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/check-public-release.js b/scripts/check-public-release.js index efc95c6..fce64d8 100644 --- a/scripts/check-public-release.js +++ b/scripts/check-public-release.js @@ -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([ @@ -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'); @@ -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'); diff --git a/scripts/prepare-public-release.js b/scripts/prepare-public-release.js index fe3c2e3..1c9ab38 100644 --- a/scripts/prepare-public-release.js +++ b/scripts/prepare-public-release.js @@ -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'); @@ -32,7 +39,7 @@ function readJson(filePath) { } function packageVersion() { - return readJson(packagePath).version; + return readProductVersion(packagePath); } function currentGitCommit() { @@ -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.`); - } - 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(); @@ -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'); @@ -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); @@ -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'); diff --git a/scripts/release-alpha.js b/scripts/release-alpha.js index d688a31..d7fd41a 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 { formatCandidateArtifactFilename, readProductVersion } = require('./release-identity'); const root = path.join(__dirname, '..'); const distDir = path.join(root, 'dist'); @@ -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 = {}) { @@ -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'); @@ -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); diff --git a/scripts/release-identity.js b/scripts/release-identity.js new file mode 100644 index 0000000..4b992d5 --- /dev/null +++ b/scripts/release-identity.js @@ -0,0 +1,102 @@ +#!/usr/bin/env node + +const fs = require('fs'); +const path = require('path'); + +const root = path.join(__dirname, '..'); +const CORE_VERSION_PATTERN = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/; +const ALPHA_TAG_PATTERN = /^v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)-alpha\.([1-9]\d*)$/; + +function assertProductVersion(productVersion) { + if (typeof productVersion !== 'string' || !CORE_VERSION_PATTERN.test(productVersion)) { + throw new Error('productVersion must be MAJOR.MINOR.PATCH core SemVer'); + } + return productVersion; +} + +function readProductVersion(packagePath = path.join(root, 'package.json')) { + let packageJson; + try { + packageJson = JSON.parse(fs.readFileSync(packagePath, 'utf8')); + } catch (error) { + throw new Error(`could not read product package metadata: ${error.message}`); + } + return assertProductVersion(packageJson.version); +} + +function formatAlphaReleaseTag(productVersion, iteration) { + assertProductVersion(productVersion); + if (!Number.isSafeInteger(iteration) || iteration < 1) { + throw new Error('prereleaseIteration must be a positive safe integer'); + } + return `v${productVersion}-alpha.${iteration}`; +} + +function parseAlphaReleaseTag(tag, productVersion) { + assertProductVersion(productVersion); + if (typeof tag !== 'string') return null; + const match = ALPHA_TAG_PATTERN.exec(tag); + if (!match || `${match[1]}.${match[2]}.${match[3]}` !== productVersion) return null; + const iteration = Number(match[4]); + if (!Number.isSafeInteger(iteration) || iteration < 1) return null; + return { + tag, + releaseTag: tag, + productVersion, + releaseChannel: 'alpha', + channel: 'alpha', + prereleaseIteration: iteration, + iteration, + suffix: iteration, + }; +} + +function alphaSuffix(tag, productVersion) { + return parseAlphaReleaseTag(tag, productVersion)?.iteration ?? null; +} + +function highestAlphaSuffix(existingTags, productVersion) { + if (!Array.isArray(existingTags)) throw new Error('existingTags must be an array'); + return existingTags.reduce((highest, tag) => Math.max(highest, alphaSuffix(tag, productVersion) || 0), 0); +} + +function validateAlphaReleaseTag(tag, productVersion, existingTags = []) { + assertProductVersion(productVersion); + if (typeof tag !== 'string' || !tag) throw new Error('release_tag is required'); + const parsed = parseAlphaReleaseTag(tag, productVersion); + if (!parsed) throw new Error(`release tag must match v${productVersion}-alpha.`); + if (!Array.isArray(existingTags)) throw new Error('existingTags must be an array'); + if (existingTags.includes(tag)) throw new Error(`release tag already exists: ${tag}`); + const highestExistingIteration = highestAlphaSuffix(existingTags, productVersion); + if (parsed.iteration <= highestExistingIteration) { + throw new Error(`release alpha suffix ${parsed.iteration} must be greater than existing highest suffix ${highestExistingIteration}`); + } + return { + ...parsed, + highestExistingIteration, + highestExistingSuffix: highestExistingIteration, + }; +} + +function formatCandidateArtifactFilename(productVersion, architecture = 'arm64') { + assertProductVersion(productVersion); + return `ScriptCut-${productVersion}-${architecture}.dmg`; +} + +function formatPublicArtifactFilename(releaseTag, productVersion, architecture = 'arm64') { + const parsed = parseAlphaReleaseTag(releaseTag, productVersion); + if (!parsed) throw new Error('releaseTag must be a validated alpha release tag for productVersion'); + return `ScriptCut-${parsed.releaseTag}-${architecture}.dmg`; +} + +module.exports = { + alphaSuffix, + assertProductVersion, + formatAlphaReleaseTag, + formatCandidateArtifactFilename, + formatPublicArtifactFilename, + highestAlphaSuffix, + parseAlphaReleaseTag, + readProductVersion, + validateAlphaReleaseTag, +}; diff --git a/scripts/smoke-packaged-release-identity.js b/scripts/smoke-packaged-release-identity.js new file mode 100644 index 0000000..00c4cc1 --- /dev/null +++ b/scripts/smoke-packaged-release-identity.js @@ -0,0 +1,81 @@ +#!/usr/bin/env node + +const fs = require('fs'); +const path = require('path'); +const { spawnSync } = require('child_process'); +const { readProductVersion } = require('./release-identity'); + +const root = path.join(__dirname, '..'); + +function optionValue(name) { + const index = process.argv.indexOf(name); + return index >= 0 ? process.argv[index + 1] : undefined; +} + +function fail(message) { + throw new Error(`Packaged release identity smoke failed: ${message}`); +} + +function assert(condition, message) { + if (!condition) fail(message); +} + +function readPlist(plistPath, key) { + const result = spawnSync('plutil', ['-extract', key, 'raw', '-o', '-', plistPath], { + cwd: root, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }); + if (result.status !== 0) fail(`could not read ${key} from Info.plist: ${(result.stderr || result.stdout || '').trim()}`); + return result.stdout.trim(); +} + +function main() { + assert(process.platform === 'darwin' && process.arch === 'arm64', `requires native macOS arm64, received ${process.platform}-${process.arch}`); + const appPath = path.resolve(optionValue('--app') || ''); + assert(appPath.endsWith('.app') && fs.existsSync(appPath), `packaged app is missing: ${appPath}`); + const plistPath = path.join(appPath, 'Contents', 'Info.plist'); + assert(fs.existsSync(plistPath), 'packaged Info.plist is missing'); + + const productVersion = readProductVersion(); + const bundleIdentifier = readPlist(plistPath, 'CFBundleIdentifier'); + const shortVersion = readPlist(plistPath, 'CFBundleShortVersionString'); + const bundleVersion = readPlist(plistPath, 'CFBundleVersion'); + const executableName = readPlist(plistPath, 'CFBundleExecutable'); + assert(bundleIdentifier === 'com.fernandoabishai.scriptcut', `CFBundleIdentifier is ${bundleIdentifier}`); + assert(shortVersion === productVersion, `CFBundleShortVersionString is ${shortVersion}, expected ${productVersion}`); + assert(!/-alpha\./.test(shortVersion), 'prerelease identity entered CFBundleShortVersionString'); + + const executablePath = path.join(appPath, 'Contents', 'MacOS', executableName); + const environment = { ...process.env, SCRIPTCUT_IDENTITY_SMOKE: '1' }; + delete environment.ELECTRON_RUN_AS_NODE; + const result = spawnSync(executablePath, [], { + cwd: root, + env: environment, + encoding: 'utf8', + timeout: 30000, + }); + if (result.error || result.status !== 0) { + fail(`packaged app identity probe failed: ${(result.error?.message || result.stderr || result.stdout || '').trim()}`); + } + const output = `${result.stdout || ''}\n${result.stderr || ''}`; + const line = output.split(/\r?\n/).find((entry) => entry.startsWith('SCRIPTCUT_IDENTITY_SMOKE_RESULT=')); + assert(line, 'packaged app did not report the app.getVersion() identity probe'); + let appInfo; + try { + appInfo = JSON.parse(line.slice('SCRIPTCUT_IDENTITY_SMOKE_RESULT='.length)); + } catch (error) { + fail(`app.getVersion() probe was not valid JSON: ${error.message}`); + } + assert(appInfo.packaged === true, 'app.getVersion() probe did not run in a packaged app'); + assert(appInfo.version === productVersion, `app.getVersion() is ${appInfo.version}, expected ${productVersion}`); + + console.log(`Packaged release identity passed: app.getVersion=${appInfo.version}, CFBundleIdentifier=${bundleIdentifier}, CFBundleShortVersionString=${shortVersion}, CFBundleVersion=${bundleVersion}`); +} + +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 d7b1d15..1797656 100644 --- a/scripts/smoke-public-release.js +++ b/scripts/smoke-public-release.js @@ -4,7 +4,8 @@ const crypto = require('crypto'); const fs = require('fs'); const os = require('os'); const path = require('path'); -const { preparePublicRelease, validateReleaseTag } = require('./prepare-public-release'); +const { preparePublicRelease } = require('./prepare-public-release'); +const { validateAlphaReleaseTag } = require('./release-identity'); const { validateManifest, validateNotes } = require('./check-public-release'); const { validateWorkflowText } = require('./check-release-workflow'); @@ -63,11 +64,11 @@ function writeFixture(rootDir) { async function main() { const invalidTags = ['v0.1.0', '0.1.0-alpha.3', 'v0.2.0-alpha.1', 'v0.1.0-beta.1', 'v0.1.0-alpha.0', 'v0.1.0-alpha.-1']; - invalidTags.forEach((tag) => expectFailure(() => validateReleaseTag(tag, '0.1.0', ['v0.1.0-alpha.1', 'v0.1.0-alpha.2']), tag)); - expectFailure(() => validateReleaseTag('v0.1.0-alpha.2', '0.1.0', ['v0.1.0-alpha.1', 'v0.1.0-alpha.2']), 'existing tag'); - expectFailure(() => validateReleaseTag('v0.1.0-alpha.1', '0.1.0', ['v0.1.0-alpha.1', 'v0.1.0-alpha.2']), 'older alpha'); - expectFailure(() => validateReleaseTag('v0.1.0-alpha.2', '0.1.0', ['v0.1.0-alpha.1', 'v0.1.0-alpha.2']), 'equal alpha'); - assert(validateReleaseTag('v0.1.0-alpha.3', '0.1.0', ['v0.1.0-alpha.1', 'v0.1.0-alpha.2']).suffix === 3, 'valid next alpha was rejected'); + invalidTags.forEach((tag) => expectFailure(() => validateAlphaReleaseTag(tag, '0.1.0', ['v0.1.0-alpha.1', 'v0.1.0-alpha.2']), tag)); + expectFailure(() => validateAlphaReleaseTag('v0.1.0-alpha.2', '0.1.0', ['v0.1.0-alpha.1', 'v0.1.0-alpha.2']), 'existing tag'); + expectFailure(() => validateAlphaReleaseTag('v0.1.0-alpha.1', '0.1.0', ['v0.1.0-alpha.1', 'v0.1.0-alpha.2']), 'older alpha'); + expectFailure(() => validateAlphaReleaseTag('v0.1.0-alpha.2', '0.1.0', ['v0.1.0-alpha.1', 'v0.1.0-alpha.2']), 'equal alpha'); + assert(validateAlphaReleaseTag('v0.1.0-alpha.3', '0.1.0', ['v0.1.0-alpha.1', 'v0.1.0-alpha.2']).suffix === 3, 'valid next alpha was rejected'); const fixtureRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'scriptcut-public-release-')); try { diff --git a/scripts/smoke-release-identity.js b/scripts/smoke-release-identity.js new file mode 100644 index 0000000..e2e4687 --- /dev/null +++ b/scripts/smoke-release-identity.js @@ -0,0 +1,87 @@ +#!/usr/bin/env node + +const fs = require('fs'); +const path = require('path'); +const { + formatAlphaReleaseTag, + formatCandidateArtifactFilename, + formatPublicArtifactFilename, + parseAlphaReleaseTag, + readProductVersion, + validateAlphaReleaseTag, +} = require('./release-identity'); + +const root = path.join(__dirname, '..'); + +function fail(message) { + throw new Error(`Release identity smoke failed: ${message}`); +} + +function assert(condition, message) { + if (!condition) fail(message); +} + +function expectFailure(callback, label) { + try { + callback(); + } catch (_error) { + return; + } + fail(`${label} was accepted`); +} + +function readJson(relativePath) { + return JSON.parse(fs.readFileSync(path.join(root, relativePath), 'utf8')); +} + +function lockRootVersion(relativePath) { + const lock = readJson(relativePath); + return lock.packages?.['']?.version || lock.version; +} + +function main() { + const productVersion = readProductVersion(); + assert(productVersion === '0.1.0', 'current productVersion must remain 0.1.0'); + assert(readJson('frontend/package.json').version === productVersion, 'frontend package version drifted from productVersion'); + assert(lockRootVersion('package-lock.json') === productVersion, 'root lockfile metadata drifted from productVersion'); + assert(lockRootVersion('frontend/package-lock.json') === productVersion, 'frontend lockfile metadata drifted from productVersion'); + + const rendererSource = fs.readFileSync(path.join(root, 'frontend', 'src', 'utils', 'releaseInfo.ts'), 'utf8'); + const settingsSource = fs.readFileSync(path.join(root, 'frontend', 'src', 'components', 'SettingsPanel.tsx'), 'utf8'); + const homeSource = fs.readFileSync(path.join(root, 'frontend', 'src', 'components', 'HomeScreen.tsx'), 'utf8'); + const viteSource = fs.readFileSync(path.join(root, 'frontend', 'vite.config.ts'), 'utf8'); + assert(/SCRIPTCUT_PRODUCT_VERSION\s*=\s*__SCRIPTCUT_PRODUCT_VERSION__/.test(rendererSource), 'renderer product version is not sourced from the Vite identity define'); + assert(/readProductVersion/.test(viteSource) && /__SCRIPTCUT_PRODUCT_VERSION__/.test(viteSource), 'Vite does not source the renderer identity from root package metadata'); + assert(!/SCRIPTCUT_VERSION|0\.1\.0-alpha\.(?:2|3)/.test(`${rendererSource}\n${settingsSource}`), 'renderer contains a stale prerelease version hardcode'); + assert(/RELEASE_LINKS\.releases/.test(`${rendererSource}\n${settingsSource}\n${homeSource}`), 'renderer does not use the GitHub Releases feed'); + assert(!/releases\/latest/.test(`${rendererSource}\n${settingsSource}\n${homeSource}`), 'renderer still labels releases/latest as current'); + + const releaseTag = formatAlphaReleaseTag(productVersion, 3); + const parsed = parseAlphaReleaseTag(releaseTag, productVersion); + assert(parsed?.productVersion === productVersion && parsed.iteration === 3 && parsed.channel === 'alpha', 'alpha parser did not round-trip the canonical tag'); + assert(formatAlphaReleaseTag(productVersion, parsed.iteration) === releaseTag, 'alpha formatter did not round-trip the parsed tag'); + assert(formatPublicArtifactFilename(releaseTag, productVersion, 'arm64') === 'ScriptCut-v0.1.0-alpha.3-arm64.dmg', 'public artifact naming did not use the validated releaseTag'); + assert(formatCandidateArtifactFilename(productVersion, 'arm64') === 'ScriptCut-0.1.0-arm64.dmg', 'candidate artifact naming did not use productVersion'); + + expectFailure(() => validateAlphaReleaseTag('v0.1.1-alpha.1', productVersion), 'wrong product version tag'); + expectFailure(() => validateAlphaReleaseTag('v0.1.0-alpha.0', productVersion), 'alpha.0'); + expectFailure(() => validateAlphaReleaseTag('v0.1.0-beta.1', productVersion), 'beta tag'); + expectFailure(() => validateAlphaReleaseTag('v0.1.0-alpha.2', productVersion, ['v0.1.0-alpha.1', 'v0.1.0-alpha.2']), 'existing alpha tag'); + expectFailure(() => validateAlphaReleaseTag('v0.1.0-alpha.2', productVersion, ['v0.1.0-alpha.3']), 'non-monotonic alpha tag'); + assert(validateAlphaReleaseTag(releaseTag, productVersion, ['v0.1.0-alpha.1', 'v0.1.0-alpha.2']).iteration === 3, 'monotonic alpha validation rejected the next iteration'); + + const publicSource = fs.readFileSync(path.join(root, 'scripts', 'prepare-public-release.js'), 'utf8'); + const candidateSource = fs.readFileSync(path.join(root, 'scripts', 'release-alpha.js'), 'utf8'); + assert(/validateAlphaReleaseTag/.test(publicSource) && /formatPublicArtifactFilename\(tagInfo\.releaseTag/.test(publicSource), 'public preparation is not consuming the centralized identity contract'); + assert(/channel:\s*'internal-release-candidate'/.test(candidateSource) && /tagCandidate:\s*null/.test(candidateSource) && /tagExists:\s*false/.test(candidateSource), 'candidate identity does not remain internal and untagged'); + assert(!/releaseTag\s*:/.test(candidateSource), 'candidate machinery claims a public releaseTag'); + + console.log(`Canonical release identity smoke passed: productVersion=${productVersion}, publicExample=${releaseTag}, channel=alpha-only`); +} + +try { + main(); +} catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); +} diff --git a/scripts/smoke-release-metadata.js b/scripts/smoke-release-metadata.js index f991638..9c7a581 100644 --- a/scripts/smoke-release-metadata.js +++ b/scripts/smoke-release-metadata.js @@ -5,6 +5,7 @@ const fs = require('fs'); const os = require('os'); const path = require('path'); const { checksumFile } = require('./release-alpha'); +const { formatCandidateArtifactFilename, readProductVersion } = require('./release-identity'); const root = path.join(__dirname, '..'); @@ -55,10 +56,13 @@ async function main() { const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); const notes = fs.readFileSync(notesPath, 'utf8'); + const productVersion = readProductVersion(); assert(manifest.schema === 'scriptcut.release.v1', 'schema must be scriptcut.release.v1'); + assert(manifest.version === productVersion, 'candidate manifest version must be the canonical productVersion'); assert(manifest.platform === 'darwin' && manifest.architecture === 'arm64', 'release target must be darwin arm64'); assert(/^[0-9a-f]{40}$/.test(manifest.commit), 'commit must be a full SHA-1'); assert(manifest.tagCandidate === null && manifest.tagExists === false, 'metadata must not pretend a tag exists'); + assert(manifest.artifact?.filename === formatCandidateArtifactFilename(productVersion, 'arm64'), 'candidate artifact must use productVersion naming'); assert(manifest.signed === false && manifest.notarized === false, 'candidate trust state must be false'); 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'); From a2f65799a7da3e97bf2e8b03542b80399fb8fecb Mon Sep 17 00:00:00 2001 From: FernandoM33 Date: Thu, 13 Aug 2026 13:03:42 -0700 Subject: [PATCH 2/2] chore: clean up phase 4b task artifacts --- docs/RELEASE.md | 6 +++--- docs/agent/COMMANDS.md | 19 ------------------- docs/agent/DECISIONS.md | 8 -------- docs/agent/ISSUE-TRACKER.md | 5 ----- docs/agent/PROJECT.md | 20 -------------------- scripts/smoke-release-identity.js | 1 - 6 files changed, 3 insertions(+), 56 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.md b/docs/RELEASE.md index 6ef4710..cadc742 100644 --- a/docs/RELEASE.md +++ b/docs/RELEASE.md @@ -4,7 +4,7 @@ This guide is for preparing a desktop release from the repository. ## Current Release Status -Phase 4B establishes the explicit release 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: +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 @@ -16,7 +16,7 @@ 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 in this implementation is alpha only; the current public prerelease is `v0.1.0-alpha.3`. +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: @@ -24,7 +24,7 @@ The installed application and the public release are intentionally separate iden - The GitHub tag and public DMG filename represent `releaseTag` (`v0.1.0-alpha.` and `ScriptCut-v0.1.0-alpha.-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; Phase 4B does not add a new build counter. Beta, RC, and stable validators/publication are not currently supported; their naming can remain future-compatible. +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 diff --git a/docs/agent/COMMANDS.md b/docs/agent/COMMANDS.md deleted file mode 100644 index 805880f..0000000 --- a/docs/agent/COMMANDS.md +++ /dev/null @@ -1,19 +0,0 @@ -# ScriptCut Commands - -Commands are run from `/Users/fm/ScriptCut`. - -| Command | Scope | Status on Phase 4B baseline | -|---|---|---| -| `npm run smoke:release-identity` | canonical package/renderer/tag contract | added by Phase 4B; targeted verification | -| `npm run smoke:release-metadata` | candidate manifest/checksum/provenance | verified passing before edits | -| `npm run smoke:public-release` | public manifest/tag/notes/docs fixtures | verified passing before edits | -| `npm run smoke:release-workflow` | guarded workflow structure/permissions | verified passing before edits | -| `npm run smoke:runtime-contract` | packaged/runtime contract source checks | verified passing before edits | -| `npm run smoke:renderer-policy` | built renderer CSP/asset policy | verified passing before edits | -| `npm run lint` | frontend ESLint | verified passing before edits | -| `npm run build:frontend` | TypeScript/Vite renderer build | required after source edits | -| `npm run smoke:backend` | backend Python smoke suite | required after source edits | -| `python -m compileall -q backend` | Python syntax compilation | required after source edits | -| `npm run release:rc:arm64` | native arm64 candidate; packages and runs release gates | Phase 4B candidate evidence; `real_model=false` | - -The native candidate requires macOS arm64, packaged runtime inputs, and local release build dependencies. It is configured for `--publish never`; Phase 4B does not publish, tag, or merge. diff --git a/docs/agent/DECISIONS.md b/docs/agent/DECISIONS.md deleted file mode 100644 index 78727f9..0000000 --- a/docs/agent/DECISIONS.md +++ /dev/null @@ -1,8 +0,0 @@ -# ScriptCut Decisions - -- Preserve the split between `productVersion` and public alpha `releaseTag`; do not append prerelease strings to package versions. -- Keep the public release channel alpha-only for this phase. Beta, RC, and stable lifecycle work belongs to later release phases. -- Source renderer identity comes from the root `package.json` through Vite build-time substitution; it must not fetch GitHub or parse package metadata at runtime. -- Candidate metadata remains internal and untagged. Public artifact names derive from a validated alpha release tag. -- `CFBundleShortVersionString` must equal `productVersion`. Do not invent a new `CFBundleVersion` counter until native evidence demonstrates a concrete consumer need. -- Do not change public release workflow permissions, publication gates, signing strategy, or product functionality in Phase 4B. diff --git a/docs/agent/ISSUE-TRACKER.md b/docs/agent/ISSUE-TRACKER.md deleted file mode 100644 index 00fae84..0000000 --- a/docs/agent/ISSUE-TRACKER.md +++ /dev/null @@ -1,5 +0,0 @@ -# Issue Tracker Context - -No repository-local issue tracker or issue ID was supplied for this task. Delivery is governed by the attached Phase 4B release identity contract and the requested branch/draft-PR boundary. - -GitHub delivery convention for this task: branch `release/release-identity-contract`, one draft PR, no merge, no tag, and no release publication. diff --git a/docs/agent/PROJECT.md b/docs/agent/PROJECT.md deleted file mode 100644 index 27ab5f2..0000000 --- a/docs/agent/PROJECT.md +++ /dev/null @@ -1,20 +0,0 @@ -# ScriptCut Engineering Context - -ScriptCut is an Electron desktop video editor with a React/Vite renderer and a FastAPI local backend. The creator workflow is local-first: the Electron main process starts the backend, exposes a narrow context-isolated preload API, and packages bundled runtime resources for native macOS candidates. - -## Architecture map - -- `electron/`: main process, preload bridge, backend startup, runtime contract. -- `frontend/`: React/Vite renderer and user-facing editor/support UI. -- `backend/`: local FastAPI services and Python smoke tests. -- `scripts/`: packaging, provenance, release metadata, and smoke gates. -- `.github/workflows/`: normal CI plus guarded native arm64 release workflow. -- `docs/`: product, installation, QA, and release contracts. - -## Trust boundaries - -Renderer code is sandboxed with context isolation, no Node integration, and a constrained preload bridge. Release publication is workflow-dispatch-only and separate from candidate builds. Do not place credentials in manifests or broaden candidate work into publication/Production without explicit authorization. - -## Release identity terms - -`productVersion` is the core package/app version (`0.1.0`). The public alpha `releaseTag` is derived separately (`v0.1.0-alpha.N`). Candidate manifests intentionally have no public tag. diff --git a/scripts/smoke-release-identity.js b/scripts/smoke-release-identity.js index e2e4687..ab6ae6c 100644 --- a/scripts/smoke-release-identity.js +++ b/scripts/smoke-release-identity.js @@ -41,7 +41,6 @@ function lockRootVersion(relativePath) { function main() { const productVersion = readProductVersion(); - assert(productVersion === '0.1.0', 'current productVersion must remain 0.1.0'); assert(readJson('frontend/package.json').version === productVersion, 'frontend package version drifted from productVersion'); assert(lockRootVersion('package-lock.json') === productVersion, 'root lockfile metadata drifted from productVersion'); assert(lockRootVersion('frontend/package-lock.json') === productVersion, 'frontend lockfile metadata drifted from productVersion');