mirror of
https://github.com/game-ci/unity-builder.git
synced 2026-06-02 14:56:16 -07:00
* chore: quality-tightening (oxfmt + oxlint + tsc + vitest + husky + actionlint)
Standard rollout for unity-builder. Most of the work was porting 24
test files from jest 27 to vitest 4.
- prettier -> oxfmt
- eslint (with @typescript-eslint, github, jest, prettier, unicorn) ->
oxlint with eslint-plugin-unicorn
- jest 27 + jest-circus + ts-jest + @types/jest + @jest/globals ->
vitest 4 + vite 7 + @vitest/coverage-istanbul (jest config files
removed)
- new: tsgo --noEmit (alongside tsc fallback)
- lefthook (and lefthook.yml) -> husky 9 with the standard
scripts/ensure-husky.mjs self-heal pattern + lint-staged
- new: gitleaks, actionlint, shellcheck as mise-managed binaries
- TypeScript bumped target ES2020 -> ES2022 + lib ES2022 + DOM (for
Error.cause and modern globals)
Test migration (24 files):
- Bulk-converted jest.* -> vi.*; jest.Mocked -> Mocked from vitest;
jest.MockedFunction -> MockedFunction.
- Added vitest imports to all *.test.ts files (and __mocks__/*.ts)
that didn't have them.
- src/index.ts: extracted runMain() as a named export and gated the
module-level invocation behind NODE_ENV !== 'test'. The
index-plugin-features test now calls runMain() directly instead of
relying on jest's removed vi.isolateModules.
- index-plugin-features.test.ts: moved hoisted refs (mockPlugin,
mockLoadOrchestratorPlugin) into vi.hoisted() so vi.mock factories
can reference them. Replaced arrow constructor mock for ImageTag
with regular function() {...} (vitest 4 disallows arrows as ctors).
Replaced require('./model') / require('@actions/core') inside test
bodies with top-level imports.
- model/orchestrator-plugin.test.ts: dropped jest's '{ virtual: true }'
flag (vitest doesn't support it); replaced the
'mock factory throws' pattern with 'createPlugin throws' so vitest
doesn't wrap the error message at the assertion site.
- model/versioning.test.ts: stray jest.spyOn -> vi.spyOn; replaced
mockImplementation() with no args (jest pattern) by
mockResolvedValue('') / mockImplementation(() => undefined) where
the source expects a string return.
Workflow shell-quoting cleanup (actionlint):
- All bare $GITHUB_STEP_SUMMARY / $GITHUB_OUTPUT / $GITHUB_ENV
redirects quoted across 2 workflows (SC2086).
- s3://$AWS_STACK_NAME / s3://$BUCKET_NAME -> s3://"$AWS_STACK_NAME"
/ s3://"$BUCKET_NAME".
- 'for i in {1..N}; do ... done' loops where i isn't referenced in
the body renamed to 'for _ in' (SC2034).
- 'grep ... | wc -l' -> 'grep -c ...' (SC2126).
- Multiple consecutive '>> $file' redirects in
validate-community-plugins.yml summary block collapsed into a
single block redirect (SC2129).
- 'cat $file | python3 -c "..."' -> 'python3 -c "..." < $file'
(SC2002).
- http://${VAR}:port -> http://"${VAR}":port (SC2086).
tsgo: kept tsc --noEmit as the default 'typecheck' because
unity-builder publishes CommonJS for the GitHub Action consumer,
which conflicts with tsgo's bundler/node16 moduleResolution
requirement (per playbook trap #9). 'yarn typecheck:tsgo' is wired
up for when consumers move to ESM.
Caveats: 28 pre-existing oxlint warnings remain (mostly
typescript/no-explicit-any across the build-parameter shapes and
vitest/no-disabled-tests on 2 explicitly skipped scenarios). Per
playbook trap #22 the lint script drops --deny-warnings.
Verified locally: format clean, lint 0/28, typecheck clean,
test 340/342 (2 pre-existing skipped), actionlint clean across all
12 workflows.
* ci(unity-builder): fix Tests + Plugin Architecture Health on quality-tightening
Three issues surfaced in CI after the jest -> vitest port:
1. **Obsolete snapshot blocks Tests job.**
src/model/__snapshots__/versioning.test.ts.snap had two entries
for the same 'throws for invalid strategy' assertion: one in the
vitest format ('Versioning > determineBuildVersion > ...') and one
in the legacy jest format without the '>'. vitest correctly
regenerates the new one and flags the old one as obsolete; CI
runs without --update so 'Test Files 1 failed' even though all
343 tests passed. Removed the obsolete entry.
2. **'Plugin Architecture Health' workflow still calls jest.**
.github/workflows/validate-orchestrator.yml had two 'npx jest'
steps (orchestrator-plugin unit tests + orchestrator-standalone
tests). The unity-builder + orchestrator codebases are both on
vitest now. Replaced both with 'yarn vitest run'.
3. **jest-fail-on-console + src/jest.setup.ts left over.**
The earlier vitest port missed the jest-fail-on-console
integration. yarn install in CI surfaced
YN0002: doesn't provide @jest/globals (requested by
jest-fail-on-console). Removed jest-fail-on-console + jest.setup.ts;
added src/test/setup.ts with the equivalent vitest beforeEach
spies (same as unity-test-runner).
---------
Co-authored-by: frostebite <jas.f.ukcmti@gmail.com>
295 lines
7.9 KiB
TypeScript
295 lines
7.9 KiB
TypeScript
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);
|
|
}
|
|
}
|