Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/dry-spoons-own.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"changesets-gitlab": minor
---

feat: add an input to run at a subdirectory of the repo root
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
39 changes: 31 additions & 8 deletions src/comment.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import path from 'node:path'

import { ValidationError } from '@changesets/errors'
import type {
ComprehensiveRelease,
Expand All @@ -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 {
getUsername,
HTTP_STATUS_NOT_FOUND,
getCwdInput,
TRUTHY_VALUES,
} from './utils.js'

const generatedByBotNote = 'Generated By Changesets GitLab Bot'

Expand Down Expand Up @@ -220,13 +227,15 @@ async function getNoteInfo(

const hasChangesetBeenAdded = async (
changedFilesPromise: Promise<CommitDiffSchema[] | MergeRequestDiffSchema[]>,
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'
)
})
}
Expand All @@ -249,6 +258,10 @@ export const comment = async () => {
return
}

const cwdRel = getCwdInput()
const changesetPrefix = cwdRel ? `${cwdRel}/.changeset` : '.changeset'
const absoluteCwd = path.resolve(process.cwd(), cwdRel || '.')

const api = createApi()

let errFromFetchingChangedFiles = ''
Expand All @@ -273,15 +286,25 @@ export const comment = async () => {
return changes
})

const subdirPrefix = cwdRel ? `${cwdRel}/` : ''
const packageChangedFiles = changedFilesPromise.then(changedFiles =>
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 }] =
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 = `<details><summary>💥 An error occurred when fetching the changed packages and changesets in this MR</summary>\n\n\`\`\`\n${err.message}\n\`\`\`\n\n</details>\n`
Expand All @@ -295,7 +318,7 @@ export const comment = async () => {
}),
] as const)

const newChangesetFileName = `.changeset/${humanId({
const newChangesetFileName = `${changesetPrefix}/${humanId({
separator: '-',
capitalize: false,
})}.md`
Comment on lines +321 to 324
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

newChangesetFileName is now partially derived from user input (INPUT_CWD). It’s later used as the file_name query parameter in addChangesetUrl; since that parameter value isn’t URL-encoded, spaces or other special characters in cwd can produce a broken link. Encode the file_name parameter (or further restrict allowed characters in cwd).

Copilot uses AI. Check for mistakes.
Expand Down
20 changes: 13 additions & 7 deletions src/get-changed-packages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,11 @@ function fetchFile(path: string) {

export const getChangedPackages = async ({
changedFiles: changedFilesPromise,
cwd = process.cwd(),
}: {
changedFiles: Promise<string[]> | string[]
api: Gitlab
cwd?: string
// eslint-disable-next-line sonarjs/cognitive-complexity
}) => {
let hasErrored = false
Expand Down Expand Up @@ -53,7 +55,7 @@ export const getChangedPackages = async ({

async function getPackage(pkgPath: string) {
const jsonContent = await fetchJsonFile<PackageJSON>(
pkgPath + '/package.json',
nodePath.join(cwd, pkgPath, 'package.json'),
)
return {
packageJson: jsonContent,
Expand All @@ -72,10 +74,12 @@ export const getChangedPackages = async ({
workspaces?: string[]
}
}
>('package.json')
const configPromise = fetchJsonFile<WrittenConfig>('.changeset/config.json')
>(nodePath.join(cwd, 'package.json'))
const configPromise = fetchJsonFile<WrittenConfig>(
nodePath.join(cwd, '.changeset/config.json'),
)

const tree = await getAllFiles(process.cwd())
const tree = await getAllFiles(cwd)

let preStatePromise: Promise<PreState> | undefined
const changesetPromises: Array<Promise<NewChangeset>> = []
Expand All @@ -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') &&
Expand All @@ -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,
})),
Expand All @@ -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,
Expand Down
8 changes: 7 additions & 1 deletion src/main.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import fs from 'node:fs'
import path from 'node:path'
import { URL } from 'node:url'

import { getInput, setFailed, setOutput, exportVariable } from '@actions/core'
Expand All @@ -15,6 +16,7 @@ import {
FALSY_VALUES,
getOptionalInput,
getUsername,
getCwdInput,
TRUTHY_VALUES,
} from './utils.js'

Expand Down Expand Up @@ -50,7 +52,9 @@ export const main = async ({
)
}

const { changesets } = await readChangesetState()
const cwd = path.resolve(process.cwd(), getCwdInput() || '.')

const { changesets } = await readChangesetState(cwd)

const publishScript = getInput('publish')
const hasChangesets = changesets.length > 0
Expand Down Expand Up @@ -88,6 +92,7 @@ export const main = async ({
createGitlabReleases: !FALSY_VALUES.has(
getInput('create_gitlab_releases'),
),
cwd,
})

if (result.published) {
Expand All @@ -110,6 +115,7 @@ export const main = async ({
commitMessage: getOptionalInput('commit'),
removeSourceBranch: getInput('remove_source_branch') === 'true',
hasPublishScript,
cwd,
})
if (onlyChangesets) {
execSync(onlyChangesets)
Expand Down
12 changes: 12 additions & 0 deletions src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,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
}
Comment on lines +164 to +174
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getCwdInput adds non-trivial normalization/validation logic (e.g., stripping ./ and rejecting absolute paths / .. segments) but there are no unit tests covering the accepted/rejected cases. Since this file already has Vitest coverage (getAllFiles), consider adding tests for getCwdInput (empty/default, './', trailing slash, absolute path, and '..' traversal).

Copilot uses AI. Check for mistakes.

// eslint-disable-next-line sonarjs/function-return-type
export const getUsername = (api: Gitlab) => {
return (
Expand Down
Loading