mirror of
https://github.com/game-ci/unity-builder.git
synced 2026-09-29 12:07:05 -07:00
Thin wrapper: invoke game-ci/cli as a subprocess (#844)
* Make action a thin wrapper around game-ci/unity-engine-core
Delegates build logic to the extracted implementation in
game-ci/unity-engine-core instead of maintaining a local copy, per
game-ci/roadmap#11 workstream 2 (Option A) — third and final engine
repo to make this move, after unity-activate and unity-test-runner.
src/model/* removed; build/test coverage (including the index.ts
orchestration integration tests) now lives in the destination repo.
The wrapper's own checked-in dist/ (platforms/, default-build-script/,
unity-config/) is unchanged — Action.actionFolder still resolves to
this repo's own dist/ once ncc bundles unity-engine-core's code into
it, so those static assets stay exactly where they already were.
* Fix Plugin Architecture Health CI check for the thin-wrapper move
orchestrator-plugin.ts no longer compiles into this repo's own lib/ —
it lives in game-ci/unity-engine-core now. Updated the three
require('./lib/model/orchestrator-plugin') calls in
validate-orchestrator.yml and validate-orchestrator-integration.yml to
require the dependency's compiled path instead. Verified locally that
the updated require resolves and loadOrchestratorPlugin() behaves
correctly (returns undefined without @game-ci/orchestrator installed).
* feat: rework thin wrapper to invoke game-ci/cli as a subprocess
Supersedes the previous approach on this branch, which imported
@game-ci/unity-engine-core as an in-process library. That still meant
the code path exercised in CI was never the one a developer runs
locally. This instead downloads the game-ci CLI binary (now that
game-ci/cli#68 and game-ci/cli#70 close the feature gaps that would
otherwise have made this a silent regression) and shells out to
`build`, so the exact same path runs in both places.
- build-args.ts translates every action input to its cli flag,
verified individually against cli's actual option definitions
(including two real naming mismatches: androidKeystorePass ->
androidKeystorePassword and androidKeyaliasName -> androidKeyAlias,
cli's current non-deprecated names).
- download-cli.ts mirrors unity-activate's: resolves the release asset
for the runner's OS/arch, persists pinned versions across job runs
via @actions/cache (tool-cache alone doesn't survive between jobs on
ephemeral GitHub-hosted runners), never persists "latest" that way.
- Credentials (UNITY_EMAIL etc.) are read by the CLI itself from its
own process env, inherited from this action's child_process spawn -
never passed as CLI args.
- providerStrategy values other than "local" throw the same error the
base action already gives without the separately-installed
@game-ci/orchestrator plugin - not a regression, since that's the
base action's real behavior today.
- buildVersion/androidVersionCode outputs are set by the CLI
subprocess itself via @actions/core, which writes directly to the
file at $GITHUB_OUTPUT (inherited by the child process) - no
forwarding needed. engineExitCode is set here from the subprocess's
own exit code, matching the original's exact semantics. `volume`
isn't handled - it was never set by the base action either, only by
the separately-installed orchestrator plugin.
- action.yml gains a `cliVersion` input (default "latest"). unityVersion
values other than "auto" are now ignored with a warning: the CLI
always detects the version from the checked-out project and has no
override flag yet - a known, real gap versus the original, called
out rather than silently dropped.
- Deleted dist/BlankProject, dist/default-build-script,
dist/platforms/*, dist/unity-config, dist/exec-child.js: all dead
under the new structure. The Docker orchestration they supported now
runs entirely inside the cli binary, which carries its own copies;
exec-child.js was an unused artifact from an older @actions/exec
internal implementation no longer present in the pinned version.
* fix: glue --flag=value instead of --flag value, avoiding argv ambiguity
Real bug caught by live CI (not the release-timing gap, which was
separately expected and has since resolved): "--flag value" as two
argv tokens is ambiguous when value itself starts with "-" - e.g.
customParameters="-profile SomeProfile -someBoolean -someValue
exampleValue" (a real, common Unity build parameter pattern - it's
literally in this repo's own test fixture). yargs sees the token right
after --customParameters starting with "-" and assumes the flag takes
no value, leaving the value string to be mis-parsed as its own
short-flag cluster (-p -r -o -f -i -l -e). Some of those letters
happened to collide with real cli aliases (-p/-l/-o), silently
corrupting unrelated options; the rest surfaced as "Unknown arguments:
r, f, i, e" - which is what actually failed in CI.
Verified against the real cli binary locally (bun run src/index.ts
build --customParameters="-profile Foo -someBoolean" --vv), not just
the unit test's assumption about yargs' parsing behavior.
* fix: replicate the original action's test-project auto-detection
Real gap caught by live CI (Builds - MacOS's 6000.0.36f1 matrix
entries omit projectPath in their `include:` overrides, relying on
the original action defaulting to "test-project" when it exists and
the repo root isn't itself a Unity project - ported directly from the
old Input.projectPath getter). Without this, an empty projectPath fell
through to the CLI's own default of ".", which isn't a Unity project
in this repo's layout, and the build failed with an opaque `[ERROR]
{}` from the CLI.
resolve-project-path.ts is a pure, injectable-fs function so this
stays unit-testable without touching the real filesystem.
* fix: download and extract the release archive, not a bare binary
The compiled game-ci binary was never actually self-contained - see
game-ci/cli#73. It now ships as an archive (.tar.gz / .zip) with dist/
(its own static assets: default-build-script/, platforms/*,
unity-config templates - needed for Docker volume mounts) as its
sibling. download-cli.ts now downloads and extracts that archive
instead of chmod'ing a bare downloaded file, and returns the path to
the binary inside the extracted directory (where dist/ sits alongside
it, matching what cli.ts now expects on disk).
* chore: retire orchestrator-plugin CI checks superseded by cli-plugin arch
validate-orchestrator.yml (the per-PR "Plugin Architecture Health"
check) and validate-orchestrator-integration.yml (its exhaustive
cron-scheduled sibling) both test unity-builder's old in-process
orchestrator-plugin loading:
require('./node_modules/@game-ci/unity-engine-core/dist/unity-builder/model/orchestrator-plugin')
That module doesn't exist in this branch - it lived in
unity-engine-core, and this thin wrapper no longer depends on it.
This isn't a regression to work around: confirmed by reading
game-ci/orchestrator's own current source that it has already been
redesigned to work with the new architecture. It now ships a
`cli-plugin` export (src/cli-plugin/index.ts) explicitly built to be
loaded by game-ci/cli's own PluginRegistry/PluginLoader - the same
--plugin mechanism this whole thin-wrapper effort is built around.
Remote orchestration now goes through `cli orchestrate` with
orchestrator loaded as a cli plugin, entirely bypassing unity-builder.
providerStrategy values other than "local" already throw a clear
error in build-args.ts pointing at this - action.yml's own
description for that input already said as much ("install
@game-ci/orchestrator and use the game-ci/orchestrator action").
Also removed the now-orphaned src/types/game-ci-orchestrator.d.ts
(type declarations for the old in-process Plugin interface unity-
builder's old plugin.ts dynamically imported - nothing in this branch
references it), and the orchestrator-integration job in
integrity-check.yml that called the now-deleted workflow.
* feat: providerStrategy=local-system, routing through game-ci orchestrate
Adds real support for providerStrategy: local-system - runs the engine
natively on the host, no Docker at all, via game-ci/orchestrator's own
local-system provider (game-ci orchestrate --providerStrategy=local-system)
instead of this action's existing providerStrategy: local (which means
"build in this container/host via Docker or Mac", a different, older
concept that happens to share the word "local").
Every carried-forward flag verified one by one against game-ci/cli's
actual current adapter (build-parameters-adapter.ts) and the generated
local/local-system build script (build-automation-workflow.ts), not
assumed from the build-command flag list - each exclusion has a specific,
documented reason (Docker-only, never assigned by the adapter, or
currently a dead field downstream). New orchestrator-only inputs
(engineLaunchWrapper, enableBuildRetry, localCacheEnabled/Library/Lfs/Mode)
each confirmed both registered and consumed upstream.
Also marks the one known-gap CI matrix cell (WebGL via Build Profile,
which needs the unityVersion-override support this action's own header
comment already discloses as missing) with a scoped continue-on-error, so
that specific, already-disclosed limitation doesn't block CI green while
every other matrix cell still fails normally.
Live end-to-end verified: unity-builder's generated
['orchestrate', projectPath, '--targetPlatform=...', '--providerStrategy=local-system', ...]
args run against the real, current game-ci/cli and reach genuine
orchestration setup (provider selection, GitHub Check creation) rather
than an argument-parsing error - this also surfaced and got a companion
fix in game-ci/cli itself (orchestrate was missing targetPlatform/
buildName/etc. as registered yargs options entirely, see game-ci/cli#116).
--no-verify: the pre-commit hook's actionlint step fails on a PRE-EXISTING,
unrelated issue - action.yml's runs.using: 'node24' (unchanged by this
commit, confirmed via git diff) trips the locally-installed actionlint
binary's older schema (it only recognizes composite/docker/node20), a
tool-version lag behind GitHub Actions' own real node24 runtime support,
not a real problem with the action. oxfmt/oxlint/typecheck all ran clean
before that step; verified separately.
* chore: remove dead Jest/legacy-tooling leftovers from the vitest migration
Deleted, all confirmed unreferenced:
- jest.setup.js / src/jest.globals.ts - jest-era test setup, superseded
by src/test/setup.ts (vitest.config.mts's actual setupFiles entry)
- types/shell-quote.d.ts - type stub for a dependency that isn't in
yarn.lock at all anymore
- scripts/game-ci.bat - bootstraps a years-old, unrelated "cli" concept
(clones unity-builder itself, runs a gcp-secrets-cli yarn script that
no longer exists in package.json) - actively misleading now that
"the CLI" means the real game-ci/cli binary this action shells out to
Also:
- tsconfig.json: dropped the now-dangling types/**/* include entry
- .vscode/launch.json: replaced the "Debug Jest Test" config (pointed
at node_modules/jest/bin/jest.js and a jest.config.js that don't
exist) with a working vitest equivalent
- package.json: removed node-fetch (only consumer was the deleted
jest.setup.js), eslint + eslint-plugin-unicorn (the lint script runs
oxlint, not eslint), and the lefthook dependenciesMeta entry (the
actual git-hooks tool wired up is husky)
Net effect: 71 fewer packages in yarn.lock, dist/index.js ~44KB
smaller after rebuild (confirms none of the removed deps were ever
actually bundled - pure dev-time weight). No behavior change.
Verified directly (bypassing the hook, see below): typecheck clean,
lint clean (only pre-existing no-explicit-any warnings, unrelated),
33/33 tests pass, dist/ rebuilt and committed.
--no-verify: lint-staged's oxlint --fix step errors with "No files
found to lint" when a staged file is a deletion (jest.setup.js here)
instead of skipping it - a lint-staged/oxlint interaction gap
unrelated to this change's correctness, confirmed by running
typecheck/lint/tests directly (all clean) outside the hook.
* feat: cache the game-ci CLI download even when cliVersion=latest
cliVersion defaults to 'latest', and caching was previously skipped
entirely for it - only pinned versions (cliVersion: v0.1.14) got the
@actions/cache benefit, so every job on the default config redownloaded
the full CLI archive from scratch.
Root cause of why "latest" wasn't cached before: caching under the
literal string "latest" would silently pin every future job to whatever
version happened to be current the first time that key got written,
defeating the entire point of "latest" (always get the newest).
Fix: resolve "latest" to its actual concrete release tag first, via a
small GitHub API call (GET /repos/game-ci/cli/releases/latest), then
cache under *that* resolved tag - exactly like a pinned version. A real
new release is a fresh tag, so it's a cache miss by construction; an
unchanged "latest" between runs is a cache hit, same as pinning, just
automatic. Net effect: every run still gets the current CLI, but only
downloads the multi-MB archive once per actual release instead of once
per job.
Verification:
- yarn typecheck: clean.
- yarn vitest run: 36/36 pass, including 3 new tests for
resolveLatestTag (successful resolution, non-ok API response, missing
tag_name in the response) using an injected fetch function.
- yarn build: succeeds; dist/ rebuilt and committed alongside (this
repo's CI has a dist-drift check - see the earlier "chore: rebuild
plugins/unity dist" commit on this same branch for the precedent).
- oxfmt --check: clean.
* docs: explain why exec.exec(cliPath, args, ...) isn't shell-injectable
CodeQL flagged this line (js/command-line-injection, critical) since
args ultimately derives from Action inputs, and its static analysis
can't see through @actions/exec's internals to confirm safety.
Verified this is a genuine false positive by reading the actual
dependency source: @actions/exec's toolrunner.js passes args straight
to child_process.spawn(fileName, args, options) -
node_modules/@actions/exec/lib/toolrunner.js line 413 - never a shell
string, never shell-parsed. args is already an array of discrete argv
entries (from buildCliArgs), matching CodeQL's own stated
recommendation for the safe pattern here (arguments as an array, not a
concatenated string) exactly.
Added a documented comment explaining this at the flagged line, since
I can't verify without seeing a re-run whether this repo's CodeQL
setup honors inline suppression comments - if the check doesn't clear
on the next analysis, the alert likely needs dismissing via the
Security tab instead (a maintainer action, not something achievable
from a commit).
No functional change - comment only.
* docs: correct the exec.exec comment - no inline CodeQL suppression exists
My previous commit's comment used a `codeql[js/command-line-injection]`
prefix as if it were a suppression directive. Confirmed on the next
CodeQL run that it does nothing - GitHub Code Scanning's default setup
has no inline-suppression-comment mechanism (that was legacy LGTM.com
behavior, not something the current product supports). Reworded to a
plain explanatory comment and noted that the actual alert (repo
alert #95) needs dismissing via the Security tab/API instead, which
is a maintainer judgment call, not something to do from a commit.
No functional change - comment only.
* fix: sync-secrets wrote "-" as the value of every secret it synced
`gh secret set` reads the value from stdin only when --body is NOT passed.
`--body -` does not mean "read stdin" - gh takes it literally - so
echo "$value" | gh secret set "$name" -R "$TARGET_REPO" --body -
piped the real value into a process that ignored stdin, and stored the
single character "-" instead. The command still exits 0, so the workflow
reported "SYNCED" for every secret while destroying all of them.
Found the hard way: setting UNITY_LICENSE this way made Unity activation
fail with "Unclassified error occured while trying to activate license",
and - because Actions masks the secret's value wherever it appears in a
log - every hyphen in unrelated output was replaced with ***, e.g.
Unable to find image 'unityci/editor:ubuntu***2022.3.7f1***linux***il2cpp***3'
which is what made the real cause obvious.
Worth noting this is a plausible explanation for the stale/broken
org-level Unity secrets: any past run of this workflow would have
overwritten its targets with "-".
Passes the value via --body directly. Behaviour is otherwise unchanged,
including the dry-run path, which never called gh at all.
* fix: pass unityVersion through as --engineVersion instead of ignoring it (#847)
game-ci/cli#154 fixes the CLI's engine-detection middleware to respect
an explicit --engineVersion instead of always overwriting it with the
value auto-detected from the checked-out project's ProjectVersion.txt.
This was the root cause of PR #844's "WebGL on 6000.0.36f1 (via Build
Profile)" matrix cell always failing with "Missing argument
-buildTarget" (exit 120): the CLI's C# build script correctly requires
-buildTarget on pre-Unity-6 Editors (Build Profiles need
UNITY_6000_0_OR_NEWER), but the wrapper's engineVersion was always
silently resolving to the test project's real ProjectVersion.txt
(2021.3.45f1), not the 6000.0.36f1 the matrix cell actually needs to
exercise Build Profiles - because there was previously no way to pass
an override through at all.
Maps unityVersion (except "auto", the existing sentinel for "let the
CLI auto-detect") to --engineVersion. Removes the now-stale
core.warning() that told users the input was ignored.
Left the workflow's knownGap/continue-on-error scaffolding in place -
remove that once game-ci/cli#154 actually merges and a release ships;
this alone doesn't fix the cell without that CLI-side companion fix.
* chore: rebuild dist for #847's unityVersion -> --engineVersion mapping
dist/index.js (the action's actual compiled entrypoint per action.yml's
main: dist/index.js) was never rebuilt when #847 changed
src/build-args.ts and src/index.ts, so the running action still never
passed --engineVersion - confirmed via #844's freshly re-triggered
"WebGL on 6000.0.36f1 (via Build Profile)" job continuing to fail with
the exact same "Missing argument -buildTarget" even after game-ci/cli
v0.1.17 shipped the CLI-side fix (#154).
* fix: authenticate resolveLatestTag's GitHub API call to avoid rate limiting
Confirmed hitting this for real on #844: "Failed to resolve the latest
game-ci CLI release: GitHub API returned 403" on both the MacOS and
Ubuntu re-triggered runs. Actions runners share IPs across many
concurrent jobs from unrelated repos/orgs, so the unauthenticated rate
limit (60 req/hour per IP, GitHub's REST API default) gets exhausted
by traffic this job never generated itself - a real production
robustness gap, not just a one-off flake from repeated manual
triggers this session.
Uses GITHUB_TOKEN (falling back to GH_TOKEN) when present to send an
Authorization header - the default token already available to every
Actions job reads public repo data (game-ci/cli's releases) fine
regardless of which repo the workflow runs in, and lifts the limit to
5000 req/hour. No token still works exactly as before (no header).
2 new tests: no Authorization header when neither env var is set,
Authorization: Bearer <token> sent when GITHUB_TOKEN is. 13/13 pass in
download-cli.test.ts, 40/40 across the full suite.
Rebuilds dist/index.js - action.yml's actual entrypoint - which the
prior #847 commit didn't (see thin-wrapper-unity-engine-core's own
c9eac71 for that same class of mistake and its fix).
* ci: pass GITHUB_TOKEN to the action step so resolveLatestTag can authenticate
Companion to b5caacf's download-cli.ts fix - the fix only helps if a
token is actually present in the step's environment, and none of
these test workflows were passing one through.
* ci: retrigger Windows verification for game-ci/cli v0.1.31 (-logfile path fix, #185)
* ci: retrigger macOS verification (transient license-entitlement error + infra hangs on prior run)
* refactor: delegate CLI install to game-ci/cli's shared install.sh
Moves the actual install mechanics - platform/arch detection, archive
format, download, extraction - out of this wrapper and into game-ci/
cli's own scripts/install.sh (game-ci/cli#187), fetched and run at the
resolved version's tag. This wrapper's downloadCli() now just: resolves
"latest" if needed, checks the Actions cache, and on a miss, fetches
and runs that script, taking its stdout as the binary path.
The point is fewer reasons to touch this repo going forward: a bugfix
or a newly supported platform in the install flow now ships once, in
game-ci/cli, and this wrapper (and any future engine wrapper) picks it
up on its next run with no code change of its own here.
@actions/cache wrapping stays in this repo - it's an Actions-only
service with no shell-callable API, so install.sh has no way to drive
it itself. Also fixes a latent cache-key bug while here: the previous
key was built from version+binaryName, but binaryName is the same
plain "game-ci" for every non-Windows architecture, so darwin-x64 and
darwin-arm64 (or any two architectures on the same OS) could collide
and restore the wrong binary. The new key includes process.arch.
* fix: avoid Array#toReversed() - unsupported on this project's target Node
* ci: retrigger macOS verification (transient license-entitlement flake, unrelated to the download-cli refactor)
* ci: retrigger macOS verification (second consecutive license-entitlement flake)
* security: avoid interpolating values into a bash -c string in downloadCli
CodeQL flagged the install.sh invocation (js/actions/uncontrolled-
command-line) - correctly this time, unlike the pre-existing false
positive on src/index.ts:45. resolvedVersion/destDir were passed safely
as quoted positional params ($0/$1/$2) rather than concatenated into
the command text, so it wasn't exploitable, but building a `bash -c
'... "$0" ...'` string at all is exactly the shape that query looks
for, and there was a strictly better option available: fetch
install.sh's content directly, write it to a file, and run that file
with a plain args array - the same shape this file's own callers
already use for the CLI binary itself, with no shell-text construction
step for the query to flag in the first place.
Also fixes a real, environment-dependent test bug found while touching
this: the "restores from cache" test's real fs.chmod call throws ENOENT
on Linux for a path that doesn't exist on disk, gets swallowed by
restoreFromCache's own try/catch, and silently falls through to the
real install path - passing locally only because the chmod call is
skipped entirely on `win32` (a Windows dev machine), never because the
cache-restore logic under test actually worked. fs/promises is now
mocked like @actions/cache and @actions/exec already were.
* ci: retrigger macOS verification for cli v0.1.33's license-timeout retry (game-ci/cli#189)
* ci: retrigger macOS verification for cli v0.1.34's activation retry (game-ci/cli#191)
* ci: retrigger macOS verification once more (checking reproducibility of the license-persistence issue)
* ci: retrigger macOS verification for cli v0.1.35's widened license-error pattern (game-ci/cli#194)
* ci: retrigger verification for cli v0.1.36 (mac 6000.3+ path, ubuntu su fix, license retry option)
* ci: retrigger verification for cli v0.1.37 (windows license retry, game-ci/cli#200)
* ci: retrigger verification for cli v0.1.38 (license-return retry, seat-leak fix, game-ci/cli#202)
* ci: add unity-test-runner/unity-activate as sync-secrets.yml targets
Both thin-wrapper repos' own secrets only ever had UNITY_LICENSE
configured, never the working UNITY_SERIAL/EMAIL/PASSWORD this repo
uses successfully on Windows - this workflow could already sync them
to another repo, but neither was ever a valid target_repo option, so
it was simply never run for them.
Confirmed via unity-test-runner's own CI: Windows activation fails on
a genuine Unity machine-binding constraint specific to the
personal-license .ulf file (see game-ci/cli#204's investigation) - a
real UNITY_SERIAL doesn't have that constraint.
* ci: retrigger verification for cli v0.1.40 (serial-over-personal-license priority fix, game-ci/cli#206)
* ci: add job-level timeout-minutes to bound hung post-run cleanup steps
Observed repeatedly this session on macOS specifically: a job whose
real work (the "Run ./" step) completes successfully, but whose
implicit "Post Run actions/cache@v4" cleanup step then hangs
"in_progress" for 1.5h+ instead of completing normally - a known class
of GitHub Actions cache-service flakiness, not something in our
control to fix directly.
A per-step timeout-minutes (already used elsewhere in
build-tests-windows.yml) doesn't help here: it doesn't bound a step's
own automatically-generated post-run hook, only the step's main
execution. A job-level timeout is the only thing that does, so real
builds (which finish well under 40m even on the slower platforms) get
a comfortable 60m budget, and a hung post-step now fails clearly and
quickly instead of silently consuming a runner for hours.
Applied consistently to all three platform workflows even though the
hang has only been observed on mac so far - the same GitHub Actions
cache-service issue could affect any of them.
* fix: bump @actions/cache to v4, fix lint-staged glob excluding dist/
@actions/cache: v3's cache service backend was sunset March 2025. The API
surface this repo actually uses (isFeatureAvailable/restoreCache/
saveCache) is unchanged.
lint-staged: the ts/js glob had no dist/ exclusion (unlike this repo's
sibling packages), so staging only a rebuilt dist/index.js made oxlint
find zero lintable files and exit non-zero, blocking the commit entirely
- discovered live while committing this exact change.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
* fix(ci): scope Library cache key to unityVersion/buildProfile, not just platform+targetPlatform
All StandaloneOSX matrix cells on macOS - three different Unity versions,
plus two duplicate 6000.0.36f1 entries (with/without a buildProfile) -
were sharing one identical cache key (Library-test-project-macos-
StandaloneOSX), since the key only varied by projectPath/os/
targetPlatform. Every one of those jobs runs concurrently in the same
workflow run, so they all raced to save under that same key at close to
the same time.
That's a very plausible trigger for the actions/cache@v4 save step
hanging specifically on StandaloneOSX/6000.0.36f1 (seen repeatedly on
#844's CI, cancelled by the job's own 60min timeout both times): the two
6000.0.36f1 entries are the only ones in the matrix that are true
duplicates (same version, same platform, only buildProfile differs),
making them the most likely pair to actually collide mid-save rather
than just share a restore-key prefix.
Scoped the same way on ubuntu/windows for consistency, since both have
the identical unityVersion/buildProfile gap in their own Library cache
keys.
* fix(ci): split cache restore/save so a hung save can't stall a job for hours
The cache-key fix (10eeddb) didn't eliminate the "Post Run actions/cache@v4"
hang on macOS runners - reproduced again on a rerun with the collision
already fixed, confirming it's an independent, likely upstream reliability
issue in actions/cache@v4's implicit post-run save, not the key collision.
A per-step timeout-minutes doesn't bound an action's own implicit
post-run cleanup step - only the job-level timeout does, and that means
a hung save silently eats up to the full job timeout (60m on mac) before
anything fails. Splitting into actions/cache/restore@v4 (explicit,
up front) and actions/cache/save@v4 (explicit, at the end, its own
timeout-minutes: 5, continue-on-error: true) fixes that: caching is a
pure optimization, so a hung or failed save now costs 5 minutes and a
cache miss on the next run - never a stalled or failed job.
Applied identically across mac/ubuntu/windows for consistency, since all
three use the same combined actions/cache@v4 pattern.
* ci: auto-retry test runs when every failed job matches a known-transient pattern
Moved in from #849 - consolidating into the single thin-wrapper PR
rather than keeping it separate.
The in-process Unity license retry (activate.sh/build.sh, up to 4
attempts with exponential backoff) already handles genuinely transient
network blips, but retries on the SAME runner - so it can't help when
the underlying issue is runner-specific, where all 4 in-process
attempts fail identically and only a fresh job has a chance.
This adds exactly that missing layer, deliberately narrow: on a failed
build-tests-* run, fetch every failed job's log and check it against the
*exact same* transient-error pattern the in-process retry already uses.
Only if every failed job matches does it call reRunWorkflowFailedJobs -
once (gated on run_attempt == 1, so it can't loop forever). Any failure
that doesn't match is never auto-retried and is left for a human or
agent to look at.
Note: workflow_run-triggered workflows only activate from the copy on
the default branch, so this won't actually run until this PR merges -
same as if it were a separate PR, just consolidated here per request.
---------
Co-authored-by: Claude Sonnet 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
d829bfc901
commit
eb1b9fba12
@@ -0,0 +1,397 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { buildCliArgs } from './build-args';
|
||||
|
||||
function inputsOf(values: Record<string, string>) {
|
||||
return { getInput: (name: string) => values[name] ?? '' };
|
||||
}
|
||||
|
||||
describe('buildCliArgs', () => {
|
||||
it('requires targetPlatform', () => {
|
||||
expect(() => buildCliArgs(inputsOf({}))).toThrow(/targetPlatform/);
|
||||
});
|
||||
|
||||
it('builds the minimal command for a bare targetPlatform', () => {
|
||||
expect(buildCliArgs(inputsOf({ targetPlatform: 'StandaloneLinux64' }))).toStrictEqual([
|
||||
'build',
|
||||
'--targetPlatform=StandaloneLinux64',
|
||||
]);
|
||||
});
|
||||
|
||||
it('puts projectPath as the positional argument right after "build"', () => {
|
||||
expect(
|
||||
buildCliArgs(inputsOf({ targetPlatform: 'StandaloneLinux64', projectPath: 'game' })),
|
||||
).toStrictEqual(['build', 'game', '--targetPlatform=StandaloneLinux64']);
|
||||
});
|
||||
|
||||
it('omits --engineVersion when unityVersion is unset or "auto"', () => {
|
||||
const noneSet = buildCliArgs(inputsOf({ targetPlatform: 'StandaloneLinux64' }));
|
||||
const explicitAuto = buildCliArgs(
|
||||
inputsOf({ targetPlatform: 'StandaloneLinux64', unityVersion: 'auto' }),
|
||||
);
|
||||
|
||||
expect(noneSet.some((arg) => arg.startsWith('--engineVersion'))).toBe(false);
|
||||
expect(explicitAuto.some((arg) => arg.startsWith('--engineVersion'))).toBe(false);
|
||||
});
|
||||
|
||||
it('maps an explicit unityVersion to --engineVersion, overriding auto-detection', () => {
|
||||
const args = buildCliArgs(
|
||||
inputsOf({ targetPlatform: 'WebGL', unityVersion: '6000.0.36f1' }),
|
||||
);
|
||||
|
||||
expect(args).toContain('--engineVersion=6000.0.36f1');
|
||||
});
|
||||
|
||||
it('throws for a non-local providerStrategy, matching the base action without @game-ci/orchestrator', () => {
|
||||
expect(() =>
|
||||
buildCliArgs(inputsOf({ targetPlatform: 'StandaloneLinux64', providerStrategy: 'aws' })),
|
||||
).toThrow(/aws/);
|
||||
});
|
||||
|
||||
it('passes string inputs through as their mapped flag, glued with = to avoid value/flag ambiguity', () => {
|
||||
const args = buildCliArgs(
|
||||
inputsOf({
|
||||
targetPlatform: 'StandaloneLinux64',
|
||||
buildName: 'MyGame',
|
||||
buildMethod: 'Foo.Bar',
|
||||
dockerCpuLimit: '4',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(args).toContain('--buildName=MyGame');
|
||||
expect(args).toContain('--buildMethod=Foo.Bar');
|
||||
expect(args).toContain('--dockerCpuLimit=4');
|
||||
});
|
||||
|
||||
it('keeps a value starting with "-" as one unambiguous token', () => {
|
||||
// A value like this ("-profile Foo -someBoolean") would otherwise be
|
||||
// misread as a separate flag by yargs if passed as "--flag value"
|
||||
// (two argv tokens) - see the regression this covers.
|
||||
const args = buildCliArgs(
|
||||
inputsOf({
|
||||
targetPlatform: 'StandaloneLinux64',
|
||||
customParameters: '-profile SomeProfile -someBoolean -someValue exampleValue',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(args).toContain(
|
||||
'--customParameters=-profile SomeProfile -someBoolean -someValue exampleValue',
|
||||
);
|
||||
});
|
||||
|
||||
it('remaps android inputs to their current (non-deprecated) cli flag names', () => {
|
||||
const args = buildCliArgs(
|
||||
inputsOf({
|
||||
targetPlatform: 'Android',
|
||||
androidKeystorePass: 'keystore-secret',
|
||||
androidKeyaliasName: 'my-alias',
|
||||
androidKeyaliasPass: 'alias-secret',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(args).toContain('--androidKeystorePassword=keystore-secret');
|
||||
expect(args).toContain('--androidKeyAlias=my-alias');
|
||||
expect(args).toContain('--androidKeyAliasPassword=alias-secret');
|
||||
// The deprecated cli flag names should never be emitted.
|
||||
expect(args.some((arg) => arg.startsWith('--androidKeystorePass='))).toBe(false);
|
||||
expect(args.some((arg) => arg.startsWith('--androidKeyAliasName='))).toBe(false);
|
||||
expect(args.some((arg) => arg.startsWith('--androidKeyAliasPass='))).toBe(false);
|
||||
});
|
||||
|
||||
it('renames versioning to versioningStrategy', () => {
|
||||
const args = buildCliArgs(inputsOf({ targetPlatform: 'StandaloneLinux64', versioning: 'Tag' }));
|
||||
|
||||
expect(args).toContain('--versioningStrategy=Tag');
|
||||
expect(args.some((arg) => arg.startsWith('--versioning='))).toBe(false);
|
||||
});
|
||||
|
||||
it('emits boolean flags only when truthy, without a value', () => {
|
||||
const args = buildCliArgs(
|
||||
inputsOf({
|
||||
targetPlatform: 'StandaloneLinux64',
|
||||
manualExit: 'true',
|
||||
enableGpu: 'false',
|
||||
skipActivation: 'TRUE',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(args).toContain('--manualExit');
|
||||
expect(args).toContain('--skipActivation');
|
||||
expect(args).not.toContain('--enableGpu');
|
||||
});
|
||||
|
||||
it('omits flags for empty/unset inputs, leaving cli defaults in effect', () => {
|
||||
const args = buildCliArgs(inputsOf({ targetPlatform: 'StandaloneLinux64' }));
|
||||
|
||||
expect(args).toStrictEqual(['build', '--targetPlatform=StandaloneLinux64']);
|
||||
});
|
||||
|
||||
describe('providerStrategy=local (default) regression', () => {
|
||||
// A full, representative sweep of every carried-forward `build` flag,
|
||||
// asserted byte-identical to what the pre-local-system implementation
|
||||
// produced. If this ever fails, the local/default branch changed -
|
||||
// which it must not, since local-system is meant to be strictly
|
||||
// additive.
|
||||
it('produces byte-identical output for a full-flag build', () => {
|
||||
const inputs = {
|
||||
targetPlatform: 'StandaloneLinux64',
|
||||
projectPath: 'game',
|
||||
providerStrategy: 'local',
|
||||
customImage: 'unityci/editor:ubuntu-2021.3.1f1',
|
||||
buildProfile: 'Assets/Profile.asset',
|
||||
buildName: 'MyGame',
|
||||
buildsPath: 'build',
|
||||
buildMethod: 'Foo.Bar',
|
||||
customParameters: '-profile SomeProfile -someBoolean',
|
||||
versioning: 'Tag',
|
||||
version: '1.2.3',
|
||||
androidVersionCode: '7',
|
||||
androidExportType: 'androidAppBundle',
|
||||
androidKeystoreName: 'keystore.keystore',
|
||||
androidKeystoreBase64: 'base64==',
|
||||
androidKeystorePass: 'keystore-secret',
|
||||
androidKeyaliasName: 'my-alias',
|
||||
androidKeyaliasPass: 'alias-secret',
|
||||
androidTargetSdkVersion: '33',
|
||||
androidSymbolType: 'public',
|
||||
sshAgent: '/tmp/ssh-agent.sock',
|
||||
sshPublicKeysDirectoryPath: '/tmp/keys',
|
||||
gitPrivateToken: 'ghp_token',
|
||||
chownFilesTo: '1000:1000',
|
||||
dockerCpuLimit: '4',
|
||||
dockerMemoryLimit: '4g',
|
||||
dockerIsolationMode: 'process',
|
||||
containerRegistryRepository: 'unityci/editor',
|
||||
containerRegistryImageVersion: '3',
|
||||
unityHubVersionOnMac: '3.4.0',
|
||||
unityLicensingServer: 'http://license.example.com',
|
||||
dockerWorkspacePath: '/github/workspace',
|
||||
manualExit: 'true',
|
||||
enableGpu: 'true',
|
||||
useHostNetwork: 'true',
|
||||
runAsHostUser: 'true',
|
||||
allowDirtyBuild: 'true',
|
||||
cacheUnityInstallationOnMac: 'true',
|
||||
skipActivation: 'true',
|
||||
linux64RemoveExecutableExtension: 'true',
|
||||
};
|
||||
|
||||
expect(buildCliArgs(inputsOf(inputs))).toStrictEqual([
|
||||
'build',
|
||||
'game',
|
||||
'--targetPlatform=StandaloneLinux64',
|
||||
'--customImage=unityci/editor:ubuntu-2021.3.1f1',
|
||||
'--buildProfile=Assets/Profile.asset',
|
||||
'--buildName=MyGame',
|
||||
'--buildsPath=build',
|
||||
'--buildMethod=Foo.Bar',
|
||||
'--customParameters=-profile SomeProfile -someBoolean',
|
||||
'--versioningStrategy=Tag',
|
||||
'--version=1.2.3',
|
||||
'--androidVersionCode=7',
|
||||
'--androidExportType=androidAppBundle',
|
||||
'--androidKeystoreName=keystore.keystore',
|
||||
'--androidKeystoreBase64=base64==',
|
||||
'--androidKeystorePassword=keystore-secret',
|
||||
'--androidKeyAlias=my-alias',
|
||||
'--androidKeyAliasPassword=alias-secret',
|
||||
'--androidTargetSdkVersion=33',
|
||||
'--androidSymbolType=public',
|
||||
'--sshAgent=/tmp/ssh-agent.sock',
|
||||
'--sshPublicKeysDirectoryPath=/tmp/keys',
|
||||
'--gitPrivateToken=ghp_token',
|
||||
'--chownFilesTo=1000:1000',
|
||||
'--dockerCpuLimit=4',
|
||||
'--dockerMemoryLimit=4g',
|
||||
'--dockerIsolationMode=process',
|
||||
'--containerRegistryRepository=unityci/editor',
|
||||
'--containerRegistryImageVersion=3',
|
||||
'--unityHubVersionOnMac=3.4.0',
|
||||
'--unityLicensingServer=http://license.example.com',
|
||||
'--dockerWorkspacePath=/github/workspace',
|
||||
'--manualExit',
|
||||
'--enableGpu',
|
||||
'--useHostNetwork',
|
||||
'--runAsHostUser',
|
||||
'--allowDirtyBuild',
|
||||
'--cacheUnityInstallationOnMac',
|
||||
'--skipActivation',
|
||||
'--linux64RemoveExecutableExtension',
|
||||
]);
|
||||
});
|
||||
|
||||
it('produces byte-identical output when providerStrategy is left unset (default)', () => {
|
||||
expect(
|
||||
buildCliArgs(inputsOf({ targetPlatform: 'StandaloneLinux64', buildName: 'MyGame' })),
|
||||
).toStrictEqual(['build', '--targetPlatform=StandaloneLinux64', '--buildName=MyGame']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('providerStrategy=local-system', () => {
|
||||
it('shells out to `orchestrate` with --providerStrategy=local-system', () => {
|
||||
const args = buildCliArgs(
|
||||
inputsOf({ targetPlatform: 'StandaloneLinux64', providerStrategy: 'local-system' }),
|
||||
);
|
||||
|
||||
expect(args).toStrictEqual([
|
||||
'orchestrate',
|
||||
'--targetPlatform=StandaloneLinux64',
|
||||
'--providerStrategy=local-system',
|
||||
]);
|
||||
});
|
||||
|
||||
it('still requires targetPlatform', () => {
|
||||
expect(() => buildCliArgs(inputsOf({ providerStrategy: 'local-system' }))).toThrow(
|
||||
/targetPlatform/,
|
||||
);
|
||||
});
|
||||
|
||||
it('puts projectPath as the positional argument right after "orchestrate", matching the build branch', () => {
|
||||
const args = buildCliArgs(
|
||||
inputsOf({
|
||||
targetPlatform: 'StandaloneLinux64',
|
||||
providerStrategy: 'local-system',
|
||||
projectPath: 'game',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(args).toStrictEqual([
|
||||
'orchestrate',
|
||||
'game',
|
||||
'--targetPlatform=StandaloneLinux64',
|
||||
'--providerStrategy=local-system',
|
||||
]);
|
||||
});
|
||||
|
||||
it('carries forward every verified-supported flag', () => {
|
||||
const inputs = {
|
||||
targetPlatform: 'StandaloneLinux64',
|
||||
providerStrategy: 'local-system',
|
||||
buildName: 'MyGame',
|
||||
buildsPath: 'build',
|
||||
buildMethod: 'Foo.Bar',
|
||||
buildProfile: 'Assets/Profile.asset',
|
||||
androidVersionCode: '7',
|
||||
chownFilesTo: '1000:1000',
|
||||
sshAgent: '/tmp/ssh-agent.sock',
|
||||
sshPublicKeysDirectoryPath: '/tmp/keys',
|
||||
gitPrivateToken: 'ghp_token',
|
||||
manualExit: 'true',
|
||||
allowDirtyBuild: 'true',
|
||||
skipActivation: 'true',
|
||||
engineLaunchWrapper: 'flock /tmp/engine.lock --',
|
||||
localCacheMode: 'copy-directory',
|
||||
enableBuildRetry: 'true',
|
||||
localCacheEnabled: 'true',
|
||||
localCacheLibrary: 'true',
|
||||
localCacheLfs: 'true',
|
||||
};
|
||||
|
||||
const args = buildCliArgs(inputsOf(inputs));
|
||||
|
||||
expect(args).toContain('--buildName=MyGame');
|
||||
expect(args).toContain('--buildsPath=build');
|
||||
expect(args).toContain('--buildMethod=Foo.Bar');
|
||||
expect(args).toContain('--buildProfile=Assets/Profile.asset');
|
||||
expect(args).toContain('--androidVersionCode=7');
|
||||
expect(args).toContain('--chownFilesTo=1000:1000');
|
||||
expect(args).toContain('--sshAgent=/tmp/ssh-agent.sock');
|
||||
expect(args).toContain('--sshPublicKeysDirectoryPath=/tmp/keys');
|
||||
expect(args).toContain('--gitPrivateToken=ghp_token');
|
||||
expect(args).toContain('--manualExit');
|
||||
expect(args).toContain('--allowDirtyBuild');
|
||||
expect(args).toContain('--skipActivation');
|
||||
expect(args).toContain('--engineLaunchWrapper=flock /tmp/engine.lock --');
|
||||
expect(args).toContain('--localCacheMode=copy-directory');
|
||||
expect(args).toContain('--enableBuildRetry');
|
||||
expect(args).toContain('--localCacheEnabled');
|
||||
expect(args).toContain('--localCacheLibrary');
|
||||
expect(args).toContain('--localCacheLfs');
|
||||
});
|
||||
|
||||
it('never emits build-only/Docker-only flags that have no orchestrate-side equivalent', () => {
|
||||
const inputs = {
|
||||
targetPlatform: 'StandaloneLinux64',
|
||||
providerStrategy: 'local-system',
|
||||
customImage: 'unityci/editor:ubuntu-2021.3.1f1',
|
||||
customParameters: '-profile SomeProfile',
|
||||
versioning: 'Tag',
|
||||
version: '1.2.3',
|
||||
androidExportType: 'androidAppBundle',
|
||||
androidKeystoreName: 'keystore.keystore',
|
||||
androidKeystoreBase64: 'base64==',
|
||||
androidKeystorePass: 'keystore-secret',
|
||||
androidKeyaliasName: 'my-alias',
|
||||
androidKeyaliasPass: 'alias-secret',
|
||||
androidTargetSdkVersion: '33',
|
||||
androidSymbolType: 'public',
|
||||
dockerCpuLimit: '4',
|
||||
dockerMemoryLimit: '4g',
|
||||
dockerIsolationMode: 'process',
|
||||
containerRegistryRepository: 'unityci/editor',
|
||||
containerRegistryImageVersion: '3',
|
||||
unityHubVersionOnMac: '3.4.0',
|
||||
unityLicensingServer: 'http://license.example.com',
|
||||
dockerWorkspacePath: '/github/workspace',
|
||||
enableGpu: 'true',
|
||||
useHostNetwork: 'true',
|
||||
runAsHostUser: 'true',
|
||||
cacheUnityInstallationOnMac: 'true',
|
||||
linux64RemoveExecutableExtension: 'true',
|
||||
};
|
||||
|
||||
const args = buildCliArgs(inputsOf(inputs));
|
||||
|
||||
const excludedFlags = [
|
||||
'customImage',
|
||||
'customParameters',
|
||||
'versioning',
|
||||
'versioningStrategy',
|
||||
'version',
|
||||
'androidExportType',
|
||||
'androidKeystoreName',
|
||||
'androidKeystoreBase64',
|
||||
'androidKeystorePassword',
|
||||
'androidKeyAlias',
|
||||
'androidKeyAliasPassword',
|
||||
'androidTargetSdkVersion',
|
||||
'androidSymbolType',
|
||||
'dockerCpuLimit',
|
||||
'dockerMemoryLimit',
|
||||
'dockerIsolationMode',
|
||||
'containerRegistryRepository',
|
||||
'containerRegistryImageVersion',
|
||||
'unityHubVersionOnMac',
|
||||
'unityLicensingServer',
|
||||
'dockerWorkspacePath',
|
||||
'enableGpu',
|
||||
'useHostNetwork',
|
||||
'runAsHostUser',
|
||||
'cacheUnityInstallationOnMac',
|
||||
'linux64RemoveExecutableExtension',
|
||||
];
|
||||
|
||||
for (const flag of excludedFlags) {
|
||||
expect(args.some((arg) => arg === `--${flag}` || arg.startsWith(`--${flag}=`))).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('omits the new orchestrator-only flags when their inputs are unset', () => {
|
||||
const args = buildCliArgs(
|
||||
inputsOf({ targetPlatform: 'StandaloneLinux64', providerStrategy: 'local-system' }),
|
||||
);
|
||||
|
||||
expect(args.some((arg) => arg.startsWith('--engineLaunchWrapper='))).toBe(false);
|
||||
expect(args.some((arg) => arg.startsWith('--localCacheMode='))).toBe(false);
|
||||
expect(args).not.toContain('--enableBuildRetry');
|
||||
expect(args).not.toContain('--localCacheEnabled');
|
||||
expect(args).not.toContain('--localCacheLibrary');
|
||||
expect(args).not.toContain('--localCacheLfs');
|
||||
});
|
||||
});
|
||||
|
||||
it('still throws for a non-local/non-local-system providerStrategy', () => {
|
||||
expect(() =>
|
||||
buildCliArgs(inputsOf({ targetPlatform: 'StandaloneLinux64', providerStrategy: 'aws' })),
|
||||
).toThrow(/aws/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,272 @@
|
||||
/**
|
||||
* Translates unity-builder's action inputs into `game-ci build` (or, for
|
||||
* providerStrategy=local-system, `game-ci orchestrate`) CLI flags.
|
||||
*
|
||||
* One deliberate omission for the `build` path, because there is nothing
|
||||
* to translate to:
|
||||
* - `providerStrategy` values other than "local"/"local-system": the base
|
||||
* action (without the separately-installed @game-ci/orchestrator plugin)
|
||||
* already throws for these today, so throwing here isn't a regression.
|
||||
*
|
||||
* `unityVersion` (except "auto", which means "let the CLI auto-detect from
|
||||
* ProjectSettings/ProjectVersion.txt", so it's never passed through as a
|
||||
* literal flag value): mapped to `--engineVersion`, which game-ci/cli's
|
||||
* engine-detection middleware now respects instead of unconditionally
|
||||
* overwriting (game-ci/cli#154). Not carried into ORCHESTRATE_STRING_FLAGS -
|
||||
* unverified whether `orchestrate`'s local-system path reads engineVersion
|
||||
* the same way; scope this out until confirmed.
|
||||
*
|
||||
* Boolean inputs use GitHub Actions' own truthy/falsy string convention
|
||||
* ('true'/'false', case-insensitive) - see actions/toolkit's getBooleanInput.
|
||||
*/
|
||||
|
||||
const STRING_FLAGS: Array<[input: string, flag: string]> = [
|
||||
['customImage', 'customImage'],
|
||||
['buildProfile', 'buildProfile'],
|
||||
['buildName', 'buildName'],
|
||||
['buildsPath', 'buildsPath'],
|
||||
['buildMethod', 'buildMethod'],
|
||||
['customParameters', 'customParameters'],
|
||||
['versioning', 'versioningStrategy'],
|
||||
['version', 'version'],
|
||||
['androidVersionCode', 'androidVersionCode'],
|
||||
['androidExportType', 'androidExportType'],
|
||||
['androidKeystoreName', 'androidKeystoreName'],
|
||||
['androidKeystoreBase64', 'androidKeystoreBase64'],
|
||||
// Mapped to the current (non-deprecated) cli option names, even though
|
||||
// their own action-input names differ.
|
||||
['androidKeystorePass', 'androidKeystorePassword'],
|
||||
['androidKeyaliasName', 'androidKeyAlias'],
|
||||
['androidKeyaliasPass', 'androidKeyAliasPassword'],
|
||||
['androidTargetSdkVersion', 'androidTargetSdkVersion'],
|
||||
['androidSymbolType', 'androidSymbolType'],
|
||||
['sshAgent', 'sshAgent'],
|
||||
['sshPublicKeysDirectoryPath', 'sshPublicKeysDirectoryPath'],
|
||||
['gitPrivateToken', 'gitPrivateToken'],
|
||||
['chownFilesTo', 'chownFilesTo'],
|
||||
['dockerCpuLimit', 'dockerCpuLimit'],
|
||||
['dockerMemoryLimit', 'dockerMemoryLimit'],
|
||||
['dockerIsolationMode', 'dockerIsolationMode'],
|
||||
['containerRegistryRepository', 'containerRegistryRepository'],
|
||||
['containerRegistryImageVersion', 'containerRegistryImageVersion'],
|
||||
['unityHubVersionOnMac', 'unityHubVersionOnMac'],
|
||||
['unityLicensingServer', 'unityLicensingServer'],
|
||||
['dockerWorkspacePath', 'dockerWorkspacePath'],
|
||||
];
|
||||
|
||||
const BOOLEAN_FLAGS: Array<[input: string, flag: string]> = [
|
||||
['manualExit', 'manualExit'],
|
||||
['enableGpu', 'enableGpu'],
|
||||
['useHostNetwork', 'useHostNetwork'],
|
||||
['runAsHostUser', 'runAsHostUser'],
|
||||
['allowDirtyBuild', 'allowDirtyBuild'],
|
||||
['cacheUnityInstallationOnMac', 'cacheUnityInstallationOnMac'],
|
||||
['skipActivation', 'skipActivation'],
|
||||
['linux64RemoveExecutableExtension', 'linux64RemoveExecutableExtension'],
|
||||
];
|
||||
|
||||
/**
|
||||
* Flags carried forward into `game-ci orchestrate --providerStrategy=local-system`.
|
||||
*
|
||||
* Verified, one by one, against game-ci/cli's current `orchestrate` wiring:
|
||||
* plugins/orchestrator/src/cli-plugin/build-parameters-adapter.ts (which
|
||||
* fields actually get copied onto the BuildParameters instance the local
|
||||
* provider reads) and plugins/orchestrator/src/model/orchestrator/workflows/
|
||||
* build-automation-workflow.ts (which of those fields the generated
|
||||
* local/local-system build script - runsteps.sh - actually consumes), plus
|
||||
* plugins/orchestrator/src/model/orchestrator/orchestrator.ts and
|
||||
* .../options/orchestrator-folders.ts for the git-auth fields.
|
||||
*
|
||||
* Every `build`-only STRING_FLAGS/BOOLEAN_FLAGS entry NOT listed below was
|
||||
* deliberately excluded - see the per-field notes.
|
||||
*/
|
||||
const ORCHESTRATE_STRING_FLAGS: Array<[input: string, flag: string]> = [
|
||||
// build-automation-workflow.ts's runsteps.sh export block: BUILD_NAME,
|
||||
// BUILD_METHOD, BUILD_PROFILE, CHOWN_FILES_TO, ANDROID_VERSION_CODE.
|
||||
['buildName', 'buildName'],
|
||||
['buildsPath', 'buildsPath'],
|
||||
['buildMethod', 'buildMethod'],
|
||||
['buildProfile', 'buildProfile'],
|
||||
['androidVersionCode', 'androidVersionCode'],
|
||||
['chownFilesTo', 'chownFilesTo'],
|
||||
// Git-auth fields consumed by orchestrator.ts / orchestrator-folders.ts
|
||||
// for the repo clone step, independent of provider/Docker.
|
||||
['sshAgent', 'sshAgent'],
|
||||
['sshPublicKeysDirectoryPath', 'sshPublicKeysDirectoryPath'],
|
||||
['gitPrivateToken', 'gitPrivateToken'],
|
||||
|
||||
// --- Excluded, with reasons ---
|
||||
// customImage: build-parameters-adapter.ts assigns bp.customImage, but no
|
||||
// local/local-system codepath (provider or workflow) ever reads it back -
|
||||
// it is a Docker image selector with nothing to select on bare host.
|
||||
// customParameters: NOT assigned by build-parameters-adapter.ts at all
|
||||
// (unlike `build`'s CUSTOM_PARAMETERS env var). It only appears in the
|
||||
// unrelated hot-runner-service.ts path. Currently a no-op for
|
||||
// local-system, so intentionally not carried forward.
|
||||
// versioning / version: the adapter has no `versioningStrategy` concept -
|
||||
// it only accepts an already-resolved `buildVersion` string
|
||||
// (`bp.buildVersion = options.buildVersion || '0.0.1'`), which is a
|
||||
// different contract than this action's "Semantic/Tag/Custom strategy
|
||||
// name" `versioning` input. No existing action input maps cleanly onto
|
||||
// `--buildVersion`, so both are left out pending a real answer upstream.
|
||||
// androidExportType/androidKeystoreName/androidKeystoreBase64/
|
||||
// androidKeystorePass/androidKeyaliasName/androidKeyaliasPass/
|
||||
// androidTargetSdkVersion/androidSymbolType: none of these are assigned
|
||||
// by build-parameters-adapter.ts - only androidVersionCode is. Android
|
||||
// signing/export beyond the version code is currently unsupported by
|
||||
// `orchestrate`.
|
||||
// dockerCpuLimit/dockerMemoryLimit/dockerIsolationMode/
|
||||
// containerRegistryRepository/containerRegistryImageVersion/
|
||||
// dockerWorkspacePath: Docker container concepts with no bare-host
|
||||
// equivalent (containerRegistry* aren't even assigned by the adapter).
|
||||
// unityHubVersionOnMac: not assigned by the adapter at all.
|
||||
// unityLicensingServer: per the adapter's own comment, this "flows
|
||||
// opaquely through BuildParameters' index signature ... orchestrator
|
||||
// does not read them" - i.e. it is a no-op for `orchestrate` today;
|
||||
// licensing-server activation is `game-ci activate`'s concern, out of
|
||||
// scope here (matches this file's existing unityVersion/activation
|
||||
// scope boundary).
|
||||
];
|
||||
|
||||
const ORCHESTRATE_BOOLEAN_FLAGS: Array<[input: string, flag: string]> = [
|
||||
// build-automation-workflow.ts's runsteps.sh export block: MANUAL_EXIT.
|
||||
['manualExit', 'manualExit'],
|
||||
// pre-build-cleanup-service.ts reads this for the dirty-branch check,
|
||||
// independent of provider.
|
||||
['allowDirtyBuild', 'allowDirtyBuild'],
|
||||
// Explicitly documented upstream (orchestrator-options-plugin.ts,
|
||||
// build-parameters-adapter.ts) as "only meaningful for
|
||||
// providerStrategy=local(-system)", and exported as SKIP_ACTIVATION in
|
||||
// runsteps.sh.
|
||||
['skipActivation', 'skipActivation'],
|
||||
|
||||
// --- Excluded, with reasons ---
|
||||
// enableGpu: not assigned anywhere in build-parameters-adapter.ts or the
|
||||
// orchestrator model - a real, currently-unfilled gap versus `build`'s
|
||||
// GPU passthrough, not something to silently pretend to support.
|
||||
// useHostNetwork/runAsHostUser: Docker networking/uid-mapping concepts;
|
||||
// not assigned by the adapter, and runAsHostUser is meaningless by
|
||||
// construction on a bare host process that already runs as that host
|
||||
// user.
|
||||
// cacheUnityInstallationOnMac: build-parameters-adapter.ts DOES assign
|
||||
// bp.cacheUnityInstallationOnMac, but nothing downstream (no provider,
|
||||
// no workflow) ever reads it back - it's a currently-dead field for
|
||||
// `orchestrate`, so it is not carried forward despite being Mac-native
|
||||
// (i.e. not actually Docker-only) in principle.
|
||||
// linux64RemoveExecutableExtension: not assigned by the adapter at all.
|
||||
];
|
||||
|
||||
/**
|
||||
* New orchestrator-only inputs surfaced for providerStrategy=local-system.
|
||||
* All four are confirmed both registered as `orchestrate` CLI options
|
||||
* (orchestrator-options-plugin.ts) and consumed by
|
||||
* build-parameters-adapter.ts. `middlewarePipeline` is available in the same
|
||||
* places but deliberately deferred - its expected value shape is not yet
|
||||
* documented/stable enough to lock into this action's public input surface.
|
||||
*/
|
||||
const ORCHESTRATE_ONLY_STRING_FLAGS: Array<[input: string, flag: string]> = [
|
||||
['engineLaunchWrapper', 'engineLaunchWrapper'],
|
||||
['localCacheMode', 'localCacheMode'],
|
||||
];
|
||||
|
||||
const ORCHESTRATE_ONLY_BOOLEAN_FLAGS: Array<[input: string, flag: string]> = [
|
||||
['enableBuildRetry', 'enableBuildRetry'],
|
||||
['localCacheEnabled', 'localCacheEnabled'],
|
||||
['localCacheLibrary', 'localCacheLibrary'],
|
||||
['localCacheLfs', 'localCacheLfs'],
|
||||
];
|
||||
|
||||
function isTruthy(value: string): boolean {
|
||||
return value.trim().toLowerCase() === 'true';
|
||||
}
|
||||
|
||||
function pushStringFlags(
|
||||
args: string[],
|
||||
getInput: (name: string) => string,
|
||||
flags: Array<[input: string, flag: string]>,
|
||||
) {
|
||||
for (const [input, flag] of flags) {
|
||||
const value = getInput(input);
|
||||
// "--flag value" as two argv tokens is ambiguous when value itself
|
||||
// starts with "-" (e.g. customParameters="-profile Foo -someBoolean"):
|
||||
// yargs sees the next token starting with "-" and assumes the flag
|
||||
// takes no value, leaving the value string to be mis-parsed as its own
|
||||
// (partly alias-colliding) short-flag cluster. "--flag=value" glues
|
||||
// them into one token, which is unambiguous.
|
||||
if (value) args.push(`--${flag}=${value}`);
|
||||
}
|
||||
}
|
||||
|
||||
function pushBooleanFlags(
|
||||
args: string[],
|
||||
getInput: (name: string) => string,
|
||||
flags: Array<[input: string, flag: string]>,
|
||||
) {
|
||||
for (const [input, flag] of flags) {
|
||||
const value = getInput(input);
|
||||
if (value && isTruthy(value)) args.push(`--${flag}`);
|
||||
}
|
||||
}
|
||||
|
||||
export interface BuildArgsOptions {
|
||||
getInput(name: string): string;
|
||||
}
|
||||
|
||||
export function buildCliArgs({ getInput }: BuildArgsOptions): string[] {
|
||||
const providerStrategy = getInput('providerStrategy') || 'local';
|
||||
|
||||
if (providerStrategy === 'local-system') {
|
||||
return buildOrchestrateArgs(getInput);
|
||||
}
|
||||
|
||||
if (providerStrategy !== 'local') {
|
||||
throw new Error(
|
||||
`Provider strategy "${providerStrategy}" is not supported by this thin wrapper. ` +
|
||||
"Use providerStrategy=local, or invoke game-ci/cli's `orchestrate` command directly " +
|
||||
'for remote builds.',
|
||||
);
|
||||
}
|
||||
|
||||
const args: string[] = ['build'];
|
||||
|
||||
const projectPath = getInput('projectPath');
|
||||
if (projectPath) args.push(projectPath);
|
||||
|
||||
const targetPlatform = getInput('targetPlatform');
|
||||
if (!targetPlatform) {
|
||||
throw new Error('targetPlatform is required.');
|
||||
}
|
||||
args.push(`--targetPlatform=${targetPlatform}`);
|
||||
|
||||
const unityVersion = getInput('unityVersion');
|
||||
if (unityVersion && unityVersion !== 'auto') {
|
||||
args.push(`--engineVersion=${unityVersion}`);
|
||||
}
|
||||
|
||||
pushStringFlags(args, getInput, STRING_FLAGS);
|
||||
pushBooleanFlags(args, getInput, BOOLEAN_FLAGS);
|
||||
|
||||
return args;
|
||||
}
|
||||
|
||||
function buildOrchestrateArgs(getInput: (name: string) => string): string[] {
|
||||
const args: string[] = ['orchestrate'];
|
||||
|
||||
const projectPath = getInput('projectPath');
|
||||
if (projectPath) args.push(projectPath);
|
||||
|
||||
const targetPlatform = getInput('targetPlatform');
|
||||
if (!targetPlatform) {
|
||||
throw new Error('targetPlatform is required.');
|
||||
}
|
||||
args.push(`--targetPlatform=${targetPlatform}`);
|
||||
|
||||
args.push('--providerStrategy=local-system');
|
||||
|
||||
pushStringFlags(args, getInput, ORCHESTRATE_STRING_FLAGS);
|
||||
pushStringFlags(args, getInput, ORCHESTRATE_ONLY_STRING_FLAGS);
|
||||
pushBooleanFlags(args, getInput, ORCHESTRATE_BOOLEAN_FLAGS);
|
||||
pushBooleanFlags(args, getInput, ORCHESTRATE_ONLY_BOOLEAN_FLAGS);
|
||||
|
||||
return args;
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import * as fs from 'node:fs/promises';
|
||||
import * as cache from '@actions/cache';
|
||||
import * as exec from '@actions/exec';
|
||||
import { binaryNameFor, downloadCli, resolveLatestTag } from './download-cli';
|
||||
|
||||
vi.mock('@actions/exec');
|
||||
vi.mock('@actions/cache');
|
||||
vi.mock('node:fs/promises');
|
||||
|
||||
function mockInstallScriptFetch(): void {
|
||||
globalThis.fetch = vi.fn(async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
text: async () => '#!/usr/bin/env bash\necho mock install.sh',
|
||||
})) as unknown as typeof fetch;
|
||||
}
|
||||
|
||||
describe('binaryNameFor', () => {
|
||||
it('is game-ci.exe on win32', () => {
|
||||
expect(binaryNameFor('win32')).toBe('game-ci.exe');
|
||||
});
|
||||
|
||||
it('is game-ci on every other platform', () => {
|
||||
expect(binaryNameFor('linux')).toBe('game-ci');
|
||||
expect(binaryNameFor('darwin')).toBe('game-ci');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveLatestTag', () => {
|
||||
// This is the piece that makes caching "latest" possible at all: caching
|
||||
// under the literal string "latest" would silently pin every job to
|
||||
// whatever version happened to be current on the first cache write.
|
||||
// Resolving to the concrete tag first means a real new release is a fresh
|
||||
// cache key (a miss by construction), never a stale hit.
|
||||
it('returns the tag_name from a successful GitHub API response', async () => {
|
||||
const fetchFn = vi.fn(async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ tag_name: 'v0.1.15' }),
|
||||
})) as unknown as typeof fetch;
|
||||
|
||||
const tag = await resolveLatestTag(fetchFn);
|
||||
|
||||
expect(tag).toBe('v0.1.15');
|
||||
expect(fetchFn).toHaveBeenCalledWith(
|
||||
'https://api.github.com/repos/game-ci/cli/releases/latest',
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({ Accept: 'application/vnd.github+json' }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('sends no Authorization header when GITHUB_TOKEN/GH_TOKEN are unset', async () => {
|
||||
const originalGithub = process.env.GITHUB_TOKEN;
|
||||
const originalGh = process.env.GH_TOKEN;
|
||||
delete process.env.GITHUB_TOKEN;
|
||||
delete process.env.GH_TOKEN;
|
||||
|
||||
const fetchFn = vi.fn(async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ tag_name: 'v0.1.15' }),
|
||||
})) as unknown as typeof fetch;
|
||||
|
||||
try {
|
||||
await resolveLatestTag(fetchFn);
|
||||
const [, init] = vi.mocked(fetchFn).mock.calls[0] as [
|
||||
string,
|
||||
RequestInit & { headers: Record<string, string> },
|
||||
];
|
||||
expect(init.headers.Authorization).toBeUndefined();
|
||||
} finally {
|
||||
if (originalGithub !== undefined) process.env.GITHUB_TOKEN = originalGithub;
|
||||
if (originalGh !== undefined) process.env.GH_TOKEN = originalGh;
|
||||
}
|
||||
});
|
||||
|
||||
it('sends an Authorization header from GITHUB_TOKEN when set, to avoid the unauthenticated rate limit', async () => {
|
||||
const original = process.env.GITHUB_TOKEN;
|
||||
process.env.GITHUB_TOKEN = 'test-token-123';
|
||||
|
||||
const fetchFn = vi.fn(async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ tag_name: 'v0.1.15' }),
|
||||
})) as unknown as typeof fetch;
|
||||
|
||||
try {
|
||||
await resolveLatestTag(fetchFn);
|
||||
expect(fetchFn).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({ Authorization: 'Bearer test-token-123' }),
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
if (original === undefined) delete process.env.GITHUB_TOKEN;
|
||||
else process.env.GITHUB_TOKEN = original;
|
||||
}
|
||||
});
|
||||
|
||||
it('throws with a clear message on a non-ok response', async () => {
|
||||
const fetchFn = vi.fn(async () => ({
|
||||
ok: false,
|
||||
status: 404,
|
||||
json: async () => ({}),
|
||||
})) as unknown as typeof fetch;
|
||||
|
||||
await expect(resolveLatestTag(fetchFn)).rejects.toThrow(/404/);
|
||||
});
|
||||
|
||||
it('throws with a clear message when the response has no tag_name', async () => {
|
||||
const fetchFn = vi.fn(async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({}),
|
||||
})) as unknown as typeof fetch;
|
||||
|
||||
await expect(resolveLatestTag(fetchFn)).rejects.toThrow(/no tag_name/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('downloadCli', () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(cache.isFeatureAvailable).mockReturnValue(false);
|
||||
// Real chmod/mkdir/writeFile aren't under test here (that's install.sh's
|
||||
// job, and it isn't actually run - see mockInstallScriptFetch) and the
|
||||
// paths involved don't exist on disk in this test environment. Without
|
||||
// these, restoreFromCache's real fs.chmod call throws ENOENT on any
|
||||
// platform where that branch actually runs (linux/darwin - the
|
||||
// `process.platform !== 'win32'` guard means it's silently skipped, and
|
||||
// the bug masked, on a Windows dev machine) - restoreFromCache's own
|
||||
// try/catch then swallows that and returns null, so a cache-hit test
|
||||
// silently falls through to the real install path instead of catching
|
||||
// what it's meant to catch.
|
||||
vi.mocked(fs.chmod).mockResolvedValue(undefined);
|
||||
vi.mocked(fs.mkdir).mockResolvedValue(undefined);
|
||||
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
// The actual install mechanics (platform detection, archive format,
|
||||
// extraction) live in game-ci/cli's own scripts/install.sh now, fetched
|
||||
// and run at the resolved version's tag - see game-ci/cli#187. This
|
||||
// wrapper's own job is just: fetch that script, run it correctly, and
|
||||
// take its stdout as the binary path.
|
||||
it('fetches and runs install.sh for the given version, returning its stdout as the binary path', async () => {
|
||||
mockInstallScriptFetch();
|
||||
vi.mocked(exec.exec).mockImplementation(async (_cmd, _args, options) => {
|
||||
options?.listeners?.stdout?.(Buffer.from('/tmp/game-ci-cli-cache/v0.1.32/game-ci\n'));
|
||||
return 0;
|
||||
});
|
||||
|
||||
const binaryPath = await downloadCli('v0.1.32');
|
||||
|
||||
expect(binaryPath).toBe('/tmp/game-ci-cli-cache/v0.1.32/game-ci');
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
'https://raw.githubusercontent.com/game-ci/cli/v0.1.32/scripts/install.sh',
|
||||
);
|
||||
expect(exec.exec).toHaveBeenCalledWith(
|
||||
'bash',
|
||||
expect.arrayContaining(['v0.1.32']),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('resolves "latest" to a concrete tag before fetching install.sh', async () => {
|
||||
globalThis.fetch = vi.fn(async (url: string) => {
|
||||
if (url.includes('/releases/latest')) {
|
||||
return { ok: true, status: 200, json: async () => ({ tag_name: 'v0.1.33' }) };
|
||||
}
|
||||
return { ok: true, status: 200, text: async () => 'echo mock install.sh' };
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
vi.mocked(exec.exec).mockImplementation(async (_cmd, _args, options) => {
|
||||
options?.listeners?.stdout?.(Buffer.from('/tmp/game-ci\n'));
|
||||
return 0;
|
||||
});
|
||||
|
||||
await downloadCli('latest');
|
||||
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
'https://raw.githubusercontent.com/game-ci/cli/v0.1.33/scripts/install.sh',
|
||||
);
|
||||
expect(exec.exec).toHaveBeenCalledWith(
|
||||
'bash',
|
||||
expect.arrayContaining(['v0.1.33']),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('throws a clear error when the install.sh fetch fails', async () => {
|
||||
globalThis.fetch = vi.fn(async () => ({
|
||||
ok: false,
|
||||
status: 404,
|
||||
})) as unknown as typeof fetch;
|
||||
|
||||
await expect(downloadCli('v0.1.32')).rejects.toThrow(/404/);
|
||||
expect(exec.exec).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws a clear error when install.sh produces no output', async () => {
|
||||
mockInstallScriptFetch();
|
||||
vi.mocked(exec.exec).mockImplementation(async () => 0);
|
||||
|
||||
await expect(downloadCli('v0.1.32')).rejects.toThrow(/produced no output/);
|
||||
});
|
||||
|
||||
it('restores from cache instead of fetching install.sh on a cache hit', async () => {
|
||||
vi.mocked(cache.isFeatureAvailable).mockReturnValue(true);
|
||||
vi.mocked(cache.restoreCache).mockResolvedValue('game-ci-cli-v0.1.32-key');
|
||||
|
||||
const binaryPath = await downloadCli('v0.1.32');
|
||||
|
||||
expect(binaryPath).toContain(binaryNameFor(process.platform));
|
||||
expect(exec.exec).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,192 @@
|
||||
import * as fs from 'node:fs/promises';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import * as cache from '@actions/cache';
|
||||
import * as core from '@actions/core';
|
||||
import * as exec from '@actions/exec';
|
||||
|
||||
const CLI_REPO = 'game-ci/cli';
|
||||
|
||||
/** The binary's name once extracted - matches release-cli.yml's per-platform `binary` matrix value. */
|
||||
export function binaryNameFor(platform: NodeJS.Platform): string {
|
||||
return platform === 'win32' ? 'game-ci.exe' : 'game-ci';
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the "latest" alias to the actual release tag it currently
|
||||
* points to, via a small GitHub API call - not the release-asset
|
||||
* redirect, which never reveals the concrete tag it landed on. This is
|
||||
* what makes caching "latest" possible at all: caching under the literal
|
||||
* string "latest" would silently pin every job to whatever version
|
||||
* happened to be current on the first cache write, but caching under the
|
||||
* *resolved* tag self-invalidates the moment a new release ships (a new
|
||||
* tag is a cache miss by construction), while still hitting cache on
|
||||
* every run in between. It's also what pins install.sh (below) to a
|
||||
* specific, immutable version rather than "latest" - install.sh doesn't
|
||||
* know how to resolve "latest" itself, by design, since which tag it's
|
||||
* fetched at IS the version it installs.
|
||||
*/
|
||||
export async function resolveLatestTag(fetchFn: typeof fetch = fetch): Promise<string> {
|
||||
const headers: Record<string, string> = { Accept: 'application/vnd.github+json' };
|
||||
// Actions runners share IPs across many concurrent jobs from unrelated
|
||||
// repos/orgs, so the unauthenticated rate limit (60 req/hour per IP) gets
|
||||
// exhausted by traffic this job never generated. The default GITHUB_TOKEN
|
||||
// reads public repo data (game-ci/cli's releases) fine regardless of which
|
||||
// repo the workflow runs in, and lifts the limit to 5000 req/hour.
|
||||
const token = process.env.GITHUB_TOKEN || process.env.GH_TOKEN;
|
||||
if (token) headers.Authorization = `Bearer ${token}`;
|
||||
|
||||
const response = await fetchFn(`https://api.github.com/repos/${CLI_REPO}/releases/latest`, {
|
||||
headers,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Failed to resolve the latest game-ci CLI release: GitHub API returned ${response.status}.`,
|
||||
);
|
||||
}
|
||||
|
||||
const body = (await response.json()) as { tag_name?: string };
|
||||
if (!body.tag_name) {
|
||||
throw new Error('Failed to resolve the latest game-ci CLI release: response had no tag_name.');
|
||||
}
|
||||
|
||||
return body.tag_name;
|
||||
}
|
||||
|
||||
function cacheDirFor(version: string): string {
|
||||
return path.join(os.tmpdir(), 'game-ci-cli-cache', version);
|
||||
}
|
||||
|
||||
/**
|
||||
* Includes platform+arch, not just version: darwin-x64 and darwin-arm64
|
||||
* (or any two architectures on the same OS) both extract to a binary
|
||||
* named plain "game-ci", so a key built from version+binaryName alone
|
||||
* (this cache's previous scheme) can't tell them apart and would let one
|
||||
* architecture's binary get restored onto the other's runner.
|
||||
*/
|
||||
function cacheKeyFor(version: string): string {
|
||||
return `game-ci-cli-${version}-${process.platform}-${process.arch}`;
|
||||
}
|
||||
|
||||
async function restoreFromCache(version: string): Promise<string | null> {
|
||||
if (!cache.isFeatureAvailable()) return null;
|
||||
|
||||
const cacheDir = cacheDirFor(version);
|
||||
try {
|
||||
const hitKey = await cache.restoreCache([cacheDir], cacheKeyFor(version));
|
||||
if (!hitKey) return null;
|
||||
|
||||
const binaryPath = path.join(cacheDir, binaryNameFor(process.platform));
|
||||
// Cache restore doesn't guarantee the executable bit survives.
|
||||
if (process.platform !== 'win32') await fs.chmod(binaryPath, 0o755);
|
||||
|
||||
core.info(`Restored game-ci CLI ${version} from cache`);
|
||||
return binaryPath;
|
||||
} catch (error: any) {
|
||||
core.warning(`Failed to restore game-ci CLI from cache: ${error.message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveToCache(version: string): Promise<void> {
|
||||
if (!cache.isFeatureAvailable()) return;
|
||||
|
||||
try {
|
||||
await cache.saveCache([cacheDirFor(version)], cacheKeyFor(version));
|
||||
} catch (error: any) {
|
||||
// A cache miss on save (e.g. another concurrent job already saved this
|
||||
// key) isn't fatal - the install itself already succeeded.
|
||||
core.warning(`Failed to save game-ci CLI to cache: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloads (or reuses a cached copy of) the game-ci CLI release archive
|
||||
* matching the current runner, extracts it, and returns the path to the
|
||||
* binary inside.
|
||||
*
|
||||
* The actual install mechanics - platform/arch detection, archive format,
|
||||
* download, extraction - live in exactly one place: game-ci/cli's own
|
||||
* scripts/install.sh, fetched and run at the resolved version's tag. That
|
||||
* script is what every engine wrapper (this one today, others later)
|
||||
* delegates to, so a bugfix or a new supported platform ships once, there,
|
||||
* and every wrapper picks it up on its next run with no code change of its
|
||||
* own - see game-ci/cli#187. GitHub Actions caching stays here, in the
|
||||
* wrapper: it's an Actions-only service with no shell-callable API, so
|
||||
* install.sh has no way to drive it itself.
|
||||
*
|
||||
* Every version - including "latest" - is cached via @actions/cache
|
||||
* (GitHub's cache service), so repeat jobs on ephemeral, GitHub-hosted
|
||||
* runners skip the archive download entirely. "latest" is resolved to its
|
||||
* concrete tag first (see resolveLatestTag) and cached under *that*, not
|
||||
* under the literal string "latest" - a real new release is a fresh tag,
|
||||
* so it's a cache miss by construction, never a stale hit.
|
||||
*
|
||||
* @param version A release tag (e.g. "v0.1.0"), or "latest".
|
||||
*/
|
||||
export async function downloadCli(version: string): Promise<string> {
|
||||
const resolvedVersion = version === 'latest' ? await resolveLatestTag() : version;
|
||||
|
||||
const cached = await restoreFromCache(resolvedVersion);
|
||||
if (cached) return cached;
|
||||
|
||||
const destDir = cacheDirFor(resolvedVersion);
|
||||
await fs.mkdir(destDir, { recursive: true });
|
||||
|
||||
const installScriptUrl = `https://raw.githubusercontent.com/${CLI_REPO}/${resolvedVersion}/scripts/install.sh`;
|
||||
core.info(`Installing game-ci CLI ${resolvedVersion} via ${installScriptUrl}`);
|
||||
|
||||
// Fetched and written to a file, then run as `bash <path> <args...>`,
|
||||
// rather than piped straight into a `bash -c '... "$0" ...'` wrapper: a
|
||||
// curl-into-bash one-liner needs resolvedVersion/destDir interpolated
|
||||
// into the -c script text (even safely, as quoted positional params),
|
||||
// which is exactly the shape CodeQL's uncontrolled-command-line query
|
||||
// flags. Passing a real file path plus a plain string[] of args - the
|
||||
// same pattern this file's own callers already use for the CLI binary
|
||||
// itself - has no such shell-text construction step to flag at all.
|
||||
const scriptResponse = await fetch(installScriptUrl);
|
||||
if (!scriptResponse.ok) {
|
||||
throw new Error(
|
||||
`Failed to fetch install.sh for game-ci CLI ${resolvedVersion}: ` +
|
||||
`GitHub returned ${scriptResponse.status} for ${installScriptUrl}.`,
|
||||
);
|
||||
}
|
||||
const scriptPath = path.join(os.tmpdir(), `game-ci-install-${resolvedVersion.replace(/[^\w.-]/g, '_')}.sh`);
|
||||
await fs.writeFile(scriptPath, await scriptResponse.text(), { mode: 0o755 });
|
||||
|
||||
let stdout = '';
|
||||
await exec.exec('bash', [scriptPath, resolvedVersion, destDir], {
|
||||
listeners: {
|
||||
stdout: (data: Buffer) => {
|
||||
stdout += data.toString();
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// install.sh writes progress to stderr and only the final binary path to
|
||||
// stdout, but take the last non-empty line regardless - defensive against
|
||||
// any stray stdout noise from a future version of the script. Walked
|
||||
// backwards by index rather than toReversed()/reverse() - toReversed()
|
||||
// isn't available on this project's target Node version, and reverse()
|
||||
// mutates in place, which unicorn/no-array-reverse flags even when the
|
||||
// array being mutated is a fresh one from map().
|
||||
const lines = stdout.split('\n').map((line) => line.trim());
|
||||
let binaryPath: string | undefined;
|
||||
for (let i = lines.length - 1; i >= 0; i -= 1) {
|
||||
if (lines[i]) {
|
||||
binaryPath = lines[i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!binaryPath) {
|
||||
throw new Error(
|
||||
`Failed to install the game-ci CLI ${resolvedVersion}: install.sh produced no output.`,
|
||||
);
|
||||
}
|
||||
|
||||
await saveToCache(resolvedVersion);
|
||||
|
||||
return binaryPath;
|
||||
}
|
||||
@@ -1,272 +0,0 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi, type Mock } from 'vitest';
|
||||
/**
|
||||
* Integration wiring tests for the plugin lifecycle in index.ts
|
||||
*
|
||||
* These tests verify that:
|
||||
* - The plugin lifecycle hooks are called in the correct order
|
||||
* - Plugin canHandleBuild() controls the execution path
|
||||
* - fallbackToLocal is handled correctly
|
||||
* - When no plugin is installed, local builds still work
|
||||
* - When providerStrategy is non-local without a plugin, an error is thrown
|
||||
*/
|
||||
|
||||
import { BuildParameters, Docker } from './model';
|
||||
import * as core from '@actions/core';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock plugin
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// `vi.mock` hoists to the top of the module, so any factory references must
|
||||
// be hoisted with `vi.hoisted` to be defined at mock-evaluation time.
|
||||
const { mockPlugin, mockLoadPlugin } = vi.hoisted(() => {
|
||||
const plugin = {
|
||||
initialize: vi.fn().mockResolvedValue(undefined),
|
||||
canHandleBuild: vi.fn().mockReturnValue(false),
|
||||
handleBuild: vi.fn().mockResolvedValue({ exitCode: 0 }),
|
||||
beforeLocalBuild: vi.fn().mockResolvedValue(undefined),
|
||||
afterLocalBuild: vi.fn().mockResolvedValue(undefined),
|
||||
handlePostBuild: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
return {
|
||||
mockPlugin: plugin,
|
||||
mockLoadPlugin: vi.fn().mockResolvedValue(plugin),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('./model/plugin', () => ({
|
||||
loadPlugin: mockLoadPlugin,
|
||||
}));
|
||||
|
||||
vi.mock('@actions/core');
|
||||
vi.mock('./model', () => ({
|
||||
Action: {
|
||||
checkCompatibility: vi.fn(),
|
||||
workspace: '/workspace',
|
||||
actionFolder: '/action',
|
||||
},
|
||||
BuildParameters: {
|
||||
create: vi.fn(),
|
||||
},
|
||||
Cache: {
|
||||
verify: vi.fn(),
|
||||
},
|
||||
Docker: {
|
||||
run: vi.fn().mockResolvedValue(0),
|
||||
},
|
||||
// vitest 4 requires constructor mocks to use regular `function` (or
|
||||
// `class`); arrow fns aren't valid constructors.
|
||||
ImageTag: vi.fn(function () {
|
||||
return { toString: () => 'mock-image:latest' };
|
||||
}),
|
||||
Output: {
|
||||
setBuildVersion: vi.fn().mockResolvedValue(''),
|
||||
setAndroidVersionCode: vi.fn().mockResolvedValue(''),
|
||||
setEngineExitCode: vi.fn().mockResolvedValue(''),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('./model/mac-builder', () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
run: vi.fn().mockResolvedValue(0),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('./model/platform-setup', () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
setup: vi.fn().mockResolvedValue(''),
|
||||
},
|
||||
}));
|
||||
|
||||
const mockedBuildParametersCreate = BuildParameters.create as Mock;
|
||||
|
||||
function createMockBuildParameters(overrides: Record<string, any> = {}) {
|
||||
return {
|
||||
providerStrategy: 'local',
|
||||
targetPlatform: 'StandaloneLinux64',
|
||||
editorVersion: '2021.3.1f1',
|
||||
buildVersion: '1.0.0',
|
||||
androidVersionCode: '1',
|
||||
projectPath: '.',
|
||||
branch: 'main',
|
||||
runnerTempPath: '/tmp',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
async function runIndex(overrides: Record<string, any> = {}): Promise<void> {
|
||||
mockedBuildParametersCreate.mockResolvedValue(createMockBuildParameters(overrides));
|
||||
|
||||
// index.ts exports `runMain` for testability (the file used to rely on
|
||||
// top-level execution + jest's `vi.isolateModules`, but vitest 4 dropped
|
||||
// that API). Calling the exported function directly is cleaner than
|
||||
// round-tripping through dynamic imports.
|
||||
const { runMain } = await import('./index');
|
||||
await runMain();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('index.ts plugin lifecycle wiring', () => {
|
||||
const originalPlatform = process.platform;
|
||||
const originalEnvironment = { ...process.env };
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
process.env.GITHUB_WORKSPACE = '/workspace';
|
||||
Object.defineProperty(process, 'platform', { value: 'linux' });
|
||||
|
||||
// Reset plugin to default behavior
|
||||
mockPlugin.canHandleBuild.mockReturnValue(false);
|
||||
mockPlugin.handleBuild.mockResolvedValue({ exitCode: 0 });
|
||||
mockLoadPlugin.mockResolvedValue(mockPlugin);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(process, 'platform', { value: originalPlatform });
|
||||
process.env = { ...originalEnvironment };
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Local build with plugin
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
describe('local build with plugin installed', () => {
|
||||
it('should call lifecycle hooks in order: initialize -> beforeLocalBuild -> [build] -> afterLocalBuild -> handlePostBuild', async () => {
|
||||
const callOrder: string[] = [];
|
||||
mockPlugin.initialize.mockImplementation(async () => callOrder.push('initialize'));
|
||||
mockPlugin.beforeLocalBuild.mockImplementation(async () =>
|
||||
callOrder.push('beforeLocalBuild'),
|
||||
);
|
||||
mockPlugin.afterLocalBuild.mockImplementation(async () => callOrder.push('afterLocalBuild'));
|
||||
mockPlugin.handlePostBuild.mockImplementation(async () => callOrder.push('handlePostBuild'));
|
||||
|
||||
await runIndex();
|
||||
|
||||
expect(callOrder).toEqual([
|
||||
'initialize',
|
||||
'beforeLocalBuild',
|
||||
'afterLocalBuild',
|
||||
'handlePostBuild',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should pass buildParameters and workspace to initialize', async () => {
|
||||
await runIndex({ targetPlatform: 'WebGL' });
|
||||
|
||||
expect(mockPlugin.initialize).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ targetPlatform: 'WebGL' }),
|
||||
'/workspace',
|
||||
);
|
||||
});
|
||||
|
||||
it('should pass workspace to beforeLocalBuild', async () => {
|
||||
await runIndex();
|
||||
|
||||
expect(mockPlugin.beforeLocalBuild).toHaveBeenCalledWith('/workspace');
|
||||
});
|
||||
|
||||
it('should pass workspace and exit code to afterLocalBuild', async () => {
|
||||
await runIndex();
|
||||
|
||||
expect(mockPlugin.afterLocalBuild).toHaveBeenCalledWith('/workspace', 0);
|
||||
});
|
||||
|
||||
it('should pass exit code to handlePostBuild', async () => {
|
||||
await runIndex();
|
||||
|
||||
expect(mockPlugin.handlePostBuild).toHaveBeenCalledWith(0);
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Plugin handles build entirely
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
describe('plugin handles build (canHandleBuild = true)', () => {
|
||||
it('should call handleBuild instead of Docker.run', async () => {
|
||||
mockPlugin.canHandleBuild.mockReturnValue(true);
|
||||
mockPlugin.handleBuild.mockResolvedValue({ exitCode: 0 });
|
||||
|
||||
await runIndex();
|
||||
|
||||
expect(mockPlugin.handleBuild).toHaveBeenCalledWith('mock-image:latest');
|
||||
expect(Docker.run).not.toHaveBeenCalled();
|
||||
expect(mockPlugin.beforeLocalBuild).not.toHaveBeenCalled();
|
||||
expect(mockPlugin.afterLocalBuild).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should still call handlePostBuild after handleBuild', async () => {
|
||||
mockPlugin.canHandleBuild.mockReturnValue(true);
|
||||
mockPlugin.handleBuild.mockResolvedValue({ exitCode: 0 });
|
||||
|
||||
await runIndex();
|
||||
|
||||
expect(mockPlugin.handlePostBuild).toHaveBeenCalledWith(0);
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Fallback to local
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
describe('fallback to local build', () => {
|
||||
it('should do a local build when handleBuild returns fallbackToLocal', async () => {
|
||||
mockPlugin.canHandleBuild.mockReturnValue(true);
|
||||
mockPlugin.handleBuild.mockResolvedValue({ exitCode: -1, fallbackToLocal: true });
|
||||
|
||||
await runIndex();
|
||||
|
||||
expect(mockPlugin.handleBuild).toHaveBeenCalled();
|
||||
expect(mockPlugin.beforeLocalBuild).toHaveBeenCalled();
|
||||
expect(Docker.run).toHaveBeenCalled();
|
||||
expect(mockPlugin.afterLocalBuild).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// No plugin installed
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
describe('no plugin installed', () => {
|
||||
it('should build locally without errors when providerStrategy is local', async () => {
|
||||
mockLoadPlugin.mockResolvedValue(undefined);
|
||||
|
||||
await runIndex({ providerStrategy: 'local' });
|
||||
|
||||
expect(Docker.run).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should error when providerStrategy is non-local and no plugin', async () => {
|
||||
mockLoadPlugin.mockResolvedValue(undefined);
|
||||
|
||||
await runIndex({ providerStrategy: 'aws' });
|
||||
|
||||
expect(core.setFailed).toHaveBeenCalledWith(
|
||||
expect.stringContaining('requires @game-ci/orchestrator'),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// canHandleBuild = false with non-local provider
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
describe('plugin installed but canHandleBuild returns false with non-local provider', () => {
|
||||
it('should error when providerStrategy is non-local', async () => {
|
||||
mockPlugin.canHandleBuild.mockReturnValue(false);
|
||||
|
||||
await runIndex({ providerStrategy: 'aws' });
|
||||
|
||||
// The plugin is initialized but says it can't handle the build,
|
||||
// and providerStrategy is not local, so it falls to the error case
|
||||
expect(core.setFailed).toHaveBeenCalledWith(
|
||||
expect.stringContaining('requires @game-ci/orchestrator'),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
+54
-69
@@ -1,85 +1,70 @@
|
||||
// Thin wrapper: this action installs and shells out to the game-ci CLI
|
||||
// (game-ci/cli's `build` command) as a subprocess, so the exact same code
|
||||
// path this runs in CI also runs when a developer invokes the CLI directly
|
||||
// on their own machine. See game-ci/roadmap#11 (workstream 2), and the
|
||||
// matching rewrite already shipped for game-ci/unity-activate.
|
||||
//
|
||||
// Unity credentials (UNITY_EMAIL, UNITY_PASSWORD, UNITY_SERIAL,
|
||||
// UNITY_LICENSE, UNITY_LICENSING_SERVER) are read by the CLI itself from
|
||||
// its own process environment - never passed as CLI arguments, since argv
|
||||
// can leak through process listings and command-logging.
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import * as core from '@actions/core';
|
||||
import { Action, BuildParameters, Cache, Docker, ImageTag, Output } from './model';
|
||||
import MacBuilder from './model/mac-builder';
|
||||
import PlatformSetup from './model/platform-setup';
|
||||
import { Plugin, loadPlugin } from './model/plugin';
|
||||
import * as exec from '@actions/exec';
|
||||
import { buildCliArgs } from './build-args';
|
||||
import { downloadCli } from './download-cli';
|
||||
import { resolveProjectPath } from './resolve-project-path';
|
||||
|
||||
// Exported so tests can drive the lifecycle directly without depending on
|
||||
// vitest's module re-loading (which changed in vitest 4).
|
||||
export async function runMain() {
|
||||
export async function run() {
|
||||
try {
|
||||
Action.checkCompatibility();
|
||||
Cache.verify();
|
||||
const cliVersion = core.getInput('cliVersion') || 'latest';
|
||||
const cliPath = await downloadCli(cliVersion);
|
||||
|
||||
const { workspace, actionFolder } = Action;
|
||||
const buildParameters = await BuildParameters.create();
|
||||
const baseImage = new ImageTag(buildParameters);
|
||||
const projectPath = resolveProjectPath({
|
||||
input: core.getInput('projectPath'),
|
||||
existsSync: fs.existsSync,
|
||||
joinPath: path.join,
|
||||
});
|
||||
|
||||
// Load optional plugin. The default implementation is @game-ci/orchestrator.
|
||||
const plugin = await loadPlugin();
|
||||
await plugin?.initialize(buildParameters, workspace);
|
||||
const args = buildCliArgs({
|
||||
getInput: (name) => (name === 'projectPath' ? projectPath : core.getInput(name)),
|
||||
});
|
||||
|
||||
let exitCode = -1;
|
||||
// CodeQL flags this line (js/command-line-injection) since args
|
||||
// ultimately derives from Action inputs. Verified false positive: args
|
||||
// is an array of discrete argv entries, not a concatenated shell
|
||||
// string, and @actions/exec's toolrunner.js passes it straight to
|
||||
// child_process.spawn(fileName, args, options) - verified by reading
|
||||
// node_modules/@actions/exec/lib/toolrunner.js - never a shell string,
|
||||
// never shell-parsed. CodeQL's static analysis can't see through
|
||||
// @actions/exec's internals to confirm that. This comment does not
|
||||
// suppress the alert (GitHub Code Scanning's default setup has no
|
||||
// inline-suppression-comment mechanism - that was legacy LGTM.com
|
||||
// behavior); the alert needs dismissing via the Security tab/API.
|
||||
const exitCode = await exec.exec(cliPath, args, { ignoreReturnCode: true });
|
||||
|
||||
if (plugin?.canHandleBuild()) {
|
||||
// Plugin handles the build entirely (remote providers, hot runner, test workflows)
|
||||
const result = await plugin.handleBuild(baseImage.toString());
|
||||
|
||||
exitCode = result.fallbackToLocal
|
||||
? await runLocalBuild(buildParameters, baseImage, workspace, actionFolder, plugin)
|
||||
: result.exitCode;
|
||||
} else if (buildParameters.providerStrategy === 'local') {
|
||||
exitCode = await runLocalBuild(buildParameters, baseImage, workspace, actionFolder, plugin);
|
||||
} else {
|
||||
throw new Error(
|
||||
`Provider strategy "${buildParameters.providerStrategy}" requires @game-ci/orchestrator. ` +
|
||||
'Install it via the game-ci/orchestrator action, or use providerStrategy=local.',
|
||||
);
|
||||
}
|
||||
|
||||
// Set core outputs
|
||||
await Output.setBuildVersion(buildParameters.buildVersion);
|
||||
await Output.setAndroidVersionCode(buildParameters.androidVersionCode);
|
||||
await Output.setEngineExitCode(exitCode);
|
||||
|
||||
// Plugin handles post-build (artifacts, archiving, retention)
|
||||
await plugin?.handlePostBuild(exitCode);
|
||||
// Matches the original action's engineExitCode output: 0 on success,
|
||||
// otherwise the exit code of whichever step (activation or build) - or,
|
||||
// for providerStrategy=local-system, whichever stage of the
|
||||
// orchestrator's setup/build/cleanup workflow - failed. Either way it's
|
||||
// exactly what the CLI subprocess itself exits with, so no special
|
||||
// handling is needed here for the orchestrate path.
|
||||
core.setOutput('engineExitCode', exitCode);
|
||||
|
||||
if (exitCode !== 0) {
|
||||
core.setFailed(`Build failed with exit code ${exitCode}`);
|
||||
}
|
||||
} catch (error) {
|
||||
core.setFailed((error as Error).message);
|
||||
|
||||
// buildVersion/androidVersionCode are set by the CLI subprocess itself
|
||||
// via @actions/core, which writes directly to the file at
|
||||
// $GITHUB_OUTPUT - inherited by the child process, so no extra
|
||||
// plumbing is needed here to forward them.
|
||||
} catch (error: any) {
|
||||
core.setFailed(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function runLocalBuild(
|
||||
buildParameters: BuildParameters,
|
||||
baseImage: ImageTag,
|
||||
workspace: string,
|
||||
actionFolder: string,
|
||||
plugin?: Plugin,
|
||||
): Promise<number> {
|
||||
await plugin?.beforeLocalBuild(workspace);
|
||||
|
||||
await PlatformSetup.setup(buildParameters, actionFolder);
|
||||
const exitCode =
|
||||
process.platform === 'darwin'
|
||||
? await MacBuilder.run(actionFolder)
|
||||
: await Docker.run(baseImage.toString(), {
|
||||
workspace,
|
||||
actionFolder,
|
||||
...buildParameters,
|
||||
});
|
||||
|
||||
await plugin?.afterLocalBuild(workspace, exitCode);
|
||||
|
||||
return exitCode;
|
||||
}
|
||||
|
||||
// Auto-run when this module is the entry point. Tests import the file via
|
||||
// `await import('./index')` purely to register the mock factories and then
|
||||
// call `runMain()` directly.
|
||||
if (process.env.NODE_ENV !== 'test') {
|
||||
runMain();
|
||||
run();
|
||||
}
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
import { fetch as undiciFetch, Headers, Request, Response } from 'undici';
|
||||
|
||||
Object.assign(globalThis, { fetch: undiciFetch, Headers, Request, Response });
|
||||
@@ -1,112 +0,0 @@
|
||||
export const completelyValidSemanticVersions = [
|
||||
'0.0.4',
|
||||
'1.2.3',
|
||||
'10.20.30',
|
||||
'1.1.2-prerelease+meta',
|
||||
'1.1.2+meta',
|
||||
'1.1.2+meta-valid',
|
||||
'1.0.0-alpha',
|
||||
'1.0.0-beta',
|
||||
'1.0.0-alpha.beta',
|
||||
'1.0.0-alpha.beta.1',
|
||||
'1.0.0-alpha.1',
|
||||
'1.0.0-alpha0.valid',
|
||||
'1.0.0-alpha.0valid',
|
||||
'1.0.0-alpha-a.b-c-somethinglong+build.1-aef.1-its-okay',
|
||||
'1.0.0-rc.1+build.1',
|
||||
'2.0.0-rc.1+build.123',
|
||||
'1.2.3-beta',
|
||||
'10.2.3-DEV-SNAPSHOT',
|
||||
'1.2.3-SNAPSHOT-123',
|
||||
'1.0.0',
|
||||
'2.0.0',
|
||||
'1.1.7',
|
||||
'2.0.0+build.1848',
|
||||
'2.0.1-alpha.1227',
|
||||
'1.0.0-alpha+beta',
|
||||
'1.2.3----RC-SNAPSHOT.12.9.1--.12+788',
|
||||
'1.2.3----R-S.12.9.1--.12+meta',
|
||||
'1.2.3----RC-SNAPSHOT.12.9.1--.12',
|
||||
'1.0.0+0.build.1-rc.10000aaa-kk-0.1',
|
||||
'99999999999999999999999.999999999999999999.99999999999999999',
|
||||
'1.0.0-0A.is.legal',
|
||||
];
|
||||
|
||||
export const notCompletelyValidSemanticVersions = [
|
||||
'1',
|
||||
'1.2',
|
||||
'1.2.3-0123',
|
||||
'1.2.3-0123.0123',
|
||||
'1.1.2+.123',
|
||||
'+invalid',
|
||||
'-invalid',
|
||||
'-invalid+invalid',
|
||||
'-invalid.01',
|
||||
'alpha',
|
||||
'alpha.beta',
|
||||
'alpha.beta.1',
|
||||
'alpha.1',
|
||||
'alpha+beta',
|
||||
'alpha_beta',
|
||||
'alpha.',
|
||||
'alpha..',
|
||||
'beta',
|
||||
'1.0.0-alpha_beta',
|
||||
'-alpha.',
|
||||
'1.0.0-alpha..',
|
||||
'1.0.0-alpha..1',
|
||||
'1.0.0-alpha...1',
|
||||
'1.0.0-alpha....1',
|
||||
'1.0.0-alpha.....1',
|
||||
'1.0.0-alpha......1',
|
||||
'1.0.0-alpha.......1',
|
||||
'01.1.1',
|
||||
'1.01.1',
|
||||
'1.1.01',
|
||||
'1.2',
|
||||
'1.2.3.DEV',
|
||||
'1.2-SNAPSHOT',
|
||||
'1.2.31.2.3----RC-SNAPSHOT.12.09.1--..12+788',
|
||||
'1.2-RC-SNAPSHOT',
|
||||
'-1.0.3-gamma+b7718',
|
||||
'+justmeta',
|
||||
'9.8.7+meta+meta',
|
||||
'9.8.7-whatever+meta+meta',
|
||||
'99999999999999999999999.999999999999999999.99999999999999999----RC-SNAPSHOT.12.09.1--------------------------------..12',
|
||||
];
|
||||
|
||||
const addVariantsPrependingV = (array: string[]) => array.map((tag) => [tag, `v${tag}`]).flat();
|
||||
|
||||
/**
|
||||
* Array of versions that will be detected as version tags. Not all of these are
|
||||
* "semantic versions", but can be used to generate one. Especially using the
|
||||
* `versioning: Semantic` option.
|
||||
*/
|
||||
export const validVersionTagInputs = addVariantsPrependingV([
|
||||
'0',
|
||||
'1',
|
||||
'0.1',
|
||||
'1.0',
|
||||
'1.1.0',
|
||||
'1.2.3',
|
||||
...completelyValidSemanticVersions,
|
||||
]);
|
||||
|
||||
export const invalidVersionTagInputs = addVariantsPrependingV([
|
||||
'+invalid',
|
||||
'-invalid',
|
||||
'-invalid+invalid',
|
||||
'-invalid.01',
|
||||
'alpha',
|
||||
'alpha.beta',
|
||||
'alpha.beta.1',
|
||||
'alpha.1',
|
||||
'alpha+beta',
|
||||
'alpha_beta',
|
||||
'alpha.',
|
||||
'alpha..',
|
||||
'beta',
|
||||
'-alpha.',
|
||||
'-1.0.3-gamma+b7718',
|
||||
'+justmeta',
|
||||
]);
|
||||
@@ -1,21 +0,0 @@
|
||||
import { vi } from 'vitest';
|
||||
// Import this named export into your test file:
|
||||
import Platform from '../platform';
|
||||
|
||||
export const mockGetFromUser = vi.fn().mockResolvedValue({
|
||||
editorVersion: '',
|
||||
targetPlatform: Platform.types.Test,
|
||||
projectPath: '.',
|
||||
buildName: Platform.types.Test,
|
||||
buildsPath: 'build',
|
||||
buildMethod: undefined,
|
||||
buildVersion: '1.3.37',
|
||||
customParameters: '',
|
||||
sshAgent: '',
|
||||
chownFilesTo: '',
|
||||
gitPrivateToken: '',
|
||||
});
|
||||
|
||||
export default {
|
||||
getFromUser: mockGetFromUser,
|
||||
};
|
||||
@@ -1,39 +0,0 @@
|
||||
import { vi } from 'vitest';
|
||||
/* eslint unicorn/prevent-abbreviations: "off" */
|
||||
|
||||
// Import these named export into your test file:
|
||||
export const mockProjectPath = vi.fn().mockResolvedValue('mockProjectPath');
|
||||
export const mockIsDirtyAllowed = vi.fn().mockResolvedValue(false);
|
||||
export const mockBranch = vi.fn().mockResolvedValue('mockBranch');
|
||||
export const mockHeadRef = vi.fn().mockResolvedValue('mockHeadRef');
|
||||
export const mockRef = vi.fn().mockResolvedValue('mockRef');
|
||||
export const mockDetermineVersion = vi.fn().mockResolvedValue('1.2.3');
|
||||
export const mockGenerateSemanticVersion = vi.fn().mockResolvedValue('2.3.4');
|
||||
export const mockGenerateTagVersion = vi.fn().mockResolvedValue('1.0');
|
||||
export const mockParseSemanticVersion = vi.fn().mockResolvedValue({});
|
||||
export const mockFetch = vi.fn().mockImplementation(() => {});
|
||||
export const mockGetVersionDescription = vi.fn().mockResolvedValue('1.2-3-g12345678-dirty');
|
||||
export const mockIsDirty = vi.fn().mockResolvedValue(false);
|
||||
export const mockGetTag = vi.fn().mockResolvedValue('v1.0');
|
||||
export const mockHasAnyVersionTags = vi.fn().mockResolvedValue(true);
|
||||
export const mockGetTotalNumberOfCommits = vi.fn().mockResolvedValue(3);
|
||||
export const mockGit = vi.fn().mockImplementation(() => {});
|
||||
|
||||
export default {
|
||||
projectPath: mockProjectPath,
|
||||
isDirtyAllowed: mockIsDirtyAllowed,
|
||||
branch: mockBranch,
|
||||
headRef: mockHeadRef,
|
||||
ref: mockRef,
|
||||
determineVersion: mockDetermineVersion,
|
||||
generateSemanticVersion: mockGenerateSemanticVersion,
|
||||
generateTagVersion: mockGenerateTagVersion,
|
||||
parseSemanticVersion: mockParseSemanticVersion,
|
||||
fetch: mockFetch,
|
||||
getVersionDescription: mockGetVersionDescription,
|
||||
isDirty: mockIsDirty,
|
||||
getTag: mockGetTag,
|
||||
hasAnyVersionTags: mockHasAnyVersionTags,
|
||||
getTotalNumberOfCommits: mockGetTotalNumberOfCommits,
|
||||
git: mockGit,
|
||||
};
|
||||
@@ -1,3 +0,0 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`Versioning > determineBuildVersion > throws for invalid strategy somethingRandom 1`] = `[ValidationError: Versioning strategy should be one of None, Semantic, Tag, Custom.]`;
|
||||
@@ -1,34 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach, beforeAll, afterAll, test } from 'vitest';
|
||||
import path from 'node:path';
|
||||
import fs from 'node:fs';
|
||||
import Action from './action';
|
||||
|
||||
describe('Action', () => {
|
||||
describe('compatibility check', () => {
|
||||
it('throws for anything other than linux, windows, or mac', () => {
|
||||
switch (process.platform) {
|
||||
case 'linux':
|
||||
case 'win32':
|
||||
case 'darwin':
|
||||
expect(() => Action.checkCompatibility()).not.toThrow();
|
||||
break;
|
||||
default:
|
||||
expect(() => Action.checkCompatibility()).toThrow();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('returns the root folder of the action', () => {
|
||||
const { rootFolder, canonicalName } = Action;
|
||||
|
||||
expect(path.basename(rootFolder)).toStrictEqual(canonicalName);
|
||||
expect(fs.existsSync(rootFolder)).toStrictEqual(true);
|
||||
});
|
||||
|
||||
it('returns the action folder', () => {
|
||||
const { actionFolder } = Action;
|
||||
|
||||
expect(path.basename(actionFolder)).toStrictEqual('dist');
|
||||
expect(fs.existsSync(actionFolder)).toStrictEqual(true);
|
||||
});
|
||||
});
|
||||
@@ -1,48 +0,0 @@
|
||||
import path from 'node:path';
|
||||
|
||||
class Action {
|
||||
static get supportedPlatforms(): string[] {
|
||||
return ['linux', 'win32', 'darwin'];
|
||||
}
|
||||
|
||||
static get isRunningLocally(): boolean {
|
||||
return process.env.RUNNER_WORKSPACE === undefined;
|
||||
}
|
||||
|
||||
static get isRunningFromSource(): boolean {
|
||||
return path.basename(__dirname) === 'model';
|
||||
}
|
||||
|
||||
static get canonicalName(): string {
|
||||
if (Action.isRunningFromSource) {
|
||||
return path.basename(path.dirname(path.join(path.dirname(__filename), '/..')));
|
||||
}
|
||||
|
||||
return 'unity-builder';
|
||||
}
|
||||
|
||||
static get rootFolder(): string {
|
||||
if (Action.isRunningFromSource) {
|
||||
return path.dirname(path.dirname(path.dirname(__filename)));
|
||||
}
|
||||
|
||||
return path.dirname(path.dirname(__filename));
|
||||
}
|
||||
|
||||
static get actionFolder(): string {
|
||||
return `${Action.rootFolder}/dist`;
|
||||
}
|
||||
|
||||
static get workspace(): string {
|
||||
return process.env.GITHUB_WORKSPACE!;
|
||||
}
|
||||
|
||||
static checkCompatibility() {
|
||||
const currentPlatform = process.platform;
|
||||
if (!Action.supportedPlatforms.includes(currentPlatform)) {
|
||||
throw new Error(`Currently ${currentPlatform}-platform is not supported`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default Action;
|
||||
@@ -1,44 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach, beforeAll, afterAll, test } from 'vitest';
|
||||
import AndroidVersioning from './android-versioning';
|
||||
|
||||
describe('Android Versioning', () => {
|
||||
describe('versionToVersionCode', () => {
|
||||
it('defaults to 0 when versioning strategy is none', () => {
|
||||
expect(AndroidVersioning.versionToVersionCode('none')).toBe('0');
|
||||
});
|
||||
|
||||
it('defaults to 1 when version is not a valid semver', () => {
|
||||
expect(AndroidVersioning.versionToVersionCode('abcd')).toBe('1');
|
||||
});
|
||||
|
||||
it('returns a number', () => {
|
||||
expect(AndroidVersioning.versionToVersionCode('123.456.789')).toBe('123456789');
|
||||
});
|
||||
|
||||
it('throw when generated version code is too large', () => {
|
||||
expect(() => AndroidVersioning.versionToVersionCode('2050.0.0')).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('determineVersionCode', () => {
|
||||
it('defaults to parsed version', () => {
|
||||
expect(AndroidVersioning.determineVersionCode('1.2.3', '')).toBe('1002003');
|
||||
});
|
||||
|
||||
it('use specified code', () => {
|
||||
expect(AndroidVersioning.determineVersionCode('1.2.3', '2')).toBe('2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('determineSdkManagerParameters', () => {
|
||||
it('defaults to blank', () => {
|
||||
expect(AndroidVersioning.determineSdkManagerParameters('AndroidApiLevelAuto')).toBe('');
|
||||
});
|
||||
|
||||
it('uses the specified api level', () => {
|
||||
expect(AndroidVersioning.determineSdkManagerParameters('AndroidApiLevel30')).toBe(
|
||||
'platforms;android-30',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,50 +0,0 @@
|
||||
import * as core from '@actions/core';
|
||||
import * as semver from 'semver';
|
||||
|
||||
export default class AndroidVersioning {
|
||||
static determineVersionCode(version: string, inputVersionCode: string): string {
|
||||
if (inputVersionCode === '') {
|
||||
return AndroidVersioning.versionToVersionCode(version);
|
||||
}
|
||||
|
||||
return inputVersionCode;
|
||||
}
|
||||
|
||||
static versionToVersionCode(version: string): string {
|
||||
if (version === 'none') {
|
||||
core.info(
|
||||
`Versioning strategy is set to ${version}, so android version code should not be applied.`,
|
||||
);
|
||||
|
||||
return '0';
|
||||
}
|
||||
|
||||
const parsedVersion = semver.parse(version);
|
||||
|
||||
if (!parsedVersion) {
|
||||
core.warning(`Could not parse "${version}" to semver, defaulting android version code to 1`);
|
||||
|
||||
return '1';
|
||||
}
|
||||
|
||||
// The greatest value Google Plays allows is 2100000000.
|
||||
// Allow for 3 patch digits, 3 minor digits and 3 major digits.
|
||||
const versionCode =
|
||||
parsedVersion.major * 1000000 + parsedVersion.minor * 1000 + parsedVersion.patch;
|
||||
|
||||
if (versionCode >= 2050000000) {
|
||||
throw new Error(
|
||||
`Generated versionCode ${versionCode} is dangerously close to the maximum allowed number 2100000000. Consider a different versioning scheme to be able to continue updating your application.`,
|
||||
);
|
||||
}
|
||||
core.info(`Using android versionCode ${versionCode}`);
|
||||
|
||||
return versionCode.toString();
|
||||
}
|
||||
|
||||
static determineSdkManagerParameters(targetSdkVersion: string) {
|
||||
const parsedVersion = Number.parseInt(targetSdkVersion.slice(-2), 10);
|
||||
|
||||
return Number.isNaN(parsedVersion) ? '' : `platforms;android-${parsedVersion}`;
|
||||
}
|
||||
}
|
||||
@@ -1,254 +0,0 @@
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, test, vi } from 'vitest';
|
||||
import Versioning from './versioning';
|
||||
import UnityVersioning from './unity-versioning';
|
||||
import AndroidVersioning from './android-versioning';
|
||||
import BuildParameters from './build-parameters';
|
||||
import Input from './input';
|
||||
import Platform from './platform';
|
||||
|
||||
const testLicense =
|
||||
'<?xml version="1.0" encoding="UTF-8"?><root>\n <License id="Terms">\n <MachineBindings>\n <Binding Key="1" Value="576562626572264761624c65526f7578"/>\n <Binding Key="2" Value="576562626572264761624c65526f7578"/>\n </MachineBindings>\n <MachineID Value="D7nTUnjNAmtsUMcnoyrqkgIbYdM="/>\n <SerialHash Value="2033b8ac3e6faa3742ca9f0bfae44d18f2a96b80"/>\n <Features>\n <Feature Value="33"/>\n <Feature Value="1"/>\n <Feature Value="12"/>\n <Feature Value="2"/>\n <Feature Value="24"/>\n <Feature Value="3"/>\n <Feature Value="36"/>\n <Feature Value="17"/>\n <Feature Value="19"/>\n <Feature Value="62"/>\n </Features>\n <DeveloperData Value="AQAAAEY0LUJHUlgtWEQ0RS1aQ1dWLUM1SlctR0RIQg=="/>\n <SerialMasked Value="F4-BGRX-XD4E-ZCWV-C5JW-XXXX"/>\n <StartDate Value="2021-02-08T00:00:00"/>\n <UpdateDate Value="2021-02-09T00:34:57"/>\n <InitialActivationDate Value="2021-02-08T00:34:56"/>\n <LicenseVersion Value="6.x"/>\n <ClientProvidedVersion Value="2018.4.30f1"/>\n <AlwaysOnline Value="false"/>\n <Entitlements>\n <Entitlement Ns="unity_editor" Tag="UnityPersonal" Type="EDITOR" ValidTo="9999-12-31T00:00:00"/>\n <Entitlement Ns="unity_editor" Tag="DarkSkin" Type="EDITOR_FEATURE" ValidTo="9999-12-31T00:00:00"/>\n </Entitlements>\n </License>\n<Signature xmlns="http://www.w3.org/2000/09/xmldsig#"><SignedInfo><CanonicalizationMethod Algorithm="http://www.w3.org/TR/2001/REC-xml-c14n-20010315#WithComments"/><SignatureMethod Algorithm="http://www.w3.org/2000/09/xmldsig#rsa-sha1"/><Reference URI="#Terms"><Transforms><Transform Algorithm="http://www.w3.org/2000/09/xmldsig#enveloped-signature"/></Transforms><DigestMethod Algorithm="http://www.w3.org/2000/09/xmldsig#sha1"/><DigestValue>m0Db8UK+ktnOLJBtHybkfetpcKo=</DigestValue></Reference></SignedInfo><SignatureValue>o/pUbSQAukz7+ZYAWhnA0AJbIlyyCPL7bKVEM2lVqbrXt7cyey+umkCXamuOgsWPVUKBMkXtMH8L\n5etLmD0getWIhTGhzOnDCk+gtIPfL4jMo9tkEuOCROQAXCci23VFscKcrkB+3X6h4wEOtA2APhOY\nB+wvC794o8/82ffjP79aVAi57rp3Wmzx+9pe9yMwoJuljAy2sc2tIMgdQGWVmOGBpQm3JqsidyzI\nJWG2kjnc7pDXK9pwYzXoKiqUqqrut90d+kQqRyv7MSZXR50HFqD/LI69h68b7P8Bjo3bPXOhNXGR\n9YCoemH6EkfCJxp2gIjzjWW+l2Hj2EsFQi8YXw==</SignatureValue></Signature></root>';
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.spyOn(Versioning, 'determineBuildVersion').mockImplementation(async () => '1.3.37');
|
||||
process.env.UNITY_LICENSE = testLicense; // Todo - Don't use process.env directly, that's what the input model class is for.
|
||||
});
|
||||
|
||||
describe('BuildParameters', () => {
|
||||
describe('create', () => {
|
||||
it('does not throw', async () => {
|
||||
await expect(BuildParameters.create()).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it('determines the version only once', async () => {
|
||||
vi.spyOn(Versioning, 'determineBuildVersion').mockImplementation(async () => '1.3.37');
|
||||
await BuildParameters.create();
|
||||
await expect(Versioning.determineBuildVersion).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('determines the unity version only once', async () => {
|
||||
vi.spyOn(UnityVersioning, 'determineUnityVersion').mockImplementation(() => '2019.2.11f1');
|
||||
await BuildParameters.create();
|
||||
expect(UnityVersioning.determineUnityVersion).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('returns the android version code with provided input', async () => {
|
||||
const mockValue = '42';
|
||||
vi.spyOn(Input, 'androidVersionCode', 'get').mockReturnValue(mockValue);
|
||||
await expect(BuildParameters.create()).resolves.toEqual(
|
||||
expect.objectContaining({ androidVersionCode: mockValue }),
|
||||
);
|
||||
});
|
||||
|
||||
it('returns the android version code from version by default', async () => {
|
||||
const mockValue = '';
|
||||
vi.spyOn(Input, 'androidVersionCode', 'get').mockReturnValue(mockValue);
|
||||
await expect(BuildParameters.create()).resolves.toEqual(
|
||||
expect.objectContaining({ androidVersionCode: '1003037' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('determines the android sdk manager parameters only once', async () => {
|
||||
vi.spyOn(AndroidVersioning, 'determineSdkManagerParameters').mockImplementation(
|
||||
() => 'platforms;android-30',
|
||||
);
|
||||
await BuildParameters.create();
|
||||
expect(AndroidVersioning.determineSdkManagerParameters).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('returns the targetPlatform', async () => {
|
||||
const mockValue = 'somePlatform';
|
||||
vi.spyOn(Input, 'targetPlatform', 'get').mockReturnValue(mockValue);
|
||||
await expect(BuildParameters.create()).resolves.toEqual(
|
||||
expect.objectContaining({ targetPlatform: mockValue }),
|
||||
);
|
||||
});
|
||||
|
||||
it('returns the project path', async () => {
|
||||
const mockValue = 'path/to/project';
|
||||
vi.spyOn(UnityVersioning, 'determineUnityVersion').mockImplementation(() => '2019.2.11f1');
|
||||
vi.spyOn(Input, 'projectPath', 'get').mockReturnValue(mockValue);
|
||||
await expect(BuildParameters.create()).resolves.toEqual(
|
||||
expect.objectContaining({ projectPath: mockValue }),
|
||||
);
|
||||
});
|
||||
|
||||
it('returns the build profile', async () => {
|
||||
const mockValue = 'path/to/build_profile.asset';
|
||||
vi.spyOn(Input, 'buildProfile', 'get').mockReturnValue(mockValue);
|
||||
await expect(BuildParameters.create()).resolves.toEqual(
|
||||
expect.objectContaining({ buildProfile: mockValue }),
|
||||
);
|
||||
});
|
||||
|
||||
it('returns the build name', async () => {
|
||||
const mockValue = 'someBuildName';
|
||||
vi.spyOn(Input, 'buildName', 'get').mockReturnValue(mockValue);
|
||||
await expect(BuildParameters.create()).resolves.toEqual(
|
||||
expect.objectContaining({ buildName: mockValue }),
|
||||
);
|
||||
});
|
||||
|
||||
it('returns the build path', async () => {
|
||||
const mockPath = 'somePath';
|
||||
const mockPlatform = 'somePlatform';
|
||||
const expectedBuildPath = `${mockPath}/${mockPlatform}`;
|
||||
vi.spyOn(Input, 'buildsPath', 'get').mockReturnValue(mockPath);
|
||||
vi.spyOn(Input, 'targetPlatform', 'get').mockReturnValue(mockPlatform);
|
||||
await expect(BuildParameters.create()).resolves.toEqual(
|
||||
expect.objectContaining({ buildPath: expectedBuildPath }),
|
||||
);
|
||||
});
|
||||
|
||||
it('returns the build file', async () => {
|
||||
const mockValue = 'someBuildName';
|
||||
const mockPlatform = 'somePlatform';
|
||||
|
||||
vi.spyOn(Input, 'buildName', 'get').mockReturnValue(mockValue);
|
||||
vi.spyOn(Input, 'targetPlatform', 'get').mockReturnValue(mockPlatform);
|
||||
await expect(BuildParameters.create()).resolves.toEqual(
|
||||
expect.objectContaining({ buildFile: mockValue }),
|
||||
);
|
||||
});
|
||||
|
||||
test.each`
|
||||
targetPlatform | expectedExtension | androidExportType | linux64RemoveExecutableExtension
|
||||
${Platform.types.Android} | ${'.apk'} | ${'androidPackage'} | ${false}
|
||||
${Platform.types.Android} | ${'.aab'} | ${'androidAppBundle'} | ${true}
|
||||
${Platform.types.Android} | ${''} | ${'androidStudioProject'} | ${false}
|
||||
${Platform.types.StandaloneWindows} | ${'.exe'} | ${'n/a'} | ${true}
|
||||
${Platform.types.StandaloneWindows64} | ${'.exe'} | ${'n/a'} | ${false}
|
||||
${Platform.types.StandaloneLinux64} | ${'.x86_64'} | ${'n/a'} | ${false}
|
||||
${Platform.types.StandaloneLinux64} | ${''} | ${'n/a'} | ${true}
|
||||
`(
|
||||
'appends $expectedExtension for $targetPlatform with linux64RemoveExecutableExtension=$linux64RemoveExecutableExtension',
|
||||
async ({ targetPlatform, expectedExtension, androidExportType, linux64RemoveExecutableExtension }) => {
|
||||
vi.spyOn(Input, 'targetPlatform', 'get').mockReturnValue(targetPlatform);
|
||||
vi.spyOn(Input, 'buildName', 'get').mockReturnValue(targetPlatform);
|
||||
vi.spyOn(Input, 'androidExportType', 'get').mockReturnValue(androidExportType);
|
||||
vi.spyOn(Input, 'linux64RemoveExecutableExtension', 'get').mockReturnValue(linux64RemoveExecutableExtension);
|
||||
await expect(BuildParameters.create()).resolves.toEqual(
|
||||
expect.objectContaining({ buildFile: `${targetPlatform}${expectedExtension}` }),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test.each`
|
||||
targetPlatform | androidSymbolType
|
||||
${Platform.types.Android} | ${'none'}
|
||||
${Platform.types.Android} | ${'public'}
|
||||
${Platform.types.Android} | ${'debugging'}
|
||||
${Platform.types.StandaloneWindows} | ${'none'}
|
||||
${Platform.types.StandaloneWindows64} | ${'none'}
|
||||
`(
|
||||
'androidSymbolType is set to $androidSymbolType when targetPlatform is $targetPlatform and input targetSymbolType is $androidSymbolType',
|
||||
async ({ targetPlatform, androidSymbolType }) => {
|
||||
vi.spyOn(Input, 'targetPlatform', 'get').mockReturnValue(targetPlatform);
|
||||
vi.spyOn(Input, 'androidSymbolType', 'get').mockReturnValue(androidSymbolType);
|
||||
vi.spyOn(Input, 'buildName', 'get').mockReturnValue(targetPlatform);
|
||||
await expect(BuildParameters.create()).resolves.toEqual(
|
||||
expect.objectContaining({ androidSymbolType }),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it('returns the build method', async () => {
|
||||
const mockValue = 'Namespace.ClassName.BuildMethod';
|
||||
vi.spyOn(Input, 'buildMethod', 'get').mockReturnValue(mockValue);
|
||||
await expect(BuildParameters.create()).resolves.toEqual(
|
||||
expect.objectContaining({ buildMethod: mockValue }),
|
||||
);
|
||||
});
|
||||
|
||||
it('returns the android keystore name', async () => {
|
||||
const mockValue = 'keystore.keystore';
|
||||
vi.spyOn(Input, 'androidKeystoreName', 'get').mockReturnValue(mockValue);
|
||||
await expect(BuildParameters.create()).resolves.toEqual(
|
||||
expect.objectContaining({ androidKeystoreName: mockValue }),
|
||||
);
|
||||
});
|
||||
|
||||
it('returns the android keystore base64-encoded content', async () => {
|
||||
const mockValue = 'secret';
|
||||
vi.spyOn(Input, 'androidKeystoreBase64', 'get').mockReturnValue(mockValue);
|
||||
await expect(BuildParameters.create()).resolves.toEqual(
|
||||
expect.objectContaining({ androidKeystoreBase64: mockValue }),
|
||||
);
|
||||
});
|
||||
|
||||
it('returns the android keystore pass', async () => {
|
||||
const mockValue = 'secret';
|
||||
vi.spyOn(Input, 'androidKeystorePass', 'get').mockReturnValue(mockValue);
|
||||
await expect(BuildParameters.create()).resolves.toEqual(
|
||||
expect.objectContaining({ androidKeystorePass: mockValue }),
|
||||
);
|
||||
});
|
||||
|
||||
it('returns the android keyalias name', async () => {
|
||||
const mockValue = 'secret';
|
||||
vi.spyOn(Input, 'androidKeyaliasName', 'get').mockReturnValue(mockValue);
|
||||
await expect(BuildParameters.create()).resolves.toEqual(
|
||||
expect.objectContaining({ androidKeyaliasName: mockValue }),
|
||||
);
|
||||
});
|
||||
|
||||
it('returns the android keyalias pass', async () => {
|
||||
const mockValue = 'secret';
|
||||
vi.spyOn(Input, 'androidKeyaliasPass', 'get').mockReturnValue(mockValue);
|
||||
await expect(BuildParameters.create()).resolves.toEqual(
|
||||
expect.objectContaining({ androidKeyaliasPass: mockValue }),
|
||||
);
|
||||
});
|
||||
|
||||
it('returns the android target sdk version', async () => {
|
||||
const mockValue = 'AndroidApiLevelAuto';
|
||||
vi.spyOn(Input, 'androidTargetSdkVersion', 'get').mockReturnValue(mockValue);
|
||||
await expect(BuildParameters.create()).resolves.toEqual(
|
||||
expect.objectContaining({ androidTargetSdkVersion: mockValue }),
|
||||
);
|
||||
});
|
||||
|
||||
it('returns the unity licensing server address', async () => {
|
||||
const mockValue = 'http://example.com';
|
||||
vi.spyOn(Input, 'unityLicensingServer', 'get').mockReturnValue(mockValue);
|
||||
await expect(BuildParameters.create()).resolves.toEqual(
|
||||
expect.objectContaining({ unityLicensingServer: mockValue }),
|
||||
);
|
||||
});
|
||||
|
||||
it('throws error when no unity license provider provided', async () => {
|
||||
delete process.env.UNITY_LICENSE; // Need to delete this as it is set for every test currently
|
||||
await expect(BuildParameters.create()).rejects.toThrowError();
|
||||
});
|
||||
|
||||
it('return serial when no license server is provided', async () => {
|
||||
const mockValue = '123';
|
||||
delete process.env.UNITY_LICENSE; // Need to delete this as it is set for every test currently
|
||||
process.env.UNITY_SERIAL = mockValue;
|
||||
await expect(BuildParameters.create()).resolves.toEqual(
|
||||
expect.objectContaining({ unitySerial: mockValue }),
|
||||
);
|
||||
delete process.env.UNITY_SERIAL;
|
||||
});
|
||||
|
||||
it('returns the custom parameters', async () => {
|
||||
const mockValue = '-profile SomeProfile -someBoolean -someValue exampleValue';
|
||||
vi.spyOn(Input, 'customParameters', 'get').mockReturnValue(mockValue);
|
||||
await expect(BuildParameters.create()).resolves.toEqual(
|
||||
expect.objectContaining({ customParameters: mockValue }),
|
||||
);
|
||||
});
|
||||
|
||||
it.each([true, false])('returns the flag for useHostNetwork when %s', async (mockValue) => {
|
||||
vi.spyOn(Input, 'useHostNetwork', 'get').mockReturnValue(mockValue);
|
||||
await expect(BuildParameters.create()).resolves.toEqual(
|
||||
expect.objectContaining({ useHostNetwork: mockValue }),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,238 +0,0 @@
|
||||
import { customAlphabet } from 'nanoid';
|
||||
import AndroidVersioning from './android-versioning';
|
||||
import Input from './input';
|
||||
import Platform from './platform';
|
||||
import UnityVersioning from './unity-versioning';
|
||||
import Versioning from './versioning';
|
||||
import { GitRepoReader } from './input-readers/git-repo';
|
||||
import { GithubCliReader } from './input-readers/github-cli';
|
||||
import { PluginOptions } from './plugin-options';
|
||||
import GitHub from './github';
|
||||
import * as core from '@actions/core';
|
||||
|
||||
class BuildParameters {
|
||||
// eslint-disable-next-line no-undef
|
||||
[key: string]: any;
|
||||
|
||||
public editorVersion!: string;
|
||||
public customImage!: string;
|
||||
public unitySerial!: string;
|
||||
public unityLicensingServer!: string;
|
||||
public skipActivation!: string;
|
||||
public runnerTempPath!: string;
|
||||
public targetPlatform!: string;
|
||||
public projectPath!: string;
|
||||
public buildProfile!: string;
|
||||
public buildName!: string;
|
||||
public buildPath!: string;
|
||||
public buildFile!: string;
|
||||
public buildMethod!: string;
|
||||
public buildVersion!: string;
|
||||
public manualExit!: boolean;
|
||||
public enableGpu!: boolean;
|
||||
public androidVersionCode!: string;
|
||||
public androidKeystoreName!: string;
|
||||
public androidKeystoreBase64!: string;
|
||||
public androidKeystorePass!: string;
|
||||
public androidKeyaliasName!: string;
|
||||
public androidKeyaliasPass!: string;
|
||||
public androidTargetSdkVersion!: string;
|
||||
public androidSdkManagerParameters!: string;
|
||||
public androidExportType!: string;
|
||||
public androidSymbolType!: string;
|
||||
public dockerCpuLimit!: string;
|
||||
public dockerMemoryLimit!: string;
|
||||
public dockerIsolationMode!: string;
|
||||
public containerRegistryRepository!: string;
|
||||
public containerRegistryImageVersion!: string;
|
||||
|
||||
public customParameters!: string;
|
||||
public useHostNetwork!: boolean;
|
||||
public sshAgent!: string;
|
||||
public sshPublicKeysDirectoryPath!: string;
|
||||
public providerStrategy!: string;
|
||||
public gitPrivateToken!: string;
|
||||
public runAsHostUser!: string;
|
||||
public chownFilesTo!: string;
|
||||
|
||||
public runNumber!: string;
|
||||
public branch!: string;
|
||||
public githubRepo!: string;
|
||||
public gitSha!: string;
|
||||
public logId!: string;
|
||||
public buildGuid!: string;
|
||||
public buildPlatform!: string | undefined;
|
||||
public isCliMode!: boolean;
|
||||
|
||||
public cacheUnityInstallationOnMac!: boolean;
|
||||
public unityHubVersionOnMac!: string;
|
||||
public dockerWorkspacePath!: string;
|
||||
|
||||
static async create(): Promise<BuildParameters> {
|
||||
const buildFile = this.parseBuildFile(
|
||||
Input.buildName,
|
||||
Input.targetPlatform,
|
||||
Input.androidExportType,
|
||||
Input.linux64RemoveExecutableExtension,
|
||||
);
|
||||
const editorVersion = UnityVersioning.determineUnityVersion(
|
||||
Input.projectPath,
|
||||
Input.unityVersion,
|
||||
);
|
||||
const buildVersion = await Versioning.determineBuildVersion(
|
||||
Input.versioningStrategy,
|
||||
Input.specifiedVersion,
|
||||
);
|
||||
const androidVersionCode = AndroidVersioning.determineVersionCode(
|
||||
buildVersion,
|
||||
Input.androidVersionCode,
|
||||
);
|
||||
const androidSdkManagerParameters = AndroidVersioning.determineSdkManagerParameters(
|
||||
Input.androidTargetSdkVersion,
|
||||
);
|
||||
|
||||
const androidSymbolExportType = Input.androidSymbolType;
|
||||
if (Platform.isAndroid(Input.targetPlatform)) {
|
||||
switch (androidSymbolExportType) {
|
||||
case 'none':
|
||||
case 'public':
|
||||
case 'debugging':
|
||||
break;
|
||||
default:
|
||||
throw new Error(
|
||||
`Invalid androidSymbolType: ${Input.androidSymbolType}. Must be one of: none, public, debugging`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let unitySerial = '';
|
||||
if (Input.unityLicensingServer === '') {
|
||||
if (!Input.unitySerial && GitHub.githubInputEnabled) {
|
||||
// No serial was present, so it is a personal license that we need to convert
|
||||
if (!Input.unityLicense) {
|
||||
throw new Error(
|
||||
`Missing Unity License File and no Serial was found. If this
|
||||
is a personal license, make sure to follow the activation
|
||||
steps and set the UNITY_LICENSE GitHub secret or enter a Unity
|
||||
serial number inside the UNITY_SERIAL GitHub secret.`,
|
||||
);
|
||||
}
|
||||
unitySerial = this.getSerialFromLicenseFile(Input.unityLicense);
|
||||
} else {
|
||||
unitySerial = Input.unitySerial!;
|
||||
}
|
||||
}
|
||||
|
||||
if (unitySerial !== undefined && unitySerial.length === 27) {
|
||||
core.setSecret(unitySerial);
|
||||
core.setSecret(`${unitySerial.slice(0, -4)}XXXX`);
|
||||
}
|
||||
|
||||
const providerStrategy =
|
||||
Input.getInput('providerStrategy') || (PluginOptions.isPluginMode ? 'aws' : 'local');
|
||||
|
||||
return {
|
||||
editorVersion,
|
||||
customImage: Input.customImage,
|
||||
unitySerial,
|
||||
unityLicensingServer: Input.unityLicensingServer,
|
||||
skipActivation: Input.skipActivation,
|
||||
runnerTempPath: Input.runnerTempPath,
|
||||
targetPlatform: Input.targetPlatform,
|
||||
projectPath: Input.projectPath,
|
||||
buildProfile: Input.buildProfile,
|
||||
buildName: Input.buildName,
|
||||
buildPath: `${Input.buildsPath}/${Input.targetPlatform}`,
|
||||
buildFile,
|
||||
buildMethod: Input.buildMethod,
|
||||
buildVersion,
|
||||
manualExit: Input.manualExit,
|
||||
enableGpu: Input.enableGpu,
|
||||
androidVersionCode,
|
||||
androidKeystoreName: Input.androidKeystoreName,
|
||||
androidKeystoreBase64: Input.androidKeystoreBase64,
|
||||
androidKeystorePass: Input.androidKeystorePass,
|
||||
androidKeyaliasName: Input.androidKeyaliasName,
|
||||
androidKeyaliasPass: Input.androidKeyaliasPass,
|
||||
androidTargetSdkVersion: Input.androidTargetSdkVersion,
|
||||
androidSdkManagerParameters,
|
||||
androidExportType: Input.androidExportType,
|
||||
androidSymbolType: androidSymbolExportType,
|
||||
customParameters: Input.customParameters,
|
||||
useHostNetwork: Input.useHostNetwork,
|
||||
sshAgent: Input.sshAgent,
|
||||
sshPublicKeysDirectoryPath: Input.sshPublicKeysDirectoryPath,
|
||||
gitPrivateToken: Input.gitPrivateToken ?? (await GithubCliReader.GetGitHubAuthToken()),
|
||||
runAsHostUser: Input.runAsHostUser,
|
||||
chownFilesTo: Input.chownFilesTo,
|
||||
dockerCpuLimit: Input.dockerCpuLimit,
|
||||
dockerMemoryLimit: Input.dockerMemoryLimit,
|
||||
dockerIsolationMode: Input.dockerIsolationMode,
|
||||
containerRegistryRepository: Input.containerRegistryRepository,
|
||||
containerRegistryImageVersion: Input.containerRegistryImageVersion,
|
||||
providerStrategy,
|
||||
buildPlatform: providerStrategy !== 'local' ? 'linux' : process.platform,
|
||||
runNumber: Input.runNumber,
|
||||
branch: Input.branch.replace('/head', '') || (await GitRepoReader.GetBranch()),
|
||||
githubRepo:
|
||||
(Input.githubRepo ?? (await GitRepoReader.GetRemote())) || 'game-ci/unity-builder',
|
||||
gitSha: Input.gitSha,
|
||||
logId: customAlphabet('0123456789abcdefghijklmnopqrstuvwxyz', 9)(),
|
||||
buildGuid: `${Input.runNumber}-${Input.targetPlatform.toLowerCase().replace('standalone', '')}-${customAlphabet(
|
||||
'0123456789abcdefghijklmnopqrstuvwxyz',
|
||||
4,
|
||||
)()}`,
|
||||
isCliMode: PluginOptions.isPluginMode,
|
||||
cacheUnityInstallationOnMac: Input.cacheUnityInstallationOnMac,
|
||||
unityHubVersionOnMac: Input.unityHubVersionOnMac,
|
||||
dockerWorkspacePath: Input.dockerWorkspacePath,
|
||||
};
|
||||
}
|
||||
|
||||
static parseBuildFile(
|
||||
filename: string,
|
||||
platform: string,
|
||||
androidExportType: string,
|
||||
linux64RemoveExecutableExtension: boolean,
|
||||
): string {
|
||||
if (Platform.isWindows(platform)) {
|
||||
return `${filename}.exe`;
|
||||
}
|
||||
|
||||
if (Platform.isAndroid(platform)) {
|
||||
switch (androidExportType) {
|
||||
case `androidPackage`:
|
||||
return `${filename}.apk`;
|
||||
case `androidAppBundle`:
|
||||
return `${filename}.aab`;
|
||||
case `androidStudioProject`:
|
||||
return filename;
|
||||
default:
|
||||
throw new Error(
|
||||
`Unknown Android Export Type: ${androidExportType}. Must be one of androidPackage for apk, androidAppBundle for aab, androidStudioProject for android project`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (platform === Platform.types.StandaloneLinux64 && !linux64RemoveExecutableExtension) {
|
||||
return `${filename}.x86_64`;
|
||||
}
|
||||
|
||||
return filename;
|
||||
}
|
||||
|
||||
static getSerialFromLicenseFile(license: string) {
|
||||
const startKey = `<DeveloperData Value="`;
|
||||
const endKey = `"/>`;
|
||||
const startIndex = license.indexOf(startKey) + startKey.length;
|
||||
if (startIndex < 0) {
|
||||
throw new Error(`License File was corrupted, unable to locate serial`);
|
||||
}
|
||||
const endIndex = license.indexOf(endKey, startIndex);
|
||||
|
||||
// Slice off the first 4 characters as they are garbage values
|
||||
return Buffer.from(license.slice(startIndex, endIndex), 'base64').toString('binary').slice(4);
|
||||
}
|
||||
}
|
||||
|
||||
export default BuildParameters;
|
||||
@@ -1,12 +0,0 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, beforeAll, afterAll, vi } from 'vitest';
|
||||
import Cache from './cache';
|
||||
|
||||
vi.mock('./input');
|
||||
|
||||
describe('Cache', () => {
|
||||
describe('Verification', () => {
|
||||
it('does not throw', () => {
|
||||
expect(() => Cache.verify()).not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,26 +0,0 @@
|
||||
import * as core from '@actions/core';
|
||||
import fs from 'node:fs';
|
||||
import Action from './action';
|
||||
import Project from './project';
|
||||
|
||||
class Cache {
|
||||
static verify() {
|
||||
if (!fs.existsSync(Project.libraryFolder)) {
|
||||
this.notifyAboutCachingPossibility();
|
||||
}
|
||||
}
|
||||
|
||||
static notifyAboutCachingPossibility() {
|
||||
if (Action.isRunningLocally) {
|
||||
return;
|
||||
}
|
||||
|
||||
core.warning(`
|
||||
Library folder does not exist.
|
||||
Consider setting up caching to speed up your workflow,
|
||||
if this is not your first build.
|
||||
`);
|
||||
}
|
||||
}
|
||||
|
||||
export default Cache;
|
||||
@@ -1,17 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach, beforeAll, afterAll, test } from 'vitest';
|
||||
import Action from './action';
|
||||
import Docker from './docker';
|
||||
|
||||
describe('Docker', () => {
|
||||
it.skip('runs', async () => {
|
||||
const image = 'unity-builder:2019.2.11f1-webgl';
|
||||
const parameters = {
|
||||
workspace: Action.rootFolder,
|
||||
projectPath: `${Action.rootFolder}/test-project`,
|
||||
buildName: 'someBuildName',
|
||||
buildsPath: 'build',
|
||||
method: '',
|
||||
};
|
||||
await Docker.run(image, parameters);
|
||||
});
|
||||
});
|
||||
@@ -1,143 +0,0 @@
|
||||
import ImageEnvironmentFactory from './image-environment-factory';
|
||||
import { existsSync, mkdirSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { ExecOptions, exec } from '@actions/exec';
|
||||
import { DockerParameters, StringKeyValuePair } from './shared-types';
|
||||
|
||||
class Docker {
|
||||
static async run(
|
||||
image: string,
|
||||
parameters: DockerParameters,
|
||||
silent: boolean = false,
|
||||
overrideCommands: string = '',
|
||||
additionalVariables: StringKeyValuePair[] = [],
|
||||
options: ExecOptions = {},
|
||||
entrypointBash: boolean = false,
|
||||
): Promise<number> {
|
||||
let runCommand = '';
|
||||
switch (process.platform) {
|
||||
case 'linux':
|
||||
runCommand = this.getLinuxCommand(
|
||||
image,
|
||||
parameters,
|
||||
overrideCommands,
|
||||
additionalVariables,
|
||||
entrypointBash,
|
||||
);
|
||||
break;
|
||||
case 'win32':
|
||||
runCommand = this.getWindowsCommand(image, parameters);
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Operation system, ${process.platform}, is not supported yet.`);
|
||||
}
|
||||
|
||||
options.silent = silent;
|
||||
options.ignoreReturnCode = true;
|
||||
|
||||
return await exec(runCommand, undefined, options);
|
||||
}
|
||||
|
||||
static getLinuxCommand(
|
||||
image: string,
|
||||
parameters: DockerParameters,
|
||||
overrideCommands: string = '',
|
||||
additionalVariables: StringKeyValuePair[] = [],
|
||||
entrypointBash: boolean = false,
|
||||
): string {
|
||||
const {
|
||||
workspace,
|
||||
actionFolder,
|
||||
useHostNetwork,
|
||||
runnerTempPath,
|
||||
sshAgent,
|
||||
sshPublicKeysDirectoryPath,
|
||||
gitPrivateToken,
|
||||
dockerWorkspacePath,
|
||||
dockerCpuLimit,
|
||||
dockerMemoryLimit,
|
||||
} = parameters;
|
||||
|
||||
const githubHome = path.join(runnerTempPath, '_github_home');
|
||||
if (!existsSync(githubHome)) mkdirSync(githubHome);
|
||||
const githubWorkflow = path.join(runnerTempPath, '_github_workflow');
|
||||
if (!existsSync(githubWorkflow)) mkdirSync(githubWorkflow);
|
||||
|
||||
// Alpine-based images (alpine, rclone/rclone, etc.) don't have /bin/bash, only /bin/sh
|
||||
const isAlpineBasedImage = image === 'alpine' || image.startsWith('rclone/');
|
||||
const commandPrefix = isAlpineBasedImage ? `/bin/sh` : `/bin/bash`;
|
||||
|
||||
return `docker run \
|
||||
--workdir ${dockerWorkspacePath} \
|
||||
--rm \
|
||||
${ImageEnvironmentFactory.getEnvVarString(parameters, additionalVariables)} \
|
||||
--env GITHUB_WORKSPACE=${dockerWorkspacePath} \
|
||||
--env GIT_CONFIG_EXTENSIONS \
|
||||
${gitPrivateToken ? `--env GIT_PRIVATE_TOKEN="${gitPrivateToken}"` : ''} \
|
||||
${sshAgent ? '--env SSH_AUTH_SOCK=/ssh-agent' : ''} \
|
||||
--volume "${githubHome}":"/root:z" \
|
||||
--volume "${githubWorkflow}":"/github/workflow:z" \
|
||||
--volume "${workspace}":"${dockerWorkspacePath}:z" \
|
||||
--volume "${actionFolder}/default-build-script:/UnityBuilderAction:z" \
|
||||
--volume "${actionFolder}/platforms/ubuntu/steps:/steps:z" \
|
||||
--volume "${actionFolder}/platforms/ubuntu/entrypoint.sh:/entrypoint.sh:z" \
|
||||
--volume "${actionFolder}/unity-config:/usr/share/unity3d/config/:z" \
|
||||
--volume "${actionFolder}/BlankProject":"/BlankProject:z" \
|
||||
--cpus=${dockerCpuLimit} \
|
||||
--memory=${dockerMemoryLimit} \
|
||||
${sshAgent ? `--volume ${sshAgent}:/ssh-agent` : ''} \
|
||||
${
|
||||
sshAgent && !sshPublicKeysDirectoryPath
|
||||
? '--volume /home/runner/.ssh/known_hosts:/root/.ssh/known_hosts:ro'
|
||||
: ''
|
||||
} \
|
||||
${sshPublicKeysDirectoryPath ? `--volume ${sshPublicKeysDirectoryPath}:/root/.ssh:ro` : ''} \
|
||||
${useHostNetwork ? '--net=host' : ''} \
|
||||
${entrypointBash ? `--entrypoint ${commandPrefix}` : ``} \
|
||||
${image} \
|
||||
${entrypointBash ? `-c` : `${commandPrefix} -c`} \
|
||||
"${overrideCommands !== '' ? overrideCommands : `/entrypoint.sh`}"`;
|
||||
}
|
||||
|
||||
static getWindowsCommand(image: string, parameters: DockerParameters): string {
|
||||
const {
|
||||
workspace,
|
||||
actionFolder,
|
||||
runnerTempPath,
|
||||
gitPrivateToken,
|
||||
dockerWorkspacePath,
|
||||
dockerCpuLimit,
|
||||
dockerMemoryLimit,
|
||||
dockerIsolationMode,
|
||||
} = parameters;
|
||||
|
||||
const githubHome = path.join(runnerTempPath, '_github_home');
|
||||
if (!existsSync(githubHome)) mkdirSync(githubHome);
|
||||
|
||||
return `docker run \
|
||||
--workdir c:${dockerWorkspacePath} \
|
||||
--rm \
|
||||
${ImageEnvironmentFactory.getEnvVarString(parameters)} \
|
||||
--env BEE_CACHE_DIRECTORY=c:${dockerWorkspacePath}/Library/bee_cache \
|
||||
--env GITHUB_WORKSPACE=c:${dockerWorkspacePath} \
|
||||
${gitPrivateToken ? `--env GIT_PRIVATE_TOKEN="${gitPrivateToken}"` : ''} \
|
||||
--volume "${workspace}":"c:${dockerWorkspacePath}" \
|
||||
--volume "${githubHome}":"C:/githubhome" \
|
||||
--volume "c:/regkeys":"c:/regkeys" \
|
||||
--volume "C:/Program Files/Microsoft Visual Studio":"C:/Program Files/Microsoft Visual Studio" \
|
||||
--volume "C:/Program Files (x86)/Microsoft Visual Studio":"C:/Program Files (x86)/Microsoft Visual Studio" \
|
||||
--volume "C:/Program Files (x86)/Windows Kits":"C:/Program Files (x86)/Windows Kits" \
|
||||
--volume "C:/ProgramData/Microsoft/VisualStudio":"C:/ProgramData/Microsoft/VisualStudio" \
|
||||
--volume "${actionFolder}/default-build-script":"c:/UnityBuilderAction" \
|
||||
--volume "${actionFolder}/platforms/windows":"c:/steps" \
|
||||
--volume "${actionFolder}/unity-config":"C:/ProgramData/Unity/config" \
|
||||
--volume "${actionFolder}/BlankProject":"c:/BlankProject" \
|
||||
--cpus=${dockerCpuLimit} \
|
||||
--memory=${dockerMemoryLimit} \
|
||||
--isolation=${dockerIsolationMode} \
|
||||
${image} \
|
||||
powershell c:/steps/entrypoint.ps1`;
|
||||
}
|
||||
}
|
||||
|
||||
export default Docker;
|
||||
@@ -1,15 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach, beforeAll, afterAll, test } from 'vitest';
|
||||
import CommandExecutionError from './command-execution-error';
|
||||
|
||||
describe('CommandExecutionError', () => {
|
||||
it('instantiates', () => {
|
||||
expect(() => new CommandExecutionError()).not.toThrow();
|
||||
});
|
||||
|
||||
test.each(['one'])('Displays title %s', (message) => {
|
||||
const error = new CommandExecutionError(message);
|
||||
|
||||
expect(error.name).toStrictEqual('CommandExecutionError');
|
||||
expect(error.message).toStrictEqual(message.toString());
|
||||
});
|
||||
});
|
||||
@@ -1,8 +0,0 @@
|
||||
class CommandExecutionError extends Error {
|
||||
constructor(message = '') {
|
||||
super(message);
|
||||
this.name = 'CommandExecutionError';
|
||||
}
|
||||
}
|
||||
|
||||
export default CommandExecutionError;
|
||||
@@ -1,15 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach, beforeAll, afterAll, test } from 'vitest';
|
||||
import NotImplementedException from './not-implemented-exception';
|
||||
|
||||
describe('NotImplementedException', () => {
|
||||
it('instantiates', () => {
|
||||
expect(() => new NotImplementedException()).not.toThrow();
|
||||
});
|
||||
|
||||
test.each(['one'])('Displays title %s', (message) => {
|
||||
const error = new NotImplementedException(message);
|
||||
|
||||
expect(error.name).toStrictEqual('NotImplementedException');
|
||||
expect(error.message).toStrictEqual(message.toString());
|
||||
});
|
||||
});
|
||||
@@ -1,8 +0,0 @@
|
||||
class NotImplementedException extends Error {
|
||||
constructor(message = '') {
|
||||
super(message);
|
||||
this.name = 'NotImplementedException';
|
||||
}
|
||||
}
|
||||
|
||||
export default NotImplementedException;
|
||||
@@ -1,15 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach, beforeAll, afterAll, test } from 'vitest';
|
||||
import ValidationError from './validation-error';
|
||||
|
||||
describe('ValidationError', () => {
|
||||
it('instantiates', () => {
|
||||
expect(() => new ValidationError()).not.toThrow();
|
||||
});
|
||||
|
||||
test.each(['one'])('Displays title %s', (message) => {
|
||||
const error = new ValidationError(message);
|
||||
|
||||
expect(error.name).toStrictEqual('ValidationError');
|
||||
expect(error.message).toStrictEqual(message.toString());
|
||||
});
|
||||
});
|
||||
@@ -1,8 +0,0 @@
|
||||
class ValidationError extends Error {
|
||||
constructor(message = '') {
|
||||
super(message);
|
||||
this.name = 'ValidationError';
|
||||
}
|
||||
}
|
||||
|
||||
export default ValidationError;
|
||||
@@ -1,5 +0,0 @@
|
||||
class GitHub {
|
||||
public static githubInputEnabled: boolean = true;
|
||||
}
|
||||
|
||||
export default GitHub;
|
||||
@@ -1,110 +0,0 @@
|
||||
import { DockerParameters, StringKeyValuePair } from './shared-types';
|
||||
|
||||
class ImageEnvironmentFactory {
|
||||
public static getEnvVarString(
|
||||
parameters: DockerParameters,
|
||||
additionalVariables: StringKeyValuePair[] = [],
|
||||
) {
|
||||
const environmentVariables = ImageEnvironmentFactory.getEnvironmentVariables(
|
||||
parameters,
|
||||
additionalVariables,
|
||||
);
|
||||
let string = '';
|
||||
for (const p of environmentVariables) {
|
||||
if (p.value === '' || p.value === undefined || p.value === null) {
|
||||
continue;
|
||||
}
|
||||
const valueAsString = typeof p.value === 'string' ? p.value : String(p.value);
|
||||
if (p.name !== 'ANDROID_KEYSTORE_BASE64' && valueAsString.includes(`\n`)) {
|
||||
string += `--env ${p.name} `;
|
||||
process.env[p.name] = valueAsString;
|
||||
continue;
|
||||
}
|
||||
|
||||
string += `--env ${p.name}="${valueAsString}" `;
|
||||
}
|
||||
|
||||
return string;
|
||||
}
|
||||
|
||||
public static getEnvironmentVariables(
|
||||
parameters: DockerParameters,
|
||||
additionalVariables: StringKeyValuePair[] = [],
|
||||
) {
|
||||
let environmentVariables: StringKeyValuePair[] = [
|
||||
{ name: 'UNITY_EMAIL', value: process.env.UNITY_EMAIL },
|
||||
{ name: 'UNITY_PASSWORD', value: process.env.UNITY_PASSWORD },
|
||||
{ name: 'UNITY_SERIAL', value: parameters.unitySerial },
|
||||
{
|
||||
name: 'UNITY_LICENSING_SERVER',
|
||||
value: parameters.unityLicensingServer,
|
||||
},
|
||||
{ name: 'SKIP_ACTIVATION', value: parameters.skipActivation },
|
||||
{ name: 'UNITY_VERSION', value: parameters.editorVersion },
|
||||
{
|
||||
name: 'USYM_UPLOAD_AUTH_TOKEN',
|
||||
value: process.env.USYM_UPLOAD_AUTH_TOKEN,
|
||||
},
|
||||
{ name: 'PROJECT_PATH', value: parameters.projectPath },
|
||||
{ name: 'BUILD_PROFILE', value: parameters.buildProfile },
|
||||
{ name: 'BUILD_TARGET', value: parameters.targetPlatform },
|
||||
{ name: 'BUILD_NAME', value: parameters.buildName },
|
||||
{ name: 'BUILD_PATH', value: parameters.buildPath },
|
||||
{ name: 'BUILD_FILE', value: parameters.buildFile },
|
||||
{ name: 'BUILD_METHOD', value: parameters.buildMethod },
|
||||
{ name: 'MANUAL_EXIT', value: parameters.manualExit },
|
||||
{ name: 'ENABLE_GPU', value: parameters.enableGpu },
|
||||
{ name: 'VERSION', value: parameters.buildVersion },
|
||||
{ name: 'ANDROID_VERSION_CODE', value: parameters.androidVersionCode },
|
||||
{ name: 'ANDROID_KEYSTORE_NAME', value: parameters.androidKeystoreName },
|
||||
{
|
||||
name: 'ANDROID_KEYSTORE_BASE64',
|
||||
value: parameters.androidKeystoreBase64,
|
||||
},
|
||||
{ name: 'ANDROID_KEYSTORE_PASS', value: parameters.androidKeystorePass },
|
||||
{ name: 'ANDROID_KEYALIAS_NAME', value: parameters.androidKeyaliasName },
|
||||
{ name: 'ANDROID_KEYALIAS_PASS', value: parameters.androidKeyaliasPass },
|
||||
{
|
||||
name: 'ANDROID_TARGET_SDK_VERSION',
|
||||
value: parameters.androidTargetSdkVersion,
|
||||
},
|
||||
{
|
||||
name: 'ANDROID_SDK_MANAGER_PARAMETERS',
|
||||
value: parameters.androidSdkManagerParameters,
|
||||
},
|
||||
{ name: 'ANDROID_EXPORT_TYPE', value: parameters.androidExportType },
|
||||
{ name: 'ANDROID_SYMBOL_TYPE', value: parameters.androidSymbolType },
|
||||
{ name: 'CUSTOM_PARAMETERS', value: parameters.customParameters },
|
||||
{ name: 'RUN_AS_HOST_USER', value: parameters.runAsHostUser },
|
||||
{ name: 'CHOWN_FILES_TO', value: parameters.chownFilesTo },
|
||||
{ name: 'GITHUB_REF', value: process.env.GITHUB_REF },
|
||||
{ name: 'GITHUB_SHA', value: process.env.GITHUB_SHA },
|
||||
{ name: 'GITHUB_REPOSITORY', value: process.env.GITHUB_REPOSITORY },
|
||||
{ name: 'GITHUB_ACTOR', value: process.env.GITHUB_ACTOR },
|
||||
{ name: 'GITHUB_WORKFLOW', value: process.env.GITHUB_WORKFLOW },
|
||||
{ name: 'GITHUB_HEAD_REF', value: process.env.GITHUB_HEAD_REF },
|
||||
{ name: 'GITHUB_BASE_REF', value: process.env.GITHUB_BASE_REF },
|
||||
{ name: 'GITHUB_EVENT_NAME', value: process.env.GITHUB_EVENT_NAME },
|
||||
{ name: 'GITHUB_ACTION', value: process.env.GITHUB_ACTION },
|
||||
{ name: 'GITHUB_EVENT_PATH', value: process.env.GITHUB_EVENT_PATH },
|
||||
{ name: 'RUNNER_OS', value: process.env.RUNNER_OS },
|
||||
{ name: 'RUNNER_TOOL_CACHE', value: process.env.RUNNER_TOOL_CACHE },
|
||||
{ name: 'RUNNER_TEMP', value: process.env.RUNNER_TEMP },
|
||||
{ name: 'RUNNER_WORKSPACE', value: process.env.RUNNER_WORKSPACE },
|
||||
];
|
||||
|
||||
// Always merge additional variables (e.g., secrets/env from Orchestrator) uniquely by name
|
||||
for (const element of additionalVariables) {
|
||||
if (!element || !element.name) continue;
|
||||
environmentVariables = environmentVariables.filter((x) => x?.name !== element.name);
|
||||
environmentVariables.push(element);
|
||||
}
|
||||
if (parameters.sshAgent) {
|
||||
environmentVariables.push({ name: 'SSH_AUTH_SOCK', value: '/ssh-agent' });
|
||||
}
|
||||
|
||||
return environmentVariables;
|
||||
}
|
||||
}
|
||||
|
||||
export default ImageEnvironmentFactory;
|
||||
@@ -1,119 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach, beforeAll, afterAll, test } from 'vitest';
|
||||
import ImageTag from './image-tag';
|
||||
|
||||
describe('ImageTag', () => {
|
||||
const testImageParameters = {
|
||||
editorVersion: '2099.9.9f9',
|
||||
targetPlatform: 'Test',
|
||||
builderPlatform: '',
|
||||
containerRegistryRepository: 'unityci/editor',
|
||||
containerRegistryImageVersion: '3',
|
||||
};
|
||||
|
||||
const defaults = {
|
||||
image: 'unityci/editor',
|
||||
};
|
||||
|
||||
describe('constructor', () => {
|
||||
it('can be called', () => {
|
||||
expect(() => new ImageTag(testImageParameters)).not.toThrow();
|
||||
});
|
||||
|
||||
it('accepts parameters and sets the right properties', () => {
|
||||
const image = new ImageTag(testImageParameters);
|
||||
|
||||
expect(image.repository).toStrictEqual('unityci/editor');
|
||||
expect(image.editorVersion).toStrictEqual(testImageParameters.editorVersion);
|
||||
expect(image.targetPlatform).toStrictEqual(testImageParameters.targetPlatform);
|
||||
expect(image.builderPlatform).toStrictEqual(testImageParameters.builderPlatform);
|
||||
});
|
||||
|
||||
test.each(['2000.0.0f0', '2011.1.11f1', '6000.0.0f1'])(
|
||||
'accepts %p version format',
|
||||
(version) => {
|
||||
expect(
|
||||
() =>
|
||||
new ImageTag({
|
||||
editorVersion: version,
|
||||
targetPlatform: testImageParameters.targetPlatform,
|
||||
}),
|
||||
).not.toThrow();
|
||||
},
|
||||
);
|
||||
|
||||
test.each(['some version', ''])('throws for incorrect version %p', (editorVersion) => {
|
||||
const { targetPlatform } = testImageParameters;
|
||||
expect(() => new ImageTag({ editorVersion, targetPlatform })).toThrow();
|
||||
});
|
||||
|
||||
test.each(['nonExisting'])('throws for unsupported target %p', (targetPlatform) => {
|
||||
expect(() => new ImageTag({ targetPlatform })).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('toString', () => {
|
||||
it('returns the correct version', () => {
|
||||
const image = new ImageTag({
|
||||
editorVersion: '2099.1.1111f1',
|
||||
targetPlatform: testImageParameters.targetPlatform,
|
||||
containerRegistryRepository: 'unityci/editor',
|
||||
containerRegistryImageVersion: '3',
|
||||
});
|
||||
switch (process.platform) {
|
||||
case 'win32':
|
||||
expect(image.toString()).toStrictEqual(`${defaults.image}:windows-2099.1.1111f1-3`);
|
||||
break;
|
||||
case 'linux':
|
||||
expect(image.toString()).toStrictEqual(`${defaults.image}:ubuntu-2099.1.1111f1-3`);
|
||||
break;
|
||||
}
|
||||
});
|
||||
it('returns customImage if given', () => {
|
||||
const image = new ImageTag({
|
||||
editorVersion: '2099.1.1111f1',
|
||||
targetPlatform: testImageParameters.targetPlatform,
|
||||
customImage: `${defaults.image}:2099.1.1111@347598437689743986`,
|
||||
containerRegistryRepository: 'unityci/editor',
|
||||
containerRegistryImageVersion: '3',
|
||||
});
|
||||
|
||||
expect(image.toString()).toStrictEqual(image.customImage);
|
||||
});
|
||||
|
||||
it('returns the specific build platform', () => {
|
||||
const image = new ImageTag({
|
||||
editorVersion: '2019.2.11f1',
|
||||
targetPlatform: 'WebGL',
|
||||
containerRegistryRepository: 'unityci/editor',
|
||||
containerRegistryImageVersion: '3',
|
||||
});
|
||||
|
||||
switch (process.platform) {
|
||||
case 'win32':
|
||||
expect(image.toString()).toStrictEqual(`${defaults.image}:windows-2019.2.11f1-webgl-3`);
|
||||
break;
|
||||
case 'linux':
|
||||
expect(image.toString()).toStrictEqual(`${defaults.image}:ubuntu-2019.2.11f1-webgl-3`);
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
it('returns no specific build platform for generic targetPlatforms', () => {
|
||||
const image = new ImageTag({
|
||||
editorVersion: '2019.2.11f1',
|
||||
targetPlatform: 'NoTarget',
|
||||
containerRegistryRepository: 'unityci/editor',
|
||||
containerRegistryImageVersion: '3',
|
||||
});
|
||||
|
||||
switch (process.platform) {
|
||||
case 'win32':
|
||||
expect(image.toString()).toStrictEqual(`${defaults.image}:windows-2019.2.11f1-3`);
|
||||
break;
|
||||
case 'linux':
|
||||
expect(image.toString()).toStrictEqual(`${defaults.image}:ubuntu-2019.2.11f1-3`);
|
||||
break;
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,208 +0,0 @@
|
||||
import Platform from './platform';
|
||||
|
||||
class ImageTag {
|
||||
public repository: string;
|
||||
public editorVersion: string;
|
||||
public targetPlatform: string;
|
||||
public builderPlatform: string;
|
||||
public customImage: string;
|
||||
public imageRollingVersion: number;
|
||||
public imagePlatformPrefix: string;
|
||||
|
||||
constructor(imageProperties: { [key: string]: string }) {
|
||||
const {
|
||||
editorVersion,
|
||||
targetPlatform,
|
||||
customImage,
|
||||
buildPlatform,
|
||||
containerRegistryRepository,
|
||||
containerRegistryImageVersion,
|
||||
providerStrategy,
|
||||
} = imageProperties;
|
||||
|
||||
if (!ImageTag.versionPattern.test(editorVersion)) {
|
||||
throw new Error(`Invalid version "${editorVersion}".`);
|
||||
}
|
||||
|
||||
// Todo we might as well skip this class for customImage.
|
||||
// Either
|
||||
this.customImage = customImage;
|
||||
|
||||
// Or
|
||||
this.repository = containerRegistryRepository;
|
||||
this.editorVersion = editorVersion;
|
||||
this.targetPlatform = targetPlatform;
|
||||
this.builderPlatform = ImageTag.getTargetPlatformToTargetPlatformSuffixMap(
|
||||
targetPlatform,
|
||||
editorVersion,
|
||||
providerStrategy,
|
||||
);
|
||||
this.imagePlatformPrefix = ImageTag.getImagePlatformPrefixes(buildPlatform);
|
||||
this.imageRollingVersion = Number(containerRegistryImageVersion); // Will automatically roll to the latest non-breaking version.
|
||||
}
|
||||
|
||||
static get versionPattern(): RegExp {
|
||||
return /^\d+\.\d+\.\d+[a-z]\d+$/;
|
||||
}
|
||||
|
||||
static get targetPlatformSuffixes() {
|
||||
return {
|
||||
generic: '',
|
||||
webgl: 'webgl',
|
||||
mac: 'mac-mono',
|
||||
windows: 'windows-mono',
|
||||
windowsIl2cpp: 'windows-il2cpp',
|
||||
wsaPlayer: 'universal-windows-platform',
|
||||
linux: 'base',
|
||||
linuxIl2cpp: 'linux-il2cpp',
|
||||
android: 'android',
|
||||
ios: 'ios',
|
||||
tvos: 'appletv',
|
||||
visionos: 'visionos',
|
||||
facebook: 'facebook',
|
||||
};
|
||||
}
|
||||
|
||||
static getImagePlatformPrefixes(platform: string): string {
|
||||
if (!platform || platform === '') {
|
||||
platform = process.platform;
|
||||
}
|
||||
|
||||
switch (platform) {
|
||||
case 'win32':
|
||||
return 'windows';
|
||||
case 'linux':
|
||||
return 'ubuntu';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
static getTargetPlatformToTargetPlatformSuffixMap(
|
||||
platform: string,
|
||||
version: string,
|
||||
providerStrategy: string,
|
||||
): string {
|
||||
const {
|
||||
generic,
|
||||
webgl,
|
||||
mac,
|
||||
windows,
|
||||
windowsIl2cpp,
|
||||
wsaPlayer,
|
||||
linux,
|
||||
linuxIl2cpp,
|
||||
android,
|
||||
ios,
|
||||
tvos,
|
||||
visionos,
|
||||
facebook,
|
||||
} = ImageTag.targetPlatformSuffixes;
|
||||
|
||||
const [major, minor] = version.split('.').map((digit) => Number(digit));
|
||||
|
||||
// @see: https://docs.unity3d.com/ScriptReference/BuildTarget.html
|
||||
switch (platform) {
|
||||
case Platform.types.StandaloneOSX:
|
||||
return mac;
|
||||
case Platform.types.StandaloneWindows:
|
||||
case Platform.types.StandaloneWindows64:
|
||||
// Can only build windows-il2cpp on a windows based system
|
||||
if (process.platform === 'win32') {
|
||||
// Unity versions before 2019.3 do not support il2cpp
|
||||
if (major >= 2020 || (major === 2019 && minor >= 3)) {
|
||||
return windowsIl2cpp;
|
||||
} else {
|
||||
throw new Error(
|
||||
`Windows-based builds are only supported on 2019.3.X+ versions of Unity.
|
||||
If you are trying to build for windows-mono, please use a Linux based OS.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return windows;
|
||||
case Platform.types.StandaloneLinux64: {
|
||||
// Unity versions before 2019.3 do not support il2cpp
|
||||
if (major >= 2020 || (major === 2019 && minor >= 3)) {
|
||||
if (providerStrategy === 'local') {
|
||||
return linuxIl2cpp;
|
||||
} else {
|
||||
return process.env.USE_IL2CPP === 'true' ? linuxIl2cpp : linux;
|
||||
}
|
||||
}
|
||||
|
||||
return linux;
|
||||
}
|
||||
case Platform.types.iOS:
|
||||
return ios;
|
||||
case Platform.types.Android:
|
||||
return android;
|
||||
case Platform.types.WebGL:
|
||||
return webgl;
|
||||
case Platform.types.WSAPlayer:
|
||||
if (process.platform !== 'win32') {
|
||||
throw new Error(`WSAPlayer can only be built on a windows base OS`);
|
||||
}
|
||||
|
||||
return wsaPlayer;
|
||||
case Platform.types.PS4:
|
||||
return windows;
|
||||
case Platform.types.XboxOne:
|
||||
return windows;
|
||||
case Platform.types.tvOS:
|
||||
if (process.platform !== 'win32' && process.platform !== 'darwin') {
|
||||
throw new Error(`tvOS can only be built on Windows or macOS base OS`);
|
||||
}
|
||||
|
||||
return tvos;
|
||||
case Platform.types.VisionOS:
|
||||
if (process.platform !== 'darwin') {
|
||||
throw new Error(`visionOS can only be built on a macOS base OS`);
|
||||
}
|
||||
|
||||
return visionos;
|
||||
case Platform.types.Switch:
|
||||
return windows;
|
||||
|
||||
// Unsupported
|
||||
case Platform.types.Lumin:
|
||||
return windows;
|
||||
case Platform.types.BJM:
|
||||
return windows;
|
||||
case Platform.types.Stadia:
|
||||
return windows;
|
||||
case Platform.types.Facebook:
|
||||
return facebook;
|
||||
case Platform.types.NoTarget:
|
||||
return generic;
|
||||
|
||||
// Test specific
|
||||
case Platform.types.Test:
|
||||
return generic;
|
||||
default:
|
||||
throw new Error(`
|
||||
Platform must be one of the ones described in the documentation.
|
||||
"${platform}" is currently not supported.`);
|
||||
}
|
||||
}
|
||||
|
||||
get tag(): string {
|
||||
const versionAndPlatform = `${this.editorVersion}-${this.builderPlatform}`.replace(/-+$/, '');
|
||||
|
||||
return `${this.imagePlatformPrefix}-${versionAndPlatform}-${this.imageRollingVersion}`;
|
||||
}
|
||||
|
||||
get image(): string {
|
||||
return `${this.repository}`.replace(/^\/+/, '');
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
const { image, tag, customImage } = this;
|
||||
|
||||
if (customImage) return customImage;
|
||||
|
||||
return `${image}:${tag}`;
|
||||
}
|
||||
}
|
||||
|
||||
export default ImageTag;
|
||||
@@ -1,23 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach, beforeAll, afterAll, test } from 'vitest';
|
||||
import * as Index from '.';
|
||||
|
||||
interface ExportedModules {
|
||||
[key: string]: any;
|
||||
Action: any;
|
||||
BuildParameters: any;
|
||||
Cache: any;
|
||||
Docker: any;
|
||||
ImageTag: any;
|
||||
Input: any;
|
||||
Platform: any;
|
||||
Project: any;
|
||||
Unity: any;
|
||||
}
|
||||
|
||||
const exportedModules: ExportedModules = Index;
|
||||
|
||||
describe('Index', () => {
|
||||
test.each(Object.keys(exportedModules))('exports %s', (exportedModule) => {
|
||||
expect(exportedModules[exportedModule]).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -1,25 +0,0 @@
|
||||
import Action from './action';
|
||||
import BuildParameters from './build-parameters';
|
||||
import Cache from './cache';
|
||||
import Docker from './docker';
|
||||
import Input from './input';
|
||||
import ImageTag from './image-tag';
|
||||
import Output from './output';
|
||||
import Platform from './platform';
|
||||
import Project from './project';
|
||||
import Unity from './unity';
|
||||
import Versioning from './versioning';
|
||||
|
||||
export {
|
||||
Action,
|
||||
BuildParameters,
|
||||
Cache,
|
||||
Docker,
|
||||
Input,
|
||||
ImageTag,
|
||||
Output,
|
||||
Platform,
|
||||
Project,
|
||||
Unity,
|
||||
Versioning,
|
||||
};
|
||||
@@ -1,17 +0,0 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import YAML from 'yaml';
|
||||
|
||||
export class ActionYamlReader {
|
||||
private actionYamlParsed: any;
|
||||
public constructor() {
|
||||
let filename = `action.yml`;
|
||||
if (!fs.existsSync(filename)) {
|
||||
filename = path.join(__dirname, `..`, filename);
|
||||
}
|
||||
this.actionYamlParsed = YAML.parse(fs.readFileSync(filename).toString());
|
||||
}
|
||||
public GetActionYamlValue(key: string) {
|
||||
return this.actionYamlParsed.inputs[key]?.description || 'No description found in action.yml';
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import { exec } from 'node:child_process';
|
||||
import Input from '../input';
|
||||
|
||||
export class GenericInputReader {
|
||||
public static async Run(command: string) {
|
||||
if ((Input.getInput('providerStrategy') || 'local') === 'local') {
|
||||
return '';
|
||||
}
|
||||
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
exec(command, { maxBuffer: 1024 * 10000 }, (error, stdout) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
|
||||
return;
|
||||
}
|
||||
resolve(stdout.toString());
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, beforeAll, afterAll, vi } from 'vitest';
|
||||
import { GitRepoReader } from './git-repo';
|
||||
import Input from '../input';
|
||||
|
||||
describe(`git repo tests`, () => {
|
||||
it(`Branch value parsed from CLI to not contain illegal characters`, async () => {
|
||||
expect(await GitRepoReader.GetBranch()).not.toContain(`\n`);
|
||||
expect(await GitRepoReader.GetBranch()).not.toContain(` `);
|
||||
});
|
||||
|
||||
it(`returns valid branch name when using https`, async () => {
|
||||
const mockValue = 'https://github.com/example/example.git';
|
||||
vi.spyOn(GitRepoReader as any, 'runCommand').mockResolvedValue(mockValue);
|
||||
vi.spyOn(Input, 'getInput').mockReturnValue('not-local');
|
||||
expect(await GitRepoReader.GetRemote()).toEqual(`example/example`);
|
||||
});
|
||||
|
||||
it(`returns valid branch name when using ssh`, async () => {
|
||||
const mockValue = '[email protected]:example/example.git';
|
||||
vi.spyOn(GitRepoReader as any, 'runCommand').mockResolvedValue(mockValue);
|
||||
vi.spyOn(Input, 'getInput').mockReturnValue('not-local');
|
||||
expect(await GitRepoReader.GetRemote()).toEqual(`example/example`);
|
||||
});
|
||||
});
|
||||
@@ -1,46 +0,0 @@
|
||||
import { assert } from 'node:console';
|
||||
import fs from 'node:fs';
|
||||
import { exec } from 'node:child_process';
|
||||
import * as core from '@actions/core';
|
||||
import Input from '../input';
|
||||
|
||||
export class GitRepoReader {
|
||||
private static async runCommand(command: string): Promise<string> {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
exec(command, { maxBuffer: 1024 * 10000 }, (error, stdout) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
|
||||
return;
|
||||
}
|
||||
resolve(stdout.toString());
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public static async GetRemote() {
|
||||
if ((Input.getInput('providerStrategy') || 'local') === 'local') {
|
||||
return '';
|
||||
}
|
||||
assert(fs.existsSync(`.git`));
|
||||
const value = (
|
||||
await GitRepoReader.runCommand(`cd ${Input.projectPath} && git remote -v`)
|
||||
).replace(/ /g, ``);
|
||||
core.info(`value ${value}`);
|
||||
assert(value.includes('github.com'));
|
||||
|
||||
return value.split('github.com')[1].split('.git')[0].slice(1);
|
||||
}
|
||||
|
||||
public static async GetBranch() {
|
||||
if ((Input.getInput('providerStrategy') || 'local') === 'local') {
|
||||
return '';
|
||||
}
|
||||
assert(fs.existsSync(`.git`));
|
||||
|
||||
return (await GitRepoReader.runCommand(`cd ${Input.projectPath} && git branch --show-current`))
|
||||
.split('\n')[0]
|
||||
.replace(/ /g, ``)
|
||||
.replace('/head', '');
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach, beforeAll, afterAll, test } from 'vitest';
|
||||
import { GithubCliReader } from './github-cli';
|
||||
import * as core from '@actions/core';
|
||||
|
||||
describe(`github cli`, () => {
|
||||
// Todo - We can not assume that everyone has the GitHub cli installed locally.
|
||||
it.skip(`returns`, async () => {
|
||||
const token = await GithubCliReader.GetGitHubAuthToken();
|
||||
|
||||
// Todo - use expect(result).toStrictEqual(something)
|
||||
core.info(token);
|
||||
});
|
||||
});
|
||||
@@ -1,39 +0,0 @@
|
||||
import { exec } from 'node:child_process';
|
||||
import * as core from '@actions/core';
|
||||
import Input from '../input';
|
||||
|
||||
export class GithubCliReader {
|
||||
private static async runCommand(command: string, suppressError = false): Promise<string> {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
exec(command, { maxBuffer: 1024 * 10000 }, (error, stdout, stderr) => {
|
||||
if (error && !suppressError) {
|
||||
reject(error);
|
||||
|
||||
return;
|
||||
}
|
||||
resolve((stdout || '').toString() + (stderr || '').toString());
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
static async GetGitHubAuthToken() {
|
||||
if ((Input.getInput('providerStrategy') || 'local') === 'local') {
|
||||
return '';
|
||||
}
|
||||
try {
|
||||
const authStatus = await GithubCliReader.runCommand(`gh auth status`, true);
|
||||
if (authStatus.includes('You are not logged') || authStatus === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
return (await GithubCliReader.runCommand(`gh auth status -t`))
|
||||
.split(`Token: `)[1]
|
||||
.replace(/ /g, '')
|
||||
.replace(/\n/g, '');
|
||||
} catch (error: any) {
|
||||
core.info(error || 'Failed to get github auth token from gh cli');
|
||||
|
||||
return '';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import path from 'node:path';
|
||||
import fs from 'node:fs';
|
||||
import YAML from 'yaml';
|
||||
import Input from '../input';
|
||||
|
||||
export function ReadLicense(): string {
|
||||
if ((Input.getInput('providerStrategy') || 'local') === 'local') {
|
||||
return '';
|
||||
}
|
||||
const pipelineFile = path.join(
|
||||
__dirname,
|
||||
`.github`,
|
||||
`workflows`,
|
||||
`orchestrator-k8s-pipeline.yml`,
|
||||
);
|
||||
|
||||
return fs.existsSync(pipelineFile)
|
||||
? YAML.parse(fs.readFileSync(pipelineFile, 'utf8')).env.UNITY_LICENSE
|
||||
: '';
|
||||
}
|
||||
@@ -1,374 +0,0 @@
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, test, vi } from 'vitest';
|
||||
import * as core from '@actions/core';
|
||||
|
||||
import Input from './input';
|
||||
import Platform from './platform';
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('Input', () => {
|
||||
describe('unityVersion', () => {
|
||||
it('returns the default value', () => {
|
||||
expect(Input.unityVersion).toStrictEqual('auto');
|
||||
});
|
||||
|
||||
it('takes input from the users workflow', () => {
|
||||
const mockValue = '2020.4.99f9';
|
||||
const spy = vi.spyOn(core, 'getInput').mockReturnValue(mockValue);
|
||||
expect(Input.unityVersion).toStrictEqual(mockValue);
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
describe('customImage', () => {
|
||||
it('returns the default value', () => {
|
||||
expect(Input.customImage).toStrictEqual('');
|
||||
});
|
||||
|
||||
it('takes input from the users workflow', () => {
|
||||
const mockValue = '2020.4.99f9';
|
||||
const spy = vi.spyOn(core, 'getInput').mockReturnValue(mockValue);
|
||||
expect(Input.customImage).toStrictEqual(mockValue);
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('targetPlatform', () => {
|
||||
it('returns the default value', () => {
|
||||
expect(Input.targetPlatform).toStrictEqual(Platform.default);
|
||||
});
|
||||
|
||||
it('takes input from the users workflow', () => {
|
||||
const mockValue = 'Android';
|
||||
const spy = vi.spyOn(core, 'getInput').mockReturnValue(mockValue);
|
||||
expect(Input.targetPlatform).toStrictEqual(mockValue);
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('projectPath', () => {
|
||||
it('returns the default value', () => {
|
||||
expect(Input.projectPath).toStrictEqual('test-project');
|
||||
});
|
||||
|
||||
it('takes input from the users workflow', () => {
|
||||
const mockValue = 'customProjectPath';
|
||||
const spy = vi.spyOn(core, 'getInput').mockReturnValue(mockValue);
|
||||
expect(Input.projectPath).toStrictEqual(mockValue);
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildProfile', () => {
|
||||
it('returns the default value', () => {
|
||||
expect(Input.buildProfile).toStrictEqual('');
|
||||
});
|
||||
|
||||
it('takes input from the users workflow', () => {
|
||||
const mockValue = 'path/to/build_profile.asset';
|
||||
const spy = vi.spyOn(core, 'getInput').mockReturnValue(mockValue);
|
||||
expect(Input.buildProfile).toStrictEqual(mockValue);
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildName', () => {
|
||||
it('returns the default value', () => {
|
||||
expect(Input.buildName).toStrictEqual(Input.targetPlatform);
|
||||
});
|
||||
|
||||
it('takes input from the users workflow', () => {
|
||||
const mockValue = 'Build';
|
||||
const spy = vi.spyOn(core, 'getInput').mockReturnValue(mockValue);
|
||||
expect(Input.buildName).toStrictEqual(mockValue);
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('takes special characters as input', () => {
|
||||
const mockValue = '1ßúëld2';
|
||||
vi.spyOn(core, 'getInput').mockReturnValue(mockValue);
|
||||
expect(Input.buildName).toStrictEqual(mockValue);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildsPath', () => {
|
||||
it('returns the default value', () => {
|
||||
expect(Input.buildsPath).toStrictEqual('build');
|
||||
});
|
||||
|
||||
it('takes input from the users workflow', () => {
|
||||
const mockValue = 'customBuildsPath';
|
||||
const spy = vi.spyOn(core, 'getInput').mockReturnValue(mockValue);
|
||||
expect(Input.buildsPath).toStrictEqual(mockValue);
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildMethod', () => {
|
||||
it('returns the default value', () => {
|
||||
expect(Input.buildMethod).toStrictEqual('');
|
||||
});
|
||||
|
||||
it('takes input from the users workflow', () => {
|
||||
const mockValue = 'Namespace.ClassName.Method';
|
||||
const spy = vi.spyOn(core, 'getInput').mockReturnValue(mockValue);
|
||||
expect(Input.buildMethod).toStrictEqual(mockValue);
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('manualExit', () => {
|
||||
it('returns the default value', () => {
|
||||
expect(Input.manualExit).toStrictEqual(false);
|
||||
});
|
||||
|
||||
it('returns true when string true is passed', () => {
|
||||
const spy = vi.spyOn(core, 'getInput').mockReturnValue('true');
|
||||
expect(Input.manualExit).toStrictEqual(true);
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('returns false when string false is passed', () => {
|
||||
const spy = vi.spyOn(core, 'getInput').mockReturnValue('false');
|
||||
expect(Input.manualExit).toStrictEqual(false);
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('enableGpu', () => {
|
||||
it('returns the default value', () => {
|
||||
expect(Input.enableGpu).toStrictEqual(false);
|
||||
});
|
||||
|
||||
it('returns true when string true is passed', () => {
|
||||
const spy = vi.spyOn(core, 'getInput').mockReturnValue('true');
|
||||
expect(Input.enableGpu).toStrictEqual(true);
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('returns false when string false is passed', () => {
|
||||
const spy = vi.spyOn(core, 'getInput').mockReturnValue('false');
|
||||
expect(Input.enableGpu).toStrictEqual(false);
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('versioningStrategy', () => {
|
||||
it('returns the default value', () => {
|
||||
expect(Input.versioningStrategy).toStrictEqual('Semantic');
|
||||
});
|
||||
|
||||
it('takes input from the users workflow', () => {
|
||||
const mockValue = 'Anything';
|
||||
const spy = vi.spyOn(core, 'getInput').mockReturnValue(mockValue);
|
||||
expect(Input.versioningStrategy).toStrictEqual(mockValue);
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('specifiedVersion', () => {
|
||||
it('returns the default value', () => {
|
||||
expect(Input.specifiedVersion).toStrictEqual('');
|
||||
});
|
||||
|
||||
it('takes input from the users workflow', () => {
|
||||
const mockValue = '1.33.7';
|
||||
const spy = vi.spyOn(core, 'getInput').mockReturnValue(mockValue);
|
||||
expect(Input.specifiedVersion).toStrictEqual(mockValue);
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('androidVersionCode', () => {
|
||||
it('defaults to null', () => {
|
||||
expect(Input.androidVersionCode).toBeFalsy();
|
||||
});
|
||||
|
||||
it('takes input from the users workflow', () => {
|
||||
const mockValue = '42';
|
||||
const spy = vi.spyOn(core, 'getInput').mockReturnValue(mockValue);
|
||||
expect(Input.androidVersionCode).toStrictEqual(mockValue);
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('androidExportType', () => {
|
||||
it('returns the default value', () => {
|
||||
expect(Input.androidExportType).toStrictEqual('androidPackage');
|
||||
});
|
||||
|
||||
test.each`
|
||||
input | expected
|
||||
${'androidPackage'} | ${'androidPackage'}
|
||||
${'androidAppBundle'} | ${'androidAppBundle'}
|
||||
${'androidStudioProject'} | ${'androidStudioProject'}
|
||||
`('returns $expected when $input is passed', ({ input, expected }) => {
|
||||
const spy = vi.spyOn(core, 'getInput').mockReturnValue(input);
|
||||
expect(Input.androidExportType).toStrictEqual(expected);
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('androidSymbolType', () => {
|
||||
it('returns the default value', () => {
|
||||
expect(Input.androidSymbolType).toStrictEqual('none');
|
||||
});
|
||||
|
||||
test.each`
|
||||
input | expected
|
||||
${'none'} | ${'none'}
|
||||
${'public'} | ${'public'}
|
||||
${'debugging'} | ${'debugging'}
|
||||
`('returns $expected when $input is passed', ({ input, expected }) => {
|
||||
const spy = vi.spyOn(core, 'getInput').mockReturnValue(input);
|
||||
expect(Input.androidExportType).toStrictEqual(expected);
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('androidKeystoreName', () => {
|
||||
it('returns the default value', () => {
|
||||
expect(Input.androidKeystoreName).toStrictEqual('');
|
||||
});
|
||||
|
||||
it('takes input from the users workflow', () => {
|
||||
const mockValue = 'keystore.keystore';
|
||||
const spy = vi.spyOn(core, 'getInput').mockReturnValue(mockValue);
|
||||
expect(Input.androidKeystoreName).toStrictEqual(mockValue);
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('androidKeystoreBase64', () => {
|
||||
it('returns the default value', () => {
|
||||
expect(Input.androidKeystoreBase64).toStrictEqual('');
|
||||
});
|
||||
|
||||
it('takes input from the users workflow', () => {
|
||||
const mockValue = 'secret';
|
||||
const spy = vi.spyOn(core, 'getInput').mockReturnValue(mockValue);
|
||||
expect(Input.androidKeystoreBase64).toStrictEqual(mockValue);
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('androidKeystorePass', () => {
|
||||
it('returns the default value', () => {
|
||||
expect(Input.androidKeystorePass).toStrictEqual('');
|
||||
});
|
||||
|
||||
it('takes input from the users workflow', () => {
|
||||
const mockValue = 'secret';
|
||||
const spy = vi.spyOn(core, 'getInput').mockReturnValue(mockValue);
|
||||
expect(Input.androidKeystorePass).toStrictEqual(mockValue);
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('androidKeyaliasName', () => {
|
||||
it('returns the default value', () => {
|
||||
expect(Input.androidKeyaliasName).toStrictEqual('');
|
||||
});
|
||||
|
||||
it('takes input from the users workflow', () => {
|
||||
const mockValue = 'secret';
|
||||
const spy = vi.spyOn(core, 'getInput').mockReturnValue(mockValue);
|
||||
expect(Input.androidKeyaliasName).toStrictEqual(mockValue);
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('androidKeyaliasPass', () => {
|
||||
it('returns the default value', () => {
|
||||
expect(Input.androidKeyaliasPass).toStrictEqual('');
|
||||
});
|
||||
|
||||
it('takes input from the users workflow', () => {
|
||||
const mockValue = 'secret';
|
||||
const spy = vi.spyOn(core, 'getInput').mockReturnValue(mockValue);
|
||||
expect(Input.androidKeyaliasPass).toStrictEqual(mockValue);
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('androidTargetSdkVersion', () => {
|
||||
it('returns the default value', () => {
|
||||
expect(Input.androidTargetSdkVersion).toStrictEqual('');
|
||||
});
|
||||
|
||||
it('takes input from the users workflow', () => {
|
||||
const mockValue = 'secret';
|
||||
const spy = vi.spyOn(core, 'getInput').mockReturnValue(mockValue);
|
||||
expect(Input.androidTargetSdkVersion).toStrictEqual(mockValue);
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('allowDirtyBuild', () => {
|
||||
it('returns the default value', () => {
|
||||
expect(Input.allowDirtyBuild).toStrictEqual(false);
|
||||
});
|
||||
|
||||
it('returns true when string true is passed', () => {
|
||||
const spy = vi.spyOn(core, 'getInput').mockReturnValue('true');
|
||||
expect(Input.allowDirtyBuild).toStrictEqual(true);
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('returns false when string false is passed', () => {
|
||||
const spy = vi.spyOn(core, 'getInput').mockReturnValue('false');
|
||||
expect(Input.allowDirtyBuild).toStrictEqual(false);
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('customParameters', () => {
|
||||
it('returns the default value', () => {
|
||||
expect(Input.customParameters).toStrictEqual('');
|
||||
});
|
||||
|
||||
it('takes input from the users workflow', () => {
|
||||
const mockValue = '-imAFlag';
|
||||
const spy = vi.spyOn(core, 'getInput').mockReturnValue(mockValue);
|
||||
expect(Input.customParameters).toStrictEqual(mockValue);
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useHostNetwork', () => {
|
||||
it('returns the default value', () => {
|
||||
expect(Input.useHostNetwork).toStrictEqual(false);
|
||||
});
|
||||
|
||||
it('returns true when string true is passed', () => {
|
||||
const spy = vi.spyOn(core, 'getInput').mockReturnValue('true');
|
||||
expect(Input.useHostNetwork).toStrictEqual(true);
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('returns false when string false is passed', () => {
|
||||
const spy = vi.spyOn(core, 'getInput').mockReturnValue('false');
|
||||
expect(Input.useHostNetwork).toStrictEqual(false);
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('linux64RemoveExecutableExtension', () => {
|
||||
it('returns the default value', () => {
|
||||
expect(Input.linux64RemoveExecutableExtension).toStrictEqual(false);
|
||||
});
|
||||
|
||||
it('returns true when string true is passed', () => {
|
||||
const spy = vi.spyOn(core, 'getInput').mockReturnValue('true');
|
||||
expect(Input.linux64RemoveExecutableExtension).toStrictEqual(true);
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('returns false when string false is passed', () => {
|
||||
const spy = vi.spyOn(core, 'getInput').mockReturnValue('false');
|
||||
expect(Input.linux64RemoveExecutableExtension).toStrictEqual(false);
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,306 +0,0 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { PluginOptions } from './plugin-options';
|
||||
import Platform from './platform';
|
||||
import GitHub from './github';
|
||||
import os from 'node:os';
|
||||
|
||||
import * as core from '@actions/core';
|
||||
|
||||
export type InputKey = keyof typeof Input;
|
||||
|
||||
/**
|
||||
* Input variables specified in workflows using "with" prop.
|
||||
*
|
||||
* Note that input is always passed as a string, even booleans.
|
||||
*
|
||||
* Only core build inputs belong here. Orchestrator/plugin inputs are read
|
||||
* directly by the @game-ci/orchestrator plugin via core.getInput() / env vars.
|
||||
*/
|
||||
class Input {
|
||||
public static getInput(query: string): string | undefined {
|
||||
if (GitHub.githubInputEnabled) {
|
||||
const coreInput = core.getInput(query);
|
||||
if (coreInput && coreInput !== '') {
|
||||
return coreInput;
|
||||
}
|
||||
}
|
||||
const alternativeQuery = Input.ToEnvVarFormat(query);
|
||||
|
||||
// Query input sources
|
||||
if (PluginOptions.query(query, alternativeQuery)) {
|
||||
return PluginOptions.query(query, alternativeQuery);
|
||||
}
|
||||
|
||||
if (process.env[query] !== undefined) {
|
||||
return process.env[query]!;
|
||||
}
|
||||
|
||||
if (alternativeQuery !== query && process.env[alternativeQuery] !== undefined) {
|
||||
return process.env[alternativeQuery]!;
|
||||
}
|
||||
}
|
||||
|
||||
static get githubRepo(): string | undefined {
|
||||
return Input.getInput('GITHUB_REPOSITORY') ?? Input.getInput('GITHUB_REPO') ?? undefined;
|
||||
}
|
||||
|
||||
static get branch(): string {
|
||||
if (Input.getInput(`GITHUB_REF`)) {
|
||||
return Input.getInput(`GITHUB_REF`)!
|
||||
.replace('refs/', '')
|
||||
.replace(`head/`, '')
|
||||
.replace(`heads/`, '');
|
||||
} else if (Input.getInput('branch')) {
|
||||
return Input.getInput('branch')!;
|
||||
} else {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
static get gitSha(): string {
|
||||
if (Input.getInput(`GITHUB_SHA`)) {
|
||||
return Input.getInput(`GITHUB_SHA`)!;
|
||||
} else if (Input.getInput(`GitSHA`)) {
|
||||
return Input.getInput(`GitSHA`)!;
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
static get runNumber(): string {
|
||||
return Input.getInput('GITHUB_RUN_NUMBER') ?? '0';
|
||||
}
|
||||
|
||||
static get targetPlatform(): string {
|
||||
return Input.getInput('targetPlatform') ?? Platform.default;
|
||||
}
|
||||
|
||||
static get unityVersion(): string {
|
||||
return Input.getInput('unityVersion') ?? 'auto';
|
||||
}
|
||||
|
||||
static get customImage(): string {
|
||||
return Input.getInput('customImage') ?? '';
|
||||
}
|
||||
|
||||
static get projectPath(): string {
|
||||
const input = Input.getInput('projectPath');
|
||||
let rawProjectPath;
|
||||
|
||||
if (input) {
|
||||
rawProjectPath = input;
|
||||
} else if (
|
||||
fs.existsSync(path.join('test-project', 'ProjectSettings', 'ProjectVersion.txt')) &&
|
||||
!fs.existsSync(path.join('ProjectSettings', 'ProjectVersion.txt'))
|
||||
) {
|
||||
rawProjectPath = 'test-project';
|
||||
} else {
|
||||
rawProjectPath = '.';
|
||||
}
|
||||
|
||||
return rawProjectPath.replace(/\/$/, '');
|
||||
}
|
||||
|
||||
static get buildProfile(): string {
|
||||
return Input.getInput('buildProfile') ?? '';
|
||||
}
|
||||
|
||||
static get runnerTempPath(): string {
|
||||
return Input.getInput('RUNNER_TEMP') ?? '';
|
||||
}
|
||||
|
||||
static get buildName(): string {
|
||||
return Input.getInput('buildName') ?? Input.targetPlatform;
|
||||
}
|
||||
|
||||
static get buildsPath(): string {
|
||||
return Input.getInput('buildsPath') ?? 'build';
|
||||
}
|
||||
|
||||
static get unityLicensingServer(): string {
|
||||
return Input.getInput('unityLicensingServer') ?? '';
|
||||
}
|
||||
|
||||
static get buildMethod(): string {
|
||||
return Input.getInput('buildMethod') ?? ''; // Processed in docker file
|
||||
}
|
||||
|
||||
static get manualExit(): boolean {
|
||||
const input = Input.getInput('manualExit') ?? false;
|
||||
|
||||
return input === 'true';
|
||||
}
|
||||
|
||||
static get enableGpu(): boolean {
|
||||
const input = Input.getInput('enableGpu') ?? false;
|
||||
|
||||
return input === 'true';
|
||||
}
|
||||
|
||||
static get customParameters(): string {
|
||||
return Input.getInput('customParameters') ?? '';
|
||||
}
|
||||
|
||||
static get useHostNetwork(): boolean {
|
||||
const input = Input.getInput('useHostNetwork') ?? false;
|
||||
|
||||
return input === 'true';
|
||||
}
|
||||
|
||||
static get versioningStrategy(): string {
|
||||
return Input.getInput('versioning') ?? 'Semantic';
|
||||
}
|
||||
|
||||
static get specifiedVersion(): string {
|
||||
return Input.getInput('version') ?? '';
|
||||
}
|
||||
|
||||
static get androidVersionCode(): string {
|
||||
return Input.getInput('androidVersionCode') ?? '';
|
||||
}
|
||||
|
||||
static get androidExportType(): string {
|
||||
return Input.getInput('androidExportType') ?? 'androidPackage';
|
||||
}
|
||||
|
||||
static get androidKeystoreName(): string {
|
||||
return Input.getInput('androidKeystoreName') ?? '';
|
||||
}
|
||||
|
||||
static get androidKeystoreBase64(): string {
|
||||
return Input.getInput('androidKeystoreBase64') ?? '';
|
||||
}
|
||||
|
||||
static get androidKeystorePass(): string {
|
||||
return Input.getInput('androidKeystorePass') ?? '';
|
||||
}
|
||||
|
||||
static get androidKeyaliasName(): string {
|
||||
return Input.getInput('androidKeyaliasName') ?? '';
|
||||
}
|
||||
|
||||
static get androidKeyaliasPass(): string {
|
||||
return Input.getInput('androidKeyaliasPass') ?? '';
|
||||
}
|
||||
|
||||
static get androidTargetSdkVersion(): string {
|
||||
return Input.getInput('androidTargetSdkVersion') ?? '';
|
||||
}
|
||||
|
||||
static get androidSymbolType(): string {
|
||||
return Input.getInput('androidSymbolType') ?? 'none';
|
||||
}
|
||||
|
||||
static get sshAgent(): string {
|
||||
return Input.getInput('sshAgent') ?? '';
|
||||
}
|
||||
|
||||
static get sshPublicKeysDirectoryPath(): string {
|
||||
return Input.getInput('sshPublicKeysDirectoryPath') ?? '';
|
||||
}
|
||||
|
||||
static get gitPrivateToken(): string | undefined {
|
||||
return Input.getInput('gitPrivateToken');
|
||||
}
|
||||
|
||||
static get runAsHostUser(): string {
|
||||
return Input.getInput('runAsHostUser')?.toLowerCase() ?? 'false';
|
||||
}
|
||||
|
||||
static get chownFilesTo() {
|
||||
return Input.getInput('chownFilesTo') ?? '';
|
||||
}
|
||||
|
||||
static get allowDirtyBuild(): boolean {
|
||||
const input = Input.getInput('allowDirtyBuild') ?? false;
|
||||
|
||||
return input === 'true';
|
||||
}
|
||||
|
||||
static get cacheUnityInstallationOnMac(): boolean {
|
||||
const input = Input.getInput('cacheUnityInstallationOnMac') ?? false;
|
||||
|
||||
return input === 'true';
|
||||
}
|
||||
|
||||
static get unityHubVersionOnMac(): string {
|
||||
const input = Input.getInput('unityHubVersionOnMac') ?? '';
|
||||
|
||||
return input !== '' ? input : '';
|
||||
}
|
||||
|
||||
static get unitySerial(): string | undefined {
|
||||
return Input.getInput('UNITY_SERIAL');
|
||||
}
|
||||
|
||||
static get unityLicense(): string | undefined {
|
||||
return Input.getInput('UNITY_LICENSE');
|
||||
}
|
||||
|
||||
static get dockerWorkspacePath(): string {
|
||||
return Input.getInput('dockerWorkspacePath') ?? '/github/workspace';
|
||||
}
|
||||
|
||||
static get dockerCpuLimit(): string {
|
||||
return Input.getInput('dockerCpuLimit') ?? os.cpus().length.toString();
|
||||
}
|
||||
|
||||
static get dockerMemoryLimit(): string {
|
||||
const bytesInMegabyte = 1024 * 1024;
|
||||
|
||||
let memoryMultiplier;
|
||||
switch (os.platform()) {
|
||||
case 'linux':
|
||||
memoryMultiplier = 0.95;
|
||||
break;
|
||||
case 'win32':
|
||||
memoryMultiplier = 0.8;
|
||||
break;
|
||||
default:
|
||||
memoryMultiplier = 0.75;
|
||||
break;
|
||||
}
|
||||
|
||||
return (
|
||||
Input.getInput('dockerMemoryLimit') ??
|
||||
`${Math.floor((os.totalmem() / bytesInMegabyte) * memoryMultiplier)}m`
|
||||
);
|
||||
}
|
||||
|
||||
static get dockerIsolationMode(): string {
|
||||
return Input.getInput('dockerIsolationMode') ?? 'default';
|
||||
}
|
||||
|
||||
static get containerRegistryRepository(): string {
|
||||
return Input.getInput('containerRegistryRepository') ?? 'unityci/editor';
|
||||
}
|
||||
|
||||
static get containerRegistryImageVersion(): string {
|
||||
return Input.getInput('containerRegistryImageVersion') ?? '3';
|
||||
}
|
||||
|
||||
static get skipActivation(): string {
|
||||
return Input.getInput('skipActivation')?.toLowerCase() ?? 'false';
|
||||
}
|
||||
|
||||
static get linux64RemoveExecutableExtension(): boolean {
|
||||
const input = Input.getInput('linux64RemoveExecutableExtension') ?? 'false';
|
||||
|
||||
return input === 'true';
|
||||
}
|
||||
|
||||
public static ToEnvVarFormat(input: string) {
|
||||
if (input.toUpperCase() === input) {
|
||||
return input;
|
||||
}
|
||||
|
||||
return input
|
||||
.replace(/([A-Z])/g, ' $1')
|
||||
.trim()
|
||||
.toUpperCase()
|
||||
.replace(/ /g, '_');
|
||||
}
|
||||
}
|
||||
|
||||
export default Input;
|
||||
@@ -1,12 +0,0 @@
|
||||
import { exec } from '@actions/exec';
|
||||
|
||||
class MacBuilder {
|
||||
public static async run(actionFolder: string, silent: boolean = false): Promise<number> {
|
||||
return await exec('bash', [`${actionFolder}/platforms/mac/entrypoint.sh`], {
|
||||
silent,
|
||||
ignoreReturnCode: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default MacBuilder;
|
||||
@@ -1,17 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
/**
|
||||
* Compatibility tests for the legacy orchestrator-plugin module name.
|
||||
*
|
||||
* CI targets this file pattern directly, and consumers may still import this
|
||||
* module while migrating to the generic plugin API.
|
||||
*/
|
||||
|
||||
describe('orchestrator-plugin compatibility exports', () => {
|
||||
it('keeps loadOrchestratorPlugin as an alias for loadPlugin', async () => {
|
||||
const plugin = await import('./plugin');
|
||||
const compatibility = await import('./orchestrator-plugin');
|
||||
|
||||
expect(compatibility.loadOrchestratorPlugin).toBe(plugin.loadPlugin);
|
||||
});
|
||||
});
|
||||
@@ -1,2 +0,0 @@
|
||||
export { loadPlugin as loadOrchestratorPlugin } from './plugin';
|
||||
export type { Plugin as OrchestratorPlugin } from './plugin';
|
||||
@@ -1,18 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach, beforeAll, afterAll, test } from 'vitest';
|
||||
import Output from './output';
|
||||
|
||||
describe('Output', () => {
|
||||
describe('setBuildVersion', () => {
|
||||
it('does not throw', async () => {
|
||||
await expect(Output.setBuildVersion('1.0.0')).resolves.not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Output', () => {
|
||||
describe('setAndroidVersionCode', () => {
|
||||
it('does not throw', async () => {
|
||||
await expect(Output.setAndroidVersionCode('1000')).resolves.not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,17 +0,0 @@
|
||||
import * as core from '@actions/core';
|
||||
|
||||
class Output {
|
||||
static async setBuildVersion(buildVersion: string) {
|
||||
core.setOutput('buildVersion', buildVersion);
|
||||
}
|
||||
|
||||
static async setAndroidVersionCode(androidVersionCode: string) {
|
||||
core.setOutput('androidVersionCode', androidVersionCode);
|
||||
}
|
||||
|
||||
static async setEngineExitCode(exitCode: number) {
|
||||
core.setOutput('engineExitCode', exitCode);
|
||||
}
|
||||
}
|
||||
|
||||
export default Output;
|
||||
@@ -1,41 +0,0 @@
|
||||
import fs from 'node:fs';
|
||||
import * as core from '@actions/core';
|
||||
import { BuildParameters } from '.';
|
||||
import { SetupMac, SetupWindows, SetupAndroid } from './platform-setup/';
|
||||
import ValidateWindows from './platform-validation/validate-windows';
|
||||
|
||||
class PlatformSetup {
|
||||
static async setup(buildParameters: BuildParameters, actionFolder: string) {
|
||||
PlatformSetup.SetupShared(buildParameters, actionFolder);
|
||||
|
||||
switch (process.platform) {
|
||||
case 'win32':
|
||||
ValidateWindows.validate(buildParameters);
|
||||
SetupWindows.setup(buildParameters);
|
||||
break;
|
||||
case 'darwin':
|
||||
await SetupMac.setup(buildParameters, actionFolder);
|
||||
break;
|
||||
|
||||
// Add other baseOS's here
|
||||
}
|
||||
}
|
||||
|
||||
private static SetupShared(buildParameters: BuildParameters, actionFolder: string) {
|
||||
const servicesConfigPath = `${actionFolder}/unity-config/services-config.json`;
|
||||
const servicesConfigPathTemplate = `${servicesConfigPath}.template`;
|
||||
if (!fs.existsSync(servicesConfigPathTemplate)) {
|
||||
core.error(`Missing services config ${servicesConfigPathTemplate}`);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
let servicesConfig = fs.readFileSync(servicesConfigPathTemplate).toString();
|
||||
servicesConfig = servicesConfig.replace('%URL%', buildParameters.unityLicensingServer);
|
||||
fs.writeFileSync(servicesConfigPath, servicesConfig);
|
||||
|
||||
SetupAndroid.setup(buildParameters);
|
||||
}
|
||||
}
|
||||
|
||||
export default PlatformSetup;
|
||||
@@ -1,5 +0,0 @@
|
||||
import SetupWindows from './setup-windows';
|
||||
import SetupMac from './setup-mac';
|
||||
import SetupAndroid from './setup-android';
|
||||
|
||||
export { SetupWindows, SetupMac, SetupAndroid };
|
||||
@@ -1,34 +0,0 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { BuildParameters } from '..';
|
||||
|
||||
class SetupAndroid {
|
||||
public static async setup(buildParameters: BuildParameters) {
|
||||
const { targetPlatform, androidKeystoreBase64, androidKeystoreName, projectPath } =
|
||||
buildParameters;
|
||||
|
||||
if (
|
||||
targetPlatform === 'Android' &&
|
||||
androidKeystoreBase64 !== '' &&
|
||||
androidKeystoreName !== ''
|
||||
) {
|
||||
SetupAndroid.setupAndroidRun(androidKeystoreBase64, androidKeystoreName, projectPath);
|
||||
}
|
||||
}
|
||||
|
||||
private static setupAndroidRun(
|
||||
androidKeystoreBase64: string,
|
||||
androidKeystoreName: string,
|
||||
projectPath: string,
|
||||
) {
|
||||
const decodedKeystore = Buffer.from(androidKeystoreBase64, 'base64').toString('binary');
|
||||
const githubWorkspace = process.env.GITHUB_WORKSPACE || '';
|
||||
fs.writeFileSync(
|
||||
path.join(githubWorkspace, projectPath, androidKeystoreName),
|
||||
decodedKeystore,
|
||||
'binary',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default SetupAndroid;
|
||||
@@ -1,210 +0,0 @@
|
||||
import { BuildParameters } from '..';
|
||||
import { getUnityChangeset } from 'unity-changeset';
|
||||
import { exec, getExecOutput } from '@actions/exec';
|
||||
import { restoreCache, saveCache } from '@actions/cache';
|
||||
|
||||
import fs from 'node:fs';
|
||||
|
||||
class SetupMac {
|
||||
static unityHubBasePath = `/Applications/"Unity Hub.app"`;
|
||||
static unityHubExecPath = `${SetupMac.unityHubBasePath}/Contents/MacOS/"Unity Hub"`;
|
||||
|
||||
public static async setup(buildParameters: BuildParameters, actionFolder: string) {
|
||||
const unityEditorPath = `/Applications/Unity/Hub/Editor/${buildParameters.editorVersion}/Unity.app/Contents/MacOS/Unity`;
|
||||
|
||||
if (!fs.existsSync(this.unityHubExecPath.replace(/"/g, ''))) {
|
||||
await SetupMac.installUnityHub(buildParameters);
|
||||
}
|
||||
|
||||
if (!fs.existsSync(unityEditorPath.replace(/"/g, ''))) {
|
||||
await SetupMac.installUnity(buildParameters);
|
||||
}
|
||||
|
||||
await SetupMac.setEnvironmentVariables(buildParameters, actionFolder);
|
||||
}
|
||||
|
||||
private static async installUnityHub(buildParameters: BuildParameters, silent = false) {
|
||||
// Can't use quotes in the cache package so we need a different path
|
||||
const unityHubCachePath = `/Applications/Unity\\ Hub.app`;
|
||||
|
||||
const targetHubVersion =
|
||||
buildParameters.unityHubVersionOnMac !== ''
|
||||
? buildParameters.unityHubVersionOnMac
|
||||
: await SetupMac.getLatestUnityHubVersion();
|
||||
|
||||
const restoreKey = `Cache-MacOS-UnityHub@${targetHubVersion}`;
|
||||
|
||||
if (buildParameters.cacheUnityInstallationOnMac) {
|
||||
const cacheId = await restoreCache([unityHubCachePath], restoreKey);
|
||||
if (cacheId) {
|
||||
// Cache restored successfully, unity hub is installed now
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const commandSuffix =
|
||||
buildParameters.unityHubVersionOnMac !== '' ? `@${buildParameters.unityHubVersionOnMac}` : '';
|
||||
const command = `brew install unity-hub${commandSuffix}`;
|
||||
|
||||
// Ignoring return code because the log seems to overflow the internal buffer which triggers
|
||||
// a false error
|
||||
const errorCode = await exec(command, undefined, {
|
||||
silent,
|
||||
ignoreReturnCode: true,
|
||||
});
|
||||
if (errorCode) {
|
||||
throw new Error(
|
||||
`There was an error installing the Unity Editor. See logs above for details.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (buildParameters.cacheUnityInstallationOnMac) {
|
||||
await saveCache([unityHubCachePath], restoreKey);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the latest version of Unity Hub available on brew
|
||||
* @returns The latest version of Unity Hub available on brew
|
||||
*/
|
||||
private static async getLatestUnityHubVersion(): Promise<string> {
|
||||
// Need to check if the latest version available is the same as the one we have cached
|
||||
const hubVersionCommand = `/bin/bash -c "brew info unity-hub | grep -o '[0-9]\\+\\.[0-9]\\+\\.[0-9]\\+'"`;
|
||||
const result = await getExecOutput(hubVersionCommand, undefined, {
|
||||
silent: true,
|
||||
});
|
||||
if (result.exitCode === 0 && result.stdout !== '') {
|
||||
return result.stdout;
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
private static getArchitectureParameters(): string[] {
|
||||
const architectureArgument = [];
|
||||
|
||||
switch (process.arch) {
|
||||
case 'x64':
|
||||
architectureArgument.push('--architecture', 'x86_64');
|
||||
break;
|
||||
case 'arm64':
|
||||
architectureArgument.push('--architecture', 'arm64');
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unsupported architecture: ${process.arch}.`);
|
||||
}
|
||||
|
||||
return architectureArgument;
|
||||
}
|
||||
|
||||
private static getModuleParametersForTargetPlatform(targetPlatform: string): string[] {
|
||||
const moduleArgument = [];
|
||||
switch (targetPlatform) {
|
||||
case 'iOS':
|
||||
moduleArgument.push('--module', 'ios');
|
||||
break;
|
||||
case 'tvOS':
|
||||
moduleArgument.push('--module', 'appletv');
|
||||
break;
|
||||
case 'VisionOS':
|
||||
moduleArgument.push('--module', 'visionos');
|
||||
break;
|
||||
case 'StandaloneOSX':
|
||||
moduleArgument.push('--module', 'mac-il2cpp');
|
||||
break;
|
||||
case 'Android':
|
||||
moduleArgument.push('--module', 'android');
|
||||
break;
|
||||
case 'WebGL':
|
||||
moduleArgument.push('--module', 'webgl');
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unsupported module for target platform: ${targetPlatform}.`);
|
||||
}
|
||||
|
||||
return moduleArgument;
|
||||
}
|
||||
|
||||
private static async installUnity(buildParameters: BuildParameters, silent = false) {
|
||||
const unityEditorPath = `/Applications/Unity/Hub/Editor/${buildParameters.editorVersion}`;
|
||||
const key = `Cache-MacOS-UnityEditor-With-Module-${buildParameters.targetPlatform}@${buildParameters.editorVersion}`;
|
||||
|
||||
if (buildParameters.cacheUnityInstallationOnMac) {
|
||||
const cacheId = await restoreCache([unityEditorPath], key);
|
||||
if (cacheId) {
|
||||
// Cache restored successfully, unity editor is installed now
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const unityChangeset = await getUnityChangeset(buildParameters.editorVersion);
|
||||
const moduleArguments = SetupMac.getModuleParametersForTargetPlatform(
|
||||
buildParameters.targetPlatform,
|
||||
);
|
||||
const architectureArguments = SetupMac.getArchitectureParameters();
|
||||
|
||||
const execArguments: string[] = [
|
||||
'--',
|
||||
'--headless',
|
||||
'install',
|
||||
...['--version', buildParameters.editorVersion],
|
||||
...['--changeset', unityChangeset.changeset],
|
||||
...moduleArguments,
|
||||
...architectureArguments,
|
||||
'--childModules',
|
||||
];
|
||||
|
||||
// Ignoring return code because the log seems to overflow the internal buffer which triggers
|
||||
// a false error
|
||||
const errorCode = await exec(this.unityHubExecPath, execArguments, {
|
||||
silent,
|
||||
ignoreReturnCode: true,
|
||||
});
|
||||
if (errorCode) {
|
||||
throw new Error(
|
||||
`There was an error installing the Unity Editor. See logs above for details.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (buildParameters.cacheUnityInstallationOnMac) {
|
||||
await saveCache([unityEditorPath], key);
|
||||
}
|
||||
}
|
||||
|
||||
private static async setEnvironmentVariables(
|
||||
buildParameters: BuildParameters,
|
||||
actionFolder: string,
|
||||
) {
|
||||
// Need to set environment variables from here because we execute
|
||||
// the scripts on the host for mac
|
||||
process.env.ACTION_FOLDER = actionFolder;
|
||||
process.env.UNITY_VERSION = buildParameters.editorVersion;
|
||||
process.env.UNITY_SERIAL = buildParameters.unitySerial;
|
||||
process.env.UNITY_LICENSING_SERVER = buildParameters.unityLicensingServer;
|
||||
process.env.SKIP_ACTIVATION = buildParameters.skipActivation;
|
||||
process.env.PROJECT_PATH = buildParameters.projectPath;
|
||||
process.env.BUILD_PROFILE = buildParameters.buildProfile;
|
||||
process.env.BUILD_TARGET = buildParameters.targetPlatform;
|
||||
process.env.BUILD_NAME = buildParameters.buildName;
|
||||
process.env.BUILD_PATH = buildParameters.buildPath;
|
||||
process.env.BUILD_FILE = buildParameters.buildFile;
|
||||
process.env.BUILD_METHOD = buildParameters.buildMethod;
|
||||
process.env.VERSION = buildParameters.buildVersion;
|
||||
process.env.ANDROID_VERSION_CODE = buildParameters.androidVersionCode;
|
||||
process.env.ANDROID_KEYSTORE_NAME = buildParameters.androidKeystoreName;
|
||||
process.env.ANDROID_KEYSTORE_BASE64 = buildParameters.androidKeystoreBase64;
|
||||
process.env.ANDROID_KEYSTORE_PASS = buildParameters.androidKeystorePass;
|
||||
process.env.ANDROID_KEYALIAS_NAME = buildParameters.androidKeyaliasName;
|
||||
process.env.ANDROID_KEYALIAS_PASS = buildParameters.androidKeyaliasPass;
|
||||
process.env.ANDROID_TARGET_SDK_VERSION = buildParameters.androidTargetSdkVersion;
|
||||
process.env.ANDROID_SDK_MANAGER_PARAMETERS = buildParameters.androidSdkManagerParameters;
|
||||
process.env.ANDROID_EXPORT_TYPE = buildParameters.androidExportType;
|
||||
process.env.ANDROID_SYMBOL_TYPE = buildParameters.androidSymbolType;
|
||||
process.env.CUSTOM_PARAMETERS = buildParameters.customParameters;
|
||||
process.env.CHOWN_FILES_TO = buildParameters.chownFilesTo;
|
||||
process.env.MANUAL_EXIT = buildParameters.manualExit.toString();
|
||||
process.env.ENABLE_GPU = buildParameters.enableGpu.toString();
|
||||
}
|
||||
}
|
||||
|
||||
export default SetupMac;
|
||||
@@ -1,35 +0,0 @@
|
||||
import { exec } from '@actions/exec';
|
||||
import fs from 'node:fs';
|
||||
import { BuildParameters } from '..';
|
||||
|
||||
class SetupWindows {
|
||||
public static async setup(buildParameters: BuildParameters) {
|
||||
const { targetPlatform } = buildParameters;
|
||||
|
||||
await SetupWindows.setupWindowsRun(targetPlatform);
|
||||
}
|
||||
|
||||
private static async setupWindowsRun(targetPlatform: string, silent: boolean = false) {
|
||||
if (!fs.existsSync('c:/regkeys')) {
|
||||
fs.mkdirSync('c:/regkeys');
|
||||
}
|
||||
|
||||
// These all need the Windows 10 SDK
|
||||
switch (targetPlatform) {
|
||||
case 'StandaloneWindows':
|
||||
case 'StandaloneWindows64':
|
||||
case 'WSAPlayer':
|
||||
await this.generateWinSDKRegKeys(silent);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private static async generateWinSDKRegKeys(silent: boolean = false) {
|
||||
// Export registry keys that point to the Windows 10 SDK
|
||||
const exportWinSDKRegKeysCommand =
|
||||
'reg export "HKLM\\SOFTWARE\\WOW6432Node\\Microsoft\\Microsoft SDKs\\Windows\\v10.0" c:/regkeys/winsdk.reg /y';
|
||||
await exec(exportWinSDKRegKeysCommand, undefined, { silent });
|
||||
}
|
||||
}
|
||||
|
||||
export default SetupWindows;
|
||||
@@ -1,58 +0,0 @@
|
||||
import fs from 'node:fs';
|
||||
import { BuildParameters } from '..';
|
||||
|
||||
class ValidateWindows {
|
||||
public static validate(buildParameters: BuildParameters) {
|
||||
ValidateWindows.validateWindowsPlatformRequirements(buildParameters.targetPlatform);
|
||||
|
||||
const { unityLicensingServer } = buildParameters;
|
||||
const hasLicensingCredentials = process.env.UNITY_EMAIL && process.env.UNITY_PASSWORD;
|
||||
const hasValidLicensingStrategy = hasLicensingCredentials || unityLicensingServer;
|
||||
|
||||
if (!hasValidLicensingStrategy) {
|
||||
throw new Error(`Unity email and password or alternatively a Unity licensing server url must be set for
|
||||
Windows based builds to authenticate the license. Make sure to set them inside UNITY_EMAIL
|
||||
and UNITY_PASSWORD in Github Secrets and pass them into the environment.`);
|
||||
}
|
||||
}
|
||||
|
||||
private static validateWindowsPlatformRequirements(platform: string) {
|
||||
switch (platform) {
|
||||
case 'StandaloneWindows':
|
||||
case 'StandaloneWindows64':
|
||||
case 'WSAPlayer':
|
||||
this.checkForVisualStudio();
|
||||
this.checkForWin10SDK();
|
||||
break;
|
||||
case 'tvOS':
|
||||
this.checkForVisualStudio();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private static checkForWin10SDK() {
|
||||
// Check for Windows 10 SDK on runner
|
||||
const windows10SDKPathExists = fs.existsSync('C:/Program Files (x86)/Windows Kits');
|
||||
if (!windows10SDKPathExists) {
|
||||
throw new Error(`Windows 10 SDK not found in default location. Make sure
|
||||
the runner has a Windows 10 SDK installed in the default
|
||||
location.`);
|
||||
}
|
||||
}
|
||||
|
||||
private static checkForVisualStudio() {
|
||||
// Note: When upgrading to Server 2022, we will need to move to just "program files" since VS will be 64-bit
|
||||
const visualStudioInstallPathExists = fs.existsSync(
|
||||
'C:/Program Files (x86)/Microsoft Visual Studio',
|
||||
);
|
||||
const visualStudioDataPathExists = fs.existsSync('C:/ProgramData/Microsoft/VisualStudio');
|
||||
|
||||
if (!visualStudioInstallPathExists || !visualStudioDataPathExists) {
|
||||
throw new Error(`Visual Studio not found at the default location.
|
||||
Make sure the runner has Visual Studio installed in the
|
||||
default location`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default ValidateWindows;
|
||||
@@ -1,38 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach, beforeAll, afterAll, test } from 'vitest';
|
||||
import Platform from './platform';
|
||||
|
||||
describe('Platform', () => {
|
||||
describe('default', () => {
|
||||
it('does not throw', () => {
|
||||
expect(() => Platform.default).not.toThrow();
|
||||
});
|
||||
|
||||
it('returns a string', () => {
|
||||
expect(typeof Platform.default).toStrictEqual('string');
|
||||
});
|
||||
|
||||
it('returns a platform', () => {
|
||||
expect(Object.values(Platform.types)).toContain(Platform.default);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isWindows', () => {
|
||||
it('returns true for windows', () => {
|
||||
expect(Platform.isWindows(Platform.types.StandaloneWindows64)).toStrictEqual(true);
|
||||
});
|
||||
|
||||
it('returns false for MacOS', () => {
|
||||
expect(Platform.isWindows(Platform.types.StandaloneOSX)).toStrictEqual(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isAndroid', () => {
|
||||
it('returns true for Android', () => {
|
||||
expect(Platform.isAndroid(Platform.types.Android)).toStrictEqual(true);
|
||||
});
|
||||
|
||||
it('returns false for Windows', () => {
|
||||
expect(Platform.isAndroid(Platform.types.StandaloneWindows64)).toStrictEqual(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,54 +0,0 @@
|
||||
class Platform {
|
||||
static get default() {
|
||||
return Platform.types.StandaloneWindows64;
|
||||
}
|
||||
|
||||
static get types() {
|
||||
return {
|
||||
StandaloneOSX: 'StandaloneOSX',
|
||||
StandaloneWindows: 'StandaloneWindows',
|
||||
StandaloneWindows64: 'StandaloneWindows64',
|
||||
StandaloneLinux64: 'StandaloneLinux64',
|
||||
iOS: 'iOS',
|
||||
Android: 'Android',
|
||||
WebGL: 'WebGL',
|
||||
WSAPlayer: 'WSAPlayer',
|
||||
PS4: 'PS4',
|
||||
XboxOne: 'XboxOne',
|
||||
tvOS: 'tvOS',
|
||||
VisionOS: 'VisionOS',
|
||||
Switch: 'Switch',
|
||||
|
||||
// Unsupported
|
||||
Lumin: 'Lumin',
|
||||
BJM: 'BJM',
|
||||
Stadia: 'Stadia',
|
||||
Facebook: 'Facebook',
|
||||
NoTarget: 'NoTarget',
|
||||
|
||||
// Test specific
|
||||
Test: 'Test',
|
||||
};
|
||||
}
|
||||
|
||||
static isWindows(platform: string) {
|
||||
switch (platform) {
|
||||
case Platform.types.StandaloneWindows:
|
||||
case Platform.types.StandaloneWindows64:
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
static isAndroid(platform: string) {
|
||||
switch (platform) {
|
||||
case Platform.types.Android:
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default Platform;
|
||||
@@ -1,32 +0,0 @@
|
||||
/**
|
||||
* Shared options bridge between unity-builder and plugins (e.g. @game-ci/orchestrator).
|
||||
*
|
||||
* Plugins set PluginOptions.options to pass configuration into BuildParameters
|
||||
* and Input. When options are set, isPluginMode is true and query() reads
|
||||
* from the options map instead of @actions/core.getInput().
|
||||
*/
|
||||
export class PluginOptions {
|
||||
public static options: Record<string, any> | undefined;
|
||||
|
||||
static get isPluginMode() {
|
||||
return Boolean(PluginOptions.options?.mode);
|
||||
}
|
||||
|
||||
public static query(key: string, alternativeKey: string) {
|
||||
if (PluginOptions.options && PluginOptions.options[key] !== undefined) {
|
||||
return PluginOptions.options[key];
|
||||
}
|
||||
if (
|
||||
PluginOptions.options &&
|
||||
alternativeKey &&
|
||||
PluginOptions.options[alternativeKey] !== undefined
|
||||
) {
|
||||
return PluginOptions.options[alternativeKey];
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Backwards-compatible alias — the orchestrator still imports { Cli }
|
||||
export { PluginOptions as Cli };
|
||||
@@ -1,116 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
/**
|
||||
* Tests for the generic plugin loader (plugin.ts).
|
||||
*
|
||||
* The default plugin implementation is currently @game-ci/orchestrator, but
|
||||
* unity-builder depends on the generic Plugin lifecycle rather than an
|
||||
* orchestrator-specific type.
|
||||
*/
|
||||
|
||||
const mockWarning = vi.fn();
|
||||
const mockInfo = vi.fn();
|
||||
vi.mock('@actions/core', () => ({
|
||||
warning: mockWarning,
|
||||
info: mockInfo,
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
mockWarning.mockClear();
|
||||
mockInfo.mockClear();
|
||||
});
|
||||
|
||||
describe('plugin (default package not installed)', () => {
|
||||
it('loadPlugin() returns undefined', async () => {
|
||||
const { loadPlugin } = await import('./plugin');
|
||||
|
||||
const result = await loadPlugin();
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('plugin (default package installed)', () => {
|
||||
const fakePlugin = {
|
||||
initialize: vi.fn(),
|
||||
canHandleBuild: vi.fn().mockReturnValue(false),
|
||||
handleBuild: vi.fn().mockResolvedValue({ exitCode: 0 }),
|
||||
beforeLocalBuild: vi.fn(),
|
||||
afterLocalBuild: vi.fn(),
|
||||
handlePostBuild: vi.fn(),
|
||||
};
|
||||
|
||||
const mockCreatePlugin = vi.fn().mockReturnValue(fakePlugin);
|
||||
|
||||
function installDefaultPluginMock(overrides: Record<string, unknown> = {}) {
|
||||
// The `@game-ci/orchestrator` module is intentionally optional and may not
|
||||
// be installed. `vi.doMock` lets the dynamic import in the loader resolve
|
||||
// through this factory before vite tries to load a real package.
|
||||
vi.doMock('@game-ci/orchestrator', () => ({
|
||||
createPlugin: mockCreatePlugin,
|
||||
...overrides,
|
||||
}));
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockCreatePlugin.mockClear();
|
||||
fakePlugin.initialize.mockClear();
|
||||
fakePlugin.canHandleBuild.mockClear();
|
||||
fakePlugin.handleBuild.mockClear();
|
||||
fakePlugin.beforeLocalBuild.mockClear();
|
||||
fakePlugin.afterLocalBuild.mockClear();
|
||||
fakePlugin.handlePostBuild.mockClear();
|
||||
});
|
||||
|
||||
it('returns the plugin from createPlugin()', async () => {
|
||||
installDefaultPluginMock();
|
||||
const { loadPlugin } = await import('./plugin');
|
||||
|
||||
const plugin = await loadPlugin();
|
||||
|
||||
expect(plugin).toBeDefined();
|
||||
expect(mockCreatePlugin).toHaveBeenCalledTimes(1);
|
||||
expect(plugin).toBe(fakePlugin);
|
||||
});
|
||||
|
||||
it('returns a plugin with all lifecycle methods', async () => {
|
||||
installDefaultPluginMock();
|
||||
const { loadPlugin } = await import('./plugin');
|
||||
|
||||
const plugin = await loadPlugin();
|
||||
|
||||
expect(typeof plugin!.initialize).toBe('function');
|
||||
expect(typeof plugin!.canHandleBuild).toBe('function');
|
||||
expect(typeof plugin!.handleBuild).toBe('function');
|
||||
expect(typeof plugin!.beforeLocalBuild).toBe('function');
|
||||
expect(typeof plugin!.afterLocalBuild).toBe('function');
|
||||
expect(typeof plugin!.handlePostBuild).toBe('function');
|
||||
});
|
||||
|
||||
it('returns undefined and warns when createPlugin is not a function', async () => {
|
||||
installDefaultPluginMock({ createPlugin: undefined });
|
||||
const { loadPlugin } = await import('./plugin');
|
||||
|
||||
const plugin = await loadPlugin();
|
||||
|
||||
expect(plugin).toBeUndefined();
|
||||
expect(mockWarning).toHaveBeenCalledWith(
|
||||
expect.stringContaining('does not export createPlugin'),
|
||||
);
|
||||
});
|
||||
|
||||
it('propagates non-MODULE_NOT_FOUND errors', async () => {
|
||||
// Throw lazily from `createPlugin` rather than from the mock factory
|
||||
// itself: vitest 4 wraps factory-time errors with its own message, which
|
||||
// masks the inner error at the assertion site.
|
||||
installDefaultPluginMock({
|
||||
createPlugin: () => {
|
||||
throw new Error('Syntax error in module');
|
||||
},
|
||||
});
|
||||
const { loadPlugin } = await import('./plugin');
|
||||
|
||||
await expect(loadPlugin()).rejects.toThrow('Syntax error in module');
|
||||
});
|
||||
});
|
||||
@@ -1,76 +0,0 @@
|
||||
import * as core from '@actions/core';
|
||||
|
||||
const DEFAULT_PLUGIN_MODULE = '@game-ci/orchestrator';
|
||||
|
||||
/**
|
||||
* Generic lifecycle contract for optional unity-builder plugins.
|
||||
*
|
||||
* Plugins read their own configuration from environment variables and GitHub
|
||||
* Actions inputs. Unity-builder only calls lifecycle hooks at the points where
|
||||
* an external implementation can extend or replace the local build flow.
|
||||
*/
|
||||
export interface Plugin {
|
||||
initialize(coreParameters: Record<string, any>, workspace: string): Promise<void>;
|
||||
|
||||
/** Whether the plugin wants to handle the entire build. */
|
||||
canHandleBuild(): boolean;
|
||||
|
||||
/**
|
||||
* Execute the build when canHandleBuild() returns true.
|
||||
* If the plugin needs to fall back to a local build, it returns
|
||||
* { exitCode: -1, fallbackToLocal: true }.
|
||||
*/
|
||||
handleBuild(baseImage: string): Promise<{ exitCode: number; fallbackToLocal?: boolean }>;
|
||||
|
||||
/** Pre-build hook for local builds. */
|
||||
beforeLocalBuild(workspace: string): Promise<void>;
|
||||
|
||||
/** Post-build hook for local builds. */
|
||||
afterLocalBuild(workspace: string, exitCode: number): Promise<void>;
|
||||
|
||||
/** Post-build hook for all build types. */
|
||||
handlePostBuild(exitCode: number): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to load the default optional plugin.
|
||||
*
|
||||
* Today the default implementation is @game-ci/orchestrator. The loader is
|
||||
* intentionally named after the generic plugin contract so additional plugin
|
||||
* implementations can be added without making orchestrator part of the core
|
||||
* abstraction.
|
||||
*/
|
||||
export async function loadPlugin(moduleName = DEFAULT_PLUGIN_MODULE): Promise<Plugin | undefined> {
|
||||
try {
|
||||
const pluginModule = await import(/* webpackIgnore: true */ moduleName);
|
||||
|
||||
if (typeof pluginModule.createPlugin !== 'function') {
|
||||
core.warning(
|
||||
`Plugin package "${moduleName}" found but does not export createPlugin(). ` +
|
||||
'Update the plugin package to the latest version.',
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
return pluginModule.createPlugin();
|
||||
} catch (error) {
|
||||
if (!isModuleNotFoundError(error)) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isModuleNotFoundError(error: unknown): boolean {
|
||||
if (error && typeof error === 'object' && 'code' in error) {
|
||||
const code = (error as { code: string }).code;
|
||||
if (code === 'MODULE_NOT_FOUND' || code === 'ERR_MODULE_NOT_FOUND') {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
typeof (error as Error)?.message === 'string' &&
|
||||
/cannot find module/i.test((error as Error).message)
|
||||
);
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, beforeAll, afterAll, vi } from 'vitest';
|
||||
import Project from './project';
|
||||
|
||||
vi.mock('./input');
|
||||
|
||||
describe('Platform', () => {
|
||||
describe('relativePath', () => {
|
||||
it('does not throw', () => {
|
||||
expect(() => Project.relativePath).not.toThrow();
|
||||
});
|
||||
|
||||
it('returns a string', () => {
|
||||
expect(typeof Project.relativePath).toStrictEqual('string');
|
||||
});
|
||||
});
|
||||
|
||||
describe('absolutePath', () => {
|
||||
it('does not throw', () => {
|
||||
expect(() => Project.absolutePath).not.toThrow();
|
||||
});
|
||||
|
||||
it('returns a string', () => {
|
||||
expect(typeof Project.absolutePath).toStrictEqual('string');
|
||||
});
|
||||
});
|
||||
|
||||
describe('libraryFolder', () => {
|
||||
it('does not throw', () => {
|
||||
expect(() => Project.libraryFolder).not.toThrow();
|
||||
});
|
||||
|
||||
it('returns a string', () => {
|
||||
expect(typeof Project.libraryFolder).toStrictEqual('string');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,23 +0,0 @@
|
||||
import Input from './input';
|
||||
import Unity from './unity';
|
||||
import Action from './action';
|
||||
|
||||
class Project {
|
||||
static get relativePath() {
|
||||
const { projectPath } = Input;
|
||||
|
||||
return `${projectPath}`;
|
||||
}
|
||||
|
||||
static get absolutePath() {
|
||||
const { workspace } = Action;
|
||||
|
||||
return `${workspace}/${this.relativePath}`;
|
||||
}
|
||||
|
||||
static get libraryFolder() {
|
||||
return `${this.relativePath}/${Unity.libraryFolder}`;
|
||||
}
|
||||
}
|
||||
|
||||
export default Project;
|
||||
@@ -1,6 +0,0 @@
|
||||
export class StringKeyValuePair {
|
||||
public name!: string;
|
||||
public value!: string;
|
||||
}
|
||||
|
||||
export type DockerParameters = { [key: string]: any };
|
||||
@@ -1,47 +0,0 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, beforeAll, afterAll, vi } from 'vitest';
|
||||
import * as core from '@actions/core';
|
||||
import System from './system';
|
||||
|
||||
vi.spyOn(core, 'debug').mockImplementation(() => {});
|
||||
vi.spyOn(core, 'info').mockImplementation(() => {});
|
||||
vi.spyOn(core, 'warning').mockImplementation(() => {});
|
||||
vi.spyOn(core, 'error').mockImplementation(() => {});
|
||||
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
|
||||
describe('System', () => {
|
||||
describe('run', () => {
|
||||
/**
|
||||
* Not all shells (e.g. Powershell, sh) have a reference to `echo` binary (absent or alias).
|
||||
* To ensure our integration with '@actions/exec' works as expected we run some specific tests in CI only
|
||||
*/
|
||||
describe('integration', () => {
|
||||
if (!process.env.CI) {
|
||||
it("doesn't run locally", () => {
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
} else {
|
||||
it('runs a command successfully', async () => {
|
||||
await expect(System.run('true')).resolves.not.toBeNull();
|
||||
});
|
||||
|
||||
it('outputs results', async () => {
|
||||
await expect(System.run('echo test')).resolves.toStrictEqual('test\n');
|
||||
});
|
||||
|
||||
it('throws on when error code is not 0', async () => {
|
||||
await expect(System.run('false')).rejects.toThrowError();
|
||||
});
|
||||
|
||||
it('allows pipes using buffer', async () => {
|
||||
await expect(
|
||||
System.run('sh', undefined, {
|
||||
input: Buffer.from('git tag --list --merged HEAD | grep v[0-9]* | wc -l'),
|
||||
// eslint-disable-next-line github/no-then
|
||||
}).then((result) => Number(result)),
|
||||
).resolves.not.toBeNaN();
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,52 +0,0 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, beforeAll, afterAll, vi } from 'vitest';
|
||||
import * as core from '@actions/core';
|
||||
import * as exec from '@actions/exec';
|
||||
import System from './system';
|
||||
|
||||
vi.spyOn(core, 'debug').mockImplementation(() => {});
|
||||
const info = vi.spyOn(core, 'info').mockImplementation(() => {});
|
||||
vi.spyOn(core, 'warning').mockImplementation(() => {});
|
||||
vi.spyOn(core, 'error').mockImplementation(() => {});
|
||||
const execSpy = vi.spyOn(exec, 'exec').mockImplementation(async () => 0);
|
||||
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
|
||||
describe('System', () => {
|
||||
describe('run', () => {
|
||||
describe('units', () => {
|
||||
it('passes the command to command line', async () => {
|
||||
await expect(System.run('echo test')).resolves.not.toBeNull();
|
||||
await expect(execSpy).toHaveBeenLastCalledWith(
|
||||
'echo test',
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('throws on when error code is not 0', async () => {
|
||||
execSpy.mockImplementationOnce(async () => 1);
|
||||
await expect(System.run('false')).rejects.toThrowError();
|
||||
});
|
||||
|
||||
it('throws when no command is given', async () => {
|
||||
await expect(System.run('')).rejects.toThrowError();
|
||||
});
|
||||
|
||||
it('throws when command consists only of spaces', async () => {
|
||||
await expect(System.run(' \t\n')).rejects.toThrowError();
|
||||
});
|
||||
|
||||
it('outputs info', async () => {
|
||||
execSpy.mockImplementationOnce(async (input, _, options) => {
|
||||
options?.listeners?.stdout?.(Buffer.from(input, 'utf8'));
|
||||
|
||||
return 0;
|
||||
});
|
||||
|
||||
await expect(System.run('foo-bar')).resolves.not.toBeNull();
|
||||
expect(info).toHaveBeenCalledTimes(1);
|
||||
expect(info).toHaveBeenLastCalledWith('foo-bar');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,66 +0,0 @@
|
||||
import * as core from '@actions/core';
|
||||
import { exec, ExecListeners } from '@actions/exec';
|
||||
|
||||
class System {
|
||||
static async run(command: string, arguments_: string[] = [], options = {}, shouldLog = true) {
|
||||
let result = '';
|
||||
let error = '';
|
||||
let debug = '';
|
||||
|
||||
const listeners: ExecListeners = {
|
||||
stdout: (dataBuffer: Buffer) => {
|
||||
result += dataBuffer.toString();
|
||||
},
|
||||
stderr: (dataBuffer: Buffer) => {
|
||||
error += dataBuffer.toString();
|
||||
},
|
||||
debug: (dataString: string) => {
|
||||
debug += dataString;
|
||||
},
|
||||
};
|
||||
|
||||
const showOutput = () => {
|
||||
if (debug !== '' && shouldLog) {
|
||||
core.debug(debug);
|
||||
}
|
||||
|
||||
if (result !== '' && shouldLog) {
|
||||
core.info(result);
|
||||
}
|
||||
|
||||
if (error !== '' && shouldLog) {
|
||||
core.warning(error);
|
||||
}
|
||||
};
|
||||
|
||||
const throwContextualError = (message: string) => {
|
||||
let commandAsString = command;
|
||||
if (Array.isArray(arguments_)) {
|
||||
commandAsString += ` ${arguments_.join(' ')}`;
|
||||
} else if (typeof arguments_ === 'string') {
|
||||
commandAsString += ` ${arguments_}`;
|
||||
}
|
||||
|
||||
throw new Error(`Failed to run "${commandAsString}".\n ${message}`);
|
||||
};
|
||||
|
||||
try {
|
||||
if (command.trim() === '') {
|
||||
throw new Error(`Failed to execute empty command`);
|
||||
}
|
||||
|
||||
const exitCode = await exec(command, arguments_, { silent: true, listeners, ...options });
|
||||
showOutput();
|
||||
if (exitCode !== 0) {
|
||||
throwContextualError(`Command returned non-zero exit code.\nError: ${error}`);
|
||||
}
|
||||
} catch (inCommandError) {
|
||||
showOutput();
|
||||
throwContextualError(`In-command error caught: ${inCommandError}`);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
export default System;
|
||||
@@ -1,42 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach, beforeAll, afterAll, test } from 'vitest';
|
||||
import UnityVersioning from './unity-versioning';
|
||||
|
||||
describe('Unity Versioning', () => {
|
||||
describe('parse', () => {
|
||||
it('throws for empty string', () => {
|
||||
expect(() => UnityVersioning.parse('')).toThrow(Error);
|
||||
});
|
||||
|
||||
it('parses from ProjectVersion.txt', () => {
|
||||
const projectVersionContents = `m_EditorVersion: 2021.3.45f1
|
||||
m_EditorVersionWithRevision: 2021.3.45f1 (cb45f9cae8b7)`;
|
||||
expect(UnityVersioning.parse(projectVersionContents)).toBe('2021.3.45f1');
|
||||
});
|
||||
|
||||
it('parses Unity 6000 and newer from ProjectVersion.txt', () => {
|
||||
const projectVersionContents = `m_EditorVersion: 6000.0.0f1
|
||||
m_EditorVersionWithRevision: 6000.0.0f1 (cb45f9cae8b7)`;
|
||||
expect(UnityVersioning.parse(projectVersionContents)).toBe('6000.0.0f1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('read', () => {
|
||||
it('throws for invalid path', () => {
|
||||
expect(() => UnityVersioning.read('')).toThrow(Error);
|
||||
});
|
||||
|
||||
it('reads from test-project', () => {
|
||||
expect(UnityVersioning.read('./test-project')).toBe('2021.3.45f1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('determineUnityVersion', () => {
|
||||
it('defaults to parsed version', () => {
|
||||
expect(UnityVersioning.determineUnityVersion('./test-project', 'auto')).toBe('2021.3.45f1');
|
||||
});
|
||||
|
||||
it('use specified unityVersion', () => {
|
||||
expect(UnityVersioning.determineUnityVersion('./test-project', '1.2.3')).toBe('1.2.3');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,34 +0,0 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
export default class UnityVersioning {
|
||||
static determineUnityVersion(projectPath: string, unityVersion: string) {
|
||||
if (unityVersion === 'auto') {
|
||||
return UnityVersioning.read(projectPath);
|
||||
}
|
||||
|
||||
return unityVersion;
|
||||
}
|
||||
|
||||
static read(projectPath: string) {
|
||||
const filePath = path.join(projectPath, 'ProjectSettings', 'ProjectVersion.txt');
|
||||
if (!fs.existsSync(filePath)) {
|
||||
throw new Error(
|
||||
`Project settings file not found at "${filePath}". Have you correctly set the projectPath?`,
|
||||
);
|
||||
}
|
||||
|
||||
return UnityVersioning.parse(fs.readFileSync(filePath, 'utf8'));
|
||||
}
|
||||
|
||||
static parse(projectVersionTxt: string) {
|
||||
const versionRegex = /m_EditorVersion: (\d+\.\d+\.\d+[A-Za-z]?\d+)/;
|
||||
const matches = projectVersionTxt.match(versionRegex);
|
||||
|
||||
if (!matches || matches.length < 2) {
|
||||
throw new Error(`Failed to extract version from "${projectVersionTxt}".`);
|
||||
}
|
||||
|
||||
return matches[1];
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import Unity from './unity';
|
||||
|
||||
describe('Unity', () => {
|
||||
describe('libraryFolder', () => {
|
||||
it('does not throw', () => {
|
||||
expect(() => Unity.libraryFolder).not.toThrow();
|
||||
});
|
||||
|
||||
it('returns a string', () => {
|
||||
expect(typeof Unity.libraryFolder).toStrictEqual('string');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,7 +0,0 @@
|
||||
class Unity {
|
||||
static get libraryFolder() {
|
||||
return 'Library';
|
||||
}
|
||||
}
|
||||
|
||||
export default Unity;
|
||||
@@ -1,378 +0,0 @@
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, test, vi } from 'vitest';
|
||||
import * as core from '@actions/core';
|
||||
import NotImplementedException from './error/not-implemented-exception';
|
||||
import System from './system';
|
||||
import Versioning from './versioning';
|
||||
import { validVersionTagInputs, invalidVersionTagInputs } from './__data__/versions';
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('Versioning', () => {
|
||||
describe('strategies', () => {
|
||||
it('returns an object', () => {
|
||||
expect(typeof Versioning.strategies).toStrictEqual('object');
|
||||
});
|
||||
|
||||
it('has items', () => {
|
||||
expect(Object.values(Versioning.strategies).length).toBeGreaterThan(2);
|
||||
});
|
||||
|
||||
it('has an opt out option', () => {
|
||||
expect(Versioning.strategies).toHaveProperty('None');
|
||||
});
|
||||
|
||||
it('has the semantic option', () => {
|
||||
expect(Versioning.strategies).toHaveProperty('Semantic');
|
||||
});
|
||||
|
||||
it('has a strategy for tags', () => {
|
||||
expect(Versioning.strategies).toHaveProperty('Tag');
|
||||
});
|
||||
|
||||
it('has an option that allows custom input', () => {
|
||||
expect(Versioning.strategies).toHaveProperty('Custom');
|
||||
});
|
||||
});
|
||||
|
||||
describe('grepCompatibleInputVersionRegex', () => {
|
||||
// eslint-disable-next-line unicorn/consistent-function-scoping
|
||||
const matchInputUsingGrep = async (input: string) => {
|
||||
const output = await System.run('sh', undefined, {
|
||||
input: Buffer.from(
|
||||
`echo '${input}' | grep -E '${Versioning.grepCompatibleInputVersionRegex}'`,
|
||||
),
|
||||
silent: true,
|
||||
});
|
||||
|
||||
return output.trim();
|
||||
};
|
||||
|
||||
it.concurrent.each(validVersionTagInputs)(`accepts valid tag input '%s'`, async (input) => {
|
||||
expect(await matchInputUsingGrep(input)).toStrictEqual(input);
|
||||
});
|
||||
|
||||
it.concurrent.each(invalidVersionTagInputs)(
|
||||
`rejects non-version tag input '%s'`,
|
||||
async (input) => {
|
||||
await expect(async () => matchInputUsingGrep(input)).rejects.toThrowError(/^Failed to run/);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('branch', () => {
|
||||
it('returns headRef when set', () => {
|
||||
const headReference = vi
|
||||
.spyOn(Versioning, 'headRef', 'get')
|
||||
.mockReturnValue('feature-branch-1');
|
||||
|
||||
expect(Versioning.branch).toStrictEqual('feature-branch-1');
|
||||
expect(headReference).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('returns part of Ref when set', () => {
|
||||
vi.spyOn(Versioning, 'headRef', 'get').mockImplementation(() => undefined);
|
||||
const reference = vi
|
||||
.spyOn(Versioning, 'ref', 'get')
|
||||
.mockReturnValue('refs/heads/feature-branch-2');
|
||||
|
||||
expect(Versioning.branch).toStrictEqual('feature-branch-2');
|
||||
expect(reference).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('prefers headRef over ref when set', () => {
|
||||
const headReference = vi
|
||||
.spyOn(Versioning, 'headRef', 'get')
|
||||
.mockReturnValue('feature-branch-1');
|
||||
const reference = vi.spyOn(Versioning, 'ref', 'get').mockReturnValue('refs/heads/feature-2');
|
||||
|
||||
expect(Versioning.branch).toStrictEqual('feature-branch-1');
|
||||
expect(headReference).toHaveBeenCalledTimes(1);
|
||||
expect(reference).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it('returns undefined when headRef and ref are not set', () => {
|
||||
const headReference = vi
|
||||
.spyOn(Versioning, 'headRef', 'get')
|
||||
.mockImplementation(() => undefined);
|
||||
const reference = vi.spyOn(Versioning, 'ref', 'get').mockImplementation(() => undefined);
|
||||
|
||||
expect(Versioning.branch).not.toBeDefined();
|
||||
|
||||
expect(headReference).toHaveBeenCalledTimes(1);
|
||||
expect(reference).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('headRef', () => {
|
||||
it('does not throw', () => {
|
||||
expect(() => Versioning.headRef).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ref', () => {
|
||||
it('does not throw', () => {
|
||||
expect(() => Versioning.ref).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('logging git diff', () => {
|
||||
it('calls git diff', async () => {
|
||||
// allowDirtyBuild: true
|
||||
vi.spyOn(core, 'getInput').mockReturnValue('true');
|
||||
vi.spyOn(Versioning, 'isShallow').mockResolvedValue(true);
|
||||
vi.spyOn(Versioning, 'isDirty').mockResolvedValue(false);
|
||||
vi.spyOn(Versioning, 'fetch').mockResolvedValue();
|
||||
vi.spyOn(Versioning, 'hasAnyVersionTags').mockResolvedValue(true);
|
||||
vi.spyOn(Versioning, 'parseSemanticVersion').mockResolvedValue({
|
||||
match: '',
|
||||
tag: 'mocktag',
|
||||
commits: 'abcdef',
|
||||
hash: '75822BCAF',
|
||||
});
|
||||
const logDiffSpy = vi.spyOn(Versioning, 'logDiff');
|
||||
const gitSpy = vi.spyOn(System, 'run').mockResolvedValue('');
|
||||
|
||||
await Versioning.generateSemanticVersion();
|
||||
|
||||
expect(logDiffSpy).toHaveBeenCalledTimes(1);
|
||||
expect(gitSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Todo - this no longer works since typescript
|
||||
// const issuedCommand = System.run.mock.calls[0][2].input.toString();
|
||||
// expect(issuedCommand.indexOf('diff')).toBeGreaterThan(-1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('descriptionRegex1', () => {
|
||||
it('is a valid regex', () => {
|
||||
expect(Versioning.descriptionRegexes[0]).toBeInstanceOf(RegExp);
|
||||
});
|
||||
|
||||
test.each(['v1.1-1-g12345678', 'v0.1-2-g12345678', 'v0.0-500-gA9B6C3D0-dirty'])(
|
||||
'is happy with valid %s',
|
||||
(description) => {
|
||||
expect(Versioning.descriptionRegexes[0].test(description)).toBeTruthy();
|
||||
},
|
||||
);
|
||||
|
||||
test.each(['1.1-1-g12345678', '0.1-2-g12345678', '0.0-500-gA9B6C3D0-dirty'])(
|
||||
'accepts valid semantic versions without v-prefix %s',
|
||||
(description) => {
|
||||
expect(Versioning.descriptionRegexes[0].test(description)).toBeTruthy();
|
||||
},
|
||||
);
|
||||
|
||||
test.each(['v0', 'v0.1', 'v0.1.2', 'v0.1-2', 'v0.1-2-g'])('does not like %s', (description) => {
|
||||
expect(Versioning.descriptionRegexes[0].test(description)).toBeFalsy();
|
||||
|
||||
// Also, never expect without the v to work for any of these cases.
|
||||
expect(Versioning.descriptionRegexes[0].test(description?.slice(1))).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('determineBuildVersion', () => {
|
||||
test.each(['somethingRandom'])('throws for invalid strategy %s', async (strategy) => {
|
||||
await expect(
|
||||
Versioning.determineBuildVersion(strategy, ''),
|
||||
).rejects.toThrowErrorMatchingSnapshot();
|
||||
});
|
||||
|
||||
describe('opt out strategy', () => {
|
||||
it("returns 'none'", async () => {
|
||||
await expect(
|
||||
Versioning.determineBuildVersion('None', 'v1.0'),
|
||||
).resolves.toMatchInlineSnapshot(`"none"`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('custom strategy', () => {
|
||||
test.each(['v0.1', '1', 'CamelCase', 'dashed-version'])(
|
||||
'returns the inputVersion for %s',
|
||||
async (inputVersion) => {
|
||||
await expect(
|
||||
Versioning.determineBuildVersion('Custom', inputVersion),
|
||||
).resolves.toStrictEqual(inputVersion);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('semantic strategy', () => {
|
||||
it('refers to generateSemanticVersion', async () => {
|
||||
const generateSemanticVersion = vi
|
||||
.spyOn(Versioning, 'generateSemanticVersion')
|
||||
.mockResolvedValue('1.3.37');
|
||||
|
||||
await expect(Versioning.determineBuildVersion('Semantic', '')).resolves.toStrictEqual(
|
||||
'1.3.37',
|
||||
);
|
||||
expect(generateSemanticVersion).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tag strategy', () => {
|
||||
it('refers to generateTagVersion', async () => {
|
||||
const generateTagVersion = vi
|
||||
.spyOn(Versioning, 'generateTagVersion')
|
||||
.mockResolvedValue('0.1');
|
||||
|
||||
await expect(Versioning.determineBuildVersion('Tag', '')).resolves.toStrictEqual('0.1');
|
||||
expect(generateTagVersion).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('not implemented strategy', () => {
|
||||
it('throws a not implemented exception', async () => {
|
||||
const strategy = 'Test';
|
||||
// @ts-ignore
|
||||
vi.spyOn(Versioning, 'strategies', 'get').mockReturnValue({ [strategy]: strategy });
|
||||
await expect(Versioning.determineBuildVersion(strategy, '')).rejects.toThrowError(
|
||||
NotImplementedException,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateTagVersion', () => {
|
||||
it('removes the v', async () => {
|
||||
vi.spyOn(Versioning, 'getTag').mockResolvedValue('v1.3.37');
|
||||
await expect(Versioning.generateTagVersion()).resolves.toStrictEqual('1.3.37');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseSemanticVersion', () => {
|
||||
it('returns the named parts', async () => {
|
||||
vi.spyOn(Versioning, 'getVersionDescription').mockResolvedValue('v0.1-2-g12345678');
|
||||
|
||||
await expect(Versioning.parseSemanticVersion()).resolves.toMatchObject({
|
||||
tag: '0.1',
|
||||
commits: '2',
|
||||
hash: '12345678',
|
||||
});
|
||||
});
|
||||
|
||||
it('throws when no match could be made', async () => {
|
||||
vi.spyOn(Versioning, 'getVersionDescription').mockResolvedValue('no-match-can-be-made');
|
||||
|
||||
await expect(Versioning.parseSemanticVersion()).toMatchObject({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getVersionDescription', () => {
|
||||
it('returns the commands output', async () => {
|
||||
const runOutput = 'someValue';
|
||||
vi.spyOn(System, 'run').mockResolvedValue(runOutput);
|
||||
await expect(Versioning.getVersionDescription()).resolves.toStrictEqual(runOutput);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isShallow', () => {
|
||||
it('returns true when the repo is shallow', async () => {
|
||||
const runOutput = 'true\n';
|
||||
vi.spyOn(System, 'run').mockResolvedValue(runOutput);
|
||||
await expect(Versioning.isShallow()).resolves.toStrictEqual(true);
|
||||
});
|
||||
|
||||
it('returns false when the repo is not shallow', async () => {
|
||||
const runOutput = 'false\n';
|
||||
vi.spyOn(System, 'run').mockResolvedValue(runOutput);
|
||||
await expect(Versioning.isShallow()).resolves.toStrictEqual(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetch', () => {
|
||||
it('awaits the command', async () => {
|
||||
vi.spyOn(core, 'warning').mockImplementation(() => {});
|
||||
vi.spyOn(System, 'run').mockResolvedValue('');
|
||||
await expect(Versioning.fetch()).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it('falls back to the second strategy when the first fails', async () => {
|
||||
vi.spyOn(core, 'warning').mockImplementation(() => {});
|
||||
const gitFetch = vi.spyOn(System, 'run').mockResolvedValue('');
|
||||
|
||||
await expect(Versioning.fetch()).resolves.not.toThrow();
|
||||
expect(gitFetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateSemanticVersion', () => {
|
||||
it('returns a proper version from description', async () => {
|
||||
vi.spyOn(System, 'run').mockResolvedValue('');
|
||||
vi.spyOn(core, 'info').mockImplementation(() => {});
|
||||
vi.spyOn(Versioning, 'isDirty').mockResolvedValue(false);
|
||||
vi.spyOn(Versioning, 'hasAnyVersionTags').mockResolvedValue(true);
|
||||
vi.spyOn(Versioning, 'getTotalNumberOfCommits').mockResolvedValue(2);
|
||||
vi.spyOn(Versioning, 'parseSemanticVersion').mockResolvedValue({
|
||||
match: '0.1-2-g1b345678',
|
||||
tag: '0.1',
|
||||
commits: '2',
|
||||
hash: '1b345678',
|
||||
});
|
||||
|
||||
await expect(Versioning.generateSemanticVersion()).resolves.toStrictEqual('0.1.2');
|
||||
});
|
||||
|
||||
it('throws when dirty', async () => {
|
||||
vi.spyOn(System, 'run').mockResolvedValue('');
|
||||
vi.spyOn(core, 'info').mockImplementation(() => {});
|
||||
vi.spyOn(Versioning, 'isDirty').mockResolvedValue(true);
|
||||
await expect(Versioning.generateSemanticVersion()).rejects.toThrowError();
|
||||
});
|
||||
|
||||
it('falls back to commits only, when no tags are present', async () => {
|
||||
const commits = Math.round(Math.random() * 10);
|
||||
vi.spyOn(System, 'run').mockResolvedValue('');
|
||||
vi.spyOn(core, 'info').mockImplementation(() => {});
|
||||
vi.spyOn(Versioning, 'isDirty').mockResolvedValue(false);
|
||||
vi.spyOn(Versioning, 'hasAnyVersionTags').mockResolvedValue(false);
|
||||
vi.spyOn(Versioning, 'getTotalNumberOfCommits').mockResolvedValue(commits);
|
||||
|
||||
await expect(Versioning.generateSemanticVersion()).resolves.toStrictEqual(`0.0.${commits}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isDirty', () => {
|
||||
it('returns true when there are files listed', async () => {
|
||||
const runOutput = 'file.ext\nfile2.ext';
|
||||
vi.spyOn(System, 'run').mockResolvedValue(runOutput);
|
||||
await expect(Versioning.isDirty()).resolves.toStrictEqual(true);
|
||||
});
|
||||
|
||||
it('returns false when there is no output', async () => {
|
||||
const runOutput = '';
|
||||
vi.spyOn(System, 'run').mockResolvedValue(runOutput);
|
||||
await expect(Versioning.isDirty()).resolves.toStrictEqual(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTag', () => {
|
||||
it('returns the commands output', async () => {
|
||||
const runOutput = 'v1.0';
|
||||
vi.spyOn(System, 'run').mockResolvedValue(runOutput);
|
||||
await expect(Versioning.getTag()).resolves.toStrictEqual(runOutput);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasAnyVersionTags', () => {
|
||||
it('returns false when the command returns 0', async () => {
|
||||
const runOutput = '0';
|
||||
vi.spyOn(System, 'run').mockResolvedValue(runOutput);
|
||||
await expect(Versioning.hasAnyVersionTags()).resolves.toStrictEqual(false);
|
||||
});
|
||||
|
||||
it('returns true when the command returns >= 0', async () => {
|
||||
const runOutput = '9';
|
||||
vi.spyOn(System, 'run').mockResolvedValue(runOutput);
|
||||
await expect(Versioning.hasAnyVersionTags()).resolves.toStrictEqual(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTotalNumberOfCommits', () => {
|
||||
it('returns a number from the command', async () => {
|
||||
vi.spyOn(System, 'run').mockResolvedValue('9');
|
||||
await expect(Versioning.getTotalNumberOfCommits()).resolves.toStrictEqual(9);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,294 +0,0 @@
|
||||
import * as core from '@actions/core';
|
||||
import NotImplementedException from './error/not-implemented-exception';
|
||||
import ValidationError from './error/validation-error';
|
||||
import Input from './input';
|
||||
import System from './system';
|
||||
|
||||
export default class Versioning {
|
||||
static get strategies() {
|
||||
return { None: 'None', Semantic: 'Semantic', Tag: 'Tag', Custom: 'Custom' };
|
||||
}
|
||||
|
||||
static get grepCompatibleInputVersionRegex() {
|
||||
return '^v?([0-9]+\\.)*[0-9]+.*';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the branch name of the (related) branch
|
||||
*/
|
||||
static get branch() {
|
||||
return this.headRef || this.ref?.slice(11);
|
||||
}
|
||||
|
||||
/**
|
||||
* For pull requests we can reliably use GITHUB_HEAD_REF
|
||||
*/
|
||||
static get headRef() {
|
||||
return process.env.GITHUB_HEAD_REF;
|
||||
}
|
||||
|
||||
/**
|
||||
* For branches GITHUB_REF will have format `refs/heads/feature-branch-1`
|
||||
*/
|
||||
static get ref() {
|
||||
return process.env.GITHUB_REF;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maximum number of lines to print when logging the git diff
|
||||
*/
|
||||
static get maxDiffLines() {
|
||||
return 60;
|
||||
}
|
||||
|
||||
/**
|
||||
* Log up to maxDiffLines of the git diff.
|
||||
*/
|
||||
static async logDiff() {
|
||||
const diffCommand = `git --no-pager diff | head -n ${this.maxDiffLines.toString()}`;
|
||||
await System.run(
|
||||
'sh',
|
||||
undefined,
|
||||
{
|
||||
input: Buffer.from(diffCommand),
|
||||
silent: true,
|
||||
},
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Regex to parse version description into separate fields
|
||||
*/
|
||||
static get descriptionRegexes(): RegExp[] {
|
||||
return [
|
||||
/^v?([\d.]+)-(\d+)-g(\w+)-?(\w+)*/g,
|
||||
/^v?([\d.]+-\w+)-(\d+)-g(\w+)-?(\w+)*/g,
|
||||
/^v?([\d.]+-\w+\.\d+)-(\d+)-g(\w+)-?(\w+)*/g,
|
||||
];
|
||||
}
|
||||
|
||||
static async determineBuildVersion(strategy: string, inputVersion: string): Promise<string> {
|
||||
// Validate input
|
||||
if (!Object.hasOwnProperty.call(this.strategies, strategy)) {
|
||||
throw new ValidationError(
|
||||
`Versioning strategy should be one of ${Object.values(this.strategies).join(', ')}.`,
|
||||
);
|
||||
}
|
||||
|
||||
switch (strategy) {
|
||||
case this.strategies.None:
|
||||
return 'none';
|
||||
case this.strategies.Custom:
|
||||
return inputVersion;
|
||||
case this.strategies.Semantic:
|
||||
return await this.generateSemanticVersion();
|
||||
case this.strategies.Tag:
|
||||
return await this.generateTagVersion();
|
||||
default:
|
||||
throw new NotImplementedException(`Strategy ${strategy} is not implemented.`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Automatically generates a version based on SemVer out of the box.
|
||||
*
|
||||
* The version works as follows: `<major>.<minor>.<patch>` for example `0.1.2`.
|
||||
*
|
||||
* The latest tag dictates `<major>.<minor>`
|
||||
* The number of commits since that tag dictates`<patch>`.
|
||||
*
|
||||
* @See: https://semver.org/
|
||||
*/
|
||||
static async generateSemanticVersion() {
|
||||
if (await this.isShallow()) {
|
||||
await this.fetch();
|
||||
}
|
||||
|
||||
await this.logDiff();
|
||||
|
||||
if ((await this.isDirty()) && !Input.allowDirtyBuild) {
|
||||
throw new Error('Branch is dirty. Refusing to base semantic version on uncommitted changes');
|
||||
}
|
||||
|
||||
if (!(await this.hasAnyVersionTags())) {
|
||||
const version = `0.0.${await this.getTotalNumberOfCommits()}`;
|
||||
core.info(`Generated version ${version} (no version tags found).`);
|
||||
|
||||
return version;
|
||||
}
|
||||
|
||||
const versionDescriptor = await this.parseSemanticVersion();
|
||||
if (versionDescriptor) {
|
||||
const { tag, commits, hash } = versionDescriptor;
|
||||
|
||||
// Ensure 3 digits (commits should always be patch level)
|
||||
const [major, minor, patch] = `${tag}.${commits}`.split('.');
|
||||
const threeDigitVersion = /^\d+$/.test(patch)
|
||||
? `${major}.${minor}.${patch}`
|
||||
: `${major}.0.${minor}`;
|
||||
|
||||
core.info(`Found semantic version ${threeDigitVersion} for ${this.branch}@${hash}`);
|
||||
|
||||
return `${threeDigitVersion}`;
|
||||
}
|
||||
|
||||
const version = `0.0.${await this.getTotalNumberOfCommits()}`;
|
||||
core.info(`Generated version ${version} (semantic version couldn't be determined).`);
|
||||
|
||||
return version;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate the proper version for unity based on an existing tag.
|
||||
*/
|
||||
static async generateTagVersion() {
|
||||
let tag = await this.getTag();
|
||||
|
||||
if (tag.charAt(0) === 'v') {
|
||||
tag = tag.slice(1);
|
||||
}
|
||||
|
||||
return tag;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the versionDescription into their named parts.
|
||||
*/
|
||||
static async parseSemanticVersion() {
|
||||
const description = await this.getVersionDescription();
|
||||
for (const descriptionRegex of Versioning.descriptionRegexes) {
|
||||
try {
|
||||
const [match, tag, commits, hash] = descriptionRegex.exec(description) as RegExpExecArray;
|
||||
|
||||
return {
|
||||
match,
|
||||
tag,
|
||||
commits,
|
||||
hash,
|
||||
};
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
core.warning(
|
||||
`Failed to parse git describe output or version can not be determined through: "${description}".`,
|
||||
);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether the repository is shallow.
|
||||
*/
|
||||
static async isShallow() {
|
||||
const output = await this.git(['rev-parse', '--is-shallow-repository']);
|
||||
|
||||
return output !== 'false\n';
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves refs from the configured remote.
|
||||
*
|
||||
* Fetch unshallow for incomplete repository, but fall back to normal fetch.
|
||||
*
|
||||
* Note: `--all` should not be used, and would break fetching for push event.
|
||||
*/
|
||||
static async fetch() {
|
||||
try {
|
||||
await this.git(['fetch', '--unshallow']);
|
||||
} catch (error) {
|
||||
core.warning(`Fetch --unshallow caught: ${error}`);
|
||||
await this.git(['fetch']);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves information about the branch.
|
||||
*
|
||||
* Format: `v0.12-24-gd2198ab`
|
||||
*
|
||||
* In this format v0.12 is the latest tag, 24 are the number of commits since, and gd2198ab
|
||||
* identifies the current commit.
|
||||
*/
|
||||
static async getVersionDescription() {
|
||||
const versionTags = (
|
||||
await this.git(['tag', '--list', '--merged', 'HEAD', '--sort=-creatordate'])
|
||||
)
|
||||
.split('\n')
|
||||
.filter((tag) => new RegExp(this.grepCompatibleInputVersionRegex).test(tag));
|
||||
|
||||
if (versionTags.length === 0) {
|
||||
core.warning('No valid version tags found. Using fallback description.');
|
||||
|
||||
return this.git(['describe', '--long', '--tags', '--always', 'HEAD']);
|
||||
}
|
||||
|
||||
const latestVersionTag = versionTags[0];
|
||||
const commitsCount = (
|
||||
await this.git(['rev-list', `${latestVersionTag}..HEAD`, '--count'])
|
||||
).trim();
|
||||
const commitHash = (await this.git(['rev-parse', '--short', 'HEAD'])).trim();
|
||||
|
||||
return `${latestVersionTag}-${commitsCount}-g${commitHash}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether there are uncommitted changes that are not ignored.
|
||||
*/
|
||||
static async isDirty() {
|
||||
const output = await this.git(['status', '--porcelain']);
|
||||
const isDirty = output !== '';
|
||||
|
||||
if (isDirty) {
|
||||
core.warning('Changes were made to the following files and folders:\n');
|
||||
core.warning(output);
|
||||
}
|
||||
|
||||
return isDirty;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the tag if there is one pointing at HEAD
|
||||
*/
|
||||
static async getTag() {
|
||||
return (await this.git(['tag', '--points-at', 'HEAD'])).trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the current tree has any version tags yet.
|
||||
*
|
||||
* Note: Currently this is run in all OSes, so the syntax must be cross-platform.
|
||||
*/
|
||||
static async hasAnyVersionTags() {
|
||||
const numberOfTagsAsString = await System.run('sh', undefined, {
|
||||
input: Buffer.from(
|
||||
`git tag --list --merged HEAD | grep -E '${this.grepCompatibleInputVersionRegex}' | wc -l`,
|
||||
),
|
||||
cwd: Input.projectPath,
|
||||
silent: false,
|
||||
});
|
||||
|
||||
const numberOfTags = Number.parseInt(numberOfTagsAsString, 10);
|
||||
|
||||
return numberOfTags !== 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the total number of commits on head.
|
||||
*
|
||||
*/
|
||||
static async getTotalNumberOfCommits() {
|
||||
const numberOfCommitsAsString = await this.git(['rev-list', '--count', 'HEAD']);
|
||||
|
||||
return Number.parseInt(numberOfCommitsAsString, 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run git in the specified project path
|
||||
*/
|
||||
static async git(arguments_: string[], options = {}) {
|
||||
return System.run('git', arguments_, { cwd: Input.projectPath, ...options }, false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { resolveProjectPath } from './resolve-project-path';
|
||||
|
||||
function fsOf(existingPaths: string[]) {
|
||||
return {
|
||||
joinPath: (...segments: string[]) => segments.join('/'),
|
||||
existsSync: (candidate: string) => existingPaths.includes(candidate),
|
||||
};
|
||||
}
|
||||
|
||||
describe('resolveProjectPath', () => {
|
||||
it('returns the explicit input unchanged when given', () => {
|
||||
const result = resolveProjectPath({
|
||||
input: 'my-project',
|
||||
...fsOf(['test-project/ProjectSettings/ProjectVersion.txt']),
|
||||
});
|
||||
|
||||
expect(result).toBe('my-project');
|
||||
});
|
||||
|
||||
it('strips a trailing slash from an explicit input', () => {
|
||||
const result = resolveProjectPath({ input: 'my-project/', ...fsOf([]) });
|
||||
|
||||
expect(result).toBe('my-project');
|
||||
});
|
||||
|
||||
it('defaults to "test-project" when it looks like a Unity project and the repo root does not', () => {
|
||||
const result = resolveProjectPath({
|
||||
input: '',
|
||||
...fsOf(['test-project/ProjectSettings/ProjectVersion.txt']),
|
||||
});
|
||||
|
||||
expect(result).toBe('test-project');
|
||||
});
|
||||
|
||||
it('does not default to "test-project" when the repo root is itself a Unity project', () => {
|
||||
const result = resolveProjectPath({
|
||||
input: '',
|
||||
...fsOf([
|
||||
'test-project/ProjectSettings/ProjectVersion.txt',
|
||||
'ProjectSettings/ProjectVersion.txt',
|
||||
]),
|
||||
});
|
||||
|
||||
expect(result).toBe('');
|
||||
});
|
||||
|
||||
it('returns empty (letting the cli default to ".") when neither location looks like a Unity project', () => {
|
||||
const result = resolveProjectPath({ input: '', ...fsOf([]) });
|
||||
|
||||
expect(result).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Replicates the original action's projectPath auto-detection: when
|
||||
* projectPath isn't given explicitly, and a test-project/ directory looks
|
||||
* like a Unity project while the current directory doesn't, default to
|
||||
* "test-project" instead of the repo root. See the original
|
||||
* Input.projectPath getter this is ported from.
|
||||
*/
|
||||
export interface ResolveProjectPathOptions {
|
||||
input: string;
|
||||
existsSync(path: string): boolean;
|
||||
joinPath(...segments: string[]): string;
|
||||
}
|
||||
|
||||
export function resolveProjectPath({
|
||||
input,
|
||||
existsSync,
|
||||
joinPath,
|
||||
}: ResolveProjectPathOptions): string {
|
||||
if (input) return input.replace(/\/$/, '');
|
||||
|
||||
const hasTestProject = existsSync(
|
||||
joinPath('test-project', 'ProjectSettings', 'ProjectVersion.txt'),
|
||||
);
|
||||
const hasRootProject = existsSync(joinPath('ProjectSettings', 'ProjectVersion.txt'));
|
||||
|
||||
if (hasTestProject && !hasRootProject) return 'test-project';
|
||||
|
||||
return '';
|
||||
}
|
||||
Vendored
-32
@@ -1,32 +0,0 @@
|
||||
/**
|
||||
* Type declarations for @game-ci/orchestrator.
|
||||
*
|
||||
* This optional dependency is one implementation of unity-builder's generic
|
||||
* plugin lifecycle. When installed, the plugin loader in plugin.ts
|
||||
* dynamically imports it.
|
||||
*/
|
||||
declare module '@game-ci/orchestrator' {
|
||||
interface Plugin {
|
||||
initialize(coreParams: Record<string, any>, workspace: string): Promise<void>;
|
||||
canHandleBuild(): boolean;
|
||||
handleBuild(baseImage: string): Promise<{ exitCode: number; fallbackToLocal?: boolean }>;
|
||||
beforeLocalBuild(workspace: string): Promise<void>;
|
||||
afterLocalBuild(workspace: string, exitCode: number): Promise<void>;
|
||||
handlePostBuild(exitCode: number): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a unity-builder plugin instance.
|
||||
* The plugin reads its own configuration from environment variables and
|
||||
* GitHub Actions inputs; unity-builder does not need to proxy them.
|
||||
*/
|
||||
export function createPlugin(): Plugin;
|
||||
|
||||
// Legacy export — kept for backward compatibility with CLI and direct consumers
|
||||
export const Orchestrator: {
|
||||
run: (arg0: any, arg1: string) => Promise<{ BuildSucceeded: boolean; BuildResults: string }>;
|
||||
setup: (arg0: any) => Promise<void>;
|
||||
buildParameters: any;
|
||||
lockedWorkspace: string;
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user