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:
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')}`)
|
||||
}
|
||||
@@ -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 },
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}`])
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user