diff options
| author | 2023-04-28 01:36:44 +0800 | |
|---|---|---|
| committer | 2023-04-28 01:36:44 +0800 | |
| commit | dd84b9d64fb98746a230cd24233ff50a562c39c9 (patch) | |
| tree | b583261ef00b3afe72ec4d6dacb31e57779a6faf /packages/turbo-ignore/src | |
| parent | 0b46fcd72ac34382387b2bcf9095233efbcc52f4 (diff) | |
| download | HydroRoll-dd84b9d64fb98746a230cd24233ff50a562c39c9.tar.gz HydroRoll-dd84b9d64fb98746a230cd24233ff50a562c39c9.zip | |
Diffstat (limited to 'packages/turbo-ignore/src')
| -rw-r--r-- | packages/turbo-ignore/src/args.ts | 89 | ||||
| -rw-r--r-- | packages/turbo-ignore/src/checkCommit.ts | 104 | ||||
| -rw-r--r-- | packages/turbo-ignore/src/errors.ts | 43 | ||||
| -rw-r--r-- | packages/turbo-ignore/src/getComparison.ts | 39 | ||||
| -rw-r--r-- | packages/turbo-ignore/src/getTask.ts | 13 | ||||
| -rw-r--r-- | packages/turbo-ignore/src/getWorkspace.ts | 37 | ||||
| -rw-r--r-- | packages/turbo-ignore/src/ignore.ts | 125 | ||||
| -rw-r--r-- | packages/turbo-ignore/src/index.ts | 6 | ||||
| -rw-r--r-- | packages/turbo-ignore/src/logger.ts | 16 | ||||
| -rw-r--r-- | packages/turbo-ignore/src/types.ts | 23 |
10 files changed, 495 insertions, 0 deletions
diff --git a/packages/turbo-ignore/src/args.ts b/packages/turbo-ignore/src/args.ts new file mode 100644 index 0000000..8d6015c --- /dev/null +++ b/packages/turbo-ignore/src/args.ts @@ -0,0 +1,89 @@ +import pkg from "../package.json"; +import { TurboIgnoreArgs } from "./types"; +import { + skipAllCommits, + forceAllCommits, + skipWorkspaceCommits, + forceWorkspaceCommits, +} from "./checkCommit"; + +export const help = ` +turbo-ignore + +Automatically ignore builds that have no changes + +Usage: + $ npx turbo-ignore [<workspace>] [flags...] + +If <workspace> is not provided, it will be inferred from the "name" +field of the "package.json" located at the current working directory. + +Flags: + --fallback=<ref> On Vercel, if no previously deployed SHA is available to compare against, + fallback to comparing against the provided ref + --help, -h Show this help message + --version, -v Show the version of this script + +--- + +turbo-ignore will also check for special commit messages to indicate if a build should be skipped or not. + +Skip turbo-ignore check and automatically ignore: +${[...skipAllCommits, ...skipWorkspaceCommits({ workspace: "<workspace>" })] + .map((msg) => ` - ${msg}`) + .join("\n")} + +Skip turbo-ignore check and automatically deploy: +${[...forceAllCommits, ...forceWorkspaceCommits({ workspace: "<workspace>" })] + .map((msg) => ` - ${msg}`) + .join("\n")} +`; + +// simple args parser because we don't want to pull in a dependency +// and we don't need many features +export default function parseArgs({ + argv, +}: { + argv: Array<string>; +}): TurboIgnoreArgs { + const args: TurboIgnoreArgs = { directory: process.cwd() }; + + // find all flags + const flags = new Set( + argv + .filter((args) => args.startsWith("-")) + .map((flag) => flag.replace(/-/g, "")) + ); + + // handle help flag and exit + if (flags.has("help") || flags.has("h")) { + console.log(help); + process.exit(0); + } + // handle version flag and exit + if (flags.has("version") || flags.has("v")) { + console.log(pkg.version); + process.exit(0); + } + + // set workspace (if provided) + if (argv.length && !argv[0].startsWith("-")) { + args.workspace = argv[0]; + } + + // set task (if provided) + const taskArgSentinel = "--task="; + const taskArg = argv.find((arg) => arg.startsWith(taskArgSentinel)); + if (taskArg && taskArg.length > taskArgSentinel.length) { + args.task = taskArg.split("=")[1]; + } + + // set fallback (if provided) + const fallbackSentinel = "--fallback="; + const fallbackArg = argv.find((arg) => arg.startsWith(fallbackSentinel)); + if (fallbackArg && fallbackArg.length > fallbackSentinel.length) { + args.fallback = fallbackArg.split("=")[1]; + } + + return args; +} diff --git a/packages/turbo-ignore/src/checkCommit.ts b/packages/turbo-ignore/src/checkCommit.ts new file mode 100644 index 0000000..af6108e --- /dev/null +++ b/packages/turbo-ignore/src/checkCommit.ts @@ -0,0 +1,104 @@ +import { execSync } from "child_process"; + +export const skipAllCommits = [ + `[skip ci]`, + `[ci skip]`, + `[no ci]`, + `[skip vercel]`, + `[vercel skip]`, +]; + +export const forceAllCommits = [`[vercel deploy]`, `[vercel build]`]; + +export function skipWorkspaceCommits({ workspace }: { workspace: string }) { + return [`[vercel skip ${workspace}]`]; +} + +export function forceWorkspaceCommits({ workspace }: { workspace: string }) { + return [`[vercel deploy ${workspace}]`, `[vercel build ${workspace}]`]; +} + +export function getCommitDetails() { + // if we're on Vercel, use the provided commit message + if (process.env.VERCEL === "1") { + if (process.env.VERCEL_GIT_COMMIT_MESSAGE) { + return process.env.VERCEL_GIT_COMMIT_MESSAGE; + } + } + return execSync("git show -s --format=%B").toString(); +} + +export function checkCommit({ workspace }: { workspace: string }): { + result: "skip" | "deploy" | "continue" | "conflict"; + scope: "global" | "workspace"; + reason: string; +} { + const commitMessage = getCommitDetails(); + const findInCommit = (commit: string) => commitMessage.includes(commit); + + // check workspace specific messages first + const forceWorkspaceDeploy = forceWorkspaceCommits({ workspace }).find( + findInCommit + ); + const forceWorkspaceSkip = skipWorkspaceCommits({ workspace }).find( + findInCommit + ); + + if (forceWorkspaceDeploy && forceWorkspaceSkip) { + return { + result: "conflict", + scope: "workspace", + reason: `Conflicting commit messages found: ${forceWorkspaceDeploy} and ${forceWorkspaceSkip}`, + }; + } + + if (forceWorkspaceDeploy) { + return { + result: "deploy", + scope: "workspace", + reason: `Found commit message: ${forceWorkspaceDeploy}`, + }; + } + + if (forceWorkspaceSkip) { + return { + result: "skip", + scope: "workspace", + reason: `Found commit message: ${forceWorkspaceSkip}`, + }; + } + + // check global messages last + const forceDeploy = forceAllCommits.find(findInCommit); + const forceSkip = skipAllCommits.find(findInCommit); + + if (forceDeploy && forceSkip) { + return { + result: "conflict", + scope: "global", + reason: `Conflicting commit messages found: ${forceDeploy} and ${forceSkip}`, + }; + } + + if (forceDeploy) { + return { + result: "deploy", + scope: "global", + reason: `Found commit message: ${forceDeploy}`, + }; + } + + if (forceSkip) { + return { + result: "skip", + scope: "global", + reason: `Found commit message: ${forceSkip}`, + }; + } + + return { + result: "continue", + scope: "global", + reason: `No deploy or skip string found in commit message.`, + }; +} diff --git a/packages/turbo-ignore/src/errors.ts b/packages/turbo-ignore/src/errors.ts new file mode 100644 index 0000000..f600dfb --- /dev/null +++ b/packages/turbo-ignore/src/errors.ts @@ -0,0 +1,43 @@ +import { NonFatalErrorKey, NonFatalErrors } from "./types"; + +export const NON_FATAL_ERRORS: NonFatalErrors = { + MISSING_LOCKFILE: { + regex: + /reading (yarn.lock|package-lock.json|pnpm-lock.yaml):.*?no such file or directory/, + message: `turbo-ignore could not complete - no lockfile found, please commit one to your repository`, + }, + NO_PACKAGE_MANAGER: { + regex: + /run failed: We did not detect an in-use package manager for your project/, + message: `turbo-ignore could not complete - no package manager detected, please commit a lockfile, or set "packageManager" in your root "package.json"`, + }, + UNREACHABLE_PARENT: { + regex: /failed to resolve packages to run: commit HEAD\^ does not exist/, + message: `turbo-ignore could not complete - parent commit does not exist or is unreachable`, + }, + UNREACHABLE_COMMIT: { + regex: /commit \S+ does not exist/, + message: `turbo-ignore could not complete - commit does not exist or is unreachable`, + }, +}; + +export function shouldWarn({ err }: { err: string }): { + level: "warn" | "error"; + message: string; + code: NonFatalErrorKey | "UNKNOWN_ERROR"; +} { + const knownError = Object.keys(NON_FATAL_ERRORS).find((key) => { + const { regex } = NON_FATAL_ERRORS[key as NonFatalErrorKey]; + return regex.test(err); + }); + + if (knownError) { + return { + level: "warn", + message: NON_FATAL_ERRORS[knownError as NonFatalErrorKey].message, + code: knownError as NonFatalErrorKey, + }; + } + + return { level: "error", message: err, code: "UNKNOWN_ERROR" }; +} diff --git a/packages/turbo-ignore/src/getComparison.ts b/packages/turbo-ignore/src/getComparison.ts new file mode 100644 index 0000000..a2ad61a --- /dev/null +++ b/packages/turbo-ignore/src/getComparison.ts @@ -0,0 +1,39 @@ +import { info } from "./logger"; +import { TurboIgnoreArgs } from "./types"; + +export interface GetComparisonArgs extends TurboIgnoreArgs { + // the workspace to check for changes + workspace: string; + // A ref/head to compare against if no previously deployed SHA is available + fallback?: string; +} + +export function getComparison(args: GetComparisonArgs): { + ref: string; + type: "previousDeploy" | "headRelative" | "customFallback"; +} | null { + const { fallback, workspace } = args; + if (process.env.VERCEL === "1") { + if (process.env.VERCEL_GIT_PREVIOUS_SHA) { + // use the commit SHA of the last successful deployment for this project / branch + info( + `Found previous deployment ("${process.env.VERCEL_GIT_PREVIOUS_SHA}") for "${workspace}" on branch "${process.env.VERCEL_GIT_COMMIT_REF}"` + ); + return { + ref: process.env.VERCEL_GIT_PREVIOUS_SHA, + type: "previousDeploy", + }; + } else { + info( + `No previous deployments found for "${workspace}" on branch "${process.env.VERCEL_GIT_COMMIT_REF}".` + ); + if (fallback) { + info(`Falling back to ref ${fallback}`); + return { ref: fallback, type: "customFallback" }; + } + + return null; + } + } + return { ref: "HEAD^", type: "headRelative" }; +} diff --git a/packages/turbo-ignore/src/getTask.ts b/packages/turbo-ignore/src/getTask.ts new file mode 100644 index 0000000..9e95e35 --- /dev/null +++ b/packages/turbo-ignore/src/getTask.ts @@ -0,0 +1,13 @@ +import { info } from "./logger"; +import { TurboIgnoreArgs } from "./types"; + +export function getTask(args: TurboIgnoreArgs): string | null { + if (args.task) { + info(`Using "${args.task}" as the task from the arguments`); + return `"${args.task}"`; + } + + info('Using "build" as the task as it was unspecified'); + + return "build"; +} diff --git a/packages/turbo-ignore/src/getWorkspace.ts b/packages/turbo-ignore/src/getWorkspace.ts new file mode 100644 index 0000000..e0b3167 --- /dev/null +++ b/packages/turbo-ignore/src/getWorkspace.ts @@ -0,0 +1,37 @@ +import fs from "fs"; +import path from "path"; +import { error, info } from "./logger"; +import { TurboIgnoreArgs } from "./types"; + +export function getWorkspace(args: TurboIgnoreArgs): string | null { + const { directory = process.cwd(), workspace } = args; + + // if the workspace is provided via args, use that + if (workspace) { + info(`Using "${workspace}" as workspace from arguments`); + return workspace; + } + + // otherwise, try and infer it from a package.json in the current directory + const packageJsonPath = path.join(directory, "package.json"); + try { + const raw = fs.readFileSync(packageJsonPath, "utf8"); + const packageJsonContent: Record<string, string> & { name: string } = + JSON.parse(raw); + + if (!packageJsonContent.name) { + error(`"${packageJsonPath}" is missing the "name" field (required).`); + return null; + } + + info( + `Inferred "${packageJsonContent.name}" as workspace from "package.json"` + ); + return packageJsonContent.name; + } catch (e) { + error( + `"${packageJsonPath}" could not be found. turbo-ignore inferencing failed` + ); + return null; + } +} diff --git a/packages/turbo-ignore/src/ignore.ts b/packages/turbo-ignore/src/ignore.ts new file mode 100644 index 0000000..a6f8f2e --- /dev/null +++ b/packages/turbo-ignore/src/ignore.ts @@ -0,0 +1,125 @@ +import { exec } from "child_process"; +import path from "path"; +import { getTurboRoot } from "@turbo/utils"; +import { getComparison } from "./getComparison"; +import { getTask } from "./getTask"; +import { getWorkspace } from "./getWorkspace"; +import { info, warn, error } from "./logger"; +import { shouldWarn } from "./errors"; +import { TurboIgnoreArgs } from "./types"; +import { checkCommit } from "./checkCommit"; + +function ignoreBuild() { + console.log("⏭ Ignoring the change"); + return process.exit(0); +} + +function continueBuild() { + console.log("✓ Proceeding with deployment"); + return process.exit(1); +} + +export default function turboIgnore({ args }: { args: TurboIgnoreArgs }) { + info( + `Using Turborepo to determine if this project is affected by the commit...\n` + ); + + // set default directory + args.directory = args.directory + ? path.resolve(args.directory) + : process.cwd(); + + // check for TURBO_FORCE and bail early if it's set + if (process.env.TURBO_FORCE === "true") { + info("`TURBO_FORCE` detected"); + return continueBuild(); + } + + // find the monorepo root + const root = getTurboRoot(args.directory); + if (!root) { + error("Monorepo root not found. turbo-ignore inferencing failed"); + return continueBuild(); + } + + // Find the workspace from the command-line args, or the package.json at the current directory + const workspace = getWorkspace(args); + if (!workspace) { + return continueBuild(); + } + + // Identify which task to execute from the command-line args + let task = getTask(args); + + // check the commit message + const parsedCommit = checkCommit({ workspace }); + if (parsedCommit.result === "skip") { + info(parsedCommit.reason); + return ignoreBuild(); + } + if (parsedCommit.result === "deploy") { + info(parsedCommit.reason); + return continueBuild(); + } + if (parsedCommit.result === "conflict") { + info(parsedCommit.reason); + } + + // Get the start of the comparison (previous deployment when available, or previous commit by default) + const comparison = getComparison({ workspace, fallback: args.fallback }); + if (!comparison) { + // This is either the first deploy of the project, or the first deploy for the branch, either way - build it. + return continueBuild(); + } + + // Build, and execute the command + const command = `npx turbo run ${task} --filter=${workspace}...[${comparison.ref}] --dry=json`; + info(`Analyzing results of \`${command}\``); + exec( + command, + { + cwd: root, + }, + (err, stdout) => { + if (err) { + const { level, code, message } = shouldWarn({ err: err.message }); + if (level === "warn") { + warn(message); + } else { + error(`${code}: ${err}`); + } + return continueBuild(); + } + + try { + const parsed = JSON.parse(stdout); + if (parsed == null) { + error(`Failed to parse JSON output from \`${command}\`.`); + return continueBuild(); + } + const { packages } = parsed; + if (packages && packages.length > 0) { + if (packages.length === 1) { + info(`This commit affects "${workspace}"`); + } else { + // subtract 1 because the first package is the workspace itself + info( + `This commit affects "${workspace}" and ${packages.length - 1} ${ + packages.length - 1 === 1 ? "dependency" : "dependencies" + } (${packages.slice(1).join(", ")})` + ); + } + + return continueBuild(); + } else { + info(`This project and its dependencies are not affected`); + return ignoreBuild(); + } + } catch (e) { + error(`Failed to parse JSON output from \`${command}\`.`); + error(e); + return continueBuild(); + } + } + ); +} diff --git a/packages/turbo-ignore/src/index.ts b/packages/turbo-ignore/src/index.ts new file mode 100644 index 0000000..0c34d3a --- /dev/null +++ b/packages/turbo-ignore/src/index.ts @@ -0,0 +1,6 @@ +#!/usr/bin/env node + +import turboIgnore from "./ignore"; +import parseArgs from "./args"; + +turboIgnore({ args: parseArgs({ argv: process.argv.slice(2) }) }); diff --git a/packages/turbo-ignore/src/logger.ts b/packages/turbo-ignore/src/logger.ts new file mode 100644 index 0000000..a7903ac --- /dev/null +++ b/packages/turbo-ignore/src/logger.ts @@ -0,0 +1,16 @@ +// ≫ +const TURBO_IGNORE_PREFIX = "\u226B "; + +function info(...args: any[]) { + console.log(TURBO_IGNORE_PREFIX, ...args); +} + +function error(...args: any[]) { + console.error(TURBO_IGNORE_PREFIX, ...args); +} + +function warn(...args: any[]) { + console.warn(TURBO_IGNORE_PREFIX, ...args); +} + +export { info, warn, error }; diff --git a/packages/turbo-ignore/src/types.ts b/packages/turbo-ignore/src/types.ts new file mode 100644 index 0000000..07fac3f --- /dev/null +++ b/packages/turbo-ignore/src/types.ts @@ -0,0 +1,23 @@ +export type NonFatalErrorKey = + | "MISSING_LOCKFILE" + | "NO_PACKAGE_MANAGER" + | "UNREACHABLE_PARENT" + | "UNREACHABLE_COMMIT"; + +export interface NonFatalError { + regex: RegExp; + message: string; +} + +export type NonFatalErrors = Record<NonFatalErrorKey, NonFatalError>; + +export interface TurboIgnoreArgs { + // the working directory to use when looking for a workspace + directory?: string; + // the workspace to check for changes + workspace?: string; + // the task to run, if not build + task?: string; + // A ref/head to compare against if no previously deployed SHA is available + fallback?: string; +} |
