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..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 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 @@ -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.` 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; 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: 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..ab6ae6c --- /dev/null +++ b/scripts/smoke-release-identity.js @@ -0,0 +1,86 @@ +#!/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(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');