diff --git a/.github/conventional-changelog/config.cjs b/.github/conventional-changelog/config.cjs index c1ea64eabb..8456dfa894 100644 --- a/.github/conventional-changelog/config.cjs +++ b/.github/conventional-changelog/config.cjs @@ -1,7 +1,9 @@ +// modified from https://github.com/lerna-lite/lerna-lite/blob//v1.9.0/packages/core/src/conventional-commits/get-github-commits.ts +// ref: https://github.com/conventional-changelog/conventional-changelog/issues/349#issuecomment-1200070203 "use strict"; const conventionalChangelogConfig = require("conventional-changelog-conventionalcommits"); const github = require("@actions/github"); -const fs = require("node:fs"); +const { execSync, spawnSync } = require("node:child_process"); const dedent = require("dedent"); const GIT_COMMIT_WITH_AUTHOR_FORMAT = @@ -34,6 +36,189 @@ function writerOptsTransform( return extendedCommit; } +function trimChars(str, cutset) { + let start = 0, + end = str.length; + + while (start < end && cutset.indexOf(str[start]) >= 0) ++start; + + while (end > start && cutset.indexOf(str[end - 1]) >= 0) --end; + + return start > 0 || end < str.length ? str.substring(start, end) : str; +} + +/** + * Parse git output and return relevant metadata. + * @param {string} stdout Result of `git describe` + * @param {string} [cwd] Defaults to `process.cwd()` + * @param [separator] Separator used within independent version tags, defaults to @ + * @returns {DescribeRefFallbackResult|DescribeRefDetailedResult} + */ +function parse(stdout, cwd, separator) { + separator = separator || "@"; + const minimalShaRegex = /^([0-9a-f]{7,40})(-dirty)?$/; + // when git describe fails to locate tags, it returns only the minimal sha + if (minimalShaRegex.test(stdout)) { + // repo might still be dirty + const [, sha, isDirty] = minimalShaRegex.exec(stdout); + + // count number of commits since beginning of time + const refCount = trimChars( + spawnSync("git", ["rev-list", "--count", sha], { cwd }).stdout.toString(), + "\n \r", + ); + + return { refCount, sha, isDirty: Boolean(isDirty) }; + } + + // If the user has specified a custom separator, it may not be regex-safe, so escape it + const escapedSeparator = separator.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const regexPattern = new RegExp( + `^((?:.*${escapedSeparator})?(.*))-(\\d+)-g([0-9a-f]+)(-dirty)?$`, + ); + + const [, lastTagName, lastVersion, refCount, sha, isDirty] = + regexPattern.exec(stdout) || []; + + return { lastTagName, lastVersion, refCount, sha, isDirty: Boolean(isDirty) }; +} + +function getArgs(options, includeMergedTags) { + let args = [ + "describe", + // fallback to short sha if no tags located + "--always", + // always return full result, helps identify existing release + "--long", + // annotate if uncommitted changes present + "--dirty", + // prefer tags originating on upstream branch + "--first-parent", + ]; + + if (options.match) { + args.push("--match", options.match); + } + + if (includeMergedTags) { + // we want to consider all tags, also from merged branches + args = args.filter((arg) => arg !== "--first-parent"); + } + + return args; +} + +function describeRefSync(options = {}, includeMergedTags, dryRun = false) { + console.error( + "git", + "describeRefSync", + getArgs(options, includeMergedTags), + // options, + // dryRun, + ); + const stdout = trimChars( + spawnSync( + "git", + getArgs(options, includeMergedTags), + // options, + // dryRun, + ).stdout.toString("utf8"), + "\n \r", + ); + const result = parse(stdout, options.cwd, options.separator); + + if (options?.match) { + console.error("git-describe.sync", "%j => %j", options?.match, stdout); + } + if (stdout) { + console.log(stdout); + console.error("git-describe", "parsed => %j", result); + } + + return result; +} + +function getOldestCommitSinceLastTag( + execOpts, + isIndependent, + includeMergedTags, +) { + let commitResult = ""; + const describeOptions = { ...execOpts }; + if (isIndependent) { + describeOptions.match = "*@*"; // independent tag pattern + } + const { lastTagName } = describeRefSync(describeOptions, includeMergedTags); + + if (lastTagName) { + const gitCommandArgs = [ + "log", + `${lastTagName}..HEAD`, + '--format="%h %aI"', + "--reverse", + ]; + console.error("git", "getCurrentBranchOldestCommitSinceLastTag"); + console.error("exec", `git ${gitCommandArgs.join(" ")}`); + let stdout = trimChars( + spawnSync( + "git", + gitCommandArgs, + // execOpts + ).stdout.toString("utf8"), + "\n \r", + ); + if (!stdout) { + // in some occasion the previous git command might return nothing, in that case we'll return the tag detail instead + stdout = trimChars( + spawnSync( + "git", + ["log", "-1", '--format="%h %aI"', lastTagName], + // execOpts, + ).stdout.toString() || "", + "\n \r", + ); + } + [commitResult] = stdout.split("\n"); + } else { + const gitCommandArgs = [ + "log", + "--oneline", + '--format="%h %aI"', + "--reverse", + "--max-parents=0", + "HEAD", + ]; + console.error("git", "getCurrentBranchFirstCommit"); + console.error("exec", `git ${gitCommandArgs.join(" ")}`); + commitResult = trimChars( + spawnSync( + "git", + gitCommandArgs, + // execOpts + ).stdout.toString("utf8"), + "\n \r", + ); + } + + const [, commitHash, commitDate] = + /^"?([0-9a-f]+)\s([0-9\-|\+T\:]*)"?$/.exec(commitResult) || []; + // prettier-ignore + console.error('oldestCommitSinceLastTag', `commit found since last tag: ${lastTagName} - (SHA) ${commitHash} - ${commitDate}`); + + return { commitHash, commitDate }; +} + +async function getCommitsSinceLastRelease(branchName, isIndependent, execOpts) { + // get the last release tag date or the first commit date if no release tag found + const { commitDate } = getOldestCommitSinceLastTag( + execOpts, + isIndependent, + false, + ); + + return getGithubCommits(branchName, commitDate, execOpts); +} + /** * From a dot (.) notation path, find and return a property within an object given a complex object path * Note that the object path does should not include the parent itself @@ -49,29 +234,81 @@ function getComplexObjectValue(object, path) { return path.split(".").reduce((obj, prop) => obj?.[prop], object); } -// Retrieve previous commits since last release from GitHub API -async function retrievePreviousCommits(branchName) { - const octokit = github.getOctokit(process.env.GITHUB_TOKEN); +function getOldestCommitSinceLastTag( + execOpts, + isIndependent, + includeMergedTags, +) { + let commitResult = ""; + const describeOptions = { ...execOpts }; + if (isIndependent) { + describeOptions.match = "*@*"; // independent tag pattern + } + const { lastTagName } = describeRefSync(describeOptions, includeMergedTags); - // first retrieve the latest tag - const { - data: { tag_name }, - } = await octokit.rest.repos.getLatestRelease({ - owner: "keiko233", - repo: "clash-nyanpasu", - }); + if (lastTagName) { + const gitCommandArgs = [ + "log", + `${lastTagName}..HEAD`, + '--format="%h %aI"', + "--reverse", + ]; + console.error("git", "getCurrentBranchOldestCommitSinceLastTag"); + console.error("exec", `git ${gitCommandArgs.join(" ")}`); + let stdout = trimChars( + spawnSync( + "git", + gitCommandArgs, + // execOpts + ).stdout.toString(), + "\n \r", + ); + if (!stdout) { + // in some occasion the previous git command might return nothing, in that case we'll return the tag detail instead + stdout = trimChars( + spawnSync( + "git", + ["log", "-1", '--format="%h %aI"', lastTagName], + // execOpts, + ).stdout.toString() || "", + "\n \r", + ); + } + [commitResult] = stdout.split("\n"); + } else { + const gitCommandArgs = [ + "log", + "--oneline", + '--format="%h %aI"', + "--reverse", + "--max-parents=0", + "HEAD", + ]; + console.error("git", "getCurrentBranchFirstCommit"); + console.error("exec", `git ${gitCommandArgs.join(" ")}`); + commitResult = trimChars( + spawnSync( + "git", + gitCommandArgs, + // execOpts + ).stdout.toString(), + "\n \r", + ); + } - // then retrieve the latest tag commit timestamp - const { data: commitData } = await octokit.rest.repos.getCommit({ - owner: "keiko233", - repo: "clash-nyanpasu", - ref: tag_name, - }); + const [, commitHash, commitDate] = + /^"?([0-9a-f]+)\s([0-9\-|\+T\:]*)"?$/.exec(commitResult) || []; + // prettier-ignore + console.error('oldestCommitSinceLastTag', `commit found since last tag: ${lastTagName} - (SHA) ${commitHash} - ${commitDate}`); - const sinceDate = - commitData.commit.committer.date || commitData.commit.author.date; + return { commitHash, commitDate }; +} +// Retrieve previous commits since last release from GitHub API +async function getGithubCommits(branchName, sinceDate, execOpts) { + const octokit = github.getOctokit(process.env.GITHUB_TOKEN); const remoteCommits = []; + let afterCursor = ""; let hasNextPage = false; @@ -119,7 +356,7 @@ async function retrievePreviousCommits(branchName) { } } while (hasNextPage); - console.log( + console.error( "github", "found %s commits since last release timestamp %s", remoteCommits.length, @@ -130,51 +367,59 @@ async function retrievePreviousCommits(branchName) { } module.exports = (async () => { - const config = await conventionalChangelogConfig({ - types: [ - { - type: "feat", - section: "โœจ Features", - }, - { - type: "fix", - section: "๐Ÿ› Bug Fixes", - }, - { - type: "chore", - section: "๐Ÿงน Maintenance", - }, - { - type: "docs", - section: "๐Ÿ“š Documentation", - }, - { - type: "style", - section: "๐Ÿ’… Styles", - }, - { - type: "refactor", - section: "๐Ÿ”จ Refactoring", - }, - { - type: "perf", - section: "โšก Performance Improvements", - }, - { - type: "test", - section: "โœ… Tests", - }, - ], - }); - const commitsSinceLastRelease = await retrievePreviousCommits("main"); - config.gitRawCommitsOpts.format = GIT_COMMIT_WITH_AUTHOR_FORMAT; - config.writerOpts.commitPartial = - config.writerOpts.commitPartial.replace(/\n*$/, "") + - ` {{#if @root.linkReferences~}}${extraCommitMsg}{{~/if}}\n`; - config.writerOpts.transform = writerOptsTransform.bind( - null, - config.writerOpts.transform, - commitsSinceLastRelease, - ); - return config; + try { + const config = await conventionalChangelogConfig({ + types: [ + { + type: "feat", + section: "โœจ Features", + }, + { + type: "fix", + section: "๐Ÿ› Bug Fixes", + }, + { + type: "chore", + section: "๐Ÿงน Maintenance", + }, + { + type: "docs", + section: "๐Ÿ“š Documentation", + }, + { + type: "style", + section: "๐Ÿ’… Styles", + }, + { + type: "refactor", + section: "๐Ÿ”จ Refactoring", + }, + { + type: "perf", + section: "โšก Performance Improvements", + }, + { + type: "test", + section: "โœ… Tests", + }, + ], + }); + const commitsSinceLastRelease = await getCommitsSinceLastRelease( + "main", + false, + ); + config.gitRawCommitsOpts.format = GIT_COMMIT_WITH_AUTHOR_FORMAT; + config.writerOpts.commitPartial = + config.writerOpts.commitPartial.replace(/\n*$/, "") + + ` {{#if @root.linkReferences~}}${extraCommitMsg}{{~/if}}\n`; + config.writerOpts.transform = writerOptsTransform.bind( + null, + config.writerOpts.transform, + commitsSinceLastRelease, + ); + return config; + } catch (e) { + console.error("pre-changelog-generation", e); + process.exit(1); + } })(); diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index b81fdef74b..5f2eb7bddb 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -26,6 +26,7 @@ jobs: uses: actions/checkout@v4 with: token: ${{ secrets.GITHUB_TOKEN }} + fetch-depth: 0 - name: Prepare Node uses: actions/setup-node@v4 with: diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4e07594424..5d3af5ab53 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1674,6 +1674,7 @@ packages: resolution: {integrity: sha512-JbEPfhndYeWHfOSeh4DOFvNXrj7ls9S/2omijVsao+LBPTPayT1uKcK3dHW3MwDJ7KO11t9m2cVTqXnTKpeaiw==} cpu: [arm64] os: [linux] + libc: [glibc] requiresBuild: true dev: true optional: true @@ -1682,6 +1683,7 @@ packages: resolution: {integrity: sha512-ahqcSXLlcV2XUBM3/f/C6cRoh7NxYA/W7Yzuv4bDU1YscTFw7ay4LmD7l6OS8EMhTNvcrWGkEettL1Bhjf+B+w==} cpu: [arm64] os: [linux] + libc: [musl] requiresBuild: true dev: true optional: true @@ -1690,6 +1692,7 @@ packages: resolution: {integrity: sha512-uwvOYNtLw8gVtrExKhdFsYHA/kotURUmZYlinH2VcQxNCQJeJXnkmWgw2hI9Xgzhgu7J9QvWiq9TtTVwWMDa+w==} cpu: [riscv64] os: [linux] + libc: [glibc] requiresBuild: true dev: true optional: true @@ -1698,6 +1701,7 @@ packages: resolution: {integrity: sha512-m6pkSwcZZD2LCFHZX/zW2aLIISyzWLU3hrLLzQKMI12+OLEzgruTovAxY5sCZJkipklaZqPy/2bEEBNjp+Y7xg==} cpu: [x64] os: [linux] + libc: [glibc] requiresBuild: true dev: true optional: true @@ -1706,6 +1710,7 @@ packages: resolution: {integrity: sha512-VFAC1RDRSbU3iOF98X42KaVicAfKf0m0OvIu8dbnqhTe26Kh6Ym9JrDulz7Hbk7/9zGc41JkV02g+p3BivOdAg==} cpu: [x64] os: [linux] + libc: [musl] requiresBuild: true dev: true optional: true @@ -1907,6 +1912,7 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] requiresBuild: true dev: true optional: true @@ -1916,6 +1922,7 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] requiresBuild: true dev: true optional: true @@ -1925,6 +1932,7 @@ packages: engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] requiresBuild: true dev: true optional: true @@ -1934,6 +1942,7 @@ packages: engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] requiresBuild: true dev: true optional: true