Implement versioning strategies in js 🧉

This commit is contained in:
Webber 2020-04-26 20:22:09 +02:00 committed by Webber Takken
parent 2e81e61af3
commit d75d7890d0
23 changed files with 361 additions and 93 deletions

View File

@ -27,7 +27,7 @@ namespace UnityBuilderAction
}; };
// Set version for this build // Set version for this build
VersionApplicator.SetVersion(options["versioning"], options["version"]); VersionApplicator.SetVersion(options["version"]);
// Perform build // Perform build
BuildReport buildReport = BuildPipeline.BuildPlayer(buildOptions); BuildReport buildReport = BuildPipeline.BuildPlayer(buildOptions);

View File

@ -95,7 +95,7 @@ namespace UnityBuilderAction.Versioning
return Run(@"describe --tags --long --match ""v[0-9]*"""); return Run(@"describe --tags --long --match ""v[0-9]*""");
// Todo - implement split function based on this more complete query // Todo - implement split function based on this more complete query
return Run(@"git describe --long --tags --dirty --always"); // return Run(@"describe --long --tags --dirty --always");
} }
/// <summary> /// <summary>

View File

@ -6,53 +6,12 @@ namespace UnityBuilderAction.Versioning
{ {
public class VersionApplicator public class VersionApplicator
{ {
enum Strategy public static void SetVersion(string version)
{ {
None, if (version == "none") {
Custom, return;
Semantic,
Tag,
}
public static void SetVersion(string strategy, [CanBeNull] string version)
{
if (!Enum.TryParse<Strategy>(strategy, out Strategy validatedStrategy)) {
throw new Exception($"Invalid versioning argument provided. {strategy} is not a valid strategy.");
} }
switch (validatedStrategy) {
case Strategy.None:
return;
case Strategy.Custom:
ApplyCustomVersion(version);
return;
case Strategy.Semantic:
ApplySemanticCommitVersion();
return;
case Strategy.Tag:
ApplyVersionFromCurrentTag();
return;
default:
throw new NotImplementedException("Version strategy has not been implemented.");
}
}
static void ApplyCustomVersion(string version)
{
Apply(version);
}
static void ApplySemanticCommitVersion()
{
string version = Git.GenerateSemanticCommitVersion();
Apply(version);
}
static void ApplyVersionFromCurrentTag()
{
string version = Git.GetTagVersion();
Apply(version); Apply(version);
} }

File diff suppressed because one or more lines are too long

View File

@ -5,4 +5,5 @@ module.exports = {
moduleFileExtensions: ['js', 'jsx', 'json', 'vue'], moduleFileExtensions: ['js', 'jsx', 'json', 'vue'],
transform: { '^.+\\.(js|jsx)?$': 'babel-jest' }, transform: { '^.+\\.(js|jsx)?$': 'babel-jest' },
transformIgnorePatterns: [`/node_modules/(?!${esModules})`], transformIgnorePatterns: [`/node_modules/(?!${esModules})`],
setupFilesAfterEnv: ['./src/jest.setup.js'],
}; };

View File

@ -7,8 +7,8 @@ async function action() {
Cache.verify(); Cache.verify();
const { dockerfile, workspace, actionFolder } = Action; const { dockerfile, workspace, actionFolder } = Action;
const buildParameters = BuildParameters.create(Input.getFromUser()); const buildParameters = BuildParameters.create(await Input.getFromUser());
const baseImage = new ImageTag({ ...buildParameters, version: buildParameters.unityVersion }); const baseImage = new ImageTag(buildParameters);
// Build docker image // Build docker image
const builtImage = await Docker.build({ path: actionFolder, dockerfile, baseImage }); const builtImage = await Docker.build({ path: actionFolder, dockerfile, baseImage });

15
src/jest.setup.js Normal file
View File

@ -0,0 +1,15 @@
expect.extend({
toBeEitherAFunctionOrAnObject(received) {
const type = typeof received;
const pass = ['object', 'function'].includes(type);
const message = `Expected a function or an object, received ${type}`;
return {
message,
pass,
};
},
});
jest.mock('./model/input');

View File

@ -0,0 +1,17 @@
// Import this named export into your test file:
import Platform from '../platform';
export const mockGetFromUser = jest.fn().mockResolvedValue({
version: '',
targetPlatform: Platform.types.Test,
projectPath: '.',
buildName: Platform.types.Test,
buildsPath: 'build',
buildMethod: undefined,
buildVersion: '1.3.37',
customParameters: '',
});
export default {
getFromUser: mockGetFromUser,
};

View File

@ -0,0 +1,5 @@
export const mockDetermineVersion = jest.fn();
export default {
determineVersion: mockDetermineVersion,
};

View File

@ -3,27 +3,25 @@ import Platform from './platform';
class BuildParameters { class BuildParameters {
static create(parameters) { static create(parameters) {
const { const {
unityVersion, version,
targetPlatform, targetPlatform,
projectPath, projectPath,
buildName, buildName,
buildsPath, buildsPath,
buildMethod, buildMethod,
versioning, buildVersion,
version,
customParameters, customParameters,
} = parameters; } = parameters;
return { return {
unityVersion, version,
platform: targetPlatform, platform: targetPlatform,
projectPath, projectPath,
buildName, buildName,
buildPath: `${buildsPath}/${targetPlatform}`, buildPath: `${buildsPath}/${targetPlatform}`,
buildFile: this.parseBuildFile(buildName, targetPlatform), buildFile: this.parseBuildFile(buildName, targetPlatform),
buildMethod, buildMethod,
versioning, buildVersion,
version,
customParameters, customParameters,
}; };
} }

View File

@ -4,7 +4,7 @@ import Platform from './platform';
describe('BuildParameters', () => { describe('BuildParameters', () => {
describe('create', () => { describe('create', () => {
const someParameters = { const someParameters = {
unityVersion: 'someVersion', version: 'someVersion',
targetPlatform: 'somePlatform', targetPlatform: 'somePlatform',
projectPath: 'path/to/project', projectPath: 'path/to/project',
buildName: 'someBuildName', buildName: 'someBuildName',
@ -18,9 +18,7 @@ describe('BuildParameters', () => {
}); });
it('returns the version', () => { it('returns the version', () => {
expect(BuildParameters.create(someParameters).unityVersion).toStrictEqual( expect(BuildParameters.create(someParameters).version).toStrictEqual(someParameters.version);
someParameters.unityVersion,
);
}); });
it('returns the platform', () => { it('returns the platform', () => {

View File

@ -19,7 +19,7 @@ class Docker {
static async run(image, parameters, silent = false) { static async run(image, parameters, silent = false) {
const { const {
unityVersion, version,
workspace, workspace,
platform, platform,
projectPath, projectPath,
@ -27,8 +27,7 @@ class Docker {
buildPath, buildPath,
buildFile, buildFile,
buildMethod, buildMethod,
versioning, buildVersion,
version,
customParameters, customParameters,
} = parameters; } = parameters;
@ -40,15 +39,14 @@ class Docker {
--env UNITY_EMAIL \ --env UNITY_EMAIL \
--env UNITY_PASSWORD \ --env UNITY_PASSWORD \
--env UNITY_SERIAL \ --env UNITY_SERIAL \
--env UNITY_VERSION="${unityVersion}" \ --env UNITY_VERSION="${version}" \
--env PROJECT_PATH="${projectPath}" \ --env PROJECT_PATH="${projectPath}" \
--env BUILD_TARGET="${platform}" \ --env BUILD_TARGET="${platform}" \
--env BUILD_NAME="${buildName}" \ --env BUILD_NAME="${buildName}" \
--env BUILD_PATH="${buildPath}" \ --env BUILD_PATH="${buildPath}" \
--env BUILD_FILE="${buildFile}" \ --env BUILD_FILE="${buildFile}" \
--env BUILD_METHOD="${buildMethod}" \ --env BUILD_METHOD="${buildMethod}" \
--env VERSIONING="${versioning}" \ --env VERSION="${buildVersion}" \
--env VERSION="${version}" \
--env CUSTOM_PARAMETERS="${customParameters}" \ --env CUSTOM_PARAMETERS="${customParameters}" \
--env HOME=/github/home \ --env HOME=/github/home \
--env GITHUB_REF \ --env GITHUB_REF \

View File

@ -0,0 +1,8 @@
class CommandExecutionError extends Error {
constructor(message) {
super(message);
this.name = 'CommandExecutionError';
}
}
export default CommandExecutionError;

View File

@ -0,0 +1,14 @@
import CommandExecutionError from './command-execution-error';
describe('CommandExecutionError', () => {
it('instantiates', () => {
expect(() => new CommandExecutionError()).not.toThrow();
});
test.each([1, 'one', { name: '!' }])('Displays title %s', message => {
const error = new CommandExecutionError(message);
expect(error.name).toStrictEqual('CommandExecutionError');
expect(error.message).toStrictEqual(message.toString());
});
});

View File

@ -0,0 +1,8 @@
class NotImplementedException extends Error {
constructor(message) {
super(message);
this.name = 'NotImplementedException';
}
}
export default NotImplementedException;

View File

@ -0,0 +1,14 @@
import NotImplementedException from './not-implemented-exception';
describe('NotImplementedException', () => {
it('instantiates', () => {
expect(() => new NotImplementedException()).not.toThrow();
});
test.each([1, 'one', { name: '!' }])('Displays title %s', message => {
const error = new NotImplementedException(message);
expect(error.name).toStrictEqual('NotImplementedException');
expect(error.message).toStrictEqual(message.toString());
});
});

View File

@ -7,5 +7,17 @@ import ImageTag from './image-tag';
import Platform from './platform'; import Platform from './platform';
import Project from './project'; import Project from './project';
import Unity from './unity'; import Unity from './unity';
import Versioning from './versioning';
export { Action, BuildParameters, Cache, Docker, Input, ImageTag, Platform, Project, Unity }; export {
Action,
BuildParameters,
Cache,
Docker,
Input,
ImageTag,
Platform,
Project,
Unity,
Versioning,
};

View File

@ -12,6 +12,6 @@ describe('Index', () => {
'Project', 'Project',
'Unity', 'Unity',
])('exports %s', exportedModule => { ])('exports %s', exportedModule => {
expect(typeof Index[exportedModule]).toStrictEqual('function'); expect(Index[exportedModule]).toBeEitherAFunctionOrAnObject();
}); });
}); });

View File

@ -1,43 +1,36 @@
import Platform from './platform'; import Platform from './platform';
import ValidationError from './error/validation-error'; import Versioning from './versioning';
const core = require('@actions/core'); const core = require('@actions/core');
const versioningStrategies = ['None', 'Semantic', 'Tag', 'Custom'];
class Input { class Input {
static getFromUser() { static async getFromUser() {
// Input variables specified in workflows using "with" prop. // Input variables specified in workflows using "with" prop.
const unityVersion = core.getInput('unityVersion'); const version = core.getInput('unityVersion');
const targetPlatform = core.getInput('targetPlatform') || Platform.default; const targetPlatform = core.getInput('targetPlatform') || Platform.default;
const rawProjectPath = core.getInput('projectPath') || '.'; const rawProjectPath = core.getInput('projectPath') || '.';
const buildName = core.getInput('buildName') || targetPlatform; const buildName = core.getInput('buildName') || targetPlatform;
const buildsPath = core.getInput('buildsPath') || 'build'; const buildsPath = core.getInput('buildsPath') || 'build';
const buildMethod = core.getInput('buildMethod'); // processed in docker file const buildMethod = core.getInput('buildMethod'); // processed in docker file
const versioning = core.getInput('versioning') || 'Semantic'; const versioningStrategy = core.getInput('versioning') || 'Semantic';
const version = core.getInput('version') || ''; const specifiedVersion = core.getInput('version') || '';
const customParameters = core.getInput('customParameters') || ''; const customParameters = core.getInput('customParameters') || '';
// Sanitise input // Sanitise input
const projectPath = rawProjectPath.replace(/\/$/, ''); const projectPath = rawProjectPath.replace(/\/$/, '');
// Validate input // Parse input
if (!versioningStrategies.includes(versioning)) { const buildVersion = await Versioning.determineVersion(versioningStrategy, specifiedVersion);
throw new ValidationError(
`Versioning strategy should be one of ${versioningStrategies.join(', ')}.`,
);
}
// Return sanitised input // Return validated input
return { return {
unityVersion, version,
targetPlatform, targetPlatform,
projectPath, projectPath,
buildName, buildName,
buildsPath, buildsPath,
buildMethod, buildMethod,
versioning, buildVersion,
version,
customParameters, customParameters,
}; };
} }

View File

@ -1,13 +1,28 @@
import { mockDetermineVersion } from './__mocks__/versioning';
import Input from './input'; import Input from './input';
jest.restoreAllMocks();
jest.mock('./versioning');
beforeEach(() => {
mockDetermineVersion.mockClear();
});
describe('Input', () => { describe('Input', () => {
describe('getFromUser', () => { describe('getFromUser', () => {
it('does not throw', () => { it('does not throw', async () => {
expect(() => Input.getFromUser()).not.toThrow(); await expect(Input.getFromUser()).resolves.not.toBeNull();
}); });
it('returns an object', () => { it('returns an object', async () => {
expect(typeof Input.getFromUser()).toStrictEqual('object'); await expect(typeof (await Input.getFromUser())).toStrictEqual('object');
});
it.skip('calls version generator once', async () => {
await Input.getFromUser();
// Todo - make sure the versioning mock is actually hit after restoreAllMocks is used.
expect(mockDetermineVersion).toHaveBeenCalledTimes(1);
}); });
}); });
}); });

View File

@ -4,7 +4,7 @@ import Action from './action';
class Project { class Project {
static get relativePath() { static get relativePath() {
const { projectPath } = Input.getFromUser(); const projectPath = Input.getFromUser().then(result => result.projectPath);
return `${projectPath}`; return `${projectPath}`;
} }

26
src/model/system.js Normal file
View File

@ -0,0 +1,26 @@
import { exec } from '@actions/exec';
class System {
static async run(command, arguments_, options) {
let result = '';
let error = '';
const listeners = {
stdout: dataBuffer => {
result += dataBuffer.toString();
},
stderr: dataBuffer => {
error += dataBuffer.toString();
},
};
const exitCode = await exec(command, arguments_, { ...options, listeners });
if (exitCode !== 0) {
throw new Error(error);
}
return result;
}
}
export default System;

187
src/model/versioning.js Normal file
View File

@ -0,0 +1,187 @@
import * as core from '@actions/core';
import { exec } from '@actions/exec';
import NotImplementedException from './error/not-implemented-exception';
import ValidationError from './error/validation-error';
import System from './system';
export default class Versioning {
static get strategies() {
return { None: 'None', Semantic: 'Semantic', Tag: 'Tag', Custom: 'Custom' };
}
/**
* 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;
}
/**
* Regex to parse version description into separate fields
*/
static get descriptionRegex() {
return /^v([\d.]+)-(\d+)-g(\w+)-?(\w+)*/g;
}
static async determineVersion(strategy, inputVersion) {
// Validate input
if (!Object.hasOwnProperty.call(this.strategies, strategy)) {
throw new ValidationError(
`Versioning strategy should be one of ${Object.values(this.strategies).join(', ')}.`,
);
}
let version;
switch (strategy) {
case this.strategies.None:
version = 'none';
break;
case this.strategies.Custom:
version = inputVersion;
break;
case this.strategies.Semantic:
version = await this.generateSemanticVersion();
break;
case this.strategies.Tag:
version = await this.generateTagVersion();
break;
default:
throw new NotImplementedException(`Strategy ${strategy} is not implemented.`);
}
return version;
}
/**
* 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() {
await this.fetchAll();
if (await this.isDirty()) {
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 { tag, commits, hash } = await this.parseSemanticVersion();
core.info(`Found semantic version ${tag}.${commits} for ${this.branch}@${hash}`);
return `${tag}.${commits}`;
}
/**
* 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();
const [match, tag, commits, hash] = this.descriptionRegex.exec(description);
if (!match) {
throw new Error(`Failed to parse git describe output: "${description}"`);
}
return {
match,
tag,
commits,
hash,
};
}
static async fetchAll() {
await exec('git', ['fetch', '--all']);
}
/**
* 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() {
return System.run('git', ['describe', '--long', '--tags', '--always', `origin/${this.branch}`]);
}
/**
* Returns whether there are uncommitted changes that are not ignored.
*/
static async isDirty() {
const output = await System.run('git', ['status', '--porcelain']);
return output !== '';
}
/**
* Get the tag if there is one pointing at HEAD
*/
static async getTag() {
return System.run('git', ['tag', '--points-at', 'HEAD']);
}
/**
* Whether or not the repository has any version tags yet.
*/
static async hasAnyVersionTags() {
const numberOfVersionCommits = await System.run('git', [
'--list',
'--merged',
'HEAD',
'|',
'grep v[0-9]*',
'|',
'wc -l',
]);
return numberOfVersionCommits !== '0';
}
/**
* Get the total number of commits on head.
*/
static async getTotalNumberOfCommits() {
const numberOfCommitsAsString = await System.run('git', ['rev-list', '--count', 'HEAD']);
return parseInt(numberOfCommitsAsString, 10);
}
}