diff --git a/README.md b/README.md index 264c30a31..fc1f73976 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,7 @@ Auto has an extensive plugin system and wide variety of official plugins. Make a - [gradle](./plugins/gradle) - Publish code with gradle - [maven](./plugins/maven) - Publish code with maven - [npm](./plugins/npm) - Publish code to npm (`default` when installed through `npm`) +- [sbt](./plugins/sbt) - Publish Scala projects with [sbt](https://www.scala-sbt.org) - [vscode](./plugins/vscode) - Publish code to the VSCode extension marketplace **Extra Functionality:** diff --git a/docs/pages/docs/_sidebar.mdx b/docs/pages/docs/_sidebar.mdx index 114c57ac7..5e8285887 100644 --- a/docs/pages/docs/_sidebar.mdx +++ b/docs/pages/docs/_sidebar.mdx @@ -55,6 +55,7 @@ Package Manager Plugins - [Gradle](./generated/gradle) - [Maven](./generated/maven) - [NPM](./generated/npm) +- [sbt](./generated/sbt) - [VSCode](./generated/vscode) Functionality Plugins diff --git a/plugins/sbt/README.md b/plugins/sbt/README.md new file mode 100644 index 000000000..c501edd79 --- /dev/null +++ b/plugins/sbt/README.md @@ -0,0 +1,92 @@ +# sbt plugin + +Publish Scala projects with [sbt](https://www.scala-sbt.org) + +> :warning: only sbt 1.4+ is supported at the moment because this plugin uses `sbt --client` functionality + +## Installation + +This plugin is not included with the `auto` CLI installed via NPM. To install: + +```bash +npm i --save-dev @auto-it/sbt +# or +yarn add -D @auto-it/sbt +``` + +## Usage + +```json +{ + "plugins": [ + "sbt" + ] +} +``` + +It is strongly recommended to use an sbt plugin to manage the version. There are a few options, but the most reliable and well maintained is [sbt-dynver](https://github.com/dwijnand/sbt-dynver). To enable it in your project add this line to `project/plugins.sbt`: + +```scala +addSbtPlugin("com.dwijnand" % "sbt-dynver" % "x.y.z") +``` + +and then, depending on the publishing repository (e.g. if you are publishing to Sonatype Nexus), you might want to add + +```scala +ThisBuild / dynverSeparator := "-" +ThisBuild / dynverSonatypeSnapshots := true +``` + +to your `build.sbt`. + +With this setup canary versions will look like this: `{last_tag}-{number_of_commits}-{commit_sha}-SNAPSHOT`, for example: + +``` +0.1.2-5-fcdf268c-SNAPSHOT +``` + +## Options + +### `setCanaryVersion: boolean` (default: `false`) + +If you don't want to use an sbt plugin for version management, you can let Auto manage the canary version: + +```json +{ + "plugins": [ + [ + "sbt", + { + "setCanaryVersion": true + } + ] + ] +} +``` + +With this option Auto will override the version in sbt during canary release process. + +Canary versions will look like this: `{last_tag}-canary.{pr_number}.{build_number}-SNAPSHOT`, for example: + +``` +0.1.2-canary.47.5fa1736-SNAPSHOT +``` + +Here build number is the git commit SHA. + +### `publishCommand: string` (default: `publish`) + +If you need to run some custom publishing command, you can change this option. For example, to cross-publish a library: + +```json +{ + "plugins": [ + [ + "sbt", + { + "publishCommand": "+publish" + } + ] + ] +} +``` diff --git a/plugins/sbt/__tests__/sbt.test.ts b/plugins/sbt/__tests__/sbt.test.ts new file mode 100644 index 000000000..eec2fac39 --- /dev/null +++ b/plugins/sbt/__tests__/sbt.test.ts @@ -0,0 +1,213 @@ +import * as Auto from "@auto-it/core"; +import { dummyLog } from "@auto-it/core/dist/utils/logger"; +import { makeHooks } from "@auto-it/core/dist/utils/make-hooks"; +import SbtPlugin, { ISbtPluginOptions, sbtClient, sbtGetVersion } from "../src"; + +const exec = jest.fn(); + +jest.mock( + "../../../packages/core/dist/utils/exec-promise", + () => (...args: any[]) => exec(...args), +); + +const rawOutput = + `[info] entering *experimental* thin client - BEEP WHIRR +[info] terminate the server with \`shutdown\` +> print version +1.2.3 +[success] Total time: 2 s, completed Apr 27, 2021 3:39:23 AM +`; + +const cleanedOutput = `1.2.3 +[success] Total time: 2 s, completed Apr 27, 2021 3:39:23 AM`; + +const rawAggregationOutput = + `[info] entering *experimental* thin client - BEEP WHIRR +[info] terminate the server with \`shutdown\` +> set version/aggregate := false +[info] Defining version / aggregate +[info] The new value will be used by no settings or tasks. +[info] Reapplying settings... +[info] set current project to auto-release-test-scala (in build file:/Users/user/project/) +[success] Total time: 2 s, completed Apr 27, 2021 3:52:04 AM +v`; + +describe("sbt plugin", () => { + let hooks: Auto.IAutoHooks; + const prefixRelease: (a: string) => string = jest.fn( + (version) => `v${version}`, + ); + const options: ISbtPluginOptions = {}; + const logger = dummyLog(); + + const setup = (options: ISbtPluginOptions) => { + const plugin = new SbtPlugin(options); + hooks = makeHooks(); + plugin.apply( + ({ + hooks, + logger, + remote: "stubRemote", + prefixRelease, + git: { + getLastTagNotInBaseBranch: async () => undefined, + getLatestRelease: async () => "0.0.1", + }, + getCurrentVersion: async () => "0.0.1", + } as unknown) as Auto.Auto, + ); + }; + + beforeEach(() => { + exec.mockClear(); + setup(options); + }); + + describe("sbt client", () => { + test("should clean output", async () => { + exec.mockReturnValueOnce(rawOutput); + const output = await sbtClient(""); + expect(output).toBe(cleanedOutput); + }); + + test("should parse version value", async () => { + exec + .mockReturnValueOnce(rawAggregationOutput) + .mockReturnValueOnce(rawOutput); + const output = await sbtGetVersion(); + expect(output).toBe("1.2.3"); + }); + + test("should error if it can't parse version value", async () => { + exec + .mockReturnValueOnce(rawAggregationOutput) + .mockReturnValueOnce(""); + await expect(sbtGetVersion()).rejects.toThrowError( + `Failed to read version from sbt: `, + ); + }); + }); + + describe("version hook", () => { + test("should set version in sbt", async () => { + exec.mockReturnValue(""); + + await hooks.version.promise({ + bump: Auto.SEMVER.minor, + }); + expect(exec).toHaveBeenCalledTimes(2); + expect(exec).lastCalledWith("sbt", [ + "--client", + 'set every version := \\"0.1.0\\"', + ]); + }); + }); + + describe("publish hook", () => { + test("should call sbt publish", async () => { + exec.mockReturnValue(""); + + await hooks.publish.promise({ + bump: Auto.SEMVER.minor, + }); + + expect(exec).toHaveBeenCalledWith("sbt", [ + "--client", + "publish", + ]); + }); + + test("should call sbt publish with custom command", async () => { + setup({ + publishCommand: "+publishLocal", + }); + exec.mockReturnValue(""); + + await hooks.publish.promise({ + bump: Auto.SEMVER.minor, + }); + + expect(exec).toHaveBeenCalledWith("sbt", [ + "--client", + "+publishLocal", + ]); + }); + }); + + describe("canary hook", () => { + test("should only read version from sbt on dry run", async () => { + exec + .mockReturnValueOnce(rawAggregationOutput) + .mockReturnValueOnce(rawOutput); + + await hooks.canary.promise({ + bump: Auto.SEMVER.minor, + canaryIdentifier: "-canary.42.1", + dryRun: true, + }); + + expect(exec).toHaveBeenCalledTimes(2); // 2 calls in sbtGetVersion + }); + + test("should return version from sbt as canary", async () => { + exec.mockReturnValue(rawOutput); + + const result = await hooks.canary.promise({ + bump: Auto.SEMVER.minor, + canaryIdentifier: "-canary.42.1", + }); + + expect(exec).not.toHaveBeenCalledWith("sbt", [ + "--client", + 'set every version := \\"0.1.0\\"', + ]); + + expect(result).toMatchObject({ + newVersion: "1.2.3", + details: [ + "```", + cleanedOutput, + "```", + ].join("\n"), + }); + }); + + test("should construct canary version when configured", async () => { + setup({ + setCanaryVersion: true, + }); + exec.mockReturnValue(rawOutput); + + const result = await hooks.canary.promise({ + bump: Auto.SEMVER.minor, + canaryIdentifier: "-canary.42.1", + }); + + const newVersion = "0.0.0-canary.42.1-SNAPSHOT"; + + expect(exec).toHaveBeenCalledWith("sbt", [ + "--client", + `set every version := \\"${newVersion}\\"`, + ]); + + expect(result).toMatchObject({ newVersion }); + }); + + test("should call sbt publish with custom command", async () => { + setup({ + publishCommand: "+publishLocal", + }); + exec.mockReturnValue(rawOutput); + + await hooks.canary.promise({ + bump: Auto.SEMVER.minor, + canaryIdentifier: "-canary.42.1", + }); + + expect(exec).toHaveBeenCalledWith("sbt", [ + "--client", + "+publishLocal", + ]); + }); + }); +}); diff --git a/plugins/sbt/package.json b/plugins/sbt/package.json new file mode 100644 index 000000000..6636f5824 --- /dev/null +++ b/plugins/sbt/package.json @@ -0,0 +1,48 @@ +{ + "name": "@auto-it/sbt", + "version": "10.25.1", + "main": "dist/index.js", + "description": "Publish Scala projects with sbt", + "license": "MIT", + "author": { + "name": "Alexey Alekhin", + "email": "laughedelic@gmail.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", + "scala", + "sbt" + ], + "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", + "semver": "^7.0.0", + "strip-ansi": "^6.0.0", + "tslib": "1.10.0" + } +} diff --git a/plugins/sbt/src/index.ts b/plugins/sbt/src/index.ts new file mode 100644 index 000000000..faf8e1989 --- /dev/null +++ b/plugins/sbt/src/index.ts @@ -0,0 +1,198 @@ +import { + Auto, + execPromise, + getCurrentBranch, + IPlugin, + validatePluginConfiguration, +} from "@auto-it/core"; +import { inc, ReleaseType } from "semver"; +import * as t from "io-ts"; +import stripAnsi from "strip-ansi"; + +const pluginOptions = t.partial({ + setCanaryVersion: t.boolean, + publishCommand: t.string, +}); + +export type ISbtPluginOptions = t.TypeOf; + +/** Calls sbt in the client and returns cleaned up logs */ +export async function sbtClient(input: string): Promise { + const output = await execPromise("sbt", ["--client", input]); + const cleanOutput = stripAnsi(output).replace(/(.*\n)*^>.*$/m, "").trim(); + return cleanOutput; +} + +/** Read version from sbt */ +export async function sbtGetVersion(): Promise { + // in multi-module projects, we want to get only ThisBuild/version + await sbtClient("set version/aggregate := false"); + const output = await sbtClient("print version"); + const version = output.split("\n").shift()?.trim(); + if (!version) { + throw new Error(`Failed to read version from sbt: ${output}`); + } + + return version; +} + +/** Set version in sbt to the given value */ +export async function sbtSetVersion(version: string): Promise { + return sbtClient(`set every version := \\"${version}\\"`); +} + +/** Run sbt publish */ +export async function sbtPublish(command?: string): Promise { + return sbtClient(command || "publish"); +} + +/** Publish Scala projects with sbt */ +export default class SbtPlugin implements IPlugin { + /** The name of the plugin */ + name = "sbt"; + + /** The options of the plugin */ + readonly options: ISbtPluginOptions; + + /** Initialize the plugin with it's options */ + constructor(options: ISbtPluginOptions) { + this.options = options; + } + + /** Tap into auto plugin points. */ + apply(auto: Auto) { + // exact copy-paste from the git-tag plugin + /** Get the latest tag in the repo, if none then the first commit */ + async function getTag() { + try { + return await auto.git!.getLatestTagInBranch(); + } catch (error) { + return auto.prefixRelease("0.0.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); + } + }); + + // exact copy-paste from the git-tag plugin + auto.hooks.getPreviousVersion.tapPromise(this.name, async () => { + if (!auto.git) { + throw new Error( + "Can't calculate previous version without Git initialized!", + ); + } + + return getTag(); + }); + + // exact copy-paste from the git-tag plugin + auto.hooks.version.tapPromise( + this.name, + async ({ bump, dryRun, quiet }) => { + if (!auto.git) { + return; + } + + const lastTag = await getTag(); + const newTag = inc(lastTag, bump as ReleaseType); + + if (dryRun && newTag) { + if (quiet) { + console.log(newTag); + } else { + auto.logger.log.info(`Would have published: ${newTag}`); + } + + return; + } + + if (!newTag) { + auto.logger.log.info("No release found, doing nothing"); + return; + } + + const prefixedTag = auto.prefixRelease(newTag); + + auto.logger.log.info(`Tagging new tag: ${lastTag} => ${prefixedTag}`); + await execPromise("git", [ + "tag", + prefixedTag, + "-m", + `"Update version to ${prefixedTag}"`, + ]); + + auto.logger.verbose.info(`Set version in sbt to "${newTag}"`); + await sbtSetVersion(newTag); + }, + ); + + auto.hooks.publish.tapPromise(this.name, async () => { + auto.logger.verbose.info("Run sbt publish"); + const publishLogs = await sbtPublish(this.options.publishCommand); + auto.logger.verbose.info("Output:\n", publishLogs); + + auto.logger.log.info("Pushing new tag to GitHub"); + await execPromise("git", [ + "push", + "--follow-tags", + "--set-upstream", + auto.remote, + getCurrentBranch() || auto.baseBranch, + ]); + }); + + auto.hooks.canary.tapPromise( + this.name, + async ({ canaryIdentifier, dryRun, quiet }) => { + if (!auto.git) { + return; + } + + /** Construct canary version using Auto-provided suffix */ + const constructCanaryVersion = async () => { + const lastTag = await getTag(); + const lastVersion = lastTag.replace(/^v/, ""); + return `${lastVersion}${canaryIdentifier}-SNAPSHOT`; + }; + + const canaryVersion = this.options.setCanaryVersion + ? await constructCanaryVersion() + : await sbtGetVersion(); + auto.logger.log.info(`Canary version: ${canaryVersion}`); + + if (dryRun) { + if (quiet) { + console.log(canaryVersion); + } else { + auto.logger.log.info(`Would have published: ${canaryVersion}`); + } + + return; + } + + if (this.options.setCanaryVersion) { + auto.logger.verbose.info(`Set version in sbt to "${canaryVersion}"`); + await sbtSetVersion(canaryVersion); + } + + auto.logger.verbose.info("Run sbt publish"); + const publishLogs = await sbtPublish(this.options.publishCommand); + auto.logger.verbose.info("Output:\n", publishLogs); + + auto.logger.verbose.info("Successfully published canary version"); + return { + newVersion: canaryVersion, + details: [ + "```", + publishLogs, + "```", + ].join("\n"), + }; + }, + ); + } +} diff --git a/plugins/sbt/tsconfig.json b/plugins/sbt/tsconfig.json new file mode 100644 index 000000000..bfbef6fc7 --- /dev/null +++ b/plugins/sbt/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 72275a469..827e03a84 100644 --- a/tsconfig.dev.json +++ b/tsconfig.dev.json @@ -88,6 +88,9 @@ }, { "path": "plugins/s3" + }, + { + "path": "plugins/sbt" } ] -} +} \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index 5cf7f1064..9ac9c14f8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -69,10 +69,10 @@ integrity sha512-TYiuOxy5Pf9ORn94X/ujl7PY9opIh+l6NzRAV8EBLpIv3IC9gmEoev4wmmyP7Q33J0/nGjqxAaZcq/n2SZrYaQ== "@auto-it/bot-list@link:packages/bot-list": - version "10.23.0" + version "10.25.1" "@auto-it/core@link:packages/core": - version "10.23.0" + version "10.25.1" dependencies: "@auto-it/bot-list" "link:packages/bot-list" "@endemolshinegroup/cosmiconfig-typescript-loader" "^3.0.2" @@ -102,6 +102,7 @@ parse-author "^2.0.0" parse-github-url "1.0.2" pretty-ms "^7.0.0" + requireg "^0.2.2" semver "^7.0.0" signale "^1.4.0" tapable "^2.0.0-beta.2" @@ -114,7 +115,7 @@ url-join "^4.0.0" "@auto-it/npm@link:plugins/npm": - version "10.23.0" + version "10.25.1" dependencies: "@auto-it/core" "link:packages/core" "@auto-it/package-json-utils" "link:packages/package-json-utils" @@ -132,13 +133,13 @@ user-home "^2.0.0" "@auto-it/package-json-utils@link:packages/package-json-utils": - version "10.23.0" + version "10.25.1" dependencies: parse-author "^2.0.0" parse-github-url "1.0.2" "@auto-it/released@link:plugins/released": - version "10.23.0" + version "10.25.1" dependencies: "@auto-it/bot-list" "link:packages/bot-list" "@auto-it/core" "link:packages/core"