From 6ae328a9dc20c28d34981584837b2d6fca1e6142 Mon Sep 17 00:00:00 2001 From: Chris Book Date: Tue, 3 Mar 2026 12:54:35 -0500 Subject: [PATCH 1/8] feat: add an input to run at a subdirectory of the repo root - Allows for situations where the root of the npm workspace is not at the top level. --- README.md | 1 + src/comment.ts | 39 +++++++++++++++++++++++++++++-------- src/get-changed-packages.ts | 20 ++++++++++++------- src/main.ts | 7 ++++++- 4 files changed, 51 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index bfc760da..1d8d9a21 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,7 @@ GitLab CI cli for [changesets](https://github.com/atlassian/changesets) like its - `INPUT_TARGET_BRANCH` -> The merge request target branch. Defaults to current branch - `INPUT_CREATE_GITLAB_RELEASES` - A boolean value to indicate whether to create Gitlab releases after publish or not. Default true. - `INPUT_LABELS` - A comma separated string of labels to be added to the version package Gitlab Merge request +- `INPUT_CWD` - A relative path from the repo root to the directory containing `package.json` and `.changeset/`. Use this when your npm/yarn workspace lives in a subdirectory of the git repo. Defaults to the repo root. ### Outputs diff --git a/src/comment.ts b/src/comment.ts index 1ea39016..40f4998a 100644 --- a/src/comment.ts +++ b/src/comment.ts @@ -1,3 +1,5 @@ +import path from 'node:path' + import { ValidationError } from '@changesets/errors' import type { ComprehensiveRelease, @@ -21,7 +23,12 @@ import * as context from './context.js' import { env } from './env.js' import { getChangedPackages } from './get-changed-packages.js' import type { LooseString } from './types.js' -import { getUsername, HTTP_STATUS_NOT_FOUND, TRUTHY_VALUES } from './utils.js' +import { + getOptionalInput, + getUsername, + HTTP_STATUS_NOT_FOUND, + TRUTHY_VALUES, +} from './utils.js' const generatedByBotNote = 'Generated By Changesets GitLab Bot' @@ -220,13 +227,15 @@ async function getNoteInfo( const hasChangesetBeenAdded = async ( changedFilesPromise: Promise, + changesetPrefix: string, ) => { const changedFiles = await changedFilesPromise return changedFiles.some(file => { return ( file.new_file && - /^\.changeset\/.+\.md$/.test(file.new_path) && - file.new_path !== '.changeset/README.md' + file.new_path.startsWith(changesetPrefix + '/') && + file.new_path.endsWith('.md') && + file.new_path !== changesetPrefix + '/README.md' ) }) } @@ -249,6 +258,12 @@ export const comment = async () => { return } + const cwdInput = getOptionalInput('cwd') + const changesetPrefix = cwdInput + ? `${cwdInput.replace(/\/$/, '')}/.changeset` + : '.changeset' + const absoluteCwd = path.resolve(process.cwd(), cwdInput ?? '.') + const api = createApi() let errFromFetchingChangedFiles = '' @@ -273,15 +288,23 @@ export const comment = async () => { return changes }) + const subdirPrefix = cwdInput ? cwdInput.replace(/\/$/, '') + '/' : '' + const packageChangedFiles = changedFilesPromise.then(changedFiles => + changedFiles.map(({ new_path }) => + subdirPrefix && new_path.startsWith(subdirPrefix) + ? new_path.slice(subdirPrefix.length) + : new_path, + ), + ) + const [noteInfo, hasChangeset, { changedPackages, releasePlan }] = await Promise.all([ getNoteInfo(api, mrIid, commentType), - hasChangesetBeenAdded(changedFilesPromise), + hasChangesetBeenAdded(changedFilesPromise, changesetPrefix), getChangedPackages({ - changedFiles: changedFilesPromise.then(changedFiles => - changedFiles.map(({ new_path }) => new_path), - ), + changedFiles: packageChangedFiles, api, + cwd: absoluteCwd, }).catch((err: unknown) => { if (err instanceof ValidationError) { errFromFetchingChangedFiles = `
💥 An error occurred when fetching the changed packages and changesets in this MR\n\n\`\`\`\n${err.message}\n\`\`\`\n\n
\n` @@ -295,7 +318,7 @@ export const comment = async () => { }), ] as const) - const newChangesetFileName = `.changeset/${humanId({ + const newChangesetFileName = `${changesetPrefix}/${humanId({ separator: '-', capitalize: false, })}.md` diff --git a/src/get-changed-packages.ts b/src/get-changed-packages.ts index 4e73dd91..268b6223 100644 --- a/src/get-changed-packages.ts +++ b/src/get-changed-packages.ts @@ -23,9 +23,11 @@ function fetchFile(path: string) { export const getChangedPackages = async ({ changedFiles: changedFilesPromise, + cwd = process.cwd(), }: { changedFiles: Promise | string[] api: Gitlab + cwd?: string // eslint-disable-next-line sonarjs/cognitive-complexity }) => { let hasErrored = false @@ -53,7 +55,7 @@ export const getChangedPackages = async ({ async function getPackage(pkgPath: string) { const jsonContent = await fetchJsonFile( - pkgPath + '/package.json', + nodePath.join(cwd, pkgPath, 'package.json'), ) return { packageJson: jsonContent, @@ -72,10 +74,12 @@ export const getChangedPackages = async ({ workspaces?: string[] } } - >('package.json') - const configPromise = fetchJsonFile('.changeset/config.json') + >(nodePath.join(cwd, 'package.json')) + const configPromise = fetchJsonFile( + nodePath.join(cwd, '.changeset/config.json'), + ) - const tree = await getAllFiles(process.cwd()) + const tree = await getAllFiles(cwd) let preStatePromise: Promise | undefined const changesetPromises: Array> = [] @@ -90,7 +94,7 @@ export const getChangedPackages = async ({ } else if (item === 'pnpm-workspace.yaml') { isPnpm = true } else if (item === '.changeset/pre.json') { - preStatePromise = fetchJsonFile('.changeset/pre.json') + preStatePromise = fetchJsonFile(nodePath.join(cwd, '.changeset/pre.json')) } else if ( item !== '.changeset/README.md' && item.startsWith('.changeset') && @@ -103,7 +107,7 @@ export const getChangedPackages = async ({ } const id = res[1] changesetPromises.push( - fetchTextFile(item).then(text => ({ + fetchTextFile(nodePath.join(cwd, item)).then(text => ({ ...parseChangeset(text), id, })), @@ -116,7 +120,9 @@ export const getChangedPackages = async ({ tool = { tool: 'pnpm', globs: ( - parse(await fetchTextFile('pnpm-workspace.yaml')) as { + parse( + await fetchTextFile(nodePath.join(cwd, 'pnpm-workspace.yaml')), + ) as { packages: string[] } ).packages, diff --git a/src/main.ts b/src/main.ts index 05232db1..993126e8 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,4 +1,5 @@ import fs from 'node:fs/promises' +import path from 'node:path' import { URL } from 'node:url' import { exportVariable, getInput, setOutput } from '@actions/core' @@ -52,7 +53,9 @@ export const main = async ({ ) } - const { changesets } = await readChangesetState() + const cwd = path.resolve(process.cwd(), getOptionalInput('cwd') ?? '.') + + const { changesets } = await readChangesetState(cwd) const publishScript = getInput('publish') const hasChangesets = changesets.length > 0 @@ -111,6 +114,7 @@ export const main = async ({ createGitlabReleases: !FALSY_VALUES.has( getInput('create_gitlab_releases'), ), + cwd, }) if (result.published) { @@ -133,6 +137,7 @@ export const main = async ({ commitMessage: getOptionalInput('commit'), removeSourceBranch: getInput('remove_source_branch') === 'true', hasPublishScript, + cwd, }) if (onlyChangesets) { execSync(onlyChangesets) From 680045f46553cb151a24f1f363c4c8916d09e3f1 Mon Sep 17 00:00:00 2001 From: bookchris Date: Wed, 4 Mar 2026 11:58:13 -0500 Subject: [PATCH 2/8] Add changeset for minor change. --- .changeset/dry-spoons-own.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/dry-spoons-own.md diff --git a/.changeset/dry-spoons-own.md b/.changeset/dry-spoons-own.md new file mode 100644 index 00000000..d5be5f19 --- /dev/null +++ b/.changeset/dry-spoons-own.md @@ -0,0 +1,5 @@ +--- +"changesets-gitlab": minor +--- + +feat: add an input to run at a subdirectory of the repo root From 9a5bb47c5c0181ad34e71e5ac58395e848833836 Mon Sep 17 00:00:00 2001 From: Chris Book Date: Mon, 9 Mar 2026 09:48:29 -0400 Subject: [PATCH 3/8] fix: address input normalization comment --- src/comment.ts | 12 +++++------- src/main.ts | 3 ++- src/utils.ts | 12 ++++++++++++ 3 files changed, 19 insertions(+), 8 deletions(-) diff --git a/src/comment.ts b/src/comment.ts index 40f4998a..051774fc 100644 --- a/src/comment.ts +++ b/src/comment.ts @@ -24,9 +24,9 @@ import { env } from './env.js' import { getChangedPackages } from './get-changed-packages.js' import type { LooseString } from './types.js' import { - getOptionalInput, getUsername, HTTP_STATUS_NOT_FOUND, + getCwdInput, TRUTHY_VALUES, } from './utils.js' @@ -258,11 +258,9 @@ export const comment = async () => { return } - const cwdInput = getOptionalInput('cwd') - const changesetPrefix = cwdInput - ? `${cwdInput.replace(/\/$/, '')}/.changeset` - : '.changeset' - const absoluteCwd = path.resolve(process.cwd(), cwdInput ?? '.') + const cwdRel = getCwdInput() + const changesetPrefix = cwdRel ? `${cwdRel}/.changeset` : '.changeset' + const absoluteCwd = path.resolve(process.cwd(), cwdRel || '.') const api = createApi() @@ -288,7 +286,7 @@ export const comment = async () => { return changes }) - const subdirPrefix = cwdInput ? cwdInput.replace(/\/$/, '') + '/' : '' + const subdirPrefix = cwdRel ? `${cwdRel}/` : '' const packageChangedFiles = changedFilesPromise.then(changedFiles => changedFiles.map(({ new_path }) => subdirPrefix && new_path.startsWith(subdirPrefix) diff --git a/src/main.ts b/src/main.ts index 993126e8..cd21c3ac 100644 --- a/src/main.ts +++ b/src/main.ts @@ -16,6 +16,7 @@ import { TRUTHY_VALUES, execSync, fileExists, + getCwdInput, getOptionalInput, getUsername, } from './utils.js' @@ -53,7 +54,7 @@ export const main = async ({ ) } - const cwd = path.resolve(process.cwd(), getOptionalInput('cwd') ?? '.') + const cwd = path.resolve(process.cwd(), getCwdInput() || '.') const { changesets } = await readChangesetState(cwd) diff --git a/src/utils.ts b/src/utils.ts index cb291ad5..82706dd2 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -157,6 +157,18 @@ export const execSync = (command: string) => export const getOptionalInput = (name: string) => getInput(name) || undefined +export const getCwdInput = (): string => { + const input = getOptionalInput('cwd') + if (!input) { + return '' + } + const normalized = input.replace(/^\.\//, '').replace(/\/$/, '') + if (path.isAbsolute(normalized) || normalized.split('/').includes('..')) { + throw new Error(`Invalid cwd input: "${input}"`) + } + return normalized === '.' ? '' : normalized +} + // eslint-disable-next-line sonarjs/function-return-type export const getUsername = (api: Gitlab) => { return ( From ed767d20a2928211efaa5b19e70c2501a705db81 Mon Sep 17 00:00:00 2001 From: Chris Book Date: Mon, 9 Mar 2026 12:13:54 -0400 Subject: [PATCH 4/8] fix: address package filtering comments --- src/comment.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/comment.ts b/src/comment.ts index 051774fc..2a1ed28f 100644 --- a/src/comment.ts +++ b/src/comment.ts @@ -288,11 +288,13 @@ export const comment = async () => { const subdirPrefix = cwdRel ? `${cwdRel}/` : '' const packageChangedFiles = changedFilesPromise.then(changedFiles => - changedFiles.map(({ new_path }) => - subdirPrefix && new_path.startsWith(subdirPrefix) - ? new_path.slice(subdirPrefix.length) - : new_path, - ), + changedFiles + .filter( + ({ new_path }) => !subdirPrefix || new_path.startsWith(subdirPrefix), + ) + .map(({ new_path }) => + subdirPrefix ? new_path.slice(subdirPrefix.length) : new_path, + ), ) const [noteInfo, hasChangeset, { changedPackages, releasePlan }] = From f876b6a78e873ea1067dc2240d8432ae18543686 Mon Sep 17 00:00:00 2001 From: JounQin Date: Sun, 9 Aug 2026 23:58:19 +0800 Subject: [PATCH 5/8] refactor: simplify getCwdInput using path.resolve + path.relative --- src/comment.ts | 20 +++++++++----------- src/main.ts | 3 +-- src/utils.ts | 12 +++++++----- 3 files changed, 17 insertions(+), 18 deletions(-) diff --git a/src/comment.ts b/src/comment.ts index 2a1ed28f..de76fcf9 100644 --- a/src/comment.ts +++ b/src/comment.ts @@ -1,5 +1,3 @@ -import path from 'node:path' - import { ValidationError } from '@changesets/errors' import type { ComprehensiveRelease, @@ -227,15 +225,15 @@ async function getNoteInfo( const hasChangesetBeenAdded = async ( changedFilesPromise: Promise, - changesetPrefix: string, + changesetDir: string, ) => { const changedFiles = await changedFilesPromise return changedFiles.some(file => { return ( file.new_file && - file.new_path.startsWith(changesetPrefix + '/') && + file.new_path.startsWith(changesetDir + '/') && file.new_path.endsWith('.md') && - file.new_path !== changesetPrefix + '/README.md' + file.new_path !== changesetDir + '/README.md' ) }) } @@ -258,9 +256,9 @@ export const comment = async () => { return } - const cwdRel = getCwdInput() - const changesetPrefix = cwdRel ? `${cwdRel}/.changeset` : '.changeset' - const absoluteCwd = path.resolve(process.cwd(), cwdRel || '.') + const { relative: relativeCwd, absolute: absoluteCwd } = getCwdInput() + + const changesetDir = relativeCwd ? `${relativeCwd}/.changeset` : '.changeset' const api = createApi() @@ -286,7 +284,7 @@ export const comment = async () => { return changes }) - const subdirPrefix = cwdRel ? `${cwdRel}/` : '' + const subdirPrefix = relativeCwd ? `${relativeCwd}/` : '' const packageChangedFiles = changedFilesPromise.then(changedFiles => changedFiles .filter( @@ -300,7 +298,7 @@ export const comment = async () => { const [noteInfo, hasChangeset, { changedPackages, releasePlan }] = await Promise.all([ getNoteInfo(api, mrIid, commentType), - hasChangesetBeenAdded(changedFilesPromise, changesetPrefix), + hasChangesetBeenAdded(changedFilesPromise, changesetDir), getChangedPackages({ changedFiles: packageChangedFiles, api, @@ -318,7 +316,7 @@ export const comment = async () => { }), ] as const) - const newChangesetFileName = `${changesetPrefix}/${humanId({ + const newChangesetFileName = `${changesetDir}/${humanId({ separator: '-', capitalize: false, })}.md` diff --git a/src/main.ts b/src/main.ts index cd21c3ac..6fd72123 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,5 +1,4 @@ import fs from 'node:fs/promises' -import path from 'node:path' import { URL } from 'node:url' import { exportVariable, getInput, setOutput } from '@actions/core' @@ -54,7 +53,7 @@ export const main = async ({ ) } - const cwd = path.resolve(process.cwd(), getCwdInput() || '.') + const { absolute: cwd } = getCwdInput() const { changesets } = await readChangesetState(cwd) diff --git a/src/utils.ts b/src/utils.ts index 82706dd2..1d6d5f80 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -157,16 +157,18 @@ export const execSync = (command: string) => export const getOptionalInput = (name: string) => getInput(name) || undefined -export const getCwdInput = (): string => { +export const getCwdInput = (): { relative: string; absolute: string } => { + const CWD = process.cwd() const input = getOptionalInput('cwd') if (!input) { - return '' + return { relative: '', absolute: CWD } } - const normalized = input.replace(/^\.\//, '').replace(/\/$/, '') - if (path.isAbsolute(normalized) || normalized.split('/').includes('..')) { + const absolute = path.resolve(CWD, input) + const relative = path.relative(CWD, absolute) + if (relative.startsWith('..')) { throw new Error(`Invalid cwd input: "${input}"`) } - return normalized === '.' ? '' : normalized + return { relative, absolute } } // eslint-disable-next-line sonarjs/function-return-type From b817178dbd3ee97146443b437c0360decd53d694 Mon Sep 17 00:00:00 2001 From: JounQin Date: Mon, 10 Aug 2026 00:33:24 +0800 Subject: [PATCH 6/8] refactor: use cwdPrefix instead of absolute cwd in getChangedPackages --- src/comment.ts | 25 ++++++++++++------------- src/get-changed-packages.ts | 27 ++++++++++++--------------- 2 files changed, 24 insertions(+), 28 deletions(-) diff --git a/src/comment.ts b/src/comment.ts index de76fcf9..9e904ab9 100644 --- a/src/comment.ts +++ b/src/comment.ts @@ -225,15 +225,15 @@ async function getNoteInfo( const hasChangesetBeenAdded = async ( changedFilesPromise: Promise, - changesetDir: string, + changesetDirPrefix: string, ) => { const changedFiles = await changedFilesPromise return changedFiles.some(file => { return ( file.new_file && - file.new_path.startsWith(changesetDir + '/') && + file.new_path.startsWith(changesetDirPrefix) && file.new_path.endsWith('.md') && - file.new_path !== changesetDir + '/README.md' + file.new_path !== changesetDirPrefix + 'README.md' ) }) } @@ -256,9 +256,11 @@ export const comment = async () => { return } - const { relative: relativeCwd, absolute: absoluteCwd } = getCwdInput() + const { relative: relativeCwd } = getCwdInput() - const changesetDir = relativeCwd ? `${relativeCwd}/.changeset` : '.changeset' + const cwdPrefix = relativeCwd ? `${relativeCwd}/` : '' + + const changesetDirPrefix = `${cwdPrefix}.changeset/` const api = createApi() @@ -284,25 +286,22 @@ export const comment = async () => { return changes }) - const subdirPrefix = relativeCwd ? `${relativeCwd}/` : '' const packageChangedFiles = changedFilesPromise.then(changedFiles => changedFiles - .filter( - ({ new_path }) => !subdirPrefix || new_path.startsWith(subdirPrefix), - ) + .filter(({ new_path }) => !cwdPrefix || new_path.startsWith(cwdPrefix)) .map(({ new_path }) => - subdirPrefix ? new_path.slice(subdirPrefix.length) : new_path, + cwdPrefix ? new_path.slice(cwdPrefix.length) : new_path, ), ) const [noteInfo, hasChangeset, { changedPackages, releasePlan }] = await Promise.all([ getNoteInfo(api, mrIid, commentType), - hasChangesetBeenAdded(changedFilesPromise, changesetDir), + hasChangesetBeenAdded(changedFilesPromise, changesetDirPrefix), getChangedPackages({ changedFiles: packageChangedFiles, api, - cwd: absoluteCwd, + cwdPrefix, }).catch((err: unknown) => { if (err instanceof ValidationError) { errFromFetchingChangedFiles = `
💥 An error occurred when fetching the changed packages and changesets in this MR\n\n\`\`\`\n${err.message}\n\`\`\`\n\n
\n` @@ -316,7 +315,7 @@ export const comment = async () => { }), ] as const) - const newChangesetFileName = `${changesetDir}/${humanId({ + const newChangesetFileName = `${changesetDirPrefix}${humanId({ separator: '-', capitalize: false, })}.md` diff --git a/src/get-changed-packages.ts b/src/get-changed-packages.ts index 268b6223..9de56089 100644 --- a/src/get-changed-packages.ts +++ b/src/get-changed-packages.ts @@ -1,5 +1,5 @@ import fs from 'node:fs/promises' -import nodePath from 'node:path' +import path from 'node:path' import assembleReleasePlan from '@changesets/assemble-release-plan' import { parse as parseConfig } from '@changesets/config' @@ -23,19 +23,18 @@ function fetchFile(path: string) { export const getChangedPackages = async ({ changedFiles: changedFilesPromise, - cwd = process.cwd(), + cwdPrefix = '', }: { changedFiles: Promise | string[] api: Gitlab - cwd?: string + cwdPrefix?: string // eslint-disable-next-line sonarjs/cognitive-complexity }) => { let hasErrored = false async function fetchJsonFile(path: string) { try { - const x = await fetchFile(path) - return JSON.parse(x) as T + return JSON.parse(await fetchFile(path)) as T } catch (err) { hasErrored = true console.error(err) @@ -55,7 +54,7 @@ export const getChangedPackages = async ({ async function getPackage(pkgPath: string) { const jsonContent = await fetchJsonFile( - nodePath.join(cwd, pkgPath, 'package.json'), + `${cwdPrefix}${pkgPath}/package.json`, ) return { packageJson: jsonContent, @@ -74,12 +73,12 @@ export const getChangedPackages = async ({ workspaces?: string[] } } - >(nodePath.join(cwd, 'package.json')) + >(`${cwdPrefix}package.json`) const configPromise = fetchJsonFile( - nodePath.join(cwd, '.changeset/config.json'), + `${cwdPrefix}.changeset/config.json`, ) - const tree = await getAllFiles(cwd) + const tree = await getAllFiles(cwdPrefix) let preStatePromise: Promise | undefined const changesetPromises: Array> = [] @@ -89,12 +88,12 @@ export const getChangedPackages = async ({ for (const item of tree) { if (item.endsWith('/package.json')) { - const dirPath = nodePath.dirname(item) + const dirPath = path.dirname(item) potentialWorkspaceDirectories.push(dirPath) } else if (item === 'pnpm-workspace.yaml') { isPnpm = true } else if (item === '.changeset/pre.json') { - preStatePromise = fetchJsonFile(nodePath.join(cwd, '.changeset/pre.json')) + preStatePromise = fetchJsonFile(`${cwdPrefix}${item}`) } else if ( item !== '.changeset/README.md' && item.startsWith('.changeset') && @@ -107,7 +106,7 @@ export const getChangedPackages = async ({ } const id = res[1] changesetPromises.push( - fetchTextFile(nodePath.join(cwd, item)).then(text => ({ + fetchTextFile(`${cwdPrefix}${item}`).then(text => ({ ...parseChangeset(text), id, })), @@ -120,9 +119,7 @@ export const getChangedPackages = async ({ tool = { tool: 'pnpm', globs: ( - parse( - await fetchTextFile(nodePath.join(cwd, 'pnpm-workspace.yaml')), - ) as { + parse(await fetchTextFile(`${cwdPrefix}pnpm-workspace.yaml`)) as { packages: string[] } ).packages, From 6c452fca886db8694a68a435714bf4b64fe1a3df Mon Sep 17 00:00:00 2001 From: JounQin Date: Mon, 10 Aug 2026 00:50:54 +0800 Subject: [PATCH 7/8] refactor: simplify filter+map to flatMap, inline fetchFile --- src/comment.ts | 19 +++++++++---------- src/get-changed-packages.ts | 24 ++++++++++-------------- test/get-changed-packages.spec.ts | 2 -- 3 files changed, 19 insertions(+), 26 deletions(-) diff --git a/src/comment.ts b/src/comment.ts index 9e904ab9..4afc4126 100644 --- a/src/comment.ts +++ b/src/comment.ts @@ -6,12 +6,12 @@ import type { } from '@changesets/types' import type { CommitDiffSchema, Gitlab } from '@gitbeaker/core' import { - GitbeakerRequestError, type DiscussionNoteSchema, type DiscussionSchema, type MergeRequestDiffSchema, type MergeRequestNoteSchema, type NoteSchema, + GitbeakerRequestError, } from '@gitbeaker/rest' import { humanId } from 'human-id' import { markdownTable } from 'markdown-table' @@ -22,10 +22,10 @@ import { env } from './env.js' import { getChangedPackages } from './get-changed-packages.js' import type { LooseString } from './types.js' import { - getUsername, HTTP_STATUS_NOT_FOUND, - getCwdInput, TRUTHY_VALUES, + getCwdInput, + getUsername, } from './utils.js' const generatedByBotNote = 'Generated By Changesets GitLab Bot' @@ -233,7 +233,7 @@ const hasChangesetBeenAdded = async ( file.new_file && file.new_path.startsWith(changesetDirPrefix) && file.new_path.endsWith('.md') && - file.new_path !== changesetDirPrefix + 'README.md' + file.new_path !== `${changesetDirPrefix}README.md` ) }) } @@ -287,11 +287,11 @@ export const comment = async () => { }) const packageChangedFiles = changedFilesPromise.then(changedFiles => - changedFiles - .filter(({ new_path }) => !cwdPrefix || new_path.startsWith(cwdPrefix)) - .map(({ new_path }) => - cwdPrefix ? new_path.slice(cwdPrefix.length) : new_path, - ), + changedFiles.flatMap(({ new_path }) => + new_path.startsWith(cwdPrefix) + ? [new_path.slice(cwdPrefix.length)] + : [], + ), ) const [noteInfo, hasChangeset, { changedPackages, releasePlan }] = @@ -300,7 +300,6 @@ export const comment = async () => { hasChangesetBeenAdded(changedFilesPromise, changesetDirPrefix), getChangedPackages({ changedFiles: packageChangedFiles, - api, cwdPrefix, }).catch((err: unknown) => { if (err instanceof ValidationError) { diff --git a/src/get-changed-packages.ts b/src/get-changed-packages.ts index 9de56089..b8934190 100644 --- a/src/get-changed-packages.ts +++ b/src/get-changed-packages.ts @@ -10,26 +10,24 @@ import type { NewChangeset, WrittenConfig, } from '@changesets/types' -import type { Gitlab } from '@gitbeaker/core' import type { Packages, Tool } from '@manypkg/get-packages' import micromatch from 'micromatch' import { parse } from 'yaml' import { getAllFiles } from './utils.js' -function fetchFile(path: string) { - return fs.readFile(path, 'utf8') -} - export const getChangedPackages = async ({ changedFiles: changedFilesPromise, cwdPrefix = '', }: { changedFiles: Promise | string[] - api: Gitlab cwdPrefix?: string // eslint-disable-next-line sonarjs/cognitive-complexity }) => { + function fetchFile(path: string) { + return fs.readFile(`${cwdPrefix}${path}`, 'utf8') + } + let hasErrored = false async function fetchJsonFile(path: string) { @@ -54,7 +52,7 @@ export const getChangedPackages = async ({ async function getPackage(pkgPath: string) { const jsonContent = await fetchJsonFile( - `${cwdPrefix}${pkgPath}/package.json`, + `${pkgPath}/package.json`, ) return { packageJson: jsonContent, @@ -73,10 +71,8 @@ export const getChangedPackages = async ({ workspaces?: string[] } } - >(`${cwdPrefix}package.json`) - const configPromise = fetchJsonFile( - `${cwdPrefix}.changeset/config.json`, - ) + >('package.json') + const configPromise = fetchJsonFile('.changeset/config.json') const tree = await getAllFiles(cwdPrefix) @@ -93,7 +89,7 @@ export const getChangedPackages = async ({ } else if (item === 'pnpm-workspace.yaml') { isPnpm = true } else if (item === '.changeset/pre.json') { - preStatePromise = fetchJsonFile(`${cwdPrefix}${item}`) + preStatePromise = fetchJsonFile(item) } else if ( item !== '.changeset/README.md' && item.startsWith('.changeset') && @@ -106,7 +102,7 @@ export const getChangedPackages = async ({ } const id = res[1] changesetPromises.push( - fetchTextFile(`${cwdPrefix}${item}`).then(text => ({ + fetchTextFile(item).then(text => ({ ...parseChangeset(text), id, })), @@ -119,7 +115,7 @@ export const getChangedPackages = async ({ tool = { tool: 'pnpm', globs: ( - parse(await fetchTextFile(`${cwdPrefix}pnpm-workspace.yaml`)) as { + parse(await fetchTextFile('pnpm-workspace.yaml')) as { packages: string[] } ).packages, diff --git a/test/get-changed-packages.spec.ts b/test/get-changed-packages.spec.ts index 16a0078a..dd84a257 100644 --- a/test/get-changed-packages.spec.ts +++ b/test/get-changed-packages.spec.ts @@ -56,7 +56,6 @@ describe('getChangedPackages', () => { test('does not match sibling packages with the same prefix', async () => { const result = await getChangedPackages({ changedFiles: ['packages/ui-kit-storybook/src/index.ts'], - api: undefined as never, }) expect(result.changedPackages).toEqual([]) @@ -65,7 +64,6 @@ describe('getChangedPackages', () => { test('matches files within the package directory', async () => { const result = await getChangedPackages({ changedFiles: ['packages/ui-kit/src/index.ts'], - api: undefined as never, }) expect(result.changedPackages).toEqual(['@example/ui-kit']) From 7251723165b501b410a737aa09edee79d1a6fa5c Mon Sep 17 00:00:00 2001 From: JounQin Date: Mon, 10 Aug 2026 01:10:38 +0800 Subject: [PATCH 8/8] fix: handle empty cwdPrefix in getAllFiles and tighten traversal guard --- package.json | 4 ++-- src/utils.ts | 7 ++++++- test/utils.spec.ts | 41 ++++++++++++++++++++++++++++++++++++++++- yarn.lock | 18 +++++++++--------- 4 files changed, 57 insertions(+), 13 deletions(-) diff --git a/package.json b/package.json index f8099286..fa5c14c2 100644 --- a/package.json +++ b/package.json @@ -96,7 +96,7 @@ "@types/global-agent": "^3.0.0", "@types/micromatch": "^4.0.9", "@types/web": "^0.0.228", - "@vitest/coverage-istanbul": "3.1.2", + "@vitest/coverage-istanbul": "^3.2.7", "clean-pkg-json": "^1.2.1", "eslint": "^9.24.0", "nano-staged": "^0.8.0", @@ -108,7 +108,7 @@ "tsx": "^4.19.3", "type-coverage": "^2.29.7", "typescript": "^5.8.3", - "vitest": "^3.1.1", + "vitest": "^3.2.7", "yarn-berry-deduplicate": "^6.1.1", "yarn-deduplicate": "^6.0.2" }, diff --git a/src/utils.ts b/src/utils.ts index 1d6d5f80..de753528 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -138,6 +138,7 @@ export const identify = ( > => !!_ export async function getAllFiles(dir: string, base = dir): Promise { + dir ||= '.' const direntList = await fs.readdir(dir, { withFileTypes: true }) const files = await Promise.all( // eslint-disable-next-line sonarjs/function-return-type, @typescript-eslint/await-thenable @@ -165,7 +166,11 @@ export const getCwdInput = (): { relative: string; absolute: string } => { } const absolute = path.resolve(CWD, input) const relative = path.relative(CWD, absolute) - if (relative.startsWith('..')) { + if ( + relative === '..' || + relative.startsWith(`..${path.sep}`) || + path.isAbsolute(relative) + ) { throw new Error(`Invalid cwd input: "${input}"`) } return { relative, absolute } diff --git a/test/utils.spec.ts b/test/utils.spec.ts index 74574f6c..bc05fea4 100644 --- a/test/utils.spec.ts +++ b/test/utils.spec.ts @@ -1,7 +1,46 @@ -import { getAllFiles } from '../src/utils.js' +import path from 'node:path' + +import { getAllFiles, getCwdInput } from '../src/utils.js' describe('utils', () => { test('getAllFiles', async () => { expect(await getAllFiles('test/fixtures')).toMatchSnapshot() }) + + test('getAllFiles with empty string', async () => { + const files = await getAllFiles('') + // Should treat empty string as cwd and not throw + expect(Array.isArray(files)).toBe(true) + expect(files.length).toBeGreaterThan(0) + }) + + describe('getCwdInput', () => { + const CWD = process.cwd() + + afterEach(() => { + delete process.env.INPUT_CWD + }) + + test('returns cwd when no input set', () => { + expect(getCwdInput()).toEqual({ relative: '', absolute: CWD }) + }) + + test('returns relative and absolute for valid subdirectory', () => { + process.env.INPUT_CWD = 'src' + expect(getCwdInput()).toEqual({ + relative: 'src', + absolute: path.join(CWD, 'src'), + }) + }) + + test('throws for parent directory traversal', () => { + process.env.INPUT_CWD = '..' + expect(() => getCwdInput()).toThrow('Invalid cwd input') + }) + + test('throws for path with ../', () => { + process.env.INPUT_CWD = 'foo/../../bar' + expect(() => getCwdInput()).toThrow('Invalid cwd input') + }) + }) }) diff --git a/yarn.lock b/yarn.lock index fc5e2567..6be0d71f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4472,12 +4472,12 @@ __metadata: languageName: node linkType: hard -"@vitest/coverage-istanbul@npm:3.1.2": - version: 3.1.2 - resolution: "@vitest/coverage-istanbul@npm:3.1.2" +"@vitest/coverage-istanbul@npm:^3.2.7": + version: 3.2.7 + resolution: "@vitest/coverage-istanbul@npm:3.2.7" dependencies: "@istanbuljs/schema": "npm:^0.1.3" - debug: "npm:^4.4.0" + debug: "npm:^4.4.1" istanbul-lib-coverage: "npm:^3.2.2" istanbul-lib-instrument: "npm:^6.0.3" istanbul-lib-report: "npm:^3.0.1" @@ -4487,8 +4487,8 @@ __metadata: test-exclude: "npm:^7.0.1" tinyrainbow: "npm:^2.0.0" peerDependencies: - vitest: 3.1.2 - checksum: 10c0/0b01b9c76495b63d303e34ad113ecd32fc21fcc6f212f9b3dd0771f5f89d8c2726fe2d62c51e88e5a738fa7f7088645d84a7257adaad1e569420a13ee1d1c48a + vitest: 3.2.7 + checksum: 10c0/4a322a9aca6f321efed553c24cff5e02cd2d786bae3d79eadf871cfc22e4d8818e166edc18c0e2826dbbabbbd4c35a89e703b6a0dae2cc6d8c2d40ebf444e06c languageName: node linkType: hard @@ -5506,7 +5506,7 @@ __metadata: "@types/global-agent": "npm:^3.0.0" "@types/micromatch": "npm:^4.0.9" "@types/web": "npm:^0.0.228" - "@vitest/coverage-istanbul": "npm:3.1.2" + "@vitest/coverage-istanbul": "npm:^3.2.7" clean-pkg-json: "npm:^1.2.1" commander: "npm:^13.1.0" dotenv: "npm:^16.5.0" @@ -5531,7 +5531,7 @@ __metadata: type-coverage: "npm:^2.29.7" typescript: "npm:^5.8.3" unified: "npm:^11.0.5" - vitest: "npm:^3.1.1" + vitest: "npm:^3.2.7" yaml: "npm:^2.7.1" yarn-berry-deduplicate: "npm:^6.1.1" yarn-deduplicate: "npm:^6.0.2" @@ -13467,7 +13467,7 @@ __metadata: languageName: node linkType: hard -"vitest@npm:^3.1.1": +"vitest@npm:^3.2.7": version: 3.2.7 resolution: "vitest@npm:3.2.7" dependencies: