diff --git a/apps/desktop/scripts/desktop-build-provenance.d.mts b/apps/desktop/scripts/desktop-build-provenance.d.mts new file mode 100644 index 0000000000..db6796515a --- /dev/null +++ b/apps/desktop/scripts/desktop-build-provenance.d.mts @@ -0,0 +1,32 @@ +/** Commit a build came from, and whether its checkout carried uncommitted changes. */ +export interface DesktopBuildProvenance { + readonly commit: string + readonly dirty: boolean +} + +/** Environment variable that carries the packaged commit. */ +export const DESKTOP_BUILD_COMMIT_ENV: 'DSH_DESKTOP_BUILD_COMMIT' + +/** Environment variable that records whether the packaged checkout had uncommitted changes. */ +export const DESKTOP_BUILD_DIRTY_ENV: 'DSH_DESKTOP_BUILD_DIRTY' + +/** + * Read the checkout's current commit and whether it carries uncommitted changes. + * @param repositoryRoot - Directory to inspect. + * @returns Provenance of the tree being packaged. + */ +export function readDesktopBuildProvenance(repositoryRoot: string): DesktopBuildProvenance + +/** + * Resolve provenance a parent packaging process recorded. + * @param env - Packaging environment. + * @returns Provenance, or undefined outside a packaging run. + */ +export function resolveDesktopBuildProvenance(env: NodeJS.ProcessEnv): DesktopBuildProvenance | undefined + +/** + * Describe provenance as the environment variables child processes read. + * @param provenance - Provenance to pass down. + * @returns Variables to merge into a child environment. + */ +export function desktopBuildProvenanceEnvironment(provenance: DesktopBuildProvenance): Record diff --git a/apps/desktop/scripts/desktop-build-provenance.mjs b/apps/desktop/scripts/desktop-build-provenance.mjs new file mode 100644 index 0000000000..b2c45a4789 --- /dev/null +++ b/apps/desktop/scripts/desktop-build-provenance.mjs @@ -0,0 +1,58 @@ +/** + * Carry the commit a build came from into its artifacts. + * + * A build that reaches a colleague or a test feed is not reachable from a tag, + * so the only way back to its sources is what the build recorded about itself. + * The packaging entry reads the checkout once and passes the result to every + * child process, because the build tree it hands to electron-builder no longer + * resembles a checkout. + */ + +import { execFileSync } from 'node:child_process' + +/** Environment variable that carries the packaged commit. */ +export const DESKTOP_BUILD_COMMIT_ENV = 'DSH_DESKTOP_BUILD_COMMIT' + +/** Environment variable that records whether the packaged checkout had uncommitted changes. */ +export const DESKTOP_BUILD_DIRTY_ENV = 'DSH_DESKTOP_BUILD_DIRTY' + +/** + * Read the checkout's current commit and whether it carries uncommitted changes. + * @param {string} repositoryRoot - Directory to inspect. + * @returns {{ commit: string, dirty: boolean }} Provenance of the tree being packaged. + */ +export function readDesktopBuildProvenance(repositoryRoot) { + const git = (args) => execFileSync('git', args, { cwd: repositoryRoot, encoding: 'utf8' }).trim() + return { + commit: git(['rev-parse', 'HEAD']), + dirty: git(['status', '--porcelain', '--untracked-files=normal']) !== '', + } +} + +/** + * Resolve provenance a parent packaging process recorded. + * @param {NodeJS.ProcessEnv} env - Packaging environment. + * @returns {{ commit: string, dirty: boolean } | undefined} Provenance, or undefined outside a packaging run. + */ +export function resolveDesktopBuildProvenance(env) { + const commit = env[DESKTOP_BUILD_COMMIT_ENV]?.trim() + if (commit === undefined || commit === '') return undefined + if (!/^[0-9a-f]{40}$/u.test(commit)) throw new Error(`desktop build provenance: ${DESKTOP_BUILD_COMMIT_ENV} must be a commit hash`) + const dirty = env[DESKTOP_BUILD_DIRTY_ENV]?.trim() + if (dirty !== undefined && dirty !== '' && dirty !== '0' && dirty !== '1') { + throw new Error(`desktop build provenance: ${DESKTOP_BUILD_DIRTY_ENV} must be 0 or 1`) + } + return { commit, dirty: dirty === '1' } +} + +/** + * Describe provenance as the environment variables child processes read. + * @param {{ commit: string, dirty: boolean }} provenance - Provenance to pass down. + * @returns {Record} Variables to merge into a child environment. + */ +export function desktopBuildProvenanceEnvironment(provenance) { + return { + [DESKTOP_BUILD_COMMIT_ENV]: provenance.commit, + [DESKTOP_BUILD_DIRTY_ENV]: provenance.dirty ? '1' : '0', + } +} diff --git a/apps/desktop/scripts/desktop-build-version-discovery.ts b/apps/desktop/scripts/desktop-build-version-discovery.ts new file mode 100644 index 0000000000..3db072f39c --- /dev/null +++ b/apps/desktop/scripts/desktop-build-version-discovery.ts @@ -0,0 +1,142 @@ +/** + * Suggest the next build version for a product version, so the sequence number + * is derived rather than remembered. + * + * The convention is `..`. What makes a + * sequence number correct is which ones are already taken, and that is recorded + * where the builds went: the update bucket for a build that was uploaded, and + * the local artifacts directory for one that was only handed over. Asking the + * bucket first is what keeps a test feed's numbering unique across machines; + * a fresh CI checkout has no local artifacts at all, which is why the local + * scan only ever serves as a fallback. + * + * The bucket query is bounded and its failures are not fatal: a build must not + * block on a listing, so an unreachable or unauthorized bucket degrades to the + * local scan rather than failing the run. Concurrent builds can therefore pick + * the same number, which matters for a release and not for a test feed — a + * release publishes the product version itself and never comes through here, + * and a production upload is expected to pass its version explicitly. + */ + +import { readdir } from 'node:fs/promises' +import { parse } from 'semver' +import { desktopTargetBuildPaths } from './desktop-build-paths.mjs' +import { resolveDesktopUploadConfig } from './desktop-auto-update-environment.mjs' +import { createDesktopCos, DESKTOP_COS_REGION } from './desktop-cos.ts' +import { validateDesktopBuildVersion } from './desktop-build-version.mjs' +import type { DesktopPackageTargetName } from './package-target.ts' + +/** How long a bucket listing may take before the suggestion falls back to local artifacts. */ +const LISTING_TIMEOUT_MS = 8_000 + +/** Inputs that decide which versions are already taken. */ +export interface DesktopBuildVersionSuggestionOptions { + readonly productVersion: string + readonly target: DesktopPackageTargetName + readonly environment: NodeJS.ProcessEnv + /** Date segment to number within; defaults to today where the build runs. */ + readonly date?: string + readonly artifactsRoot?: string +} + +/** + * Format a date as the convention's segment. + * @param date - Date to format. + * @returns The date as `YYYYMMDD` in the build host's own time zone. + */ +export function desktopBuildDateSegment(date: Date = new Date()): string { + const month = String(date.getMonth() + 1).padStart(2, '0') + return `${String(date.getFullYear())}${month}${String(date.getDate()).padStart(2, '0')}` +} + +/** + * Read the sequence numbers already used for one product version and date. + * @param versions - Versions found in a bucket or directory. + * @param prefix - `..` that a numbered build starts with. + * @returns Every sequence number present, unordered. + */ +function sequenceNumbers(versions: Iterable, prefix: string): number[] { + const numbers: number[] = [] + for (const version of versions) { + if (!version.startsWith(prefix)) continue + const sequence = version.slice(prefix.length) + if (/^\d+$/u.test(sequence)) numbers.push(Number(sequence)) + } + return numbers +} + +/** + * Read the versions one artifacts directory already holds. + * @param artifactsRoot - Directory electron-builder wrote installers into. + * @returns Versions parsed from artifact names. + */ +async function localVersions(artifactsRoot: string): Promise { + const entries = await readdir(artifactsRoot).catch(() => []) + const versions: string[] = [] + for (const entry of entries) { + const named = /^deepseek-harness-(?.+)-(?:mac|win)-(?:arm64|x64)\.(?:exe|dmg|zip)$/u.exec(entry) + const version = named?.groups?.version + if (version !== undefined && parse(version) !== null) versions.push(version) + } + return versions +} + +/** + * Read the versions one update bucket already publishes for a target. + * @param options - Product version, target, and environment naming the bucket. + * @returns Versions parsed from object names, or undefined when the bucket cannot be listed in time. + */ +async function remoteVersions(options: DesktopBuildVersionSuggestionOptions): Promise { + let update + try { + const target = options.target === 'win-x64' ? { platform: 'win32' as const, arch: 'x64' } : { platform: 'darwin' as const, arch: options.target === 'mac-arm64' ? 'arm64' : 'x64' } + update = resolveDesktopUploadConfig(options.environment, target.platform, target.arch) + } + catch { + // Without a configured destination there is nothing to be unique against. + return undefined + } + const secretId = options.environment[update.secretIdEnvName]?.trim() + const secretKey = options.environment[update.secretKeyEnvName]?.trim() + if (secretId === undefined || secretId === '' || secretKey === undefined || secretKey === '') return undefined + const cos = createDesktopCos({ secretId, secretKey }) + const listing = new Promise((resolveListing, rejectListing) => { + cos.getBucket({ + Bucket: update.bucket, + Region: DESKTOP_COS_REGION, + Prefix: `${update.binaryKeyPrefix}/deepseek-harness-`, + MaxKeys: 1000, + }, (error, data) => { + if (error !== null && error !== undefined) rejectListing(error instanceof Error ? error : new Error(String(error))) + else resolveListing((data?.Contents ?? []).map(object => object.Key)) + }) + }) + const timeout = new Promise((resolveTimeout) => { setTimeout(() => resolveTimeout(undefined), LISTING_TIMEOUT_MS).unref() }) + const keys = await Promise.race([listing, timeout]).catch(() => undefined) + if (keys === undefined) return undefined + const versions: string[] = [] + for (const key of keys) { + const named = /deepseek-harness-(?.+)-(?:mac|win)-(?:arm64|x64)\.(?:exe|dmg|zip)$/u.exec(key) + const version = named?.groups?.version + if (version !== undefined && parse(version) !== null) versions.push(version) + } + return versions +} + +/** + * Suggest the next build version for today, numbering after what is already taken. + * @param options - Product version, target, and environment to search. + * @returns A validated build version whose sequence number is free. + */ +export async function suggestDesktopBuildVersion(options: DesktopBuildVersionSuggestionOptions): Promise { + const date = options.date ?? desktopBuildDateSegment() + const prefix = `${options.productVersion}.${date}.` + const remote = await remoteVersions(options) + const taken = remote ?? await localVersions(options.artifactsRoot ?? desktopTargetBuildPaths(options.target).artifacts) + const used = sequenceNumbers(taken, prefix) + const next = used.length === 0 ? 1 : Math.max(...used) + 1 + const suggestion = validateDesktopBuildVersion(`${prefix}${String(next)}`, options.productVersion) + process.stdout.write(`desktop package: numbering ${suggestion} after ${String(used.length)} ${ + remote === undefined ? 'local artifact' : 'published build'}${used.length === 1 ? '' : 's'} for ${prefix}*\n`) + return suggestion +} diff --git a/apps/desktop/scripts/desktop-build-version.d.mts b/apps/desktop/scripts/desktop-build-version.d.mts new file mode 100644 index 0000000000..f924075dd7 --- /dev/null +++ b/apps/desktop/scripts/desktop-build-version.d.mts @@ -0,0 +1,18 @@ +/** Environment variable that carries the build identifier through one packaging and upload run. */ +export const DESKTOP_BUILD_VERSION_ENV: 'DSH_DESKTOP_BUILD_VERSION' + +/** + * Validate a build identifier against the product version it must extend. + * @param buildVersion - Build identifier to publish under. + * @param productVersion - Version the manifests declare. + * @returns The identifier as semver normalizes it, which is what the artifacts will carry. + */ +export function validateDesktopBuildVersion(buildVersion: string, productVersion: string): string + +/** + * Resolve the version a build publishes. + * @param env - Packaging or upload environment. + * @param productVersion - Version the manifests declare. + * @returns The build identifier when one is present, otherwise the product version. + */ +export function resolveDesktopBuildVersion(env: NodeJS.ProcessEnv, productVersion: string): string diff --git a/apps/desktop/scripts/desktop-build-version.mjs b/apps/desktop/scripts/desktop-build-version.mjs new file mode 100644 index 0000000000..e828ca312f --- /dev/null +++ b/apps/desktop/scripts/desktop-build-version.mjs @@ -0,0 +1,79 @@ +/** + * Resolve the version one build publishes, which is not always the version the + * repository declares. + * + * A release publishes the version in the manifests, aligned with the `dsh` npm + * package. Test and hand-delivered builds publish a build identifier that + * extends it with dot-separated segments, conventionally a date and a sequence + * number, so one update feed can carry several builds of a single product + * version. Passing that identifier here keeps it out of the manifests: the + * tracked version stays the product's, and the build identifier reaches + * electron-builder, the update feed, and the upload validation as one input. + * + * `electron-updater` compares feed versions with `semver.gt` against the + * installed `app.getVersion()`, so validation here uses the same library: an + * identifier must be a version that library accepts, and must outrank the + * product version it extends. Semver gives a prerelease lower precedence than + * its release and orders prerelease identifiers field by field, so + * `0.1.6-alpha.2.20260921.1` outranks `0.1.6-alpha.2` while `0.1.6.20260921.1` + * is not a version at all. An identifier therefore only extends a product + * version that already carries a prerelease. + */ + +import { gt, parse } from 'semver' + +/** Environment variable that carries the build identifier through one packaging and upload run. */ +export const DESKTOP_BUILD_VERSION_ENV = 'DSH_DESKTOP_BUILD_VERSION' + +/** + * Parse a version the updater would accept. + * @param {string} version - Version to read. + * @param {string} label - Description used in failures. + * @returns {import('semver').SemVer} The parsed version. + */ +function parseVersion(version, label) { + const parsed = parse(version, { loose: false }) + if (parsed === null) throw new Error(`desktop build version: ${label} ${version} is not a version`) + if (parsed.build.length > 0) { + // Build metadata does not participate in precedence, so two builds would compare equal to the updater. + throw new Error(`desktop build version: ${label} ${version} cannot carry build metadata`) + } + return parsed +} + +/** + * Validate a build identifier against the product version it must extend. + * @param {string} buildVersion - Build identifier to publish under. + * @param {string} productVersion - Version the manifests declare. + * @returns {string} The identifier as semver normalizes it, which is what the artifacts will carry. + */ +export function validateDesktopBuildVersion(buildVersion, productVersion) { + const identifier = parseVersion(buildVersion, 'build identifier') + const product = parseVersion(productVersion, 'product version') + if (identifier.version === product.version) return identifier.version + if (identifier.compareMain(product) !== 0) { + throw new Error(`desktop build version: ${buildVersion} must extend product version ${productVersion}`) + } + if (product.prerelease.length === 0) { + // On a stable version an identifier ranks below every prerelease of the next release, so no update would offer it. + throw new Error(`desktop build version: ${productVersion} carries no prerelease, so ${buildVersion} cannot extend it`) + } + const extendsProduct = identifier.prerelease.length > product.prerelease.length + && product.prerelease.every((field, index) => identifier.prerelease[index] === field) + if (!extendsProduct || !gt(identifier, product)) { + throw new Error(`desktop build version: ${buildVersion} must extend prerelease ${product.prerelease.join('.')} of ${productVersion}`) + } + return identifier.version +} + +/** + * Resolve the version a build publishes. + * @param {NodeJS.ProcessEnv} env - Packaging or upload environment. + * @param {string} productVersion - Version the manifests declare. + * @returns {string} The build identifier when one is present, otherwise the product version. + */ +export function resolveDesktopBuildVersion(env, productVersion) { + const buildVersion = env[DESKTOP_BUILD_VERSION_ENV]?.trim() + if (buildVersion === undefined || buildVersion === '') return productVersion + return validateDesktopBuildVersion(buildVersion, productVersion) +} diff --git a/apps/desktop/scripts/desktop-release-tag.ts b/apps/desktop/scripts/desktop-release-tag.ts new file mode 100644 index 0000000000..b5066290dc --- /dev/null +++ b/apps/desktop/scripts/desktop-release-tag.ts @@ -0,0 +1,67 @@ +/** + * Record a production release in git after it reaches the download domain. + * + * A build that only goes to a colleague or a test feed carries its commit + * inside the artifact, which is enough to trace it. A build that external users + * install should also be reachable from the repository, so a production upload + * tags the commit it was packaged from. The tag is written after the upload + * succeeds: a tag for a release that never shipped would be worse than none. + * + * Tagging never fails an upload that already completed. The artifacts are + * public by then, and nothing about a missing tag can be repaired by reporting + * the upload as failed; the operator is told what to run instead. + */ + +import { execFileSync } from 'node:child_process' + +/** What tagging one uploaded release did, or why it did nothing. */ +export interface DesktopReleaseTagResult { + readonly tag: string + readonly status: 'created' | 'present' | 'failed' + readonly detail?: string +} + +/** + * Name the tag one published version carries. + * @param version - Version the release published. + * @returns The tag name, distinct from the `dsh-v*` tags the npm family publishes from. + */ +export function desktopReleaseTag(version: string): string { + return `desktop-v${version}` +} + +/** + * Tag and push the commit a production release was packaged from. + * @param options - Published version, packaged commit, repository, and remote. + * @returns What tagging did; a failure is reported rather than thrown. + */ +export function tagDesktopRelease(options: { + readonly version: string + readonly commit: string + readonly repositoryRoot: string + readonly remote?: string + readonly run?: (command: string, args: readonly string[]) => string +}): DesktopReleaseTagResult { + const tag = desktopReleaseTag(options.version) + const remote = options.remote ?? 'origin' + const run = options.run + ?? ((command: string, args: readonly string[]) => + execFileSync(command, [...args], { cwd: options.repositoryRoot, encoding: 'utf8' }).trim()) + try { + const existing = run('git', ['tag', '--list', tag]) + if (existing !== '') { + const tagged = run('git', ['rev-list', '-n', '1', tag]) + if (tagged !== options.commit) { + return { tag, status: 'failed', detail: `${tag} already names ${tagged}, not the uploaded commit ${options.commit}` } + } + run('git', ['push', remote, tag]) + return { tag, status: 'present' } + } + run('git', ['tag', tag, options.commit]) + run('git', ['push', remote, tag]) + return { tag, status: 'created' } + } + catch (error) { + return { tag, status: 'failed', detail: error instanceof Error ? error.message : String(error) } + } +} diff --git a/apps/desktop/scripts/desktop-toolchain-preflight.ts b/apps/desktop/scripts/desktop-toolchain-preflight.ts new file mode 100644 index 0000000000..812e7b1828 --- /dev/null +++ b/apps/desktop/scripts/desktop-toolchain-preflight.ts @@ -0,0 +1,102 @@ +/** + * Probe the external tools a packaging run will shell out to, before it starts. + * + * The packaging sequence reaches its first archive step minutes in, and its + * first installer step later still, so a tool that is absent or behaves + * differently than the scripts assume wastes a whole run. Each probe here + * exercises the specific behaviour the run depends on rather than merely + * checking that the executable resolves. + */ + +import { execFile } from 'node:child_process' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { basename, dirname, join } from 'node:path' +import { promisify } from 'node:util' + +const run = promisify(execFile) + +/** One tool the packaging run requires, and what went wrong with it. */ +export interface DesktopToolchainProbeFailure { + readonly tool: string + readonly detail: string +} + +async function probeTar(): Promise { + // GNU tar reads the colon in an absolute Windows path as a remote-host separator, which the + // release scripts avoid by running beside the archive; this probe confirms the archive reader works there. + const directory = await mkdtemp(join(tmpdir(), 'dsh-tar-probe-')) + try { + const member = join(directory, 'probe.txt') + await writeFile(member, 'probe\n') + const archive = join(directory, 'probe.tar.gz') + await run('tar', ['-czf', basename(archive), basename(member)], { cwd: directory, windowsHide: true, timeout: 20_000 }) + const listed = await run('tar', ['-tzf', basename(archive)], { cwd: dirname(archive), windowsHide: true, timeout: 20_000 }) + if (!listed.stdout.includes('probe.txt')) return `tar listed ${JSON.stringify(listed.stdout)} instead of the archived member` + return undefined + } + catch (error) { + return error instanceof Error ? error.message : String(error) + } + finally { + await rm(directory, { recursive: true, force: true }) + } +} + +async function probeWindowsInstallerToolchain(environment: NodeJS.ProcessEnv): Promise { + const failures: DesktopToolchainProbeFailure[] = [] + const programFiles = environment['ProgramFiles(x86)'] + const vswhere = programFiles === undefined + ? undefined + : join(programFiles, 'Microsoft Visual Studio/Installer/vswhere.exe') + if (vswhere === undefined) { + failures.push({ tool: 'vswhere', detail: 'ProgramFiles(x86) is not set, so Visual Studio cannot be located' }) + } + else { + try { + const located = await run(vswhere, ['-latest', '-products', '*', + '-requires', 'Microsoft.VisualStudio.Component.VC.Tools.x86.x64', '-property', 'installationPath'], + { windowsHide: true, timeout: 30_000 }) + if (located.stdout.trim() === '') { + failures.push({ tool: 'vswhere', detail: 'Visual Studio C++ build tools are not installed, so the installer helper cannot compile' }) + } + } + catch (error) { + failures.push({ tool: 'vswhere', detail: error instanceof Error ? error.message : String(error) }) + } + } + return failures +} + +/** + * Probe every external tool one packaging run needs. + * @param platform - Target platform, which decides the installer toolchain probes. + * @param environment - Packaging environment used to locate Windows tooling. + * @returns Every probe that failed, empty when the host can run the packaging sequence. + */ +export async function probeDesktopToolchain( + platform: 'darwin' | 'win32', + environment: NodeJS.ProcessEnv = process.env, +): Promise { + const failures: DesktopToolchainProbeFailure[] = [] + const tar = await probeTar() + if (tar !== undefined) failures.push({ tool: 'tar', detail: tar }) + if (platform === 'win32') failures.push(...await probeWindowsInstallerToolchain(environment)) + return failures +} + +/** + * Probe the toolchain and fail with every problem the host has. + * @param platform - Target platform, which decides the installer toolchain probes. + * @param environment - Packaging environment used to locate Windows tooling. + * @returns Resolves when every probe passes. + */ +export async function requireDesktopToolchain( + platform: 'darwin' | 'win32', + environment: NodeJS.ProcessEnv = process.env, +): Promise { + const failures = await probeDesktopToolchain(platform, environment) + if (failures.length === 0) return + throw new Error(`desktop package: the build host cannot run this packaging sequence:\n${ + failures.map(failure => ` ${failure.tool}: ${failure.detail}`).join('\n')}`) +} diff --git a/apps/desktop/scripts/desktop-upload-plan.ts b/apps/desktop/scripts/desktop-upload-plan.ts index 32f891dcc8..e21439f015 100644 --- a/apps/desktop/scripts/desktop-upload-plan.ts +++ b/apps/desktop/scripts/desktop-upload-plan.ts @@ -13,6 +13,7 @@ import { resolveDesktopUploadConfig, } from './desktop-auto-update-environment.mjs' import { desktopTargetBuildPaths } from './desktop-build-paths.mjs' +import { resolveDesktopBuildVersion } from './desktop-build-version.mjs' const APP_ROOT = resolve(import.meta.dirname, '..') const REPOSITORY_ROOT = resolve(APP_ROOT, '..', '..') @@ -47,6 +48,10 @@ export interface DesktopUploadPlan { readonly secretIdEnvName: string readonly secretKeyEnvName: string readonly artifacts: readonly DesktopUploadArtifact[] + /** Commit the artifacts were packaged from, absent for a package built before builds recorded it. */ + readonly commit?: string + /** Whether that checkout carried uncommitted changes. */ + readonly dirty?: boolean } /** Filesystem and environment inputs used to validate one upload. */ @@ -185,6 +190,8 @@ export async function createDesktopUploadPlan( if (dshVersion !== desktopVersion) { throw new Error(`desktop upload: desktop version ${desktopVersion} does not match current dsh version ${dshVersion}`) } + // A test or hand-delivered build publishes an identifier extending the product version; a release publishes the version itself. + const buildVersion = resolveDesktopBuildVersion(environment, dshVersion) const update = resolveDesktopUploadConfig(environment, target.platform, target.arch) const buildRecord = await jsonFile( @@ -193,13 +200,13 @@ export async function createDesktopUploadPlan( ) if (buildRecord.schemaVersion !== 1 || buildRecord.target !== targetName - || buildRecord.version !== dshVersion + || buildRecord.version !== buildVersion || buildRecord.environment !== update.environment || buildRecord.publicUrl !== update.publicUrl) { - throw new Error(`desktop upload: ${targetName} package completion record does not match dsh ${dshVersion} and ${update.environment} update destination`) + throw new Error(`desktop upload: ${targetName} package completion record does not match ${buildVersion} and ${update.environment} update destination`) } - const metadataFilename = desktopUpdateMetadataFilename(dshVersion, target.platform) + const metadataFilename = desktopUpdateMetadataFilename(buildVersion, target.platform) const metadataPath = join(artifactsRoot, metadataFilename) let metadataValue: unknown try { @@ -210,14 +217,14 @@ export async function createDesktopUploadPlan( } const metadata = object(metadataValue, metadataFilename) const metadataVersion = stringField(metadata.version, `${metadataFilename}.version`) - if (metadataVersion !== dshVersion) { - throw new Error(`desktop upload: ${metadataFilename} version ${metadataVersion} does not match current dsh version ${dshVersion}`) + if (metadataVersion !== buildVersion) { + throw new Error(`desktop upload: ${metadataFilename} version ${metadataVersion} does not match published version ${buildVersion}`) } if (!Array.isArray(metadata.files) || metadata.files.length !== 1) { throw new Error(`desktop upload: ${metadataFilename}.files must contain exactly one target update file`) } - const base = `deepseek-harness-${dshVersion}-${target.os}-${target.arch}` + const base = `deepseek-harness-${buildVersion}-${target.os}-${target.arch}` const updaterExtension = target.platform === 'darwin' ? 'zip' : 'exe' const updaterInfo = updateFileInfo(metadata.files[0], `${metadataFilename}.files[0]`, `${base}.${updaterExtension}`) const updaterPath = await verifyChecksummedArtifact(artifactsRoot, updaterInfo) @@ -254,18 +261,20 @@ export async function createDesktopUploadPlan( contents: dump(published), } artifacts.push(channelArtifact) - if (prerelease(dshVersion) === null) { + if (prerelease(buildVersion) === null) { const stableFilename = metadataFilename.replace('nightly', 'latest') artifacts.push({ ...channelArtifact, filename: stableFilename, key: `${update.keyPrefix}/${stableFilename}` }) } return { environment: update.environment, target: targetName, - version: dshVersion, + version: buildVersion, publicUrl: update.publicUrl, bucket: update.bucket, secretIdEnvName: update.secretIdEnvName, secretKeyEnvName: update.secretKeyEnvName, artifacts, + ...typeof buildRecord.commit === 'string' ? { commit: buildRecord.commit } : {}, + ...typeof buildRecord.dirty === 'boolean' ? { dirty: buildRecord.dirty } : {}, } } diff --git a/apps/desktop/scripts/electron-builder-config.mjs b/apps/desktop/scripts/electron-builder-config.mjs index dd4b00d24c..6ed1da2c14 100644 --- a/apps/desktop/scripts/electron-builder-config.mjs +++ b/apps/desktop/scripts/electron-builder-config.mjs @@ -1,4 +1,5 @@ import { X509Certificate } from 'node:crypto' +import { readFileSync } from 'node:fs' import { readFile } from 'node:fs/promises' import { join } from 'node:path' import { fileURLToPath } from 'node:url' @@ -18,6 +19,8 @@ import { scrubWindowsSigningEnvironment, } from './windows-sign.mjs' import { resolveDesktopAutoUpdateConfig } from './desktop-auto-update-environment.mjs' +import { resolveDesktopBuildProvenance } from './desktop-build-provenance.mjs' +import { resolveDesktopBuildVersion } from './desktop-build-version.mjs' import { resolveDesktopPolicyEnvironment } from './desktop-policy-environment.mjs' import { desktopTargetBuildPaths, resolveDesktopBuildTarget } from './desktop-build-paths.mjs' import { installWindowsDirectoryInstaller } from './windows-directory-installer.mjs' @@ -85,9 +88,19 @@ export function createElectronBuilderConfig( } const update = unsigned ? undefined : resolveDesktopAutoUpdateConfig(env, resolvedPlatform, resolvedArch) if (preparedRuntime !== undefined) buildPaths.dsh = preparedRuntime + // electron-builder merges extraMetadata into the packaged manifest, so a build identifier here reaches + // the artifact names, the update feed, and the installed app.getVersion() the updater compares against. + const productVersion = JSON.parse(readFileSync(fileURLToPath(new URL('../package.json', import.meta.url)), 'utf8')).version + const releaseId = resolveDesktopBuildVersion(env, productVersion) + const provenance = resolveDesktopBuildProvenance(env) return { appId, - extraMetadata: { dshDesktopAppId: appId, dshMandatoryUpdatePolicy: policy }, + extraMetadata: { + dshDesktopAppId: appId, + dshMandatoryUpdatePolicy: policy, + ...releaseId === productVersion ? {} : { version: releaseId }, + ...provenance === undefined ? {} : { dshBuildCommit: provenance.commit, dshBuildDirty: provenance.dirty }, + }, productName: 'DeepSeek Harness', artifactName: 'deepseek-harness-${version}-${os}-${arch}.${ext}', directories: { output: unsigned ? join(buildPaths.root, 'unsigned-artifacts') : buildPaths.artifacts }, diff --git a/apps/desktop/scripts/package-target.ts b/apps/desktop/scripts/package-target.ts index 688d3d9671..fb54d7da79 100644 --- a/apps/desktop/scripts/package-target.ts +++ b/apps/desktop/scripts/package-target.ts @@ -1,6 +1,6 @@ /** Build one release target with matching Electron and dsh architecture. */ -import { execFileSync, spawn } from 'node:child_process' +import { spawn } from 'node:child_process' import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs' import { parseArgs } from 'node:util' import { join, resolve } from 'node:path' @@ -19,6 +19,10 @@ import { macOSDownloadEnvironment, resolveMacOSPackageSettings } from './macos-p import { packagingErrorDetails, packagingStep } from './packaging-step.mjs' import { notarizeMacOS } from './notarize-macos.mjs' import { resolveMacOSNotarizationEnvironment } from './desktop-release-environment.mjs' +import { DESKTOP_BUILD_VERSION_ENV, resolveDesktopBuildVersion, validateDesktopBuildVersion } from './desktop-build-version.mjs' +import { suggestDesktopBuildVersion } from './desktop-build-version-discovery.ts' +import { desktopBuildProvenanceEnvironment, readDesktopBuildProvenance, resolveDesktopBuildProvenance } from './desktop-build-provenance.mjs' +import { requireDesktopToolchain } from './desktop-toolchain-preflight.ts' import { withMacOSNotarizationProxy } from './macos-notarization-proxy.ts' const APP_ROOT = resolve(import.meta.dirname, '..') @@ -39,6 +43,9 @@ const DESKTOP_UPLOAD_CREDENTIAL_ENV_NAMES = new Set([ 'DOWNLOAD_PROD_COS_SECRET_KEY', ]) +/** `--release-id` value that numbers a build after the ones already taken. */ +const AUTOMATIC_RELEASE_ID = 'auto' + /** Fixed platform and architecture identifiers exposed by package scripts. */ export type DesktopPackageTargetName = 'mac-arm64' | 'mac-x64' | 'win-x64' @@ -136,15 +143,19 @@ function writeReleaseRecord( if (desktopVersion !== dshVersion) { throw new Error(`desktop package: desktop version ${desktopVersion} does not match dsh version ${dshVersion}`) } + const buildVersion = resolveDesktopBuildVersion(environment, dshVersion) + const provenance = resolveDesktopBuildProvenance(environment) const update = resolveDesktopAutoUpdateConfig(environment, target.platform, target.arch) const recordPath = join(artifactsRoot, desktopBuildRecordFilename(target.name)) const temporaryPath = `${recordPath}.tmp` writeFileSync(temporaryPath, `${JSON.stringify({ schemaVersion: 1, target: target.name, - version: dshVersion, + version: buildVersion, environment: update.environment, publicUrl: update.publicUrl, + // Upload tags the commit a production release was packaged from, which is no longer discoverable from the build tree. + ...provenance === undefined ? {} : { commit: provenance.commit, dirty: provenance.dirty }, }, null, 2)}\n`) renameSync(temporaryPath, recordPath) } @@ -186,6 +197,8 @@ interface DesktopPackageInvocation { readonly prepareOnly: boolean readonly unsigned: boolean readonly check: boolean + /** Build identifier to publish under, when this build does not publish the product version. */ + readonly releaseId: string | undefined } function hostTargetName(platform: NodeJS.Platform, arch: string): DesktopPackageTargetName { @@ -214,18 +227,24 @@ export function parseDesktopPackageInvocation( 'prepare-only': { type: 'boolean', default: false }, unsigned: { type: 'boolean', default: false }, check: { type: 'boolean', default: false }, + 'release-id': { type: 'string' }, }, }) if (positionals.length > 1) throw new Error('desktop package: expected at most one target') const name = positionals[0] ?? hostTargetName(hostPlatform, hostArch) if (values.unsigned && name !== 'win-x64') throw new Error('desktop package: --unsigned requires win-x64') if (values.unsigned && values['prepare-only']) throw new Error('desktop package: --unsigned cannot use --prepare-only') + const releaseId = values['release-id']?.trim() + if (values['release-id'] !== undefined && (releaseId === undefined || releaseId === '')) { + throw new Error('desktop package: --release-id requires a value') + } return { target: resolveDesktopPackageTarget(name, hostPlatform, hostArch), directory: values.dir, prepareOnly: values['prepare-only'], unsigned: values.unsigned, check: values.check, + releaseId, } } @@ -289,17 +308,29 @@ async function main(): Promise { const invocation = parseDesktopPackageInvocation(process.argv.slice(2)) const { target } = invocation const environment = loadDesktopPackageEnvironment(target.platform) + const productVersion = packageVersion(join(APP_ROOT, 'package.json'), 'desktop package') + // Resolving before any build step means a malformed version fails in seconds rather than after the packaging run. + const buildVersion = invocation.releaseId === undefined + ? resolveDesktopBuildVersion(environment, productVersion) + : invocation.releaseId === AUTOMATIC_RELEASE_ID + ? await suggestDesktopBuildVersion({ productVersion, target: target.name, environment }) + : validateDesktopBuildVersion(invocation.releaseId, productVersion) + environment[DESKTOP_BUILD_VERSION_ENV] = buildVersion if (invocation.check) { validateDesktopPackageEnvironment(environment, target, invocation) - process.stdout.write(`desktop package: ${target.name} local configuration valid; signing and notarization were not attempted\n`) + await requireDesktopToolchain(target.platform, environment) + process.stdout.write(`desktop package: ${target.name} would publish ${buildVersion}; local configuration and toolchain valid, signing and notarization were not attempted\n`) return } + process.stdout.write(`desktop package: ${target.name} publishes ${buildVersion}${buildVersion === productVersion ? '' : ` for product version ${productVersion}`}\n`) + const provenance = readDesktopBuildProvenance(REPOSITORY_ROOT) + Object.assign(environment, desktopBuildProvenanceEnvironment(provenance)) const secrets = Object.entries(environment).filter(([name]) => /KEY|SECRET|TOKEN|PASSWORD|APPLE_ID/iu.test(name)).map(([, value]) => value ?? '') const run = createPackagingRun(join(APP_ROOT, '.desktop-build', 'packaging-runs'), { target: target.name, unsigned: invocation.unsigned, directory: invocation.directory, prepareOnly: invocation.prepareOnly, - version: packageVersion(join(APP_ROOT, 'package.json'), 'desktop package'), node: process.version, - commit: execFileSync('git', ['rev-parse', 'HEAD'], { cwd: REPOSITORY_ROOT, encoding: 'utf8' }).trim(), - dirty: execFileSync('git', ['status', '--porcelain', '--untracked-files=normal'], { cwd: REPOSITORY_ROOT, encoding: 'utf8' }).trim() !== '', + version: buildVersion, productVersion, node: process.version, + commit: provenance.commit, + dirty: provenance.dirty, }, { parallel: target.platform === 'darwin', secrets }) console.log(`DESKTOP_PACKAGING_RECORD ${run.directory}`) const previousDirectory = process.env.DSH_DESKTOP_PACKAGING_RUN_DIR @@ -307,6 +338,7 @@ async function main(): Promise { let success = false try { await packagingStep(run.directory, 'configuration', async () => { validateDesktopPackageEnvironment(environment, target, invocation) }, secrets) + await packagingStep(run.directory, 'toolchain', () => requireDesktopToolchain(target.platform, environment), secrets) if (target.platform === 'darwin') { const settings = resolveMacOSPackageSettings(environment) recordPackagingEvent(run.directory, { type: 'macos-settings', packConcurrency: settings.packConcurrency, @@ -426,7 +458,8 @@ export async function packageTarget( ], electronBuilderEnv) await withMacOSNotarizationProxy(mac?.notarizationProxy, () => packageMacOSArtifacts({ arch: target.arch, - version: packageVersion(join(APP_ROOT, 'package.json'), 'desktop package'), + // electron-builder named these artifacts after the published version, so locating them uses the same identifier. + version: resolveDesktopBuildVersion(environment, packageVersion(join(APP_ROOT, 'package.json'), 'desktop package')), artifactsRoot: buildPaths.artifacts, environment: electronBuilderEnv, }, artifact => execute(desktopElectronBuilderArguments(target, false, artifact), electronBuilderEnv)), undefined, undefined, proxyEvent) diff --git a/apps/desktop/scripts/upload-target.ts b/apps/desktop/scripts/upload-target.ts index f587d3b977..e61c552e7b 100644 --- a/apps/desktop/scripts/upload-target.ts +++ b/apps/desktop/scripts/upload-target.ts @@ -6,7 +6,8 @@ import type { DesktopPackageTargetName } from './package-target.ts' import { createDesktopCos } from './desktop-cos.ts' import { resolveDesktopUploadConfig } from './desktop-auto-update-environment.mjs' import { loadDesktopPackageEnvironment } from './desktop-package-environment.mjs' -import { createDesktopUploadPlan } from './desktop-upload-plan.ts' +import { createDesktopUploadPlan, type DesktopUploadPlan } from './desktop-upload-plan.ts' +import { desktopReleaseTag, tagDesktopRelease } from './desktop-release-tag.ts' import { uploadDesktopRelease } from './desktop-upload-run.ts' const SUPPORTED_TARGETS = new Set(['mac-arm64', 'mac-x64', 'win-x64']) @@ -85,6 +86,31 @@ async function main(): Promise { }) process.stdout.write(`desktop upload: ${plan.target} ${plan.version} -> ${plan.publicUrl}\n`) await uploadDesktopRelease(plan, cos, resolve(import.meta.dirname, '../.desktop-build/upload-records')) + if (plan.environment === 'production') recordProductionRelease(plan) +} + +/** + * Tag the commit an external release was packaged from, after its artifacts are public. + * @param plan Completed production upload. + */ +function recordProductionRelease(plan: DesktopUploadPlan): void { + const tag = desktopReleaseTag(plan.version) + if (plan.commit === undefined) { + process.stdout.write(`desktop upload: this package predates recorded provenance; tag it by hand as ${tag}\n`) + return + } + if (plan.dirty === true) { + // A tag would name a commit whose tree is not what the users received. + process.stderr.write(`desktop upload: packaged from ${plan.commit} with uncommitted changes, so ${tag} was not created\n`) + return + } + const result = tagDesktopRelease({ version: plan.version, commit: plan.commit, repositoryRoot: resolve(import.meta.dirname, '../../..') }) + if (result.status === 'failed') { + process.stderr.write(`desktop upload: the release is published but ${result.tag} was not recorded: ${result.detail ?? 'unknown error'}\n`) + process.stderr.write(`desktop upload: record it with: git tag ${result.tag} ${plan.commit} && git push origin ${result.tag}\n`) + return + } + process.stdout.write(`desktop upload: ${result.tag} ${result.status === 'created' ? 'records' : 'already recorded'} ${plan.commit}\n`) } if (process.argv[1] !== undefined && import.meta.filename === resolve(process.argv[1])) { diff --git a/apps/desktop/tests/desktop-build-version-discovery.spec.ts b/apps/desktop/tests/desktop-build-version-discovery.spec.ts new file mode 100644 index 0000000000..ad2f9065a7 --- /dev/null +++ b/apps/desktop/tests/desktop-build-version-discovery.spec.ts @@ -0,0 +1,67 @@ +import { mkdtemp, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { + desktopBuildDateSegment, + suggestDesktopBuildVersion, +} from '../scripts/desktop-build-version-discovery.ts' + +const PRODUCT = '0.1.6-alpha.2' +const DATE = '20260921' + +async function artifactsWith(names: readonly string[]): Promise { + const directory = await mkdtemp(join(tmpdir(), 'dsh-discovery-')) + for (const name of names) await writeFile(join(directory, name), '') + return directory +} + +describe('desktop build version discovery', () => { + it('formats the date segment in the build host time zone', () => { + expect(desktopBuildDateSegment(new Date(2026, 8, 21))).toBe('20260921') + expect(desktopBuildDateSegment(new Date(2026, 0, 5))).toBe('20260105') + }) + + it('starts at one when nothing is taken', async () => { + const artifactsRoot = await artifactsWith([]) + await expect(suggestDesktopBuildVersion({ productVersion: PRODUCT, target: 'win-x64', environment: {}, date: DATE, artifactsRoot })) + .resolves.toBe(`${PRODUCT}.${DATE}.1`) + }) + + it('numbers after the highest local artifact for the same date', async () => { + const artifactsRoot = await artifactsWith([ + `deepseek-harness-${PRODUCT}.${DATE}.1-win-x64.exe`, + `deepseek-harness-${PRODUCT}.${DATE}.2-win-x64.exe`, + `deepseek-harness-${PRODUCT}.${DATE}.10-win-x64.exe`, + ]) + await expect(suggestDesktopBuildVersion({ productVersion: PRODUCT, target: 'win-x64', environment: {}, date: DATE, artifactsRoot })) + .resolves.toBe(`${PRODUCT}.${DATE}.11`) + }) + + it('ignores artifacts from another date or product version', async () => { + const artifactsRoot = await artifactsWith([ + `deepseek-harness-${PRODUCT}.20260920.7-win-x64.exe`, + `deepseek-harness-0.1.5-alpha.1.${DATE}.9-win-x64.exe`, + `deepseek-harness-${PRODUCT}-win-x64.exe`, + 'unrelated.exe', + ]) + await expect(suggestDesktopBuildVersion({ productVersion: PRODUCT, target: 'win-x64', environment: {}, date: DATE, artifactsRoot })) + .resolves.toBe(`${PRODUCT}.${DATE}.1`) + }) + + it('reads macOS artifact names too', async () => { + const artifactsRoot = await artifactsWith([ + `deepseek-harness-${PRODUCT}.${DATE}.3-mac-arm64.dmg`, + `deepseek-harness-${PRODUCT}.${DATE}.3-mac-arm64.zip`, + ]) + await expect(suggestDesktopBuildVersion({ productVersion: PRODUCT, target: 'mac-arm64', environment: {}, date: DATE, artifactsRoot })) + .resolves.toBe(`${PRODUCT}.${DATE}.4`) + }) + + it('refuses to number a stable product version, whose numbering is not even a version', async () => { + const artifactsRoot = await artifactsWith([]) + // 0.1.6.20260921.1 has a fourth release number, which semver does not accept at all. + await expect(suggestDesktopBuildVersion({ productVersion: '0.1.6', target: 'win-x64', environment: {}, date: DATE, artifactsRoot })) + .rejects.toThrow(/is not a version/u) + }) +}) diff --git a/apps/desktop/tests/desktop-build-version.spec.ts b/apps/desktop/tests/desktop-build-version.spec.ts new file mode 100644 index 0000000000..65f6760aad --- /dev/null +++ b/apps/desktop/tests/desktop-build-version.spec.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest' +import { + DESKTOP_BUILD_VERSION_ENV, + resolveDesktopBuildVersion, + validateDesktopBuildVersion, +} from '../scripts/desktop-build-version.mjs' + +const PRODUCT = '0.1.6-alpha.2' + +describe('desktop release id', () => { + it('publishes the product version when no identifier is present', () => { + expect(resolveDesktopBuildVersion({}, PRODUCT)).toBe(PRODUCT) + expect(resolveDesktopBuildVersion({ [DESKTOP_BUILD_VERSION_ENV]: ' ' }, PRODUCT)).toBe(PRODUCT) + }) + + it.each(['0.1.6-alpha.2.20260921.1', '0.1.6-alpha.2.20260921.12', '0.1.6-alpha.2.nightly'])( + 'accepts %s as an extension of the product prerelease', (releaseId) => { + expect(resolveDesktopBuildVersion({ [DESKTOP_BUILD_VERSION_ENV]: releaseId }, PRODUCT)).toBe(releaseId) + expect(validateDesktopBuildVersion(releaseId, PRODUCT)).toBe(releaseId) + }) + + it('accepts the product version itself', () => { + expect(validateDesktopBuildVersion(PRODUCT, PRODUCT)).toBe(PRODUCT) + }) + + it.each(['', 'nightly', '0.1.6-alpha.2.'])( + 'rejects %j as a version', (releaseId) => { + expect(() => validateDesktopBuildVersion(releaseId, PRODUCT)).toThrow(/is not a version/u) + }) + + it('normalizes what the artifacts carry, so validation and publication agree', () => { + expect(validateDesktopBuildVersion('v0.1.6-alpha.2.1', PRODUCT)).toBe('0.1.6-alpha.2.1') + }) + + it('rejects build metadata, which does not affect updater precedence', () => { + expect(() => validateDesktopBuildVersion('0.1.6-alpha.2.1+build', PRODUCT)).toThrow(/build metadata/u) + }) + + it.each(['0.1.7-alpha.2.20260921.1', '0.2.6-alpha.2.20260921.1'])( + 'rejects %s because it leaves the product release numbers', (releaseId) => { + expect(() => validateDesktopBuildVersion(releaseId, PRODUCT)).toThrow(/must extend product version/u) + }) + + it.each(['0.1.6-beta.2.20260921.1', '0.1.6-alpha.3', '0.1.6'])( + 'rejects %s because it does not extend the product prerelease', (releaseId) => { + expect(() => validateDesktopBuildVersion(releaseId, PRODUCT)).toThrow(/must extend prerelease/u) + }) + + it('refuses to extend a stable product version, which an update would never offer', () => { + // semver ranks 0.1.6.20260921.1 below every 0.1.7 prerelease and it is not a version at all. + expect(() => validateDesktopBuildVersion('0.1.6-20260921.1', '0.1.6')).toThrow(/carries no prerelease/u) + }) +}) diff --git a/apps/desktop/tests/desktop-release-tag.spec.ts b/apps/desktop/tests/desktop-release-tag.spec.ts new file mode 100644 index 0000000000..035805c7c9 --- /dev/null +++ b/apps/desktop/tests/desktop-release-tag.spec.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest' +import { desktopReleaseTag, tagDesktopRelease } from '../scripts/desktop-release-tag.ts' + +const COMMIT = '0'.repeat(40) +const OTHER = '1'.repeat(40) +const VERSION = '0.1.6-alpha.2' + +function recorder(responses: Record): { + readonly run: (command: string, args: readonly string[]) => string + readonly calls: string[][] +} { + const calls: string[][] = [] + return { + calls, + run: (command, args) => { + calls.push([command, ...args]) + const response = responses[args[0] ?? ''] + if (response instanceof Error) throw response + return response ?? '' + }, + } +} + +describe('desktop release tag', () => { + it('names a tag distinct from the npm family tags', () => { + expect(desktopReleaseTag(VERSION)).toBe(`desktop-v${VERSION}`) + }) + + it('creates and pushes a tag for the packaged commit', () => { + const { run, calls } = recorder({ tag: '' }) + expect(tagDesktopRelease({ version: VERSION, commit: COMMIT, repositoryRoot: '.', run })) + .toEqual({ tag: `desktop-v${VERSION}`, status: 'created' }) + expect(calls).toEqual([ + ['git', 'tag', '--list', `desktop-v${VERSION}`], + ['git', 'tag', `desktop-v${VERSION}`, COMMIT], + ['git', 'push', 'origin', `desktop-v${VERSION}`], + ]) + }) + + it('pushes an existing tag that already names the uploaded commit', () => { + const { run, calls } = recorder({ tag: `desktop-v${VERSION}`, 'rev-list': COMMIT }) + expect(tagDesktopRelease({ version: VERSION, commit: COMMIT, repositoryRoot: '.', run })) + .toEqual({ tag: `desktop-v${VERSION}`, status: 'present' }) + expect(calls.at(-1)).toEqual(['git', 'push', 'origin', `desktop-v${VERSION}`]) + }) + + it('refuses to move a tag that names another commit', () => { + const { run, calls } = recorder({ tag: `desktop-v${VERSION}`, 'rev-list': OTHER }) + const result = tagDesktopRelease({ version: VERSION, commit: COMMIT, repositoryRoot: '.', run }) + expect(result.status).toBe('failed') + expect(result.detail).toMatch(new RegExp(`already names ${OTHER}`, 'u')) + expect(calls.some(call => call.includes('push'))).toBe(false) + }) + + it('reports a git failure instead of throwing, because the release is already public', () => { + const { run } = recorder({ tag: new Error('git unavailable') }) + expect(tagDesktopRelease({ version: VERSION, commit: COMMIT, repositoryRoot: '.', run })) + .toMatchObject({ status: 'failed', detail: 'git unavailable' }) + }) + + it('pushes to a named remote', () => { + const { run, calls } = recorder({ tag: '' }) + tagDesktopRelease({ version: VERSION, commit: COMMIT, repositoryRoot: '.', remote: 'upstream', run }) + expect(calls.at(-1)).toEqual(['git', 'push', 'upstream', `desktop-v${VERSION}`]) + }) +})