/* istanbul ignore file */
import { execSync } from "node:child_process";
import { readFileSync } from "node:fs";
import {
SemVersion,
SemVersionRegex,
BugFlag,
FixFlag,
BreakingFlag,
PrereleaseFlag,
PreferredSkipCiFlag,
hasSkipCiSuffix,
stripSkipCiSuffix,
} from "../../utils/constants";
import { UserInput } from "../../input/input";
import { Command } from "../command";
import { DefaultCommandValues } from "../index";
import { LoggingConfig } from "@decaf-ts/logging";
import { printCommandHelp } from "./help.command";
import { resolveSecret, hasSecret } from "./credentials.command";
const options = {
message: {
type: "string",
short: "m",
},
tag: {
type: "string",
short: "t",
default: undefined,
},
public: {
type: "boolean",
default: false,
},
private: {
type: "boolean",
default: false,
},
"no-ci": {
type: "boolean",
default: false,
},
"git-token": {
type: "string",
default: "github",
},
"npm-token": {
type: "string",
default: "npm",
},
"git-user": {
type: "string",
default: undefined,
},
};
/**
* @class ReleaseScript
* @extends {Command}
* @category scripts
* @description TypeScript-native alternative to bin/tag-release.sh.
* @summary Automates the release process: derives (or accepts) the semver bump from
* the release message, updates the version, tags, pushes, and optionally publishes to
* npm. Mirrors bin/tag-release.sh's flags and message-suffix conventions so both stay
* interchangeable across decaf-ts repositories. The release message is never stripped
* of its suffix flags before being committed/tagged, so CI can act on the same
* -bug/-fix/-breaking/-prerelease convention. Skip-CI detection accepts this project's
* own -no-ci flag as well as any of GitHub's natively-recognized skip keywords
* ([skip ci], [ci skip], [no ci], [skip actions], [actions skip]); when this script
* itself needs to mark a message as skip-CI it appends [skip ci], since that one is
* also honored natively by GitHub for push/pull_request-triggered workflows.
* Repo-specific publish quirks (e.g. an Angular library that must be published from
* its ng-packagr build output rather than the repo root) are handled by a
* {@link PublishStrategy}, selected via package.json's `tagRelease` key -- see
* {@link TagReleaseConfig} and {@link PUBLISH_STRATEGIES} -- rather than special-cased
* here, so each repo's own exceptions live with that repo, and a new kind of exception
* is a new strategy implementation rather than a change to this class.
*
* @param {Object} options - Configuration options for the script
* @param {string} options.message - The release message (short: 'm')
* @param {string} options.tag - The version tag to use (short: 't'); derived from the message when omitted
* @param {boolean} options.public - Publish to the public npm registry (default)
* @param {boolean} options.private - Publish to the restricted npm registry
* @param {boolean} options.no-ci - Append [skip ci] to the message (if no skip-CI flag is already present) and publish locally instead of waiting for CI
* @param {string} options.git-token - Secret name for the git push token (default: 'github')
* @param {string} options.npm-token - Secret name for the npm publish token (default: 'npm')
* @param {string} options.git-user - Git user name embedded in authenticated pushes
*
* Positional arguments are also accepted, mirroring bin/tag-release.sh: the first
* positional is the tag, everything after it is joined (unquoted) into the message —
* `tag-release patch fix a critical login bug` needs no quoting. Positionals only fill
* in whichever of --tag/--message was not passed as a flag.
*/
/**
* @description Inputs a {@link PublishStrategy} needs to run the local npm publish step.
* @interface PublishContext
* @property {string} npmToken - Resolved npm auth token
* @property {"public" | "restricted"} accessValue - npm `--access` value
* @property {boolean} isPrerelease - Whether the bump was a prerelease (needs `--tag prerelease`)
* @property {string} cwd - Repository root
* @memberOf module:utils
*/
export interface PublishContext {
npmToken: string;
accessValue: "public" | "restricted";
isPrerelease: boolean;
cwd: string;
}
/**
* @description Strategy for the local (skip-CI) npm publish step.
* @summary Lets a repo's release process diverge from a plain `npm publish` from the
* repo root -- e.g. an Angular library published from its ng-packagr build output --
* without special-casing that repo in this shared class. Add a new implementation for
* each new kind of exception and register it in {@link PUBLISH_STRATEGIES}; a repo
* opts in via its own package.json, not by editing this file.
* @interface PublishStrategy
* @memberOf module:utils
*/
export interface PublishStrategy {
publish(ctx: PublishContext): void;
}
/**
* @description Default publish strategy: `npm publish` from the repository root.
* @class RootPublishStrategy
* @implements {PublishStrategy}
* @memberOf module:utils
*/
export class RootPublishStrategy implements PublishStrategy {
publish(ctx: PublishContext): void {
const tagFlag = ctx.isPrerelease ? " --tag prerelease" : "";
execSync(
`NPM_TOKEN="${ctx.npmToken}" npm publish --ignore-scripts --access "${ctx.accessValue}"${tagFlag}`,
{ cwd: ctx.cwd, stdio: "inherit" }
);
}
}
/**
* @description Publishes a subdirectory's own package.json, optionally building it first.
* @summary For repos whose publishable output lives in a subdirectory with its own
* package.json -- e.g. `dist/lib` from ng-packagr -- rather than the repo root.
* @class SubdirectoryPublishStrategy
* @implements {PublishStrategy}
* @param {string} dir - Directory (relative to the repo root) to publish
* @param {string} [prePublishScript] - npm script to run before publishing
* @memberOf module:utils
*/
export class SubdirectoryPublishStrategy implements PublishStrategy {
constructor(
private readonly dir: string,
private readonly prePublishScript?: string
) {}
publish(ctx: PublishContext): void {
if (this.prePublishScript) {
execSync(`npm run ${this.prePublishScript}`, {
cwd: ctx.cwd,
stdio: "inherit",
});
}
// A bare relative path (e.g. "dist/lib") is ambiguous to npm -- it can be parsed
// as a <github-user>/<repo> spec instead of a local folder. "./" disambiguates it.
const dir =
this.dir.startsWith(".") || this.dir.startsWith("/")
? this.dir
: `./${this.dir}`;
const tagFlag = ctx.isPrerelease ? " --tag prerelease" : "";
execSync(
`NPM_TOKEN="${ctx.npmToken}" npm publish "${dir}" --ignore-scripts --access "${ctx.accessValue}"${tagFlag}`,
{ cwd: ctx.cwd, stdio: "inherit" }
);
}
}
/**
* @description Per-repository release overrides, read from package.json's `tagRelease` key.
* @summary `strategy` selects a {@link PublishStrategy} by name from
* {@link PUBLISH_STRATEGIES}; when omitted, "subdirectory" is inferred if `publishDir`
* or `prePublishScript` is set, else "root". Add fields here as new strategies need
* their own config, e.g.:
* `{ "tagRelease": { "strategy": "subdirectory", "publishDir": "dist/lib", "prePublishScript": "build:prod" } }`.
* @typedef {Object} TagReleaseConfig
* @property {string} [strategy] - Publish strategy name (a key in {@link PUBLISH_STRATEGIES})
* @property {string} [publishDir] - Directory to publish (subdirectory strategy)
* @property {string} [prePublishScript] - npm script to run before publishing (subdirectory strategy)
* @memberOf module:utils
*/
export interface TagReleaseConfig {
strategy?: string;
publishDir?: string;
prePublishScript?: string;
}
/**
* @description Registry of named publish strategies, resolved from {@link TagReleaseConfig}.
* @summary Add an entry here for each new {@link PublishStrategy} implementation, so a
* repo can opt in via `"tagRelease": { "strategy": "<name>" }` in its own package.json
* -- scaling to new release-process exceptions never requires touching this class.
* @const PUBLISH_STRATEGIES
* @memberOf module:utils
*/
export const PUBLISH_STRATEGIES: Record<
string,
(config: TagReleaseConfig) => PublishStrategy
> = {
root: () => new RootPublishStrategy(),
subdirectory: (config) =>
new SubdirectoryPublishStrategy(
config.publishDir || ".",
config.prePublishScript
),
};
export class ReleaseScript extends Command<typeof options, void> {
constructor() {
super("ReleaseScript", options);
}
/**
* @description Reads this repo's tag-release config.
* @summary Missing file/key/parse errors all resolve to "no overrides" -- config is
* optional, absence just means the default {@link RootPublishStrategy}.
* @returns {TagReleaseConfig} The repo's tagRelease config, or an empty object
*/
private readTagReleaseConfig(): TagReleaseConfig {
try {
const pkg = JSON.parse(readFileSync("package.json", "utf8"));
return (pkg.tagRelease as TagReleaseConfig) || {};
} catch {
return {};
}
}
/**
* @description Resolves this repo's {@link PublishStrategy} from its tagRelease config.
* @summary An explicit `strategy` name wins; otherwise "subdirectory" is inferred
* when `publishDir`/`prePublishScript` is set, else "root". An unrecognized strategy
* name logs a warning and falls back to {@link RootPublishStrategy} rather than
* failing the release outright.
* @returns {PublishStrategy}
*/
private resolvePublishStrategy(): PublishStrategy {
const config = this.readTagReleaseConfig();
const name =
config.strategy ||
(config.publishDir || config.prePublishScript ? "subdirectory" : "root");
const factory = PUBLISH_STRATEGIES[name];
if (!factory) {
this.log
.for(this.resolvePublishStrategy)
.warn(
`Unknown tagRelease.strategy '${name}'; falling back to the root strategy.`
);
return new RootPublishStrategy();
}
return factory(config);
}
private ensureReleaseBranch(): void {
const currentBranch = execSync("git rev-parse --abbrev-ref HEAD", {
cwd: process.cwd(),
encoding: "utf8",
}).trim();
if (currentBranch !== "master" && currentBranch !== "main") {
throw new Error(
`release must be run from 'master' or 'main' branch. Current branch: ${currentBranch}`
);
}
}
/**
* @description Derives the semver bump type from a release message's suffix.
* @summary -breaking bumps major, -bug/-fix bump patch, -prerelease triggers a
* prerelease bump, and no matching suffix defaults to minor. Any trailing skip-CI
* flag (this project's -no-ci, or one of GitHub's native [skip ci]-style keywords)
* is stripped only for this check; the message itself is returned untouched by the
* caller so every flag stays in the committed/tagged text.
* @param {string} message - The release message
* @returns {string} One of the {@link SemVersion} values
*/
deriveBumpType(message: string): string {
const stripped = stripSkipCiSuffix(message);
if (stripped.endsWith(BreakingFlag)) return SemVersion.MAJOR;
if (stripped.endsWith(BugFlag) || stripped.endsWith(FixFlag))
return SemVersion.PATCH;
if (stripped.endsWith(PrereleaseFlag)) return SemVersion.PRERELEASE;
return SemVersion.MINOR;
}
/**
* @description Prepares the version for the release.
* @summary Validates the provided tag, or falls back to the derived bump type
* (confirmed interactively) if none was given.
* @param {string} [tag] - The version tag to prepare
* @param {string} suggested - The bump type derived from the release message
* @returns {Promise<string>} The prepared version tag
*/
async prepareVersion(
tag: string | undefined,
suggested: string
): Promise<string> {
const log = this.log.for(this.prepareVersion);
const validated = this.testVersion((tag as string) || "");
if (validated) return validated;
log.verbose(
"No release version provided. Deriving one from the message:"
);
log.info(`Listing latest git tags:`);
execSync("git tag --sort=-taggerdate | head -n 5", {
cwd: process.cwd(),
stdio: "inherit",
});
const useSuggested = await UserInput.askConfirmation(
"tag-suggestion",
`Use '${suggested}' as the version bump?`,
true
);
if (useSuggested) return suggested;
return await UserInput.insistForText(
"tag",
"Enter the new tag number (patch|minor|major|prerelease or v*.*.*[-...])",
(val) => !!this.testVersion(val.toString())
);
}
/**
* @description Tests if the provided version is valid.
* @summary This method checks if the version is a valid semantic version or a predefined update type (PATCH, MINOR, MAJOR, PRERELEASE).
* @param {string} version - The version to test
* @returns {string | undefined} The validated version or undefined if invalid
*/
testVersion(version: string): string | undefined {
const log = this.log.for(this.testVersion);
version = version.trim().toLowerCase();
switch (version) {
case SemVersion.PATCH:
case SemVersion.MINOR:
case SemVersion.MAJOR:
case SemVersion.PRERELEASE:
log.verbose(`Using provided SemVer update: ${version}`, 1);
return version;
default:
log.verbose(
`Testing provided version for SemVer compatibility: ${version}`,
1
);
if (!new RegExp(SemVersionRegex).test(version)) {
log.debug(`Invalid version number: ${version}`);
return undefined;
}
log.verbose(`version approved: ${version}`, 1);
return version;
}
}
/**
* @description Prepares the release message.
* @summary This method either returns the provided message or prompts the user for a new one if not provided.
* @param {string} [message] - The release message
* @returns {Promise<string>} The prepared release message
*/
async prepareMessage(message?: string): Promise<string> {
const log = this.log.for(this.prepareMessage);
if (!message) {
log.verbose("No release message provided. Prompting for one");
return await UserInput.insistForText(
"message",
"What should be the release message/ticket? (end with -bug/-fix, -breaking or -prerelease to pick the version bump; no matching suffix defaults to minor)",
(val) => !!val && val.toString().length > 5
);
}
return message;
}
protected override help(): void {
printCommandHelp(
this.log,
"tag-release",
"Prepare, tag, and publish a release from the current repository (TypeScript-native alternative to bin/tag-release.sh).",
"tag-release [options] [tag] [message...]",
[
{
flag: "--tag <version>, [tag]",
description:
"Release tag to use (patch|minor|major|prerelease or v*.*.*[-...]). Also accepted as the first positional argument. Omit to derive it from the message.",
},
{
flag: "--message <text>, [message...]",
description:
"Release message or ticket reference. Also accepted as everything after the positional tag, unquoted (e.g. 'tag-release patch fix a bug'). A -bug/-fix suffix bumps patch, -breaking bumps major, -prerelease bumps prerelease; no matching suffix defaults to minor.",
},
{
flag: "--public",
description: "Publish to the public npm registry",
defaultValue: "false",
},
{
flag: "--private",
description: "Publish to the restricted npm registry",
defaultValue: "false",
},
{
flag: "--no-ci",
description:
"Append [skip ci] to the release message (unless it already ends with -no-ci or a GitHub skip-CI keyword) and publish to npm locally instead of waiting for CI.",
defaultValue: "false",
},
{
flag: "--git-token <name>",
description: "Secret name for the git push token",
defaultValue: "github",
},
{
flag: "--npm-token <name>",
description: "Secret name for the npm publish token",
defaultValue: "npm",
},
{
flag: "--git-user <name>",
description: "Git user name embedded in authenticated pushes",
},
{
flag: "--version",
description: "Print the package version and exit",
},
{
flag: "-h, --help",
description: "Show this help text and exit",
},
],
[
"If tag or message are omitted (via flag or positional), the command prompts interactively.",
"A successful run updates the package version, creates a git tag, pushes tags, and optionally publishes to npm.",
"Tokens are resolved via the credentials command (env var → OS keychain → legacy .token/.npmtoken file).",
"A message word starting with '-' (e.g. the -bug/-fix/-breaking/-prerelease suffix) needs a leading -- so it isn't parsed as a flag, e.g. tag-release -- fix login crash -bug",
],
[
"tag-release patch fix login crash",
"tag-release -- fix login crash -bug",
"tag-release prerelease JIRA-1234 preview build --no-ci",
]
);
}
/**
* @description Runs the release script.
* @summary Orchestrates the entire release process: message/version preparation,
* git tagging, authenticated push, and conditional npm publish. Mirrors
* bin/tag-release.sh step for step.
* @param {ParseArgsResult} args - The parsed command-line arguments
* @returns {Promise<void>}
*/
async run(
args: LoggingConfig &
typeof DefaultCommandValues & {
[k in keyof typeof options]: unknown;
} & { positionals: string[] }
): Promise<void> {
const log = this.log.for(this.run);
this.ensureReleaseBranch();
const publishAccessValue = args.private === true ? "restricted" : "public";
const gitTokenName = `${args["git-token"] || "github"}`;
const npmTokenName = `${args["npm-token"] || "npm"}`;
// Mirrors bin/tag-release.sh's positional convention, but only consumes the
// leading positional as the tag when it actually validates as one; otherwise
// there was no explicit tag and the whole positional list is the message (so a
// typo'd/omitted tag doesn't silently drop words from the message).
const positionals = args.positionals || [];
let tagArg: string | undefined =
typeof args.tag === "string" && args.tag.trim().length > 0
? (args.tag as string)
: undefined;
let messageArg: string | undefined =
typeof args.message === "string" && args.message.trim().length > 0
? (args.message as string)
: undefined;
if (tagArg === undefined && positionals.length > 0) {
if (this.testVersion(positionals[0])) {
tagArg = positionals[0];
if (messageArg === undefined && positionals.length > 1) {
messageArg = positionals.slice(1).join(" ");
}
} else if (messageArg === undefined) {
messageArg = positionals.join(" ");
}
}
// Matches bin/tag-release.sh's ordering: prepare-release runs right after args
// are parsed, before message/tag are resolved (which may prompt interactively).
execSync("npm run prepare-release", {
cwd: process.cwd(),
stdio: "inherit",
});
let message: string = await this.prepareMessage(messageArg);
if (args["no-ci"] === true && !hasSkipCiSuffix(message)) {
message = `${message} ${PreferredSkipCiFlag}`;
}
// Normalize whatever skip-CI flag ended up in the message (-no-ci, a GitHub
// native keyword, or the one --no-ci just appended) to the one canonical flag,
// so every downstream consumer only ever needs to test for a single flag.
if (hasSkipCiSuffix(message)) {
message = `${stripSkipCiSuffix(message)} ${PreferredSkipCiFlag}`;
}
const suggestedBump = this.deriveBumpType(message);
const tag: string = await this.prepareVersion(tagArg, suggestedBump);
// Matches bin/tag-release.sh: commit whatever prepare-release changed, no prompt.
const status = execSync("git status --porcelain", {
cwd: process.cwd(),
encoding: "utf8",
});
if (status.trim().length > 0) {
execSync("git add .", { cwd: process.cwd(), stdio: "inherit" });
execSync(
`git commit -m "${tag} - ${message} - after release preparation"`,
{ cwd: process.cwd(), stdio: "inherit" }
);
}
execSync(`npm version "${tag}" -m "${message}"`, {
cwd: process.cwd(),
stdio: "inherit",
});
const remoteUrl = execSync("git remote get-url origin", {
cwd: process.cwd(),
encoding: "utf8",
}).trim();
if (hasSecret(gitTokenName)) {
const currentBranch = execSync("git rev-parse --abbrev-ref HEAD", {
cwd: process.cwd(),
encoding: "utf8",
}).trim();
let upstream = "";
try {
upstream = execSync(
"git rev-parse --abbrev-ref --symbolic-full-name '@{u}'",
{ cwd: process.cwd(), encoding: "utf8" }
).trim();
} catch {
upstream = "";
}
const gitUser =
typeof args["git-user"] === "string" &&
(args["git-user"] as string).trim().length > 0
? (args["git-user"] as string).trim()
: execSync("git config user.name", {
cwd: process.cwd(),
encoding: "utf8",
}).trim();
const token = resolveSecret(gitTokenName);
execSync(
`git push "https://${gitUser}:${token}@${remoteUrl.replace(/^https:\/\//, "")}" --follow-tags`,
{ cwd: process.cwd(), stdio: "inherit" }
);
if (upstream.length > 0) {
try {
execSync(
`git branch --set-upstream-to="${upstream}" "${currentBranch}"`,
{ cwd: process.cwd(), stdio: "inherit" }
);
} catch {
// ignore restore failures
}
}
} else {
execSync("git push --follow-tags", {
cwd: process.cwd(),
stdio: "inherit",
});
}
if (hasSkipCiSuffix(message)) {
if (hasSecret(npmTokenName)) {
const npmToken = resolveSecret(npmTokenName);
const strategy = this.resolvePublishStrategy();
strategy.publish({
npmToken,
accessValue: publishAccessValue as "public" | "restricted",
isPrerelease: tag === SemVersion.PRERELEASE,
cwd: process.cwd(),
});
} else {
log.warn(
`Release message ends with a skip-CI flag, so CI will skip publishing too, but no npm token was found (checked secret '${npmTokenName}') — this release will not be published anywhere. Publish it manually or configure the token.`
);
}
} else {
log.info("Skipping local npm publish; CI will publish this release.");
}
}
}
Source