import * as core from "@actions/core"; import * as ccp from "conventional-commits-parser"; import type { Commit } from "conventional-commits-parser"; import { execSync } from "child_process"; export enum IncrementType { MAJOR = "major", MINOR = "minor", PATCH = "patch", } export type Tag = { major: number; minor: number; patch: number; }; export interface GitService { listTags(perPage: number): Promise<{ name: string }[]>; getCommit(ref: string): Promise<{ commit: { message: string } }>; testConnection(): Promise; } export interface CoreService { getInput(name: string): string; info(message: string): void; warning(message: string): void; setOutput(name: string, value: string): void; setFailed(message: string): void; } export class ConventionalCommitAnalyzer { analyzeCommit(message: string): Commit { return ccp.sync(message); } determineIncrementType(analyzedCommit: Commit): IncrementType { if ( analyzedCommit.breaking || analyzedCommit.header?.includes("!") || analyzedCommit.notes.some((note: any) => note.title === "BREAKING CHANGE") ) { return IncrementType.MAJOR; } else if (analyzedCommit.type === "feat") { return IncrementType.MINOR; } else { return IncrementType.PATCH; } } } export class TagService { bumpTag(tag: Tag, incrementType: IncrementType): Tag { switch (incrementType) { case IncrementType.MAJOR: return { major: tag.major + 1, minor: 0, patch: 0, }; case IncrementType.MINOR: return { major: tag.major, minor: tag.minor + 1, patch: 0, }; case IncrementType.PATCH: return { major: tag.major, minor: tag.minor, patch: tag.patch + 1, }; } } renderTag(tag: Tag, prefix: string = "", prereleaseSuffix: string = ""): string { return `${prefix}${tag.major}.${tag.minor}.${tag.patch}${prereleaseSuffix}`; } parseTag(tagString: string): Tag { if (!/^v?\d+\.\d+\.\d+$/.test(tagString)) { throw new Error(`Invalid tag format: ${tagString}. Expected semantic version (e.g., 1.2.3)`); } const versionMatch = tagString.match(/^v?(\d+)\.(\d+)\.(\d+)$/); if (!versionMatch) { throw new Error(`Could not parse version from tag: ${tagString}`); } return { major: parseInt(versionMatch[1]), minor: parseInt(versionMatch[2]), patch: parseInt(versionMatch[3]), }; } } export class TagIncrementer { constructor( private gitService: GitService, private coreService: CoreService, private commitAnalyzer: ConventionalCommitAnalyzer, private tagService: TagService ) {} async getBaseTag(lastTag: string | undefined, maxTags: number = 50): Promise { if (lastTag) { this.coreService.info(`Using provided tag: ${lastTag}`); return lastTag; } this.coreService.info(`Fetching up to ${maxTags} tags from local repository`); const tags = await this.gitService.listTags(maxTags); if (tags.length === 0) { this.coreService.warning("No tags found in repository, defaulting to 0.0.0"); return "0.0.0"; } const realTags = tags.filter((tag) => !tag.name.includes("-rc-")); if (realTags.length === 0) { this.coreService.warning("No real tags found (only pre-release tags), defaulting to 0.0.0"); return "0.0.0"; } const baseTag = realTags[0].name; this.coreService.info(`Using latest real tag: ${baseTag}`); return baseTag; } async determineIncrementType(ref: string): Promise { this.coreService.info(`Fetching commit ${ref}`); try { const { commit } = await this.gitService.getCommit(ref); this.coreService.info(`Successfully fetched commit: ${commit.message.substring(0, 100)}...`); const analyzedCommit = this.commitAnalyzer.analyzeCommit(commit.message); this.coreService.info( `Analyzed commit - type: ${analyzedCommit.type}, breaking: ${analyzedCommit.breaking}` ); const incrementType = this.commitAnalyzer.determineIncrementType(analyzedCommit); this.coreService.info(`Determined increment type: ${incrementType}`); return incrementType; } catch (error) { this.coreService.info( `Error fetching commit ${ref}: ${error instanceof Error ? error.message : String(error)}` ); // Additional debugging for commit-related errors if (error instanceof Error && error.message.includes("404")) { this.coreService.info(`404 error suggests commit ${ref} was not found in repository`); this.coreService.info( `This could indicate: 1) SHA doesn't exist, 2) Repository access issue, 3) API endpoint mismatch` ); } throw error; } } async incrementTag( lastTag: string | undefined, ref: string, maxTags: number = 50, prerelease: boolean = false, githubSha: string = "" ): Promise { this.coreService.info(`Starting tag increment process at ref ${ref}`); const baseTag = await this.getBaseTag(lastTag, maxTags); const parsedTag = this.tagService.parseTag(baseTag); this.coreService.info( `Parsed version: ${parsedTag.major}.${parsedTag.minor}.${parsedTag.patch}` ); const incrementType = await this.determineIncrementType(ref); const newTag = this.tagService.bumpTag(parsedTag, incrementType); const prereleaseSuffix = prerelease && githubSha ? `-rc-${githubSha}` : ""; const renderedTag = this.tagService.renderTag(newTag, "", prereleaseSuffix); this.coreService.info(`New tag: ${renderedTag}`); this.coreService.info( `Successfully determined new tag: ${renderedTag} (${incrementType} increment)` ); return renderedTag; } } export class LocalGitService implements GitService { async listTags(perPage: number): Promise<{ name: string }[]> { try { const tags = execSync("git tag --sort=-version:refname", { encoding: "utf8" }) .split("\n") .filter(Boolean) .slice(0, perPage); return tags.map((name) => ({ name })); } catch (error) { // If no tags exist, return empty array return []; } } async getCommit(ref: string): Promise<{ commit: { message: string } }> { try { const message = execSync(`git log -1 --pretty=%B ${ref}`, { encoding: "utf8" }).trim(); return { commit: { message } }; } catch (error) { throw new Error( `Failed to get commit ${ref}: ${error instanceof Error ? error.message : String(error)}` ); } } async testConnection(): Promise { try { // Test if we're in a git repository execSync("git rev-parse --git-dir", { stdio: "pipe" }); } catch (error) { throw new Error("Not in a git repository. Please ensure this action runs after checkout."); } } } export class LocalCoreService implements CoreService { getInput(name: string): string { return core.getInput(name); } info(message: string): void { core.info(message); } warning(message: string): void { core.warning(message); } setOutput(name: string, value: string): void { core.setOutput(name, value); } setFailed(message: string): void { core.setFailed(message); } } export async function run(): Promise { try { const coreService = new LocalCoreService(); const lastTag = coreService.getInput("last-tag"); const maxTagsInput = coreService.getInput("max-tags"); const maxTags = parseInt(maxTagsInput || "50", 10); const prereleaseInput = coreService.getInput("prerelease"); const prerelease = prereleaseInput === "true"; const githubSha = process.env.GITHUB_SHA || ""; const ref = "HEAD"; if (prerelease && !githubSha) { const errorMessage = "Prerelease is enabled but GITHUB_SHA environment variable is not available. This is required for prerelease tags."; coreService.setFailed(errorMessage); return; } // Log context information for debugging coreService.info(`Working with local git repository`); coreService.info(`Commit ref: ${ref}`); coreService.info(`Last tag input: ${lastTag || "undefined"}`); coreService.info(`Max tags limit: ${maxTags}`); coreService.info(`Prerelease: ${prerelease}`); coreService.info(`GitHub SHA: ${githubSha || "undefined"}`); const gitService = new LocalGitService(); const commitAnalyzer = new ConventionalCommitAnalyzer(); const tagService = new TagService(); const tagIncrementer = new TagIncrementer(gitService, coreService, commitAnalyzer, tagService); coreService.info("Starting tag increment process..."); // Test the connection first coreService.info("Testing git repository connection..."); await gitService.testConnection(); coreService.info("Git repository connection successful"); const newTag = await tagIncrementer.incrementTag( lastTag || undefined, ref, maxTags, prerelease, githubSha ); coreService.info(`Process completed successfully. New tag: ${newTag}`); coreService.setOutput("new-tag", newTag); } catch (error) { const coreService = new LocalCoreService(); // Enhanced error logging if (error instanceof Error) { coreService.info(`Error details: ${error.message}`); coreService.info(`Error stack: ${error.stack}`); coreService.setFailed(error.message); } else { coreService.info(`Unknown error: ${String(error)}`); coreService.setFailed("An unknown error occurred"); } } }