diff --git a/.github/workflows/real-project-examples.yml b/.github/workflows/real-project-examples.yml new file mode 100644 index 0000000..70841a2 --- /dev/null +++ b/.github/workflows/real-project-examples.yml @@ -0,0 +1,142 @@ +name: Real Project Examples + +# Validates the CLI against real, popular open-source projects - not our own +# minimal fixtures. Deliberately separate from engine-smoke-test.yml (which +# stays fast and runs on every PR): cloning external repos and running real +# engine work (a real Godot import, a real cargo build) is slower and, more +# importantly, depends on projects we don't control - an upstream breakage +# here shouldn't block unrelated PRs. Runs on a schedule and manually instead, +# so upstream drift surfaces on its own rather than silently. +# +# Engine coverage here mirrors what's actually usable today: +# - Godot and Bevy have real, functional plugins - both included. +# - Unity is deliberately NOT included: this repo's own Unity coverage +# (engine-smoke-test.yml) already exercises real Unity build/activate +# logic against dedicated fixtures with a real license; a second, +# external-project version would mostly duplicate that for uncertain +# benefit, given how few Unity projects are both open-source and +# buildable without proprietary assets. +# - GameMaker, Ren'Py, RPG Maker, and the other structural-draft plugins +# are NOT included: their domain logic isn't implemented yet (see each +# plugin's own README), regardless of how many public example projects +# exist for those engines - there's nothing here for them to build yet. + +on: + schedule: + # Mondays 06:00 UTC - catches upstream breakage on our own schedule, + # not tied to unrelated PR activity. + - cron: '0 6 * * 1' + workflow_dispatch: + +permissions: + contents: read + +jobs: + # ─── Godot — godotengine/godot-demo-projects ──────────────────── + godot-real-project: + name: Godot real project (dodge_the_creeps) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Set up Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Check out godot-demo-projects (2d/dodge_the_creeps) + run: | + mkdir -p /tmp/godot-example + git clone --no-checkout --depth 1 --branch 4.3 \ + https://github.com/godotengine/godot-demo-projects.git /tmp/godot-example + cd /tmp/godot-example + git sparse-checkout set --no-cone 2d/dodge_the_creeps + git checkout 52e30044658448149b04e8f69b475eebbfbd8f6e + + - name: Build via the CLI (real Godot 4.3 image, real project) + run: | + bun run src/index.ts build /tmp/godot-example/2d/dodge_the_creeps \ + --engine=godot \ + --engineVersion=4.3 \ + --containerRegistryRepository=barichello/godot-ci \ + --containerRegistryImageVersion=4.3 + + # ─── Bevy — NiklasEi/bevy_game_template ───────────────────────── + bevy-real-project: + name: Bevy real project (bevy_game_template) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Set up Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Set up Rust + uses: dtolnay/rust-toolchain@stable + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Check out bevy_game_template + run: | + git clone --depth 1 https://github.com/NiklasEi/bevy_game_template.git /tmp/bevy-example + cd /tmp/bevy-example + git fetch --depth 1 origin 333a3bfe176dfe126156fefe0db81950cd80e719 + git checkout 333a3bfe176dfe126156fefe0db81950cd80e719 + + - name: Build via the CLI (real cargo build, no --plugin flag) + run: bun run src/index.ts build /tmp/bevy-example + + # ─── Bevy — webbertakken/death-arena ──────────────────────────── + bevy-death-arena: + name: Bevy real project (death-arena) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Set up Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Set up Rust + uses: dtolnay/rust-toolchain@stable + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Check out death-arena + run: | + git clone --depth 1 https://github.com/webbertakken/death-arena.git /tmp/death-arena + cd /tmp/death-arena + git fetch --depth 1 origin 0d615dd66cd71582a90ad70e9fb0292398379d9b + git checkout 0d615dd66cd71582a90ad70e9fb0292398379d9b + + - name: Build via the CLI (real cargo build, no --plugin flag) + run: bun run src/index.ts build /tmp/death-arena + + # Aggregate gate, same pattern as engine-smoke-test.yml's smoke-gate. + real-project-gate: + name: Real project examples gate + needs: [godot-real-project, bevy-real-project, bevy-death-arena] + if: always() + runs-on: ubuntu-latest + steps: + - name: Verify no real-project example failed + run: | + if [ "${{ contains(needs.*.result, 'failure') }}" = "true" ]; then + echo "A real-project example build failed." + exit 1 + fi + echo "All real-project examples that ran are green." diff --git a/action.yml b/action.yml index bc1a087..11f4527 100644 --- a/action.yml +++ b/action.yml @@ -67,31 +67,52 @@ runs: $installDir = Join-Path $env:RUNNER_TEMP 'game-ci\bin' $binaryPath = Join-Path $installDir 'game-ci.exe' - $assetName = 'game-ci-windows-x64.exe' + # The release archive, not a bare .exe: the binary alone isn't + # self-contained - it resolves its own static assets + # (default-build-script/, platforms/*, unity-config/services-config.json.template) + # relative to its own directory on disk, shipped as dist/ alongside + # it inside this same archive (see game-ci/cli#73 and + # release-cli.yml's matching comment). + $assetName = 'game-ci-windows-x64.zip' + $archivePath = Join-Path $env:RUNNER_TEMP $assetName $downloadUrl = "https://github.com/$repo/releases/download/$version/$assetName" $checksumUrl = "https://github.com/$repo/releases/download/$version/checksums.txt" New-Item -ItemType Directory -Force -Path $installDir | Out-Null - Invoke-WebRequest -Uri $downloadUrl -OutFile $binaryPath -UseBasicParsing + Invoke-WebRequest -Uri $downloadUrl -OutFile $archivePath -UseBasicParsing $expectedHash = '' try { - $checksums = Invoke-WebRequest -Uri $checksumUrl -UseBasicParsing | Select-Object -ExpandProperty Content - $expectedLine = $checksums -split "`n" | Where-Object { $_ -match $assetName } | Select-Object -First 1 - if ($expectedLine) { - $expectedHash = ($expectedLine -split '\s+')[0].ToLower() + # .Content on a plain-text response can come back as a raw byte[] + # rather than a decoded string, depending on the PowerShell/runner + # combination - explicitly decode rather than assuming a string, + # since a silent type mismatch here makes every checksum check a + # silent no-op (an empty $expectedHash skips verification below + # without ever surfacing a warning). + $response = Invoke-WebRequest -Uri $checksumUrl -UseBasicParsing + $checksums = if ($response.Content -is [byte[]]) { + [System.Text.Encoding]::UTF8.GetString($response.Content) + } else { + $response.Content + } + $expectedLine = $checksums -split "`n" | Where-Object { $_ -match [regex]::Escape($assetName) } | Select-Object -First 1 + if ($expectedLine -match '^(\S+)') { + $expectedHash = $Matches[1].ToLower() } } catch { Write-Warning "Checksum verification skipped because checksum metadata could not be fetched: $_" } if ($expectedHash) { - $actualHash = (Get-FileHash -Path $binaryPath -Algorithm SHA256).Hash.ToLower() + $actualHash = (Get-FileHash -Path $archivePath -Algorithm SHA256).Hash.ToLower() if ($expectedHash -ne $actualHash) { throw "Checksum verification failed. Expected $expectedHash, got $actualHash" } } + Expand-Archive -Path $archivePath -DestinationPath $installDir -Force + Remove-Item $archivePath + & $binaryPath --help | Out-Null $installDir | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append diff --git a/bun.lock b/bun.lock index b3790e6..2bef233 100644 --- a/bun.lock +++ b/bun.lock @@ -5,9 +5,15 @@ "": { "name": "@game-ci/cli", "dependencies": { + "@game-ci/bevy": "workspace:*", + "@game-ci/code-signing": "workspace:*", + "@game-ci/github-release-deploy": "workspace:*", + "@game-ci/itch-deploy": "workspace:*", "@game-ci/orchestrator": "workspace:*", + "@game-ci/pseudo-localization": "workspace:*", "@game-ci/runtime-test-framework": "workspace:*", "@game-ci/steam-deploy": "workspace:*", + "@game-ci/steam-workshop": "workspace:*", "dotenv": "^16.3.1", "semver": "^7.5.4", "unity-changeset": "^3.4.0", diff --git a/package.json b/package.json index 1daeb16..92ca8e3 100644 --- a/package.json +++ b/package.json @@ -27,9 +27,14 @@ }, "dependencies": { "@game-ci/bevy": "workspace:*", + "@game-ci/code-signing": "workspace:*", + "@game-ci/github-release-deploy": "workspace:*", + "@game-ci/itch-deploy": "workspace:*", "@game-ci/orchestrator": "workspace:*", + "@game-ci/pseudo-localization": "workspace:*", "@game-ci/runtime-test-framework": "workspace:*", "@game-ci/steam-deploy": "workspace:*", + "@game-ci/steam-workshop": "workspace:*", "dotenv": "^16.3.1", "semver": "^7.5.4", "unity-changeset": "^3.4.0", diff --git a/plugins/code-signing/README.md b/plugins/code-signing/README.md index 3745632..1f99975 100644 --- a/plugins/code-signing/README.md +++ b/plugins/code-signing/README.md @@ -1,5 +1,6 @@ -> **EXPERIMENTAL.** Functional, but not published to npm and never loaded -> unless you pass `--plugin @game-ci/code-signing` explicitly. +> **EXPERIMENTAL.** Functional, and registered by default in every `game-ci` +> binary (no `--plugin` flag needed) - not published to npm, so it's loaded +> via a literal `import()` compiled directly into the binary instead. # @game-ci/code-signing @@ -13,7 +14,6 @@ storefronts (direct download, itch.io). ```bash APPLE_ID=... APPLE_TEAM_ID=... APPLE_APP_SPECIFIC_PASSWORD=... game-ci \ - --plugin @game-ci/code-signing \ sign ./build/Game.app --platform macos --identity "Developer ID Application: Studio Name (TEAM123)" ``` @@ -33,7 +33,6 @@ Apple ID password. ```bash WINDOWS_CERTIFICATE_PASSWORD=... game-ci \ - --plugin @game-ci/code-signing \ sign ./build/Game.exe --platform windows --certificatePath ./cert.pfx --timestampUrl http://timestamp.digicert.com ``` diff --git a/plugins/github-release-deploy/README.md b/plugins/github-release-deploy/README.md index 2904692..d31bdae 100644 --- a/plugins/github-release-deploy/README.md +++ b/plugins/github-release-deploy/README.md @@ -1,6 +1,6 @@ -> **EXPERIMENTAL.** Functional, but not published to npm and never loaded -> unless you pass `--plugin @game-ci/github-release-deploy` explicitly - -> and not yet wired into core's default load list. +> **EXPERIMENTAL.** Functional, and registered by default in every `game-ci` +> binary (no `--plugin` flag needed) - not published to npm, so it's loaded +> via a literal `import()` compiled directly into the binary instead. # @game-ci/github-release-deploy diff --git a/plugins/github-release-deploy/src/index.ts b/plugins/github-release-deploy/src/index.ts index 8b5e0d7..67a95a6 100644 --- a/plugins/github-release-deploy/src/index.ts +++ b/plugins/github-release-deploy/src/index.ts @@ -22,10 +22,12 @@ export const githubReleaseDeployPlugin = { engine: "*", createCommand(command: string, subCommands: string[]) { if (command === "deploy" && subCommands[0] === "github-release") { - // Warned here rather than in onLoad: this plugin is only loaded - // via an explicit --plugin flag, but warning there would fire - // even for someone just inspecting `--help` output. This fires - // exactly when the experimental feature is used. + // Warned here rather than in onLoad: onLoad fires for every + // command (this plugin is registered by default, see cli.ts's + // loadPlugins), so warning there would fire even for someone + // just inspecting `--help` output or running an unrelated + // command. This fires exactly when the experimental feature is + // actually used. console.warn( "[game-ci] WARNING: `deploy github-release` is EXPERIMENTAL. " + "Verify against a test repo before pointing it at a real release.", diff --git a/plugins/itch-deploy/README.md b/plugins/itch-deploy/README.md index d6ad44e..bb9d5e8 100644 --- a/plugins/itch-deploy/README.md +++ b/plugins/itch-deploy/README.md @@ -1,6 +1,6 @@ -> **EXPERIMENTAL.** Functional, but not published to npm and never loaded -> unless you pass `--plugin @game-ci/itch-deploy` explicitly. Not wired -> into core's default load list. +> **EXPERIMENTAL.** Functional, and registered by default in every `game-ci` +> binary (no `--plugin` flag needed) - not published to npm, so it's loaded +> via a literal `import()` compiled directly into the binary instead. # @game-ci/itch-deploy diff --git a/plugins/pseudo-localization/README.md b/plugins/pseudo-localization/README.md index b5f9636..b6d43af 100644 --- a/plugins/pseudo-localization/README.md +++ b/plugins/pseudo-localization/README.md @@ -1,5 +1,6 @@ -> **EXPERIMENTAL.** Functional, but not published to npm and never loaded -> unless you pass `--plugin @game-ci/pseudo-localization` explicitly. +> **EXPERIMENTAL.** Functional, and registered by default in every `game-ci` +> binary (no `--plugin` flag needed) - not published to npm, so it's loaded +> via a literal `import()` compiled directly into the binary instead. # @game-ci/pseudo-localization diff --git a/plugins/steam-workshop/README.md b/plugins/steam-workshop/README.md index d984717..ea080d3 100644 --- a/plugins/steam-workshop/README.md +++ b/plugins/steam-workshop/README.md @@ -1,5 +1,6 @@ -> **EXPERIMENTAL.** Functional, but not published to npm and never loaded -> unless you pass `--plugin @game-ci/steam-workshop` explicitly. +> **EXPERIMENTAL.** Functional, and registered by default in every `game-ci` +> binary (no `--plugin` flag needed) - not published to npm, so it's loaded +> via a literal `import()` compiled directly into the binary instead. # @game-ci/steam-workshop @@ -13,7 +14,6 @@ genuinely different upload target and VDF schema from ```bash STEAM_USERNAME=... STEAM_PASSWORD=... game-ci \ - --plugin @game-ci/steam-workshop \ deploy steam-workshop ./my-mod --appId 480 --title "My Mod" ``` diff --git a/src/cli.ts b/src/cli.ts index f55d7da..d582ea9 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -93,6 +93,27 @@ export class Cli { // must go through this literal `import()` instead, same reasoning as // orchestrator/steam-deploy/runtime-test-framework above. await PluginLoader.loadModule(await import("@game-ci/bevy"), "@game-ci/bevy"); + // Same reasoning as bevy above: functional, but not published to npm, + // so --plugin (loadFromNpm) can't reach them either - only a literal + // import() traced into the compiled binary works. Each registers its + // own subcommand(s) rather than doing engine-style project detection, + // so - like steam-deploy above - there's no auto-detection cost to + // registering them unconditionally; they only activate when their own + // subcommand is actually invoked. + await PluginLoader.loadModule( + await import("@game-ci/github-release-deploy"), + "@game-ci/github-release-deploy", + ); + await PluginLoader.loadModule(await import("@game-ci/itch-deploy"), "@game-ci/itch-deploy"); + await PluginLoader.loadModule( + await import("@game-ci/pseudo-localization"), + "@game-ci/pseudo-localization", + ); + await PluginLoader.loadModule(await import("@game-ci/code-signing"), "@game-ci/code-signing"); + await PluginLoader.loadModule( + await import("@game-ci/steam-workshop"), + "@game-ci/steam-workshop", + ); const options = await this.getPreCommandOptions(); const pluginSources = this.getPluginSources(options); diff --git a/src/command/build/godot-build-command.test.ts b/src/command/build/godot-build-command.test.ts new file mode 100644 index 0000000..476a935 --- /dev/null +++ b/src/command/build/godot-build-command.test.ts @@ -0,0 +1,81 @@ +import { describe, it, expect, mock, afterEach, beforeAll, afterAll } from 'bun:test'; +import { Docker } from '../../model/index.ts'; +import { fsSync } from '../../dependencies.ts'; +import { GodotBuildCommand } from './godot-build-command.ts'; + +// fsSync (exported as fsSyncCompat) is a plain object, so its properties +// can be reassigned directly on the real, already-imported module - same +// as how Docker.run is stubbed below. Deliberately NOT using mock.module +// here: dependencies.ts is imported by most of this codebase, and +// mock.module patches the module registry process-wide (bun runs all test +// files in one process) - replacing it wholesale broke ~20 unrelated tests +// elsewhere that read the real filesystem through the same module. +const originalExistsSync = fsSync.existsSync; +const originalDockerRun = Docker.run; +let originalLogInfo: ((msg: any, ...args: any[]) => void) | undefined; +let originalLogGroup: ((title: string, fn: () => Promise) => Promise) | undefined; + +beforeAll(() => { + originalLogInfo = (globalThis as any).log?.info; + originalLogGroup = (globalThis as any).log?.group; + if ((globalThis as any).log) { + (globalThis as any).log.info = () => {}; + (globalThis as any).log.group = async (_title: string, fn: () => Promise) => fn(); + } +}); + +afterAll(() => { + if ((globalThis as any).log) { + if (originalLogInfo) (globalThis as any).log.info = originalLogInfo; + if (originalLogGroup) (globalThis as any).log.group = originalLogGroup; + } +}); + +afterEach(() => { + Docker.run = originalDockerRun; + fsSync.existsSync = originalExistsSync; +}); + +describe('GodotBuildCommand', () => { + it('falls back to --import when export_presets.cfg is missing (real projects commonly omit it)', async () => { + fsSync.existsSync = () => false; + const command = new GodotBuildCommand('build'); + + const dockerRunMock = mock(async (_image: string, options: any) => { + expect(options.commands).toBe('godot --headless --verbose --import'); + }); + Docker.run = dockerRunMock as any; + + const result = await command.execute({ projectPath: '/some/project' } as any); + + expect(result).toBe(true); + expect(dockerRunMock).toHaveBeenCalledTimes(1); + }); + + it('exports for real when export_presets.cfg is present', async () => { + fsSync.existsSync = () => true; + const command = new GodotBuildCommand('build'); + + const dockerRunMock = mock(async (_image: string, options: any) => { + expect(options.commands).toBe('godot --headless --verbose --export-release "Linux/X11" build/game'); + }); + Docker.run = dockerRunMock as any; + + const result = await command.execute({ projectPath: '/some/project' } as any); + + expect(result).toBe(true); + expect(dockerRunMock).toHaveBeenCalledTimes(1); + }); + + it('checks for export_presets.cfg inside projectPath', async () => { + const existsSyncMock = mock((_path: string) => false); + fsSync.existsSync = existsSyncMock as any; + const command = new GodotBuildCommand('build'); + Docker.run = mock(async () => {}) as any; + + await command.execute({ projectPath: '/some/project' } as any); + + const calledWith = existsSyncMock.mock.calls[0][0] as string; + expect(calledWith.replace(/\\/g, '/')).toBe('/some/project/export_presets.cfg'); + }); +}); diff --git a/src/command/build/godot-build-command.ts b/src/command/build/godot-build-command.ts index 9c4e8bc..aaec495 100644 --- a/src/command/build/godot-build-command.ts +++ b/src/command/build/godot-build-command.ts @@ -2,6 +2,7 @@ import { CommandInterface } from '../command-interface.ts'; import { CommandBase } from '../command-base.ts'; import type { YargsInstance, YargsArguments } from '../../dependencies.ts'; import { ProjectOptions } from '../../command-options/project-options.ts'; +import { path, fsSync as fs } from '../../dependencies.ts'; export class GodotBuildCommand extends CommandBase implements CommandInterface { public async execute(options: YargsArguments): Promise { @@ -12,15 +13,36 @@ export class GodotBuildCommand extends CommandBase implements CommandInterface { log.info(`Building Godot project at ${projectPath}`); log.info(`Using image: ${godotImage}`); - log.info(`Export preset: ${exportPreset}`); + + // export_presets.cfg is commonly untracked (it can carry + // machine-specific paths/keystore locations - much like a .env file), + // so plenty of real, otherwise-buildable Godot projects don't have one + // checked in. Exporting without one fails outright with no useful + // signal, so fall back to `--import` (validates the project actually + // opens/imports cleanly) instead of a hard failure - the same + // accommodation this repo's own engine-smoke-test.yml already made by + // hand for its Godot fixture, now built into the command itself. + const hasExportPresets = fs.existsSync(path.join(projectPath, 'export_presets.cfg')); const { Docker } = await import('../../model/index.ts'); - await log.group('Godot export', async () => { - await Docker.run(godotImage, { - ...options, - commands: `godot --headless --verbose --export-release "${exportPreset}" ${outputPath}`, + + if (hasExportPresets) { + log.info(`Export preset: ${exportPreset}`); + await log.group('Godot export', async () => { + await Docker.run(godotImage, { + ...options, + commands: `godot --headless --verbose --export-release "${exportPreset}" ${outputPath}`, + }); }); - }); + } else { + log.info('No export_presets.cfg found - validating the project imports cleanly instead of exporting.'); + await log.group('Godot import validation', async () => { + await Docker.run(godotImage, { + ...options, + commands: 'godot --headless --verbose --import', + }); + }); + } return true; }