diff --git a/packages/cli/package.json b/packages/cli/package.json index 3edcf695b..71f8780da 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -52,6 +52,7 @@ "@auto-it/core": "link:../core", "@auto-it/npm": "link:../../plugins/npm", "@auto-it/released": "link:../../plugins/released", + "@auto-it/version-file": "link:../../plugins/version-file", "await-to-js": "^3.0.0", "chalk": "^4.0.0", "command-line-application": "^0.10.1", diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json index 0f4f9be91..c87d50c51 100644 --- a/packages/cli/tsconfig.json +++ b/packages/cli/tsconfig.json @@ -17,6 +17,9 @@ }, { "path": "../../plugins/released" + }, + { + "path": "../../plugins/version-file" } ] } diff --git a/plugins/version-file/README.md b/plugins/version-file/README.md new file mode 100644 index 000000000..2043ad867 --- /dev/null +++ b/plugins/version-file/README.md @@ -0,0 +1,39 @@ +# Version File Plugin + +For managing versions in a repository that maintains the version primarily in a flat file. +Agnostic to the primary language of the repository. +Optional input for a release script to call during the publish/canary/next hooks. + +## Installation + +This plugin is included with the `auto` CLI so you do not have to install it. To install if you are using the `auto` API directly: + +```bash +npm i --save-dev @auto-it/version-file +# or +yarn add -D @auto-it/version-file +``` + +## Options + +- versionFile (optional, default="VERSION"): Path to where the version is stored in the repository. It should be a file containing just the semver. +- releaseScript: (optional, default=None): Path to script that runs the publish actions in your repository. If not supplied nothing will be called. If supplied will be called during the `publish`,`canary` and `next` hooks. For the `publish` hook the first parameter passed to the script will be `release` to indicate that a regular release is being called. For `canary` and `next` hooks the first parameter will be `snapshot` to indicate a prerelease version. + +## Usage + +### With default options +```json +{ + "plugins": [ + "version-file" + // other plugins + ] +} +``` +### With optional arguments +```json +{ + "plugins": [ + "version-file", {"versionFile": "./tools/Version.txt", "releaseScript":"./tools/publish.sh"} + ] +} \ No newline at end of file diff --git a/plugins/version-file/__tests__/version-file.test.ts b/plugins/version-file/__tests__/version-file.test.ts new file mode 100644 index 000000000..003bf83a4 --- /dev/null +++ b/plugins/version-file/__tests__/version-file.test.ts @@ -0,0 +1,305 @@ +import Auto, { SEMVER } from '@auto-it/core'; +import mockFs from "mock-fs"; +import fs from "fs"; +import BazelPlugin from '../src'; +import { makeHooks } from '@auto-it/core/dist/utils/make-hooks'; +import { dummyLog } from "@auto-it/core/dist/utils/logger"; + +// Mocks +const execPromise = jest.fn(); +jest.mock( + "../../../packages/core/dist/utils/exec-promise", + () => (...args: any[]) => execPromise(...args) +); +jest.mock("../../../packages/core/dist/utils/get-current-branch", () => ({ + getCurrentBranch: () => "main", +})); + +beforeEach(() => { + execPromise.mockClear(); +}); +afterEach(() => { + mockFs.restore(); +}) + +describe('Version File Read Operations', () => { + test("It should return the value in the default file", async () => { + mockFs({ + "VERSION": `1.0.0`, + }); + const plugin = new BazelPlugin({}); + const hooks = makeHooks(); + + plugin.apply({ + hooks, + remote: "origin", + baseBranch: "main", + logger: dummyLog(), + } as Auto); + + expect(await hooks.getPreviousVersion.promise()).toBe("1.0.0"); + }); + + test("It should return the value in the specified file", async () => { + mockFs({ + "VERSIONFILE": `1.0.0`, + }); + const plugin = new BazelPlugin({versionFile: "VERSIONFILE"}); + const hooks = makeHooks(); + + plugin.apply({ + hooks, + remote: "origin", + baseBranch: "main", + logger: dummyLog(), + } as Auto); + + expect(await hooks.getPreviousVersion.promise()).toBe("1.0.0"); + }); +}); + +describe("Version File Write Operations", () => { + test("It should version the file properly for major releases", async () => { + mockFs({ + "VERSION": `1.0.0`, + }); + const plugin = new BazelPlugin({}); + const hooks = makeHooks(); + + plugin.apply({ + hooks, + remote: "origin", + baseBranch: "main", + logger: dummyLog(), + } as Auto); + + await hooks.version.promise({bump: SEMVER.major}) + expect(fs.readFileSync("VERSION", "utf-8")).toStrictEqual("2.0.0") + // check that the proper git operations were performed + expect(execPromise).toHaveBeenNthCalledWith(1, "git", ["commit", "-am", "\"Bump version to: v2.0.0 [skip ci]\""]); + expect(execPromise).toHaveBeenNthCalledWith(2, "git", ["tag", "v2.0.0"]); + }); + + test("It should version the file properly for minor releases", async () => { + mockFs({ + "VERSION": `1.0.0`, + }); + const plugin = new BazelPlugin({}); + const hooks = makeHooks(); + + plugin.apply({ + hooks, + remote: "origin", + baseBranch: "main", + logger: dummyLog(), + } as Auto); + + await hooks.version.promise({bump: SEMVER.minor}) + expect(fs.readFileSync("VERSION", "utf-8")).toStrictEqual("1.1.0"); + // check that the proper git operations were performed + expect(execPromise).toHaveBeenNthCalledWith(1, "git", ["commit", "-am", "\"Bump version to: v1.1.0 [skip ci]\""]); + expect(execPromise).toHaveBeenNthCalledWith(2, "git", ["tag", "v1.1.0"]); + }); + + test("It should version the file properly for patch releases", async () => { + mockFs({ + "VERSION": `1.0.0`, + }); + const plugin = new BazelPlugin({}); + const hooks = makeHooks(); + + plugin.apply({ + hooks, + remote: "origin", + baseBranch: "main", + logger: dummyLog(), + } as Auto); + + await hooks.version.promise({bump: SEMVER.patch}) + expect(fs.readFileSync("VERSION", "utf-8")).toStrictEqual("1.0.1"); + // check that the proper git operations were performed + expect(execPromise).toHaveBeenNthCalledWith(1, "git", ["commit", "-am", "\"Bump version to: v1.0.1 [skip ci]\""]); + expect(execPromise).toHaveBeenNthCalledWith(2, "git", ["tag", "v1.0.1"]); + }); +}) + +describe("Test Release Types", () => { + test("Full release with no release script", async () => { + mockFs({ + "VERSION": `1.0.0`, + }); + const plugin = new BazelPlugin({}); + const hooks = makeHooks(); + + plugin.apply({ + hooks, + remote: "origin", + baseBranch: "main", + logger: dummyLog(), + } as Auto); + + await hooks.publish.promise({bump: SEMVER.major}) + + // check release script was not called but check changes would be pushed + expect(execPromise).toHaveBeenNthCalledWith(1, "git", ["push", "origin", "main", "--tags"]); + }); + + test("Full release with release script", async () => { + mockFs({ + "VERSION": `1.0.0`, + }); + const plugin = new BazelPlugin({publishScript:"./tools/release.sh"}); + const hooks = makeHooks(); + + plugin.apply({ + hooks, + remote: "origin", + baseBranch: "main", + logger: dummyLog(), + } as Auto); + + await hooks.publish.promise({bump: SEMVER.major}) + + // check release script was called + expect(execPromise).toHaveBeenNthCalledWith(1, "./tools/release.sh", ["release"]); + + // check changes would be pushed + expect(execPromise).toHaveBeenNthCalledWith(2, "git", ["push", "origin", "main", "--tags"]); + }); + + test("Canary release with no release script", async () => { + mockFs({ + "VERSION": `1.0.0`, + }); + const plugin = new BazelPlugin({}); + const hooks = makeHooks(); + + plugin.apply(({ + hooks, + remote: "origin", + baseBranch: "main", + logger: dummyLog(), + getCurrentVersion: () => "1.0.0", + git: { + getLatestRelease: () => "1.0.0", + getLatestTagInBranch: () => Promise.resolve("1.0.0"), + }, + } as unknown) as Auto); + + await hooks.canary.promise({bump: SEMVER.minor, canaryIdentifier: "canary.368.1"}) + + // check release script was not called and local changes were reverted + expect(execPromise).toHaveBeenNthCalledWith(1, "git", ["reset", "--hard", "HEAD"]); + + // Check the right version was written + expect(fs.readFileSync("VERSION", "utf-8")).toStrictEqual("1.1.0-canary.368.1") + }); + + test("Canary release with release script", async () => { + mockFs({ + "VERSION": `1.0.0`, + }); + const plugin = new BazelPlugin({publishScript:"./tools/release.sh"}); + const hooks = makeHooks(); + + plugin.apply(({ + hooks, + remote: "origin", + baseBranch: "main", + logger: dummyLog(), + getCurrentVersion: () => "1.0.0", + git: { + getLatestRelease: () => "1.0.0", + getLatestTagInBranch: () => Promise.resolve("1.0.0"), + }, + } as unknown) as Auto); + + await hooks.canary.promise({bump: SEMVER.minor, canaryIdentifier: "canary.368.1"}) + + // check release script was called + expect(execPromise).toHaveBeenNthCalledWith(1, "./tools/release.sh", ["snapshot"]); + + // check local changes were reverted + expect(execPromise).toHaveBeenNthCalledWith(2, "git", ["reset", "--hard", "HEAD"]); + + // Check the right version was written + expect(fs.readFileSync("VERSION", "utf-8")).toStrictEqual("1.1.0-canary.368.1") + }); + + test("Next release with no release script", async () => { + + const prefixRelease: (a: string) => string = (version: string) => { + return `v${version}`; + }; + + mockFs({ + "VERSION": `1.0.0`, + }); + const plugin = new BazelPlugin({}); + const hooks = makeHooks(); + + plugin.apply(({ + hooks, + config: { prereleaseBranches: ["next"] }, + remote: "origin", + baseBranch: "main", + logger: dummyLog(), + prefixRelease, + getCurrentVersion: () => "1.0.0", + git: { + getLastTagNotInBaseBranch: async () => undefined, + getLatestRelease: () => "1.0.0", + getLatestTagInBranch: () => Promise.resolve("1.0.0"), + }, + } as unknown) as Auto); + + await hooks.next.promise(["1.0.0"], {bump: SEMVER.major, fullReleaseNotes:"", releaseNotes:"", commits:[]}) + + // check release script was not called but git ops were performed + expect(execPromise).toHaveBeenNthCalledWith(1, "git", ["tag", "v2.0.0-next.0"]); + expect(execPromise).toHaveBeenNthCalledWith(2, "git", ["push", "origin", "main", "--tags"]); + + // Check the right version was written + expect(fs.readFileSync("VERSION", "utf-8")).toStrictEqual("v2.0.0-next.0") + }); + + test("Next release with release script", async () => { + + const prefixRelease: (a: string) => string = (version: string) => { + return `v${version}`; + }; + + mockFs({ + "VERSION": `1.0.0`, + }); + const plugin = new BazelPlugin({publishScript:"./tools/release.sh"}); + const hooks = makeHooks(); + + plugin.apply(({ + hooks, + config: { prereleaseBranches: ["next"] }, + remote: "origin", + baseBranch: "main", + logger: dummyLog(), + prefixRelease, + getCurrentVersion: () => "1.0.0", + git: { + getLastTagNotInBaseBranch: async () => undefined, + getLatestRelease: () => "1.0.0", + getLatestTagInBranch: () => Promise.resolve("1.0.0"), + }, + } as unknown) as Auto); + + await hooks.next.promise(["1.0.0"], {bump: SEMVER.major, fullReleaseNotes:"", releaseNotes:"", commits:[]}) + + // check release script was called + expect(execPromise).toHaveBeenNthCalledWith(1, "./tools/release.sh", ["snapshot"]); + + // Check git ops + expect(execPromise).toHaveBeenNthCalledWith(2, "git", ["tag", "v2.0.0-next.0"]); + expect(execPromise).toHaveBeenNthCalledWith(3, "git", ["push", "origin", "main", "--tags"]); + + // Check the right version was written + expect(fs.readFileSync("VERSION", "utf-8")).toStrictEqual("v2.0.0-next.0") + }); +}); \ No newline at end of file diff --git a/plugins/version-file/package.json b/plugins/version-file/package.json new file mode 100644 index 000000000..5849757df --- /dev/null +++ b/plugins/version-file/package.json @@ -0,0 +1,45 @@ +{ + "name": "@auto-it/version-file", + "version": "10.32.2", + "main": "dist/index.js", + "description": "", + "license": "MIT", + "author": { + "name": "Ketan Reddy", + "email": "ketan@ketanreddy.com" + }, + "publishConfig": { + "registry": "https://registry.npmjs.org/", + "access": "public" + }, + "repository": { + "type": "git", + "url": "https://github.com/intuit/auto" + }, + "files": [ + "dist" + ], + "keywords": [ + "automation", + "semantic", + "release", + "github", + "labels", + "automated", + "continuos integration", + "changelog" + ], + "scripts": { + "build": "tsc -b", + "start": "npm run build -- -w", + "lint": "eslint src --ext .ts", + "test": "jest --maxWorkers=2 --config ../../package.json" + }, + "dependencies": { + "@auto-it/core": "link:../../packages/core", + "fp-ts": "^2.5.3", + "io-ts": "^2.1.2", + "tslib": "1.10.0", + "semver":"^7.0.0" + } +} diff --git a/plugins/version-file/src/index.ts b/plugins/version-file/src/index.ts new file mode 100644 index 000000000..87a16c74e --- /dev/null +++ b/plugins/version-file/src/index.ts @@ -0,0 +1,199 @@ +import { Auto, IPlugin, execPromise, validatePluginConfiguration, getCurrentBranch, determineNextVersion, DEFAULT_PRERELEASE_BRANCHES } from '@auto-it/core'; +import { promisify } from "util"; +import * as t from "io-ts"; +import * as fs from "fs"; +import { inc, ReleaseType } from "semver"; + +const readFile = promisify(fs.readFile); +const writeFile = promisify(fs.writeFile); + + +const pluginOptions = t.partial({ + /** Path to file (from where auto is executed) where the version is stored */ + versionFile: t.string, + + /** Optional script that executes release pipeline stages */ + publishScript: t.string +}); + +export type IVersionFilePluginOptions = t.TypeOf; + +/** + * Reads version file from location specified in config + */ +async function getPreviousVersion(auto: Auto, versionFile: string) { + auto.logger.veryVerbose.info(`Reading version from file `, versionFile) + return readFile(versionFile, "utf-8") +} + +/** Writes new version to version file at specified location */ +async function writeNewVersion(auto: Auto, version: string, versionFile: string) { + auto.logger.veryVerbose.info(`Writing version to file `, versionFile) + return writeFile(versionFile, version) +} + +/** Reset the scope changes of all the packages */ +async function gitReset(auto: Auto) { + auto.logger.veryVerbose.info("Hard resetting local changes") + await execPromise("git", ["reset", "--hard", "HEAD"]); +} + +/** Generates canary release notes */ +function makeCanaryNotes(canaryVersion: string){ + return `Try this version out locally by upgrading relevant packages to ${canaryVersion}` +} + + +/** Plugin to orchestrate releases in a repo where version is maintained in a flat file */ +export default class VersionFilePlugin implements IPlugin { + /** The name of the plugin */ + name = 'version-file'; + + /** Version file location */ + readonly versionFile: string; + + /** Release script location */ + readonly publishScript: string | undefined + + /** Initialize the plugin with it's options */ + constructor(options: IVersionFilePluginOptions) { + this.versionFile = options.versionFile ?? "VERSION"; + this.publishScript = options.publishScript + } + + + /** Tap into auto plugin points. */ + apply(auto: Auto) { + + const prereleaseBranches = + auto.config?.prereleaseBranches || DEFAULT_PRERELEASE_BRANCHES; + + const branch = getCurrentBranch(); + // if ran from baseBranch we publish the prerelease to the first + // configured prerelease branch + const prereleaseBranch = + branch && prereleaseBranches.includes(branch) + ? branch + : prereleaseBranches[0]; + + auto.hooks.validateConfig.tapPromise(this.name, async (name, options) => { + // If it's a string thats valid config + if (name === this.name && typeof options !== "string") { + return validatePluginConfiguration(this.name, pluginOptions, options); + } + }); + + auto.hooks.getPreviousVersion.tapPromise(this.name, () =>{ + return getPreviousVersion(auto, this.versionFile) + }); + + auto.hooks.version.tapPromise( this.name, async ({ bump }) => { + const lastVersion = await getPreviousVersion(auto, this.versionFile) + const newVersion = inc(lastVersion, bump as ReleaseType); + + auto.logger.log.info(`Calculated new version as: ${newVersion}`) + + if (newVersion){ + // Seal versions via commit and tag + await writeNewVersion(auto, newVersion, this.versionFile) + await execPromise("git", ["commit", "-am", `"Bump version to: v${newVersion} [skip ci]"`]); + await execPromise("git", [ + "tag", + `v${newVersion}` + ]); + auto.logger.verbose.info("Successfully versioned repo"); + } else { + auto.logger.log.error(`Error: Unable to calculate new version based off of ${lastVersion} being bumped with a ${bump} release`) + throw new Error ("Version bump failed") + } + }); + + auto.hooks.publish.tapPromise(this.name, async () => { + + // Call release script if provided + if(this.publishScript){ + auto.logger.log.info(`Calling release script in repo at ${this.publishScript}`); + await execPromise(this.publishScript, ["release"]) + } else { + auto.logger.log.info("Skipping calling release script in repo since none was provided"); + } + + // push tag and version change commit up + await execPromise("git", ["push", auto.remote, branch || auto.baseBranch, "--tags"]); + }); + + auto.hooks.canary.tapPromise(this.name, async ({ bump, canaryIdentifier}) => { + + // Figure out canary version + const lastRelease = + (await auto.git!.getLatestRelease()) || + (await auto.git?.getLastTagNotInBaseBranch(prereleaseBranch)) || + (await getPreviousVersion(auto, this.versionFile)); + const current = await auto.getCurrentVersion(lastRelease); + const nextVersion = inc(current, bump as ReleaseType); + const canaryVersion = `${nextVersion}-${canaryIdentifier}`; + + auto.logger.log.info(`Marking version as ${canaryVersion}`); + + // Write Canary version + await writeNewVersion(auto, canaryVersion, this.versionFile) + + // Ship canary release if release script is provided + if(this.publishScript){ + auto.logger.log.info(`Calling release script in repo at ${this.publishScript}`); + await execPromise(this.publishScript, ["snapshot"]); + } else { + auto.logger.log.info("Skipping calling release script in repo since none was provided"); + } + + + // Reset temporary canary versioning + await gitReset(auto); + + return { + newVersion: canaryVersion, + details: makeCanaryNotes(canaryVersion), + }; + }); + + auto.hooks.next.tapPromise(this.name, async (preReleaseVersions, { bump }) => { + + // Figure out next version + const lastRelease = await auto.git!.getLatestRelease(); + const latestTag = + (await auto.git?.getLastTagNotInBaseBranch(prereleaseBranch)) || + (await getPreviousVersion(auto, this.versionFile)); + const nextVersion = determineNextVersion( + lastRelease, + latestTag, + bump, + prereleaseBranch + ); + const prefixedVersion = auto.prefixRelease(nextVersion); + preReleaseVersions.push(prefixedVersion); + + auto.logger.log.info(`Marking version as ${prefixedVersion}`); + + // Write version to file + await writeNewVersion(auto, prefixedVersion, this.versionFile) + + // ship next release if release script is provided + if(this.publishScript){ + auto.logger.log.info(`Calling release script in repo at ${this.publishScript}`); + await execPromise(this.publishScript, ["snapshot"]); + } else { + auto.logger.log.info("Skipping calling release script in repo since none was provided"); + } + + // Push next tag + await execPromise("git", [ + "tag", + prefixedVersion + ]); + await execPromise("git", ["push", auto.remote, branch, "--tags"]); + + return preReleaseVersions + }); + + } +} diff --git a/plugins/version-file/tsconfig.json b/plugins/version-file/tsconfig.json new file mode 100644 index 000000000..bfbef6fc7 --- /dev/null +++ b/plugins/version-file/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "../../tsconfig.json", + "include": ["src/**/*", "../../typings/**/*"], + + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "composite": true + }, + + "references": [ + { + "path": "../../packages/core" + } + ] +} diff --git a/tsconfig.dev.json b/tsconfig.dev.json index 827e03a84..027fb450a 100644 --- a/tsconfig.dev.json +++ b/tsconfig.dev.json @@ -91,6 +91,9 @@ }, { "path": "plugins/sbt" + }, + { + "path": "plugins/version-file" } ] } \ No newline at end of file