feat(desktop): publish a build version without rewriting the manifests

Packaging a build for a test feed or a colleague required bumping the
version in 295 manifests plus the lockfile first, because the version a
build publishes was only ever read from the checkout. Those edits cannot
be committed, so every such build left the working tree dirty, and the
`<version>.<date>.<sequence>` convention behind them lived only in the
operator's memory.

The version a build publishes is now an input. `--release-id` names it,
`--release-id auto` numbers it after the builds already published to the
destination bucket (falling back to local artifacts, since a fresh CI
checkout has none), and it reaches electron-builder through
extraMetadata, the update feed, and the upload validation as one value.
The manifests keep the product version, aligned with the dsh npm
package, and a release still publishes exactly that.

Validation goes through `semver` itself, because `electron-updater`
compares feed versions with `semver.gt` against the installed
`app.getVersion()`: a build version must outrank the product version it
extends, which holds for `0.1.6-alpha.2.20260921.1` over
`0.1.6-alpha.2` and cannot hold on a stable version, where a fourth
release number is not a version at all.

Two things a build could not previously answer for itself:

- Artifacts now carry the commit they were packaged from and whether
  that checkout was dirty, so a build handed to a colleague is traceable
  without a tag.
- A production upload tags that commit as `desktop-v<version>` once the
  artifacts are public, and reports what to run by hand rather than
  failing an upload that already completed. Test and local builds are
  deliberately left untagged.

A toolchain step now probes tar and the Windows installer compiler
before the first build step, so a host that cannot finish the sequence
says so in seconds rather than minutes.
This commit is contained in:
winewill committed 2026-09-21 14:23:48 +08:00
1 parent 766eee63eb
commit 950ed3f6df
14 files changed
+782 -17

No files matched your search

@@ -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<string, string>
@@ -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<string, string>} 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',
}
}
@@ -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 `<product version>.<date>.<sequence>`. 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 - `<product version>.<date>.` that a numbered build starts with.
* @returns Every sequence number present, unordered.
*/
function sequenceNumbers(versions: Iterable<string>, 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<string[]> {
const entries = await readdir(artifactsRoot).catch(() => [])
const versions: string[] = []
for (const entry of entries) {
const named = /^deepseek-harness-(?<version>.+)-(?: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<string[] | undefined> {
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<string[]>((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<undefined>((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-(?<version>.+)-(?: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<string> {
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
}
@@ -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
@@ -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)
}
@@ -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) }
}
}
@@ -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<string | undefined> {
// 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<DesktopToolchainProbeFailure[]> {
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<readonly DesktopToolchainProbeFailure[]> {
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<void> {
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')}`)
}
+17 -8
View File
@@ -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 } : {},
}
}
@@ -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 },
+40 -7
View File
@@ -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<void> {
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<void> {
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)
+27 -1
View File
@@ -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<DesktopPackageTargetName>(['mac-arm64', 'mac-x64', 'win-x64'])
@@ -85,6 +86,31 @@ async function main(): Promise<void> {
})
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])) {
@@ -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<string> {
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)
})
})
@@ -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)
})
})
@@ -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<string, string | Error>): {
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}`])
})
})