From b85cf687725a8cda1da9f512d04e8b4d27e16d72 Mon Sep 17 00:00:00 2001 From: David Sutherland Date: Thu, 16 Jul 2026 15:05:22 +0100 Subject: [PATCH] Detecting version drift and showing warning / error --- src/utils/godot.ts | 27 ++++++++++++++++++ src/utils/ship/index.ts | 31 +++++++++++++++++++++ test/utils/godot.test.ts | 59 +++++++++++++++++++++++++++++++++++++++- 3 files changed, 116 insertions(+), 1 deletion(-) diff --git a/src/utils/godot.ts b/src/utils/godot.ts index 349f234f..89e11968 100644 --- a/src/utils/godot.ts +++ b/src/utils/godot.ts @@ -178,6 +178,33 @@ export function getGodotVersion(): string { return version as string } +/** Detects the Godot version from project.godot. Returns null when we can't tell. */ +export function detectGodotVersion(): null | string { + try { + return getGodotVersion() + } catch { + return null + } +} + +export type GodotVersionDrift = 'major' | 'minor' + +function parseMajorMinor(version: string): null | {major: number; minor: number} { + const match = version.trim().match(/^(\d+)\.(\d+)/) + if (!match) return null + return {major: Number(match[1]), minor: Number(match[2])} +} + +/** Compares on major.minor, ignoring the patch. Null when equal or either is unparseable. */ +export function getGodotVersionDrift(detected: string, configured: string): GodotVersionDrift | null { + const a = parseMajorMinor(detected) + const b = parseMajorMinor(configured) + if (!a || !b) return null + if (a.major !== b.major) return 'major' + if (a.minor !== b.minor) return 'minor' + return null +} + export function getExportPresetsPath(): string { // Get the preset options from any export_presets.cfg if found const cwd = process.cwd() diff --git a/src/utils/ship/index.ts b/src/utils/ship/index.ts index b98036c1..a6600f21 100644 --- a/src/utils/ship/index.ts +++ b/src/utils/ship/index.ts @@ -4,6 +4,7 @@ import {v4 as uuid} from 'uuid' import {getNewUploadTicket, getProject, startJobsFromUpload} from '@cli/api/index.js' import type {Job, Platform, ProjectConfig, ShipGameFlags, UploadDetails, UploadTicket} from '@cli/types' +import {detectGodotVersion, getGodotVersionDrift} from '@cli/utils/godot.js' import {getCWDGitInfo, getFileHash} from '@cli/utils/index.js' import {getFilesToShip} from './glob.js' @@ -14,6 +15,26 @@ import {createZip} from './zip.js' const ERR_NOT_CONFIGURED = 'No Android or iOS configuration found. Please run `shipthis game wizard android` or `shipthis game wizard ios` to configure your game.' +const getVersionMismatch = (detected: string, configured: string) => + `Your project.godot targets Godot ${detected}, but this game builds with Godot ${configured}.` + +// The commands go on their own lines so they stand out - this.error() and console.warn +// both preserve newlines. +const getVersionFixHint = (detected: string) => + `To build with Godot ${detected}, update the game:\n\n` + + ` shipthis game details --gameEngineVersion ${detected} --force\n\n` + + `Or ship once without changing the game:\n\n` + + ` shipthis game ship --gameEngineVersion ${detected}` + +const getMajorDriftError = (detected: string, configured: string) => + `${getVersionMismatch(detected, configured)}\n` + + `Building a Godot ${detected} project with Godot ${configured} does not produce a working game, ` + + `so this ship has been stopped before building.\n\n` + + getVersionFixHint(detected) + +const getMinorDriftWarning = (detected: string, configured: string) => + `${getVersionMismatch(detected, configured)}\n\n` + getVersionFixHint(detected) + // Main function to ship the game export async function ship({command, log, warnLog, shipFlags}: ShipOptions): Promise { const commandFlags = command.getFlags() as ShipGameFlags @@ -34,6 +55,16 @@ export async function ship({command, log, warnLog, shipFlags}: ShipOptions): Pro if (!projectConfig.project) throw new Error('No project found in project config') const project = await getProject(projectConfig.project.id) + // gameEngineVersion is detected once at create time and drifts when the project is + // upgraded. A major drift does not build correctly, so stop before we publish one. + if (!finalFlags.gameEngineVersion) { + const detected = detectGodotVersion() + const configured = project.details?.gameEngineVersion + const drift = detected && configured ? getGodotVersionDrift(detected, configured) : null + if (drift === 'major') throw new Error(getMajorDriftError(detected!, configured!)) + if (drift === 'minor') warnLog(getMinorDriftWarning(detected!, configured!)) + } + const projectUsesDemoCredentials = Boolean(project.details?.useDemoCredentials) const isUsingDemoCredentials = useDemoCredentials ?? projectUsesDemoCredentials ?? false diff --git a/test/utils/godot.test.ts b/test/utils/godot.test.ts index 5426c4de..cf1a1995 100644 --- a/test/utils/godot.test.ts +++ b/test/utils/godot.test.ts @@ -8,7 +8,12 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url)) import {parseEntitlementsAdditional} from '../../src/apple/entitlements.js' import {CapabilityType} from '../../src/apple/expo.js' import {Platform} from '../../src/types/index.js' -import {getGodotProjectCapabilities, getGodotVersion} from '../../src/utils/godot.js' +import { + detectGodotVersion, + getGodotProjectCapabilities, + getGodotVersion, + getGodotVersionDrift, +} from '../../src/utils/godot.js' describe('getGodotProjectCapabilities', () => { it('returns PUSH_NOTIFICATIONS when entitlements/push_notifications is Production', async () => { @@ -141,6 +146,58 @@ describe('getGodotVersion', () => { }) }) +describe('detectGodotVersion', () => { + const originalCwd = process.cwd() + + afterEach(() => { + process.chdir(originalCwd) + }) + + it('returns 3.6 for a Godot 3.x project without config/features', () => { + process.chdir(path.resolve(__dirname, '../fixtures/godot/v3_5')) + expect(detectGodotVersion()).to.equal('3.6') + }) + + it('returns 4.2 for a Godot 4.2 project with config/features', () => { + process.chdir(path.resolve(__dirname, '../fixtures/godot/v4_2')) + expect(detectGodotVersion()).to.equal('4.2') + }) + + it('returns null when there is no project.godot', () => { + process.chdir(path.resolve(__dirname, '../fixtures')) + expect(detectGodotVersion()).to.equal(null) + }) +}) + +describe('getGodotVersionDrift', () => { + it('returns major when the majors differ', () => { + expect(getGodotVersionDrift('4.2', '3.6')).to.equal('major') + }) + + it('returns major for a downgrade, which is equally broken', () => { + expect(getGodotVersionDrift('3.6', '4.2')).to.equal('major') + }) + + it('returns minor when only the minors differ', () => { + expect(getGodotVersionDrift('4.7', '4.6')).to.equal('minor') + }) + + it('returns null when only the patch differs', () => { + expect(getGodotVersionDrift('4.5', '4.5.1')).to.equal(null) + }) + + it('returns null when a version has no minor', () => { + expect(getGodotVersionDrift('4', '4.7')).to.equal(null) + }) + + it('returns null for empty or garbage input', () => { + expect(getGodotVersionDrift('', '4.7')).to.equal(null) + expect(getGodotVersionDrift('4.7', '')).to.equal(null) + expect(getGodotVersionDrift('not-a-version', '4.7')).to.equal(null) + expect(getGodotVersionDrift('4.7', 'latest')).to.equal(null) + }) +}) + describe('parseEntitlementsAdditional', () => { it('returns APPLE_ID_AUTH for content containing com.apple.developer.applesignin', () => { const raw = 'com.apple.developer.applesigninDefault'