From 761e4f579152c2880c3491e9fe9c65c6ac4b7102 Mon Sep 17 00:00:00 2001
From: Cursor Agent
Date: Thu, 4 Dec 2025 15:12:47 +0000
Subject: [PATCH 01/15] feat: Allow custom changelog entries from PR
descriptions
Co-authored-by: daniel.szoke
---
README.md | 28 ++
src/utils/__tests__/changelog.test.ts | 521 +++++++++++++++++++++++++-
src/utils/changelog.ts | 60 ++-
3 files changed, 599 insertions(+), 10 deletions(-)
diff --git a/README.md b/README.md
index 182fc7e5..8945fcc3 100644
--- a/README.md
+++ b/README.md
@@ -400,6 +400,34 @@ In `auto` mode, `craft prepare` will use the following logic:
Check out [GitHub's release notes documentation](https://docs.github.com/en/repositories/releasing-projects-on-github/automatically-generated-release-notes#configuration-options)
for the base configuration format.
+**Custom Changelog Entries from PR Descriptions**
+
+By default, the changelog entry for a PR is generated from its title. However,
+PR authors can override this by adding a "Changelog Entry" section to the PR
+description. This allows for more detailed, user-facing changelog entries without
+cluttering the PR title.
+
+To use this feature, add a markdown heading (level 2 or 3) titled "Changelog Entry"
+to your PR description, followed by the desired changelog text:
+
+```markdown
+### Description
+
+Add `foo` function, and add unit tests to thoroughly check all edge cases.
+
+### Changelog Entry
+
+Add a new function called `foo` which prints "Hello, world!"
+
+### Issues
+
+Closes #123
+```
+
+The text under "Changelog Entry" will be used verbatim in the changelog instead
+of the PR title. If no such section is present, the PR title is used as usual.
+Multi-line entries and markdown formatting are supported.
+
**Default Conventional Commits Configuration**
If `.github/release.yml` doesn't exist or has no `changelog` section, craft uses
diff --git a/src/utils/__tests__/changelog.test.ts b/src/utils/__tests__/changelog.test.ts
index 7b266135..0724d2b3 100644
--- a/src/utils/__tests__/changelog.test.ts
+++ b/src/utils/__tests__/changelog.test.ts
@@ -25,6 +25,7 @@ import {
generateChangesetFromGit,
extractScope,
formatScopeTitle,
+ extractChangelogEntry,
SKIP_CHANGELOG_MAGIC_WORD,
BODY_IN_CHANGELOG_MAGIC_WORD,
} from '../changelog';
@@ -1903,14 +1904,14 @@ describe('generateChangesetFromGit', () => {
headerPattern: RegExp
): string | null {
const match = markdown.match(headerPattern);
- if (!match) return null;
+ if (!match || match.index === undefined) return null;
- const startIndex = match.index! + match[0].length;
+ const startIndex = match.index + match[0].length;
// Find the next header (### or ####)
const restOfContent = markdown.slice(startIndex);
const nextHeaderMatch = restOfContent.match(/^#{3,4} /m);
- const endIndex = nextHeaderMatch
- ? startIndex + nextHeaderMatch.index!
+ const endIndex = nextHeaderMatch && nextHeaderMatch.index !== undefined
+ ? startIndex + nextHeaderMatch.index
: markdown.length;
return markdown.slice(startIndex, endIndex).trim();
@@ -2532,6 +2533,332 @@ describe('generateChangesetFromGit', () => {
expect(myComponentSection).toContain('feat(my_component): feature with underscores');
});
});
+
+ describe('custom changelog entries', () => {
+ it('should use custom changelog entry from PR body instead of PR title', async () => {
+ setup(
+ [
+ {
+ hash: 'abc123',
+ title: 'feat: Add `foo` function',
+ body: '',
+ pr: {
+ remote: {
+ number: '1',
+ author: { login: 'alice' },
+ body: `### Description
+
+Add \`foo\` function, and add unit tests to thoroughly check all edge cases.
+
+### Changelog Entry
+
+Add a new function called \`foo\` which prints "Hello, world!"
+
+### Issues
+
+Closes #123`,
+ labels: [],
+ },
+ },
+ },
+ ],
+ null
+ );
+
+ const changes = await generateChangesetFromGit(dummyGit, '1.0.0', 10);
+
+ // Should use the custom changelog entry, not the PR title
+ expect(changes).toContain(
+ 'Add a new function called `foo` which prints "Hello, world!"'
+ );
+ expect(changes).not.toContain('feat: Add `foo` function');
+ });
+
+ it('should fall back to PR title when no changelog entry section exists', async () => {
+ setup(
+ [
+ {
+ hash: 'abc123',
+ title: 'feat: Add bar function',
+ body: '',
+ pr: {
+ remote: {
+ number: '1',
+ author: { login: 'alice' },
+ body: `### Description
+
+Add bar function with tests.
+
+### Issues
+
+Closes #456`,
+ labels: [],
+ },
+ },
+ },
+ ],
+ null
+ );
+
+ const changes = await generateChangesetFromGit(dummyGit, '1.0.0', 10);
+
+ // Should use PR title when no custom changelog entry exists
+ expect(changes).toContain('feat: Add bar function');
+ });
+
+ it('should handle multiple PRs with mixed custom and default changelog entries', async () => {
+ setup(
+ [
+ {
+ hash: 'abc123',
+ title: 'feat: Add feature A',
+ body: '',
+ pr: {
+ remote: {
+ number: '1',
+ author: { login: 'alice' },
+ body: `### Changelog Entry
+
+Custom entry for feature A`,
+ labels: [],
+ },
+ },
+ },
+ {
+ hash: 'def456',
+ title: 'feat: Add feature B',
+ body: '',
+ pr: {
+ remote: {
+ number: '2',
+ author: { login: 'bob' },
+ body: 'No changelog entry section here',
+ labels: [],
+ },
+ },
+ },
+ {
+ hash: 'ghi789',
+ title: 'fix: Fix bug C',
+ body: '',
+ pr: {
+ remote: {
+ number: '3',
+ author: { login: 'charlie' },
+ body: `## Changelog Entry
+
+Custom entry for bug fix C`,
+ labels: [],
+ },
+ },
+ },
+ ],
+ null
+ );
+
+ const changes = await generateChangesetFromGit(dummyGit, '1.0.0', 10);
+
+ expect(changes).toContain('Custom entry for feature A');
+ expect(changes).toContain('feat: Add feature B');
+ expect(changes).toContain('Custom entry for bug fix C');
+ expect(changes).not.toContain('feat: Add feature A');
+ expect(changes).not.toContain('fix: Fix bug C');
+ });
+
+ it('should work with changelog entry in categorized PRs', async () => {
+ setup(
+ [
+ {
+ hash: 'abc123',
+ title: 'Add new API endpoint',
+ body: '',
+ pr: {
+ remote: {
+ number: '1',
+ author: { login: 'alice' },
+ body: '### Changelog Entry\n\nIntroduce a powerful new /api/v2/users endpoint with filtering support',
+ labels: ['feature'],
+ },
+ },
+ },
+ {
+ hash: 'def456',
+ title: 'Fix memory leak',
+ body: '',
+ pr: {
+ remote: {
+ number: '2',
+ author: { login: 'bob' },
+ body: '### Changelog Entry\n\nResolve critical memory leak that occurred during large file uploads',
+ labels: ['bug'],
+ },
+ },
+ },
+ ],
+ `changelog:
+ categories:
+ - title: Features
+ labels:
+ - feature
+ - title: Bug Fixes
+ labels:
+ - bug`
+ );
+
+ const changes = await generateChangesetFromGit(dummyGit, '1.0.0', 10);
+
+ expect(changes).toContain('### Features');
+ expect(changes).toContain('### Bug Fixes');
+ expect(changes).toContain(
+ 'Introduce a powerful new /api/v2/users endpoint with filtering support'
+ );
+ expect(changes).toContain(
+ 'Resolve critical memory leak that occurred during large file uploads'
+ );
+ expect(changes).not.toContain('Add new API endpoint');
+ expect(changes).not.toContain('Fix memory leak');
+ });
+
+ it('should preserve scope extraction when using custom changelog entry', async () => {
+ setup(
+ [
+ {
+ hash: 'abc123',
+ title: 'feat(api): Add endpoint',
+ body: '',
+ pr: {
+ remote: {
+ number: '1',
+ author: { login: 'alice' },
+ body: `### Changelog Entry
+
+Add powerful new endpoint for user management`,
+ labels: [],
+ },
+ },
+ },
+ {
+ hash: 'def456',
+ title: 'feat(api): Add another endpoint',
+ body: '',
+ pr: {
+ remote: {
+ number: '2',
+ author: { login: 'bob' },
+ body: `### Changelog Entry
+
+Add endpoint for data export`,
+ labels: [],
+ },
+ },
+ },
+ ],
+ null
+ );
+
+ const changes = await generateChangesetFromGit(dummyGit, '1.0.0', 10);
+
+ // Should still group by scope even with custom changelog entries
+ expect(changes).toContain('#### Api');
+ expect(changes).toContain('Add powerful new endpoint for user management');
+ expect(changes).toContain('Add endpoint for data export');
+ });
+
+ it('should work with changelog entry in "Other" section', async () => {
+ setup(
+ [
+ {
+ hash: 'abc123',
+ title: 'Update dependencies',
+ body: '',
+ pr: {
+ remote: {
+ number: '1',
+ author: { login: 'alice' },
+ body: `### Changelog Entry
+
+Update all dependencies to their latest versions for improved security`,
+ labels: [],
+ },
+ },
+ },
+ ],
+ null
+ );
+
+ const changes = await generateChangesetFromGit(dummyGit, '1.0.0', 10);
+
+ expect(changes).toContain(
+ 'Update all dependencies to their latest versions for improved security'
+ );
+ expect(changes).not.toContain('Update dependencies');
+ });
+
+ it('should handle multi-line custom changelog entries', async () => {
+ setup(
+ [
+ {
+ hash: 'abc123',
+ title: 'feat: Big feature',
+ body: '',
+ pr: {
+ remote: {
+ number: '1',
+ author: { login: 'alice' },
+ body: `### Changelog Entry
+
+Add comprehensive user authentication system with the following features:
+- OAuth2 support
+- Two-factor authentication
+- Session management`,
+ labels: [],
+ },
+ },
+ },
+ ],
+ null
+ );
+
+ const changes = await generateChangesetFromGit(dummyGit, '1.0.0', 10);
+
+ expect(changes).toContain(
+ 'Add comprehensive user authentication system with the following features:'
+ );
+ expect(changes).toContain('- OAuth2 support');
+ expect(changes).toContain('- Two-factor authentication');
+ expect(changes).toContain('- Session management');
+ });
+
+ it('should ignore empty changelog entry sections', async () => {
+ setup(
+ [
+ {
+ hash: 'abc123',
+ title: 'feat: Add feature',
+ body: '',
+ pr: {
+ remote: {
+ number: '1',
+ author: { login: 'alice' },
+ body: `### Changelog Entry
+
+### Issues
+
+Closes #123`,
+ labels: [],
+ },
+ },
+ },
+ ],
+ null
+ );
+
+ const changes = await generateChangesetFromGit(dummyGit, '1.0.0', 10);
+
+ // Should fall back to PR title when changelog entry is empty
+ expect(changes).toContain('feat: Add feature');
+ });
+ });
});
describe('extractScope', () => {
@@ -2577,3 +2904,189 @@ describe('formatScopeTitle', () => {
expect(formatScopeTitle(scope)).toBe(expected);
});
});
+
+describe('extractChangelogEntry', () => {
+ it('should extract content from "### Changelog Entry" section', () => {
+ const prBody = `### Description
+
+This PR adds a new feature.
+
+### Changelog Entry
+
+Add a new function called \`foo\` which prints "Hello, world!"
+
+### Issues
+
+Closes #123`;
+
+ expect(extractChangelogEntry(prBody)).toBe(
+ 'Add a new function called `foo` which prints "Hello, world!"'
+ );
+ });
+
+ it('should extract content from "## Changelog Entry" section', () => {
+ const prBody = `## Description
+
+This PR adds a new feature.
+
+## Changelog Entry
+
+Add a new function called \`foo\` which prints "Hello, world!"
+
+## Issues
+
+Closes #123`;
+
+ expect(extractChangelogEntry(prBody)).toBe(
+ 'Add a new function called `foo` which prints "Hello, world!"'
+ );
+ });
+
+ it('should handle changelog entry at the end of PR body', () => {
+ const prBody = `### Description
+
+This PR adds a new feature.
+
+### Changelog Entry
+
+This is the last section with no sections after it.`;
+
+ expect(extractChangelogEntry(prBody)).toBe(
+ 'This is the last section with no sections after it.'
+ );
+ });
+
+ it('should be case-insensitive', () => {
+ const prBody = `### Description
+
+This PR adds a new feature.
+
+### changelog entry
+
+Custom changelog text here
+
+### Issues
+
+Closes #123`;
+
+ expect(extractChangelogEntry(prBody)).toBe('Custom changelog text here');
+ });
+
+ it('should handle multiple lines in changelog entry', () => {
+ const prBody = `### Description
+
+Description here
+
+### Changelog Entry
+
+This is a multi-line
+changelog entry that
+spans several lines.
+
+### Issues
+
+Closes #123`;
+
+ expect(extractChangelogEntry(prBody)).toBe(
+ 'This is a multi-line\nchangelog entry that\nspans several lines.'
+ );
+ });
+
+ it('should handle changelog entry with markdown formatting', () => {
+ const prBody = `### Changelog Entry
+
+- Add **bold** feature
+- Add *italic* feature
+- Add \`code\` feature`;
+
+ expect(extractChangelogEntry(prBody)).toBe(
+ '- Add **bold** feature\n- Add *italic* feature\n- Add `code` feature'
+ );
+ });
+
+ it('should return null when no changelog entry section exists', () => {
+ const prBody = `### Description
+
+This PR adds a new feature.
+
+### Issues
+
+Closes #123`;
+
+ expect(extractChangelogEntry(prBody)).toBeNull();
+ });
+
+ it('should return null when prBody is null', () => {
+ expect(extractChangelogEntry(null)).toBeNull();
+ });
+
+ it('should return null when prBody is empty string', () => {
+ expect(extractChangelogEntry('')).toBeNull();
+ });
+
+ it('should return null when changelog entry section is empty', () => {
+ const prBody = `### Description
+
+This PR adds a new feature.
+
+### Changelog Entry
+
+### Issues
+
+Closes #123`;
+
+ expect(extractChangelogEntry(prBody)).toBeNull();
+ });
+
+ it('should handle changelog entry with trailing/leading whitespace', () => {
+ const prBody = `### Changelog Entry
+
+ This has leading whitespace
+
+### Issues`;
+
+ expect(extractChangelogEntry(prBody)).toBe('This has leading whitespace');
+ });
+
+ it('should handle changelog entry header with trailing hashes', () => {
+ const prBody = `### Description
+
+This PR adds a new feature.
+
+### Changelog Entry ###
+
+Custom changelog text
+
+### Issues`;
+
+ expect(extractChangelogEntry(prBody)).toBe('Custom changelog text');
+ });
+
+ it('should not match "Changelog Entry" in regular text', () => {
+ const prBody = `### Description
+
+This PR adds Changelog Entry functionality.
+
+### Issues
+
+Closes #123`;
+
+ expect(extractChangelogEntry(prBody)).toBeNull();
+ });
+
+ it('should extract only until next heading, not all remaining text', () => {
+ const prBody = `### Changelog Entry
+
+This is the changelog.
+
+### Issues
+
+This should not be included.
+
+### More Sections
+
+Neither should this.`;
+
+ expect(extractChangelogEntry(prBody)).toBe('This is the changelog.');
+ });
+});
diff --git a/src/utils/changelog.ts b/src/utils/changelog.ts
index 6796bd7c..38553cf4 100644
--- a/src/utils/changelog.ts
+++ b/src/utils/changelog.ts
@@ -91,6 +91,46 @@ export function formatScopeTitle(scope: string): string {
.replace(/\b\w/g, char => char.toUpperCase());
}
+/**
+ * Extracts the "Changelog Entry" section from a PR description.
+ * This allows PR authors to override the default changelog entry (which is the PR title)
+ * with custom text that's more user-facing and detailed.
+ *
+ * Looks for a markdown heading (either ### or ##) with the text "Changelog Entry"
+ * and extracts the content until the next heading of the same or higher level.
+ *
+ * @param prBody The PR description/body text
+ * @returns The extracted changelog entry text, or null if no "Changelog Entry" section is found
+ */
+export function extractChangelogEntry(prBody: string | null | undefined): string | null {
+ if (!prBody) {
+ return null;
+ }
+
+ // Match markdown headings (## or ###) followed by "Changelog Entry" (case-insensitive)
+ // This matches both with and without the # at the end (e.g., "## Changelog Entry ##" or "## Changelog Entry")
+ const headerRegex = /^#{2,3}\s+Changelog Entry\s*(?:#{2,3})?\s*$/im;
+ const match = prBody.match(headerRegex);
+
+ if (!match || match.index === undefined) {
+ return null;
+ }
+
+ // Find the start of the content (after the heading line)
+ const startIndex = match.index + match[0].length;
+ const restOfBody = prBody.slice(startIndex);
+
+ // Find the next heading of level 2 or 3 (## or ###)
+ const nextHeaderMatch = restOfBody.match(/^#{2,3}\s+/m);
+ const endIndex = nextHeaderMatch?.index ?? restOfBody.length;
+
+ // Extract and trim the content
+ const content = restOfBody.slice(0, endIndex).trim();
+
+ // Return null if the section is empty
+ return content || null;
+}
+
/**
* Extracts a specific changeset from a markdown document
*
@@ -733,12 +773,16 @@ export async function generateChangesetFromGit(
category.scopeGroups.set(scope, scopeGroup);
}
+ // Check for a custom changelog entry in the PR body
+ const customChangelogEntry = extractChangelogEntry(commit.prBody);
+ const changelogTitle = customChangelogEntry ?? prTitle;
+
scopeGroup.push({
author: commit.author,
number: commit.pr,
hash: commit.hash,
body: commit.prBody ?? '',
- title: prTitle,
+ title: changelogTitle,
});
}
}
@@ -833,9 +877,13 @@ export async function generateChangesetFromGit(
changelogSections.push(
leftovers
.slice(0, maxLeftovers)
- .map(commit =>
- formatChangelogEntry({
- title: commit.prTitle ?? commit.title,
+ .map(commit => {
+ // Check for a custom changelog entry in the PR body
+ const customChangelogEntry = extractChangelogEntry(commit.prBody);
+ const changelogTitle = customChangelogEntry ?? commit.prTitle ?? commit.title;
+
+ return formatChangelogEntry({
+ title: changelogTitle,
author: commit.author,
prNumber: commit.pr ?? undefined,
hash: commit.hash,
@@ -846,8 +894,8 @@ export async function generateChangesetFromGit(
: commit.body.includes(BODY_IN_CHANGELOG_MAGIC_WORD)
? commit.body
: undefined,
- })
- )
+ });
+ })
.join('\n')
);
if (nLeftovers > maxLeftovers) {
From 44d8b9e7d5f8111ab15d063c4689ac1c45fccb3f Mon Sep 17 00:00:00 2001
From: Cursor Agent
Date: Thu, 4 Dec 2025 15:26:37 +0000
Subject: [PATCH 02/15] feat: Support multiple and nested changelog entries
Co-authored-by: daniel.szoke
---
README.md | 41 ++++-
src/utils/__tests__/changelog.test.ts | 222 ++++++++++++++++++++++----
src/utils/changelog.ts | 209 ++++++++++++++++++++----
3 files changed, 408 insertions(+), 64 deletions(-)
diff --git a/README.md b/README.md
index 8945fcc3..30639e39 100644
--- a/README.md
+++ b/README.md
@@ -426,7 +426,46 @@ Closes #123
The text under "Changelog Entry" will be used verbatim in the changelog instead
of the PR title. If no such section is present, the PR title is used as usual.
-Multi-line entries and markdown formatting are supported.
+
+**Advanced Features:**
+
+1. **Multiple Entries**: If you use multiple top-level bullet points in the
+ "Changelog Entry" section, each bullet will become a separate changelog entry:
+
+ ```markdown
+ ### Changelog Entry
+
+ - Add OAuth2 authentication
+ - Add two-factor authentication
+ - Add session management
+ ```
+
+2. **Nested Content**: Indented bullets (4+ spaces or tabs) are preserved as
+ nested content under their parent entry:
+
+ ```markdown
+ ### Changelog Entry
+
+ - Add authentication system
+ - OAuth2 support
+ - Two-factor authentication
+ - Session management
+ ```
+
+ This will generate:
+ ```markdown
+ - Add authentication system by @user in [#123](url)
+ - OAuth2 support
+ - Two-factor authentication
+ - Session management
+ ```
+
+3. **Plain Text**: If no bullets are used, the entire content is treated as a
+ single changelog entry. Multi-line text is supported.
+
+4. **Content Isolation**: Only content within the "Changelog Entry" section is
+ included in the changelog. Other sections (Description, Issues, etc.) are
+ ignored.
**Default Conventional Commits Configuration**
diff --git a/src/utils/__tests__/changelog.test.ts b/src/utils/__tests__/changelog.test.ts
index 0724d2b3..cc24674a 100644
--- a/src/utils/__tests__/changelog.test.ts
+++ b/src/utils/__tests__/changelog.test.ts
@@ -2794,7 +2794,7 @@ Update all dependencies to their latest versions for improved security`,
expect(changes).not.toContain('Update dependencies');
});
- it('should handle multi-line custom changelog entries', async () => {
+ it('should handle multi-line custom changelog entries with nested bullets', async () => {
setup(
[
{
@@ -2807,10 +2807,10 @@ Update all dependencies to their latest versions for improved security`,
author: { login: 'alice' },
body: `### Changelog Entry
-Add comprehensive user authentication system with the following features:
-- OAuth2 support
-- Two-factor authentication
-- Session management`,
+- Add comprehensive user authentication system
+ - OAuth2 support
+ - Two-factor authentication
+ - Session management`,
labels: [],
},
},
@@ -2822,11 +2822,83 @@ Add comprehensive user authentication system with the following features:
const changes = await generateChangesetFromGit(dummyGit, '1.0.0', 10);
expect(changes).toContain(
- 'Add comprehensive user authentication system with the following features:'
+ 'Add comprehensive user authentication system by @alice in [#1]'
);
- expect(changes).toContain('- OAuth2 support');
- expect(changes).toContain('- Two-factor authentication');
- expect(changes).toContain('- Session management');
+ expect(changes).toContain(' OAuth2 support');
+ expect(changes).toContain(' Two-factor authentication');
+ expect(changes).toContain(' Session management');
+ });
+
+ it('should create multiple changelog entries from multiple bullets in PR', async () => {
+ setup(
+ [
+ {
+ hash: 'abc123',
+ title: 'feat: Multiple features',
+ body: '',
+ pr: {
+ remote: {
+ number: '1',
+ author: { login: 'alice' },
+ body: `### Changelog Entry
+
+- Add OAuth2 authentication
+- Add two-factor authentication
+- Add session management`,
+ labels: [],
+ },
+ },
+ },
+ ],
+ null
+ );
+
+ const changes = await generateChangesetFromGit(dummyGit, '1.0.0', 10);
+
+ // Should have 3 separate changelog entries from the same PR
+ expect(changes).toContain('Add OAuth2 authentication by @alice in [#1]');
+ expect(changes).toContain('Add two-factor authentication by @alice in [#1]');
+ expect(changes).toContain('Add session management by @alice in [#1]');
+ });
+
+ it('should handle multiple bullets with nested content in changelog entries', async () => {
+ setup(
+ [
+ {
+ hash: 'abc123',
+ title: 'feat: Big update',
+ body: '',
+ pr: {
+ remote: {
+ number: '1',
+ author: { login: 'alice' },
+ body: `### Changelog Entry
+
+- Add authentication
+ - OAuth2
+ - 2FA
+- Add user profiles
+ - Avatar upload
+ - Bio editing`,
+ labels: [],
+ },
+ },
+ },
+ ],
+ null
+ );
+
+ const changes = await generateChangesetFromGit(dummyGit, '1.0.0', 10);
+
+ // First entry with nested content
+ expect(changes).toContain('Add authentication by @alice in [#1]');
+ expect(changes).toContain(' OAuth2');
+ expect(changes).toContain(' 2FA');
+
+ // Second entry with nested content
+ expect(changes).toContain('Add user profiles by @alice in [#1]');
+ expect(changes).toContain(' Avatar upload');
+ expect(changes).toContain(' Bio editing');
});
it('should ignore empty changelog entry sections', async () => {
@@ -2906,7 +2978,7 @@ describe('formatScopeTitle', () => {
});
describe('extractChangelogEntry', () => {
- it('should extract content from "### Changelog Entry" section', () => {
+ it('should extract content from "### Changelog Entry" section as single entry', () => {
const prBody = `### Description
This PR adds a new feature.
@@ -2919,9 +2991,10 @@ Add a new function called \`foo\` which prints "Hello, world!"
Closes #123`;
- expect(extractChangelogEntry(prBody)).toBe(
- 'Add a new function called `foo` which prints "Hello, world!"'
- );
+ const result = extractChangelogEntry(prBody);
+ expect(result).toHaveLength(1);
+ expect(result![0].text).toBe('Add a new function called `foo` which prints "Hello, world!"');
+ expect(result![0].nestedContent).toBeUndefined();
});
it('should extract content from "## Changelog Entry" section', () => {
@@ -2937,9 +3010,9 @@ Add a new function called \`foo\` which prints "Hello, world!"
Closes #123`;
- expect(extractChangelogEntry(prBody)).toBe(
- 'Add a new function called `foo` which prints "Hello, world!"'
- );
+ const result = extractChangelogEntry(prBody);
+ expect(result).toHaveLength(1);
+ expect(result![0].text).toBe('Add a new function called `foo` which prints "Hello, world!"');
});
it('should handle changelog entry at the end of PR body', () => {
@@ -2951,9 +3024,9 @@ This PR adds a new feature.
This is the last section with no sections after it.`;
- expect(extractChangelogEntry(prBody)).toBe(
- 'This is the last section with no sections after it.'
- );
+ const result = extractChangelogEntry(prBody);
+ expect(result).toHaveLength(1);
+ expect(result![0].text).toBe('This is the last section with no sections after it.');
});
it('should be case-insensitive', () => {
@@ -2969,10 +3042,12 @@ Custom changelog text here
Closes #123`;
- expect(extractChangelogEntry(prBody)).toBe('Custom changelog text here');
+ const result = extractChangelogEntry(prBody);
+ expect(result).toHaveLength(1);
+ expect(result![0].text).toBe('Custom changelog text here');
});
- it('should handle multiple lines in changelog entry', () => {
+ it('should handle multiple lines in plain text as single entry', () => {
const prBody = `### Description
Description here
@@ -2987,21 +3062,25 @@ spans several lines.
Closes #123`;
- expect(extractChangelogEntry(prBody)).toBe(
+ const result = extractChangelogEntry(prBody);
+ expect(result).toHaveLength(1);
+ expect(result![0].text).toBe(
'This is a multi-line\nchangelog entry that\nspans several lines.'
);
});
- it('should handle changelog entry with markdown formatting', () => {
+ it('should handle multiple top-level bullets as separate entries', () => {
const prBody = `### Changelog Entry
- Add **bold** feature
- Add *italic* feature
- Add \`code\` feature`;
- expect(extractChangelogEntry(prBody)).toBe(
- '- Add **bold** feature\n- Add *italic* feature\n- Add `code` feature'
- );
+ const result = extractChangelogEntry(prBody);
+ expect(result).toHaveLength(3);
+ expect(result![0].text).toBe('Add **bold** feature');
+ expect(result![1].text).toBe('Add *italic* feature');
+ expect(result![2].text).toBe('Add `code` feature');
});
it('should return null when no changelog entry section exists', () => {
@@ -3045,7 +3124,9 @@ Closes #123`;
### Issues`;
- expect(extractChangelogEntry(prBody)).toBe('This has leading whitespace');
+ const result = extractChangelogEntry(prBody);
+ expect(result).toHaveLength(1);
+ expect(result![0].text).toBe('This has leading whitespace');
});
it('should handle changelog entry header with trailing hashes', () => {
@@ -3059,7 +3140,9 @@ Custom changelog text
### Issues`;
- expect(extractChangelogEntry(prBody)).toBe('Custom changelog text');
+ const result = extractChangelogEntry(prBody);
+ expect(result).toHaveLength(1);
+ expect(result![0].text).toBe('Custom changelog text');
});
it('should not match "Changelog Entry" in regular text', () => {
@@ -3087,6 +3170,87 @@ This should not be included.
Neither should this.`;
- expect(extractChangelogEntry(prBody)).toBe('This is the changelog.');
+ const result = extractChangelogEntry(prBody);
+ expect(result).toHaveLength(1);
+ expect(result![0].text).toBe('This is the changelog.');
+ });
+
+ it('should handle nested bullets under a top-level bullet', () => {
+ const prBody = `### Changelog Entry
+
+- Add authentication system
+ - OAuth2 support
+ - Two-factor authentication
+ - Session management`;
+
+ const result = extractChangelogEntry(prBody);
+ expect(result).toHaveLength(1);
+ expect(result![0].text).toBe('Add authentication system');
+ expect(result![0].nestedContent).toBe(
+ ' OAuth2 support\n Two-factor authentication\n Session management'
+ );
+ });
+
+ it('should handle multiple top-level bullets with nested content', () => {
+ const prBody = `### Changelog Entry
+
+- Add authentication system
+ - OAuth2 support
+ - Two-factor authentication
+- Add user profile page
+ - Avatar upload
+ - Bio editing
+- Add settings panel`;
+
+ const result = extractChangelogEntry(prBody);
+ expect(result).toHaveLength(3);
+ expect(result![0].text).toBe('Add authentication system');
+ expect(result![0].nestedContent).toBe(' OAuth2 support\n Two-factor authentication');
+ expect(result![1].text).toBe('Add user profile page');
+ expect(result![1].nestedContent).toBe(' Avatar upload\n Bio editing');
+ expect(result![2].text).toBe('Add settings panel');
+ expect(result![2].nestedContent).toBeUndefined();
+ });
+
+ it('should handle paragraph followed by nested bullets', () => {
+ const prBody = `### Changelog Entry
+
+Comprehensive authentication system with the following features:
+ - OAuth2 support
+ - Two-factor authentication
+ - Session management`;
+
+ const result = extractChangelogEntry(prBody);
+ expect(result).toHaveLength(1);
+ expect(result![0].text).toBe('Comprehensive authentication system with the following features:');
+ expect(result![0].nestedContent).toBeDefined();
+ expect(result![0].nestedContent).toContain('OAuth2 support');
+ });
+
+ it('should only include content within the Changelog Entry section', () => {
+ const prBody = `### Description
+
+This should not be included.
+
+### Changelog Entry
+
+- Add feature A
+- Add feature B
+
+### Issues
+
+This should also not be included.
+
+Closes #123`;
+
+ const result = extractChangelogEntry(prBody);
+ expect(result).toHaveLength(2);
+ expect(result![0].text).toBe('Add feature A');
+ expect(result![1].text).toBe('Add feature B');
+ // Make sure content from other sections isn't included
+ const allText = result!.map(e => e.text + (e.nestedContent || '')).join('');
+ expect(allText).not.toContain('This should not be included');
+ expect(allText).not.toContain('This should also not be included');
+ expect(allText).not.toContain('Closes #123');
});
});
diff --git a/src/utils/changelog.ts b/src/utils/changelog.ts
index 38553cf4..16412404 100644
--- a/src/utils/changelog.ts
+++ b/src/utils/changelog.ts
@@ -92,17 +92,33 @@ export function formatScopeTitle(scope: string): string {
}
/**
- * Extracts the "Changelog Entry" section from a PR description.
+ * Represents a single changelog entry item, which may have nested sub-items
+ */
+export interface ChangelogEntryItem {
+ /** The main text of the changelog entry */
+ text: string;
+ /** Optional nested content (e.g., sub-bullets) to be indented under this entry */
+ nestedContent?: string;
+}
+
+/**
+ * Extracts the "Changelog Entry" section from a PR description and parses it into structured entries.
* This allows PR authors to override the default changelog entry (which is the PR title)
* with custom text that's more user-facing and detailed.
*
* Looks for a markdown heading (either ### or ##) with the text "Changelog Entry"
* and extracts the content until the next heading of the same or higher level.
*
+ * Parsing rules:
+ * - Multiple top-level bullets (-, *, +) become separate changelog entries
+ * - Plain text (no bullets) becomes a single entry
+ * - Nested bullets are preserved as nested content under their parent entry
+ * - Only content within the "Changelog Entry" section is included
+ *
* @param prBody The PR description/body text
- * @returns The extracted changelog entry text, or null if no "Changelog Entry" section is found
+ * @returns Array of changelog entry items, or null if no "Changelog Entry" section is found
*/
-export function extractChangelogEntry(prBody: string | null | undefined): string | null {
+export function extractChangelogEntry(prBody: string | null | undefined): ChangelogEntryItem[] | null {
if (!prBody) {
return null;
}
@@ -128,7 +144,92 @@ export function extractChangelogEntry(prBody: string | null | undefined): string
const content = restOfBody.slice(0, endIndex).trim();
// Return null if the section is empty
- return content || null;
+ if (!content) {
+ return null;
+ }
+
+ // Parse the content into structured entries
+ return parseChangelogContent(content);
+}
+
+/**
+ * Parses changelog content into structured entries.
+ * Handles multiple top-level bullets and nested content.
+ */
+function parseChangelogContent(content: string): ChangelogEntryItem[] {
+ // First, check if the content has any bullet points at all
+ const hasTopLevelBullets = /^(\s{0,3})[-*+]\s+/m.test(content);
+ const hasIndentedBullets = /^(\s{4,}|\t+)[-*+]\s+/m.test(content);
+
+ // If no bullets found at all, treat entire content as a single entry
+ if (!hasTopLevelBullets && !hasIndentedBullets) {
+ return [{
+ text: content.trim(),
+ }];
+ }
+
+ const lines = content.split('\n');
+ const entries: ChangelogEntryItem[] = [];
+ let currentEntry: ChangelogEntryItem | null = null;
+ let nestedLines: string[] = [];
+
+ for (const line of lines) {
+ // Match top-level bullets (-, *, or + at the start of line, possibly with leading spaces)
+ const topLevelBulletMatch = line.match(/^(\s{0,3})[-*+]\s+(.+)$/);
+
+ if (topLevelBulletMatch) {
+ // Save previous entry if exists
+ if (currentEntry) {
+ if (nestedLines.length > 0) {
+ currentEntry.nestedContent = nestedLines.join('\n');
+ nestedLines = [];
+ }
+ entries.push(currentEntry);
+ }
+
+ // Start new entry
+ currentEntry = {
+ text: topLevelBulletMatch[2].trim(),
+ };
+ } else if (currentEntry) {
+ // Check if this is a nested bullet (more than 3 spaces of indentation, or tab)
+ const nestedMatch = line.match(/^(\s{4,}|\t+)[-*+]\s+(.+)$/);
+ if (nestedMatch) {
+ nestedLines.push(` ${nestedMatch[2].trim()}`);
+ } else if (line.trim()) {
+ // Non-empty line that's not a bullet - could be continuation text or nested content
+ // Add to nested content if it has any indentation or follows other nested content
+ if (nestedLines.length > 0 || line.match(/^\s+/)) {
+ nestedLines.push(line.trimEnd());
+ }
+ }
+ } else {
+ // No current entry yet - check if this is a paragraph that might have nested bullets after it
+ if (line.trim() && !line.match(/^(\s{4,}|\t+)[-*+]\s+/)) {
+ // Non-indented, non-bullet line - start a new entry
+ currentEntry = {
+ text: line.trim(),
+ };
+ }
+ }
+ }
+
+ // Save the last entry
+ if (currentEntry) {
+ if (nestedLines.length > 0) {
+ currentEntry.nestedContent = nestedLines.join('\n');
+ }
+ entries.push(currentEntry);
+ }
+
+ // If we have indented bullets but no entries, treat entire content as a single entry
+ if (entries.length === 0 && content.trim()) {
+ return [{
+ text: content.trim(),
+ }];
+ }
+
+ return entries;
}
/**
@@ -671,11 +772,23 @@ function formatChangelogEntry(entry: ChangelogEntry): string {
}
}
- // Add body if magic word is present
- if (entry.body?.includes(BODY_IN_CHANGELOG_MAGIC_WORD)) {
- const body = entry.body.replace(BODY_IN_CHANGELOG_MAGIC_WORD, '').trim();
- if (body) {
- text += `\n ${body}`;
+ // Add body content
+ // Two cases: 1) legacy magic word behavior, 2) nested content from structured changelog entries
+ if (entry.body) {
+ if (entry.body.includes(BODY_IN_CHANGELOG_MAGIC_WORD)) {
+ // Legacy behavior: extract and format body with magic word
+ const body = entry.body.replace(BODY_IN_CHANGELOG_MAGIC_WORD, '').trim();
+ if (body) {
+ text += `\n ${body}`;
+ }
+ } else if (entry.body.trim()) {
+ // New behavior: nested content from parsed changelog entries
+ // Don't trim() before splitting to preserve indentation on all lines
+ const lines = entry.body.split('\n');
+ for (const line of lines) {
+ // Each line already has the proper indentation from parsing
+ text += `\n${line}`;
+ }
}
}
@@ -773,17 +886,30 @@ export async function generateChangesetFromGit(
category.scopeGroups.set(scope, scopeGroup);
}
- // Check for a custom changelog entry in the PR body
- const customChangelogEntry = extractChangelogEntry(commit.prBody);
- const changelogTitle = customChangelogEntry ?? prTitle;
-
- scopeGroup.push({
- author: commit.author,
- number: commit.pr,
- hash: commit.hash,
- body: commit.prBody ?? '',
- title: changelogTitle,
- });
+ // Check for custom changelog entries in the PR body
+ const customChangelogEntries = extractChangelogEntry(commit.prBody);
+
+ if (customChangelogEntries) {
+ // If there are multiple changelog entries, add each as a separate item
+ for (const entry of customChangelogEntries) {
+ scopeGroup.push({
+ author: commit.author,
+ number: commit.pr,
+ hash: commit.hash,
+ body: entry.nestedContent ?? '',
+ title: entry.text,
+ });
+ }
+ } else {
+ // No custom entry, use PR title as before
+ scopeGroup.push({
+ author: commit.author,
+ number: commit.pr,
+ hash: commit.hash,
+ body: commit.prBody ?? '',
+ title: prTitle,
+ });
+ }
}
}
}
@@ -874,16 +1000,30 @@ export async function generateChangesetFromGit(
if (changelogSections.length > 0) {
changelogSections.push(markdownHeader(SUBSECTION_HEADER_LEVEL, 'Other'));
}
- changelogSections.push(
- leftovers
- .slice(0, maxLeftovers)
- .map(commit => {
- // Check for a custom changelog entry in the PR body
- const customChangelogEntry = extractChangelogEntry(commit.prBody);
- const changelogTitle = customChangelogEntry ?? commit.prTitle ?? commit.title;
-
- return formatChangelogEntry({
- title: changelogTitle,
+ const leftoverEntries: string[] = [];
+ for (const commit of leftovers.slice(0, maxLeftovers)) {
+ // Check for custom changelog entries in the PR body
+ const customChangelogEntries = extractChangelogEntry(commit.prBody);
+
+ if (customChangelogEntries) {
+ // If there are multiple changelog entries, add each as a separate line
+ for (const entry of customChangelogEntries) {
+ leftoverEntries.push(
+ formatChangelogEntry({
+ title: entry.text,
+ author: commit.author,
+ prNumber: commit.pr ?? undefined,
+ hash: commit.hash,
+ repoUrl,
+ body: entry.nestedContent,
+ })
+ );
+ }
+ } else {
+ // No custom entry, use PR title or commit title as before
+ leftoverEntries.push(
+ formatChangelogEntry({
+ title: commit.prTitle ?? commit.title,
author: commit.author,
prNumber: commit.pr ?? undefined,
hash: commit.hash,
@@ -894,10 +1034,11 @@ export async function generateChangesetFromGit(
: commit.body.includes(BODY_IN_CHANGELOG_MAGIC_WORD)
? commit.body
: undefined,
- });
- })
- .join('\n')
- );
+ })
+ );
+ }
+ }
+ changelogSections.push(leftoverEntries.join('\n'));
if (nLeftovers > maxLeftovers) {
changelogSections.push(`_Plus ${nLeftovers - maxLeftovers} more_`);
}
From 7f8687ac0a3a2cb2ace46905fdfe4d08db66f1db Mon Sep 17 00:00:00 2001
From: Cursor Agent
Date: Thu, 4 Dec 2025 15:32:47 +0000
Subject: [PATCH 03/15] Refactor changelog tests to handle null results and
improve readability
Co-authored-by: daniel.szoke
---
src/utils/__tests__/changelog.test.ts | 107 ++++++++++++++++++--------
1 file changed, 73 insertions(+), 34 deletions(-)
diff --git a/src/utils/__tests__/changelog.test.ts b/src/utils/__tests__/changelog.test.ts
index cc24674a..6155e48d 100644
--- a/src/utils/__tests__/changelog.test.ts
+++ b/src/utils/__tests__/changelog.test.ts
@@ -2993,8 +2993,11 @@ Closes #123`;
const result = extractChangelogEntry(prBody);
expect(result).toHaveLength(1);
- expect(result![0].text).toBe('Add a new function called `foo` which prints "Hello, world!"');
- expect(result![0].nestedContent).toBeUndefined();
+ expect(result).not.toBeNull();
+ if (result) {
+ expect(result[0].text).toBe('Add a new function called `foo` which prints "Hello, world!"');
+ expect(result[0].nestedContent).toBeUndefined();
+ }
});
it('should extract content from "## Changelog Entry" section', () => {
@@ -3012,7 +3015,10 @@ Closes #123`;
const result = extractChangelogEntry(prBody);
expect(result).toHaveLength(1);
- expect(result![0].text).toBe('Add a new function called `foo` which prints "Hello, world!"');
+ expect(result).not.toBeNull();
+ if (result) {
+ expect(result[0].text).toBe('Add a new function called `foo` which prints "Hello, world!"');
+ }
});
it('should handle changelog entry at the end of PR body', () => {
@@ -3026,7 +3032,10 @@ This is the last section with no sections after it.`;
const result = extractChangelogEntry(prBody);
expect(result).toHaveLength(1);
- expect(result![0].text).toBe('This is the last section with no sections after it.');
+ expect(result).not.toBeNull();
+ if (result) {
+ expect(result[0].text).toBe('This is the last section with no sections after it.');
+ }
});
it('should be case-insensitive', () => {
@@ -3044,7 +3053,10 @@ Closes #123`;
const result = extractChangelogEntry(prBody);
expect(result).toHaveLength(1);
- expect(result![0].text).toBe('Custom changelog text here');
+ expect(result).not.toBeNull();
+ if (result) {
+ expect(result[0].text).toBe('Custom changelog text here');
+ }
});
it('should handle multiple lines in plain text as single entry', () => {
@@ -3064,9 +3076,12 @@ Closes #123`;
const result = extractChangelogEntry(prBody);
expect(result).toHaveLength(1);
- expect(result![0].text).toBe(
- 'This is a multi-line\nchangelog entry that\nspans several lines.'
- );
+ expect(result).not.toBeNull();
+ if (result) {
+ expect(result[0].text).toBe(
+ 'This is a multi-line\nchangelog entry that\nspans several lines.'
+ );
+ }
});
it('should handle multiple top-level bullets as separate entries', () => {
@@ -3078,9 +3093,12 @@ Closes #123`;
const result = extractChangelogEntry(prBody);
expect(result).toHaveLength(3);
- expect(result![0].text).toBe('Add **bold** feature');
- expect(result![1].text).toBe('Add *italic* feature');
- expect(result![2].text).toBe('Add `code` feature');
+ expect(result).not.toBeNull();
+ if (result) {
+ expect(result[0].text).toBe('Add **bold** feature');
+ expect(result[1].text).toBe('Add *italic* feature');
+ expect(result[2].text).toBe('Add `code` feature');
+ }
});
it('should return null when no changelog entry section exists', () => {
@@ -3126,7 +3144,10 @@ Closes #123`;
const result = extractChangelogEntry(prBody);
expect(result).toHaveLength(1);
- expect(result![0].text).toBe('This has leading whitespace');
+ expect(result).not.toBeNull();
+ if (result) {
+ expect(result[0].text).toBe('This has leading whitespace');
+ }
});
it('should handle changelog entry header with trailing hashes', () => {
@@ -3142,7 +3163,10 @@ Custom changelog text
const result = extractChangelogEntry(prBody);
expect(result).toHaveLength(1);
- expect(result![0].text).toBe('Custom changelog text');
+ expect(result).not.toBeNull();
+ if (result) {
+ expect(result[0].text).toBe('Custom changelog text');
+ }
});
it('should not match "Changelog Entry" in regular text', () => {
@@ -3172,7 +3196,10 @@ Neither should this.`;
const result = extractChangelogEntry(prBody);
expect(result).toHaveLength(1);
- expect(result![0].text).toBe('This is the changelog.');
+ expect(result).not.toBeNull();
+ if (result) {
+ expect(result[0].text).toBe('This is the changelog.');
+ }
});
it('should handle nested bullets under a top-level bullet', () => {
@@ -3185,10 +3212,13 @@ Neither should this.`;
const result = extractChangelogEntry(prBody);
expect(result).toHaveLength(1);
- expect(result![0].text).toBe('Add authentication system');
- expect(result![0].nestedContent).toBe(
- ' OAuth2 support\n Two-factor authentication\n Session management'
- );
+ expect(result).not.toBeNull();
+ if (result) {
+ expect(result[0].text).toBe('Add authentication system');
+ expect(result[0].nestedContent).toBe(
+ ' OAuth2 support\n Two-factor authentication\n Session management'
+ );
+ }
});
it('should handle multiple top-level bullets with nested content', () => {
@@ -3204,12 +3234,15 @@ Neither should this.`;
const result = extractChangelogEntry(prBody);
expect(result).toHaveLength(3);
- expect(result![0].text).toBe('Add authentication system');
- expect(result![0].nestedContent).toBe(' OAuth2 support\n Two-factor authentication');
- expect(result![1].text).toBe('Add user profile page');
- expect(result![1].nestedContent).toBe(' Avatar upload\n Bio editing');
- expect(result![2].text).toBe('Add settings panel');
- expect(result![2].nestedContent).toBeUndefined();
+ expect(result).not.toBeNull();
+ if (result) {
+ expect(result[0].text).toBe('Add authentication system');
+ expect(result[0].nestedContent).toBe(' OAuth2 support\n Two-factor authentication');
+ expect(result[1].text).toBe('Add user profile page');
+ expect(result[1].nestedContent).toBe(' Avatar upload\n Bio editing');
+ expect(result[2].text).toBe('Add settings panel');
+ expect(result[2].nestedContent).toBeUndefined();
+ }
});
it('should handle paragraph followed by nested bullets', () => {
@@ -3222,9 +3255,12 @@ Comprehensive authentication system with the following features:
const result = extractChangelogEntry(prBody);
expect(result).toHaveLength(1);
- expect(result![0].text).toBe('Comprehensive authentication system with the following features:');
- expect(result![0].nestedContent).toBeDefined();
- expect(result![0].nestedContent).toContain('OAuth2 support');
+ expect(result).not.toBeNull();
+ if (result) {
+ expect(result[0].text).toBe('Comprehensive authentication system with the following features:');
+ expect(result[0].nestedContent).toBeDefined();
+ expect(result[0].nestedContent).toContain('OAuth2 support');
+ }
});
it('should only include content within the Changelog Entry section', () => {
@@ -3245,12 +3281,15 @@ Closes #123`;
const result = extractChangelogEntry(prBody);
expect(result).toHaveLength(2);
- expect(result![0].text).toBe('Add feature A');
- expect(result![1].text).toBe('Add feature B');
- // Make sure content from other sections isn't included
- const allText = result!.map(e => e.text + (e.nestedContent || '')).join('');
- expect(allText).not.toContain('This should not be included');
- expect(allText).not.toContain('This should also not be included');
- expect(allText).not.toContain('Closes #123');
+ expect(result).not.toBeNull();
+ if (result) {
+ expect(result[0].text).toBe('Add feature A');
+ expect(result[1].text).toBe('Add feature B');
+ // Make sure content from other sections isn't included
+ const allText = result.map(e => e.text + (e.nestedContent || '')).join('');
+ expect(allText).not.toContain('This should not be included');
+ expect(allText).not.toContain('This should also not be included');
+ expect(allText).not.toContain('Closes #123');
+ }
});
});
From b13717712ed5aefd225f6188b90ae8c1b8e79bff Mon Sep 17 00:00:00 2001
From: Burak Yigit Kaya
Date: Fri, 26 Dec 2025 23:56:03 +0300
Subject: [PATCH 04/15] Merge conflict resolution (#671)
Resolve merge conflicts with master, update documentation, and fix
related tests to integrate new features and the new docs site.
The branch had conflicts due to a major documentation site migration
(from Jekyll to Astro/Starlight) and the introduction of new GitHub
Actions in `master`. This PR resolves these conflicts, updates the
`README.md` to point to the new docs site, migrates detailed changelog
documentation, and ensures all tests pass with the merged changes.
---
---------
Signed-off-by: dependabot[bot]
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: getsentry-bot
Co-authored-by: getsentry-bot
Co-authored-by: Cursor Agent
---
.craft.yml | 7 +-
.eslintrc.js | 1 +
.github/workflows/build.yml | 18 +-
.github/workflows/changelog-preview.yml | 105 +
.github/workflows/docs-preview.yml | 69 +
.github/workflows/release.yml | 14 +-
.gitignore | 5 +
.nojekyll | 0
AGENTS.md | 68 +
CHANGELOG.md | 79 +
Dockerfile | 5 +-
Gemfile | 5 -
Gemfile.lock | 114 -
README.md | 1633 +------
action.yml | 208 +
docs/_site/assets/main.css | 158 -
docs/_site/assets/normalize.css | 427 --
docs/_site/favicon.ico | Bin 6518 -> 0 bytes
docs/_site/images/sentry-glyph-black.png | Bin 4024 -> 0 bytes
docs/_site/index.html | 74 -
docs/astro.config.mjs | 43 +
docs/package.json | 15 +
docs/public/favicon.svg | 1 +
docs/src/assets/logo.svg | 1 +
docs/src/content/config.ts | 6 +
docs/src/content/docs/configuration.md | 330 ++
docs/src/content/docs/contributing.md | 129 +
docs/src/content/docs/getting-started.md | 228 +
docs/src/content/docs/github-actions.md | 250 ++
docs/src/content/docs/index.mdx | 67 +
.../content/docs/targets/aws-lambda-layer.md | 48 +
docs/src/content/docs/targets/brew.md | 54 +
docs/src/content/docs/targets/cocoapods.md | 32 +
.../docs/targets/commit-on-git-repository.md | 39 +
docs/src/content/docs/targets/crates.md | 32 +
docs/src/content/docs/targets/docker.md | 104 +
docs/src/content/docs/targets/gcs.md | 45 +
docs/src/content/docs/targets/gem.md | 28 +
docs/src/content/docs/targets/gh-pages.md | 39 +
docs/src/content/docs/targets/github.md | 48 +
docs/src/content/docs/targets/hex.md | 28 +
docs/src/content/docs/targets/index.md | 80 +
docs/src/content/docs/targets/maven.md | 75 +
docs/src/content/docs/targets/npm.md | 83 +
docs/src/content/docs/targets/nuget.md | 28 +
docs/src/content/docs/targets/powershell.md | 34 +
docs/src/content/docs/targets/pub-dev.md | 59 +
docs/src/content/docs/targets/pypi.md | 48 +
docs/src/content/docs/targets/registry.md | 54 +
.../content/docs/targets/symbol-collector.md | 28 +
docs/src/content/docs/targets/upm.md | 26 +
docs/tsconfig.json | 5 +
docs/yarn.lock | 3752 +++++++++++++++++
install/action.yml | 71 +
package.json | 15 +-
src/__tests__/index.test.ts | 80 +-
src/commands/__tests__/prepare.test.ts | 28 +-
src/commands/__tests__/targets.test.ts | 117 +
src/commands/changelog.ts | 96 +
src/commands/prepare.ts | 239 +-
src/commands/publish.ts | 4 +-
src/commands/targets.ts | 10 +-
src/config.ts | 133 +
src/index.ts | 6 +-
src/logger.ts | 11 +
src/schemas/projectConfig.schema.ts | 63 +
src/schemas/project_config.ts | 29 +
src/targets/__tests__/docker.test.ts | 1217 ++++++
src/targets/__tests__/npm.test.ts | 165 +-
src/targets/docker.ts | 559 ++-
src/targets/github.ts | 95 +-
src/targets/npm.ts | 165 +
.../workspaces/no-workspace/package.json | 4 +
.../workspaces/npm-workspace/package.json | 5 +
.../npm-workspace/packages/pkg-a/package.json | 4 +
.../npm-workspace/packages/pkg-b/package.json | 8 +
.../workspaces/pnpm-workspace/package.json | 4 +
.../packages/pkg-a/package.json | 4 +
.../packages/pkg-b/package.json | 4 +
.../pnpm-workspace/pnpm-workspace.yaml | 2 +
src/utils/__tests__/autoVersion.test.ts | 250 ++
src/utils/__tests__/calver.test.ts | 199 +
src/utils/__tests__/changelog.test.ts | 317 +-
src/utils/__tests__/workspaces.test.ts | 265 ++
src/utils/autoVersion.ts | 91 +
src/utils/calver.ts | 101 +
src/utils/changelog.ts | 618 ++-
src/utils/git.ts | 5 +-
src/utils/helpers.ts | 25 +
src/utils/system.ts | 16 +-
src/utils/workspaces.ts | 422 ++
yarn.lock | 253 +-
92 files changed, 11886 insertions(+), 2643 deletions(-)
create mode 100644 .github/workflows/changelog-preview.yml
create mode 100644 .github/workflows/docs-preview.yml
create mode 100644 .nojekyll
create mode 100644 AGENTS.md
delete mode 100644 Gemfile
delete mode 100644 Gemfile.lock
create mode 100644 action.yml
delete mode 100644 docs/_site/assets/main.css
delete mode 100644 docs/_site/assets/normalize.css
delete mode 100644 docs/_site/favicon.ico
delete mode 100644 docs/_site/images/sentry-glyph-black.png
delete mode 100644 docs/_site/index.html
create mode 100644 docs/astro.config.mjs
create mode 100644 docs/package.json
create mode 100644 docs/public/favicon.svg
create mode 100644 docs/src/assets/logo.svg
create mode 100644 docs/src/content/config.ts
create mode 100644 docs/src/content/docs/configuration.md
create mode 100644 docs/src/content/docs/contributing.md
create mode 100644 docs/src/content/docs/getting-started.md
create mode 100644 docs/src/content/docs/github-actions.md
create mode 100644 docs/src/content/docs/index.mdx
create mode 100644 docs/src/content/docs/targets/aws-lambda-layer.md
create mode 100644 docs/src/content/docs/targets/brew.md
create mode 100644 docs/src/content/docs/targets/cocoapods.md
create mode 100644 docs/src/content/docs/targets/commit-on-git-repository.md
create mode 100644 docs/src/content/docs/targets/crates.md
create mode 100644 docs/src/content/docs/targets/docker.md
create mode 100644 docs/src/content/docs/targets/gcs.md
create mode 100644 docs/src/content/docs/targets/gem.md
create mode 100644 docs/src/content/docs/targets/gh-pages.md
create mode 100644 docs/src/content/docs/targets/github.md
create mode 100644 docs/src/content/docs/targets/hex.md
create mode 100644 docs/src/content/docs/targets/index.md
create mode 100644 docs/src/content/docs/targets/maven.md
create mode 100644 docs/src/content/docs/targets/npm.md
create mode 100644 docs/src/content/docs/targets/nuget.md
create mode 100644 docs/src/content/docs/targets/powershell.md
create mode 100644 docs/src/content/docs/targets/pub-dev.md
create mode 100644 docs/src/content/docs/targets/pypi.md
create mode 100644 docs/src/content/docs/targets/registry.md
create mode 100644 docs/src/content/docs/targets/symbol-collector.md
create mode 100644 docs/src/content/docs/targets/upm.md
create mode 100644 docs/tsconfig.json
create mode 100644 docs/yarn.lock
create mode 100644 install/action.yml
create mode 100644 src/commands/__tests__/targets.test.ts
create mode 100644 src/commands/changelog.ts
create mode 100644 src/targets/__tests__/docker.test.ts
create mode 100644 src/utils/__fixtures__/workspaces/no-workspace/package.json
create mode 100644 src/utils/__fixtures__/workspaces/npm-workspace/package.json
create mode 100644 src/utils/__fixtures__/workspaces/npm-workspace/packages/pkg-a/package.json
create mode 100644 src/utils/__fixtures__/workspaces/npm-workspace/packages/pkg-b/package.json
create mode 100644 src/utils/__fixtures__/workspaces/pnpm-workspace/package.json
create mode 100644 src/utils/__fixtures__/workspaces/pnpm-workspace/packages/pkg-a/package.json
create mode 100644 src/utils/__fixtures__/workspaces/pnpm-workspace/packages/pkg-b/package.json
create mode 100644 src/utils/__fixtures__/workspaces/pnpm-workspace/pnpm-workspace.yaml
create mode 100644 src/utils/__tests__/autoVersion.test.ts
create mode 100644 src/utils/__tests__/calver.test.ts
create mode 100644 src/utils/__tests__/workspaces.test.ts
create mode 100644 src/utils/autoVersion.ts
create mode 100644 src/utils/calver.ts
create mode 100644 src/utils/workspaces.ts
diff --git a/.craft.yml b/.craft.yml
index f8774b57..cc9ffee4 100644
--- a/.craft.yml
+++ b/.craft.yml
@@ -1,5 +1,6 @@
-minVersion: '0.30.0'
-changelogPolicy: auto
+minVersion: '2.14.0'
+changelog:
+ policy: auto
preReleaseCommand: >-
node -p "
const {execSync} = require('child_process');
@@ -42,4 +43,6 @@ targets:
target: getsentry/craft
targetFormat: '{{{target}}}:latest'
- name: github
+ floatingTags:
+ - 'v{major}'
- name: gh-pages
diff --git a/.eslintrc.js b/.eslintrc.js
index d1d6e708..5d0c425d 100644
--- a/.eslintrc.js
+++ b/.eslintrc.js
@@ -4,6 +4,7 @@ module.exports = {
es2017: true,
node: true,
},
+ ignorePatterns: ['docs/**'],
parser: '@typescript-eslint/parser',
plugins: ['@typescript-eslint'],
extends: [
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index a00268fd..62db42f7 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -5,6 +5,11 @@ on:
- master
- release/**
pull_request:
+ workflow_call:
+
+concurrency:
+ group: ${{ github.ref_name || github.sha }}
+ cancel-in-progress: true
jobs:
test:
@@ -52,10 +57,19 @@ jobs:
run: yarn install --frozen-lockfile
- name: Build
run: yarn build --define:process.env.CRAFT_BUILD_SHA='"'${{ github.sha }}'"'
+ - name: Smoke Test
+ run: ./dist/craft --help
- name: NPM Pack
run: npm pack
- - name: Docs
- run: cd docs && zip -r ../gh-pages _site/
+ - name: Build Docs
+ working-directory: docs
+ run: |
+ yarn install --frozen-lockfile
+ yarn build
+ - name: Package Docs
+ run: |
+ cp .nojekyll docs/dist/
+ cd docs/dist && zip -r ../../gh-pages.zip .
- name: Archive Artifacts
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
diff --git a/.github/workflows/changelog-preview.yml b/.github/workflows/changelog-preview.yml
new file mode 100644
index 00000000..95d6bdcb
--- /dev/null
+++ b/.github/workflows/changelog-preview.yml
@@ -0,0 +1,105 @@
+name: Changelog Preview
+
+on:
+ # Allow this workflow to be called from other repositories
+ workflow_call:
+ inputs:
+ craft-version:
+ description: 'Version of Craft to use (tag or "latest")'
+ required: false
+ type: string
+ default: 'latest'
+
+ # Also run on PRs in this repository (for dogfooding)
+ # Includes 'edited' and 'labeled' to update when PR title/description/labels change
+ pull_request:
+ types: [opened, synchronize, reopened, edited, labeled]
+
+permissions:
+ pull-requests: write
+
+jobs:
+ preview:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+
+ # Install Craft using the shared install action
+ # TODO: Change to @v2 or @master after this PR is merged
+ - name: Install Craft
+ uses: getsentry/craft/install@pull/669/head
+ with:
+ craft-version: ${{ inputs.craft-version || 'latest' }}
+
+ - name: Generate Changelog Preview
+ shell: bash
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ CRAFT_LOG_LEVEL: Warn
+ run: |
+ PR_NUMBER="${{ github.event.pull_request.number }}"
+
+ # Generate changelog with current PR injected and highlighted (JSON format)
+ echo "Running craft changelog --pr $PR_NUMBER --format json..."
+ RESULT=$(craft changelog --pr "$PR_NUMBER" --format json 2>/dev/null || echo '{"changelog":"","bumpType":null}')
+
+ # Extract fields from JSON
+ CHANGELOG=$(echo "$RESULT" | jq -r '.changelog // ""')
+ BUMP_TYPE=$(echo "$RESULT" | jq -r '.bumpType // "none"')
+
+ if [[ -z "$CHANGELOG" ]]; then
+ CHANGELOG="_No changelog entries will be generated from this PR._"
+ fi
+
+ # Format bump type for display
+ case "$BUMP_TYPE" in
+ major) BUMP_BADGE="🔴 **Major** (breaking changes)" ;;
+ minor) BUMP_BADGE="🟡 **Minor** (new features)" ;;
+ patch) BUMP_BADGE="🟢 **Patch** (bug fixes)" ;;
+ *) BUMP_BADGE="⚪ **None** (no version bump detected)" ;;
+ esac
+
+ # Build comment body using a temp file (safer than heredoc)
+ COMMENT_FILE=$(mktemp)
+ cat > "$COMMENT_FILE" << CRAFT_CHANGELOG_COMMENT_END
+
+ ## Suggested Version Bump
+
+ ${BUMP_BADGE}
+
+ ## 📋 Changelog Preview
+
+ This is how your changes will appear in the changelog.
+ Entries from this PR are highlighted with a left border (blockquote style).
+
+ ---
+
+ ${CHANGELOG}
+
+ ---
+
+ 🤖 This preview updates automatically when you update the PR.
+ CRAFT_CHANGELOG_COMMENT_END
+
+ # Find existing comment with our marker
+ COMMENT_ID=$(gh api \
+ "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/comments" \
+ --jq '.[] | select(.body | contains("")) | .id' \
+ | head -1)
+
+ if [[ -n "$COMMENT_ID" ]]; then
+ echo "Updating existing comment $COMMENT_ID..."
+ gh api -X PATCH \
+ "repos/$GITHUB_REPOSITORY/issues/comments/$COMMENT_ID" \
+ -F body=@"$COMMENT_FILE"
+ else
+ echo "Creating new comment..."
+ gh api -X POST \
+ "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/comments" \
+ -F body=@"$COMMENT_FILE"
+ fi
+
+ rm -f "$COMMENT_FILE"
diff --git a/.github/workflows/docs-preview.yml b/.github/workflows/docs-preview.yml
new file mode 100644
index 00000000..d5bbfa65
--- /dev/null
+++ b/.github/workflows/docs-preview.yml
@@ -0,0 +1,69 @@
+name: Docs Preview
+
+on:
+ pull_request:
+ paths:
+ - 'docs/**'
+ - '.github/workflows/docs-preview.yml'
+
+permissions:
+ contents: write
+ pull-requests: write
+
+jobs:
+ preview:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-node@v4
+ with:
+ node-version: '22'
+
+ - name: Build Docs for Preview
+ working-directory: docs
+ env:
+ # Override base path for PR preview
+ DOCS_BASE_PATH: /craft/pr-preview/pr-${{ github.event.pull_request.number }}
+ run: |
+ yarn install --frozen-lockfile
+ yarn build
+
+ - name: Ensure .nojekyll at gh-pages root
+ run: |
+ git config user.name "github-actions[bot]"
+ git config user.email "github-actions[bot]@users.noreply.github.com"
+
+ # Try to fetch the gh-pages branch
+ if git fetch origin gh-pages:gh-pages 2>/dev/null; then
+ # Branch exists remotely, check if .nojekyll is present
+ if git show gh-pages:.nojekyll &>/dev/null; then
+ echo ".nojekyll already exists at gh-pages root"
+ else
+ echo "Adding .nojekyll to existing gh-pages branch"
+ git checkout gh-pages
+ touch .nojekyll
+ git add .nojekyll
+ git commit -m "Add .nojekyll to disable Jekyll processing"
+ git push origin gh-pages
+ git checkout -
+ fi
+ else
+ # Branch doesn't exist, create it as an orphan branch
+ echo "Creating gh-pages branch with .nojekyll"
+ git checkout --orphan gh-pages
+ git rm -rf .
+ touch .nojekyll
+ git add .nojekyll
+ git commit -m "Initialize gh-pages with .nojekyll"
+ git push origin gh-pages
+ git checkout -
+ fi
+
+ - name: Deploy Preview
+ uses: rossjrw/pr-preview-action@v1
+ with:
+ source-dir: docs/dist/
+ preview-branch: gh-pages
+ umbrella-dir: pr-preview
+ action: auto
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 7dd901ec..4a8d49c0 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -1,15 +1,25 @@
name: Release
+concurrency: ${{ github.workflow }}-${{ github.ref }}
on:
workflow_dispatch:
inputs:
version:
description: Version to release
- required: false
+ required: true
+ default: "auto"
force:
description: Force a release even when there are release-blockers (optional)
required: false
+
jobs:
+ build:
+ name: Build
+ uses: ./.github/workflows/build.yml
+ permissions:
+ contents: read
+
release:
+ needs: build
runs-on: ubuntu-latest
name: 'Release a new version'
permissions:
@@ -27,7 +37,7 @@ jobs:
token: ${{ steps.token.outputs.token }}
fetch-depth: 0
- name: Prepare release
- uses: getsentry/action-prepare-release@3cea80dc3938c0baf5ec4ce752ecb311f8780cdc # v1
+ uses: ./
env:
GITHUB_TOKEN: ${{ steps.token.outputs.token }}
with:
diff --git a/.gitignore b/.gitignore
index c53729a0..03e03aaf 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,6 +2,11 @@ coverage/
dist/
node_modules/
+# Docs build artifacts
+docs/dist/
+docs/node_modules/
+docs/.astro/
+
yarn-error.log
npm-debug.log
diff --git a/.nojekyll b/.nojekyll
new file mode 100644
index 00000000..e69de29b
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 00000000..1d43909f
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,68 @@
+# AGENTS.md
+
+This file provides guidance for AI coding assistants working with the Craft codebase.
+
+## Package Management
+
+- **Always use `yarn`** (v1) for package management. Never use `npm` or `pnpm`.
+- Node.js version is managed by [Volta](https://volta.sh/) (currently v22.12.0).
+- Install dependencies with `yarn install --frozen-lockfile`.
+
+## Development Commands
+
+| Command | Description |
+|---------|-------------|
+| `yarn build` | Build the project (outputs to `dist/craft`) |
+| `yarn test` | Run tests |
+| `yarn lint` | Run ESLint |
+| `yarn fix` | Auto-fix lint issues |
+
+To manually test changes:
+
+```bash
+yarn build && ./dist/craft
+```
+
+## Code Style
+
+- **TypeScript** is used throughout the codebase.
+- **Prettier** with single quotes and no arrow parens (configured in `.prettierrc.yml`).
+- **ESLint** extends `@typescript-eslint/recommended`.
+- Unused variables prefixed with `_` are allowed (e.g., `_unusedParam`).
+
+## Project Structure
+
+```
+src/
+├── __mocks__/ # Test mocks
+├── __tests__/ # Test files (*.test.ts)
+├── artifact_providers/ # Artifact provider implementations
+├── commands/ # CLI command implementations
+├── schemas/ # JSON schema and TypeScript types for config
+├── status_providers/ # Status provider implementations
+├── targets/ # Release target implementations
+├── types/ # Shared TypeScript types
+├── utils/ # Utility functions
+├── config.ts # Configuration loading
+├── index.ts # CLI entry point
+└── logger.ts # Logging utilities
+dist/
+└── craft # Single bundled executable (esbuild output)
+```
+
+## Testing
+
+- Tests use **Jest** with `ts-jest`.
+- Test files are located in `src/__tests__/` and follow the `*.test.ts` naming pattern.
+- Run tests with `yarn test`.
+
+## CI/CD
+
+- Main branch is `master`.
+- CI runs tests on Node.js 20 and 22.
+- Craft releases itself using its own tooling (dogfooding).
+
+## Configuration
+
+- Project configuration lives in `.craft.yml` at the repository root.
+- The configuration schema is defined in `src/schemas/`.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7efc3d00..eaa7bb07 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,84 @@
# Changelog
+## 2.15.0
+
+### New Features ✨
+
+#### Github
+
+- feat(github): Integrate action-prepare-release into Craft repo by @BYK in [#667](https://github.com/getsentry/craft/pull/667)
+- feat(github): Emit resolved version to GITHUB_OUTPUTS on prepare by @BYK in [#666](https://github.com/getsentry/craft/pull/666)
+
+## 2.14.1
+
+### Bug Fixes 🐛
+
+#### Changelog
+
+- fix(changelog): Fix whitespace related issues by @BYK in [#664](https://github.com/getsentry/craft/pull/664)
+- fix(changelog): Add ref and perf to internal changes prefixes by @BYK in [#662](https://github.com/getsentry/craft/pull/662)
+
+### Build / dependencies / internal 🔧
+
+- ci(deps): Upgrade action-prepare-release to latest by @BYK in [#663](https://github.com/getsentry/craft/pull/663)
+
+- ci(release): Add support for auto versioning by @BYK in [#665](https://github.com/getsentry/craft/pull/665)
+
+## 2.14.0
+
+### New Features ✨
+
+- feat(docker): Add support for multiple registries by @BYK in [#657](https://github.com/getsentry/craft/pull/657)
+
+- feat: Add automatic version bumping based on conventional commits by @BYK in [#656](https://github.com/getsentry/craft/pull/656)
+- feat: Add `skip-changelog` label by default by @BYK in [#655](https://github.com/getsentry/craft/pull/655)
+
+### Bug Fixes 🐛
+
+- fix(changelog): Unscoped entries should be grouped under "other" by @BYK in [#659](https://github.com/getsentry/craft/pull/659)
+
+### Build / dependencies / internal 🔧
+
+- ci: Update action-prepare-release to v1.6.5 by @BYK in [#654](https://github.com/getsentry/craft/pull/654)
+
+### Other
+
+- fix(docker): Support regional Artifact Registry endpoints in isGoogleCloudRegistry by @BYK in [#661](https://github.com/getsentry/craft/pull/661)
+
+## 2.13.1
+
+### Build / dependencies / internal 🔧
+
+- ci: Fix release input desc and concurrency by @BYK in [#653](https://github.com/getsentry/craft/pull/653)
+
+### Bug Fixes 🐛
+
+- fix: Fix startup issue with yargs by @BYK in [#651](https://github.com/getsentry/craft/pull/651)
+
+### Documentation 📚
+
+- docs: Add AGENTS.md by @BYK in [#652](https://github.com/getsentry/craft/pull/652)
+
+## 2.13.0
+
+### New Features ✨
+
+- feat(npm): Add workspaces support by @BYK in [#645](https://github.com/getsentry/craft/pull/645)
+- feat(changelog): Add grouping by scope by @BYK in [#644](https://github.com/getsentry/craft/pull/644)
+- feat(changelog): Add section ordering by @BYK in [#640](https://github.com/getsentry/craft/pull/640)
+
+### Build / dependencies / internal 🔧
+
+- build(deps): bump jws from 4.0.0 to 4.0.1 by @dependabot in [#650](https://github.com/getsentry/craft/pull/650)
+
+### Bug Fixes 🐛
+
+- fix(changelog): default matcher should match scopes with dashes by @BYK in [#641](https://github.com/getsentry/craft/pull/641)
+
+### Other
+
+- build(deps-dev): bump @octokit/request-error from 6.1.8 to 7.0.0 by @dependabot in [#643](https://github.com/getsentry/craft/pull/643)
+
## 2.12.1
### Bug Fixes 🐛
diff --git a/Dockerfile b/Dockerfile
index f28edf11..9d3b78a1 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -44,8 +44,6 @@ RUN apt-get -qq update \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
-COPY Gemfile Gemfile.lock ./
-
RUN python3 -m venv /venv && pip install twine==6.1.0 pkginfo==1.12.1.2 --no-cache
RUN : \
@@ -78,7 +76,8 @@ RUN : \
&& curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- --profile minimal -y \
&& cargo --version \
&& cargo install cargo-hack \
- && gem install -g --no-document \
+ # Pin CocoaPods version to avoid bugs like https://github.com/CocoaPods/CocoaPods/issues/12081
+ && gem install cocoapods -v 1.16.2 --no-document \
# Install https://github.com/getsentry/symbol-collector
&& symbol_collector_url=$(curl -s https://api.github.com/repos/getsentry/symbol-collector/releases/tags/1.17.0 | \
jq -r '.assets[].browser_download_url | select(endswith("symbolcollector-console-linux-x64.zip"))') \
diff --git a/Gemfile b/Gemfile
deleted file mode 100644
index 93ff5d27..00000000
--- a/Gemfile
+++ /dev/null
@@ -1,5 +0,0 @@
-# Gemfile
-# Pin CocoaPods Version to avoid that bugs in CocoaPods like
-# https://github.com/CocoaPods/CocoaPods/issues/12081 break our release
-# workflow.
-gem "cocoapods", "= 1.16.2"
diff --git a/Gemfile.lock b/Gemfile.lock
deleted file mode 100644
index c0eac5ab..00000000
--- a/Gemfile.lock
+++ /dev/null
@@ -1,114 +0,0 @@
-GEM
- remote: https://rubygems.org/
- specs:
- CFPropertyList (3.0.7)
- base64
- nkf
- rexml
- activesupport (7.2.2.1)
- base64
- benchmark (>= 0.3)
- bigdecimal
- concurrent-ruby (~> 1.0, >= 1.3.1)
- connection_pool (>= 2.2.5)
- drb
- i18n (>= 1.6, < 2)
- logger (>= 1.4.2)
- minitest (>= 5.1)
- securerandom (>= 0.3)
- tzinfo (~> 2.0, >= 2.0.5)
- addressable (2.8.7)
- public_suffix (>= 2.0.2, < 7.0)
- algoliasearch (1.27.5)
- httpclient (~> 2.8, >= 2.8.3)
- json (>= 1.5.1)
- atomos (0.1.3)
- base64 (0.2.0)
- benchmark (0.4.0)
- bigdecimal (3.1.9)
- claide (1.1.0)
- cocoapods (1.16.2)
- addressable (~> 2.8)
- claide (>= 1.0.2, < 2.0)
- cocoapods-core (= 1.16.2)
- cocoapods-deintegrate (>= 1.0.3, < 2.0)
- cocoapods-downloader (>= 2.1, < 3.0)
- cocoapods-plugins (>= 1.0.0, < 2.0)
- cocoapods-search (>= 1.0.0, < 2.0)
- cocoapods-trunk (>= 1.6.0, < 2.0)
- cocoapods-try (>= 1.1.0, < 2.0)
- colored2 (~> 3.1)
- escape (~> 0.0.4)
- fourflusher (>= 2.3.0, < 3.0)
- gh_inspector (~> 1.0)
- molinillo (~> 0.8.0)
- nap (~> 1.0)
- ruby-macho (>= 2.3.0, < 3.0)
- xcodeproj (>= 1.27.0, < 2.0)
- cocoapods-core (1.16.2)
- activesupport (>= 5.0, < 8)
- addressable (~> 2.8)
- algoliasearch (~> 1.0)
- concurrent-ruby (~> 1.1)
- fuzzy_match (~> 2.0.4)
- nap (~> 1.0)
- netrc (~> 0.11)
- public_suffix (~> 4.0)
- typhoeus (~> 1.0)
- cocoapods-deintegrate (1.0.5)
- cocoapods-downloader (2.1)
- cocoapods-plugins (1.0.0)
- nap
- cocoapods-search (1.0.1)
- cocoapods-trunk (1.6.0)
- nap (>= 0.8, < 2.0)
- netrc (~> 0.11)
- cocoapods-try (1.2.0)
- colored2 (3.1.2)
- concurrent-ruby (1.3.5)
- connection_pool (2.5.0)
- drb (2.2.1)
- escape (0.0.4)
- ethon (0.16.0)
- ffi (>= 1.15.0)
- ffi (1.17.1)
- fourflusher (2.3.1)
- fuzzy_match (2.0.4)
- gh_inspector (1.1.3)
- httpclient (2.9.0)
- mutex_m
- i18n (1.14.7)
- concurrent-ruby (~> 1.0)
- json (2.10.2)
- logger (1.6.6)
- minitest (5.25.5)
- molinillo (0.8.0)
- mutex_m (0.3.0)
- nanaimo (0.4.0)
- nap (1.1.0)
- netrc (0.11.0)
- nkf (0.2.0)
- public_suffix (4.0.7)
- rexml (3.4.1)
- ruby-macho (2.5.1)
- securerandom (0.4.1)
- typhoeus (1.4.1)
- ethon (>= 0.9.0)
- tzinfo (2.0.6)
- concurrent-ruby (~> 1.0)
- xcodeproj (1.27.0)
- CFPropertyList (>= 2.3.3, < 4.0)
- atomos (~> 0.1.3)
- claide (>= 1.0.2, < 2.0)
- colored2 (~> 3.1)
- nanaimo (~> 0.4.0)
- rexml (>= 3.3.6, < 4.0)
-
-PLATFORMS
- ruby
-
-DEPENDENCIES
- cocoapods (= 1.16.2)
-
-BUNDLED WITH
- 2.4.20
diff --git a/README.md b/README.md
index 30639e39..7fc86b4e 100644
--- a/README.md
+++ b/README.md
@@ -3,1594 +3,163 @@
-# Craft: Universal Release Tool (And More)
+# Craft: Universal Release Tool
-[](https://travis-ci.org/getsentry/craft)
[](https://github.com/getsentry/craft/releases/latest)
[](https://www.npmjs.com/package/@sentry/craft)
[](https://github.com/getsentry/craft/blob/master/LICENSE)
-`craft` is a command line tool that helps to automate and pipeline package releases. It suggests, and
-then enforces a specific workflow for managing release branches, changelogs, artifact publishing, etc.
+Craft is a command line tool that helps automate and pipeline package releases. It enforces a specific workflow for managing release branches, changelogs, and artifact publishing.
-## Table of Contents
+📚 **[Full Documentation](https://getsentry.github.io/craft/)**
-- [Installation](#installation)
- - [Binary](#binary)
- - [npm (not recommended)](#npm-not-recommended)
-- [Usage](#usage)
-- [Caveats](#caveats)
-- [Global Configuration](#global-configuration)
- - [Environment Files](#environment-files)
-- [Workflow](#workflow)
- - [`craft prepare`: Preparing a New Release](#craft-prepare-preparing-a-new-release)
- - [`craft publish`: Publishing the Release](#craft-publish-publishing-the-release)
- - [Example](#example)
-- [Configuration File: `.craft.yml`](#configuration-file-craftyml)
- - [GitHub project](#github-project)
- - [Pre-release Command](#pre-release-command)
- - [Post-release Command](#post-release-command)
- - [Release Branch Name](#release-branch-name)
- - [Changelog Policies](#changelog-policies)
- - [Minimal Version](#minimal-version)
- - [Required Files](#required-files)
-- [Status Provider](#status-provider)
-- [Artifact Provider](#artifact-provider)
-- [Target Configurations](#target-configurations)
- - [Per-target options](#per-target-options)
- - [GitHub (`github`)](#github-github)
- - [NPM (`npm`)](#npm-npm)
- - [Python Package Index (`pypi`)](#python-package-index-pypi)
- - [Sentry internal PyPI (`sentry-pypi`)](#sentry-internal-pypi-sentry-pypi)
- - [Homebrew (`brew`)](#homebrew-brew)
- - [NuGet (`nuget`)](#nuget-nuget)
- - [Rust Crates (`crates`)](#rust-crates-crates)
- - [Google Cloud Storage (`gcs`)](#google-cloud-storage-gcs)
- - [GitHub Pages (`gh-pages`)](#github-pages-gh-pages)
- - [Sentry Release Registry (`registry`)](#sentry-release-registry-registry)
- - [Cocoapods (`cocoapods`)](#cocoapods-cocoapods)
- - [Docker (`docker`)](#docker-docker)
- - [Ruby Gems Index (`gem`)](#ruby-gems-index-gem)
- - [AWS Lambda Layer (`aws-lambda-layer`)](#aws-lambda-layer-aws-lambda-layer)
- - [Unity Package Manager (`upm`)](#unity-package-manager-upm)
- - [Maven central (`maven`)](#maven-central-maven)
- - [Symbol Collector (`symbol-collector`)](#symbol-collector-symbol-collector)
- - [pub.dev (`pub-dev`)](#pubdev-pub-dev)
- - [Hex (`hex`)](#hex-hex)
- - [Commit on Git Repository (`commit-on-git-repository`)](#git-repository-commit-on-git-repository)
-- [Integrating Your Project with `craft`](#integrating-your-project-with-craft)
-- [Pre-release (Version-bumping) Script: Conventions](#pre-release-version-bumping-script-conventions)
-- [Post-release Script: Conventions](#post-release-script-conventions)
+## Quick Start
-## Installation
+### Installation
-### Binary
-
-`craft` is [distributed as a minified single JS binary](https://github.com/getsentry/craft/releases/latest).
-
-### npm (not recommended)
-
-Recommendation is to used this file directly but one can also install `craft` as
-an [NPM package](https://yarn.pm/@sentry/craft) and can be installed via `yarn`
-or `npm`:
-
-```shell
-yarn global add @sentry/craft
-```
+Download the [latest binary release](https://github.com/getsentry/craft/releases/latest), or install via npm:
```shell
npm install -g @sentry/craft
```
-## Usage
-
-```shell
-$ craft -h
-craft
-
-Commands:
- craft prepare NEW-VERSION 🚢 Prepare a new release branch
- [aliases: p, prerelease, prepublish, prepare, release]
- craft publish NEW-VERSION 🛫 Publish artifacts [aliases: pp, publish]
- craft targets List defined targets as JSON array
- craft config Print the parsed, processed, and validated Craft
- config for the current project in pretty-JSON.
- craft artifacts 📦 Manage artifacts [aliases: a, artifact]
-
-Options:
- --no-input Suppresses all user prompts [default: false]
- --dry-run Dry run mode: do not perform any real actions
- --log-level Logging level
- [choices: "Fatal", "Error", "Warn", "Log", "Info", "Success", "Debug",
- "Trace", "Silent", "Verbose"] [default: "Info"]
- -v, --version Show version number [boolean]
- -h, --help Show help [boolean]
-```
-
-
-### Version naming conventions
-
-Craft currently supports [semantic versioning (semver)](https://semver.org)-like versions for the `NEW-VERSION` argument passed to its `prepare` and `publish` commands. This means, releases made with craft need to follow a general pattern as follows:
-
-```txt
-..(-)?(-)?
-```
-
-- The ``, `` and `` numbers are required
-- The `` and `` identifiers are optional
-
-#### Preview releases (``)
-
-Preview or pre-release identifiers **must** include one of the following identifiers
-
-```txt
-preview|pre|rc|dev|alpha|beta|unstable|a|b
-```
-
-and may additionally include incremental pre-release version numbers.
-Adding identifiers other than the ones listed above result in Craft either rejecting the release (if not parse-able) or the release being treated by individual targets as a stable release.
-
-Examples:
-
-```txt
-1.0.0-preview
-1.0.0-alpha.0
-1.0.0-beta.1
-1.0.0-rc.20
-1.0.0-a
-
-// invalid or incorrectly treated
-1.0.0-foo
-1.0.0-canary.0
-```
-
-#### Special Case: Python Post Releases
-
-
-Python has the concept of post releases, which craft handles implicitly. A post release is indicated by a `-\d+` suffix to the semver version, for example: `1.0.0-1`.
-Given that we only consider certain identifiers as [pre-releases](#preview-releases-prerelease), post releases are considered stable releases.
-
-### Build identifiers (``)
-
-Craft supports adding a build identifier to your version, for example if you release the same package version for different platforms or architectures.
-You can also combine build and pre-release identifiers but in this case, the pre-release identifier has to come first.
-
-Examples:
-
-```txt
-// valid
-1.0.0+x86_64
-1.0.0-rc.1+x86_64
-
-// invalid or incorrectly treated
-1.0.0+rc.1+x86_64
-1.0.0+x86_64-beta.0
-```
-
-## Caveats
-
-- When interacting with remote GitHub repositories, `craft` uses the
- remote `origin` by default. If you have a different setup, set the
- `CRAFT_REMOTE` environment variable or the `--remote` option to the git remote
- you are using.
-
-## Global Configuration
-
-Global configuration for `craft` can be done either by using environment
-variables or by adding values to a configuration file (see below).
-
-All command line flags can be set through environment variables by prefixing
-them with `CRAFT_` and converting them to UPPERCASE_UNDERSCORED versions:
+### Usage
```shell
-CRAFT_LOG_LEVEL=Debug
-CRAFT_DRY_RUN=1
-CRAFT_NO_INPUT=0
-```
-
-Since Craft heavily relies on GitHub, it needs the `GITHUB_TOKEN` environment
-variable to be set to a proper
-[GitHub Personal Access Token](https://github.com/settings/tokens) for almost
-anything. The token only needs `repo` scope (`repo:status` and `public_repo`
-subscopes, to be precise).
-
-Additional environment variables may be required when publishing to specific
-targets (e.g. `TWINE_USERNAME` and `TWINE_PASSWORD` for PyPI target).
-
-### Environment Files
-
-`craft` will read configuration variables (keys, tokens, etc.) from the
-following locations:
-
-- `$HOME/.craft.env`
-- `$PROJECT_DIR/.craft.env`
-- the shell's environment
-
-where `$HOME` is the current user's home directory, and `$PROJECT_DIR` is the
-directory where `.craft.yml` is located.
-
-These locations will be checked in the order specified above, with values
-found in one location overwriting anything found in previous locations. In other
-words, environment variables will take precedence over either configuration
-file, and the project-specific file will take precedence over the file in
-`$HOME`.
-
-The env files must be written in shell (`sh`/`bash`) format.
-Leading `export` is allowed.
-
-Example:
-
-```shell
-# ~/.craft.env
-GITHUB_TOKEN=token123
-export NUGET_API_TOKEN=abcdefgh
-```
-
-## Workflow
-
-### `craft prepare`: Preparing a New Release
-
-This command will create a new release branch, check the changelog entries,
-run a version-bumping script, and push this branch to GitHub. We expect
-that CI triggered by pushing this branch will result in release artifacts
-being built and uploaded to the artifact provider you wish to use during the
-subsequent `publish` step.
-
-```shell
-craft prepare NEW-VERSION
-
-🚢 Prepare a new release branch
-
-Positionals:
- NEW-VERSION The new version you want to release [string] [required]
-
-Options:
- --no-input Suppresses all user prompts [default: false]
- --dry-run Dry run mode: do not perform any real actions
- --log-level Logging level
- [choices: "Fatal", "Error", "Warn", "Log", "Info", "Success", "Debug",
- "Trace", "Silent", "Verbose"] [default: "Info"]
- --rev, -r Source revision (git SHA or tag) to prepare from (if not
- branch head) [string]
- --no-push Do not push the release branch [boolean] [default: false]
- --no-git-checks Ignore local git changes and unsynchronized remotes
- [boolean] [default: false]
- --no-changelog Do not check for changelog entries [boolean] [default: false]
- --publish Run "publish" right after "release"[boolean] [default: false]
- --remote The git remote to use when pushing
- [string] [default: "origin"]
- -v, --version Show version number [boolean]
- -h, --help Show help [boolean]
-```
-
-### `craft publish`: Publishing the Release
-
-The command will find a release branch for the provided version. The normal flow
-is for this release branch to be created automatically by `craft prepare`, but
-that's not strictly necessary. Then, it subscribes to the latest status checks on
-that branch. Once the checks pass, it downloads the release artifacts from the
-artifact provider configured in `.craft.yml` and uploads them to the targets named
-on the command line (and pre-configured in `.craft.yml`).
-
-```shell
-craft publish NEW-VERSION
-
-🛫 Publish artifacts
-
-Positionals:
- NEW-VERSION Version to publish [string] [required]
-
-Options:
- --no-input Suppresses all user prompts [default: false]
- --dry-run Dry run mode: do not perform any real actions
- --log-level Logging level
- [choices: "Fatal", "Error", "Warn", "Log", "Info", "Success", "Debug",
- "Trace", "Silent", "Verbose"] [default: "Info"]
- --target, -t Publish to this target
- [string] [choices: "npm", "gcs", "registry", "docker", "github", "gh-pages",
- "all", "none"] [default: "all"]
- --rev, -r Source revision (git SHA or tag) to publish (if not release
- branch head) [string]
- --no-merge Do not merge the release branch after publishing
- [boolean] [default: false]
- --keep-branch Do not remove release branch after merging it
- [boolean] [default: false]
- --keep-downloads Keep all downloaded files [boolean] [default: false]
- --no-status-check Do not check for build status [boolean] [default: false]
- -v, --version Show version number [boolean]
- -h, --help Show help [boolean]
-```
-
-### Example
-
-Let's imagine we want to release a new version of our package, and the version
-in question is `1.2.3`.
-
-We run `prepare` command first:
-
-`$ craft prepare 1.2.3`
-
-After some basic sanity checks this command creates a new release branch
-`release/1.2.3`, runs the version-bumping script (`scripts/bump-version.sh`),
-commits the changes made by the script, and then pushes the new branch to
-GitHub. At this point CI systems kick in, and the results of those builds, as
-well as built artifacts (binaries, NPM archives, Python wheels) are gradually
-uploaded to GitHub.
-
-To publish the built artifacts we run `publish`:
-
-`$ craft publish 1.2.3`
-
-This command will find our release branch (`release/1.2.3`), check the build
-status of the respective git revision in GitHub, and then publish available
-artifacts to configured targets (for example, to GitHub and NPM in the case of
-Craft).
-
-## Configuration File: `.craft.yml`
-
-Project configuration for `craft` is stored in `.craft.yml` configuration file,
-located in the project root.
-
-### GitHub project
-
-Craft tries to determine the GitHub repo information from the local git repo and
-its remotes configuration. However, since `publish` command does not require a
-local git checkout, you may want to hard-code this information into the
-configuration itself:
-
-```yaml
-github:
- owner: getsentry
- repo: sentry-javascript
-```
-
-### Pre-release Command
-
-This command will run on your newly created release branch as part of `prepare`
-command. By default, it is set to `bash scripts/bump-version.sh`. Please refer
-to the [Pre-release version bumping script conventions section](#pre-release-version-bumping-script-conventions)
-for more details.
-
-```yaml
-preReleaseCommand: bash scripts/bump-version.sh
-```
-
-### Post-release Command
-
-This command will run after a successful `publish`. By default, it is set to
-`bash scripts/post-release.sh`. It will _not_ error if the default script is
-missing though, as this may not be needed by all projects. Please refer to the
-[Post-release script conventions section](#post-release-script-conventions)
-for more details.
-
-```yaml
-postReleaseCommand: bash scripts/post-release.sh
-```
-
-### Release Branch Name
-
-This overrides the prefix for the release branch name. The full branch name used
-for a release is `{releaseBranchPrefix}/{version}`. The prefix defaults to
-`"release"`.
-
-```yaml
-releaseBranchPrefix: publish
-```
-
-### Changelog Policies
-
-`craft` can help you to maintain change logs for your projects. At the moment,
-`craft` supports two approaches: `simple`, and `auto` to changelog management.
-
-In `simple` mode, `craft prepare` will remind you to add a changelog entry to the
-changelog file (`CHANGELOG.md` by default).
-
-In `auto` mode, `craft prepare` will use the following logic:
-
-1. If there's already an entry for the given version, use that
-2. Else if there is an entry named `Unreleased`, rename that to the given
- version
-3. Else, create a new section for the version and populate it with the changes
- since the last version. It uses `.github/release.yml` configuration to
- categorize PRs by labels or commit title patterns. PRs are matched to
- categories based on their labels first; if no label matches, the commit/PR
- title is checked against `commit_patterns`. Any PRs that don't match a
- category are listed under the "Other" section. The system supports custom
- categories, exclusions (both global and per-category), and wildcard matching.
- Check out [GitHub's release notes documentation](https://docs.github.com/en/repositories/releasing-projects-on-github/automatically-generated-release-notes#configuration-options)
- for the base configuration format.
-
-**Custom Changelog Entries from PR Descriptions**
-
-By default, the changelog entry for a PR is generated from its title. However,
-PR authors can override this by adding a "Changelog Entry" section to the PR
-description. This allows for more detailed, user-facing changelog entries without
-cluttering the PR title.
-
-To use this feature, add a markdown heading (level 2 or 3) titled "Changelog Entry"
-to your PR description, followed by the desired changelog text:
-
-```markdown
-### Description
-
-Add `foo` function, and add unit tests to thoroughly check all edge cases.
-
-### Changelog Entry
-
-Add a new function called `foo` which prints "Hello, world!"
-
-### Issues
-
-Closes #123
-```
-
-The text under "Changelog Entry" will be used verbatim in the changelog instead
-of the PR title. If no such section is present, the PR title is used as usual.
-
-**Advanced Features:**
-
-1. **Multiple Entries**: If you use multiple top-level bullet points in the
- "Changelog Entry" section, each bullet will become a separate changelog entry:
-
- ```markdown
- ### Changelog Entry
-
- - Add OAuth2 authentication
- - Add two-factor authentication
- - Add session management
- ```
-
-2. **Nested Content**: Indented bullets (4+ spaces or tabs) are preserved as
- nested content under their parent entry:
-
- ```markdown
- ### Changelog Entry
-
- - Add authentication system
- - OAuth2 support
- - Two-factor authentication
- - Session management
- ```
-
- This will generate:
- ```markdown
- - Add authentication system by @user in [#123](url)
- - OAuth2 support
- - Two-factor authentication
- - Session management
- ```
-
-3. **Plain Text**: If no bullets are used, the entire content is treated as a
- single changelog entry. Multi-line text is supported.
+# Auto-determine version from conventional commits
+craft prepare auto
-4. **Content Isolation**: Only content within the "Changelog Entry" section is
- included in the changelog. Other sections (Description, Issues, etc.) are
- ignored.
+# Or specify a version explicitly
+craft prepare 1.2.3
-**Default Conventional Commits Configuration**
-
-If `.github/release.yml` doesn't exist or has no `changelog` section, craft uses
-a default configuration based on [Conventional Commits](https://www.conventionalcommits.org/):
-
-| Category | Pattern |
-| ------------------------------- | ------------------------------------ |
-| Breaking Changes | `^\w+(\(\w+\))?!:` (e.g., `feat!:`) |
-| Build / dependencies / internal | `^(build\|ref\|chore\|ci)(\(\w+\))?:`|
-| Bug Fixes | `^fix(\(\w+\))?:` |
-| Documentation | `^docs?(\(\w+\))?:` |
-| New Features | `^feat(\(\w+\))?:` |
-
-**Commit Log Patterns**
-
-In addition to GitHub labels, you can match commits to categories using
-`commit_patterns`. This is an array of JavaScript regex strings that are
-matched against the PR title (or commit message if no PR exists). Labels always
-take precedence over patterns.
-
-```yaml
-# .github/release.yml
-changelog:
- categories:
- - title: Features
- labels:
- - enhancement
- commit_patterns:
- - "^feat(\\(\\w+\\))?:"
- - title: Bug Fixes
- labels:
- - bug
- commit_patterns:
- - "^fix(\\(\\w+\\))?:"
-```
-
-Patterns are matched case-insensitively. You can use both `labels` and
-`commit_patterns` in the same category - PRs will be matched by label first,
-then by pattern if no label matches.
-
-**Section Ordering**
-
-Changelog sections are sorted according to the order in which categories are
-defined in `.github/release.yml`. For example, if your config lists categories
-as `Features`, `Bug Fixes`, `Documentation`, the generated changelog will
-display sections in that exact order, regardless of which type of PR was
-encountered first in the git history. The "Other" section (for uncategorized
-changes) always appears last.
-
-**Scope Grouping**
-
-When using [Conventional Commits](https://www.conventionalcommits.org/) with
-scopes (e.g., `feat(api): add endpoint`), changes within each category are
-automatically grouped by scope. Scope names are formatted as title case
-sub-headers (level 4, `####`), with dashes and underscores converted to spaces.
-
-For example, `feat(my-component): add feature` will appear under a
-`#### My Component` sub-header within the Features category.
-
-Scopes are normalized to lowercase for grouping purposes, so `feat(API):` and
-`feat(api):` will be grouped together under the same `#### Api` header.
-Additionally, dashes and underscores are treated as equivalent, so `feat(my-component):`
-and `feat(my_component):` will also be grouped together.
-
-Scope headers are only shown for scopes with more than one entry (single-entry
-scope headers aren't useful). Entries without a scope (e.g., `feat: add feature`)
-are listed at the bottom of each category section without a sub-header.
-
-**Example output with scope grouping:**
-
-```text
-### New Features
-
-#### Api
-
-- feat(api): add user endpoint by @alice in [#1](https://github.com/...)
-- feat(api): add auth endpoint by @bob in [#2](https://github.com/...)
-
-#### Ui
-
-- feat(ui): add dashboard by @charlie in [#3](https://github.com/...)
-
-- feat: general improvement by @dave in [#4](https://github.com/...)
-```
-
-**Configuration**
-
-The `changelog` option can be either a string (file path) or an object with more options:
-
-| Option | Description |
-| ------------------------- | ------------------------------------------------------------------------------------------ |
-| `changelog` | **optional**. Path to changelog file (string) OR configuration object (see below) |
-| `changelog.filePath` | **optional**. Path to the changelog file. Defaults to `CHANGELOG.md` |
-| `changelog.policy` | **optional**. Changelog management mode (`none`, `simple`, or `auto`). Defaults to `none`. |
-| `changelog.scopeGrouping` | **optional**. Enable scope-based grouping within categories. Defaults to `true`. |
-| `changelogPolicy` | **deprecated**. Use `changelog.policy` instead. |
-
-**Example (`simple` with file path only):**
-
-```yaml
-changelog: CHANGES
-```
-
-**Example (`simple` with custom file path):**
-
-```yaml
-changelog:
- filePath: CHANGES.md
- policy: simple
+# Publish to all configured targets
+craft publish 1.2.3
```
-**Valid changelog example:**
-
-```text
-## 1.3.5
-
-* Removed something
-
-## 1.3.4
-
-* Added something
-```
+## Features
-**Example (`auto`):**
+- **Auto Versioning** - Automatically determine version bumps from conventional commits
+- **Multiple Targets** - Publish to GitHub, NPM, PyPI, Docker, Crates.io, NuGet, and more
+- **Changelog Management** - Auto-generate changelogs from commits or validate manual entries
+- **Workspace Support** - Handle monorepos with NPM/Yarn workspaces
+- **CI Integration** - Wait for CI to pass, download artifacts, and publish
+- **GitHub Actions** - Built-in actions for release preparation and changelog previews
-```yaml
-changelog:
- policy: auto
-```
+## Configuration
-**Example (disable scope grouping):**
+Create a `.craft.yml` in your project root:
```yaml
+minVersion: "2.0.0"
changelog:
policy: auto
- scopeGrouping: false
-```
-
-**Changelog with staged changes example:**
-
-```text
-## Unreleased
-
-* Removed something
-
-## 1.3.4
-
-* Added something
-```
-
-Additionally, `.craft.yml` is used for listing targets where you want to
-publish your new release.
-
-### Minimal Version
-
-It is possible to specify minimal `craft` version that is required to work with
-your configuration.
-
-**Example:**
-
-```yaml
-minVersion: '0.5.0'
-```
-
-### Required Files
-
-You can provide a list of patterns for files that _have to be_ available before
-proceeding with publishing. In other words, for every pattern in the given list
-there has to be a file present that matches that pattern. This might be helpful
-to ensure that we're not trying to do an incomplete release.
-
-**Example:**
-
-```yaml
-requireNames:
- - /^sentry-craft.*\.tgz$/
- - /^gh-pages.zip$/
-```
-
-## Status Provider
-
-You can configure which status providers `craft` will use to check for your build status.
-By default, it will use GitHub but you can add more providers if needed.
-
-**Configuration**
-
-| Option | Description |
-| -------- | -------------------------------------------------------------------------------------------------- |
-| `name` | Name of the status provider: only `github` (default) for now. |
-| `config` | In case of `github`: may include `contexts` key that contains a list of required contexts (checks) |
-
-**Example:**
-
-```yaml
-statusProvider:
- name: github
- config:
- contexts:
- - Travis CI - Branch
-```
-
-## Artifact Provider
-
-You can configure which artifact providers `craft` will use to fetch artifacts from.
-By default, GitHub is used, but in case you don't need use any artifacts in your
-project, you can set it to `none`.
-
-**Configuration**
-
-| Option | Description |
-| ------ | ------------------------------------------------------------------- |
-| `name` | Name of the artifact provider: `github` (default), `gcs`, or `none` |
-
-**Example:**
-
-```yaml
-artifactProvider:
- name: none
-```
-
-## Target Configurations
-
-The configuration specifies which release targets to run for the repository. To
-run more targets, list the target identifiers under the `targets` key in
-`.craft.yml`.
-
-**Example:**
-
-```yaml
targets:
- - name: npm
- name: github
- - name: registry
- id: browser
- type: sdk
- onlyIfPresent: /^sentry-browser-.*\.tgz$/
- includeNames: /\.js$/
- checksums:
- - algorithm: sha384
- format: base64
- config:
- canonical: 'npm:@sentry/browser'
- - name: registry
- id: node
- type: sdk
- onlyIfPresent: /^sentry-node-.*\.tgz$/
- config:
- canonical: 'npm:@sentry/node'
-```
-
-### Per-target options
-
-The following options can be applied to every target individually:
-
-| Name | Description |
-| -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
-| `includeNames` | **optional**. Regular expression: only matched files will be processed by the target. There is one special case that `includeNames` supports. |
-| `excludeNames` | **optional**. Regular expression: the matched files will be skipped by the target. Matching is performed after testing for inclusion (via `includeNames`). |
-| `id` | **optional**. A unique id for the target type so one can refer to that target individually with the `-t` option with the `publish` command like `-t registry[browser]`. (see the example config above) |
-
-If neither option is included, all artifacts for the release will be processed by the target.
-
-**Example:**
-
-```yaml
-targets:
- - name: github
- includeNames: /^.*\.exe$/
- excludeNames: /^test.exe$/
-```
-
-### GitHub (`github`)
-
-Create a release on Github. If a Markdown changelog is present in the
-repository, this target tries to read the release name and description from the
-changelog. Otherwise, defaults to the tag name and tag's commit message.
-
-If `previewReleases` is set to `true` (which is the default), the release
-created on GitHub will be marked as a pre-release version if the release name
-contains any one of [pre-release identifiers](#preview-releases-prerelease).
-
-**Environment**
-
-| Name | Description |
-| -------------- | ------------------------------------------------------------------ |
-| `GITHUB_TOKEN` | Personal GitHub API token (see ) |
-
-**Configuration**
-
-| Option | Description |
-| ----------------- | ------------------------------------------------------------------------------------------------ |
-| `tagPrefix` | **optional**. Prefix for new git tags (e.g. "v"). Empty by default. |
-| `previewReleases` | **optional**. Automatically detect and create preview releases. `true` by default. |
-| `tagOnly` | **optional**. If set to `true`, only create a tag (without a GitHub release).`false` by default. |
-
-**Example:**
-
-```yaml
-targets:
- - name: github
- tagPrefix: v
- previewReleases: false
-```
-
-### NPM (`npm`)
-
-Releases an NPM package to the public registry. This requires a package tarball
-generated by `npm pack` in the artifacts. The file will be uploaded to the
-registry with `npm publish`, or with `yarn publish` if `npm` is not found. This
-requires NPM to be authenticated with sufficient permissions to publish the package.
-
-**Environment**
-
-The `npm` utility must be installed on the system.
-
-| Name | Description |
-| ------------------- | ------------------------------------------------------------------- |
-| `NPM_TOKEN` | An [automation token][npm-automation-token] allowed to publish. |
-| `NPM_BIN` | **optional**. Path to the npm executable. Defaults to `npm` |
-| `YARN_BIN` | **optional**. Path to the yarn executable. Defaults to `yarn` |
-| `CRAFT_NPM_USE_OTP` | **optional**. If set to "1", you will be asked for an OTP (for 2FA) |
-
-[npm-automation-token]: https://docs.npmjs.com/creating-and-viewing-access-tokens
-
-**Configuration**
-
-| Option | Description |
-| ------------------ | ------------------------------------------------------------------------------------------------------------------------------- |
-| `access` | **optional**. Visibility for scoped packages: `restricted` (default) or `public` |
-| `checkPackageName` | **optional**. If defined, check this package on the registry to get the current latest version to compare for the `latest` tag. The package(s) to be published will only be tagged with `latest` if the new version is greater than the checked package's version|
-
-**Example**
-
-```yaml
-targets:
- name: npm
access: public
```
-### Python Package Index (`pypi`)
-
-Uploads source dists and wheels to the Python Package Index via [twine](https://pypi.org/project/twine/).
-The source code bundles and/or wheels must be in the release assets.
-
-**Environment**
-
-The `twine` Python package must be installed on the system.
-
-| Name | Description |
-| ---------------- | ----------------------------------------------------- |
-| `TWINE_USERNAME` | User name for PyPI with access rights for the package |
-| `TWINE_PASSWORD` | Password for the PyPI user |
-| `TWINE_BIN` | **optional**. Path to twine. Defaults to `twine` |
-
-**Configuration**
-
-_none_
-
-**Example**
-
-```yaml
-targets:
- - name: pypi
-```
-
-### Sentry internal PyPI (`sentry-pypi`)
-
-Creates a GitHub pull request to import the package into a repo set up
-like [getsentry/pypi]
-
-[getsentry/pypi]: https://github.com/getsentry/pypi
-
-**Environment**
-
-| Name | Description |
-| -------------- | ------------------------------------------------------------------ |
-| `GITHUB_TOKEN` | Personal GitHub API token (see ) |
-
-**Configuration**
-
-| Option | Description |
-| ------------------ | ------------------------------------ |
-| `internalPypiRepo` | GitHub repo containing pypi metadata |
-
-**Example**
-
-```yaml
-targets:
- - name: pypi
- - name: sentry-pypi
- internalPypiRepo: getsentry/pypi
-```
-
-### Homebrew (`brew`)
-
-Pushes a new or updated homebrew formula to a brew tap repository. The formula
-is committed directly to the master branch of the tap on GitHub, therefore the
-bot needs rights to commit to `master` on that repository. Therefore, formulas
-on `homebrew/core` are not supported, yet.
-
-The tap is configured with the mandatory `tap` parameter in the same format as
-the `brew` utility. A tap `/` will expand to the GitHub repository
-`github.com:/homebrew-`.
-
-The formula contents are given as configuration value and can be interpolated
-with Mustache template syntax (`{{ variable }}`). The interpolation context
-contains the following variables:
-
-- `version`: The new version
-- `revision`: The tag's commit SHA
-- `checksums`: A map containing sha256 checksums for every release asset. Use
- the full filename to access the sha, e.g. `checksums.MyProgram-x86`. If the
- filename contains dots (`.`), they are being replaced with `__`. If the
- filename contains the currently released version, it is replaced with `__VERSION__`.
- For example, `sentry-wizard-v3.9.3.tgz` checksums will be accessible by the key
- `checksums.sentry-wizard-v__VERSION____tgz`.
-
-**Environment**
-
-| Name | Description |
-| -------------- | ------------------------------------------------------------------ |
-| `GITHUB_TOKEN` | Personal GitHub API token (seeh ttps://github.com/settings/tokens) |
-
-**Configuration**
-
-| Option | Description |
-| ---------- | ------------------------------------------------------------------ |
-| `tap` | The name of the homebrew tap used to access the GitHub repo |
-| `template` | The template for contents of the formula file (ruby code) |
-| `formula` | **optional**. Name of the formula. Defaults to the repository name |
-| `path` | **optional**. Path to store the formula in. Defaults to `Formula` |
-
-**Example**
-
-```yaml
-targets:
- - name: brew
- tap: octocat/tools # Expands to github.com:octocat/homebrew-tools
- formula: myproject # Creates the file myproject.rb
- path: HomebrewFormula # Creates the file in HomebrewFormula/
- template: >
- class MyProject < Formula
- desc "This is a test for homebrew formulae"
- homepage "https://github.com/octocat/my-project"
- url "https://github.com/octocat/my-project/releases/download/{{version}}/binary-darwin"
- version "{{version}}"
- sha256 "{{checksums.binary-darwin}}"
-
- def install
- mv "binary-darwin", "myproject"
- bin.install "myproject"
- end
- end
-```
-
-### NuGet (`nuget`)
-
-Uploads packages to [NuGet](https://www.nuget.org/) via [.NET Core](https://github.com/dotnet/core).
-Normally, `craft` targets raise an exception when trying to release a version that already exists. *This target diverges from the norm and allows re-entrant publishing* as it can publish multiple packages at once and the processes might get interrupted. This behavior allows us to finalize half-finished releases without having to publish a new version and play cat & mouse with the flaky upstream package repository.
-
-**Environment**
-
-The `dotnet` tool must be available on the system.
-
-| Name | Description |
-| ------------------ | ----------------------------------------------------------------- |
-| `NUGET_API_TOKEN` | NuGet personal [API token](https://www.nuget.org/account/apikeys) |
-| `NUGET_DOTNET_BIN` | **optional**. Path to .NET Core. Defaults to `dotnet` |
-
-**Configuration**
-
-_none_
-
-**Example**
-
-```yaml
-targets:
- - name: nuget
-```
-
-### Rust Crates (`crates`)
-
-Publishes a single Rust package or entire workspace on the public crate registry
-([crates.io](https://crates.io)). If the workspace contains multiple crates,
-they are published in an order depending on their dependencies.
-
-**Environment**
-
-"cargo" must be installed and configured on the system.
-
-| Name | Description |
-| ----------------- | ------------------------------------------------- |
-| `CRATES_IO_TOKEN` | The access token to the crates.io account |
-| `CARGO_BIN` | **optional**. Path to cargo. Defaults to `cargo`. |
-
-**Configuration**
-
-| Option | Description |
-| ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| `noDevDeps` | **optional**. Strips `devDependencies` from crates before publishing. This is useful if a workspace crate uses circular dependencies for docs. Requires [`cargo-hack`](https://github.com/taiki-e/cargo-hack#readme) installed. Defaults to `false`. |
-
-**Example**
-
-```yaml
-targets:
- - name: crates
- noDevDeps: false
-```
-
-### Google Cloud Storage (`gcs`)
-
-Uploads artifacts to a bucket in Google Cloud Storage.
-
-The bucket paths (`paths`) can be interpolated using Mustache syntax (`{{ variable }}`). The interpolation context contains the following variables:
-
-- `version`: The new project version
-- `revision`: The SHA revision of the new version
-
-**Environment**
-
-Google Cloud credentials can be provided using either of the following two environment variables.
-
-| Name | Description |
-| ----------------------------- | ------------------------------------------------------------------------ |
-| `CRAFT_GCS_TARGET_CREDS_PATH` | Local filesystem path to Google Cloud credentials (service account file) |
-| `CRAFT_GCS_TARGET_CREDS_JSON` | Full service account file contents, as a JSON string |
-
-If defined, `CRAFT_GCS_TARGET_CREDS_JSON` will be preferred over `CRAFT_GCS_TARGET_CREDS_PATH`.
-
-_Note:_ `CRAFT_GCS_TARGET_CREDS_JSON` and `CRAFT_GCS_TARGET_CREDS_PATH` were formerly called `CRAFT_GCS_CREDENTIALS_JSON` and `CRAFT_GCS_CREDENTIALS_PATH`, respectively. While those names will continue to work for the foreseeable future, you'll receive a warning encouraging you to switch to the new names.
-
-**Configuration**
-
-| Option | Description |
-| ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| `bucket` | The name of the GCS bucket where artifacts are uploaded. |
-| `paths` | A list of path objects that represent bucket paths. |
-| `paths.path` | Template-aware bucket path, which can contain `{{ version }}` and/or `{{ revision }}`. |
-| `paths.metadata` | **optional** [Metadata](https://cloud.google.com/storage/docs/json_api/v1/objects/insert#request_properties_JSON) for uploaded files. By default, it sets `Cache-Control` to `"public, max-age=300"`. |
-
-**Example**
-
-```yaml
-targets:
- - name: gcs
- bucket: bucket-name
- paths:
- - path: release/{{version}}/download
- metadata:
- cacheControl: `public, max-age=3600`
- - path: release/{{revision}}/platform/package
-```
-
-### GitHub Pages (`gh-pages`)
-
-Extracts an archive with static assets and pushes them to the specified git
-branch (`gh-pages` by default). Thus, it can be used to publish documentation
-or any other assets to [GitHub Pages](https://pages.github.com/), so they will be later automatically rendered
-by GitHub.
-
-By default, this target will look for an artifact named `gh-pages.zip`, extract it,
-and commit its contents to `gh-pages` branch.
-
-_WARNING!_ The destination branch will be completely overwritten by the contents
-of the archive.
-
-**Environment**
-
-_none_
-
-**Configuration**
-
-| Option | Description |
-| ------------- | --------------------------------------------------------------------------------------- |
-| `branch` | **optional** The name of the branch to push the changes to. `gh-pages` by default. |
-| `githubOwner` | **optional** GitHub project owner, defaults to the value from the global configuration. |
-| `githubRepo` | **optional** GitHub project name, defaults to the value from the global configuration. |
-
-**Example**
-
-```yaml
-targets:
- - name: gh-pages
- branch: gh-pages
-```
-
-### Sentry Release Registry (`registry`)
-
-The target will update the Sentry release registry repo( ) with the latest version of the
-project `craft` is used with. The release registry repository will be checked out
-locally, and then the new version file will be created there, along with the necessary
-symbolic links.
-
-Two package types are supported: "sdk" and "app". Type "sdk" means that the package
-is uploaded to one of the public registries (PyPI, NPM, Nuget, etc.), and that
-the corresponding package directory can be found inside "packages" directory of the
-release regsitry. Type "app" indicates that the package's version files are located
-in "apps" directory of the registry.
-
-It is strongly discouraged to have multiple `registry` targets in a config as it
-supports grouping/batching multiple apps and SDKs in a single target.
-
-**Environment**
-
-_none_
-
-**Configuration**
-
-| Option | Description |
-| ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
-| `apps` | List of `app` configs as a dict, keyed by their canonical names (example: `app:craft`) |
-| `sdks` | List of `sdk` configs as a dict, keyed by their canonical names (example: `maven:io.sentry:sentry`) |
-| `(sdks\|apps).urlTemplate` | **optional** URL template that will be used to generate download links for "app" package type. |
-| `(sdks\|apps).linkPrereleases` | **optional** Update package versions even if the release is a preview release, "false" by default. |
-| `(sdks\|apps).checksums` | **optional** A list of checksums that will be computed for matched files (see `includeNames`). Every checksum entry is an object with two attributes: algorithm (one of `sha256`, `sha384`, and `sha512`) and format (`base64` and `hex`). |
-| `(sdks\|apps).onlyIfPresent` | **optional** A file pattern. The target will be executed _only_ when the matched file is found. |
-
-**Example**
-
-```yaml
-targets:
- - name: registry
- sdks:
- 'npm:@sentry/browser':
- apps:
- 'npm:@sentry/browser':
- urlTemplate: 'https://example.com/{{version}}/{{file}}'
- checksums:
- - algorithm: sha256
- format: hex
-```
-
-### Cocoapods (`cocoapods`)
-
-Pushes a new podspec to the central cocoapods repository. The Podspec is fetched
-from the Github repository with the revision that is being released. No release
-assets are required for this target.
-
-**Environment**
-
-The `cocoapods` gem must be installed on the system.
-
-| Name | Description |
-| ----------------------- | ----------------------------------------- |
-| `COCOAPODS_TRUNK_TOKEN` | The access token to the cocoapods account |
-| `COCOAPODS_BIN` | **optional**. Path to `pod` executable. |
-
-**Configuration**
-
-| Option | Description |
-| ---------- | ------------------------------------------ |
-| `specPath` | Path to the Podspec file in the repository |
-
-**Example**
-
-```yaml
-targets:
- - name: cocoapods
- specPath: MyProject.podspec
-```
-
-### Docker (`docker`)
-
-Copies an existing source image tagged with the revision SHA to a new target
-tagged with the released version. No release assets are required for this target
-except for the source image at the provided source image location so it would be
-a good idea to add a status check that ensures the source image exists, otherwise
-`craft publish` will fail at the copy step, causing an interrupted publish.
-This is an issue for other, non-idempotent targets, not for the Docker target.
-
-**Environment**
-
-`docker` executable (or something equivalent) with BuildKit must be installed on the system.
-
-| Name | Description |
-| ----------------- | ------------------------------------------ |
-| `DOCKER_USERNAME` | The username for the Docker registry. |
-| `DOCKER_PASSWORD` | The personal access token for the account. |
-| `DOCKER_BIN` | **optional**. Path to `docker` executable. |
-
-**Configuration**
-
-| Option | Description |
-| -------------- | ------------------------------------------------------------------------ |
-| `source` | Path to the source Docker image to be pulled |
-| `sourceFormat` | Format for the source image name. Default: `{{{source}}}:{{{revision}}}` |
-| `target` | Path to the target Docker image to be pushed |
-| `targetFormat` | Format for the target image name. Default: `{{{target}}}:{{{version}}}` |
-
-**Example**
-
-```yaml
-targets:
- - name: docker
- source: us.gcr.io/sentryio/craft
- target: getsentry/craft
-# Optional but strongly recommended
-statusProvider:
- name: github
- config:
- contexts:
- - Travis CI - Branch # or whatever builds and pushes your source image
-```
-
-### Ruby Gems Index (`gem`)
+See the [configuration reference](https://getsentry.github.io/craft/configuration/) for all options.
-Pushes a gem [Ruby Gems](https://rubygems.org).
-It also requires you to be logged in with `gem login`.
+## Supported Targets
-**Environment**
+| Target | Description |
+|--------|-------------|
+| `github` | GitHub releases and tags |
+| `npm` | NPM registry (with workspace support) |
+| `pypi` | Python Package Index |
+| `crates` | Rust crates.io |
+| `nuget` | .NET NuGet |
+| `docker` | Docker registries |
+| `brew` | Homebrew formulas |
+| `gcs` | Google Cloud Storage |
+| `gh-pages` | GitHub Pages |
+| `cocoapods` | CocoaPods |
+| `gem` | RubyGems |
+| `maven` | Maven Central |
+| `hex` | Elixir Hex |
+| `pub-dev` | Dart/Flutter pub.dev |
+| `aws-lambda-layer` | AWS Lambda layers |
+| `powershell` | PowerShell Gallery |
-`gem` must be installed on the system.
+See the [targets documentation](https://getsentry.github.io/craft/targets/) for configuration details.
-| Name | Description |
-| --------- | --------------------------------------------------------- |
-| `GEM_BIN` | **optional**. Path to "gem" executable. Defaults to `gem` |
+## GitHub Actions
-**Configuration**
+Craft provides GitHub Actions for automating releases and previewing changelog entries.
-_none_
+### Prepare Release Action
-**Example**
+Automates the `craft prepare` workflow in GitHub Actions:
```yaml
-targets:
- - name: gem
-```
-
-### AWS Lambda Layer (`aws-lambda-layer`)
+name: Release
+on:
+ workflow_dispatch:
+ inputs:
+ version:
+ description: 'Version to release (or "auto")'
+ required: false
-The target will create a new public lambda layer in each available region with
-the extracted artifact from the artifact provider, and update the Sentry release
-registry with the new layer versions afterwards.
-
-**Environment**
-
-| Name | Description |
-| --------------------- | -------------------------------------------------------------------------- |
-| AWS_ACCESS_KEY | The access key of the AWS account to create and publish the layers. |
-| AWS_SECRET_ACCESS_KEY | The secret access key of the AWS account to create and publish the layers. |
-
-**Configuration**
-
-| Option | Description |
-| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------- |
-| linkPrereleases | **optional** Updates layer versions even if the release is a preview release, `false` by default. |
-| includeNames | **optional** Exists for all targets, [see here](##per-target-options). It must filter exactly one artifact. |
-| layerName | The name of the layer to be published. |
-| compatibleRuntimes | A list of compatible runtimes for the layer. Each compatible runtime consists on the name of the runtime and a list of compatible versions. |
-| license | The license of the layer. |
-
-**Example**
-
-```yaml
-targets:
- - name: aws-lambda-layer
- includeNames: /^sentry-node-serverless-\d+(\.\d+)*\.zip$/
- layerName: SentryNodeServerlessSDK
- compatibleRuntimes:
- - name: node
- versions:
- - nodejs10.x
- - nodejs12.x
- license: MIT
+jobs:
+ release:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+ - uses: getsentry/craft@v2
+ with:
+ version: ${{ github.event.inputs.version }}
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
```
-### Unity Package Manager (`upm`)
-
-Pulls the package as a zipped artifact and pushes the unzipped content to the target repository, tagging it with the provided version.
-
-_WARNING!_ The destination repository will be completely overwritten.
-
-**Environment**
-
-_none_
-
-**Configuration**
-
-| Option | Description |
-| ------------------ | --------------------------------------- |
-| `releaseRepoOwner` | Name of the owner of the release target |
-| `releaseRepoName` | Name of the repo of the release target |
-
-**Example**
-
-```yaml
-targets:
- - name: upm
- releaseRepoOwner: 'getsentry'
- releaseRepoName: 'unity'
-```
-
-### Maven central (`maven`)
-
-PGP signs and publishes packages to Maven Central.
-
-Note: in order to see the output of the commands, set the [logging level](#logging-level) to `trace`.
-
-**Environment**
+**Inputs:**
-| Name | Description |
-| ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
-| `OSSRH_USERNAME` | Username of Sonatype repository. |
-| `OSSRH_PASSWORD` | Password of Sonatype repository. |
-| `GPG_PASSPHRASE` | Passphrase for your default GPG Private Key. |
-| `GPG_PRIVATE_KEY` | **optional** GPG Private Key generated via `gpg --armor --export-secret-keys YOUR_ID`. If not provided, default key from your machine will be used. |
+| Input | Description | Default |
+|-------|-------------|---------|
+| `version` | Version to release (semver, "auto", "major", "minor", "patch") | Uses `versioning.policy` from config |
+| `merge_target` | Target branch to merge into | Default branch |
+| `force` | Force release even with blockers | `false` |
+| `blocker_label` | Label that blocks releases | `release-blocker` |
+| `publish_repo` | Repository for publish issues | `{owner}/publish` |
-**Configuration**
+**Outputs:**
-| Option | Description |
-| ------------------- | -------------------------------------------------------------------- |
-| `mavenCliPath` | Path to the Maven CLI. It must be executable by the calling process. |
-| `mavenSettingsPath` | Path to the Maven `settings.xml` file. |
-| `mavenRepoId` | ID of the Maven server in the `settings.xml`. |
-| `mavenRepoUrl` | URL of the Maven repository. |
-| `android` | Android configuration, see below. |
-| `kmp` | Kotlin Multiplatform configuration, see below. |
+| Output | Description |
+|--------|-------------|
+| `version` | The resolved version being released |
+| `branch` | The release branch name |
+| `sha` | The commit SHA on the release branch |
+| `changelog` | The changelog for this release |
-The Kotlin Multiplatform configuration is optional and `false` by default.
-If your project isn't related to Android, you don't need this configuration and
-can set the option to `false`. If not, set the following nested elements:
+### Changelog Preview (Reusable Workflow)
-- `distDirRegex`: pattern of distribution directory names.
-- `fileReplaceeRegex`: pattern of substring of distribution module names to be replaced to get the Android distribution file.
-- `fileReplacerStr`: string to be replaced in the module names to get the Android distribution file.
-
-**Example (without Android config)**
-
-```yaml
-targets:
- - name: maven
- mavenCliPath: scripts/mvnw.cmd
- mavenSettingsPath: scripts/settings.xml
- mavenRepoId: ossrh
- mavenRepoUrl: https://oss.sonatype.org/service/local/staging/deploy/maven2/
- android: false
-```
-
-**Example (with Android config)**
+Posts a preview comment on PRs showing how they'll appear in the changelog:
```yaml
-targets:
- - name: maven
- mavenCliPath: scripts/mvnw.cmd
- mavenSettingsPath: scripts/settings.xml
- mavenRepoId: ossrh
- mavenRepoUrl: https://oss.sonatype.org/service/local/staging/deploy/maven2/
- android:
- distDirRegex: /^sentry-android-.*$/
- fileReplaceeRegex: /\d\.\d\.\d(-SNAPSHOT)?/
- fileReplacerStr: release.aar
-```
+name: Changelog Preview
+on:
+ pull_request:
+ types: [opened, synchronize, reopened, edited, labeled]
-**Example (with Kotlin Multiplatform config)**
-
-```yaml
-targets:
- - name: maven
- mavenCliPath: scripts/mvnw.cmd
- mavenSettingsPath: scripts/settings.xml
- mavenRepoId: ossrh
- mavenRepoUrl: https://oss.sonatype.org/service/local/staging/deploy/maven2/
- android:
- distDirRegex: /^sentry-android-.*$/
- fileReplaceeRegex: /\d\.\d\.\d(-SNAPSHOT)?/
- fileReplacerStr: release.aar
- kmp:
- rootDistDirRegex: /sentry-kotlin-multiplatform-[0-9]+.*$/
- appleDistDirRegex: /sentry-kotlin-multiplatform-(macos|ios|tvos|watchos).*/
- klibDistDirRegex: /sentry-kotlin-multiplatform-(js|wasm-js).*/
+jobs:
+ changelog-preview:
+ uses: getsentry/craft/.github/workflows/changelog-preview.yml@v2
+ secrets: inherit
```
-### Symbol Collector (`symbol-collector`)
-
-Using the [`symbol-collector`](https://github.com/getsentry/symbol-collector) client, uploads native symbols.
-The `symbol-collector` needs to be available in the path.
+The workflow will:
+- Generate the upcoming changelog including the PR's changes
+- Highlight entries from the PR using blockquote style (left border)
+- Post a comment on the PR with the preview
+- Automatically update when you update the PR (push, edit title/description, or change labels)
-**Configuration**
+## Contributing
-| Option | Description |
-| ---------------- | -------------------------------------------------------------------------------------------- |
-| `serverEndpoint` | **optional** The server endpoint. Defaults to `https://symbol-collector.services.sentry.io`. |
-| `batchType` | The batch type of the symbols to be uploaded. I.e: `Android`, `macOS`, `iOS`. |
-| `bundleIdPrefix` | The prefix of the bundle ID. The new version will be appended to the end of this prefix. |
-
-**Example**
-
-```yaml
-targets:
- - name: symbol-collector
- includeNames: /libsentry(-android)?\.so/
- batchType: Android
- bundleIdPrefix: android-ndk-
-```
-
-### pub.dev (`pub-dev`)
-
-Pushes a new Dart or Flutter package to [pub.dev](https://pub.dev/).
-
-Because there is [no automated way](https://github.com/dart-lang/pub-dev/issues/5388) to login and obtain required tokens, you need to perform a valid release beforehand, for every package that you configure. This will open up your browser and use Google's OAuth to log you in, and generate an appropriate file with stored credentials.
-
-Based on your environment, you can find this file at either `$HOME/.pub-cache/credentials.json` or `$HOME/Library/Application\ Support/dart/pub-credentials.json` for OSX and `$HOME/.config/dart/pub-credentials.json` for Linux, depending on your setup.
-
-For this target to work correctly, either `dart` must be installed on the system or a valid `dartCliPath` must be provided.
-
-**Environment**
-
-| Name | Description |
-| ---------------------- | ------------------------------------------------------------ |
-| `PUBDEV_ACCESS_TOKEN` | Value of `accessToken` obtained from `pub-credentials.json` |
-| `PUBDEV_REFRESH_TOKEN` | Value of `refreshToken` obtained from `pub-credentials.json` |
-
-**Configuration**
-
-| Option | Description |
-| ------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| `dartCliPath` | **optional** Path to the Dart CLI. It must be executable by the calling process. Defaults to `dart`. |
-| `packages` | **optional** List of directories to be released, relative to the root. Useful when a single repository contains multiple packages. When skipped, root directory is assumed as the only package. |
-| `skipValidation` | **optional** Publishes the package without going through validation steps, such as analyzer & dependency checks. This is useful in particular situations when package maintainers know why the validation fails and wish to side step the issue. For example, there may be analyzer issues due to not following the current (latest) dart SDK recommendation because the package needs to maintain the package compatibility with an old SDK version. This option should be used with caution and only after testing and verifying the reported issue shouldn't affect the package. It is advisable to do an alpha pre-release to further reduce the chance of a potential negative impact. |
-
-**Example**
-
-```yaml
-targets:
- - name: pub-dev
- packages:
- uno:
- dos:
- tres:
-```
+See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup and guidelines.
-### Hex (`hex`)
+## License
-Pushes a package to the Elixir / Erlang package manager [Hex](https://hex.pm).
-
-**Environment**
-
-`mix` (bundled with the `elixir` language) must be installed on the system.
-
-| Name | Description |
-| ------------- | --------------------------------------------------------- |
-| `HEX_API_KEY` | API Key obtained from hex.pm account |
-| `MIX_BIN` | **optional**. Path to "mix" executable. Defaults to `mix` |
-
-**Configuration**
-
-_none_
-
-**Example**
-
-```yaml
-targets:
- - name: hex
-```
-
-### Commit on Git Repository (`commit-on-git-repository`)
-
-Takes a tarball and pushes the unpacked contents to a git repository.
-
-**Environment**
-
-| Name | Description |
-| ------------------ | ------------------------------------------------------------------------------------------------ |
-| `GITHUB_API_TOKEN` | GitHub PAT that will be used for authentication when a the `repositoryUrl` host is `github.com`. |
-
-**Configuration**
-
-| Option | Description |
-| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
-| `archive` | Regular expression to match a `.tgz` file in the build artifacts. The content of the found file will be pushed to the git repository. Needs to match exactly one file. |
-| `repositoryUrl` | Url to the git remote git repository. Must use http or https protocol! (no `git@...`) |
-| `branch` | Which repository branch to push to. |
-| `stripComponents` | **optional**. How many leading path elements should be removed when unpacking the tarball. Default: 0 (see `tar --strip-components` option) |
-| `createTag` | **optional**. Whether to attach a tag to the created commit. The content of the tag is gonna be equal to the release version passed to craft ("NEW-VERSION"). Default: `false` |
-
-**Example**
-
-```yaml
-targets:
- - name: commit-on-git-repository
- archive: /^sentry-deno-\d.*\.tgz$/
- repositoryUrl: https://github.com/getsentry/sentry-deno
- stripComponents: 1
- branch: main
- createTag: true
-```
-
-### PowerShellGet (`powershell`)
-
-Uploads a module to [PowerShell Gallery](https://www.powershellgallery.com/) or another repository
-supported by [PowerShellGet](https://learn.microsoft.com/en-us/powershell/module/powershellget)'s `Publish-Module`.
-
-The action looks for an artifact named `.zip` and extracts it to a temporary directory.
-The extracted directory is then published as a module.
-
-#### Environment
-
-The `pwsh` executable [must be installed](https://github.com/powershell/powershell#get-powershell) on the system.
-
-| Name | Description | Default |
-| -------------------- | ---------------------------------------------------- | --------- |
-| `POWERSHELL_API_KEY` | **required** PowerShell Gallery API key | |
-| `POWERSHELL_BIN` | **optional** Path to PowerShell binary | `pwsh` |
-
-#### Configuration
-
-| Option | Description | Default |
-| -------------------- | ---------------------------------------------------- | --------- |
-| `module` | **required** Module name. | |
-| `repository` | **optional** Repository to publish the package to. | PSGallery |
-
-#### Example
-
-```yaml
-targets:
- - name: powershell
- module: Sentry
-```
-
-## Integrating Your Project with `craft`
-
-Here is how you can integrate your GitHub project with `craft`:
-
-1. Set up a workflow that builds your assets and runs your tests. Allow building
- release branches (their names follow `release/{VERSION}` by default,
- configurable through `releaseBranchPrefix`).
-
- ```yaml
- on:
- push:
- branches:
- - 'release/**'
- ```
-
-2. Use the official `actions/upload-artifact@v2` action to upload your assets.
- Here is an example config (step) of an archive job:
-
- ```yaml
- - name: Archive Artifacts
- uses: actions/upload-artifact@v2
- with:
- name: ${{ github.sha }}
- path: |
- ${{ github.workspace }}/*.tgz
- ${{ github.workspace }}/packages/tracing/build/**
- ${{ github.workspace }}/packages/**/*.tgz
- ```
-
- A few important things to note:
-
- - The name of the artifacts is very important and needs to be `name: ${{ github.sha }}`. Craft uses this as a unique id to fetch the artifacts.
- - Keep in mind that this action maintains the folder structure and zips everything together. Craft will download the zip and recursively walk it to find all assets.
-
-3. Add `.craft.yml` configuration file to your project
-
- - List there all the targets you want to publish to
- - Configure additional options (changelog management policy, tag prefix, etc.)
-
-4. Add a [pre-release script](#pre-release-version-bumping-script-conventions) to your project.
-5. Get various [configuration tokens](#global-configuration)
-6. Run `craft prepare --publish` and profit!
-
-## Pre-release (Version-bumping) Script: Conventions
-
-Among other actions, `craft prepare` runs an external, project-specific command
-or script that is responsible for version bumping. By default, this script
-should be located at: `./scripts/bump-version.sh`. The command can be configured
-by specifying the `preReleaseCommand` configuration option in `craft.yml`.
-
-The following requirements are on the script interface and functionality:
-
-- The script should accept at least two arguments. Craft will pass the old ("from")
- version and the new ("to") version as the last two arguments, respectively.
-- The script must replace all relevant occurrences of the old version string
- with the new one.
-- The script must not commit the changes made.
-- The script must not change the state of the git repository (e.g. changing branches)
-
-**Example**
-
-```bash
-#!/bin/bash
-### Example of a version-bumping script for an NPM project.
-### Located at: ./scripts/bump-version.sh
-set -eux
-OLD_VERSION="${1}"
-NEW_VERSION="${2}"
-
-# Do not tag and commit changes made by "npm version"
-export npm_config_git_tag_version=false
-npm version "${NEW_VERSION}"
-```
-
-## Post-release Script: Conventions
-
-Among other actions, `craft publish` runs an external, project-specific command
-or script that can do things like bumping the development version. By default,
-this script should be located at: `./scripts/post-release.sh`. Unlike the
-pre-release command, this script is not mandatory so if the file does not exist,
-`craft` will report this fact and then move along as usual. This command can be
-configured by specifying `postReleaseCommand` configuration option in `craft.yml`.
-
-The following requirements are on the script interface and functionality:
-
-- The script should accept at least two arguments. Craft will pass the old ("from")
- version and the new ("to") version as the last two arguments, respectively.
-- The script is responsible for any and all `git` state management as `craft` will
- simply exit after running this script as the final step. This means the script
- is responsible for committing and pushing any changes that it may have made.
-
-**Example**
-
-```bash
-#!/bin/bash
-### Example of a dev-version-bumping script for a Python project
-### Located at: ./scripts/post-release.sh
-set -eux
-OLD_VERSION="${1}"
-NEW_VERSION="${2}"
-
-# Ensure master branch
-git checkout master
-# Advance the CalVer release by one-month and add the `.dev0` suffix
-./scripts/bump-version.sh '' $(date -d "$(echo $NEW_VERSION | sed -e 's/^\([0-9]\{2\}\)\.\([0-9]\{1,2\}\)\.[0-9]\+$/20\1-\2-1/') 1 month" +%y.%-m.0.dev0)
-# Only commit if there are changes, make sure to `pull --rebase` before pushing to avoid conflicts
-git diff --quiet || git commit -anm 'meta: Bump new development version' && git pull --rebase && git push
-```
+MIT
diff --git a/action.yml b/action.yml
new file mode 100644
index 00000000..e0aa73f7
--- /dev/null
+++ b/action.yml
@@ -0,0 +1,208 @@
+name: "Craft Prepare Release"
+description: "Prepare a new release using Craft"
+
+inputs:
+ version:
+ description: >
+ Version to release. Can be a semver string (e.g., "1.2.3"),
+ a bump type ("major", "minor", "patch"), or "auto" for automatic detection.
+ required: false
+ merge_target:
+ description: Target branch to merge into. Uses the default branch as a fallback.
+ required: false
+ force:
+ description: Force a release even when there are release-blockers
+ required: false
+ default: "false"
+ blocker_label:
+ description: Label that blocks releases
+ required: false
+ default: "release-blocker"
+ publish_repo:
+ description: Repository for publish issues (owner/repo format)
+ required: false
+ git_user_name:
+ description: Git committer name
+ required: false
+ git_user_email:
+ description: Git committer email
+ required: false
+ path:
+ description: The path that Craft will run inside
+ required: false
+ default: "."
+ craft_config_from_merge_target:
+ description: Use the craft config from the merge target branch
+ required: false
+ default: "false"
+
+outputs:
+ version:
+ description: The resolved version being released
+ value: ${{ steps.craft.outputs.version }}
+ branch:
+ description: The release branch name
+ value: ${{ steps.craft.outputs.branch }}
+ sha:
+ description: The commit SHA on the release branch
+ value: ${{ steps.craft.outputs.sha }}
+ previous_tag:
+ description: The tag before this release (for diff links)
+ value: ${{ steps.craft.outputs.previous_tag }}
+ changelog:
+ description: The changelog for this release
+ value: ${{ steps.craft.outputs.changelog }}
+
+runs:
+ using: "composite"
+ steps:
+ - id: killswitch
+ name: Check release blockers
+ shell: bash
+ run: |
+ if [[ '${{ inputs.force }}' != 'true' ]] && gh issue list -l '${{ inputs.blocker_label }}' -s open | grep -q '^[0-9]\+[[:space:]]'; then
+ echo "::error::Open release-blocking issues found (label: ${{ inputs.blocker_label }}), cancelling release..."
+ gh api -X POST repos/:owner/:repo/actions/runs/$GITHUB_RUN_ID/cancel
+ fi
+
+ - name: Set git user
+ shell: bash
+ run: |
+ # Use provided values or fall back to triggering actor
+ GIT_USER_NAME='${{ inputs.git_user_name }}'
+ GIT_USER_EMAIL='${{ inputs.git_user_email }}'
+
+ if [[ -z "$GIT_USER_NAME" ]]; then
+ GIT_USER_NAME="${GITHUB_ACTOR}"
+ fi
+ if [[ -z "$GIT_USER_EMAIL" ]]; then
+ GIT_USER_EMAIL="${GITHUB_ACTOR_ID}+${GITHUB_ACTOR}@users.noreply.github.com"
+ fi
+
+ echo "GIT_COMMITTER_NAME=${GIT_USER_NAME}" >> $GITHUB_ENV
+ echo "GIT_AUTHOR_NAME=${GIT_USER_NAME}" >> $GITHUB_ENV
+ echo "EMAIL=${GIT_USER_EMAIL}" >> $GITHUB_ENV
+
+ - name: Install Craft
+ uses: ./install
+
+ - name: Craft Prepare
+ id: craft
+ shell: bash
+ env:
+ CRAFT_LOG_LEVEL: Debug
+ working-directory: ${{ inputs.path }}
+ run: |
+ # Ensure we have origin/HEAD set
+ git remote set-head origin --auto
+
+ # Build command with optional flags
+ CRAFT_ARGS=""
+ if [[ '${{ inputs.craft_config_from_merge_target }}' == 'true' && -n '${{ inputs.merge_target }}' ]]; then
+ CRAFT_ARGS="--config-from ${{ inputs.merge_target }}"
+ fi
+
+ # Version is optional - if not provided, Craft uses versioning.policy from config
+ VERSION_ARG=""
+ if [[ -n '${{ inputs.version }}' ]]; then
+ VERSION_ARG="${{ inputs.version }}"
+ fi
+
+ craft prepare $VERSION_ARG $CRAFT_ARGS
+
+ - name: Read Craft Targets
+ id: craft-targets
+ shell: bash
+ working-directory: ${{ inputs.path }}
+ env:
+ CRAFT_LOG_LEVEL: Warn
+ run: |
+ targets=$(craft targets | jq -r '.[]|" - [ ] \(.)"')
+
+ # https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions#multiline-strings
+ echo "targets<> "$GITHUB_OUTPUT"
+ echo "$targets" >> "$GITHUB_OUTPUT"
+ echo "EOF" >> "$GITHUB_OUTPUT"
+
+ - name: Request publish
+ shell: bash
+ run: |
+ if [[ '${{ inputs.path }}' == '.' ]]; then
+ subdirectory=''
+ else
+ subdirectory='/${{ inputs.path }}'
+ fi
+
+ if [[ -n '${{ inputs.merge_target }}' ]]; then
+ merge_target='${{ inputs.merge_target }}'
+ else
+ merge_target='(default)'
+ fi
+
+ # Use resolved version from Craft output
+ RESOLVED_VERSION="${{ steps.craft.outputs.version }}"
+ if [[ -z "$RESOLVED_VERSION" ]]; then
+ echo "::error::Craft did not output a version. This is unexpected."
+ exit 1
+ fi
+
+ title="publish: ${GITHUB_REPOSITORY}${subdirectory}@${RESOLVED_VERSION}"
+
+ # Determine publish repo
+ PUBLISH_REPO='${{ inputs.publish_repo }}'
+ if [[ -z "$PUBLISH_REPO" ]]; then
+ PUBLISH_REPO="${GITHUB_REPOSITORY_OWNER}/publish"
+ fi
+
+ # Check if issue already exists
+ # GitHub only allows search with the "in" operator and this issue search can
+ # return non-exact matches. We extract the titles and check with grep -xF
+ if gh -R "$PUBLISH_REPO" issue list -S "'$title' in:title" --json title -q '.[] | .title' | grep -qxF -- "$title"; then
+ echo "There's already an open publish request, skipped issue creation."
+ exit 0
+ fi
+
+ # Use Craft outputs for git info
+ RELEASE_BRANCH="${{ steps.craft.outputs.branch }}"
+ RELEASE_SHA="${{ steps.craft.outputs.sha }}"
+ PREVIOUS_TAG="${{ steps.craft.outputs.previous_tag }}"
+
+ # Fall back to HEAD if no previous tag
+ if [[ -z "$PREVIOUS_TAG" ]]; then
+ PREVIOUS_TAG="HEAD"
+ fi
+
+ # Build changelog section if available
+ CHANGELOG='${{ steps.craft.outputs.changelog }}'
+ if [[ -n "$CHANGELOG" ]]; then
+ CHANGELOG_SECTION="
+ ---
+
+
+ 📋 Changelog
+
+ ${CHANGELOG}
+
+ "
+ else
+ CHANGELOG_SECTION=""
+ fi
+
+ body="Requested by: @${GITHUB_ACTOR}
+
+ Merge target: ${merge_target}
+
+ Quick links:
+ - [View changes](https://github.com/${GITHUB_REPOSITORY}/compare/${PREVIOUS_TAG}...${RELEASE_BRANCH})
+ - [View check runs](https://github.com/${GITHUB_REPOSITORY}/commit/${RELEASE_SHA}/checks/)
+
+ Assign the **accepted** label to this issue to approve the release.
+ To retract the release, the person requesting it must leave a comment containing \`#retract\` on a line by itself under this issue.
+
+ ### Targets
+
+ ${{ steps.craft-targets.outputs.targets }}
+
+ Checked targets will be skipped (either already published or user-requested skip). Uncheck to retry a target.
+ ${CHANGELOG_SECTION}"
+ gh issue create -R "$PUBLISH_REPO" --title "$title" --body "$body"
diff --git a/docs/_site/assets/main.css b/docs/_site/assets/main.css
deleted file mode 100644
index 84804805..00000000
--- a/docs/_site/assets/main.css
+++ /dev/null
@@ -1,158 +0,0 @@
-body {
- font-family: 'Oxygen', serif;
- color: #46433a;
- background-color: #fcfcfc;
-}
-
-header,
-main {
- padding: 0 20px;
-}
-
-/* ** wrapper div for both header and main ** */
-.wrapper {
- margin-top: 10%;
-}
-
-/* ** anchor tags ** */
-a:link,
-a:visited,
-a:hover,
-a:active {
- color: #ce534d;
- text-decoration: none;
-}
-
-a:hover {
- text-decoration: underline;
-}
-
-/* ** main content list ** */
-.main-list-item {
- font-weight: bold;
- font-size: 1.2em;
- margin: 0.8em 0;
-}
-
-/* override the left margin added by font awesome for the main content list,
-since it must be aligned with the content */
-.fa-ul.main-list {
- margin-left: 0;
-}
-
-/* list icons */
-.main-list-item-icon {
- width: 36px;
- color: #46433a;
-}
-
-/* ** logo ** */
-.logo-container {
- text-align: center;
-}
-
-.logo {
- width: 160px;
- height: 160px;
- display: inline-block;
- background-size: cover;
- border: 2px solid #fcfcfc;
-}
-
-/* ** author ** */
-.author-container h1 {
- font-size: 1.6em;
- margin-top: 0;
- margin-bottom: 0;
- text-align: center;
-}
-
-/* ** tagline ** */
-.tagline-container p {
- font-size: 1.3em;
- text-align: center;
- margin-bottom: 2em;
-}
-
-/* **** */
-hr {
- border: 0;
- height: 1px;
- background-image: -webkit-linear-gradient(
- left,
- rgba(0, 0, 0, 0),
- #46433a,
- rgba(0, 0, 0, 0)
- );
- background-image: -moz-linear-gradient(
- left,
- rgba(0, 0, 0, 0),
- #46433a,
- rgba(0, 0, 0, 0)
- );
- background-image: -ms-linear-gradient(
- left,
- rgba(0, 0, 0, 0),
- #46433a,
- rgba(0, 0, 0, 0)
- );
- background-image: -o-linear-gradient(
- left,
- rgba(0, 0, 0, 0),
- #46433a,
- rgba(0, 0, 0, 0)
- );
-}
-
-/* ** footer ** */
-footer {
- position: fixed;
- bottom: 0;
- right: 0;
- height: 20px;
-}
-
-.poweredby {
- font-family: 'Arial Narrow', Arial;
- font-size: 0.6em;
- line-height: 0.6em;
- padding: 0 5px;
-}
-
-/* ** media queries ** */
-/* X-Small devices (phones, 480px and up) */
-@media (min-width: 480px) {
- /* wrapper stays 480px wide past 480px wide and is kept centered */
- .wrapper {
- width: 480px;
- margin: 10% auto 0 auto;
- }
-}
-/* All other devices (768px and up) */
-@media (min-width: 768px) {
- /* past 768px the layout is changed and the wrapper has a fixed width of 760px
- to accomodate both the header column and the content column */
- .wrapper {
- width: 760px;
- }
-
- /* the header column stays left and has a dynamic width with all contents
- aligned right */
- header {
- float: left;
- width: 46%;
- text-align: right;
- }
-
- .author-container h1,
- .logo-container,
- .tagline-container p {
- text-align: right;
- }
-
- main {
- width: 46%;
- margin-left: 54%;
- padding: 0;
- }
-}
diff --git a/docs/_site/assets/normalize.css b/docs/_site/assets/normalize.css
deleted file mode 100644
index d9f54fd9..00000000
--- a/docs/_site/assets/normalize.css
+++ /dev/null
@@ -1,427 +0,0 @@
-/*! normalize.css v3.0.2 | MIT License | git.io/normalize */
-
-/**
- * 1. Set default font family to sans-serif.
- * 2. Prevent iOS text size adjust after orientation change, without disabling
- * user zoom.
- */
-
-html {
- font-family: sans-serif; /* 1 */
- -ms-text-size-adjust: 100%; /* 2 */
- -webkit-text-size-adjust: 100%; /* 2 */
-}
-
-/**
- * Remove default margin.
- */
-
-body {
- margin: 0;
-}
-
-/* HTML5 display definitions
- ========================================================================== */
-
-/**
- * Correct `block` display not defined for any HTML5 element in IE 8/9.
- * Correct `block` display not defined for `details` or `summary` in IE 10/11
- * and Firefox.
- * Correct `block` display not defined for `main` in IE 11.
- */
-
-article,
-aside,
-details,
-figcaption,
-figure,
-footer,
-header,
-hgroup,
-main,
-menu,
-nav,
-section,
-summary {
- display: block;
-}
-
-/**
- * 1. Correct `inline-block` display not defined in IE 8/9.
- * 2. Normalize vertical alignment of `progress` in Chrome, Firefox, and Opera.
- */
-
-audio,
-canvas,
-progress,
-video {
- display: inline-block; /* 1 */
- vertical-align: baseline; /* 2 */
-}
-
-/**
- * Prevent modern browsers from displaying `audio` without controls.
- * Remove excess height in iOS 5 devices.
- */
-
-audio:not([controls]) {
- display: none;
- height: 0;
-}
-
-/**
- * Address `[hidden]` styling not present in IE 8/9/10.
- * Hide the `template` element in IE 8/9/11, Safari, and Firefox < 22.
- */
-
-[hidden],
-template {
- display: none;
-}
-
-/* Links
- ========================================================================== */
-
-/**
- * Remove the gray background color from active links in IE 10.
- */
-
-a {
- background-color: transparent;
-}
-
-/**
- * Improve readability when focused and also mouse hovered in all browsers.
- */
-
-a:active,
-a:hover {
- outline: 0;
-}
-
-/* Text-level semantics
- ========================================================================== */
-
-/**
- * Address styling not present in IE 8/9/10/11, Safari, and Chrome.
- */
-
-abbr[title] {
- border-bottom: 1px dotted;
-}
-
-/**
- * Address style set to `bolder` in Firefox 4+, Safari, and Chrome.
- */
-
-b,
-strong {
- font-weight: bold;
-}
-
-/**
- * Address styling not present in Safari and Chrome.
- */
-
-dfn {
- font-style: italic;
-}
-
-/**
- * Address variable `h1` font-size and margin within `section` and `article`
- * contexts in Firefox 4+, Safari, and Chrome.
- */
-
-h1 {
- font-size: 2em;
- margin: 0.67em 0;
-}
-
-/**
- * Address styling not present in IE 8/9.
- */
-
-mark {
- background: #ff0;
- color: #000;
-}
-
-/**
- * Address inconsistent and variable font size in all browsers.
- */
-
-small {
- font-size: 80%;
-}
-
-/**
- * Prevent `sub` and `sup` affecting `line-height` in all browsers.
- */
-
-sub,
-sup {
- font-size: 75%;
- line-height: 0;
- position: relative;
- vertical-align: baseline;
-}
-
-sup {
- top: -0.5em;
-}
-
-sub {
- bottom: -0.25em;
-}
-
-/* Embedded content
- ========================================================================== */
-
-/**
- * Remove border when inside `a` element in IE 8/9/10.
- */
-
-img {
- border: 0;
-}
-
-/**
- * Correct overflow not hidden in IE 9/10/11.
- */
-
-svg:not(:root) {
- overflow: hidden;
-}
-
-/* Grouping content
- ========================================================================== */
-
-/**
- * Address margin not present in IE 8/9 and Safari.
- */
-
-figure {
- margin: 1em 40px;
-}
-
-/**
- * Address differences between Firefox and other browsers.
- */
-
-hr {
- -moz-box-sizing: content-box;
- box-sizing: content-box;
- height: 0;
-}
-
-/**
- * Contain overflow in all browsers.
- */
-
-pre {
- overflow: auto;
-}
-
-/**
- * Address odd `em`-unit font size rendering in all browsers.
- */
-
-code,
-kbd,
-pre,
-samp {
- font-family: monospace, monospace;
- font-size: 1em;
-}
-
-/* Forms
- ========================================================================== */
-
-/**
- * Known limitation: by default, Chrome and Safari on OS X allow very limited
- * styling of `select`, unless a `border` property is set.
- */
-
-/**
- * 1. Correct color not being inherited.
- * Known issue: affects color of disabled elements.
- * 2. Correct font properties not being inherited.
- * 3. Address margins set differently in Firefox 4+, Safari, and Chrome.
- */
-
-button,
-input,
-optgroup,
-select,
-textarea {
- color: inherit; /* 1 */
- font: inherit; /* 2 */
- margin: 0; /* 3 */
-}
-
-/**
- * Address `overflow` set to `hidden` in IE 8/9/10/11.
- */
-
-button {
- overflow: visible;
-}
-
-/**
- * Address inconsistent `text-transform` inheritance for `button` and `select`.
- * All other form control elements do not inherit `text-transform` values.
- * Correct `button` style inheritance in Firefox, IE 8/9/10/11, and Opera.
- * Correct `select` style inheritance in Firefox.
- */
-
-button,
-select {
- text-transform: none;
-}
-
-/**
- * 1. Avoid the WebKit bug in Android 4.0.* where (2) destroys native `audio`
- * and `video` controls.
- * 2. Correct inability to style clickable `input` types in iOS.
- * 3. Improve usability and consistency of cursor style between image-type
- * `input` and others.
- */
-
-button,
-html input[type="button"], /* 1 */
-input[type="reset"],
-input[type="submit"] {
- -webkit-appearance: button; /* 2 */
- cursor: pointer; /* 3 */
-}
-
-/**
- * Re-set default cursor for disabled elements.
- */
-
-button[disabled],
-html input[disabled] {
- cursor: default;
-}
-
-/**
- * Remove inner padding and border in Firefox 4+.
- */
-
-button::-moz-focus-inner,
-input::-moz-focus-inner {
- border: 0;
- padding: 0;
-}
-
-/**
- * Address Firefox 4+ setting `line-height` on `input` using `!important` in
- * the UA stylesheet.
- */
-
-input {
- line-height: normal;
-}
-
-/**
- * It's recommended that you don't attempt to style these elements.
- * Firefox's implementation doesn't respect box-sizing, padding, or width.
- *
- * 1. Address box sizing set to `content-box` in IE 8/9/10.
- * 2. Remove excess padding in IE 8/9/10.
- */
-
-input[type='checkbox'],
-input[type='radio'] {
- box-sizing: border-box; /* 1 */
- padding: 0; /* 2 */
-}
-
-/**
- * Fix the cursor style for Chrome's increment/decrement buttons. For certain
- * `font-size` values of the `input`, it causes the cursor style of the
- * decrement button to change from `default` to `text`.
- */
-
-input[type='number']::-webkit-inner-spin-button,
-input[type='number']::-webkit-outer-spin-button {
- height: auto;
-}
-
-/**
- * 1. Address `appearance` set to `searchfield` in Safari and Chrome.
- * 2. Address `box-sizing` set to `border-box` in Safari and Chrome
- * (include `-moz` to future-proof).
- */
-
-input[type='search'] {
- -webkit-appearance: textfield; /* 1 */
- -moz-box-sizing: content-box;
- -webkit-box-sizing: content-box; /* 2 */
- box-sizing: content-box;
-}
-
-/**
- * Remove inner padding and search cancel button in Safari and Chrome on OS X.
- * Safari (but not Chrome) clips the cancel button when the search input has
- * padding (and `textfield` appearance).
- */
-
-input[type='search']::-webkit-search-cancel-button,
-input[type='search']::-webkit-search-decoration {
- -webkit-appearance: none;
-}
-
-/**
- * Define consistent border, margin, and padding.
- */
-
-fieldset {
- border: 1px solid #c0c0c0;
- margin: 0 2px;
- padding: 0.35em 0.625em 0.75em;
-}
-
-/**
- * 1. Correct `color` not being inherited in IE 8/9/10/11.
- * 2. Remove padding so people aren't caught out if they zero out fieldsets.
- */
-
-legend {
- border: 0; /* 1 */
- padding: 0; /* 2 */
-}
-
-/**
- * Remove default vertical scrollbar in IE 8/9/10/11.
- */
-
-textarea {
- overflow: auto;
-}
-
-/**
- * Don't inherit the `font-weight` (applied by a rule above).
- * NOTE: the default cannot safely be changed in Chrome and Safari on OS X.
- */
-
-optgroup {
- font-weight: bold;
-}
-
-/* Tables
- ========================================================================== */
-
-/**
- * Remove most spacing between table cells.
- */
-
-table {
- border-collapse: collapse;
- border-spacing: 0;
-}
-
-td,
-th {
- padding: 0;
-}
diff --git a/docs/_site/favicon.ico b/docs/_site/favicon.ico
deleted file mode 100644
index d90a3de22019643e5cce3dfd4f5ad52dee6ac749..0000000000000000000000000000000000000000
GIT binary patch
literal 0
HcmV?d00001
literal 6518
zcmeI$e`wWZ6vy#rS7)SiI3^UNwrtjl#x~t=-LI=66pacL%)mtZK@s&wL}D}yYQ9$?ObAj7A^Ii8kb)3pNl_U&Gt{xV%TTwqyQlYA=NZTE?%l?&AmIitp7Z^j^E_V}
zc<=W+=dxVP&6(p^3tUmQb2mBX3JUaku5-;~MP!UH@n6p39C`G|d+#z)Qc~hFQCeE+
z(2fCkqlS8)xt3lrIv}&J!!2ICSp0=ftj5dmD8yIjKpR5VM12%z;mXU)MLSMl2KL|>
zuEyWkf@jbV>0gg?*alZwSt({?0Un3PdOUz63h_A(KxX&gBy#1ty1F`OqV{+X^U#C$
zaT}7@h&v!NYcag9wzk%(EY|%vh9B_@uEhbZb6KS)GhvTgwrrVoE7t=!ig{?qAmV6&
z^lrh(p8EQF7qN=#e7u7pJcNa~fR}L5d}CvyOQC}-iTkv-kM%d?=(P)LJRWx`tS0lg
z6U|ynHfnbH^5rg#&&a#cgHO}UxD_i_q_dFT_xK4?r<=UFQRo3ec97G&fF25li6O#Idb-Kv_S4D
zJ@E>n_epnMp?jVkXHTcj$@z&Cv+~{O%F4<_FZ=`*>7KT6<`3yyy7POaFW;3&K)y%C
zUgRTB-N+hxN$OdUJv&YQB(=;e6@InI9vZCEx9#zsux@sAHygujzG~{D{d{
zY5tVSc9>bY&otOQ!~G>omN+cJ0K8hqD!DkQnevpW#Z*sNJNXOrpm
zQOiBUbBe6;+~M&hW<#}C47J>A_`dS2ltKyFDabRIIE_5WY%@aMPd!ib
zttOY}vT*Jq>ORxkWO`x7t{Wrsa*w#~nZ^1u!u{XYd&=YC}J!_+a;b5&JU
zQB7ptc-qXoYx1+y*J*E~$rIE$ny)muoY|oH$UVdJW@P=4Yk3|OotTC#dL8n|xRz(t
z9Goz{7d4;G`kl!?&^$aB53e;fH7_
z2Xze6d)4H>Q|D+t>R$GlKSg~%nOECcuhN(i6Vv;aI?MFJ%*YyjZyD!$4gFqtHOP7o!n@DyrZ+@g
zV|rocqP3x+!A-z5%B!iP>23G
z>iMSUEuSKfjDL9rS%`C95x@57fU%i^4`&AtAh7V_^A2O%>PzJI#+?=kW!dPDFLNv_M0
z?!7-{KFRm@H`wPRUYq1ullePAHurh~XK-5U>8z7E>$2{--0#oPzXwcRPyGg_egjv|
GZ{S~sF((KB
diff --git a/docs/_site/images/sentry-glyph-black.png b/docs/_site/images/sentry-glyph-black.png
deleted file mode 100644
index 787c55fe74ffd89cd7815b426c11975171a4755c..0000000000000000000000000000000000000000
GIT binary patch
literal 0
HcmV?d00001
literal 4024
zcmb_fX*iqf){dcu#*`q2Qd7+_v#V4oVlGW32t{M8Qc7(~cd3~eN@}dJA?7irmMW#l
zHcwSkC^kjRoKjUza?W@4eEYi2zwi6;4(oc?de^e#@1~BwZM{|L
z$~kCiWQYbGnJs9Oer~UpZ-zI+(exam-Q92!xdwwup&uPqQ+#PuCDd+Ka
z#yfpZ{K?V&ey9Cf%@q@4OJgDS({V%NW~^JqmfK*i9%Mwwm<#KM?TO9^O+!>QWlL^f
zAv|A(z{J-dFU?*zlF7;w2C6&22gL0)+DP?9wCF>|0KF4#_+5?nD>`GM&?H*Ib1@KX
zQSl$gDT>~2yNI+&F+qtt&p-i407|E~5aVgw^-9~V$Dbah(+u+!E$iEb(;0uB^L3#i
z;V;r%W6U467&e&mqlob`64+|x(9HzR7tn&IHjLYCMcR#;hM@l5+5=i5tp*rrCT`E`
zaN^NO;-a9J@F*e~58AArUP!(m5Qy+?pYcA^9?{#5aUJ#J4Y)_~-d~6@X{s7KXW9J}
zP@nAY{Ga$Sf^^3z@xS?QoMebU{zrV+zSlG*h{$!rO{?{TVS3IDYnx&#DW%^U2|-Av}k~cD_3I6$;RFs`bWOo#K-(sJ>9EXh?W=b4sbs3-3^$hJhALmpWas
zgJ;(;BU@@*XH8r+!l4B>p>(?_W_RIEr;?S&3VME*gn>i>B$vkAj7({O@i!M
zV9YmMXi%kp<(#K**jF}sy7LMyCSRp^ou?uxd#7=U-&$0b8>K_eyyS3D${#2K-Zzk!
z{tyE#$ScJq%bgc~OZqCpH+U!f1vQy+aWdP2%_i(<6ek}wppL8MwX2~nc05d)a)Ie<
z;Z&U)xLUWg`H5Pjt)c(Tt12!u=5I3kDwJAmOi!GiNRtMVFI+L;=A1TJ(K3D@(~$q?
zWWoI*M*)=1Oh>(9YJ(Rp)KIrQ1=w_FcCa-ggJ)Y;vk+5O)Gj!`;V{;)y)~}&x&8L3
z)z0#RR|4#ooKx_EM@oHkd%91?Nw2
zZUvaWE0(zGC-v9UFRyr&yG*(i{RUOt~xHh3GklaM$(fi*95&txV4IKOBM*FV~R@<)#r=&
z*j)b5Q!QuYw10PuowaC=E~R_afDgFE_LwC3{!>9UlUVAPdViIQW1tvtS1`c>y?5Zb
zWcNveWppsXgGoPX86WUfqMVo6ZgjDY
zj<_2&)@1F-qt(`ol!Mb!Y6#mYoxi@th&{W=>;Pl!w25X~eSNF5fSTwoc(>7yR!pM!
z&&MIb0c0)S&JL(RD#AJz69yn>EnUZj5M
zX9wm46=HP9v_T3%$%x?mK*hYGKjJ-`HomV<-`SJAE!2rt6(+2?BI*?Le8$@SchKOTkW&dzGZ$R%h02y}P)e-}s4_L
z+i1JQ7!I_}XGMk?K{+4gmI8Vqg1W{Xc7rxSf%&Mw62cBPp{-i{b@nv3U749)l6ZNh
z(MfZ3a{!qUESev+>9HMJ*_3TpLbkzx|ImkMCCO}j5j8W#2GWN=DkkI~<~+Sl>iXTL
znA=}P50d*rrg*=q@28gDHSwoy*WS2-dUVX&OuEu5T?Ji-6xdMyNUl8lZZ_75P^~6V
zR}dRncQeX4DPFvVn$XD_g;K6|MQiWQ5$_Mh=cb2+qMTXbE;Q9}n8<7yapP9d
zc~8`yTg=BRrX{45J}D>;=rYO-)t>0RoxR^^u<{gtM7PJ$GxjEA@tPnNhzpQd@{@vh9=3h%~~(r;F`~slG8+By#-fy@k(P^
zS9$G6D7&*E-E(?hyPEwv$`r50!B3CQZ070;tLjs~rv=f>z)_2UrFN&2?-`E9du*0r
z7R(vy6Y@u5t{p=O<1{t(>}g(hW%@is9MYy(XjrKR7=ynPsT3d;XU#8msiq3i-_d@>
zhaS}+UM|Ju*Z{*;JF6H7M*3zE^svbWXVV_CY2I!`+_+8N&p6a;~3MDH7Ai}E14iMB2F5f!^`+H(eUc7FbmmZ*tf6A
zsdG9;q7azT$mv3(e6}lRhv%%Bi&Bhf&g(kPd9&-DC~q$C{>YZKI#(DN8z*Bhvw+;p
z$lI9VuFTS4E=17r;TAU|UlmnytgrEv|p(
z?ne18H0@}*Q1G5pbm=u!_VKW(HK*i9RSThKcbiW6DCJ9uYW!ov{9SIZWS|uLI4wEc
z9aCFwRNqebm9k|h$Bc%Jz-KO#pXlcZ~g@VA4WytFs#
zZqd*dnZ&Fj_PBAZgt9&(GnT;8GWLDaGMpXde}@-1{UheRIdC-&7?%FQlCOQ|g8>mH?Vk2Tg;1d)=kIfk
zOMa+4>)Ao$;^7mQD%0RE8W7AdI3!h7#3z!t7t-$G)--h)cDm5mfmdhuwzM1apF;JHE?FjxrQJECV0WT28r*A!je<`UI
z1Xk9!+~;$PzUh@H?<7{zf&)_e53soucl%UyYHGPRx3w~aso#0{sXqnG`(`!SG)lWq
z;d*7tY~TGYS+j+x%nd&kbtf_6phrPwl)+$+AE`Qq^co&NGc6WsUy1XvwNUihV57$IP9L{Kbph*uJ}g#v+zqvF9;I9ijB+`Yj4N&_Q8y0C&HVPooj^vo{RQn@Kj3PJDdI0B*?|
zOt}E>7TK=4^@JsYT#YBxnuBn-_yjfaO7Y|B6qq*dUz2E#cCw8$>NBUUG|(8;B;A#i
zj;DA0)LS*N5G;Y=TrOy@D~@u8jR>1QRvJ`#QjoNTEZFtzw+n!ZBXjnAZ{{?W#n&$5NuWUbb$|ddA6&63~G5ZXZ|mXJE0bk-(XM#AZiX0bo*N(~RC
z39+pCLJx@NvEsd9#dj?uGsD!q)$u$7+zM%w+pt1t$LC_aaH?_Cu{ax8!Z%Te%A8#*~{*_b6*7wWn$8z@%dD9_oBm}
zqDA7`7lQ@OHAhvCK`NBDhfv*1V=$1coeZhe)3IGuww(UGsf(YhFC1%yK240xXSAEStu5N8}CE
z-hFK=igT_oaT)dX80ci*da3><3K}pzUW1*d=;C^ADCI7Z`ul-_X!h4~FXuOYz
zhvx*LhLBKod-YH&ofT=I2LW`b(o-4T{}b2yMg0E5DF4P)f6>eTD?JS+o&y*O6%jp1
SejW-H2N=W5^{aL7h5r+)?;*JW
diff --git a/docs/_site/index.html b/docs/_site/index.html
deleted file mode 100644
index 020b0e19..00000000
--- a/docs/_site/index.html
+++ /dev/null
@@ -1,74 +0,0 @@
-
-
-
-
-
- • Sentry Craft
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Sentry Craft
-
- "Craft" is a command line tool that helps to automate and pipeline
- package releases.
-
-
-
-
-
-
-
-
diff --git a/docs/astro.config.mjs b/docs/astro.config.mjs
new file mode 100644
index 00000000..9046114c
--- /dev/null
+++ b/docs/astro.config.mjs
@@ -0,0 +1,43 @@
+import { defineConfig } from 'astro/config';
+import starlight from '@astrojs/starlight';
+
+// Allow base path override via environment variable for PR previews
+const base = process.env.DOCS_BASE_PATH || '/craft';
+
+export default defineConfig({
+ site: 'https://getsentry.github.io',
+ base: base,
+ integrations: [
+ starlight({
+ title: 'Craft',
+ logo: {
+ src: './src/assets/logo.svg',
+ },
+ social: {
+ github: 'https://github.com/getsentry/craft',
+ },
+ sidebar: [
+ {
+ label: 'Getting Started',
+ items: [
+ { label: 'Introduction', slug: '' },
+ { label: 'Installation', slug: 'getting-started' },
+ { label: 'Configuration', slug: 'configuration' },
+ { label: 'GitHub Actions', slug: 'github-actions' },
+ ],
+ },
+ {
+ label: 'Targets',
+ autogenerate: { directory: 'targets' },
+ },
+ {
+ label: 'Resources',
+ items: [
+ { label: 'Contributing', slug: 'contributing' },
+ ],
+ },
+ ],
+ customCss: [],
+ }),
+ ],
+});
diff --git a/docs/package.json b/docs/package.json
new file mode 100644
index 00000000..2cc74a4e
--- /dev/null
+++ b/docs/package.json
@@ -0,0 +1,15 @@
+{
+ "name": "craft-docs",
+ "version": "1.0.0",
+ "private": true,
+ "scripts": {
+ "dev": "astro dev",
+ "build": "astro build",
+ "preview": "astro preview"
+ },
+ "dependencies": {
+ "@astrojs/starlight": "^0.31.1",
+ "astro": "^5.1.1",
+ "sharp": "^0.33.5"
+ }
+}
diff --git a/docs/public/favicon.svg b/docs/public/favicon.svg
new file mode 100644
index 00000000..e839048d
--- /dev/null
+++ b/docs/public/favicon.svg
@@ -0,0 +1 @@
+
diff --git a/docs/src/assets/logo.svg b/docs/src/assets/logo.svg
new file mode 100644
index 00000000..b67cc92f
--- /dev/null
+++ b/docs/src/assets/logo.svg
@@ -0,0 +1 @@
+
diff --git a/docs/src/content/config.ts b/docs/src/content/config.ts
new file mode 100644
index 00000000..31b74762
--- /dev/null
+++ b/docs/src/content/config.ts
@@ -0,0 +1,6 @@
+import { defineCollection } from 'astro:content';
+import { docsSchema } from '@astrojs/starlight/schema';
+
+export const collections = {
+ docs: defineCollection({ schema: docsSchema() }),
+};
diff --git a/docs/src/content/docs/configuration.md b/docs/src/content/docs/configuration.md
new file mode 100644
index 00000000..18e6c4f2
--- /dev/null
+++ b/docs/src/content/docs/configuration.md
@@ -0,0 +1,330 @@
+---
+title: Configuration
+description: Complete reference for .craft.yml configuration
+---
+
+Project configuration for Craft is stored in `.craft.yml` in the project root.
+
+## GitHub Project
+
+Craft tries to determine GitHub repo information from the local git repo. You can also hard-code it:
+
+```yaml
+github:
+ owner: getsentry
+ repo: sentry-javascript
+```
+
+## Pre-release Command
+
+This command runs on your release branch as part of `craft prepare`. Default: `bash scripts/bump-version.sh`.
+
+```yaml
+preReleaseCommand: bash scripts/bump-version.sh
+```
+
+The script should:
+- Accept old and new version as the last two arguments
+- Replace version occurrences
+- Not commit changes
+- Not change git state
+
+Example script:
+
+```bash
+#!/bin/bash
+set -eux
+OLD_VERSION="${1}"
+NEW_VERSION="${2}"
+
+export npm_config_git_tag_version=false
+npm version "${NEW_VERSION}"
+```
+
+## Post-release Command
+
+This command runs after a successful `craft publish`. Default: `bash scripts/post-release.sh`.
+
+```yaml
+postReleaseCommand: bash scripts/post-release.sh
+```
+
+## Release Branch Name
+
+Override the release branch prefix. Default: `release`.
+
+```yaml
+releaseBranchPrefix: publish
+```
+
+Full branch name: `{releaseBranchPrefix}/{version}`
+
+## Changelog Policies
+
+Craft supports `simple` and `auto` changelog management modes.
+
+### Simple Mode
+
+Reminds you to add a changelog entry:
+
+```yaml
+changelog: CHANGES
+```
+
+Or with options:
+
+```yaml
+changelog:
+ filePath: CHANGES.md
+ policy: simple
+```
+
+### Auto Mode
+
+Automatically generates changelog from commits:
+
+```yaml
+changelog:
+ policy: auto
+```
+
+Auto mode uses `.github/release.yml` to categorize PRs by labels or commit patterns. If not present, it uses default [Conventional Commits](https://www.conventionalcommits.org/) patterns:
+
+| Category | Pattern |
+|----------|---------|
+| Breaking Changes | `^\w+(\(\w+\))?!:` |
+| Build / dependencies | `^(build\|ref\|chore\|ci)(\(\w+\))?:` |
+| Bug Fixes | `^fix(\(\w+\))?:` |
+| Documentation | `^docs?(\(\w+\))?:` |
+| New Features | `^feat(\(\w+\))?:` |
+
+Example `.github/release.yml`:
+
+```yaml
+changelog:
+ categories:
+ - title: Features
+ labels:
+ - enhancement
+ commit_patterns:
+ - "^feat(\\(\\w+\\))?:"
+ - title: Bug Fixes
+ labels:
+ - bug
+ commit_patterns:
+ - "^fix(\\(\\w+\\))?:"
+```
+
+### Custom Changelog Entries from PR Descriptions
+
+By default, the changelog entry for a PR is generated from its title. However,
+PR authors can override this by adding a "Changelog Entry" section to the PR
+description. This allows for more detailed, user-facing changelog entries without
+cluttering the PR title.
+
+To use this feature, add a markdown heading (level 2 or 3) titled "Changelog Entry"
+to your PR description, followed by the desired changelog text:
+
+```markdown
+### Description
+
+Add `foo` function, and add unit tests to thoroughly check all edge cases.
+
+### Changelog Entry
+
+Add a new function called `foo` which prints "Hello, world!"
+
+### Issues
+
+Closes #123
+```
+
+The text under "Changelog Entry" will be used verbatim in the changelog instead
+of the PR title. If no such section is present, the PR title is used as usual.
+
+#### Advanced Features
+
+1. **Multiple Entries**: If you use multiple top-level bullet points in the
+ "Changelog Entry" section, each bullet will become a separate changelog entry:
+
+ ```markdown
+ ### Changelog Entry
+
+ - Add OAuth2 authentication
+ - Add two-factor authentication
+ - Add session management
+ ```
+
+2. **Nested Content**: Indented bullets (4+ spaces or tabs) are preserved as
+ nested content under their parent entry:
+
+ ```markdown
+ ### Changelog Entry
+
+ - Add authentication system
+ - OAuth2 support
+ - Two-factor authentication
+ - Session management
+ ```
+
+ This will generate:
+ ```markdown
+ - Add authentication system by @user in [#123](url)
+ - OAuth2 support
+ - Two-factor authentication
+ - Session management
+ ```
+
+3. **Plain Text**: If no bullets are used, the entire content is treated as a
+ single changelog entry. Multi-line text is supported.
+
+4. **Content Isolation**: Only content within the "Changelog Entry" section is
+ included in the changelog. Other sections (Description, Issues, etc.) are
+ ignored.
+
+### Scope Grouping
+
+Changes are automatically grouped by scope (e.g., `feat(api):` groups under "Api"):
+
+```yaml
+changelog:
+ policy: auto
+ scopeGrouping: true # default
+```
+
+Scope headers are only shown for scopes with more than one entry. Entries without
+a scope are listed at the bottom of each category section without a sub-header.
+
+Example output with scope grouping:
+
+```text
+### New Features
+
+#### Api
+
+- feat(api): add user endpoint by @alice in [#1](https://github.com/...)
+- feat(api): add auth endpoint by @bob in [#2](https://github.com/...)
+
+#### Ui
+
+- feat(ui): add dashboard by @charlie in [#3](https://github.com/...)
+
+- feat: general improvement by @dave in [#4](https://github.com/...)
+```
+
+### Configuration Options
+
+| Option | Description |
+|--------|-------------|
+| `changelog` | Path to changelog file (string) OR configuration object |
+| `changelog.filePath` | Path to changelog file. Default: `CHANGELOG.md` |
+| `changelog.policy` | Mode: `none`, `simple`, or `auto`. Default: `none` |
+| `changelog.scopeGrouping` | Enable scope-based grouping. Default: `true` |
+
+## Versioning
+
+Configure default versioning behavior:
+
+```yaml
+versioning:
+ policy: auto # auto, manual, or calver
+```
+
+### Versioning Policies
+
+| Policy | Description |
+|--------|-------------|
+| `auto` | Analyze commits to determine version bump (default when using `craft prepare auto`) |
+| `manual` | Require explicit version argument |
+| `calver` | Use calendar-based versioning |
+
+### Calendar Versioning (CalVer)
+
+For projects using calendar-based versions:
+
+```yaml
+versioning:
+ policy: calver
+ calver:
+ format: "%y.%-m" # e.g., 24.12 for December 2024
+ offset: 14 # Days to look back for date calculation
+```
+
+Format supports:
+- `%y` - 2-digit year
+- `%m` - Zero-padded month
+- `%-m` - Month without padding
+
+## Minimal Version
+
+Require a minimum Craft version:
+
+```yaml
+minVersion: '0.5.0'
+```
+
+## Required Files
+
+Ensure specific artifacts exist before publishing:
+
+```yaml
+requireNames:
+ - /^sentry-craft.*\.tgz$/
+ - /^gh-pages.zip$/
+```
+
+## Status Provider
+
+Configure build status checks:
+
+```yaml
+statusProvider:
+ name: github
+ config:
+ contexts:
+ - Travis CI - Branch
+```
+
+## Artifact Provider
+
+Configure where to fetch artifacts from:
+
+```yaml
+artifactProvider:
+ name: github # or 'gcs' or 'none'
+```
+
+## Targets
+
+List release targets in your configuration:
+
+```yaml
+targets:
+ - name: npm
+ - name: github
+ - name: registry
+ id: browser
+ type: sdk
+ onlyIfPresent: /^sentry-browser-.*\.tgz$/
+```
+
+See [Target Configurations](./targets/) for details on each target.
+
+### Per-target Options
+
+These options apply to all targets:
+
+| Option | Description |
+|--------|-------------|
+| `includeNames` | Regex: only matched files are processed |
+| `excludeNames` | Regex: matched files are skipped |
+| `id` | Unique ID for the target (use with `-t target[id]`) |
+
+Example:
+
+```yaml
+targets:
+ - name: github
+ includeNames: /^.*\.exe$/
+ excludeNames: /^test.exe$/
+```
diff --git a/docs/src/content/docs/contributing.md b/docs/src/content/docs/contributing.md
new file mode 100644
index 00000000..0f537a00
--- /dev/null
+++ b/docs/src/content/docs/contributing.md
@@ -0,0 +1,129 @@
+---
+title: Contributing
+description: How to contribute to Craft
+---
+
+Thank you for your interest in contributing to Craft! This guide will help you get started.
+
+## Development Setup
+
+### Prerequisites
+
+- Node.js v22+ (managed by [Volta](https://volta.sh/))
+- Yarn v1
+
+### Installation
+
+```bash
+# Clone the repository
+git clone https://github.com/getsentry/craft.git
+cd craft
+
+# Install dependencies
+yarn install --frozen-lockfile
+```
+
+## Development Commands
+
+| Command | Description |
+|---------|-------------|
+| `yarn build` | Build the project (outputs to `dist/craft`) |
+| `yarn test` | Run tests |
+| `yarn lint` | Run ESLint |
+| `yarn fix` | Auto-fix lint issues |
+
+### Manual Testing
+
+To test your changes locally:
+
+```bash
+yarn build && ./dist/craft
+```
+
+## Project Structure
+
+```
+src/
+├── __mocks__/ # Test mocks
+├── __tests__/ # Test files (*.test.ts)
+├── artifact_providers/ # Artifact provider implementations
+├── commands/ # CLI command implementations
+├── schemas/ # JSON schema and TypeScript types
+├── status_providers/ # Status provider implementations
+├── targets/ # Release target implementations
+├── types/ # Shared TypeScript types
+├── utils/ # Utility functions
+├── config.ts # Configuration loading
+├── index.ts # CLI entry point
+└── logger.ts # Logging utilities
+```
+
+## Code Style
+
+- **TypeScript** throughout the codebase
+- **Prettier** for formatting (single quotes, no arrow parens)
+- **ESLint** with `@typescript-eslint/recommended`
+- Unused variables prefixed with `_` are allowed
+
+## Testing
+
+- Tests use **Jest** with `ts-jest`
+- Test files are in `src/__tests__/` with the `*.test.ts` pattern
+
+```bash
+# Run all tests
+yarn test
+
+# Run tests in watch mode
+yarn test:watch
+```
+
+## Adding a New Target
+
+1. Create a new file in `src/targets/` (e.g., `myTarget.ts`)
+2. Implement the `BaseTarget` interface
+3. Register the target in `src/targets/index.ts`
+4. Add configuration schema in `src/schemas/`
+5. Write tests in `src/__tests__/`
+6. Document the target in the docs
+
+## Pull Requests
+
+1. Fork the repository
+2. Create a feature branch
+3. Make your changes
+4. Run tests and linting
+5. Submit a pull request
+
+## Pre-release Script Conventions
+
+The pre-release script (`scripts/bump-version.sh`) should:
+
+- Accept old and new version as the last two arguments
+- Replace version occurrences in project files
+- **Not** commit changes
+- **Not** change git state
+
+Example:
+
+```bash
+#!/bin/bash
+set -eux
+OLD_VERSION="${1}"
+NEW_VERSION="${2}"
+
+export npm_config_git_tag_version=false
+npm version "${NEW_VERSION}"
+```
+
+## Post-release Script Conventions
+
+The post-release script (`scripts/post-release.sh`) runs after successful publish and should:
+
+- Accept old and new version as arguments
+- Handle its own git operations (commit, push)
+
+## Questions?
+
+- Open an issue on [GitHub](https://github.com/getsentry/craft/issues)
+- Check existing issues and pull requests
diff --git a/docs/src/content/docs/getting-started.md b/docs/src/content/docs/getting-started.md
new file mode 100644
index 00000000..bdfb5e17
--- /dev/null
+++ b/docs/src/content/docs/getting-started.md
@@ -0,0 +1,228 @@
+---
+title: Getting Started
+description: How to install and use Craft
+---
+
+## Installation
+
+### Binary
+
+Craft is [distributed as a minified single JS binary](https://github.com/getsentry/craft/releases/latest). Download the latest release and add it to your PATH.
+
+### npm (not recommended)
+
+While the recommended approach is to use the binary directly, you can also install Craft as an [NPM package](https://yarn.pm/@sentry/craft):
+
+```shell
+yarn global add @sentry/craft
+```
+
+```shell
+npm install -g @sentry/craft
+```
+
+## Usage
+
+```shell
+$ craft -h
+craft
+
+Commands:
+ craft prepare NEW-VERSION 🚢 Prepare a new release branch
+ [aliases: p, prerelease, prepublish, prepare, release]
+ craft publish NEW-VERSION 🛫 Publish artifacts [aliases: pp, publish]
+ craft targets List defined targets as JSON array
+ craft config Print the parsed, processed, and validated Craft
+ config for the current project in pretty-JSON.
+ craft artifacts 📦 Manage artifacts [aliases: a, artifact]
+
+Options:
+ --no-input Suppresses all user prompts [default: false]
+ --dry-run Dry run mode: do not perform any real actions
+ --log-level Logging level
+ [choices: "Fatal", "Error", "Warn", "Log", "Info", "Success", "Debug",
+ "Trace", "Silent", "Verbose"] [default: "Info"]
+ -v, --version Show version number [boolean]
+ -h, --help Show help [boolean]
+```
+
+## Workflow
+
+### `craft prepare`: Preparing a New Release
+
+This command creates a new release branch, checks the changelog entries, runs a version-bumping script, and pushes this branch to GitHub. CI triggered by pushing this branch will build release artifacts and upload them to your artifact provider.
+
+**Version Specification**
+
+The `NEW-VERSION` argument can be specified in three ways:
+
+1. **Explicit version** (e.g., `1.2.3`): Release with the specified version
+2. **Bump type** (`major`, `minor`, or `patch`): Automatically increment the latest tag
+3. **Auto** (`auto`): Analyze commits since the last tag and determine bump type from conventional commit patterns
+
+```shell
+craft prepare NEW-VERSION
+
+🚢 Prepare a new release branch
+
+Positionals:
+ NEW-VERSION The new version to release. Can be: a semver string (e.g.,
+ "1.2.3"), a bump type ("major", "minor", or "patch"), or "auto"
+ to determine automatically from conventional commits.
+ [string] [required]
+
+Options:
+ --no-input Suppresses all user prompts [default: false]
+ --dry-run Dry run mode: do not perform any real actions
+ --rev, -r Source revision (git SHA or tag) to prepare from
+ --no-push Do not push the release branch [boolean] [default: false]
+ --no-git-checks Ignore local git changes and unsynchronized remotes
+ --no-changelog Do not check for changelog entries [boolean] [default: false]
+ --publish Run "publish" right after "release"[boolean] [default: false]
+ --remote The git remote to use when pushing [string] [default: "origin"]
+ -v, --version Show version number [boolean]
+ -h, --help Show help [boolean]
+```
+
+### `craft publish`: Publishing the Release
+
+This command finds a release branch for the provided version, checks the build status, downloads release artifacts, and uploads them to configured targets.
+
+```shell
+craft publish NEW-VERSION
+
+🛫 Publish artifacts
+
+Positionals:
+ NEW-VERSION Version to publish [string] [required]
+
+Options:
+ --no-input Suppresses all user prompts [default: false]
+ --dry-run Dry run mode: do not perform any real actions
+ --target, -t Publish to this target [default: "all"]
+ --rev, -r Source revision (git SHA or tag) to publish
+ --no-merge Do not merge the release branch after publishing
+ --keep-branch Do not remove release branch after merging it
+ --keep-downloads Keep all downloaded files [boolean] [default: false]
+ --no-status-check Do not check for build status [boolean] [default: false]
+ -v, --version Show version number [boolean]
+ -h, --help Show help [boolean]
+```
+
+### Example
+
+Let's release version `1.2.3`:
+
+```shell
+# Prepare the release
+$ craft prepare 1.2.3
+```
+
+This creates a release branch `release/1.2.3`, runs the version-bumping script, commits changes, and pushes to GitHub. CI builds artifacts and uploads them.
+
+```shell
+# Publish the release
+$ craft publish 1.2.3
+```
+
+This finds the release branch, waits for CI to pass, downloads artifacts, and publishes to configured targets (e.g., GitHub and NPM).
+
+## Version Naming Conventions
+
+Craft supports [semantic versioning (semver)](https://semver.org)-like versions:
+
+```txt
+..(-)?(-)?
+```
+
+- The ``, ``, and `` numbers are required
+- The `` and `` identifiers are optional
+
+### Preview Releases
+
+Preview or pre-release identifiers **must** include one of:
+
+```txt
+preview|pre|rc|dev|alpha|beta|unstable|a|b
+```
+
+Examples:
+- `1.0.0-preview`
+- `1.0.0-alpha.0`
+- `1.0.0-beta.1`
+- `1.0.0-rc.20`
+
+### Build Identifiers
+
+Add a build identifier for platform-specific releases:
+
+```txt
+1.0.0+x86_64
+1.0.0-rc.1+x86_64
+```
+
+## Global Configuration
+
+Configure Craft using environment variables or configuration files.
+
+All command line flags can be set through environment variables by prefixing them with `CRAFT_`:
+
+```shell
+CRAFT_LOG_LEVEL=Debug
+CRAFT_DRY_RUN=1
+CRAFT_NO_INPUT=0
+```
+
+Since Craft relies heavily on GitHub, set the `GITHUB_TOKEN` environment variable to a [GitHub Personal Access Token](https://github.com/settings/tokens) with `repo` scope.
+
+### Environment Files
+
+Craft reads configuration from these locations (in order of precedence):
+
+1. `$HOME/.craft.env`
+2. `$PROJECT_DIR/.craft.env`
+3. Shell environment
+
+Example `.craft.env`:
+
+```shell
+# ~/.craft.env
+GITHUB_TOKEN=token123
+export NUGET_API_TOKEN=abcdefgh
+```
+
+## Caveats
+
+- When interacting with remote GitHub repositories, Craft uses the remote `origin` by default. Set `CRAFT_REMOTE` or use the `--remote` option to change this.
+
+## Integrating Your Project
+
+1. **Set up a workflow** that builds assets and runs tests. Allow building release branches:
+
+ ```yaml
+ on:
+ push:
+ branches:
+ - 'release/**'
+ ```
+
+2. **Upload artifacts** using `actions/upload-artifact@v2`:
+
+ ```yaml
+ - name: Archive Artifacts
+ uses: actions/upload-artifact@v2
+ with:
+ name: ${{ github.sha }}
+ path: |
+ ${{ github.workspace }}/*.tgz
+ ```
+
+ Note: The artifact name must be `${{ github.sha }}`.
+
+3. **Add `.craft.yml`** to your project with targets and options.
+
+4. **Add a pre-release script** (default: `scripts/bump-version.sh`).
+
+5. **Configure environment variables** for your targets.
+
+6. **Run** `craft prepare --publish`!
diff --git a/docs/src/content/docs/github-actions.md b/docs/src/content/docs/github-actions.md
new file mode 100644
index 00000000..2cffed2b
--- /dev/null
+++ b/docs/src/content/docs/github-actions.md
@@ -0,0 +1,250 @@
+---
+title: GitHub Actions
+description: Automate releases and changelog previews with Craft GitHub Actions
+---
+
+Craft provides GitHub Actions for automating releases and previewing changelog entries in pull requests.
+
+For a real-world example of using Craft's GitHub Actions, see the [getsentry/publish](https://github.com/getsentry/publish) repository.
+
+## Prepare Release Action
+
+The main Craft action automates the `craft prepare` workflow in GitHub Actions. It creates a release branch, updates the changelog, and opens a publish request issue.
+
+### Basic Usage
+
+```yaml
+name: Release
+on:
+ workflow_dispatch:
+ inputs:
+ version:
+ description: 'Version to release (or "auto")'
+ required: false
+
+jobs:
+ release:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+ - uses: getsentry/craft@v2
+ with:
+ version: ${{ github.event.inputs.version }}
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+```
+
+### Inputs
+
+| Input | Description | Default |
+|-------|-------------|---------|
+| `version` | Version to release. Can be a semver string (e.g., "1.2.3"), a bump type ("major", "minor", "patch"), or "auto" for automatic detection. | Uses `versioning.policy` from config |
+| `merge_target` | Target branch to merge into. | Default branch |
+| `force` | Force a release even when there are release-blockers. | `false` |
+| `blocker_label` | Label that blocks releases. | `release-blocker` |
+| `publish_repo` | Repository for publish issues (owner/repo format). | `{owner}/publish` |
+| `git_user_name` | Git committer name. | GitHub actor |
+| `git_user_email` | Git committer email. | Actor's noreply email |
+| `path` | The path that Craft will run inside. | `.` |
+| `craft_config_from_merge_target` | Use the craft config from the merge target branch. | `false` |
+
+### Outputs
+
+| Output | Description |
+|--------|-------------|
+| `version` | The resolved version being released |
+| `branch` | The release branch name |
+| `sha` | The commit SHA on the release branch |
+| `previous_tag` | The tag before this release (for diff links) |
+| `changelog` | The changelog for this release |
+
+### Auto-versioning Example
+
+When using auto-versioning, Craft analyzes conventional commits to determine the version bump:
+
+```yaml
+name: Auto Release
+on:
+ schedule:
+ - cron: '0 10 * * 1' # Every Monday at 10 AM
+
+jobs:
+ release:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+ - uses: getsentry/craft@v2
+ with:
+ version: auto
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+```
+
+## Changelog Preview (Reusable Workflow)
+
+The changelog preview workflow posts a comment on pull requests showing how they will appear in the changelog. This helps contributors understand the impact of their changes.
+
+### Basic Usage
+
+Call the reusable workflow from your repository:
+
+```yaml
+name: Changelog Preview
+on:
+ pull_request:
+ types: [opened, synchronize, reopened, edited, labeled]
+
+jobs:
+ changelog-preview:
+ uses: getsentry/craft/.github/workflows/changelog-preview.yml@v2
+ secrets: inherit
+```
+
+### Inputs
+
+| Input | Description | Default |
+|-------|-------------|---------|
+| `craft-version` | Version of Craft to use (tag or "latest") | `latest` |
+
+### Pinning a Specific Version
+
+```yaml
+jobs:
+ changelog-preview:
+ uses: getsentry/craft/.github/workflows/changelog-preview.yml@v2
+ with:
+ craft-version: "2.15.0"
+ secrets: inherit
+```
+
+### How It Works
+
+1. **Generates the changelog** - Runs `craft changelog --pr --format json` to generate the upcoming changelog with metadata
+2. **Fetches PR info** - Gets PR title, body, labels, and base branch from GitHub API
+3. **Categorizes the PR** - Matches the PR to changelog categories based on labels and commit patterns
+4. **Suggests version bump** - Based on matched categories with semver fields (major/minor/patch)
+5. **Highlights PR entries** - The current PR is rendered with blockquote style (displayed with a left border in GitHub)
+6. **Posts a comment** - Creates or updates a comment on the PR with the changelog preview
+7. **Auto-updates** - The comment is automatically updated when you update the PR (push commits, edit title/description, or change labels)
+
+### Example Comment
+
+The workflow posts a comment like this:
+
+```markdown
+## Suggested Version Bump
+
+🟡 **Minor** (new features)
+
+## 📋 Changelog Preview
+
+This is how your changes will appear in the changelog.
+Entries from this PR are highlighted with a left border (blockquote style).
+
+---
+
+### New Features ✨
+
+> - feat(api): Add new endpoint by @you in #123
+
+- feat(core): Existing feature by @other in #100
+
+### Bug Fixes 🐛
+
+- fix(ui): Resolve crash by @other in #99
+
+---
+
+🤖 This preview updates automatically when you update the PR.
+```
+
+### PR Trigger Types
+
+The workflow supports these PR event types:
+- `opened` - When a PR is created
+- `synchronize` - When new commits are pushed
+- `reopened` - When a closed PR is reopened
+- `edited` - When the PR title or description is changed
+- `labeled` - When labels are added or removed
+
+### Requirements
+
+- Use `secrets: inherit` to pass the GitHub token
+- The repository should have a git history with tags for the changelog to be meaningful
+
+## Skipping Changelog Entries
+
+### Using Magic Words
+
+Use `#skip-changelog` in your commit message or PR body to exclude a commit from the changelog:
+
+```
+chore: Update dependencies
+
+#skip-changelog
+```
+
+### Using Labels
+
+You can configure labels to exclude PRs from the changelog. In your `.craft.yml`:
+
+```yaml
+changelog:
+ categories:
+ - title: "New Features ✨"
+ labels: ["feature", "enhancement"]
+ - title: "Bug Fixes 🐛"
+ labels: ["bug", "fix"]
+ exclude:
+ labels: ["skip-changelog", "dependencies"]
+ authors: ["dependabot[bot]", "renovate[bot]"]
+```
+
+PRs with the `skip-changelog` label or from excluded authors will not appear in the changelog.
+
+## Tips
+
+### Combining Both Actions
+
+You can use both the changelog preview and release actions together for a complete release workflow. See the [getsentry/publish](https://github.com/getsentry/publish) repository for a real-world example.
+
+```yaml
+# .github/workflows/changelog-preview.yml
+name: Changelog Preview
+on:
+ pull_request:
+ types: [opened, synchronize, reopened, edited, labeled]
+
+jobs:
+ changelog-preview:
+ uses: getsentry/craft/.github/workflows/changelog-preview.yml@v2
+ secrets: inherit
+```
+
+```yaml
+# .github/workflows/release.yml
+name: Release
+on:
+ workflow_dispatch:
+ inputs:
+ version:
+ description: 'Version (leave empty for auto)'
+ required: false
+
+jobs:
+ release:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+ - uses: getsentry/craft@v2
+ with:
+ version: ${{ github.event.inputs.version || 'auto' }}
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+```
diff --git a/docs/src/content/docs/index.mdx b/docs/src/content/docs/index.mdx
new file mode 100644
index 00000000..8052b47c
--- /dev/null
+++ b/docs/src/content/docs/index.mdx
@@ -0,0 +1,67 @@
+---
+title: Craft
+description: Universal Release Tool (And More)
+template: splash
+hero:
+ tagline: A command line tool that helps to automate and pipeline package releases.
+ image:
+ file: ../../assets/logo.svg
+ actions:
+ - text: Get Started
+ link: ./getting-started/
+ icon: right-arrow
+ - text: View on GitHub
+ link: https://github.com/getsentry/craft
+ icon: external
+ variant: minimal
+---
+
+import { Card, CardGrid } from '@astrojs/starlight/components';
+
+## Features
+
+
+
+ Prepare and publish releases with a single command. Craft handles version bumping, changelog management, and artifact publishing.
+
+
+ Automatically determine version bumps from conventional commits. Just run `craft prepare auto` and let Craft figure out the rest.
+
+
+ Publish to GitHub, NPM, PyPI, Docker, NuGet, Crates.io, and many more registries from a single configuration.
+
+
+ Automatic changelog generation using conventional commits or manual changelog policies.
+
+
+ Works seamlessly with GitHub Actions and other CI systems. Fetch artifacts and publish them to your targets.
+
+
+
+## Quick Example
+
+```bash
+# Auto-determine version from conventional commits
+craft prepare auto
+
+# Or specify a bump type
+craft prepare minor
+
+# Or use an explicit version
+craft prepare 1.2.3
+
+# Then publish to all configured targets
+craft publish 1.2.3
+```
+
+## Why Craft?
+
+Craft enforces a specific workflow for managing release branches, changelogs, and artifact publishing. It:
+
+- Creates release branches automatically
+- Validates changelog entries
+- Runs version-bumping scripts
+- Waits for CI to complete
+- Downloads build artifacts
+- Publishes to configured targets
+- Merges release branches back to main
diff --git a/docs/src/content/docs/targets/aws-lambda-layer.md b/docs/src/content/docs/targets/aws-lambda-layer.md
new file mode 100644
index 00000000..835f1d37
--- /dev/null
+++ b/docs/src/content/docs/targets/aws-lambda-layer.md
@@ -0,0 +1,48 @@
+---
+title: AWS Lambda Layer
+description: Publish Lambda layers to all AWS regions
+---
+
+Creates a new public Lambda layer in each available AWS region and updates the Sentry release registry.
+
+## Configuration
+
+| Option | Description |
+|--------|-------------|
+| `layerName` | Name of the Lambda layer |
+| `compatibleRuntimes` | List of runtime configurations |
+| `license` | Layer license |
+| `linkPrereleases` | Update for preview releases. Default: `false` |
+| `includeNames` | Must filter to exactly one artifact |
+
+### Runtime Configuration
+
+```yaml
+compatibleRuntimes:
+ - name: node
+ versions:
+ - nodejs10.x
+ - nodejs12.x
+```
+
+## Environment Variables
+
+| Name | Description |
+|------|-------------|
+| `AWS_ACCESS_KEY` | AWS account access key |
+| `AWS_SECRET_ACCESS_KEY` | AWS account secret key |
+
+## Example
+
+```yaml
+targets:
+ - name: aws-lambda-layer
+ includeNames: /^sentry-node-serverless-\d+(\.\d+)*\.zip$/
+ layerName: SentryNodeServerlessSDK
+ compatibleRuntimes:
+ - name: node
+ versions:
+ - nodejs10.x
+ - nodejs12.x
+ license: MIT
+```
diff --git a/docs/src/content/docs/targets/brew.md b/docs/src/content/docs/targets/brew.md
new file mode 100644
index 00000000..dd62567f
--- /dev/null
+++ b/docs/src/content/docs/targets/brew.md
@@ -0,0 +1,54 @@
+---
+title: Homebrew
+description: Update Homebrew formulas
+---
+
+Pushes a new or updated Homebrew formula to a tap repository. The formula is committed directly to the master branch.
+
+:::note
+Formulas on `homebrew/core` are not supported. Use your own tap repository.
+:::
+
+## Configuration
+
+| Option | Description |
+|--------|-------------|
+| `tap` | Homebrew tap name (e.g., `octocat/tools` → `github.com:octocat/homebrew-tools`) |
+| `template` | Formula template (Ruby code) with Mustache interpolation |
+| `formula` | Formula name. Default: repository name |
+| `path` | Path to store formula. Default: `Formula` |
+
+### Template Variables
+
+- `version`: The new version
+- `revision`: The tag's commit SHA
+- `checksums`: Map of sha256 checksums by filename (dots replaced with `__`, version with `__VERSION__`)
+
+## Environment Variables
+
+| Name | Description |
+|------|-------------|
+| `GITHUB_TOKEN` | GitHub API token |
+
+## Example
+
+```yaml
+targets:
+ - name: brew
+ tap: octocat/tools
+ formula: myproject
+ path: HomebrewFormula
+ template: >
+ class MyProject < Formula
+ desc "This is a test for homebrew formulae"
+ homepage "https://github.com/octocat/my-project"
+ url "https://github.com/octocat/my-project/releases/download/{{version}}/binary-darwin"
+ version "{{version}}"
+ sha256 "{{checksums.binary-darwin}}"
+
+ def install
+ mv "binary-darwin", "myproject"
+ bin.install "myproject"
+ end
+ end
+```
diff --git a/docs/src/content/docs/targets/cocoapods.md b/docs/src/content/docs/targets/cocoapods.md
new file mode 100644
index 00000000..a0c12d2a
--- /dev/null
+++ b/docs/src/content/docs/targets/cocoapods.md
@@ -0,0 +1,32 @@
+---
+title: CocoaPods
+description: Publish pods to CocoaPods
+---
+
+Pushes a new podspec to the central CocoaPods repository. The podspec is fetched from the GitHub repository at the release revision.
+
+## Configuration
+
+| Option | Description |
+|--------|-------------|
+| `specPath` | Path to the Podspec file in the repository |
+
+## Environment Variables
+
+| Name | Description |
+|------|-------------|
+| `COCOAPODS_TRUNK_TOKEN` | Access token for CocoaPods account |
+| `COCOAPODS_BIN` | Path to `pod` executable |
+
+## Example
+
+```yaml
+targets:
+ - name: cocoapods
+ specPath: MyProject.podspec
+```
+
+## Notes
+
+- The `cocoapods` gem must be installed on the system
+- No release artifacts are required for this target
diff --git a/docs/src/content/docs/targets/commit-on-git-repository.md b/docs/src/content/docs/targets/commit-on-git-repository.md
new file mode 100644
index 00000000..518af8b6
--- /dev/null
+++ b/docs/src/content/docs/targets/commit-on-git-repository.md
@@ -0,0 +1,39 @@
+---
+title: Commit on Git Repository
+description: Push unpacked tarball contents to a git repository
+---
+
+Takes a tarball and pushes the unpacked contents to a git repository.
+
+## Configuration
+
+| Option | Description |
+|--------|-------------|
+| `archive` | Regex to match a `.tgz` file in artifacts (must match exactly one) |
+| `repositoryUrl` | Git remote URL (must use http or https, not `git@...`) |
+| `branch` | Target branch |
+| `stripComponents` | Leading path elements to remove when unpacking. Default: `0` |
+| `createTag` | Create a tag with the release version. Default: `false` |
+
+## Environment Variables
+
+| Name | Description |
+|------|-------------|
+| `GITHUB_API_TOKEN` | GitHub PAT for authentication (when host is `github.com`) |
+
+## Example
+
+```yaml
+targets:
+ - name: commit-on-git-repository
+ archive: /^sentry-deno-\d.*\.tgz$/
+ repositoryUrl: https://github.com/getsentry/sentry-deno
+ stripComponents: 1
+ branch: main
+ createTag: true
+```
+
+## Notes
+
+- The repository URL must use HTTP or HTTPS protocol
+- For GitHub repos, authentication uses `GITHUB_API_TOKEN`
diff --git a/docs/src/content/docs/targets/crates.md b/docs/src/content/docs/targets/crates.md
new file mode 100644
index 00000000..0de6e093
--- /dev/null
+++ b/docs/src/content/docs/targets/crates.md
@@ -0,0 +1,32 @@
+---
+title: Crates
+description: Publish Rust packages to crates.io
+---
+
+Publishes a single Rust package or entire workspace to [crates.io](https://crates.io). If the workspace contains multiple crates, they are published in dependency order.
+
+## Configuration
+
+| Option | Description |
+|--------|-------------|
+| `noDevDeps` | Strip `devDependencies` before publishing. Requires [`cargo-hack`](https://github.com/taiki-e/cargo-hack). Default: `false` |
+
+## Environment Variables
+
+| Name | Description |
+|------|-------------|
+| `CRATES_IO_TOKEN` | Access token for crates.io |
+| `CARGO_BIN` | Path to cargo. Default: `cargo` |
+
+## Example
+
+```yaml
+targets:
+ - name: crates
+ noDevDeps: false
+```
+
+## Notes
+
+- `cargo` must be installed and configured on the system
+- For workspaces, crates are published in topological order based on dependencies
diff --git a/docs/src/content/docs/targets/docker.md b/docs/src/content/docs/targets/docker.md
new file mode 100644
index 00000000..40b663e8
--- /dev/null
+++ b/docs/src/content/docs/targets/docker.md
@@ -0,0 +1,104 @@
+---
+title: Docker
+description: Tag and push Docker images
+---
+
+Copies an existing source image tagged with the revision SHA to a new target tagged with the released version. Supports Docker Hub, GitHub Container Registry (ghcr.io), Google Container Registry (gcr.io), and other OCI-compliant registries.
+
+## Configuration
+
+| Option | Description |
+|--------|-------------|
+| `source` | Source image: string or object with `image`, `registry`, `format`, `usernameVar`, `passwordVar`, `skipLogin` |
+| `target` | Target image: string or object (same options as source) |
+
+### Image Object Options
+
+| Property | Description |
+|----------|-------------|
+| `image` | Docker image path (e.g., `ghcr.io/org/image`) |
+| `registry` | Override the registry (auto-detected from `image`) |
+| `format` | Format template. Default: `{{{source}}}:{{{revision}}}` for source |
+| `usernameVar` | Env var name for username |
+| `passwordVar` | Env var name for password |
+| `skipLogin` | Skip `docker login` for this registry |
+
+## Environment Variables
+
+**Target Registry Credentials** (resolved in order):
+
+1. Explicit `usernameVar`/`passwordVar` from config
+2. Registry-derived: `DOCKER__USERNAME/PASSWORD` (e.g., `DOCKER_GHCR_IO_USERNAME`)
+3. Built-in defaults for `ghcr.io`: `GITHUB_ACTOR` and `GITHUB_TOKEN`
+4. Fallback: `DOCKER_USERNAME` and `DOCKER_PASSWORD`
+
+| Name | Description |
+|------|-------------|
+| `DOCKER_USERNAME` | Default username for target registry |
+| `DOCKER_PASSWORD` | Default password/token for target registry |
+| `DOCKER_BIN` | Path to `docker` executable |
+
+## Examples
+
+### Docker Hub
+
+```yaml
+targets:
+ - name: docker
+ source: ghcr.io/getsentry/craft
+ target: getsentry/craft
+```
+
+### GitHub Container Registry (zero-config in GitHub Actions)
+
+```yaml
+targets:
+ - name: docker
+ source: ghcr.io/getsentry/craft
+ target: ghcr.io/getsentry/craft
+```
+
+### Multiple Registries
+
+```yaml
+targets:
+ # Docker Hub
+ - name: docker
+ source: ghcr.io/getsentry/craft
+ target: getsentry/craft
+
+ # GHCR
+ - name: docker
+ source: ghcr.io/getsentry/craft
+ target: ghcr.io/getsentry/craft
+
+ # GCR with shared credentials
+ - name: docker
+ source: ghcr.io/getsentry/craft
+ target: us.gcr.io/my-project/craft
+ registry: gcr.io
+```
+
+### Cross-registry with Explicit Credentials
+
+```yaml
+targets:
+ - name: docker
+ source:
+ image: private.registry.io/image
+ usernameVar: PRIVATE_REGISTRY_USER
+ passwordVar: PRIVATE_REGISTRY_PASS
+ target: getsentry/craft
+```
+
+### Google Cloud Registries
+
+Craft auto-detects Google Cloud registries and uses `gcloud auth configure-docker`:
+
+```yaml
+# Works with google-github-actions/auth
+targets:
+ - name: docker
+ source: ghcr.io/myorg/image
+ target: us-docker.pkg.dev/my-project/repo/image
+```
diff --git a/docs/src/content/docs/targets/gcs.md b/docs/src/content/docs/targets/gcs.md
new file mode 100644
index 00000000..a4950022
--- /dev/null
+++ b/docs/src/content/docs/targets/gcs.md
@@ -0,0 +1,45 @@
+---
+title: Google Cloud Storage
+description: Upload artifacts to GCS buckets
+---
+
+Uploads artifacts to a bucket in Google Cloud Storage.
+
+## Configuration
+
+| Option | Description |
+|--------|-------------|
+| `bucket` | GCS bucket name |
+| `paths` | List of path objects |
+| `paths.path` | Bucket path with `{{ version }}` and/or `{{ revision }}` templates |
+| `paths.metadata` | Optional metadata for uploaded files |
+
+## Environment Variables
+
+| Name | Description |
+|------|-------------|
+| `CRAFT_GCS_TARGET_CREDS_PATH` | Path to Google Cloud credentials file |
+| `CRAFT_GCS_TARGET_CREDS_JSON` | Service account file contents as JSON string |
+
+If both are set, `CRAFT_GCS_TARGET_CREDS_JSON` takes precedence.
+
+## Example
+
+```yaml
+targets:
+ - name: gcs
+ bucket: bucket-name
+ paths:
+ - path: release/{{version}}/download
+ metadata:
+ cacheControl: 'public, max-age=3600'
+ - path: release/{{revision}}/platform/package
+```
+
+## Default Metadata
+
+By default, files are uploaded with:
+
+```yaml
+cacheControl: 'public, max-age=300'
+```
diff --git a/docs/src/content/docs/targets/gem.md b/docs/src/content/docs/targets/gem.md
new file mode 100644
index 00000000..cda80732
--- /dev/null
+++ b/docs/src/content/docs/targets/gem.md
@@ -0,0 +1,28 @@
+---
+title: Ruby Gems
+description: Publish gems to RubyGems
+---
+
+Pushes a gem to [RubyGems](https://rubygems.org).
+
+## Configuration
+
+No additional configuration options.
+
+## Environment Variables
+
+| Name | Description |
+|------|-------------|
+| `GEM_BIN` | Path to `gem` executable. Default: `gem` |
+
+## Example
+
+```yaml
+targets:
+ - name: gem
+```
+
+## Notes
+
+- `gem` must be installed on the system
+- You must be logged in with `gem login`
diff --git a/docs/src/content/docs/targets/gh-pages.md b/docs/src/content/docs/targets/gh-pages.md
new file mode 100644
index 00000000..791d0f13
--- /dev/null
+++ b/docs/src/content/docs/targets/gh-pages.md
@@ -0,0 +1,39 @@
+---
+title: GitHub Pages
+description: Deploy static sites to GitHub Pages
+---
+
+Extracts an archive with static assets and pushes them to a git branch for GitHub Pages deployment.
+
+:::caution
+The destination branch will be completely overwritten by the archive contents.
+:::
+
+## Configuration
+
+| Option | Description |
+|--------|-------------|
+| `branch` | Branch to push to. Default: `gh-pages` |
+| `githubOwner` | GitHub project owner. Default: from global config |
+| `githubRepo` | GitHub project name. Default: from global config |
+
+## Default Behavior
+
+By default, this target:
+1. Looks for an artifact named `gh-pages.zip`
+2. Extracts its contents
+3. Commits to the `gh-pages` branch
+
+## Example
+
+```yaml
+targets:
+ - name: gh-pages
+ branch: gh-pages
+```
+
+## Workflow
+
+1. Create a `gh-pages.zip` artifact in your CI workflow
+2. Configure the target in `.craft.yml`
+3. Enable GitHub Pages in repository settings to serve from the `gh-pages` branch
diff --git a/docs/src/content/docs/targets/github.md b/docs/src/content/docs/targets/github.md
new file mode 100644
index 00000000..6f681e4e
--- /dev/null
+++ b/docs/src/content/docs/targets/github.md
@@ -0,0 +1,48 @@
+---
+title: GitHub
+description: Create GitHub releases and tags
+---
+
+Creates a release on GitHub. If a Markdown changelog is present, this target reads the release name and description from it.
+
+## Configuration
+
+| Option | Description |
+|--------|-------------|
+| `tagPrefix` | Prefix for new git tags (e.g., `v`). Empty by default. |
+| `previewReleases` | Automatically detect and create preview releases. Default: `true` |
+| `tagOnly` | Only create a tag (without a GitHub release). Default: `false` |
+| `floatingTags` | List of floating tags to create/update. Supports `{major}`, `{minor}`, `{patch}` placeholders. |
+
+## Environment Variables
+
+| Name | Description |
+|------|-------------|
+| `GITHUB_TOKEN` | Personal GitHub API token ([create one](https://github.com/settings/tokens)) |
+
+## Example
+
+```yaml
+targets:
+ - name: github
+ tagPrefix: v
+ previewReleases: true
+```
+
+## Floating Tags
+
+Use `floatingTags` to maintain "latest major version" tags that always point to the most recent release:
+
+```yaml
+targets:
+ - name: github
+ floatingTags:
+ - "v{major}" # Creates v2 for version 2.15.0
+ - "v{major}.{minor}" # Creates v2.15 for version 2.15.0
+```
+
+This is useful for users who want to pin to a major version while automatically receiving updates.
+
+## Preview Releases
+
+If `previewReleases` is `true` (default), releases containing pre-release identifiers like `alpha`, `beta`, `rc`, etc. are marked as pre-releases on GitHub.
diff --git a/docs/src/content/docs/targets/hex.md b/docs/src/content/docs/targets/hex.md
new file mode 100644
index 00000000..8eddc8c1
--- /dev/null
+++ b/docs/src/content/docs/targets/hex.md
@@ -0,0 +1,28 @@
+---
+title: Hex
+description: Publish Elixir/Erlang packages to Hex
+---
+
+Pushes a package to [Hex](https://hex.pm), the package manager for Elixir and Erlang.
+
+## Configuration
+
+No additional configuration options.
+
+## Environment Variables
+
+| Name | Description |
+|------|-------------|
+| `HEX_API_KEY` | API key from hex.pm account |
+| `MIX_BIN` | Path to `mix` executable. Default: `mix` |
+
+## Example
+
+```yaml
+targets:
+ - name: hex
+```
+
+## Notes
+
+- `mix` (bundled with Elixir) must be installed on the system
diff --git a/docs/src/content/docs/targets/index.md b/docs/src/content/docs/targets/index.md
new file mode 100644
index 00000000..13758a21
--- /dev/null
+++ b/docs/src/content/docs/targets/index.md
@@ -0,0 +1,80 @@
+---
+title: Targets Overview
+description: Overview of all available release targets
+---
+
+Targets define where Craft publishes your release artifacts. Configure them in `.craft.yml` under the `targets` key.
+
+## Available Targets
+
+| Target | Description |
+|--------|-------------|
+| [GitHub](./github/) | Create GitHub releases and tags |
+| [NPM](./npm/) | Publish to NPM registry |
+| [PyPI](./pypi/) | Publish to Python Package Index |
+| [Crates](./crates/) | Publish Rust crates |
+| [NuGet](./nuget/) | Publish .NET packages |
+| [Docker](./docker/) | Tag and push Docker images |
+| [Homebrew](./brew/) | Update Homebrew formulas |
+| [GCS](./gcs/) | Upload to Google Cloud Storage |
+| [GitHub Pages](./gh-pages/) | Deploy static sites |
+| [CocoaPods](./cocoapods/) | Publish iOS/macOS pods |
+| [Ruby Gems](./gem/) | Publish Ruby gems |
+| [Maven](./maven/) | Publish to Maven Central |
+| [Hex](./hex/) | Publish Elixir packages |
+| [pub.dev](./pub-dev/) | Publish Dart/Flutter packages |
+| [AWS Lambda Layer](./aws-lambda-layer/) | Publish Lambda layers |
+| [Registry](./registry/) | Update Sentry release registry |
+| [UPM](./upm/) | Publish Unity packages |
+| [Symbol Collector](./symbol-collector/) | Upload native symbols |
+| [PowerShell](./powershell/) | Publish PowerShell modules |
+| [Commit on Git Repository](./commit-on-git-repository/) | Push to a git repository |
+
+## Basic Configuration
+
+```yaml
+targets:
+ - name: npm
+ - name: github
+```
+
+## Per-target Options
+
+These options can be applied to any target:
+
+| Option | Description |
+|--------|-------------|
+| `includeNames` | Regex pattern: only matched files are processed |
+| `excludeNames` | Regex pattern: matched files are skipped |
+| `id` | Unique ID to reference this target with `-t target[id]` |
+| `onlyIfPresent` | Only run if a file matching this pattern exists |
+
+Example:
+
+```yaml
+targets:
+ - name: github
+ includeNames: /^.*\.exe$/
+ excludeNames: /^test.exe$/
+ - name: registry
+ id: browser
+ onlyIfPresent: /^sentry-browser-.*\.tgz$/
+```
+
+## Running Specific Targets
+
+Use the `-t` flag with `craft publish`:
+
+```shell
+# Publish to all targets
+craft publish 1.2.3
+
+# Publish to specific target
+craft publish 1.2.3 -t npm
+
+# Publish to target with ID
+craft publish 1.2.3 -t registry[browser]
+
+# Skip publishing (just merge branch)
+craft publish 1.2.3 -t none
+```
diff --git a/docs/src/content/docs/targets/maven.md b/docs/src/content/docs/targets/maven.md
new file mode 100644
index 00000000..994846d0
--- /dev/null
+++ b/docs/src/content/docs/targets/maven.md
@@ -0,0 +1,75 @@
+---
+title: Maven
+description: Publish packages to Maven Central
+---
+
+PGP signs and publishes packages to Maven Central.
+
+:::tip
+Set the logging level to `trace` to see command output.
+:::
+
+## Configuration
+
+| Option | Description |
+|--------|-------------|
+| `mavenCliPath` | Path to Maven CLI (must be executable) |
+| `mavenSettingsPath` | Path to Maven `settings.xml` |
+| `mavenRepoId` | Maven server ID in `settings.xml` |
+| `mavenRepoUrl` | Maven repository URL |
+| `android` | Android configuration object or `false` |
+| `kmp` | Kotlin Multiplatform configuration or `false` |
+
+### Android Configuration
+
+| Option | Description |
+|--------|-------------|
+| `distDirRegex` | Pattern for distribution directory names |
+| `fileReplaceeRegex` | Pattern for module name substring to replace |
+| `fileReplacerStr` | Replacement string for Android distribution file |
+
+### KMP Configuration
+
+| Option | Description |
+|--------|-------------|
+| `rootDistDirRegex` | Pattern for root distribution directory |
+| `appleDistDirRegex` | Pattern for Apple platform directories |
+| `klibDistDirRegex` | Pattern for JS/WASM directories |
+
+## Environment Variables
+
+| Name | Description |
+|------|-------------|
+| `OSSRH_USERNAME` | Sonatype repository username |
+| `OSSRH_PASSWORD` | Sonatype repository password |
+| `GPG_PASSPHRASE` | Passphrase for GPG private key |
+| `GPG_PRIVATE_KEY` | GPG private key (optional, uses default if not set) |
+
+## Examples
+
+### Without Android
+
+```yaml
+targets:
+ - name: maven
+ mavenCliPath: scripts/mvnw.cmd
+ mavenSettingsPath: scripts/settings.xml
+ mavenRepoId: ossrh
+ mavenRepoUrl: https://oss.sonatype.org/service/local/staging/deploy/maven2/
+ android: false
+```
+
+### With Android
+
+```yaml
+targets:
+ - name: maven
+ mavenCliPath: scripts/mvnw.cmd
+ mavenSettingsPath: scripts/settings.xml
+ mavenRepoId: ossrh
+ mavenRepoUrl: https://oss.sonatype.org/service/local/staging/deploy/maven2/
+ android:
+ distDirRegex: /^sentry-android-.*$/
+ fileReplaceeRegex: /\d\.\d\.\d(-SNAPSHOT)?/
+ fileReplacerStr: release.aar
+```
diff --git a/docs/src/content/docs/targets/npm.md b/docs/src/content/docs/targets/npm.md
new file mode 100644
index 00000000..9c5d425f
--- /dev/null
+++ b/docs/src/content/docs/targets/npm.md
@@ -0,0 +1,83 @@
+---
+title: NPM
+description: Publish packages to NPM registry
+---
+
+Releases an NPM package to the public registry. Requires a package tarball generated by `npm pack` in the artifacts.
+
+## Configuration
+
+| Option | Description |
+|--------|-------------|
+| `access` | Visibility for scoped packages: `restricted` (default) or `public` |
+| `checkPackageName` | Package to check for current version when determining `latest` tag |
+
+## Environment Variables
+
+| Name | Description |
+|------|-------------|
+| `NPM_TOKEN` | An [automation token](https://docs.npmjs.com/creating-and-viewing-access-tokens) allowed to publish |
+| `NPM_BIN` | Path to npm executable. Default: `npm` |
+| `YARN_BIN` | Path to yarn executable. Default: `yarn` |
+| `CRAFT_NPM_USE_OTP` | If `1`, prompts for OTP (for 2FA) |
+
+## Example
+
+```yaml
+targets:
+ - name: npm
+ access: public
+```
+
+## Workspaces Support
+
+Craft supports automatic discovery and publishing of NPM/Yarn workspace packages. When enabled, the npm target automatically expands into multiple targets—one per workspace package—published in dependency order.
+
+### Workspace Configuration
+
+| Option | Description |
+|--------|-------------|
+| `workspaces` | Enable workspace discovery. Default: `false` |
+| `includeWorkspaces` | Regex pattern to filter which packages to include (e.g., `/^@sentry\//`) |
+| `excludeWorkspaces` | Regex pattern to filter which packages to exclude (e.g., `/^@sentry-internal\//`) |
+| `artifactTemplate` | Template for artifact filenames. Variables: `{{name}}`, `{{simpleName}}`, `{{version}}` |
+
+### Workspace Example
+
+```yaml
+targets:
+ - name: npm
+ access: public
+ workspaces: true
+ includeWorkspaces: /^@sentry\//
+ excludeWorkspaces: /^@sentry-internal\//
+```
+
+### Workspace Features
+
+- **Auto-discovery**: Detects packages from `package.json` workspaces field (npm/yarn workspaces)
+- **Dependency ordering**: Publishes packages in topological order (dependencies before dependents)
+- **Private package filtering**: Automatically excludes packages marked as `private: true`
+- **Validation**: Errors if public packages depend on private workspace packages
+- **Scoped package warnings**: Warns if scoped packages don't have `publishConfig.access: 'public'`
+
+### Artifact Naming
+
+By default, Craft expects artifacts named like:
+- `@sentry/browser` → `sentry-browser-{version}.tgz`
+
+Use `artifactTemplate` for custom naming:
+
+```yaml
+targets:
+ - name: npm
+ workspaces: true
+ artifactTemplate: "{{simpleName}}-{{version}}.tgz"
+```
+
+## Notes
+
+- The `npm` utility must be installed on the system
+- If `npm` is not found, Craft falls back to `yarn publish`
+- For scoped packages (`@org/package`), set `access: public` to publish publicly
+- Pre-release versions are automatically tagged as `next` instead of `latest`
diff --git a/docs/src/content/docs/targets/nuget.md b/docs/src/content/docs/targets/nuget.md
new file mode 100644
index 00000000..74a89bc3
--- /dev/null
+++ b/docs/src/content/docs/targets/nuget.md
@@ -0,0 +1,28 @@
+---
+title: NuGet
+description: Publish .NET packages to NuGet
+---
+
+Uploads packages to [NuGet](https://www.nuget.org/) via .NET Core.
+
+:::note
+This target allows re-entrant publishing to handle interrupted releases when publishing multiple packages.
+:::
+
+## Configuration
+
+No additional configuration options.
+
+## Environment Variables
+
+| Name | Description |
+|------|-------------|
+| `NUGET_API_TOKEN` | NuGet [API token](https://www.nuget.org/account/apikeys) |
+| `NUGET_DOTNET_BIN` | Path to .NET Core. Default: `dotnet` |
+
+## Example
+
+```yaml
+targets:
+ - name: nuget
+```
diff --git a/docs/src/content/docs/targets/powershell.md b/docs/src/content/docs/targets/powershell.md
new file mode 100644
index 00000000..0b5a4ae2
--- /dev/null
+++ b/docs/src/content/docs/targets/powershell.md
@@ -0,0 +1,34 @@
+---
+title: PowerShell
+description: Publish PowerShell modules to PowerShell Gallery
+---
+
+Uploads a module to [PowerShell Gallery](https://www.powershellgallery.com/) or another repository supported by PowerShellGet's `Publish-Module`.
+
+The action looks for an artifact named `.zip` and extracts it to a temporary directory for publishing.
+
+## Configuration
+
+| Option | Description |
+|--------|-------------|
+| `module` | Module name (required) |
+| `repository` | Repository to publish to. Default: `PSGallery` |
+
+## Environment Variables
+
+| Name | Description |
+|------|-------------|
+| `POWERSHELL_API_KEY` | PowerShell Gallery API key (required) |
+| `POWERSHELL_BIN` | Path to PowerShell binary. Default: `pwsh` |
+
+## Example
+
+```yaml
+targets:
+ - name: powershell
+ module: Sentry
+```
+
+## Notes
+
+- `pwsh` must be [installed](https://github.com/powershell/powershell#get-powershell) on the system
diff --git a/docs/src/content/docs/targets/pub-dev.md b/docs/src/content/docs/targets/pub-dev.md
new file mode 100644
index 00000000..632ef6e4
--- /dev/null
+++ b/docs/src/content/docs/targets/pub-dev.md
@@ -0,0 +1,59 @@
+---
+title: pub.dev
+description: Publish Dart/Flutter packages to pub.dev
+---
+
+Pushes a new Dart or Flutter package to [pub.dev](https://pub.dev/).
+
+## Setup
+
+Because there is [no automated way](https://github.com/dart-lang/pub-dev/issues/5388) to obtain tokens, you must perform a valid release manually first for each package. This generates credentials at:
+
+- macOS: `$HOME/Library/Application Support/dart/pub-credentials.json`
+- Linux: `$HOME/.config/dart/pub-credentials.json`
+- Or: `$HOME/.pub-cache/credentials.json`
+
+## Configuration
+
+| Option | Description |
+|--------|-------------|
+| `dartCliPath` | Path to Dart CLI. Default: `dart` |
+| `packages` | List of package directories (for monorepos) |
+| `skipValidation` | Skip analyzer and dependency checks |
+
+## Environment Variables
+
+| Name | Description |
+|------|-------------|
+| `PUBDEV_ACCESS_TOKEN` | Value of `accessToken` from credentials file |
+| `PUBDEV_REFRESH_TOKEN` | Value of `refreshToken` from credentials file |
+
+## Examples
+
+### Single Package
+
+```yaml
+targets:
+ - name: pub-dev
+```
+
+### Multiple Packages (Monorepo)
+
+```yaml
+targets:
+ - name: pub-dev
+ packages:
+ uno:
+ dos:
+ tres:
+```
+
+### Skip Validation
+
+Use cautiously—this skips analyzer checks:
+
+```yaml
+targets:
+ - name: pub-dev
+ skipValidation: true
+```
diff --git a/docs/src/content/docs/targets/pypi.md b/docs/src/content/docs/targets/pypi.md
new file mode 100644
index 00000000..222b3e6e
--- /dev/null
+++ b/docs/src/content/docs/targets/pypi.md
@@ -0,0 +1,48 @@
+---
+title: PyPI
+description: Publish packages to Python Package Index
+---
+
+Uploads source distributions and wheels to the Python Package Index via [twine](https://pypi.org/project/twine/).
+
+## Configuration
+
+No additional configuration options.
+
+## Environment Variables
+
+| Name | Description |
+|------|-------------|
+| `TWINE_USERNAME` | PyPI username with access rights |
+| `TWINE_PASSWORD` | PyPI password |
+| `TWINE_BIN` | Path to twine. Default: `twine` |
+
+## Example
+
+```yaml
+targets:
+ - name: pypi
+```
+
+## Sentry Internal PyPI
+
+For Sentry's internal PyPI, use the `sentry-pypi` target which creates a PR to import the package:
+
+```yaml
+targets:
+ - name: pypi
+ - name: sentry-pypi
+ internalPypiRepo: getsentry/pypi
+```
+
+### Sentry PyPI Configuration
+
+| Option | Description |
+|--------|-------------|
+| `internalPypiRepo` | GitHub repo containing pypi metadata |
+
+### Sentry PyPI Environment
+
+| Name | Description |
+|------|-------------|
+| `GITHUB_TOKEN` | GitHub API token |
diff --git a/docs/src/content/docs/targets/registry.md b/docs/src/content/docs/targets/registry.md
new file mode 100644
index 00000000..bb524152
--- /dev/null
+++ b/docs/src/content/docs/targets/registry.md
@@ -0,0 +1,54 @@
+---
+title: Sentry Release Registry
+description: Update Sentry's release registry
+---
+
+Updates the [Sentry release registry](https://github.com/getsentry/sentry-release-registry/) with the latest version.
+
+:::tip
+Avoid having multiple `registry` targets—it supports batching multiple apps and SDKs in a single target.
+:::
+
+## Configuration
+
+| Option | Description |
+|--------|-------------|
+| `apps` | Dict of app configs keyed by canonical name (e.g., `app:craft`) |
+| `sdks` | Dict of SDK configs keyed by canonical name (e.g., `maven:io.sentry:sentry`) |
+
+### App/SDK Options
+
+| Option | Description |
+|--------|-------------|
+| `urlTemplate` | URL template for download links |
+| `linkPrereleases` | Update for preview releases. Default: `false` |
+| `checksums` | List of checksum configs |
+| `onlyIfPresent` | Only run if matching file exists |
+
+### Checksum Configuration
+
+```yaml
+checksums:
+ - algorithm: sha256 # or sha384, sha512
+ format: hex # or base64
+```
+
+## Example
+
+```yaml
+targets:
+ - name: registry
+ sdks:
+ 'npm:@sentry/browser':
+ apps:
+ 'app:craft':
+ urlTemplate: 'https://example.com/{{version}}/{{file}}'
+ checksums:
+ - algorithm: sha256
+ format: hex
+```
+
+## Package Types
+
+- **sdk**: Package uploaded to public registries (PyPI, NPM, etc.)
+- **app**: Standalone application with version files in the registry
diff --git a/docs/src/content/docs/targets/symbol-collector.md b/docs/src/content/docs/targets/symbol-collector.md
new file mode 100644
index 00000000..5d1d96fd
--- /dev/null
+++ b/docs/src/content/docs/targets/symbol-collector.md
@@ -0,0 +1,28 @@
+---
+title: Symbol Collector
+description: Upload native symbols to Symbol Collector
+---
+
+Uses the [`symbol-collector`](https://github.com/getsentry/symbol-collector) client to upload native symbols.
+
+## Configuration
+
+| Option | Description |
+|--------|-------------|
+| `serverEndpoint` | Server endpoint. Default: `https://symbol-collector.services.sentry.io` |
+| `batchType` | Symbol batch type: `Android`, `macOS`, `iOS` |
+| `bundleIdPrefix` | Prefix for bundle ID (version is appended) |
+
+## Example
+
+```yaml
+targets:
+ - name: symbol-collector
+ includeNames: /libsentry(-android)?\.so/
+ batchType: Android
+ bundleIdPrefix: android-ndk-
+```
+
+## Notes
+
+- The `symbol-collector` CLI must be available in PATH
diff --git a/docs/src/content/docs/targets/upm.md b/docs/src/content/docs/targets/upm.md
new file mode 100644
index 00000000..acb78882
--- /dev/null
+++ b/docs/src/content/docs/targets/upm.md
@@ -0,0 +1,26 @@
+---
+title: Unity Package Manager
+description: Publish Unity packages
+---
+
+Pulls a package as a zipped artifact and pushes the unzipped content to a target repository, tagging it with the release version.
+
+:::caution
+The destination repository will be completely overwritten.
+:::
+
+## Configuration
+
+| Option | Description |
+|--------|-------------|
+| `releaseRepoOwner` | Owner of the release target repository |
+| `releaseRepoName` | Name of the release target repository |
+
+## Example
+
+```yaml
+targets:
+ - name: upm
+ releaseRepoOwner: 'getsentry'
+ releaseRepoName: 'unity'
+```
diff --git a/docs/tsconfig.json b/docs/tsconfig.json
new file mode 100644
index 00000000..e9c3ffbe
--- /dev/null
+++ b/docs/tsconfig.json
@@ -0,0 +1,5 @@
+{
+ "extends": "astro/tsconfigs/strict",
+ "include": ["src/**/*"],
+ "exclude": ["node_modules"]
+}
diff --git a/docs/yarn.lock b/docs/yarn.lock
new file mode 100644
index 00000000..ebfd2168
--- /dev/null
+++ b/docs/yarn.lock
@@ -0,0 +1,3752 @@
+# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
+# yarn lockfile v1
+
+
+"@astrojs/compiler@^2.13.0":
+ version "2.13.0"
+ resolved "https://registry.yarnpkg.com/@astrojs/compiler/-/compiler-2.13.0.tgz#a40bef3106fff808bd91b41680275a7e28996d63"
+ integrity sha512-mqVORhUJViA28fwHYaWmsXSzLO9osbdZ5ImUfxBarqsYdMlPbqAqGJCxsNzvppp1BEzc1mJNjOVvQqeDN8Vspw==
+
+"@astrojs/internal-helpers@0.7.5":
+ version "0.7.5"
+ resolved "https://registry.yarnpkg.com/@astrojs/internal-helpers/-/internal-helpers-0.7.5.tgz#c1491b70a7ac00efbd0de650f3a4058e07112a31"
+ integrity sha512-vreGnYSSKhAjFJCWAwe/CNhONvoc5lokxtRoZims+0wa3KbHBdPHSSthJsKxPd8d/aic6lWKpRTYGY/hsgK6EA==
+
+"@astrojs/markdown-remark@6.3.10":
+ version "6.3.10"
+ resolved "https://registry.yarnpkg.com/@astrojs/markdown-remark/-/markdown-remark-6.3.10.tgz#75b8ce7398eec483006de8d76c604cc6ff841606"
+ integrity sha512-kk4HeYR6AcnzC4QV8iSlOfh+N8TZ3MEStxPyenyCtemqn8IpEATBFMTJcfrNW32dgpt6MY3oCkMM/Tv3/I4G3A==
+ dependencies:
+ "@astrojs/internal-helpers" "0.7.5"
+ "@astrojs/prism" "3.3.0"
+ github-slugger "^2.0.0"
+ hast-util-from-html "^2.0.3"
+ hast-util-to-text "^4.0.2"
+ import-meta-resolve "^4.2.0"
+ js-yaml "^4.1.1"
+ mdast-util-definitions "^6.0.0"
+ rehype-raw "^7.0.0"
+ rehype-stringify "^10.0.1"
+ remark-gfm "^4.0.1"
+ remark-parse "^11.0.0"
+ remark-rehype "^11.1.2"
+ remark-smartypants "^3.0.2"
+ shiki "^3.19.0"
+ smol-toml "^1.5.2"
+ unified "^11.0.5"
+ unist-util-remove-position "^5.0.0"
+ unist-util-visit "^5.0.0"
+ unist-util-visit-parents "^6.0.2"
+ vfile "^6.0.3"
+
+"@astrojs/mdx@^4.0.5":
+ version "4.3.13"
+ resolved "https://registry.yarnpkg.com/@astrojs/mdx/-/mdx-4.3.13.tgz#a7f6ad87df991ac7f5f9a2d26d1ad07eb3426476"
+ integrity sha512-IHDHVKz0JfKBy3//52JSiyWv089b7GVSChIXLrlUOoTLWowG3wr2/8hkaEgEyd/vysvNQvGk+QhysXpJW5ve6Q==
+ dependencies:
+ "@astrojs/markdown-remark" "6.3.10"
+ "@mdx-js/mdx" "^3.1.1"
+ acorn "^8.15.0"
+ es-module-lexer "^1.7.0"
+ estree-util-visit "^2.0.0"
+ hast-util-to-html "^9.0.5"
+ piccolore "^0.1.3"
+ rehype-raw "^7.0.0"
+ remark-gfm "^4.0.1"
+ remark-smartypants "^3.0.2"
+ source-map "^0.7.6"
+ unist-util-visit "^5.0.0"
+ vfile "^6.0.3"
+
+"@astrojs/prism@3.3.0":
+ version "3.3.0"
+ resolved "https://registry.yarnpkg.com/@astrojs/prism/-/prism-3.3.0.tgz#5888fcd5665d416450a4fe55b1b7b701b8d586d9"
+ integrity sha512-q8VwfU/fDZNoDOf+r7jUnMC2//H2l0TuQ6FkGJL8vD8nw/q5KiL3DS1KKBI3QhI9UQhpJ5dc7AtqfbXWuOgLCQ==
+ dependencies:
+ prismjs "^1.30.0"
+
+"@astrojs/sitemap@^3.2.1":
+ version "3.6.0"
+ resolved "https://registry.yarnpkg.com/@astrojs/sitemap/-/sitemap-3.6.0.tgz#e9a83abb96df6c7e89be301b07adce032e49a96f"
+ integrity sha512-4aHkvcOZBWJigRmMIAJwRQXBS+ayoP5z40OklTXYXhUDhwusz+DyDl+nSshY6y9DvkVEavwNcFO8FD81iGhXjg==
+ dependencies:
+ sitemap "^8.0.0"
+ stream-replace-string "^2.0.0"
+ zod "^3.25.76"
+
+"@astrojs/starlight@^0.31.1":
+ version "0.31.1"
+ resolved "https://registry.yarnpkg.com/@astrojs/starlight/-/starlight-0.31.1.tgz#617835c93c466d3d7d4c71d8e270d08ae0d83bce"
+ integrity sha512-VIVkHugwgtEqJPiRH8+ouP0UqUfdmpBO9C64R+6QaQ2qmADNkI/BA3/YAJHTBZYlMQQGEEuLJwD9qpaUovi52Q==
+ dependencies:
+ "@astrojs/mdx" "^4.0.5"
+ "@astrojs/sitemap" "^3.2.1"
+ "@pagefind/default-ui" "^1.3.0"
+ "@types/hast" "^3.0.4"
+ "@types/js-yaml" "^4.0.9"
+ "@types/mdast" "^4.0.4"
+ astro-expressive-code "^0.40.0"
+ bcp-47 "^2.1.0"
+ hast-util-from-html "^2.0.1"
+ hast-util-select "^6.0.2"
+ hast-util-to-string "^3.0.0"
+ hastscript "^9.0.0"
+ i18next "^23.11.5"
+ js-yaml "^4.1.0"
+ mdast-util-directive "^3.0.0"
+ mdast-util-to-markdown "^2.1.0"
+ mdast-util-to-string "^4.0.0"
+ pagefind "^1.3.0"
+ rehype "^13.0.1"
+ rehype-format "^5.0.0"
+ remark-directive "^3.0.0"
+ unified "^11.0.5"
+ unist-util-visit "^5.0.0"
+ vfile "^6.0.2"
+
+"@astrojs/telemetry@3.3.0":
+ version "3.3.0"
+ resolved "https://registry.yarnpkg.com/@astrojs/telemetry/-/telemetry-3.3.0.tgz#397dc1f3ab123470571d80c9b4c1335195d30417"
+ integrity sha512-UFBgfeldP06qu6khs/yY+q1cDAaArM2/7AEIqQ9Cuvf7B1hNLq0xDrZkct+QoIGyjq56y8IaE2I3CTvG99mlhQ==
+ dependencies:
+ ci-info "^4.2.0"
+ debug "^4.4.0"
+ dlv "^1.1.3"
+ dset "^3.1.4"
+ is-docker "^3.0.0"
+ is-wsl "^3.1.0"
+ which-pm-runs "^1.1.0"
+
+"@babel/helper-string-parser@^7.27.1":
+ version "7.27.1"
+ resolved "https://registry.yarnpkg.com/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz#54da796097ab19ce67ed9f88b47bb2ec49367687"
+ integrity sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==
+
+"@babel/helper-validator-identifier@^7.28.5":
+ version "7.28.5"
+ resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz#010b6938fab7cb7df74aa2bbc06aa503b8fe5fb4"
+ integrity sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==
+
+"@babel/parser@^7.28.5":
+ version "7.28.5"
+ resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.28.5.tgz#0b0225ee90362f030efd644e8034c99468893b08"
+ integrity sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==
+ dependencies:
+ "@babel/types" "^7.28.5"
+
+"@babel/runtime@^7.23.2":
+ version "7.28.4"
+ resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.28.4.tgz#a70226016fabe25c5783b2f22d3e1c9bc5ca3326"
+ integrity sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==
+
+"@babel/types@^7.28.5":
+ version "7.28.5"
+ resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.28.5.tgz#10fc405f60897c35f07e85493c932c7b5ca0592b"
+ integrity sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==
+ dependencies:
+ "@babel/helper-string-parser" "^7.27.1"
+ "@babel/helper-validator-identifier" "^7.28.5"
+
+"@capsizecss/unpack@^3.0.1":
+ version "3.0.1"
+ resolved "https://registry.yarnpkg.com/@capsizecss/unpack/-/unpack-3.0.1.tgz#d40cd7fded06110a3d6198dd1e7a9bbcded52880"
+ integrity sha512-8XqW8xGn++Eqqbz3e9wKuK7mxryeRjs4LOHLxbh2lwKeSbuNR4NFifDZT4KzvjU6HMOPbiNTsWpniK5EJfTWkg==
+ dependencies:
+ fontkit "^2.0.2"
+
+"@ctrl/tinycolor@^4.0.4":
+ version "4.2.0"
+ resolved "https://registry.yarnpkg.com/@ctrl/tinycolor/-/tinycolor-4.2.0.tgz#ba5d0b917303c0b3d3c14c4865cdc6ded25ac05f"
+ integrity sha512-kzyuwOAQnXJNLS9PSyrk0CWk35nWJW/zl/6KvnTBMFK65gm7U1/Z5BqjxeapjZCIhQcM/DsrEmcbRwDyXyXK4A==
+
+"@emnapi/runtime@^1.2.0", "@emnapi/runtime@^1.7.0":
+ version "1.7.1"
+ resolved "https://registry.yarnpkg.com/@emnapi/runtime/-/runtime-1.7.1.tgz#a73784e23f5d57287369c808197288b52276b791"
+ integrity sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA==
+ dependencies:
+ tslib "^2.4.0"
+
+"@esbuild/aix-ppc64@0.25.12":
+ version "0.25.12"
+ resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz#80fcbe36130e58b7670511e888b8e88a259ed76c"
+ integrity sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==
+
+"@esbuild/android-arm64@0.25.12":
+ version "0.25.12"
+ resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz#8aa4965f8d0a7982dc21734bf6601323a66da752"
+ integrity sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==
+
+"@esbuild/android-arm@0.25.12":
+ version "0.25.12"
+ resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.25.12.tgz#300712101f7f50f1d2627a162e6e09b109b6767a"
+ integrity sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==
+
+"@esbuild/android-x64@0.25.12":
+ version "0.25.12"
+ resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.25.12.tgz#87dfb27161202bdc958ef48bb61b09c758faee16"
+ integrity sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==
+
+"@esbuild/darwin-arm64@0.25.12":
+ version "0.25.12"
+ resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz#79197898ec1ff745d21c071e1c7cc3c802f0c1fd"
+ integrity sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==
+
+"@esbuild/darwin-x64@0.25.12":
+ version "0.25.12"
+ resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz#146400a8562133f45c4d2eadcf37ddd09718079e"
+ integrity sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==
+
+"@esbuild/freebsd-arm64@0.25.12":
+ version "0.25.12"
+ resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz#1c5f9ba7206e158fd2b24c59fa2d2c8bb47ca0fe"
+ integrity sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==
+
+"@esbuild/freebsd-x64@0.25.12":
+ version "0.25.12"
+ resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz#ea631f4a36beaac4b9279fa0fcc6ca29eaeeb2b3"
+ integrity sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==
+
+"@esbuild/linux-arm64@0.25.12":
+ version "0.25.12"
+ resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz#e1066bce58394f1b1141deec8557a5f0a22f5977"
+ integrity sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==
+
+"@esbuild/linux-arm@0.25.12":
+ version "0.25.12"
+ resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz#452cd66b20932d08bdc53a8b61c0e30baf4348b9"
+ integrity sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==
+
+"@esbuild/linux-ia32@0.25.12":
+ version "0.25.12"
+ resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz#b24f8acc45bcf54192c7f2f3be1b53e6551eafe0"
+ integrity sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==
+
+"@esbuild/linux-loong64@0.25.12":
+ version "0.25.12"
+ resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz#f9cfffa7fc8322571fbc4c8b3268caf15bd81ad0"
+ integrity sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==
+
+"@esbuild/linux-mips64el@0.25.12":
+ version "0.25.12"
+ resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz#575a14bd74644ffab891adc7d7e60d275296f2cd"
+ integrity sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==
+
+"@esbuild/linux-ppc64@0.25.12":
+ version "0.25.12"
+ resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz#75b99c70a95fbd5f7739d7692befe60601591869"
+ integrity sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==
+
+"@esbuild/linux-riscv64@0.25.12":
+ version "0.25.12"
+ resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz#2e3259440321a44e79ddf7535c325057da875cd6"
+ integrity sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==
+
+"@esbuild/linux-s390x@0.25.12":
+ version "0.25.12"
+ resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz#17676cabbfe5928da5b2a0d6df5d58cd08db2663"
+ integrity sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==
+
+"@esbuild/linux-x64@0.25.12":
+ version "0.25.12"
+ resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz#0583775685ca82066d04c3507f09524d3cd7a306"
+ integrity sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==
+
+"@esbuild/netbsd-arm64@0.25.12":
+ version "0.25.12"
+ resolved "https://registry.yarnpkg.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz#f04c4049cb2e252fe96b16fed90f70746b13f4a4"
+ integrity sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==
+
+"@esbuild/netbsd-x64@0.25.12":
+ version "0.25.12"
+ resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz#77da0d0a0d826d7c921eea3d40292548b258a076"
+ integrity sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==
+
+"@esbuild/openbsd-arm64@0.25.12":
+ version "0.25.12"
+ resolved "https://registry.yarnpkg.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz#6296f5867aedef28a81b22ab2009c786a952dccd"
+ integrity sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==
+
+"@esbuild/openbsd-x64@0.25.12":
+ version "0.25.12"
+ resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz#f8d23303360e27b16cf065b23bbff43c14142679"
+ integrity sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==
+
+"@esbuild/openharmony-arm64@0.25.12":
+ version "0.25.12"
+ resolved "https://registry.yarnpkg.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz#49e0b768744a3924be0d7fd97dd6ce9b2923d88d"
+ integrity sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==
+
+"@esbuild/sunos-x64@0.25.12":
+ version "0.25.12"
+ resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz#a6ed7d6778d67e528c81fb165b23f4911b9b13d6"
+ integrity sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==
+
+"@esbuild/win32-arm64@0.25.12":
+ version "0.25.12"
+ resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz#9ac14c378e1b653af17d08e7d3ce34caef587323"
+ integrity sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==
+
+"@esbuild/win32-ia32@0.25.12":
+ version "0.25.12"
+ resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz#918942dcbbb35cc14fca39afb91b5e6a3d127267"
+ integrity sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==
+
+"@esbuild/win32-x64@0.25.12":
+ version "0.25.12"
+ resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz#9bdad8176be7811ad148d1f8772359041f46c6c5"
+ integrity sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==
+
+"@expressive-code/core@^0.40.2":
+ version "0.40.2"
+ resolved "https://registry.yarnpkg.com/@expressive-code/core/-/core-0.40.2.tgz#4bf2ffd3879e7adc6177bf240182d153b68b5c6d"
+ integrity sha512-gXY3v7jbgz6nWKvRpoDxK4AHUPkZRuJsM79vHX/5uhV9/qX6Qnctp/U/dMHog/LCVXcuOps+5nRmf1uxQVPb3w==
+ dependencies:
+ "@ctrl/tinycolor" "^4.0.4"
+ hast-util-select "^6.0.2"
+ hast-util-to-html "^9.0.1"
+ hast-util-to-text "^4.0.1"
+ hastscript "^9.0.0"
+ postcss "^8.4.38"
+ postcss-nested "^6.0.1"
+ unist-util-visit "^5.0.0"
+ unist-util-visit-parents "^6.0.1"
+
+"@expressive-code/plugin-frames@^0.40.2":
+ version "0.40.2"
+ resolved "https://registry.yarnpkg.com/@expressive-code/plugin-frames/-/plugin-frames-0.40.2.tgz#8df2936519de7fde4d70e221b72199fdf038887c"
+ integrity sha512-aLw5IlDlZWb10Jo/TTDCVsmJhKfZ7FJI83Zo9VDrV0OBlmHAg7klZqw68VDz7FlftIBVAmMby53/MNXPnMjTSQ==
+ dependencies:
+ "@expressive-code/core" "^0.40.2"
+
+"@expressive-code/plugin-shiki@^0.40.2":
+ version "0.40.2"
+ resolved "https://registry.yarnpkg.com/@expressive-code/plugin-shiki/-/plugin-shiki-0.40.2.tgz#afeff8b98b24cf3b79ec1839825b804fc825bd44"
+ integrity sha512-t2HMR5BO6GdDW1c1ISBTk66xO503e/Z8ecZdNcr6E4NpUfvY+MRje+LtrcvbBqMwWBBO8RpVKcam/Uy+1GxwKQ==
+ dependencies:
+ "@expressive-code/core" "^0.40.2"
+ shiki "^1.26.1"
+
+"@expressive-code/plugin-text-markers@^0.40.2":
+ version "0.40.2"
+ resolved "https://registry.yarnpkg.com/@expressive-code/plugin-text-markers/-/plugin-text-markers-0.40.2.tgz#62b9f267634f5574306985b0d73f41da4a3b8395"
+ integrity sha512-/XoLjD67K9nfM4TgDlXAExzMJp6ewFKxNpfUw4F7q5Ecy+IU3/9zQQG/O70Zy+RxYTwKGw2MA9kd7yelsxnSmw==
+ dependencies:
+ "@expressive-code/core" "^0.40.2"
+
+"@img/colour@^1.0.0":
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/@img/colour/-/colour-1.0.0.tgz#d2fabb223455a793bf3bf9c70de3d28526aa8311"
+ integrity sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==
+
+"@img/sharp-darwin-arm64@0.33.5":
+ version "0.33.5"
+ resolved "https://registry.yarnpkg.com/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz#ef5b5a07862805f1e8145a377c8ba6e98813ca08"
+ integrity sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==
+ optionalDependencies:
+ "@img/sharp-libvips-darwin-arm64" "1.0.4"
+
+"@img/sharp-darwin-arm64@0.34.5":
+ version "0.34.5"
+ resolved "https://registry.yarnpkg.com/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz#6e0732dcade126b6670af7aa17060b926835ea86"
+ integrity sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==
+ optionalDependencies:
+ "@img/sharp-libvips-darwin-arm64" "1.2.4"
+
+"@img/sharp-darwin-x64@0.33.5":
+ version "0.33.5"
+ resolved "https://registry.yarnpkg.com/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz#e03d3451cd9e664faa72948cc70a403ea4063d61"
+ integrity sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==
+ optionalDependencies:
+ "@img/sharp-libvips-darwin-x64" "1.0.4"
+
+"@img/sharp-darwin-x64@0.34.5":
+ version "0.34.5"
+ resolved "https://registry.yarnpkg.com/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz#19bc1dd6eba6d5a96283498b9c9f401180ee9c7b"
+ integrity sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==
+ optionalDependencies:
+ "@img/sharp-libvips-darwin-x64" "1.2.4"
+
+"@img/sharp-libvips-darwin-arm64@1.0.4":
+ version "1.0.4"
+ resolved "https://registry.yarnpkg.com/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz#447c5026700c01a993c7804eb8af5f6e9868c07f"
+ integrity sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==
+
+"@img/sharp-libvips-darwin-arm64@1.2.4":
+ version "1.2.4"
+ resolved "https://registry.yarnpkg.com/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz#2894c0cb87d42276c3889942e8e2db517a492c43"
+ integrity sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==
+
+"@img/sharp-libvips-darwin-x64@1.0.4":
+ version "1.0.4"
+ resolved "https://registry.yarnpkg.com/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz#e0456f8f7c623f9dbfbdc77383caa72281d86062"
+ integrity sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==
+
+"@img/sharp-libvips-darwin-x64@1.2.4":
+ version "1.2.4"
+ resolved "https://registry.yarnpkg.com/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz#e63681f4539a94af9cd17246ed8881734386f8cc"
+ integrity sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==
+
+"@img/sharp-libvips-linux-arm64@1.0.4":
+ version "1.0.4"
+ resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz#979b1c66c9a91f7ff2893556ef267f90ebe51704"
+ integrity sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==
+
+"@img/sharp-libvips-linux-arm64@1.2.4":
+ version "1.2.4"
+ resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz#b1b288b36864b3bce545ad91fa6dadcf1a4ad318"
+ integrity sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==
+
+"@img/sharp-libvips-linux-arm@1.0.5":
+ version "1.0.5"
+ resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz#99f922d4e15216ec205dcb6891b721bfd2884197"
+ integrity sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==
+
+"@img/sharp-libvips-linux-arm@1.2.4":
+ version "1.2.4"
+ resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz#b9260dd1ebe6f9e3bdbcbdcac9d2ac125f35852d"
+ integrity sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==
+
+"@img/sharp-libvips-linux-ppc64@1.2.4":
+ version "1.2.4"
+ resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz#4b83ecf2a829057222b38848c7b022e7b4d07aa7"
+ integrity sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==
+
+"@img/sharp-libvips-linux-riscv64@1.2.4":
+ version "1.2.4"
+ resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz#880b4678009e5a2080af192332b00b0aaf8a48de"
+ integrity sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==
+
+"@img/sharp-libvips-linux-s390x@1.0.4":
+ version "1.0.4"
+ resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.4.tgz#f8a5eb1f374a082f72b3f45e2fb25b8118a8a5ce"
+ integrity sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==
+
+"@img/sharp-libvips-linux-s390x@1.2.4":
+ version "1.2.4"
+ resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz#74f343c8e10fad821b38f75ced30488939dc59ec"
+ integrity sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==
+
+"@img/sharp-libvips-linux-x64@1.0.4":
+ version "1.0.4"
+ resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz#d4c4619cdd157774906e15770ee119931c7ef5e0"
+ integrity sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==
+
+"@img/sharp-libvips-linux-x64@1.2.4":
+ version "1.2.4"
+ resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz#df4183e8bd8410f7d61b66859a35edeab0a531ce"
+ integrity sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==
+
+"@img/sharp-libvips-linuxmusl-arm64@1.0.4":
+ version "1.0.4"
+ resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz#166778da0f48dd2bded1fa3033cee6b588f0d5d5"
+ integrity sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==
+
+"@img/sharp-libvips-linuxmusl-arm64@1.2.4":
+ version "1.2.4"
+ resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz#c8d6b48211df67137541007ee8d1b7b1f8ca8e06"
+ integrity sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==
+
+"@img/sharp-libvips-linuxmusl-x64@1.0.4":
+ version "1.0.4"
+ resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz#93794e4d7720b077fcad3e02982f2f1c246751ff"
+ integrity sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==
+
+"@img/sharp-libvips-linuxmusl-x64@1.2.4":
+ version "1.2.4"
+ resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz#be11c75bee5b080cbee31a153a8779448f919f75"
+ integrity sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==
+
+"@img/sharp-linux-arm64@0.33.5":
+ version "0.33.5"
+ resolved "https://registry.yarnpkg.com/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz#edb0697e7a8279c9fc829a60fc35644c4839bb22"
+ integrity sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==
+ optionalDependencies:
+ "@img/sharp-libvips-linux-arm64" "1.0.4"
+
+"@img/sharp-linux-arm64@0.34.5":
+ version "0.34.5"
+ resolved "https://registry.yarnpkg.com/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz#7aa7764ef9c001f15e610546d42fce56911790cc"
+ integrity sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==
+ optionalDependencies:
+ "@img/sharp-libvips-linux-arm64" "1.2.4"
+
+"@img/sharp-linux-arm@0.33.5":
+ version "0.33.5"
+ resolved "https://registry.yarnpkg.com/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz#422c1a352e7b5832842577dc51602bcd5b6f5eff"
+ integrity sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==
+ optionalDependencies:
+ "@img/sharp-libvips-linux-arm" "1.0.5"
+
+"@img/sharp-linux-arm@0.34.5":
+ version "0.34.5"
+ resolved "https://registry.yarnpkg.com/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz#5fb0c3695dd12522d39c3ff7a6bc816461780a0d"
+ integrity sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==
+ optionalDependencies:
+ "@img/sharp-libvips-linux-arm" "1.2.4"
+
+"@img/sharp-linux-ppc64@0.34.5":
+ version "0.34.5"
+ resolved "https://registry.yarnpkg.com/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz#9c213a81520a20caf66978f3d4c07456ff2e0813"
+ integrity sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==
+ optionalDependencies:
+ "@img/sharp-libvips-linux-ppc64" "1.2.4"
+
+"@img/sharp-linux-riscv64@0.34.5":
+ version "0.34.5"
+ resolved "https://registry.yarnpkg.com/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz#cdd28182774eadbe04f62675a16aabbccb833f60"
+ integrity sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==
+ optionalDependencies:
+ "@img/sharp-libvips-linux-riscv64" "1.2.4"
+
+"@img/sharp-linux-s390x@0.33.5":
+ version "0.33.5"
+ resolved "https://registry.yarnpkg.com/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.5.tgz#f5c077926b48e97e4a04d004dfaf175972059667"
+ integrity sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==
+ optionalDependencies:
+ "@img/sharp-libvips-linux-s390x" "1.0.4"
+
+"@img/sharp-linux-s390x@0.34.5":
+ version "0.34.5"
+ resolved "https://registry.yarnpkg.com/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz#93eac601b9f329bb27917e0e19098c722d630df7"
+ integrity sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==
+ optionalDependencies:
+ "@img/sharp-libvips-linux-s390x" "1.2.4"
+
+"@img/sharp-linux-x64@0.33.5":
+ version "0.33.5"
+ resolved "https://registry.yarnpkg.com/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz#d806e0afd71ae6775cc87f0da8f2d03a7c2209cb"
+ integrity sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==
+ optionalDependencies:
+ "@img/sharp-libvips-linux-x64" "1.0.4"
+
+"@img/sharp-linux-x64@0.34.5":
+ version "0.34.5"
+ resolved "https://registry.yarnpkg.com/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz#55abc7cd754ffca5002b6c2b719abdfc846819a8"
+ integrity sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==
+ optionalDependencies:
+ "@img/sharp-libvips-linux-x64" "1.2.4"
+
+"@img/sharp-linuxmusl-arm64@0.33.5":
+ version "0.33.5"
+ resolved "https://registry.yarnpkg.com/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz#252975b915894fb315af5deea174651e208d3d6b"
+ integrity sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==
+ optionalDependencies:
+ "@img/sharp-libvips-linuxmusl-arm64" "1.0.4"
+
+"@img/sharp-linuxmusl-arm64@0.34.5":
+ version "0.34.5"
+ resolved "https://registry.yarnpkg.com/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz#d6515ee971bb62f73001a4829b9d865a11b77086"
+ integrity sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==
+ optionalDependencies:
+ "@img/sharp-libvips-linuxmusl-arm64" "1.2.4"
+
+"@img/sharp-linuxmusl-x64@0.33.5":
+ version "0.33.5"
+ resolved "https://registry.yarnpkg.com/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz#3f4609ac5d8ef8ec7dadee80b560961a60fd4f48"
+ integrity sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==
+ optionalDependencies:
+ "@img/sharp-libvips-linuxmusl-x64" "1.0.4"
+
+"@img/sharp-linuxmusl-x64@0.34.5":
+ version "0.34.5"
+ resolved "https://registry.yarnpkg.com/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz#d97978aec7c5212f999714f2f5b736457e12ee9f"
+ integrity sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==
+ optionalDependencies:
+ "@img/sharp-libvips-linuxmusl-x64" "1.2.4"
+
+"@img/sharp-wasm32@0.33.5":
+ version "0.33.5"
+ resolved "https://registry.yarnpkg.com/@img/sharp-wasm32/-/sharp-wasm32-0.33.5.tgz#6f44f3283069d935bb5ca5813153572f3e6f61a1"
+ integrity sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==
+ dependencies:
+ "@emnapi/runtime" "^1.2.0"
+
+"@img/sharp-wasm32@0.34.5":
+ version "0.34.5"
+ resolved "https://registry.yarnpkg.com/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz#2f15803aa626f8c59dd7c9d0bbc766f1ab52cfa0"
+ integrity sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==
+ dependencies:
+ "@emnapi/runtime" "^1.7.0"
+
+"@img/sharp-win32-arm64@0.34.5":
+ version "0.34.5"
+ resolved "https://registry.yarnpkg.com/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz#3706e9e3ac35fddfc1c87f94e849f1b75307ce0a"
+ integrity sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==
+
+"@img/sharp-win32-ia32@0.33.5":
+ version "0.33.5"
+ resolved "https://registry.yarnpkg.com/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.5.tgz#1a0c839a40c5351e9885628c85f2e5dfd02b52a9"
+ integrity sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==
+
+"@img/sharp-win32-ia32@0.34.5":
+ version "0.34.5"
+ resolved "https://registry.yarnpkg.com/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz#0b71166599b049e032f085fb9263e02f4e4788de"
+ integrity sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==
+
+"@img/sharp-win32-x64@0.33.5":
+ version "0.33.5"
+ resolved "https://registry.yarnpkg.com/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz#56f00962ff0c4e0eb93d34a047d29fa995e3e342"
+ integrity sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==
+
+"@img/sharp-win32-x64@0.34.5":
+ version "0.34.5"
+ resolved "https://registry.yarnpkg.com/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz#a81ffb00e69267cd0a1d626eaedb8a8430b2b2f8"
+ integrity sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==
+
+"@jridgewell/sourcemap-codec@^1.5.5":
+ version "1.5.5"
+ resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz#6912b00d2c631c0d15ce1a7ab57cd657f2a8f8ba"
+ integrity sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==
+
+"@mdx-js/mdx@^3.1.1":
+ version "3.1.1"
+ resolved "https://registry.yarnpkg.com/@mdx-js/mdx/-/mdx-3.1.1.tgz#c5ffd991a7536b149e17175eee57a1a2a511c6d1"
+ integrity sha512-f6ZO2ifpwAQIpzGWaBQT2TXxPv6z3RBzQKpVftEWN78Vl/YweF1uwussDx8ECAXVtr3Rs89fKyG9YlzUs9DyGQ==
+ dependencies:
+ "@types/estree" "^1.0.0"
+ "@types/estree-jsx" "^1.0.0"
+ "@types/hast" "^3.0.0"
+ "@types/mdx" "^2.0.0"
+ acorn "^8.0.0"
+ collapse-white-space "^2.0.0"
+ devlop "^1.0.0"
+ estree-util-is-identifier-name "^3.0.0"
+ estree-util-scope "^1.0.0"
+ estree-walker "^3.0.0"
+ hast-util-to-jsx-runtime "^2.0.0"
+ markdown-extensions "^2.0.0"
+ recma-build-jsx "^1.0.0"
+ recma-jsx "^1.0.0"
+ recma-stringify "^1.0.0"
+ rehype-recma "^1.0.0"
+ remark-mdx "^3.0.0"
+ remark-parse "^11.0.0"
+ remark-rehype "^11.0.0"
+ source-map "^0.7.0"
+ unified "^11.0.0"
+ unist-util-position-from-estree "^2.0.0"
+ unist-util-stringify-position "^4.0.0"
+ unist-util-visit "^5.0.0"
+ vfile "^6.0.0"
+
+"@oslojs/encoding@^1.1.0":
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/@oslojs/encoding/-/encoding-1.1.0.tgz#55f3d9a597430a01f2a5ef63c6b42f769f9ce34e"
+ integrity sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==
+
+"@pagefind/darwin-arm64@1.4.0":
+ version "1.4.0"
+ resolved "https://registry.yarnpkg.com/@pagefind/darwin-arm64/-/darwin-arm64-1.4.0.tgz#0315030e6a89bec3121273b1851f7aadf0b12425"
+ integrity sha512-2vMqkbv3lbx1Awea90gTaBsvpzgRs7MuSgKDxW0m9oV1GPZCZbZBJg/qL83GIUEN2BFlY46dtUZi54pwH+/pTQ==
+
+"@pagefind/darwin-x64@1.4.0":
+ version "1.4.0"
+ resolved "https://registry.yarnpkg.com/@pagefind/darwin-x64/-/darwin-x64-1.4.0.tgz#671e1fe0f0733350a3eb244ace2675166186793e"
+ integrity sha512-e7JPIS6L9/cJfow+/IAqknsGqEPjJnVXGjpGm25bnq+NPdoD3c/7fAwr1OXkG4Ocjx6ZGSCijXEV4ryMcH2E3A==
+
+"@pagefind/default-ui@^1.3.0":
+ version "1.4.0"
+ resolved "https://registry.yarnpkg.com/@pagefind/default-ui/-/default-ui-1.4.0.tgz#036017ba6ed40e9f34ff5652b9caed11113f7bcc"
+ integrity sha512-wie82VWn3cnGEdIjh4YwNESyS1G6vRHwL6cNjy9CFgNnWW/PGRjsLq300xjVH5sfPFK3iK36UxvIBymtQIEiSQ==
+
+"@pagefind/freebsd-x64@1.4.0":
+ version "1.4.0"
+ resolved "https://registry.yarnpkg.com/@pagefind/freebsd-x64/-/freebsd-x64-1.4.0.tgz#3419701ce810e7ec050bbf4786b1c3bee78ec51b"
+ integrity sha512-WcJVypXSZ+9HpiqZjFXMUobfFfZZ6NzIYtkhQ9eOhZrQpeY5uQFqNWLCk7w9RkMUwBv1HAMDW3YJQl/8OqsV0Q==
+
+"@pagefind/linux-arm64@1.4.0":
+ version "1.4.0"
+ resolved "https://registry.yarnpkg.com/@pagefind/linux-arm64/-/linux-arm64-1.4.0.tgz#ba2a5c8d10d5273fe61a8d230b546b08d22cb676"
+ integrity sha512-PIt8dkqt4W06KGmQjONw7EZbhDF+uXI7i0XtRLN1vjCUxM9vGPdtJc2mUyVPevjomrGz5M86M8bqTr6cgDp1Uw==
+
+"@pagefind/linux-x64@1.4.0":
+ version "1.4.0"
+ resolved "https://registry.yarnpkg.com/@pagefind/linux-x64/-/linux-x64-1.4.0.tgz#1e56bb3c91fd0128be84e98897c9785c489fbbb7"
+ integrity sha512-z4oddcWwQ0UHrTHR8psLnVlz6USGJ/eOlDPTDYZ4cI8TK8PgwRUPQZp9D2iJPNIPcS6Qx/E4TebjuGJOyK8Mmg==
+
+"@pagefind/windows-x64@1.4.0":
+ version "1.4.0"
+ resolved "https://registry.yarnpkg.com/@pagefind/windows-x64/-/windows-x64-1.4.0.tgz#ba68fd609621132e8e314a89e2d2d52516f61723"
+ integrity sha512-NkT+YAdgS2FPCn8mIA9bQhiBs+xmniMGq1LFPDhcFn0+2yIUEiIG06t7bsZlhdjknEQRTSdT7YitP6fC5qwP0g==
+
+"@rollup/pluginutils@^5.3.0":
+ version "5.3.0"
+ resolved "https://registry.yarnpkg.com/@rollup/pluginutils/-/pluginutils-5.3.0.tgz#57ba1b0cbda8e7a3c597a4853c807b156e21a7b4"
+ integrity sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==
+ dependencies:
+ "@types/estree" "^1.0.0"
+ estree-walker "^2.0.2"
+ picomatch "^4.0.2"
+
+"@rollup/rollup-android-arm-eabi@4.54.0":
+ version "4.54.0"
+ resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.54.0.tgz#f3ff5dbde305c4fa994d49aeb0a5db5305eff03b"
+ integrity sha512-OywsdRHrFvCdvsewAInDKCNyR3laPA2mc9bRYJ6LBp5IyvF3fvXbbNR0bSzHlZVFtn6E0xw2oZlyjg4rKCVcng==
+
+"@rollup/rollup-android-arm64@4.54.0":
+ version "4.54.0"
+ resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.54.0.tgz#c97d6ee47846a7ab1cd38e968adce25444a90a19"
+ integrity sha512-Skx39Uv+u7H224Af+bDgNinitlmHyQX1K/atIA32JP3JQw6hVODX5tkbi2zof/E69M1qH2UoN3Xdxgs90mmNYw==
+
+"@rollup/rollup-darwin-arm64@4.54.0":
+ version "4.54.0"
+ resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.54.0.tgz#a13fc2d82e01eaf8ac823634a3f5f76fd9d0f938"
+ integrity sha512-k43D4qta/+6Fq+nCDhhv9yP2HdeKeP56QrUUTW7E6PhZP1US6NDqpJj4MY0jBHlJivVJD5P8NxrjuobZBJTCRw==
+
+"@rollup/rollup-darwin-x64@4.54.0":
+ version "4.54.0"
+ resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.54.0.tgz#db4fa8b2b76d86f7e9b68ce4661fafe9767adf9b"
+ integrity sha512-cOo7biqwkpawslEfox5Vs8/qj83M/aZCSSNIWpVzfU2CYHa2G3P1UN5WF01RdTHSgCkri7XOlTdtk17BezlV3A==
+
+"@rollup/rollup-freebsd-arm64@4.54.0":
+ version "4.54.0"
+ resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.54.0.tgz#b2c6039de4b75efd3f29417fcb1a795c75a4e3ee"
+ integrity sha512-miSvuFkmvFbgJ1BevMa4CPCFt5MPGw094knM64W9I0giUIMMmRYcGW/JWZDriaw/k1kOBtsWh1z6nIFV1vPNtA==
+
+"@rollup/rollup-freebsd-x64@4.54.0":
+ version "4.54.0"
+ resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.54.0.tgz#9ae2a216c94f87912a596a3b3a2ec5199a689ba5"
+ integrity sha512-KGXIs55+b/ZfZsq9aR026tmr/+7tq6VG6MsnrvF4H8VhwflTIuYh+LFUlIsRdQSgrgmtM3fVATzEAj4hBQlaqQ==
+
+"@rollup/rollup-linux-arm-gnueabihf@4.54.0":
+ version "4.54.0"
+ resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.54.0.tgz#69d5de7f781132f138514f2b900c523e38e2461f"
+ integrity sha512-EHMUcDwhtdRGlXZsGSIuXSYwD5kOT9NVnx9sqzYiwAc91wfYOE1g1djOEDseZJKKqtHAHGwnGPQu3kytmfaXLQ==
+
+"@rollup/rollup-linux-arm-musleabihf@4.54.0":
+ version "4.54.0"
+ resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.54.0.tgz#b6431e5699747f285306ffe8c1194d7af74f801f"
+ integrity sha512-+pBrqEjaakN2ySv5RVrj/qLytYhPKEUwk+e3SFU5jTLHIcAtqh2rLrd/OkbNuHJpsBgxsD8ccJt5ga/SeG0JmA==
+
+"@rollup/rollup-linux-arm64-gnu@4.54.0":
+ version "4.54.0"
+ resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.54.0.tgz#a32931baec8a0fa7b3288afb72d400ae735112c2"
+ integrity sha512-NSqc7rE9wuUaRBsBp5ckQ5CVz5aIRKCwsoa6WMF7G01sX3/qHUw/z4pv+D+ahL1EIKy6Enpcnz1RY8pf7bjwng==
+
+"@rollup/rollup-linux-arm64-musl@4.54.0":
+ version "4.54.0"
+ resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.54.0.tgz#0ad72572b01eb946c0b1a7a6f17ab3be6689a963"
+ integrity sha512-gr5vDbg3Bakga5kbdpqx81m2n9IX8M6gIMlQQIXiLTNeQW6CucvuInJ91EuCJ/JYvc+rcLLsDFcfAD1K7fMofg==
+
+"@rollup/rollup-linux-loong64-gnu@4.54.0":
+ version "4.54.0"
+ resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.54.0.tgz#05681f000310906512279944b5bef38c0cd4d326"
+ integrity sha512-gsrtB1NA3ZYj2vq0Rzkylo9ylCtW/PhpLEivlgWe0bpgtX5+9j9EZa0wtZiCjgu6zmSeZWyI/e2YRX1URozpIw==
+
+"@rollup/rollup-linux-ppc64-gnu@4.54.0":
+ version "4.54.0"
+ resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.54.0.tgz#9847a8c9dd76d687c3bdbe38d7f5f32c6b2743c8"
+ integrity sha512-y3qNOfTBStmFNq+t4s7Tmc9hW2ENtPg8FeUD/VShI7rKxNW7O4fFeaYbMsd3tpFlIg1Q8IapFgy7Q9i2BqeBvA==
+
+"@rollup/rollup-linux-riscv64-gnu@4.54.0":
+ version "4.54.0"
+ resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.54.0.tgz#173f20c278ac770ae3e969663a27d172a4545e87"
+ integrity sha512-89sepv7h2lIVPsFma8iwmccN7Yjjtgz0Rj/Ou6fEqg3HDhpCa+Et+YSufy27i6b0Wav69Qv4WBNl3Rs6pwhebQ==
+
+"@rollup/rollup-linux-riscv64-musl@4.54.0":
+ version "4.54.0"
+ resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.54.0.tgz#db70c2377ae1ef61ef8673354d107ecb3fa7ffed"
+ integrity sha512-ZcU77ieh0M2Q8Ur7D5X7KvK+UxbXeDHwiOt/CPSBTI1fBmeDMivW0dPkdqkT4rOgDjrDDBUed9x4EgraIKoR2A==
+
+"@rollup/rollup-linux-s390x-gnu@4.54.0":
+ version "4.54.0"
+ resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.54.0.tgz#b2c461778add1c2ee70ec07d1788611548647962"
+ integrity sha512-2AdWy5RdDF5+4YfG/YesGDDtbyJlC9LHmL6rZw6FurBJ5n4vFGupsOBGfwMRjBYH7qRQowT8D/U4LoSvVwOhSQ==
+
+"@rollup/rollup-linux-x64-gnu@4.54.0":
+ version "4.54.0"
+ resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.54.0.tgz#ab140b356569601f57ab8727bd7306463841894f"
+ integrity sha512-WGt5J8Ij/rvyqpFexxk3ffKqqbLf9AqrTBbWDk7ApGUzaIs6V+s2s84kAxklFwmMF/vBNGrVdYgbblCOFFezMQ==
+
+"@rollup/rollup-linux-x64-musl@4.54.0":
+ version "4.54.0"
+ resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.54.0.tgz#810134b4a9d0d88576938f2eed38999a653814a1"
+ integrity sha512-JzQmb38ATzHjxlPHuTH6tE7ojnMKM2kYNzt44LO/jJi8BpceEC8QuXYA908n8r3CNuG/B3BV8VR3Hi1rYtmPiw==
+
+"@rollup/rollup-openharmony-arm64@4.54.0":
+ version "4.54.0"
+ resolved "https://registry.yarnpkg.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.54.0.tgz#0182bae7a54e748be806acef7a7f726f6949213c"
+ integrity sha512-huT3fd0iC7jigGh7n3q/+lfPcXxBi+om/Rs3yiFxjvSxbSB6aohDFXbWvlspaqjeOh+hx7DDHS+5Es5qRkWkZg==
+
+"@rollup/rollup-win32-arm64-msvc@4.54.0":
+ version "4.54.0"
+ resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.54.0.tgz#1f19349bd1c5e454d03e4508a9277b6354985b9d"
+ integrity sha512-c2V0W1bsKIKfbLMBu/WGBz6Yci8nJ/ZJdheE0EwB73N3MvHYKiKGs3mVilX4Gs70eGeDaMqEob25Tw2Gb9Nqyw==
+
+"@rollup/rollup-win32-ia32-msvc@4.54.0":
+ version "4.54.0"
+ resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.54.0.tgz#234ff739993539f64efac6c2e59704a691a309c2"
+ integrity sha512-woEHgqQqDCkAzrDhvDipnSirm5vxUXtSKDYTVpZG3nUdW/VVB5VdCYA2iReSj/u3yCZzXID4kuKG7OynPnB3WQ==
+
+"@rollup/rollup-win32-x64-gnu@4.54.0":
+ version "4.54.0"
+ resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.54.0.tgz#a4df0507c3be09c152a795cfc0c4f0c225765c5c"
+ integrity sha512-dzAc53LOuFvHwbCEOS0rPbXp6SIhAf2txMP5p6mGyOXXw5mWY8NGGbPMPrs4P1WItkfApDathBj/NzMLUZ9rtQ==
+
+"@rollup/rollup-win32-x64-msvc@4.54.0":
+ version "4.54.0"
+ resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.54.0.tgz#beacb356412eef5dc0164e9edfee51c563732054"
+ integrity sha512-hYT5d3YNdSh3mbCU1gwQyPgQd3T2ne0A3KG8KSBdav5TiBg6eInVmV+TeR5uHufiIgSFg0XsOWGW5/RhNcSvPg==
+
+"@shikijs/core@1.29.2":
+ version "1.29.2"
+ resolved "https://registry.yarnpkg.com/@shikijs/core/-/core-1.29.2.tgz#9c051d3ac99dd06ae46bd96536380c916e552bf3"
+ integrity sha512-vju0lY9r27jJfOY4Z7+Rt/nIOjzJpZ3y+nYpqtUZInVoXQ/TJZcfGnNOGnKjFdVZb8qexiCuSlZRKcGfhhTTZQ==
+ dependencies:
+ "@shikijs/engine-javascript" "1.29.2"
+ "@shikijs/engine-oniguruma" "1.29.2"
+ "@shikijs/types" "1.29.2"
+ "@shikijs/vscode-textmate" "^10.0.1"
+ "@types/hast" "^3.0.4"
+ hast-util-to-html "^9.0.4"
+
+"@shikijs/core@3.20.0":
+ version "3.20.0"
+ resolved "https://registry.yarnpkg.com/@shikijs/core/-/core-3.20.0.tgz#ccb9f687de1a236247d8f306cc193dde35f51688"
+ integrity sha512-f2ED7HYV4JEk827mtMDwe/yQ25pRiXZmtHjWF8uzZKuKiEsJR7Ce1nuQ+HhV9FzDcbIo4ObBCD9GPTzNuy9S1g==
+ dependencies:
+ "@shikijs/types" "3.20.0"
+ "@shikijs/vscode-textmate" "^10.0.2"
+ "@types/hast" "^3.0.4"
+ hast-util-to-html "^9.0.5"
+
+"@shikijs/engine-javascript@1.29.2":
+ version "1.29.2"
+ resolved "https://registry.yarnpkg.com/@shikijs/engine-javascript/-/engine-javascript-1.29.2.tgz#a821ad713a3e0b7798a1926fd9e80116e38a1d64"
+ integrity sha512-iNEZv4IrLYPv64Q6k7EPpOCE/nuvGiKl7zxdq0WFuRPF5PAE9PRo2JGq/d8crLusM59BRemJ4eOqrFrC4wiQ+A==
+ dependencies:
+ "@shikijs/types" "1.29.2"
+ "@shikijs/vscode-textmate" "^10.0.1"
+ oniguruma-to-es "^2.2.0"
+
+"@shikijs/engine-javascript@3.20.0":
+ version "3.20.0"
+ resolved "https://registry.yarnpkg.com/@shikijs/engine-javascript/-/engine-javascript-3.20.0.tgz#b0a40ea401b2dc167b14ed924979081c7f920650"
+ integrity sha512-OFx8fHAZuk7I42Z9YAdZ95To6jDePQ9Rnfbw9uSRTSbBhYBp1kEOKv/3jOimcj3VRUKusDYM6DswLauwfhboLg==
+ dependencies:
+ "@shikijs/types" "3.20.0"
+ "@shikijs/vscode-textmate" "^10.0.2"
+ oniguruma-to-es "^4.3.4"
+
+"@shikijs/engine-oniguruma@1.29.2":
+ version "1.29.2"
+ resolved "https://registry.yarnpkg.com/@shikijs/engine-oniguruma/-/engine-oniguruma-1.29.2.tgz#d879717ced61d44e78feab16f701f6edd75434f1"
+ integrity sha512-7iiOx3SG8+g1MnlzZVDYiaeHe7Ez2Kf2HrJzdmGwkRisT7r4rak0e655AcM/tF9JG/kg5fMNYlLLKglbN7gBqA==
+ dependencies:
+ "@shikijs/types" "1.29.2"
+ "@shikijs/vscode-textmate" "^10.0.1"
+
+"@shikijs/engine-oniguruma@3.20.0":
+ version "3.20.0"
+ resolved "https://registry.yarnpkg.com/@shikijs/engine-oniguruma/-/engine-oniguruma-3.20.0.tgz#4b476a8dff29561dfd9af1ba2edb4c378d3bee06"
+ integrity sha512-Yx3gy7xLzM0ZOjqoxciHjA7dAt5tyzJE3L4uQoM83agahy+PlW244XJSrmJRSBvGYELDhYXPacD4R/cauV5bzQ==
+ dependencies:
+ "@shikijs/types" "3.20.0"
+ "@shikijs/vscode-textmate" "^10.0.2"
+
+"@shikijs/langs@1.29.2":
+ version "1.29.2"
+ resolved "https://registry.yarnpkg.com/@shikijs/langs/-/langs-1.29.2.tgz#4f1de46fde8991468c5a68fa4a67dd2875d643cd"
+ integrity sha512-FIBA7N3LZ+223U7cJDUYd5shmciFQlYkFXlkKVaHsCPgfVLiO+e12FmQE6Tf9vuyEsFe3dIl8qGWKXgEHL9wmQ==
+ dependencies:
+ "@shikijs/types" "1.29.2"
+
+"@shikijs/langs@3.20.0":
+ version "3.20.0"
+ resolved "https://registry.yarnpkg.com/@shikijs/langs/-/langs-3.20.0.tgz#5dcfdeb9eb2d5f811144ca606553a4d8a6a667d5"
+ integrity sha512-le+bssCxcSHrygCWuOrYJHvjus6zhQ2K7q/0mgjiffRbkhM4o1EWu2m+29l0yEsHDbWaWPNnDUTRVVBvBBeKaA==
+ dependencies:
+ "@shikijs/types" "3.20.0"
+
+"@shikijs/themes@1.29.2":
+ version "1.29.2"
+ resolved "https://registry.yarnpkg.com/@shikijs/themes/-/themes-1.29.2.tgz#293cc5c83dd7df3fdc8efa25cec8223f3a6acb0d"
+ integrity sha512-i9TNZlsq4uoyqSbluIcZkmPL9Bfi3djVxRnofUHwvx/h6SRW3cwgBC5SML7vsDcWyukY0eCzVN980rqP6qNl9g==
+ dependencies:
+ "@shikijs/types" "1.29.2"
+
+"@shikijs/themes@3.20.0":
+ version "3.20.0"
+ resolved "https://registry.yarnpkg.com/@shikijs/themes/-/themes-3.20.0.tgz#9b030fe81fcd0a8b7941131ef14c274b4c6451a8"
+ integrity sha512-U1NSU7Sl26Q7ErRvJUouArxfM2euWqq1xaSrbqMu2iqa+tSp0D1Yah8216sDYbdDHw4C8b75UpE65eWorm2erQ==
+ dependencies:
+ "@shikijs/types" "3.20.0"
+
+"@shikijs/types@1.29.2":
+ version "1.29.2"
+ resolved "https://registry.yarnpkg.com/@shikijs/types/-/types-1.29.2.tgz#a93fdb410d1af8360c67bf5fc1d1a68d58e21c4f"
+ integrity sha512-VJjK0eIijTZf0QSTODEXCqinjBn0joAHQ+aPSBzrv4O2d/QSbsMw+ZeSRx03kV34Hy7NzUvV/7NqfYGRLrASmw==
+ dependencies:
+ "@shikijs/vscode-textmate" "^10.0.1"
+ "@types/hast" "^3.0.4"
+
+"@shikijs/types@3.20.0":
+ version "3.20.0"
+ resolved "https://registry.yarnpkg.com/@shikijs/types/-/types-3.20.0.tgz#b1fbacba2e1e38d31e3f869309fff216a5d27126"
+ integrity sha512-lhYAATn10nkZcBQ0BlzSbJA3wcmL5MXUUF8d2Zzon6saZDlToKaiRX60n2+ZaHJCmXEcZRWNzn+k9vplr8Jhsw==
+ dependencies:
+ "@shikijs/vscode-textmate" "^10.0.2"
+ "@types/hast" "^3.0.4"
+
+"@shikijs/vscode-textmate@^10.0.1", "@shikijs/vscode-textmate@^10.0.2":
+ version "10.0.2"
+ resolved "https://registry.yarnpkg.com/@shikijs/vscode-textmate/-/vscode-textmate-10.0.2.tgz#a90ab31d0cc1dfb54c66a69e515bf624fa7b2224"
+ integrity sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==
+
+"@swc/helpers@^0.5.12":
+ version "0.5.17"
+ resolved "https://registry.yarnpkg.com/@swc/helpers/-/helpers-0.5.17.tgz#5a7be95ac0f0bf186e7e6e890e7a6f6cda6ce971"
+ integrity sha512-5IKx/Y13RsYd+sauPb2x+U/xZikHjolzfuDgTAl/Tdf3Q8rslRvC19NKDLgAJQ6wsqADk10ntlv08nPFw/gO/A==
+ dependencies:
+ tslib "^2.8.0"
+
+"@types/debug@^4.0.0":
+ version "4.1.12"
+ resolved "https://registry.yarnpkg.com/@types/debug/-/debug-4.1.12.tgz#a155f21690871953410df4b6b6f53187f0500917"
+ integrity sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==
+ dependencies:
+ "@types/ms" "*"
+
+"@types/estree-jsx@^1.0.0":
+ version "1.0.5"
+ resolved "https://registry.yarnpkg.com/@types/estree-jsx/-/estree-jsx-1.0.5.tgz#858a88ea20f34fe65111f005a689fa1ebf70dc18"
+ integrity sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==
+ dependencies:
+ "@types/estree" "*"
+
+"@types/estree@*", "@types/estree@1.0.8", "@types/estree@^1.0.0":
+ version "1.0.8"
+ resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.8.tgz#958b91c991b1867ced318bedea0e215ee050726e"
+ integrity sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==
+
+"@types/fontkit@^2.0.8":
+ version "2.0.8"
+ resolved "https://registry.yarnpkg.com/@types/fontkit/-/fontkit-2.0.8.tgz#59725be650e68acbbff6df9f3fccbd54d9ef7f4c"
+ integrity sha512-wN+8bYxIpJf+5oZdrdtaX04qUuWHcKxcDEgRS9Qm9ZClSHjzEn13SxUC+5eRM+4yXIeTYk8mTzLAWGF64847ew==
+ dependencies:
+ "@types/node" "*"
+
+"@types/hast@^3.0.0", "@types/hast@^3.0.4":
+ version "3.0.4"
+ resolved "https://registry.yarnpkg.com/@types/hast/-/hast-3.0.4.tgz#1d6b39993b82cea6ad783945b0508c25903e15aa"
+ integrity sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==
+ dependencies:
+ "@types/unist" "*"
+
+"@types/js-yaml@^4.0.9":
+ version "4.0.9"
+ resolved "https://registry.yarnpkg.com/@types/js-yaml/-/js-yaml-4.0.9.tgz#cd82382c4f902fed9691a2ed79ec68c5898af4c2"
+ integrity sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==
+
+"@types/mdast@^4.0.0", "@types/mdast@^4.0.4":
+ version "4.0.4"
+ resolved "https://registry.yarnpkg.com/@types/mdast/-/mdast-4.0.4.tgz#7ccf72edd2f1aa7dd3437e180c64373585804dd6"
+ integrity sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==
+ dependencies:
+ "@types/unist" "*"
+
+"@types/mdx@^2.0.0":
+ version "2.0.13"
+ resolved "https://registry.yarnpkg.com/@types/mdx/-/mdx-2.0.13.tgz#68f6877043d377092890ff5b298152b0a21671bd"
+ integrity sha512-+OWZQfAYyio6YkJb3HLxDrvnx6SWWDbC0zVPfBRzUk0/nqoDyf6dNxQi3eArPe8rJ473nobTMQ/8Zk+LxJ+Yuw==
+
+"@types/ms@*":
+ version "2.1.0"
+ resolved "https://registry.yarnpkg.com/@types/ms/-/ms-2.1.0.tgz#052aa67a48eccc4309d7f0191b7e41434b90bb78"
+ integrity sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==
+
+"@types/nlcst@^2.0.0":
+ version "2.0.3"
+ resolved "https://registry.yarnpkg.com/@types/nlcst/-/nlcst-2.0.3.tgz#31cad346eaab48a9a8a58465d3d05e2530dda762"
+ integrity sha512-vSYNSDe6Ix3q+6Z7ri9lyWqgGhJTmzRjZRqyq15N0Z/1/UnVsno9G/N40NBijoYx2seFDIl0+B2mgAb9mezUCA==
+ dependencies:
+ "@types/unist" "*"
+
+"@types/node@*":
+ version "25.0.3"
+ resolved "https://registry.yarnpkg.com/@types/node/-/node-25.0.3.tgz#79b9ac8318f373fbfaaf6e2784893efa9701f269"
+ integrity sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA==
+ dependencies:
+ undici-types "~7.16.0"
+
+"@types/node@^17.0.5":
+ version "17.0.45"
+ resolved "https://registry.yarnpkg.com/@types/node/-/node-17.0.45.tgz#2c0fafd78705e7a18b7906b5201a522719dc5190"
+ integrity sha512-w+tIMs3rq2afQdsPJlODhoUEKzFP1ayaoyl1CcnwtIlsVe7K7bA1NGm4s3PraqTLlXnbIN84zuBlxBWo1u9BLw==
+
+"@types/sax@^1.2.1":
+ version "1.2.7"
+ resolved "https://registry.yarnpkg.com/@types/sax/-/sax-1.2.7.tgz#ba5fe7df9aa9c89b6dff7688a19023dd2963091d"
+ integrity sha512-rO73L89PJxeYM3s3pPPjiPgVVcymqU490g0YO5n5By0k2Erzj6tay/4lr1CHAAU4JyOWd1rpQ8bCf6cZfHU96A==
+ dependencies:
+ "@types/node" "*"
+
+"@types/unist@*", "@types/unist@^3.0.0":
+ version "3.0.3"
+ resolved "https://registry.yarnpkg.com/@types/unist/-/unist-3.0.3.tgz#acaab0f919ce69cce629c2d4ed2eb4adc1b6c20c"
+ integrity sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==
+
+"@types/unist@^2.0.0":
+ version "2.0.11"
+ resolved "https://registry.yarnpkg.com/@types/unist/-/unist-2.0.11.tgz#11af57b127e32487774841f7a4e54eab166d03c4"
+ integrity sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==
+
+"@ungap/structured-clone@^1.0.0":
+ version "1.3.0"
+ resolved "https://registry.yarnpkg.com/@ungap/structured-clone/-/structured-clone-1.3.0.tgz#d06bbb384ebcf6c505fde1c3d0ed4ddffe0aaff8"
+ integrity sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==
+
+acorn-jsx@^5.0.0:
+ version "5.3.2"
+ resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz#7ed5bb55908b3b2f1bc55c6af1653bada7f07937"
+ integrity sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==
+
+acorn@^8.0.0, acorn@^8.15.0:
+ version "8.15.0"
+ resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.15.0.tgz#a360898bc415edaac46c8241f6383975b930b816"
+ integrity sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==
+
+ansi-align@^3.0.1:
+ version "3.0.1"
+ resolved "https://registry.yarnpkg.com/ansi-align/-/ansi-align-3.0.1.tgz#0cdf12e111ace773a86e9a1fad1225c43cb19a59"
+ integrity sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w==
+ dependencies:
+ string-width "^4.1.0"
+
+ansi-regex@^5.0.1:
+ version "5.0.1"
+ resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.1.tgz#082cb2c89c9fe8659a311a53bd6a4dc5301db304"
+ integrity sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==
+
+ansi-regex@^6.0.1:
+ version "6.2.2"
+ resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-6.2.2.tgz#60216eea464d864597ce2832000738a0589650c1"
+ integrity sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==
+
+ansi-styles@^6.2.1:
+ version "6.2.3"
+ resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-6.2.3.tgz#c044d5dcc521a076413472597a1acb1f103c4041"
+ integrity sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==
+
+anymatch@^3.1.3:
+ version "3.1.3"
+ resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-3.1.3.tgz#790c58b19ba1720a84205b57c618d5ad8524973e"
+ integrity sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==
+ dependencies:
+ normalize-path "^3.0.0"
+ picomatch "^2.0.4"
+
+arg@^5.0.0:
+ version "5.0.2"
+ resolved "https://registry.yarnpkg.com/arg/-/arg-5.0.2.tgz#c81433cc427c92c4dcf4865142dbca6f15acd59c"
+ integrity sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==
+
+argparse@^2.0.1:
+ version "2.0.1"
+ resolved "https://registry.yarnpkg.com/argparse/-/argparse-2.0.1.tgz#246f50f3ca78a3240f6c997e8a9bd1eac49e4b38"
+ integrity sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==
+
+aria-query@^5.3.2:
+ version "5.3.2"
+ resolved "https://registry.yarnpkg.com/aria-query/-/aria-query-5.3.2.tgz#93f81a43480e33a338f19163a3d10a50c01dcd59"
+ integrity sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==
+
+array-iterate@^2.0.0:
+ version "2.0.1"
+ resolved "https://registry.yarnpkg.com/array-iterate/-/array-iterate-2.0.1.tgz#6efd43f8295b3fee06251d3d62ead4bd9805dd24"
+ integrity sha512-I1jXZMjAgCMmxT4qxXfPXa6SthSoE8h6gkSI9BGGNv8mP8G/v0blc+qFnZu6K42vTOiuME596QaLO0TP3Lk0xg==
+
+astring@^1.8.0:
+ version "1.9.0"
+ resolved "https://registry.yarnpkg.com/astring/-/astring-1.9.0.tgz#cc73e6062a7eb03e7d19c22d8b0b3451fd9bfeef"
+ integrity sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg==
+
+astro-expressive-code@^0.40.0:
+ version "0.40.2"
+ resolved "https://registry.yarnpkg.com/astro-expressive-code/-/astro-expressive-code-0.40.2.tgz#caee511b873c5c93f22cbca469dcd6f7ad3d86ee"
+ integrity sha512-yJMQId0yXSAbW9I6yqvJ3FcjKzJ8zRL7elbJbllkv1ZJPlsI0NI83Pxn1YL1IapEM347EvOOkSW2GL+2+NO61w==
+ dependencies:
+ rehype-expressive-code "^0.40.2"
+
+astro@^5.1.1:
+ version "5.16.6"
+ resolved "https://registry.yarnpkg.com/astro/-/astro-5.16.6.tgz#9f2d8a7bbb7e69fc7db4718cca82233b20b76d08"
+ integrity sha512-6mF/YrvwwRxLTu+aMEa5pwzKUNl5ZetWbTyZCs9Um0F12HUmxUiF5UHiZPy4rifzU3gtpM3xP2DfdmkNX9eZRg==
+ dependencies:
+ "@astrojs/compiler" "^2.13.0"
+ "@astrojs/internal-helpers" "0.7.5"
+ "@astrojs/markdown-remark" "6.3.10"
+ "@astrojs/telemetry" "3.3.0"
+ "@capsizecss/unpack" "^3.0.1"
+ "@oslojs/encoding" "^1.1.0"
+ "@rollup/pluginutils" "^5.3.0"
+ acorn "^8.15.0"
+ aria-query "^5.3.2"
+ axobject-query "^4.1.0"
+ boxen "8.0.1"
+ ci-info "^4.3.1"
+ clsx "^2.1.1"
+ common-ancestor-path "^1.0.1"
+ cookie "^1.0.2"
+ cssesc "^3.0.0"
+ debug "^4.4.3"
+ deterministic-object-hash "^2.0.2"
+ devalue "^5.5.0"
+ diff "^5.2.0"
+ dlv "^1.1.3"
+ dset "^3.1.4"
+ es-module-lexer "^1.7.0"
+ esbuild "^0.25.0"
+ estree-walker "^3.0.3"
+ flattie "^1.1.1"
+ fontace "~0.3.1"
+ github-slugger "^2.0.0"
+ html-escaper "3.0.3"
+ http-cache-semantics "^4.2.0"
+ import-meta-resolve "^4.2.0"
+ js-yaml "^4.1.1"
+ magic-string "^0.30.21"
+ magicast "^0.5.1"
+ mrmime "^2.0.1"
+ neotraverse "^0.6.18"
+ p-limit "^6.2.0"
+ p-queue "^8.1.1"
+ package-manager-detector "^1.5.0"
+ piccolore "^0.1.3"
+ picomatch "^4.0.3"
+ prompts "^2.4.2"
+ rehype "^13.0.2"
+ semver "^7.7.3"
+ shiki "^3.15.0"
+ smol-toml "^1.5.2"
+ svgo "^4.0.0"
+ tinyexec "^1.0.2"
+ tinyglobby "^0.2.15"
+ tsconfck "^3.1.6"
+ ultrahtml "^1.6.0"
+ unifont "~0.6.0"
+ unist-util-visit "^5.0.0"
+ unstorage "^1.17.3"
+ vfile "^6.0.3"
+ vite "^6.4.1"
+ vitefu "^1.1.1"
+ xxhash-wasm "^1.1.0"
+ yargs-parser "^21.1.1"
+ yocto-spinner "^0.2.3"
+ zod "^3.25.76"
+ zod-to-json-schema "^3.25.0"
+ zod-to-ts "^1.2.0"
+ optionalDependencies:
+ sharp "^0.34.0"
+
+axobject-query@^4.1.0:
+ version "4.1.0"
+ resolved "https://registry.yarnpkg.com/axobject-query/-/axobject-query-4.1.0.tgz#28768c76d0e3cff21bc62a9e2d0b6ac30042a1ee"
+ integrity sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==
+
+bail@^2.0.0:
+ version "2.0.2"
+ resolved "https://registry.yarnpkg.com/bail/-/bail-2.0.2.tgz#d26f5cd8fe5d6f832a31517b9f7c356040ba6d5d"
+ integrity sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==
+
+base-64@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/base-64/-/base-64-1.0.0.tgz#09d0f2084e32a3fd08c2475b973788eee6ae8f4a"
+ integrity sha512-kwDPIFCGx0NZHog36dj+tHiwP4QMzsZ3AgMViUBKI0+V5n4U0ufTCUMhnQ04diaRI8EX/QcPfql7zlhZ7j4zgg==
+
+base64-js@^1.1.2, base64-js@^1.3.0:
+ version "1.5.1"
+ resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a"
+ integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==
+
+bcp-47-match@^2.0.0:
+ version "2.0.3"
+ resolved "https://registry.yarnpkg.com/bcp-47-match/-/bcp-47-match-2.0.3.tgz#603226f6e5d3914a581408be33b28a53144b09d0"
+ integrity sha512-JtTezzbAibu8G0R9op9zb3vcWZd9JF6M0xOYGPn0fNCd7wOpRB1mU2mH9T8gaBGbAAyIIVgB2G7xG0GP98zMAQ==
+
+bcp-47@^2.1.0:
+ version "2.1.0"
+ resolved "https://registry.yarnpkg.com/bcp-47/-/bcp-47-2.1.0.tgz#7e80734c3338fe8320894981dccf4968c3092df6"
+ integrity sha512-9IIS3UPrvIa1Ej+lVDdDwO7zLehjqsaByECw0bu2RRGP73jALm6FYbzI5gWbgHLvNdkvfXB5YrSbocZdOS0c0w==
+ dependencies:
+ is-alphabetical "^2.0.0"
+ is-alphanumerical "^2.0.0"
+ is-decimal "^2.0.0"
+
+boolbase@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/boolbase/-/boolbase-1.0.0.tgz#68dff5fbe60c51eb37725ea9e3ed310dcc1e776e"
+ integrity sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==
+
+boxen@8.0.1:
+ version "8.0.1"
+ resolved "https://registry.yarnpkg.com/boxen/-/boxen-8.0.1.tgz#7e9fcbb45e11a2d7e6daa8fdcebfc3242fc19fe3"
+ integrity sha512-F3PH5k5juxom4xktynS7MoFY+NUWH5LC4CnH11YB8NPew+HLpmBLCybSAEyb2F+4pRXhuhWqFesoQd6DAyc2hw==
+ dependencies:
+ ansi-align "^3.0.1"
+ camelcase "^8.0.0"
+ chalk "^5.3.0"
+ cli-boxes "^3.0.0"
+ string-width "^7.2.0"
+ type-fest "^4.21.0"
+ widest-line "^5.0.0"
+ wrap-ansi "^9.0.0"
+
+brotli@^1.3.2:
+ version "1.3.3"
+ resolved "https://registry.yarnpkg.com/brotli/-/brotli-1.3.3.tgz#7365d8cc00f12cf765d2b2c898716bcf4b604d48"
+ integrity sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==
+ dependencies:
+ base64-js "^1.1.2"
+
+camelcase@^8.0.0:
+ version "8.0.0"
+ resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-8.0.0.tgz#c0d36d418753fb6ad9c5e0437579745c1c14a534"
+ integrity sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA==
+
+ccount@^2.0.0:
+ version "2.0.1"
+ resolved "https://registry.yarnpkg.com/ccount/-/ccount-2.0.1.tgz#17a3bf82302e0870d6da43a01311a8bc02a3ecf5"
+ integrity sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==
+
+chalk@^5.3.0:
+ version "5.6.2"
+ resolved "https://registry.yarnpkg.com/chalk/-/chalk-5.6.2.tgz#b1238b6e23ea337af71c7f8a295db5af0c158aea"
+ integrity sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==
+
+character-entities-html4@^2.0.0:
+ version "2.1.0"
+ resolved "https://registry.yarnpkg.com/character-entities-html4/-/character-entities-html4-2.1.0.tgz#1f1adb940c971a4b22ba39ddca6b618dc6e56b2b"
+ integrity sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==
+
+character-entities-legacy@^3.0.0:
+ version "3.0.0"
+ resolved "https://registry.yarnpkg.com/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz#76bc83a90738901d7bc223a9e93759fdd560125b"
+ integrity sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==
+
+character-entities@^2.0.0:
+ version "2.0.2"
+ resolved "https://registry.yarnpkg.com/character-entities/-/character-entities-2.0.2.tgz#2d09c2e72cd9523076ccb21157dff66ad43fcc22"
+ integrity sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==
+
+character-reference-invalid@^2.0.0:
+ version "2.0.1"
+ resolved "https://registry.yarnpkg.com/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz#85c66b041e43b47210faf401278abf808ac45cb9"
+ integrity sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==
+
+chokidar@^4.0.3:
+ version "4.0.3"
+ resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-4.0.3.tgz#7be37a4c03c9aee1ecfe862a4a23b2c70c205d30"
+ integrity sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==
+ dependencies:
+ readdirp "^4.0.1"
+
+ci-info@^4.2.0, ci-info@^4.3.1:
+ version "4.3.1"
+ resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-4.3.1.tgz#355ad571920810b5623e11d40232f443f16f1daa"
+ integrity sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA==
+
+cli-boxes@^3.0.0:
+ version "3.0.0"
+ resolved "https://registry.yarnpkg.com/cli-boxes/-/cli-boxes-3.0.0.tgz#71a10c716feeba005e4504f36329ef0b17cf3145"
+ integrity sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==
+
+clone@^2.1.2:
+ version "2.1.2"
+ resolved "https://registry.yarnpkg.com/clone/-/clone-2.1.2.tgz#1b7f4b9f591f1e8f83670401600345a02887435f"
+ integrity sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==
+
+clsx@^2.1.1:
+ version "2.1.1"
+ resolved "https://registry.yarnpkg.com/clsx/-/clsx-2.1.1.tgz#eed397c9fd8bd882bfb18deab7102049a2f32999"
+ integrity sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==
+
+collapse-white-space@^2.0.0:
+ version "2.1.0"
+ resolved "https://registry.yarnpkg.com/collapse-white-space/-/collapse-white-space-2.1.0.tgz#640257174f9f42c740b40f3b55ee752924feefca"
+ integrity sha512-loKTxY1zCOuG4j9f6EPnuyyYkf58RnhhWTvRoZEokgB+WbdXehfjFviyOVYkqzEWz1Q5kRiZdBYS5SwxbQYwzw==
+
+color-convert@^2.0.1:
+ version "2.0.1"
+ resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-2.0.1.tgz#72d3a68d598c9bdb3af2ad1e84f21d896abd4de3"
+ integrity sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==
+ dependencies:
+ color-name "~1.1.4"
+
+color-name@^1.0.0, color-name@~1.1.4:
+ version "1.1.4"
+ resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2"
+ integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==
+
+color-string@^1.9.0:
+ version "1.9.1"
+ resolved "https://registry.yarnpkg.com/color-string/-/color-string-1.9.1.tgz#4467f9146f036f855b764dfb5bf8582bf342c7a4"
+ integrity sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==
+ dependencies:
+ color-name "^1.0.0"
+ simple-swizzle "^0.2.2"
+
+color@^4.2.3:
+ version "4.2.3"
+ resolved "https://registry.yarnpkg.com/color/-/color-4.2.3.tgz#d781ecb5e57224ee43ea9627560107c0e0c6463a"
+ integrity sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==
+ dependencies:
+ color-convert "^2.0.1"
+ color-string "^1.9.0"
+
+comma-separated-tokens@^2.0.0:
+ version "2.0.3"
+ resolved "https://registry.yarnpkg.com/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz#4e89c9458acb61bc8fef19f4529973b2392839ee"
+ integrity sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==
+
+commander@^11.1.0:
+ version "11.1.0"
+ resolved "https://registry.yarnpkg.com/commander/-/commander-11.1.0.tgz#62fdce76006a68e5c1ab3314dc92e800eb83d906"
+ integrity sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==
+
+common-ancestor-path@^1.0.1:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/common-ancestor-path/-/common-ancestor-path-1.0.1.tgz#4f7d2d1394d91b7abdf51871c62f71eadb0182a7"
+ integrity sha512-L3sHRo1pXXEqX8VU28kfgUY+YGsk09hPqZiZmLacNib6XNTCM8ubYeT7ryXQw8asB1sKgcU5lkB7ONug08aB8w==
+
+cookie-es@^1.2.2:
+ version "1.2.2"
+ resolved "https://registry.yarnpkg.com/cookie-es/-/cookie-es-1.2.2.tgz#18ceef9eb513cac1cb6c14bcbf8bdb2679b34821"
+ integrity sha512-+W7VmiVINB+ywl1HGXJXmrqkOhpKrIiVZV6tQuV54ZyQC7MMuBt81Vc336GMLoHBq5hV/F9eXgt5Mnx0Rha5Fg==
+
+cookie@^1.0.2:
+ version "1.1.1"
+ resolved "https://registry.yarnpkg.com/cookie/-/cookie-1.1.1.tgz#3bb9bdfc82369db9c2f69c93c9c3ceb310c88b3c"
+ integrity sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==
+
+crossws@^0.3.5:
+ version "0.3.5"
+ resolved "https://registry.yarnpkg.com/crossws/-/crossws-0.3.5.tgz#daad331d44148ea6500098bc858869f3a5ab81a6"
+ integrity sha512-ojKiDvcmByhwa8YYqbQI/hg7MEU0NC03+pSdEq4ZUnZR9xXpwk7E43SMNGkn+JxJGPFtNvQ48+vV2p+P1ml5PA==
+ dependencies:
+ uncrypto "^0.1.3"
+
+css-select@^5.1.0:
+ version "5.2.2"
+ resolved "https://registry.yarnpkg.com/css-select/-/css-select-5.2.2.tgz#01b6e8d163637bb2dd6c982ca4ed65863682786e"
+ integrity sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==
+ dependencies:
+ boolbase "^1.0.0"
+ css-what "^6.1.0"
+ domhandler "^5.0.2"
+ domutils "^3.0.1"
+ nth-check "^2.0.1"
+
+css-selector-parser@^3.0.0:
+ version "3.3.0"
+ resolved "https://registry.yarnpkg.com/css-selector-parser/-/css-selector-parser-3.3.0.tgz#1a34220d76762c929ae99993df5a60721f505082"
+ integrity sha512-Y2asgMGFqJKF4fq4xHDSlFYIkeVfRsm69lQC1q9kbEsH5XtnINTMrweLkjYMeaUgiXBy/uvKeO/a1JHTNnmB2g==
+
+css-tree@^3.0.0, css-tree@^3.0.1:
+ version "3.1.0"
+ resolved "https://registry.yarnpkg.com/css-tree/-/css-tree-3.1.0.tgz#7aabc035f4e66b5c86f54570d55e05b1346eb0fd"
+ integrity sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==
+ dependencies:
+ mdn-data "2.12.2"
+ source-map-js "^1.0.1"
+
+css-tree@~2.2.0:
+ version "2.2.1"
+ resolved "https://registry.yarnpkg.com/css-tree/-/css-tree-2.2.1.tgz#36115d382d60afd271e377f9c5f67d02bd48c032"
+ integrity sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA==
+ dependencies:
+ mdn-data "2.0.28"
+ source-map-js "^1.0.1"
+
+css-what@^6.1.0:
+ version "6.2.2"
+ resolved "https://registry.yarnpkg.com/css-what/-/css-what-6.2.2.tgz#cdcc8f9b6977719fdfbd1de7aec24abf756b9dea"
+ integrity sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==
+
+cssesc@^3.0.0:
+ version "3.0.0"
+ resolved "https://registry.yarnpkg.com/cssesc/-/cssesc-3.0.0.tgz#37741919903b868565e1c09ea747445cd18983ee"
+ integrity sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==
+
+csso@^5.0.5:
+ version "5.0.5"
+ resolved "https://registry.yarnpkg.com/csso/-/csso-5.0.5.tgz#f9b7fe6cc6ac0b7d90781bb16d5e9874303e2ca6"
+ integrity sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ==
+ dependencies:
+ css-tree "~2.2.0"
+
+debug@^4.0.0, debug@^4.4.0, debug@^4.4.3:
+ version "4.4.3"
+ resolved "https://registry.yarnpkg.com/debug/-/debug-4.4.3.tgz#c6ae432d9bd9662582fce08709b038c58e9e3d6a"
+ integrity sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==
+ dependencies:
+ ms "^2.1.3"
+
+decode-named-character-reference@^1.0.0:
+ version "1.2.0"
+ resolved "https://registry.yarnpkg.com/decode-named-character-reference/-/decode-named-character-reference-1.2.0.tgz#25c32ae6dd5e21889549d40f676030e9514cc0ed"
+ integrity sha512-c6fcElNV6ShtZXmsgNgFFV5tVX2PaV4g+MOAkb8eXHvn6sryJBrZa9r0zV6+dtTyoCKxtDy5tyQ5ZwQuidtd+Q==
+ dependencies:
+ character-entities "^2.0.0"
+
+defu@^6.1.4:
+ version "6.1.4"
+ resolved "https://registry.yarnpkg.com/defu/-/defu-6.1.4.tgz#4e0c9cf9ff68fe5f3d7f2765cc1a012dfdcb0479"
+ integrity sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==
+
+dequal@^2.0.0:
+ version "2.0.3"
+ resolved "https://registry.yarnpkg.com/dequal/-/dequal-2.0.3.tgz#2644214f1997d39ed0ee0ece72335490a7ac67be"
+ integrity sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==
+
+destr@^2.0.5:
+ version "2.0.5"
+ resolved "https://registry.yarnpkg.com/destr/-/destr-2.0.5.tgz#7d112ff1b925fb8d2079fac5bdb4a90973b51fdb"
+ integrity sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==
+
+detect-libc@^2.0.3, detect-libc@^2.1.2:
+ version "2.1.2"
+ resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-2.1.2.tgz#689c5dcdc1900ef5583a4cb9f6d7b473742074ad"
+ integrity sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==
+
+deterministic-object-hash@^2.0.2:
+ version "2.0.2"
+ resolved "https://registry.yarnpkg.com/deterministic-object-hash/-/deterministic-object-hash-2.0.2.tgz#b251ddc801443905f0e9fef08816a46bc9fe3807"
+ integrity sha512-KxektNH63SrbfUyDiwXqRb1rLwKt33AmMv+5Nhsw1kqZ13SJBRTgZHtGbE+hH3a1mVW1cz+4pqSWVPAtLVXTzQ==
+ dependencies:
+ base-64 "^1.0.0"
+
+devalue@^5.5.0:
+ version "5.6.1"
+ resolved "https://registry.yarnpkg.com/devalue/-/devalue-5.6.1.tgz#f4c0a6e71d1a2bc50c02f9ca3c54ecafeb6a0445"
+ integrity sha512-jDwizj+IlEZBunHcOuuFVBnIMPAEHvTsJj0BcIp94xYguLRVBcXO853px/MyIJvbVzWdsGvrRweIUWJw8hBP7A==
+
+devlop@^1.0.0, devlop@^1.1.0:
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/devlop/-/devlop-1.1.0.tgz#4db7c2ca4dc6e0e834c30be70c94bbc976dc7018"
+ integrity sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==
+ dependencies:
+ dequal "^2.0.0"
+
+dfa@^1.2.0:
+ version "1.2.0"
+ resolved "https://registry.yarnpkg.com/dfa/-/dfa-1.2.0.tgz#96ac3204e2d29c49ea5b57af8d92c2ae12790657"
+ integrity sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==
+
+diff@^5.2.0:
+ version "5.2.0"
+ resolved "https://registry.yarnpkg.com/diff/-/diff-5.2.0.tgz#26ded047cd1179b78b9537d5ef725503ce1ae531"
+ integrity sha512-uIFDxqpRZGZ6ThOk84hEfqWoHx2devRFvpTZcTHur85vImfaxUbTW9Ryh4CpCuDnToOP1CEtXKIgytHBPVff5A==
+
+direction@^2.0.0:
+ version "2.0.1"
+ resolved "https://registry.yarnpkg.com/direction/-/direction-2.0.1.tgz#71800dd3c4fa102406502905d3866e65bdebb985"
+ integrity sha512-9S6m9Sukh1cZNknO1CWAr2QAWsbKLafQiyM5gZ7VgXHeuaoUwffKN4q6NC4A/Mf9iiPlOXQEKW/Mv/mh9/3YFA==
+
+dlv@^1.1.3:
+ version "1.1.3"
+ resolved "https://registry.yarnpkg.com/dlv/-/dlv-1.1.3.tgz#5c198a8a11453596e751494d49874bc7732f2e79"
+ integrity sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==
+
+dom-serializer@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-2.0.0.tgz#e41b802e1eedf9f6cae183ce5e622d789d7d8e53"
+ integrity sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==
+ dependencies:
+ domelementtype "^2.3.0"
+ domhandler "^5.0.2"
+ entities "^4.2.0"
+
+domelementtype@^2.3.0:
+ version "2.3.0"
+ resolved "https://registry.yarnpkg.com/domelementtype/-/domelementtype-2.3.0.tgz#5c45e8e869952626331d7aab326d01daf65d589d"
+ integrity sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==
+
+domhandler@^5.0.2, domhandler@^5.0.3:
+ version "5.0.3"
+ resolved "https://registry.yarnpkg.com/domhandler/-/domhandler-5.0.3.tgz#cc385f7f751f1d1fc650c21374804254538c7d31"
+ integrity sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==
+ dependencies:
+ domelementtype "^2.3.0"
+
+domutils@^3.0.1:
+ version "3.2.2"
+ resolved "https://registry.yarnpkg.com/domutils/-/domutils-3.2.2.tgz#edbfe2b668b0c1d97c24baf0f1062b132221bc78"
+ integrity sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==
+ dependencies:
+ dom-serializer "^2.0.0"
+ domelementtype "^2.3.0"
+ domhandler "^5.0.3"
+
+dset@^3.1.4:
+ version "3.1.4"
+ resolved "https://registry.yarnpkg.com/dset/-/dset-3.1.4.tgz#f8eaf5f023f068a036d08cd07dc9ffb7d0065248"
+ integrity sha512-2QF/g9/zTaPDc3BjNcVTGoBbXBgYfMTTceLaYcFJ/W9kggFUkhxD/hMEeuLKbugyef9SqAx8cpgwlIP/jinUTA==
+
+emoji-regex-xs@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/emoji-regex-xs/-/emoji-regex-xs-1.0.0.tgz#e8af22e5d9dbd7f7f22d280af3d19d2aab5b0724"
+ integrity sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg==
+
+emoji-regex@^10.3.0:
+ version "10.6.0"
+ resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-10.6.0.tgz#bf3d6e8f7f8fd22a65d9703475bc0147357a6b0d"
+ integrity sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==
+
+emoji-regex@^8.0.0:
+ version "8.0.0"
+ resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37"
+ integrity sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==
+
+entities@^4.2.0:
+ version "4.5.0"
+ resolved "https://registry.yarnpkg.com/entities/-/entities-4.5.0.tgz#5d268ea5e7113ec74c4d033b79ea5a35a488fb48"
+ integrity sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==
+
+entities@^6.0.0:
+ version "6.0.1"
+ resolved "https://registry.yarnpkg.com/entities/-/entities-6.0.1.tgz#c28c34a43379ca7f61d074130b2f5f7020a30694"
+ integrity sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==
+
+es-module-lexer@^1.7.0:
+ version "1.7.0"
+ resolved "https://registry.yarnpkg.com/es-module-lexer/-/es-module-lexer-1.7.0.tgz#9159601561880a85f2734560a9099b2c31e5372a"
+ integrity sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==
+
+esast-util-from-estree@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/esast-util-from-estree/-/esast-util-from-estree-2.0.0.tgz#8d1cfb51ad534d2f159dc250e604f3478a79f1ad"
+ integrity sha512-4CyanoAudUSBAn5K13H4JhsMH6L9ZP7XbLVe/dKybkxMO7eDyLsT8UHl9TRNrU2Gr9nz+FovfSIjuXWJ81uVwQ==
+ dependencies:
+ "@types/estree-jsx" "^1.0.0"
+ devlop "^1.0.0"
+ estree-util-visit "^2.0.0"
+ unist-util-position-from-estree "^2.0.0"
+
+esast-util-from-js@^2.0.0:
+ version "2.0.1"
+ resolved "https://registry.yarnpkg.com/esast-util-from-js/-/esast-util-from-js-2.0.1.tgz#5147bec34cc9da44accf52f87f239a40ac3e8225"
+ integrity sha512-8Ja+rNJ0Lt56Pcf3TAmpBZjmx8ZcK5Ts4cAzIOjsjevg9oSXJnl6SUQ2EevU8tv3h6ZLWmoKL5H4fgWvdvfETw==
+ dependencies:
+ "@types/estree-jsx" "^1.0.0"
+ acorn "^8.0.0"
+ esast-util-from-estree "^2.0.0"
+ vfile-message "^4.0.0"
+
+esbuild@^0.25.0:
+ version "0.25.12"
+ resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.25.12.tgz#97a1d041f4ab00c2fce2f838d2b9969a2d2a97a5"
+ integrity sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==
+ optionalDependencies:
+ "@esbuild/aix-ppc64" "0.25.12"
+ "@esbuild/android-arm" "0.25.12"
+ "@esbuild/android-arm64" "0.25.12"
+ "@esbuild/android-x64" "0.25.12"
+ "@esbuild/darwin-arm64" "0.25.12"
+ "@esbuild/darwin-x64" "0.25.12"
+ "@esbuild/freebsd-arm64" "0.25.12"
+ "@esbuild/freebsd-x64" "0.25.12"
+ "@esbuild/linux-arm" "0.25.12"
+ "@esbuild/linux-arm64" "0.25.12"
+ "@esbuild/linux-ia32" "0.25.12"
+ "@esbuild/linux-loong64" "0.25.12"
+ "@esbuild/linux-mips64el" "0.25.12"
+ "@esbuild/linux-ppc64" "0.25.12"
+ "@esbuild/linux-riscv64" "0.25.12"
+ "@esbuild/linux-s390x" "0.25.12"
+ "@esbuild/linux-x64" "0.25.12"
+ "@esbuild/netbsd-arm64" "0.25.12"
+ "@esbuild/netbsd-x64" "0.25.12"
+ "@esbuild/openbsd-arm64" "0.25.12"
+ "@esbuild/openbsd-x64" "0.25.12"
+ "@esbuild/openharmony-arm64" "0.25.12"
+ "@esbuild/sunos-x64" "0.25.12"
+ "@esbuild/win32-arm64" "0.25.12"
+ "@esbuild/win32-ia32" "0.25.12"
+ "@esbuild/win32-x64" "0.25.12"
+
+escape-string-regexp@^5.0.0:
+ version "5.0.0"
+ resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz#4683126b500b61762f2dbebace1806e8be31b1c8"
+ integrity sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==
+
+estree-util-attach-comments@^3.0.0:
+ version "3.0.0"
+ resolved "https://registry.yarnpkg.com/estree-util-attach-comments/-/estree-util-attach-comments-3.0.0.tgz#344bde6a64c8a31d15231e5ee9e297566a691c2d"
+ integrity sha512-cKUwm/HUcTDsYh/9FgnuFqpfquUbwIqwKM26BVCGDPVgvaCl/nDCCjUfiLlx6lsEZ3Z4RFxNbOQ60pkaEwFxGw==
+ dependencies:
+ "@types/estree" "^1.0.0"
+
+estree-util-build-jsx@^3.0.0:
+ version "3.0.1"
+ resolved "https://registry.yarnpkg.com/estree-util-build-jsx/-/estree-util-build-jsx-3.0.1.tgz#b6d0bced1dcc4f06f25cf0ceda2b2dcaf98168f1"
+ integrity sha512-8U5eiL6BTrPxp/CHbs2yMgP8ftMhR5ww1eIKoWRMlqvltHF8fZn5LRDvTKuxD3DUn+shRbLGqXemcP51oFCsGQ==
+ dependencies:
+ "@types/estree-jsx" "^1.0.0"
+ devlop "^1.0.0"
+ estree-util-is-identifier-name "^3.0.0"
+ estree-walker "^3.0.0"
+
+estree-util-is-identifier-name@^3.0.0:
+ version "3.0.0"
+ resolved "https://registry.yarnpkg.com/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz#0b5ef4c4ff13508b34dcd01ecfa945f61fce5dbd"
+ integrity sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==
+
+estree-util-scope@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/estree-util-scope/-/estree-util-scope-1.0.0.tgz#9cbdfc77f5cb51e3d9ed4ad9c4adbff22d43e585"
+ integrity sha512-2CAASclonf+JFWBNJPndcOpA8EMJwa0Q8LUFJEKqXLW6+qBvbFZuF5gItbQOs/umBUkjviCSDCbBwU2cXbmrhQ==
+ dependencies:
+ "@types/estree" "^1.0.0"
+ devlop "^1.0.0"
+
+estree-util-to-js@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/estree-util-to-js/-/estree-util-to-js-2.0.0.tgz#10a6fb924814e6abb62becf0d2bc4dea51d04f17"
+ integrity sha512-WDF+xj5rRWmD5tj6bIqRi6CkLIXbbNQUcxQHzGysQzvHmdYG2G7p/Tf0J0gpxGgkeMZNTIjT/AoSvC9Xehcgdg==
+ dependencies:
+ "@types/estree-jsx" "^1.0.0"
+ astring "^1.8.0"
+ source-map "^0.7.0"
+
+estree-util-visit@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/estree-util-visit/-/estree-util-visit-2.0.0.tgz#13a9a9f40ff50ed0c022f831ddf4b58d05446feb"
+ integrity sha512-m5KgiH85xAhhW8Wta0vShLcUvOsh3LLPI2YVwcbio1l7E09NTLL1EyMZFM1OyWowoH0skScNbhOPl4kcBgzTww==
+ dependencies:
+ "@types/estree-jsx" "^1.0.0"
+ "@types/unist" "^3.0.0"
+
+estree-walker@^2.0.2:
+ version "2.0.2"
+ resolved "https://registry.yarnpkg.com/estree-walker/-/estree-walker-2.0.2.tgz#52f010178c2a4c117a7757cfe942adb7d2da4cac"
+ integrity sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==
+
+estree-walker@^3.0.0, estree-walker@^3.0.3:
+ version "3.0.3"
+ resolved "https://registry.yarnpkg.com/estree-walker/-/estree-walker-3.0.3.tgz#67c3e549ec402a487b4fc193d1953a524752340d"
+ integrity sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==
+ dependencies:
+ "@types/estree" "^1.0.0"
+
+eventemitter3@^5.0.1:
+ version "5.0.1"
+ resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-5.0.1.tgz#53f5ffd0a492ac800721bb42c66b841de96423c4"
+ integrity sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==
+
+expressive-code@^0.40.2:
+ version "0.40.2"
+ resolved "https://registry.yarnpkg.com/expressive-code/-/expressive-code-0.40.2.tgz#34eca86bcfa54716c6887a860ca59682b2d983e6"
+ integrity sha512-1zIda2rB0qiDZACawzw2rbdBQiWHBT56uBctS+ezFe5XMAaFaHLnnSYND/Kd+dVzO9HfCXRDpzH3d+3fvOWRcw==
+ dependencies:
+ "@expressive-code/core" "^0.40.2"
+ "@expressive-code/plugin-frames" "^0.40.2"
+ "@expressive-code/plugin-shiki" "^0.40.2"
+ "@expressive-code/plugin-text-markers" "^0.40.2"
+
+extend@^3.0.0:
+ version "3.0.2"
+ resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.2.tgz#f8b1136b4071fbd8eb140aff858b1019ec2915fa"
+ integrity sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==
+
+fast-deep-equal@^3.1.3:
+ version "3.1.3"
+ resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525"
+ integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==
+
+fdir@^6.4.4, fdir@^6.5.0:
+ version "6.5.0"
+ resolved "https://registry.yarnpkg.com/fdir/-/fdir-6.5.0.tgz#ed2ab967a331ade62f18d077dae192684d50d350"
+ integrity sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==
+
+flattie@^1.1.1:
+ version "1.1.1"
+ resolved "https://registry.yarnpkg.com/flattie/-/flattie-1.1.1.tgz#88182235723113667d36217fec55359275d6fe3d"
+ integrity sha512-9UbaD6XdAL97+k/n+N7JwX46K/M6Zc6KcFYskrYL8wbBV/Uyk0CTAMY0VT+qiK5PM7AIc9aTWYtq65U7T+aCNQ==
+
+fontace@~0.3.1:
+ version "0.3.1"
+ resolved "https://registry.yarnpkg.com/fontace/-/fontace-0.3.1.tgz#2325007b9784695103e630f865b3ac8d771547c8"
+ integrity sha512-9f5g4feWT1jWT8+SbL85aLIRLIXUaDygaM2xPXRmzPYxrOMNok79Lr3FGJoKVNKibE0WCunNiEVG2mwuE+2qEg==
+ dependencies:
+ "@types/fontkit" "^2.0.8"
+ fontkit "^2.0.4"
+
+fontkit@^2.0.2, fontkit@^2.0.4:
+ version "2.0.4"
+ resolved "https://registry.yarnpkg.com/fontkit/-/fontkit-2.0.4.tgz#4765d664c68b49b5d6feb6bd1051ee49d8ec5ab0"
+ integrity sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==
+ dependencies:
+ "@swc/helpers" "^0.5.12"
+ brotli "^1.3.2"
+ clone "^2.1.2"
+ dfa "^1.2.0"
+ fast-deep-equal "^3.1.3"
+ restructure "^3.0.0"
+ tiny-inflate "^1.0.3"
+ unicode-properties "^1.4.0"
+ unicode-trie "^2.0.0"
+
+fsevents@~2.3.2, fsevents@~2.3.3:
+ version "2.3.3"
+ resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6"
+ integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==
+
+get-east-asian-width@^1.0.0:
+ version "1.4.0"
+ resolved "https://registry.yarnpkg.com/get-east-asian-width/-/get-east-asian-width-1.4.0.tgz#9bc4caa131702b4b61729cb7e42735bc550c9ee6"
+ integrity sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q==
+
+github-slugger@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/github-slugger/-/github-slugger-2.0.0.tgz#52cf2f9279a21eb6c59dd385b410f0c0adda8f1a"
+ integrity sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==
+
+h3@^1.15.4:
+ version "1.15.4"
+ resolved "https://registry.yarnpkg.com/h3/-/h3-1.15.4.tgz#022ab3563bbaf2108c25375c40460f3e54a5fe02"
+ integrity sha512-z5cFQWDffyOe4vQ9xIqNfCZdV4p//vy6fBnr8Q1AWnVZ0teurKMG66rLj++TKwKPUP3u7iMUvrvKaEUiQw2QWQ==
+ dependencies:
+ cookie-es "^1.2.2"
+ crossws "^0.3.5"
+ defu "^6.1.4"
+ destr "^2.0.5"
+ iron-webcrypto "^1.2.1"
+ node-mock-http "^1.0.2"
+ radix3 "^1.1.2"
+ ufo "^1.6.1"
+ uncrypto "^0.1.3"
+
+hast-util-embedded@^3.0.0:
+ version "3.0.0"
+ resolved "https://registry.yarnpkg.com/hast-util-embedded/-/hast-util-embedded-3.0.0.tgz#be4477780fbbe079cdba22982e357a0de4ba853e"
+ integrity sha512-naH8sld4Pe2ep03qqULEtvYr7EjrLK2QHY8KJR6RJkTUjPGObe1vnx585uzem2hGra+s1q08DZZpfgDVYRbaXA==
+ dependencies:
+ "@types/hast" "^3.0.0"
+ hast-util-is-element "^3.0.0"
+
+hast-util-format@^1.0.0:
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/hast-util-format/-/hast-util-format-1.1.0.tgz#373e77382e07deb04f6676f1b4437e7d8549d985"
+ integrity sha512-yY1UDz6bC9rDvCWHpx12aIBGRG7krurX0p0Fm6pT547LwDIZZiNr8a+IHDogorAdreULSEzP82Nlv5SZkHZcjA==
+ dependencies:
+ "@types/hast" "^3.0.0"
+ hast-util-embedded "^3.0.0"
+ hast-util-minify-whitespace "^1.0.0"
+ hast-util-phrasing "^3.0.0"
+ hast-util-whitespace "^3.0.0"
+ html-whitespace-sensitive-tag-names "^3.0.0"
+ unist-util-visit-parents "^6.0.0"
+
+hast-util-from-html@^2.0.0, hast-util-from-html@^2.0.1, hast-util-from-html@^2.0.3:
+ version "2.0.3"
+ resolved "https://registry.yarnpkg.com/hast-util-from-html/-/hast-util-from-html-2.0.3.tgz#485c74785358beb80c4ba6346299311ac4c49c82"
+ integrity sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw==
+ dependencies:
+ "@types/hast" "^3.0.0"
+ devlop "^1.1.0"
+ hast-util-from-parse5 "^8.0.0"
+ parse5 "^7.0.0"
+ vfile "^6.0.0"
+ vfile-message "^4.0.0"
+
+hast-util-from-parse5@^8.0.0:
+ version "8.0.3"
+ resolved "https://registry.yarnpkg.com/hast-util-from-parse5/-/hast-util-from-parse5-8.0.3.tgz#830a35022fff28c3fea3697a98c2f4cc6b835a2e"
+ integrity sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==
+ dependencies:
+ "@types/hast" "^3.0.0"
+ "@types/unist" "^3.0.0"
+ devlop "^1.0.0"
+ hastscript "^9.0.0"
+ property-information "^7.0.0"
+ vfile "^6.0.0"
+ vfile-location "^5.0.0"
+ web-namespaces "^2.0.0"
+
+hast-util-has-property@^3.0.0:
+ version "3.0.0"
+ resolved "https://registry.yarnpkg.com/hast-util-has-property/-/hast-util-has-property-3.0.0.tgz#4e595e3cddb8ce530ea92f6fc4111a818d8e7f93"
+ integrity sha512-MNilsvEKLFpV604hwfhVStK0usFY/QmM5zX16bo7EjnAEGofr5YyI37kzopBlZJkHD4t887i+q/C8/tr5Q94cA==
+ dependencies:
+ "@types/hast" "^3.0.0"
+
+hast-util-is-body-ok-link@^3.0.0:
+ version "3.0.1"
+ resolved "https://registry.yarnpkg.com/hast-util-is-body-ok-link/-/hast-util-is-body-ok-link-3.0.1.tgz#ef63cb2f14f04ecf775139cd92bda5026380d8b4"
+ integrity sha512-0qpnzOBLztXHbHQenVB8uNuxTnm/QBFUOmdOSsEn7GnBtyY07+ENTWVFBAnXd/zEgd9/SUG3lRY7hSIBWRgGpQ==
+ dependencies:
+ "@types/hast" "^3.0.0"
+
+hast-util-is-element@^3.0.0:
+ version "3.0.0"
+ resolved "https://registry.yarnpkg.com/hast-util-is-element/-/hast-util-is-element-3.0.0.tgz#6e31a6532c217e5b533848c7e52c9d9369ca0932"
+ integrity sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==
+ dependencies:
+ "@types/hast" "^3.0.0"
+
+hast-util-minify-whitespace@^1.0.0:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/hast-util-minify-whitespace/-/hast-util-minify-whitespace-1.0.1.tgz#7588fd1a53f48f1d30406b81959dffc3650daf55"
+ integrity sha512-L96fPOVpnclQE0xzdWb/D12VT5FabA7SnZOUMtL1DbXmYiHJMXZvFkIZfiMmTCNJHUeO2K9UYNXoVyfz+QHuOw==
+ dependencies:
+ "@types/hast" "^3.0.0"
+ hast-util-embedded "^3.0.0"
+ hast-util-is-element "^3.0.0"
+ hast-util-whitespace "^3.0.0"
+ unist-util-is "^6.0.0"
+
+hast-util-parse-selector@^4.0.0:
+ version "4.0.0"
+ resolved "https://registry.yarnpkg.com/hast-util-parse-selector/-/hast-util-parse-selector-4.0.0.tgz#352879fa86e25616036037dd8931fb5f34cb4a27"
+ integrity sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==
+ dependencies:
+ "@types/hast" "^3.0.0"
+
+hast-util-phrasing@^3.0.0:
+ version "3.0.1"
+ resolved "https://registry.yarnpkg.com/hast-util-phrasing/-/hast-util-phrasing-3.0.1.tgz#fa284c0cd4a82a0dd6020de8300a7b1ebffa1690"
+ integrity sha512-6h60VfI3uBQUxHqTyMymMZnEbNl1XmEGtOxxKYL7stY2o601COo62AWAYBQR9lZbYXYSBoxag8UpPRXK+9fqSQ==
+ dependencies:
+ "@types/hast" "^3.0.0"
+ hast-util-embedded "^3.0.0"
+ hast-util-has-property "^3.0.0"
+ hast-util-is-body-ok-link "^3.0.0"
+ hast-util-is-element "^3.0.0"
+
+hast-util-raw@^9.0.0:
+ version "9.1.0"
+ resolved "https://registry.yarnpkg.com/hast-util-raw/-/hast-util-raw-9.1.0.tgz#79b66b26f6f68fb50dfb4716b2cdca90d92adf2e"
+ integrity sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw==
+ dependencies:
+ "@types/hast" "^3.0.0"
+ "@types/unist" "^3.0.0"
+ "@ungap/structured-clone" "^1.0.0"
+ hast-util-from-parse5 "^8.0.0"
+ hast-util-to-parse5 "^8.0.0"
+ html-void-elements "^3.0.0"
+ mdast-util-to-hast "^13.0.0"
+ parse5 "^7.0.0"
+ unist-util-position "^5.0.0"
+ unist-util-visit "^5.0.0"
+ vfile "^6.0.0"
+ web-namespaces "^2.0.0"
+ zwitch "^2.0.0"
+
+hast-util-select@^6.0.2:
+ version "6.0.4"
+ resolved "https://registry.yarnpkg.com/hast-util-select/-/hast-util-select-6.0.4.tgz#1d8f69657a57441d0ce0ade35887874d3e65a303"
+ integrity sha512-RqGS1ZgI0MwxLaKLDxjprynNzINEkRHY2i8ln4DDjgv9ZhcYVIHN9rlpiYsqtFwrgpYU361SyWDQcGNIBVu3lw==
+ dependencies:
+ "@types/hast" "^3.0.0"
+ "@types/unist" "^3.0.0"
+ bcp-47-match "^2.0.0"
+ comma-separated-tokens "^2.0.0"
+ css-selector-parser "^3.0.0"
+ devlop "^1.0.0"
+ direction "^2.0.0"
+ hast-util-has-property "^3.0.0"
+ hast-util-to-string "^3.0.0"
+ hast-util-whitespace "^3.0.0"
+ nth-check "^2.0.0"
+ property-information "^7.0.0"
+ space-separated-tokens "^2.0.0"
+ unist-util-visit "^5.0.0"
+ zwitch "^2.0.0"
+
+hast-util-to-estree@^3.0.0:
+ version "3.1.3"
+ resolved "https://registry.yarnpkg.com/hast-util-to-estree/-/hast-util-to-estree-3.1.3.tgz#e654c1c9374645135695cc0ab9f70b8fcaf733d7"
+ integrity sha512-48+B/rJWAp0jamNbAAf9M7Uf//UVqAoMmgXhBdxTDJLGKY+LRnZ99qcG+Qjl5HfMpYNzS5v4EAwVEF34LeAj7w==
+ dependencies:
+ "@types/estree" "^1.0.0"
+ "@types/estree-jsx" "^1.0.0"
+ "@types/hast" "^3.0.0"
+ comma-separated-tokens "^2.0.0"
+ devlop "^1.0.0"
+ estree-util-attach-comments "^3.0.0"
+ estree-util-is-identifier-name "^3.0.0"
+ hast-util-whitespace "^3.0.0"
+ mdast-util-mdx-expression "^2.0.0"
+ mdast-util-mdx-jsx "^3.0.0"
+ mdast-util-mdxjs-esm "^2.0.0"
+ property-information "^7.0.0"
+ space-separated-tokens "^2.0.0"
+ style-to-js "^1.0.0"
+ unist-util-position "^5.0.0"
+ zwitch "^2.0.0"
+
+hast-util-to-html@^9.0.0, hast-util-to-html@^9.0.1, hast-util-to-html@^9.0.4, hast-util-to-html@^9.0.5:
+ version "9.0.5"
+ resolved "https://registry.yarnpkg.com/hast-util-to-html/-/hast-util-to-html-9.0.5.tgz#ccc673a55bb8e85775b08ac28380f72d47167005"
+ integrity sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==
+ dependencies:
+ "@types/hast" "^3.0.0"
+ "@types/unist" "^3.0.0"
+ ccount "^2.0.0"
+ comma-separated-tokens "^2.0.0"
+ hast-util-whitespace "^3.0.0"
+ html-void-elements "^3.0.0"
+ mdast-util-to-hast "^13.0.0"
+ property-information "^7.0.0"
+ space-separated-tokens "^2.0.0"
+ stringify-entities "^4.0.0"
+ zwitch "^2.0.4"
+
+hast-util-to-jsx-runtime@^2.0.0:
+ version "2.3.6"
+ resolved "https://registry.yarnpkg.com/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz#ff31897aae59f62232e21594eac7ef6b63333e98"
+ integrity sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==
+ dependencies:
+ "@types/estree" "^1.0.0"
+ "@types/hast" "^3.0.0"
+ "@types/unist" "^3.0.0"
+ comma-separated-tokens "^2.0.0"
+ devlop "^1.0.0"
+ estree-util-is-identifier-name "^3.0.0"
+ hast-util-whitespace "^3.0.0"
+ mdast-util-mdx-expression "^2.0.0"
+ mdast-util-mdx-jsx "^3.0.0"
+ mdast-util-mdxjs-esm "^2.0.0"
+ property-information "^7.0.0"
+ space-separated-tokens "^2.0.0"
+ style-to-js "^1.0.0"
+ unist-util-position "^5.0.0"
+ vfile-message "^4.0.0"
+
+hast-util-to-parse5@^8.0.0:
+ version "8.0.1"
+ resolved "https://registry.yarnpkg.com/hast-util-to-parse5/-/hast-util-to-parse5-8.0.1.tgz#95aa391cc0514b4951418d01c883d1038af42f5d"
+ integrity sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA==
+ dependencies:
+ "@types/hast" "^3.0.0"
+ comma-separated-tokens "^2.0.0"
+ devlop "^1.0.0"
+ property-information "^7.0.0"
+ space-separated-tokens "^2.0.0"
+ web-namespaces "^2.0.0"
+ zwitch "^2.0.0"
+
+hast-util-to-string@^3.0.0:
+ version "3.0.1"
+ resolved "https://registry.yarnpkg.com/hast-util-to-string/-/hast-util-to-string-3.0.1.tgz#a4f15e682849326dd211c97129c94b0c3e76527c"
+ integrity sha512-XelQVTDWvqcl3axRfI0xSeoVKzyIFPwsAGSLIsKdJKQMXDYJS4WYrBNF/8J7RdhIcFI2BOHgAifggsvsxp/3+A==
+ dependencies:
+ "@types/hast" "^3.0.0"
+
+hast-util-to-text@^4.0.1, hast-util-to-text@^4.0.2:
+ version "4.0.2"
+ resolved "https://registry.yarnpkg.com/hast-util-to-text/-/hast-util-to-text-4.0.2.tgz#57b676931e71bf9cb852453678495b3080bfae3e"
+ integrity sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A==
+ dependencies:
+ "@types/hast" "^3.0.0"
+ "@types/unist" "^3.0.0"
+ hast-util-is-element "^3.0.0"
+ unist-util-find-after "^5.0.0"
+
+hast-util-whitespace@^3.0.0:
+ version "3.0.0"
+ resolved "https://registry.yarnpkg.com/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz#7778ed9d3c92dd9e8c5c8f648a49c21fc51cb621"
+ integrity sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==
+ dependencies:
+ "@types/hast" "^3.0.0"
+
+hastscript@^9.0.0:
+ version "9.0.1"
+ resolved "https://registry.yarnpkg.com/hastscript/-/hastscript-9.0.1.tgz#dbc84bef6051d40084342c229c451cd9dc567dff"
+ integrity sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==
+ dependencies:
+ "@types/hast" "^3.0.0"
+ comma-separated-tokens "^2.0.0"
+ hast-util-parse-selector "^4.0.0"
+ property-information "^7.0.0"
+ space-separated-tokens "^2.0.0"
+
+html-escaper@3.0.3:
+ version "3.0.3"
+ resolved "https://registry.yarnpkg.com/html-escaper/-/html-escaper-3.0.3.tgz#4d336674652beb1dcbc29ef6b6ba7f6be6fdfed6"
+ integrity sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==
+
+html-void-elements@^3.0.0:
+ version "3.0.0"
+ resolved "https://registry.yarnpkg.com/html-void-elements/-/html-void-elements-3.0.0.tgz#fc9dbd84af9e747249034d4d62602def6517f1d7"
+ integrity sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==
+
+html-whitespace-sensitive-tag-names@^3.0.0:
+ version "3.0.1"
+ resolved "https://registry.yarnpkg.com/html-whitespace-sensitive-tag-names/-/html-whitespace-sensitive-tag-names-3.0.1.tgz#c35edd28205f3bf8c1fd03274608d60b923de5b2"
+ integrity sha512-q+310vW8zmymYHALr1da4HyXUQ0zgiIwIicEfotYPWGN0OJVEN/58IJ3A4GBYcEq3LGAZqKb+ugvP0GNB9CEAA==
+
+http-cache-semantics@^4.2.0:
+ version "4.2.0"
+ resolved "https://registry.yarnpkg.com/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz#205f4db64f8562b76a4ff9235aa5279839a09dd5"
+ integrity sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==
+
+i18next@^23.11.5:
+ version "23.16.8"
+ resolved "https://registry.yarnpkg.com/i18next/-/i18next-23.16.8.tgz#3ae1373d344c2393f465556f394aba5a9233b93a"
+ integrity sha512-06r/TitrM88Mg5FdUXAKL96dJMzgqLE5dv3ryBAra4KCwD9mJ4ndOTS95ZuymIGoE+2hzfdaMak2X11/es7ZWg==
+ dependencies:
+ "@babel/runtime" "^7.23.2"
+
+import-meta-resolve@^4.2.0:
+ version "4.2.0"
+ resolved "https://registry.yarnpkg.com/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz#08cb85b5bd37ecc8eb1e0f670dc2767002d43734"
+ integrity sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==
+
+inline-style-parser@0.2.7:
+ version "0.2.7"
+ resolved "https://registry.yarnpkg.com/inline-style-parser/-/inline-style-parser-0.2.7.tgz#b1fc68bfc0313b8685745e4464e37f9376b9c909"
+ integrity sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==
+
+iron-webcrypto@^1.2.1:
+ version "1.2.1"
+ resolved "https://registry.yarnpkg.com/iron-webcrypto/-/iron-webcrypto-1.2.1.tgz#aa60ff2aa10550630f4c0b11fd2442becdb35a6f"
+ integrity sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg==
+
+is-alphabetical@^2.0.0:
+ version "2.0.1"
+ resolved "https://registry.yarnpkg.com/is-alphabetical/-/is-alphabetical-2.0.1.tgz#01072053ea7c1036df3c7d19a6daaec7f19e789b"
+ integrity sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==
+
+is-alphanumerical@^2.0.0:
+ version "2.0.1"
+ resolved "https://registry.yarnpkg.com/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz#7c03fbe96e3e931113e57f964b0a368cc2dfd875"
+ integrity sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==
+ dependencies:
+ is-alphabetical "^2.0.0"
+ is-decimal "^2.0.0"
+
+is-arrayish@^0.3.1:
+ version "0.3.4"
+ resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.3.4.tgz#1ee5553818511915685d33bb13d31bf854e5059d"
+ integrity sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==
+
+is-decimal@^2.0.0:
+ version "2.0.1"
+ resolved "https://registry.yarnpkg.com/is-decimal/-/is-decimal-2.0.1.tgz#9469d2dc190d0214fd87d78b78caecc0cc14eef7"
+ integrity sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==
+
+is-docker@^3.0.0:
+ version "3.0.0"
+ resolved "https://registry.yarnpkg.com/is-docker/-/is-docker-3.0.0.tgz#90093aa3106277d8a77a5910dbae71747e15a200"
+ integrity sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==
+
+is-fullwidth-code-point@^3.0.0:
+ version "3.0.0"
+ resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz#f116f8064fe90b3f7844a38997c0b75051269f1d"
+ integrity sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==
+
+is-hexadecimal@^2.0.0:
+ version "2.0.1"
+ resolved "https://registry.yarnpkg.com/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz#86b5bf668fca307498d319dfc03289d781a90027"
+ integrity sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==
+
+is-inside-container@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/is-inside-container/-/is-inside-container-1.0.0.tgz#e81fba699662eb31dbdaf26766a61d4814717ea4"
+ integrity sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==
+ dependencies:
+ is-docker "^3.0.0"
+
+is-plain-obj@^4.0.0:
+ version "4.1.0"
+ resolved "https://registry.yarnpkg.com/is-plain-obj/-/is-plain-obj-4.1.0.tgz#d65025edec3657ce032fd7db63c97883eaed71f0"
+ integrity sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==
+
+is-wsl@^3.1.0:
+ version "3.1.0"
+ resolved "https://registry.yarnpkg.com/is-wsl/-/is-wsl-3.1.0.tgz#e1c657e39c10090afcbedec61720f6b924c3cbd2"
+ integrity sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw==
+ dependencies:
+ is-inside-container "^1.0.0"
+
+js-yaml@^4.1.0, js-yaml@^4.1.1:
+ version "4.1.1"
+ resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.1.1.tgz#854c292467705b699476e1a2decc0c8a3458806b"
+ integrity sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==
+ dependencies:
+ argparse "^2.0.1"
+
+kleur@^3.0.3:
+ version "3.0.3"
+ resolved "https://registry.yarnpkg.com/kleur/-/kleur-3.0.3.tgz#a79c9ecc86ee1ce3fa6206d1216c501f147fc07e"
+ integrity sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==
+
+longest-streak@^3.0.0:
+ version "3.1.0"
+ resolved "https://registry.yarnpkg.com/longest-streak/-/longest-streak-3.1.0.tgz#62fa67cd958742a1574af9f39866364102d90cd4"
+ integrity sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==
+
+lru-cache@^10.4.3:
+ version "10.4.3"
+ resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-10.4.3.tgz#410fc8a17b70e598013df257c2446b7f3383f119"
+ integrity sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==
+
+magic-string@^0.30.21:
+ version "0.30.21"
+ resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.30.21.tgz#56763ec09a0fa8091df27879fd94d19078c00d91"
+ integrity sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==
+ dependencies:
+ "@jridgewell/sourcemap-codec" "^1.5.5"
+
+magicast@^0.5.1:
+ version "0.5.1"
+ resolved "https://registry.yarnpkg.com/magicast/-/magicast-0.5.1.tgz#518959aea78851cd35d4bb0da92f780db3f606d3"
+ integrity sha512-xrHS24IxaLrvuo613F719wvOIv9xPHFWQHuvGUBmPnCA/3MQxKI3b+r7n1jAoDHmsbC5bRhTZYR77invLAxVnw==
+ dependencies:
+ "@babel/parser" "^7.28.5"
+ "@babel/types" "^7.28.5"
+ source-map-js "^1.2.1"
+
+markdown-extensions@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/markdown-extensions/-/markdown-extensions-2.0.0.tgz#34bebc83e9938cae16e0e017e4a9814a8330d3c4"
+ integrity sha512-o5vL7aDWatOTX8LzaS1WMoaoxIiLRQJuIKKe2wAw6IeULDHaqbiqiggmx+pKvZDb1Sj+pE46Sn1T7lCqfFtg1Q==
+
+markdown-table@^3.0.0:
+ version "3.0.4"
+ resolved "https://registry.yarnpkg.com/markdown-table/-/markdown-table-3.0.4.tgz#fe44d6d410ff9d6f2ea1797a3f60aa4d2b631c2a"
+ integrity sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==
+
+mdast-util-definitions@^6.0.0:
+ version "6.0.0"
+ resolved "https://registry.yarnpkg.com/mdast-util-definitions/-/mdast-util-definitions-6.0.0.tgz#c1bb706e5e76bb93f9a09dd7af174002ae69ac24"
+ integrity sha512-scTllyX6pnYNZH/AIp/0ePz6s4cZtARxImwoPJ7kS42n+MnVsI4XbnG6d4ibehRIldYMWM2LD7ImQblVhUejVQ==
+ dependencies:
+ "@types/mdast" "^4.0.0"
+ "@types/unist" "^3.0.0"
+ unist-util-visit "^5.0.0"
+
+mdast-util-directive@^3.0.0:
+ version "3.1.0"
+ resolved "https://registry.yarnpkg.com/mdast-util-directive/-/mdast-util-directive-3.1.0.tgz#f3656f4aab6ae3767d3c72cfab5e8055572ccba1"
+ integrity sha512-I3fNFt+DHmpWCYAT7quoM6lHf9wuqtI+oCOfvILnoicNIqjh5E3dEJWiXuYME2gNe8vl1iMQwyUHa7bgFmak6Q==
+ dependencies:
+ "@types/mdast" "^4.0.0"
+ "@types/unist" "^3.0.0"
+ ccount "^2.0.0"
+ devlop "^1.0.0"
+ mdast-util-from-markdown "^2.0.0"
+ mdast-util-to-markdown "^2.0.0"
+ parse-entities "^4.0.0"
+ stringify-entities "^4.0.0"
+ unist-util-visit-parents "^6.0.0"
+
+mdast-util-find-and-replace@^3.0.0:
+ version "3.0.2"
+ resolved "https://registry.yarnpkg.com/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz#70a3174c894e14df722abf43bc250cbae44b11df"
+ integrity sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==
+ dependencies:
+ "@types/mdast" "^4.0.0"
+ escape-string-regexp "^5.0.0"
+ unist-util-is "^6.0.0"
+ unist-util-visit-parents "^6.0.0"
+
+mdast-util-from-markdown@^2.0.0:
+ version "2.0.2"
+ resolved "https://registry.yarnpkg.com/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.2.tgz#4850390ca7cf17413a9b9a0fbefcd1bc0eb4160a"
+ integrity sha512-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA==
+ dependencies:
+ "@types/mdast" "^4.0.0"
+ "@types/unist" "^3.0.0"
+ decode-named-character-reference "^1.0.0"
+ devlop "^1.0.0"
+ mdast-util-to-string "^4.0.0"
+ micromark "^4.0.0"
+ micromark-util-decode-numeric-character-reference "^2.0.0"
+ micromark-util-decode-string "^2.0.0"
+ micromark-util-normalize-identifier "^2.0.0"
+ micromark-util-symbol "^2.0.0"
+ micromark-util-types "^2.0.0"
+ unist-util-stringify-position "^4.0.0"
+
+mdast-util-gfm-autolink-literal@^2.0.0:
+ version "2.0.1"
+ resolved "https://registry.yarnpkg.com/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz#abd557630337bd30a6d5a4bd8252e1c2dc0875d5"
+ integrity sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==
+ dependencies:
+ "@types/mdast" "^4.0.0"
+ ccount "^2.0.0"
+ devlop "^1.0.0"
+ mdast-util-find-and-replace "^3.0.0"
+ micromark-util-character "^2.0.0"
+
+mdast-util-gfm-footnote@^2.0.0:
+ version "2.1.0"
+ resolved "https://registry.yarnpkg.com/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz#7778e9d9ca3df7238cc2bd3fa2b1bf6a65b19403"
+ integrity sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==
+ dependencies:
+ "@types/mdast" "^4.0.0"
+ devlop "^1.1.0"
+ mdast-util-from-markdown "^2.0.0"
+ mdast-util-to-markdown "^2.0.0"
+ micromark-util-normalize-identifier "^2.0.0"
+
+mdast-util-gfm-strikethrough@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz#d44ef9e8ed283ac8c1165ab0d0dfd058c2764c16"
+ integrity sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==
+ dependencies:
+ "@types/mdast" "^4.0.0"
+ mdast-util-from-markdown "^2.0.0"
+ mdast-util-to-markdown "^2.0.0"
+
+mdast-util-gfm-table@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz#7a435fb6223a72b0862b33afbd712b6dae878d38"
+ integrity sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==
+ dependencies:
+ "@types/mdast" "^4.0.0"
+ devlop "^1.0.0"
+ markdown-table "^3.0.0"
+ mdast-util-from-markdown "^2.0.0"
+ mdast-util-to-markdown "^2.0.0"
+
+mdast-util-gfm-task-list-item@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz#e68095d2f8a4303ef24094ab642e1047b991a936"
+ integrity sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==
+ dependencies:
+ "@types/mdast" "^4.0.0"
+ devlop "^1.0.0"
+ mdast-util-from-markdown "^2.0.0"
+ mdast-util-to-markdown "^2.0.0"
+
+mdast-util-gfm@^3.0.0:
+ version "3.1.0"
+ resolved "https://registry.yarnpkg.com/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz#2cdf63b92c2a331406b0fb0db4c077c1b0331751"
+ integrity sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==
+ dependencies:
+ mdast-util-from-markdown "^2.0.0"
+ mdast-util-gfm-autolink-literal "^2.0.0"
+ mdast-util-gfm-footnote "^2.0.0"
+ mdast-util-gfm-strikethrough "^2.0.0"
+ mdast-util-gfm-table "^2.0.0"
+ mdast-util-gfm-task-list-item "^2.0.0"
+ mdast-util-to-markdown "^2.0.0"
+
+mdast-util-mdx-expression@^2.0.0:
+ version "2.0.1"
+ resolved "https://registry.yarnpkg.com/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz#43f0abac9adc756e2086f63822a38c8d3c3a5096"
+ integrity sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==
+ dependencies:
+ "@types/estree-jsx" "^1.0.0"
+ "@types/hast" "^3.0.0"
+ "@types/mdast" "^4.0.0"
+ devlop "^1.0.0"
+ mdast-util-from-markdown "^2.0.0"
+ mdast-util-to-markdown "^2.0.0"
+
+mdast-util-mdx-jsx@^3.0.0:
+ version "3.2.0"
+ resolved "https://registry.yarnpkg.com/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz#fd04c67a2a7499efb905a8a5c578dddc9fdada0d"
+ integrity sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==
+ dependencies:
+ "@types/estree-jsx" "^1.0.0"
+ "@types/hast" "^3.0.0"
+ "@types/mdast" "^4.0.0"
+ "@types/unist" "^3.0.0"
+ ccount "^2.0.0"
+ devlop "^1.1.0"
+ mdast-util-from-markdown "^2.0.0"
+ mdast-util-to-markdown "^2.0.0"
+ parse-entities "^4.0.0"
+ stringify-entities "^4.0.0"
+ unist-util-stringify-position "^4.0.0"
+ vfile-message "^4.0.0"
+
+mdast-util-mdx@^3.0.0:
+ version "3.0.0"
+ resolved "https://registry.yarnpkg.com/mdast-util-mdx/-/mdast-util-mdx-3.0.0.tgz#792f9cf0361b46bee1fdf1ef36beac424a099c41"
+ integrity sha512-JfbYLAW7XnYTTbUsmpu0kdBUVe+yKVJZBItEjwyYJiDJuZ9w4eeaqks4HQO+R7objWgS2ymV60GYpI14Ug554w==
+ dependencies:
+ mdast-util-from-markdown "^2.0.0"
+ mdast-util-mdx-expression "^2.0.0"
+ mdast-util-mdx-jsx "^3.0.0"
+ mdast-util-mdxjs-esm "^2.0.0"
+ mdast-util-to-markdown "^2.0.0"
+
+mdast-util-mdxjs-esm@^2.0.0:
+ version "2.0.1"
+ resolved "https://registry.yarnpkg.com/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz#019cfbe757ad62dd557db35a695e7314bcc9fa97"
+ integrity sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==
+ dependencies:
+ "@types/estree-jsx" "^1.0.0"
+ "@types/hast" "^3.0.0"
+ "@types/mdast" "^4.0.0"
+ devlop "^1.0.0"
+ mdast-util-from-markdown "^2.0.0"
+ mdast-util-to-markdown "^2.0.0"
+
+mdast-util-phrasing@^4.0.0:
+ version "4.1.0"
+ resolved "https://registry.yarnpkg.com/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz#7cc0a8dec30eaf04b7b1a9661a92adb3382aa6e3"
+ integrity sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==
+ dependencies:
+ "@types/mdast" "^4.0.0"
+ unist-util-is "^6.0.0"
+
+mdast-util-to-hast@^13.0.0:
+ version "13.2.1"
+ resolved "https://registry.yarnpkg.com/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz#d7ff84ca499a57e2c060ae67548ad950e689a053"
+ integrity sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==
+ dependencies:
+ "@types/hast" "^3.0.0"
+ "@types/mdast" "^4.0.0"
+ "@ungap/structured-clone" "^1.0.0"
+ devlop "^1.0.0"
+ micromark-util-sanitize-uri "^2.0.0"
+ trim-lines "^3.0.0"
+ unist-util-position "^5.0.0"
+ unist-util-visit "^5.0.0"
+ vfile "^6.0.0"
+
+mdast-util-to-markdown@^2.0.0, mdast-util-to-markdown@^2.1.0:
+ version "2.1.2"
+ resolved "https://registry.yarnpkg.com/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz#f910ffe60897f04bb4b7e7ee434486f76288361b"
+ integrity sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==
+ dependencies:
+ "@types/mdast" "^4.0.0"
+ "@types/unist" "^3.0.0"
+ longest-streak "^3.0.0"
+ mdast-util-phrasing "^4.0.0"
+ mdast-util-to-string "^4.0.0"
+ micromark-util-classify-character "^2.0.0"
+ micromark-util-decode-string "^2.0.0"
+ unist-util-visit "^5.0.0"
+ zwitch "^2.0.0"
+
+mdast-util-to-string@^4.0.0:
+ version "4.0.0"
+ resolved "https://registry.yarnpkg.com/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz#7a5121475556a04e7eddeb67b264aae79d312814"
+ integrity sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==
+ dependencies:
+ "@types/mdast" "^4.0.0"
+
+mdn-data@2.0.28:
+ version "2.0.28"
+ resolved "https://registry.yarnpkg.com/mdn-data/-/mdn-data-2.0.28.tgz#5ec48e7bef120654539069e1ae4ddc81ca490eba"
+ integrity sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==
+
+mdn-data@2.12.2:
+ version "2.12.2"
+ resolved "https://registry.yarnpkg.com/mdn-data/-/mdn-data-2.12.2.tgz#9ae6c41a9e65adf61318b32bff7b64fbfb13f8cf"
+ integrity sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==
+
+micromark-core-commonmark@^2.0.0:
+ version "2.0.3"
+ resolved "https://registry.yarnpkg.com/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz#c691630e485021a68cf28dbc2b2ca27ebf678cd4"
+ integrity sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==
+ dependencies:
+ decode-named-character-reference "^1.0.0"
+ devlop "^1.0.0"
+ micromark-factory-destination "^2.0.0"
+ micromark-factory-label "^2.0.0"
+ micromark-factory-space "^2.0.0"
+ micromark-factory-title "^2.0.0"
+ micromark-factory-whitespace "^2.0.0"
+ micromark-util-character "^2.0.0"
+ micromark-util-chunked "^2.0.0"
+ micromark-util-classify-character "^2.0.0"
+ micromark-util-html-tag-name "^2.0.0"
+ micromark-util-normalize-identifier "^2.0.0"
+ micromark-util-resolve-all "^2.0.0"
+ micromark-util-subtokenize "^2.0.0"
+ micromark-util-symbol "^2.0.0"
+ micromark-util-types "^2.0.0"
+
+micromark-extension-directive@^3.0.0:
+ version "3.0.2"
+ resolved "https://registry.yarnpkg.com/micromark-extension-directive/-/micromark-extension-directive-3.0.2.tgz#2eb61985d1995a7c1ff7621676a4f32af29409e8"
+ integrity sha512-wjcXHgk+PPdmvR58Le9d7zQYWy+vKEU9Se44p2CrCDPiLr2FMyiT4Fyb5UFKFC66wGB3kPlgD7q3TnoqPS7SZA==
+ dependencies:
+ devlop "^1.0.0"
+ micromark-factory-space "^2.0.0"
+ micromark-factory-whitespace "^2.0.0"
+ micromark-util-character "^2.0.0"
+ micromark-util-symbol "^2.0.0"
+ micromark-util-types "^2.0.0"
+ parse-entities "^4.0.0"
+
+micromark-extension-gfm-autolink-literal@^2.0.0:
+ version "2.1.0"
+ resolved "https://registry.yarnpkg.com/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz#6286aee9686c4462c1e3552a9d505feddceeb935"
+ integrity sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==
+ dependencies:
+ micromark-util-character "^2.0.0"
+ micromark-util-sanitize-uri "^2.0.0"
+ micromark-util-symbol "^2.0.0"
+ micromark-util-types "^2.0.0"
+
+micromark-extension-gfm-footnote@^2.0.0:
+ version "2.1.0"
+ resolved "https://registry.yarnpkg.com/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz#4dab56d4e398b9853f6fe4efac4fc9361f3e0750"
+ integrity sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==
+ dependencies:
+ devlop "^1.0.0"
+ micromark-core-commonmark "^2.0.0"
+ micromark-factory-space "^2.0.0"
+ micromark-util-character "^2.0.0"
+ micromark-util-normalize-identifier "^2.0.0"
+ micromark-util-sanitize-uri "^2.0.0"
+ micromark-util-symbol "^2.0.0"
+ micromark-util-types "^2.0.0"
+
+micromark-extension-gfm-strikethrough@^2.0.0:
+ version "2.1.0"
+ resolved "https://registry.yarnpkg.com/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz#86106df8b3a692b5f6a92280d3879be6be46d923"
+ integrity sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==
+ dependencies:
+ devlop "^1.0.0"
+ micromark-util-chunked "^2.0.0"
+ micromark-util-classify-character "^2.0.0"
+ micromark-util-resolve-all "^2.0.0"
+ micromark-util-symbol "^2.0.0"
+ micromark-util-types "^2.0.0"
+
+micromark-extension-gfm-table@^2.0.0:
+ version "2.1.1"
+ resolved "https://registry.yarnpkg.com/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz#fac70bcbf51fe65f5f44033118d39be8a9b5940b"
+ integrity sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==
+ dependencies:
+ devlop "^1.0.0"
+ micromark-factory-space "^2.0.0"
+ micromark-util-character "^2.0.0"
+ micromark-util-symbol "^2.0.0"
+ micromark-util-types "^2.0.0"
+
+micromark-extension-gfm-tagfilter@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz#f26d8a7807b5985fba13cf61465b58ca5ff7dc57"
+ integrity sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==
+ dependencies:
+ micromark-util-types "^2.0.0"
+
+micromark-extension-gfm-task-list-item@^2.0.0:
+ version "2.1.0"
+ resolved "https://registry.yarnpkg.com/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz#bcc34d805639829990ec175c3eea12bb5b781f2c"
+ integrity sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==
+ dependencies:
+ devlop "^1.0.0"
+ micromark-factory-space "^2.0.0"
+ micromark-util-character "^2.0.0"
+ micromark-util-symbol "^2.0.0"
+ micromark-util-types "^2.0.0"
+
+micromark-extension-gfm@^3.0.0:
+ version "3.0.0"
+ resolved "https://registry.yarnpkg.com/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz#3e13376ab95dd7a5cfd0e29560dfe999657b3c5b"
+ integrity sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==
+ dependencies:
+ micromark-extension-gfm-autolink-literal "^2.0.0"
+ micromark-extension-gfm-footnote "^2.0.0"
+ micromark-extension-gfm-strikethrough "^2.0.0"
+ micromark-extension-gfm-table "^2.0.0"
+ micromark-extension-gfm-tagfilter "^2.0.0"
+ micromark-extension-gfm-task-list-item "^2.0.0"
+ micromark-util-combine-extensions "^2.0.0"
+ micromark-util-types "^2.0.0"
+
+micromark-extension-mdx-expression@^3.0.0:
+ version "3.0.1"
+ resolved "https://registry.yarnpkg.com/micromark-extension-mdx-expression/-/micromark-extension-mdx-expression-3.0.1.tgz#43d058d999532fb3041195a3c3c05c46fa84543b"
+ integrity sha512-dD/ADLJ1AeMvSAKBwO22zG22N4ybhe7kFIZ3LsDI0GlsNr2A3KYxb0LdC1u5rj4Nw+CHKY0RVdnHX8vj8ejm4Q==
+ dependencies:
+ "@types/estree" "^1.0.0"
+ devlop "^1.0.0"
+ micromark-factory-mdx-expression "^2.0.0"
+ micromark-factory-space "^2.0.0"
+ micromark-util-character "^2.0.0"
+ micromark-util-events-to-acorn "^2.0.0"
+ micromark-util-symbol "^2.0.0"
+ micromark-util-types "^2.0.0"
+
+micromark-extension-mdx-jsx@^3.0.0:
+ version "3.0.2"
+ resolved "https://registry.yarnpkg.com/micromark-extension-mdx-jsx/-/micromark-extension-mdx-jsx-3.0.2.tgz#ffc98bdb649798902fa9fc5689f67f9c1c902044"
+ integrity sha512-e5+q1DjMh62LZAJOnDraSSbDMvGJ8x3cbjygy2qFEi7HCeUT4BDKCvMozPozcD6WmOt6sVvYDNBKhFSz3kjOVQ==
+ dependencies:
+ "@types/estree" "^1.0.0"
+ devlop "^1.0.0"
+ estree-util-is-identifier-name "^3.0.0"
+ micromark-factory-mdx-expression "^2.0.0"
+ micromark-factory-space "^2.0.0"
+ micromark-util-character "^2.0.0"
+ micromark-util-events-to-acorn "^2.0.0"
+ micromark-util-symbol "^2.0.0"
+ micromark-util-types "^2.0.0"
+ vfile-message "^4.0.0"
+
+micromark-extension-mdx-md@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/micromark-extension-mdx-md/-/micromark-extension-mdx-md-2.0.0.tgz#1d252881ea35d74698423ab44917e1f5b197b92d"
+ integrity sha512-EpAiszsB3blw4Rpba7xTOUptcFeBFi+6PY8VnJ2hhimH+vCQDirWgsMpz7w1XcZE7LVrSAUGb9VJpG9ghlYvYQ==
+ dependencies:
+ micromark-util-types "^2.0.0"
+
+micromark-extension-mdxjs-esm@^3.0.0:
+ version "3.0.0"
+ resolved "https://registry.yarnpkg.com/micromark-extension-mdxjs-esm/-/micromark-extension-mdxjs-esm-3.0.0.tgz#de21b2b045fd2059bd00d36746081de38390d54a"
+ integrity sha512-DJFl4ZqkErRpq/dAPyeWp15tGrcrrJho1hKK5uBS70BCtfrIFg81sqcTVu3Ta+KD1Tk5vAtBNElWxtAa+m8K9A==
+ dependencies:
+ "@types/estree" "^1.0.0"
+ devlop "^1.0.0"
+ micromark-core-commonmark "^2.0.0"
+ micromark-util-character "^2.0.0"
+ micromark-util-events-to-acorn "^2.0.0"
+ micromark-util-symbol "^2.0.0"
+ micromark-util-types "^2.0.0"
+ unist-util-position-from-estree "^2.0.0"
+ vfile-message "^4.0.0"
+
+micromark-extension-mdxjs@^3.0.0:
+ version "3.0.0"
+ resolved "https://registry.yarnpkg.com/micromark-extension-mdxjs/-/micromark-extension-mdxjs-3.0.0.tgz#b5a2e0ed449288f3f6f6c544358159557549de18"
+ integrity sha512-A873fJfhnJ2siZyUrJ31l34Uqwy4xIFmvPY1oj+Ean5PHcPBYzEsvqvWGaWcfEIr11O5Dlw3p2y0tZWpKHDejQ==
+ dependencies:
+ acorn "^8.0.0"
+ acorn-jsx "^5.0.0"
+ micromark-extension-mdx-expression "^3.0.0"
+ micromark-extension-mdx-jsx "^3.0.0"
+ micromark-extension-mdx-md "^2.0.0"
+ micromark-extension-mdxjs-esm "^3.0.0"
+ micromark-util-combine-extensions "^2.0.0"
+ micromark-util-types "^2.0.0"
+
+micromark-factory-destination@^2.0.0:
+ version "2.0.1"
+ resolved "https://registry.yarnpkg.com/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz#8fef8e0f7081f0474fbdd92deb50c990a0264639"
+ integrity sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==
+ dependencies:
+ micromark-util-character "^2.0.0"
+ micromark-util-symbol "^2.0.0"
+ micromark-util-types "^2.0.0"
+
+micromark-factory-label@^2.0.0:
+ version "2.0.1"
+ resolved "https://registry.yarnpkg.com/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz#5267efa97f1e5254efc7f20b459a38cb21058ba1"
+ integrity sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==
+ dependencies:
+ devlop "^1.0.0"
+ micromark-util-character "^2.0.0"
+ micromark-util-symbol "^2.0.0"
+ micromark-util-types "^2.0.0"
+
+micromark-factory-mdx-expression@^2.0.0:
+ version "2.0.3"
+ resolved "https://registry.yarnpkg.com/micromark-factory-mdx-expression/-/micromark-factory-mdx-expression-2.0.3.tgz#bb09988610589c07d1c1e4425285895041b3dfa9"
+ integrity sha512-kQnEtA3vzucU2BkrIa8/VaSAsP+EJ3CKOvhMuJgOEGg9KDC6OAY6nSnNDVRiVNRqj7Y4SlSzcStaH/5jge8JdQ==
+ dependencies:
+ "@types/estree" "^1.0.0"
+ devlop "^1.0.0"
+ micromark-factory-space "^2.0.0"
+ micromark-util-character "^2.0.0"
+ micromark-util-events-to-acorn "^2.0.0"
+ micromark-util-symbol "^2.0.0"
+ micromark-util-types "^2.0.0"
+ unist-util-position-from-estree "^2.0.0"
+ vfile-message "^4.0.0"
+
+micromark-factory-space@^2.0.0:
+ version "2.0.1"
+ resolved "https://registry.yarnpkg.com/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz#36d0212e962b2b3121f8525fc7a3c7c029f334fc"
+ integrity sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==
+ dependencies:
+ micromark-util-character "^2.0.0"
+ micromark-util-types "^2.0.0"
+
+micromark-factory-title@^2.0.0:
+ version "2.0.1"
+ resolved "https://registry.yarnpkg.com/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz#237e4aa5d58a95863f01032d9ee9b090f1de6e94"
+ integrity sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==
+ dependencies:
+ micromark-factory-space "^2.0.0"
+ micromark-util-character "^2.0.0"
+ micromark-util-symbol "^2.0.0"
+ micromark-util-types "^2.0.0"
+
+micromark-factory-whitespace@^2.0.0:
+ version "2.0.1"
+ resolved "https://registry.yarnpkg.com/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz#06b26b2983c4d27bfcc657b33e25134d4868b0b1"
+ integrity sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==
+ dependencies:
+ micromark-factory-space "^2.0.0"
+ micromark-util-character "^2.0.0"
+ micromark-util-symbol "^2.0.0"
+ micromark-util-types "^2.0.0"
+
+micromark-util-character@^2.0.0:
+ version "2.1.1"
+ resolved "https://registry.yarnpkg.com/micromark-util-character/-/micromark-util-character-2.1.1.tgz#2f987831a40d4c510ac261e89852c4e9703ccda6"
+ integrity sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==
+ dependencies:
+ micromark-util-symbol "^2.0.0"
+ micromark-util-types "^2.0.0"
+
+micromark-util-chunked@^2.0.0:
+ version "2.0.1"
+ resolved "https://registry.yarnpkg.com/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz#47fbcd93471a3fccab86cff03847fc3552db1051"
+ integrity sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==
+ dependencies:
+ micromark-util-symbol "^2.0.0"
+
+micromark-util-classify-character@^2.0.0:
+ version "2.0.1"
+ resolved "https://registry.yarnpkg.com/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz#d399faf9c45ca14c8b4be98b1ea481bced87b629"
+ integrity sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==
+ dependencies:
+ micromark-util-character "^2.0.0"
+ micromark-util-symbol "^2.0.0"
+ micromark-util-types "^2.0.0"
+
+micromark-util-combine-extensions@^2.0.0:
+ version "2.0.1"
+ resolved "https://registry.yarnpkg.com/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz#2a0f490ab08bff5cc2fd5eec6dd0ca04f89b30a9"
+ integrity sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==
+ dependencies:
+ micromark-util-chunked "^2.0.0"
+ micromark-util-types "^2.0.0"
+
+micromark-util-decode-numeric-character-reference@^2.0.0:
+ version "2.0.2"
+ resolved "https://registry.yarnpkg.com/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz#fcf15b660979388e6f118cdb6bf7d79d73d26fe5"
+ integrity sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==
+ dependencies:
+ micromark-util-symbol "^2.0.0"
+
+micromark-util-decode-string@^2.0.0:
+ version "2.0.1"
+ resolved "https://registry.yarnpkg.com/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz#6cb99582e5d271e84efca8e61a807994d7161eb2"
+ integrity sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==
+ dependencies:
+ decode-named-character-reference "^1.0.0"
+ micromark-util-character "^2.0.0"
+ micromark-util-decode-numeric-character-reference "^2.0.0"
+ micromark-util-symbol "^2.0.0"
+
+micromark-util-encode@^2.0.0:
+ version "2.0.1"
+ resolved "https://registry.yarnpkg.com/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz#0d51d1c095551cfaac368326963cf55f15f540b8"
+ integrity sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==
+
+micromark-util-events-to-acorn@^2.0.0:
+ version "2.0.3"
+ resolved "https://registry.yarnpkg.com/micromark-util-events-to-acorn/-/micromark-util-events-to-acorn-2.0.3.tgz#e7a8a6b55a47e5a06c720d5a1c4abae8c37c98f3"
+ integrity sha512-jmsiEIiZ1n7X1Rr5k8wVExBQCg5jy4UXVADItHmNk1zkwEVhBuIUKRu3fqv+hs4nxLISi2DQGlqIOGiFxgbfHg==
+ dependencies:
+ "@types/estree" "^1.0.0"
+ "@types/unist" "^3.0.0"
+ devlop "^1.0.0"
+ estree-util-visit "^2.0.0"
+ micromark-util-symbol "^2.0.0"
+ micromark-util-types "^2.0.0"
+ vfile-message "^4.0.0"
+
+micromark-util-html-tag-name@^2.0.0:
+ version "2.0.1"
+ resolved "https://registry.yarnpkg.com/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz#e40403096481986b41c106627f98f72d4d10b825"
+ integrity sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==
+
+micromark-util-normalize-identifier@^2.0.0:
+ version "2.0.1"
+ resolved "https://registry.yarnpkg.com/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz#c30d77b2e832acf6526f8bf1aa47bc9c9438c16d"
+ integrity sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==
+ dependencies:
+ micromark-util-symbol "^2.0.0"
+
+micromark-util-resolve-all@^2.0.0:
+ version "2.0.1"
+ resolved "https://registry.yarnpkg.com/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz#e1a2d62cdd237230a2ae11839027b19381e31e8b"
+ integrity sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==
+ dependencies:
+ micromark-util-types "^2.0.0"
+
+micromark-util-sanitize-uri@^2.0.0:
+ version "2.0.1"
+ resolved "https://registry.yarnpkg.com/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz#ab89789b818a58752b73d6b55238621b7faa8fd7"
+ integrity sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==
+ dependencies:
+ micromark-util-character "^2.0.0"
+ micromark-util-encode "^2.0.0"
+ micromark-util-symbol "^2.0.0"
+
+micromark-util-subtokenize@^2.0.0:
+ version "2.1.0"
+ resolved "https://registry.yarnpkg.com/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz#d8ade5ba0f3197a1cf6a2999fbbfe6357a1a19ee"
+ integrity sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==
+ dependencies:
+ devlop "^1.0.0"
+ micromark-util-chunked "^2.0.0"
+ micromark-util-symbol "^2.0.0"
+ micromark-util-types "^2.0.0"
+
+micromark-util-symbol@^2.0.0:
+ version "2.0.1"
+ resolved "https://registry.yarnpkg.com/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz#e5da494e8eb2b071a0d08fb34f6cefec6c0a19b8"
+ integrity sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==
+
+micromark-util-types@^2.0.0:
+ version "2.0.2"
+ resolved "https://registry.yarnpkg.com/micromark-util-types/-/micromark-util-types-2.0.2.tgz#f00225f5f5a0ebc3254f96c36b6605c4b393908e"
+ integrity sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==
+
+micromark@^4.0.0:
+ version "4.0.2"
+ resolved "https://registry.yarnpkg.com/micromark/-/micromark-4.0.2.tgz#91395a3e1884a198e62116e33c9c568e39936fdb"
+ integrity sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==
+ dependencies:
+ "@types/debug" "^4.0.0"
+ debug "^4.0.0"
+ decode-named-character-reference "^1.0.0"
+ devlop "^1.0.0"
+ micromark-core-commonmark "^2.0.0"
+ micromark-factory-space "^2.0.0"
+ micromark-util-character "^2.0.0"
+ micromark-util-chunked "^2.0.0"
+ micromark-util-combine-extensions "^2.0.0"
+ micromark-util-decode-numeric-character-reference "^2.0.0"
+ micromark-util-encode "^2.0.0"
+ micromark-util-normalize-identifier "^2.0.0"
+ micromark-util-resolve-all "^2.0.0"
+ micromark-util-sanitize-uri "^2.0.0"
+ micromark-util-subtokenize "^2.0.0"
+ micromark-util-symbol "^2.0.0"
+ micromark-util-types "^2.0.0"
+
+mrmime@^2.0.1:
+ version "2.0.1"
+ resolved "https://registry.yarnpkg.com/mrmime/-/mrmime-2.0.1.tgz#bc3e87f7987853a54c9850eeb1f1078cd44adddc"
+ integrity sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==
+
+ms@^2.1.3:
+ version "2.1.3"
+ resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2"
+ integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==
+
+nanoid@^3.3.11:
+ version "3.3.11"
+ resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.11.tgz#4f4f112cefbe303202f2199838128936266d185b"
+ integrity sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==
+
+neotraverse@^0.6.18:
+ version "0.6.18"
+ resolved "https://registry.yarnpkg.com/neotraverse/-/neotraverse-0.6.18.tgz#abcb33dda2e8e713cf6321b29405e822230cdb30"
+ integrity sha512-Z4SmBUweYa09+o6pG+eASabEpP6QkQ70yHj351pQoEXIs8uHbaU2DWVmzBANKgflPa47A50PtB2+NgRpQvr7vA==
+
+nlcst-to-string@^4.0.0:
+ version "4.0.0"
+ resolved "https://registry.yarnpkg.com/nlcst-to-string/-/nlcst-to-string-4.0.0.tgz#05511e8461ebfb415952eb0b7e9a1a7d40471bd4"
+ integrity sha512-YKLBCcUYKAg0FNlOBT6aI91qFmSiFKiluk655WzPF+DDMA02qIyy8uiRqI8QXtcFpEvll12LpL5MXqEmAZ+dcA==
+ dependencies:
+ "@types/nlcst" "^2.0.0"
+
+node-fetch-native@^1.6.7:
+ version "1.6.7"
+ resolved "https://registry.yarnpkg.com/node-fetch-native/-/node-fetch-native-1.6.7.tgz#9d09ca63066cc48423211ed4caf5d70075d76a71"
+ integrity sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==
+
+node-mock-http@^1.0.2:
+ version "1.0.4"
+ resolved "https://registry.yarnpkg.com/node-mock-http/-/node-mock-http-1.0.4.tgz#21f2ab4ce2fe4fbe8a660d7c5195a1db85e042a4"
+ integrity sha512-8DY+kFsDkNXy1sJglUfuODx1/opAGJGyrTuFqEoN90oRc2Vk0ZbD4K2qmKXBBEhZQzdKHIVfEJpDU8Ak2NJEvQ==
+
+normalize-path@^3.0.0:
+ version "3.0.0"
+ resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-3.0.0.tgz#0dcd69ff23a1c9b11fd0978316644a0388216a65"
+ integrity sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==
+
+nth-check@^2.0.0, nth-check@^2.0.1:
+ version "2.1.1"
+ resolved "https://registry.yarnpkg.com/nth-check/-/nth-check-2.1.1.tgz#c9eab428effce36cd6b92c924bdb000ef1f1ed1d"
+ integrity sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==
+ dependencies:
+ boolbase "^1.0.0"
+
+ofetch@^1.4.1, ofetch@^1.5.1:
+ version "1.5.1"
+ resolved "https://registry.yarnpkg.com/ofetch/-/ofetch-1.5.1.tgz#5c43cc56e03398b273014957060344254505c5c7"
+ integrity sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA==
+ dependencies:
+ destr "^2.0.5"
+ node-fetch-native "^1.6.7"
+ ufo "^1.6.1"
+
+ohash@^2.0.0:
+ version "2.0.11"
+ resolved "https://registry.yarnpkg.com/ohash/-/ohash-2.0.11.tgz#60b11e8cff62ca9dee88d13747a5baa145f5900b"
+ integrity sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==
+
+oniguruma-parser@^0.12.1:
+ version "0.12.1"
+ resolved "https://registry.yarnpkg.com/oniguruma-parser/-/oniguruma-parser-0.12.1.tgz#82ba2208d7a2b69ee344b7efe0ae930c627dcc4a"
+ integrity sha512-8Unqkvk1RYc6yq2WBYRj4hdnsAxVze8i7iPfQr8e4uSP3tRv0rpZcbGUDvxfQQcdwHt/e9PrMvGCsa8OqG9X3w==
+
+oniguruma-to-es@^2.2.0:
+ version "2.3.0"
+ resolved "https://registry.yarnpkg.com/oniguruma-to-es/-/oniguruma-to-es-2.3.0.tgz#35ea9104649b7c05f3963c6b3b474d964625028b"
+ integrity sha512-bwALDxriqfKGfUufKGGepCzu9x7nJQuoRoAFp4AnwehhC2crqrDIAP/uN2qdlsAvSMpeRC3+Yzhqc7hLmle5+g==
+ dependencies:
+ emoji-regex-xs "^1.0.0"
+ regex "^5.1.1"
+ regex-recursion "^5.1.1"
+
+oniguruma-to-es@^4.3.4:
+ version "4.3.4"
+ resolved "https://registry.yarnpkg.com/oniguruma-to-es/-/oniguruma-to-es-4.3.4.tgz#0b909d960faeb84511c979b1f2af64e9bc37ce34"
+ integrity sha512-3VhUGN3w2eYxnTzHn+ikMI+fp/96KoRSVK9/kMTcFqj1NRDh2IhQCKvYxDnWePKRXY/AqH+Fuiyb7VHSzBjHfA==
+ dependencies:
+ oniguruma-parser "^0.12.1"
+ regex "^6.0.1"
+ regex-recursion "^6.0.2"
+
+p-limit@^6.2.0:
+ version "6.2.0"
+ resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-6.2.0.tgz#c254d22ba6aeef441a3564c5e6c2f2da59268a0f"
+ integrity sha512-kuUqqHNUqoIWp/c467RI4X6mmyuojY5jGutNU0wVTmEOOfcuwLqyMVoAi9MKi2Ak+5i9+nhmrK4ufZE8069kHA==
+ dependencies:
+ yocto-queue "^1.1.1"
+
+p-queue@^8.1.1:
+ version "8.1.1"
+ resolved "https://registry.yarnpkg.com/p-queue/-/p-queue-8.1.1.tgz#dac3e8c57412fffa18fe6c341b141dbb3a16408b"
+ integrity sha512-aNZ+VfjobsWryoiPnEApGGmf5WmNsCo9xu8dfaYamG5qaLP7ClhLN6NgsFe6SwJ2UbLEBK5dv9x8Mn5+RVhMWQ==
+ dependencies:
+ eventemitter3 "^5.0.1"
+ p-timeout "^6.1.2"
+
+p-timeout@^6.1.2:
+ version "6.1.4"
+ resolved "https://registry.yarnpkg.com/p-timeout/-/p-timeout-6.1.4.tgz#418e1f4dd833fa96a2e3f532547dd2abdb08dbc2"
+ integrity sha512-MyIV3ZA/PmyBN/ud8vV9XzwTrNtR4jFrObymZYnZqMmW0zA8Z17vnT0rBgFE/TlohB+YCHqXMgZzb3Csp49vqg==
+
+package-manager-detector@^1.5.0:
+ version "1.6.0"
+ resolved "https://registry.yarnpkg.com/package-manager-detector/-/package-manager-detector-1.6.0.tgz#70d0cf0aa02c877eeaf66c4d984ede0be9130734"
+ integrity sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==
+
+pagefind@^1.3.0:
+ version "1.4.0"
+ resolved "https://registry.yarnpkg.com/pagefind/-/pagefind-1.4.0.tgz#0154b0a44b5ef9ef55c156824a3244bfc0c4008d"
+ integrity sha512-z2kY1mQlL4J8q5EIsQkLzQjilovKzfNVhX8De6oyE6uHpfFtyBaqUpcl/XzJC/4fjD8vBDyh1zolimIcVrCn9g==
+ optionalDependencies:
+ "@pagefind/darwin-arm64" "1.4.0"
+ "@pagefind/darwin-x64" "1.4.0"
+ "@pagefind/freebsd-x64" "1.4.0"
+ "@pagefind/linux-arm64" "1.4.0"
+ "@pagefind/linux-x64" "1.4.0"
+ "@pagefind/windows-x64" "1.4.0"
+
+pako@^0.2.5:
+ version "0.2.9"
+ resolved "https://registry.yarnpkg.com/pako/-/pako-0.2.9.tgz#f3f7522f4ef782348da8161bad9ecfd51bf83a75"
+ integrity sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==
+
+parse-entities@^4.0.0:
+ version "4.0.2"
+ resolved "https://registry.yarnpkg.com/parse-entities/-/parse-entities-4.0.2.tgz#61d46f5ed28e4ee62e9ddc43d6b010188443f159"
+ integrity sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==
+ dependencies:
+ "@types/unist" "^2.0.0"
+ character-entities-legacy "^3.0.0"
+ character-reference-invalid "^2.0.0"
+ decode-named-character-reference "^1.0.0"
+ is-alphanumerical "^2.0.0"
+ is-decimal "^2.0.0"
+ is-hexadecimal "^2.0.0"
+
+parse-latin@^7.0.0:
+ version "7.0.0"
+ resolved "https://registry.yarnpkg.com/parse-latin/-/parse-latin-7.0.0.tgz#8dfacac26fa603f76417f36233fc45602a323e1d"
+ integrity sha512-mhHgobPPua5kZ98EF4HWiH167JWBfl4pvAIXXdbaVohtK7a6YBOy56kvhCqduqyo/f3yrHFWmqmiMg/BkBkYYQ==
+ dependencies:
+ "@types/nlcst" "^2.0.0"
+ "@types/unist" "^3.0.0"
+ nlcst-to-string "^4.0.0"
+ unist-util-modify-children "^4.0.0"
+ unist-util-visit-children "^3.0.0"
+ vfile "^6.0.0"
+
+parse5@^7.0.0:
+ version "7.3.0"
+ resolved "https://registry.yarnpkg.com/parse5/-/parse5-7.3.0.tgz#d7e224fa72399c7a175099f45fc2ad024b05ec05"
+ integrity sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==
+ dependencies:
+ entities "^6.0.0"
+
+piccolore@^0.1.3:
+ version "0.1.3"
+ resolved "https://registry.yarnpkg.com/piccolore/-/piccolore-0.1.3.tgz#ef33f6180e6a37b35fe12a45765a900171cfaedc"
+ integrity sha512-o8bTeDWjE086iwKrROaDf31K0qC/BENdm15/uH9usSC/uZjJOKb2YGiVHfLY4GhwsERiPI1jmwI2XrA7ACOxVw==
+
+picocolors@^1.1.1:
+ version "1.1.1"
+ resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.1.1.tgz#3d321af3eab939b083c8f929a1d12cda81c26b6b"
+ integrity sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==
+
+picomatch@^2.0.4:
+ version "2.3.1"
+ resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42"
+ integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==
+
+picomatch@^4.0.2, picomatch@^4.0.3:
+ version "4.0.3"
+ resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.3.tgz#796c76136d1eead715db1e7bad785dedd695a042"
+ integrity sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==
+
+postcss-nested@^6.0.1:
+ version "6.2.0"
+ resolved "https://registry.yarnpkg.com/postcss-nested/-/postcss-nested-6.2.0.tgz#4c2d22ab5f20b9cb61e2c5c5915950784d068131"
+ integrity sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==
+ dependencies:
+ postcss-selector-parser "^6.1.1"
+
+postcss-selector-parser@^6.1.1:
+ version "6.1.2"
+ resolved "https://registry.yarnpkg.com/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz#27ecb41fb0e3b6ba7a1ec84fff347f734c7929de"
+ integrity sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==
+ dependencies:
+ cssesc "^3.0.0"
+ util-deprecate "^1.0.2"
+
+postcss@^8.4.38, postcss@^8.5.3:
+ version "8.5.6"
+ resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.6.tgz#2825006615a619b4f62a9e7426cc120b349a8f3c"
+ integrity sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==
+ dependencies:
+ nanoid "^3.3.11"
+ picocolors "^1.1.1"
+ source-map-js "^1.2.1"
+
+prismjs@^1.30.0:
+ version "1.30.0"
+ resolved "https://registry.yarnpkg.com/prismjs/-/prismjs-1.30.0.tgz#d9709969d9d4e16403f6f348c63553b19f0975a9"
+ integrity sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==
+
+prompts@^2.4.2:
+ version "2.4.2"
+ resolved "https://registry.yarnpkg.com/prompts/-/prompts-2.4.2.tgz#7b57e73b3a48029ad10ebd44f74b01722a4cb069"
+ integrity sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==
+ dependencies:
+ kleur "^3.0.3"
+ sisteransi "^1.0.5"
+
+property-information@^7.0.0:
+ version "7.1.0"
+ resolved "https://registry.yarnpkg.com/property-information/-/property-information-7.1.0.tgz#b622e8646e02b580205415586b40804d3e8bfd5d"
+ integrity sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==
+
+radix3@^1.1.2:
+ version "1.1.2"
+ resolved "https://registry.yarnpkg.com/radix3/-/radix3-1.1.2.tgz#fd27d2af3896c6bf4bcdfab6427c69c2afc69ec0"
+ integrity sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA==
+
+readdirp@^4.0.1:
+ version "4.1.2"
+ resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-4.1.2.tgz#eb85801435fbf2a7ee58f19e0921b068fc69948d"
+ integrity sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==
+
+recma-build-jsx@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/recma-build-jsx/-/recma-build-jsx-1.0.0.tgz#c02f29e047e103d2fab2054954e1761b8ea253c4"
+ integrity sha512-8GtdyqaBcDfva+GUKDr3nev3VpKAhup1+RvkMvUxURHpW7QyIvk9F5wz7Vzo06CEMSilw6uArgRqhpiUcWp8ew==
+ dependencies:
+ "@types/estree" "^1.0.0"
+ estree-util-build-jsx "^3.0.0"
+ vfile "^6.0.0"
+
+recma-jsx@^1.0.0:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/recma-jsx/-/recma-jsx-1.0.1.tgz#58e718f45e2102ed0bf2fa994f05b70d76801a1a"
+ integrity sha512-huSIy7VU2Z5OLv6oFLosQGGDqPqdO1iq6bWNAdhzMxSJP7RAso4fCZ1cKu8j9YHCZf3TPrq4dw3okhrylgcd7w==
+ dependencies:
+ acorn-jsx "^5.0.0"
+ estree-util-to-js "^2.0.0"
+ recma-parse "^1.0.0"
+ recma-stringify "^1.0.0"
+ unified "^11.0.0"
+
+recma-parse@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/recma-parse/-/recma-parse-1.0.0.tgz#c351e161bb0ab47d86b92a98a9d891f9b6814b52"
+ integrity sha512-OYLsIGBB5Y5wjnSnQW6t3Xg7q3fQ7FWbw/vcXtORTnyaSFscOtABg+7Pnz6YZ6c27fG1/aN8CjfwoUEUIdwqWQ==
+ dependencies:
+ "@types/estree" "^1.0.0"
+ esast-util-from-js "^2.0.0"
+ unified "^11.0.0"
+ vfile "^6.0.0"
+
+recma-stringify@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/recma-stringify/-/recma-stringify-1.0.0.tgz#54632030631e0c7546136ff9ef8fde8e7b44f130"
+ integrity sha512-cjwII1MdIIVloKvC9ErQ+OgAtwHBmcZ0Bg4ciz78FtbT8In39aAYbaA7zvxQ61xVMSPE8WxhLwLbhif4Js2C+g==
+ dependencies:
+ "@types/estree" "^1.0.0"
+ estree-util-to-js "^2.0.0"
+ unified "^11.0.0"
+ vfile "^6.0.0"
+
+regex-recursion@^5.1.1:
+ version "5.1.1"
+ resolved "https://registry.yarnpkg.com/regex-recursion/-/regex-recursion-5.1.1.tgz#5a73772d18adbf00f57ad097bf54171b39d78f8b"
+ integrity sha512-ae7SBCbzVNrIjgSbh7wMznPcQel1DNlDtzensnFxpiNpXt1U2ju/bHugH422r+4LAVS1FpW1YCwilmnNsjum9w==
+ dependencies:
+ regex "^5.1.1"
+ regex-utilities "^2.3.0"
+
+regex-recursion@^6.0.2:
+ version "6.0.2"
+ resolved "https://registry.yarnpkg.com/regex-recursion/-/regex-recursion-6.0.2.tgz#a0b1977a74c87f073377b938dbedfab2ea582b33"
+ integrity sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==
+ dependencies:
+ regex-utilities "^2.3.0"
+
+regex-utilities@^2.3.0:
+ version "2.3.0"
+ resolved "https://registry.yarnpkg.com/regex-utilities/-/regex-utilities-2.3.0.tgz#87163512a15dce2908cf079c8960d5158ff43280"
+ integrity sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==
+
+regex@^5.1.1:
+ version "5.1.1"
+ resolved "https://registry.yarnpkg.com/regex/-/regex-5.1.1.tgz#cf798903f24d6fe6e531050a36686e082b29bd03"
+ integrity sha512-dN5I359AVGPnwzJm2jN1k0W9LPZ+ePvoOeVMMfqIMFz53sSwXkxaJoxr50ptnsC771lK95BnTrVSZxq0b9yCGw==
+ dependencies:
+ regex-utilities "^2.3.0"
+
+regex@^6.0.1:
+ version "6.1.0"
+ resolved "https://registry.yarnpkg.com/regex/-/regex-6.1.0.tgz#d7ce98f8ee32da7497c13f6601fca2bc4a6a7803"
+ integrity sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==
+ dependencies:
+ regex-utilities "^2.3.0"
+
+rehype-expressive-code@^0.40.2:
+ version "0.40.2"
+ resolved "https://registry.yarnpkg.com/rehype-expressive-code/-/rehype-expressive-code-0.40.2.tgz#93b0541796228eb59a318fbbb1db4a97a2c6a38a"
+ integrity sha512-+kn+AMGCrGzvtH8Q5lC6Y5lnmTV/r33fdmi5QU/IH1KPHKobKr5UnLwJuqHv5jBTSN/0v2wLDS7RTM73FVzqmQ==
+ dependencies:
+ expressive-code "^0.40.2"
+
+rehype-format@^5.0.0:
+ version "5.0.1"
+ resolved "https://registry.yarnpkg.com/rehype-format/-/rehype-format-5.0.1.tgz#e255e59bed0c062156aaf51c16fad5a521a1f5c8"
+ integrity sha512-zvmVru9uB0josBVpr946OR8ui7nJEdzZobwLOOqHb/OOD88W0Vk2SqLwoVOj0fM6IPCCO6TaV9CvQvJMWwukFQ==
+ dependencies:
+ "@types/hast" "^3.0.0"
+ hast-util-format "^1.0.0"
+
+rehype-parse@^9.0.0:
+ version "9.0.1"
+ resolved "https://registry.yarnpkg.com/rehype-parse/-/rehype-parse-9.0.1.tgz#9993bda129acc64c417a9d3654a7be38b2a94c20"
+ integrity sha512-ksCzCD0Fgfh7trPDxr2rSylbwq9iYDkSn8TCDmEJ49ljEUBxDVCzCHv7QNzZOfODanX4+bWQ4WZqLCRWYLfhag==
+ dependencies:
+ "@types/hast" "^3.0.0"
+ hast-util-from-html "^2.0.0"
+ unified "^11.0.0"
+
+rehype-raw@^7.0.0:
+ version "7.0.0"
+ resolved "https://registry.yarnpkg.com/rehype-raw/-/rehype-raw-7.0.0.tgz#59d7348fd5dbef3807bbaa1d443efd2dd85ecee4"
+ integrity sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==
+ dependencies:
+ "@types/hast" "^3.0.0"
+ hast-util-raw "^9.0.0"
+ vfile "^6.0.0"
+
+rehype-recma@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/rehype-recma/-/rehype-recma-1.0.0.tgz#d68ef6344d05916bd96e25400c6261775411aa76"
+ integrity sha512-lqA4rGUf1JmacCNWWZx0Wv1dHqMwxzsDWYMTowuplHF3xH0N/MmrZ/G3BDZnzAkRmxDadujCjaKM2hqYdCBOGw==
+ dependencies:
+ "@types/estree" "^1.0.0"
+ "@types/hast" "^3.0.0"
+ hast-util-to-estree "^3.0.0"
+
+rehype-stringify@^10.0.0, rehype-stringify@^10.0.1:
+ version "10.0.1"
+ resolved "https://registry.yarnpkg.com/rehype-stringify/-/rehype-stringify-10.0.1.tgz#2ec1ebc56c6aba07905d3b4470bdf0f684f30b75"
+ integrity sha512-k9ecfXHmIPuFVI61B9DeLPN0qFHfawM6RsuX48hoqlaKSF61RskNjSm1lI8PhBEM0MRdLxVVm4WmTqJQccH9mA==
+ dependencies:
+ "@types/hast" "^3.0.0"
+ hast-util-to-html "^9.0.0"
+ unified "^11.0.0"
+
+rehype@^13.0.1, rehype@^13.0.2:
+ version "13.0.2"
+ resolved "https://registry.yarnpkg.com/rehype/-/rehype-13.0.2.tgz#ab0b3ac26573d7b265a0099feffad450e4cf1952"
+ integrity sha512-j31mdaRFrwFRUIlxGeuPXXKWQxet52RBQRvCmzl5eCefn/KGbomK5GMHNMsOJf55fgo3qw5tST5neDuarDYR2A==
+ dependencies:
+ "@types/hast" "^3.0.0"
+ rehype-parse "^9.0.0"
+ rehype-stringify "^10.0.0"
+ unified "^11.0.0"
+
+remark-directive@^3.0.0:
+ version "3.0.1"
+ resolved "https://registry.yarnpkg.com/remark-directive/-/remark-directive-3.0.1.tgz#689ba332f156cfe1118e849164cc81f157a3ef0a"
+ integrity sha512-gwglrEQEZcZYgVyG1tQuA+h58EZfq5CSULw7J90AFuCTyib1thgHPoqQ+h9iFvU6R+vnZ5oNFQR5QKgGpk741A==
+ dependencies:
+ "@types/mdast" "^4.0.0"
+ mdast-util-directive "^3.0.0"
+ micromark-extension-directive "^3.0.0"
+ unified "^11.0.0"
+
+remark-gfm@^4.0.1:
+ version "4.0.1"
+ resolved "https://registry.yarnpkg.com/remark-gfm/-/remark-gfm-4.0.1.tgz#33227b2a74397670d357bf05c098eaf8513f0d6b"
+ integrity sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==
+ dependencies:
+ "@types/mdast" "^4.0.0"
+ mdast-util-gfm "^3.0.0"
+ micromark-extension-gfm "^3.0.0"
+ remark-parse "^11.0.0"
+ remark-stringify "^11.0.0"
+ unified "^11.0.0"
+
+remark-mdx@^3.0.0:
+ version "3.1.1"
+ resolved "https://registry.yarnpkg.com/remark-mdx/-/remark-mdx-3.1.1.tgz#047f97038bc7ec387aebb4b0a4fe23779999d845"
+ integrity sha512-Pjj2IYlUY3+D8x00UJsIOg5BEvfMyeI+2uLPn9VO9Wg4MEtN/VTIq2NEJQfde9PnX15KgtHyl9S0BcTnWrIuWg==
+ dependencies:
+ mdast-util-mdx "^3.0.0"
+ micromark-extension-mdxjs "^3.0.0"
+
+remark-parse@^11.0.0:
+ version "11.0.0"
+ resolved "https://registry.yarnpkg.com/remark-parse/-/remark-parse-11.0.0.tgz#aa60743fcb37ebf6b069204eb4da304e40db45a1"
+ integrity sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==
+ dependencies:
+ "@types/mdast" "^4.0.0"
+ mdast-util-from-markdown "^2.0.0"
+ micromark-util-types "^2.0.0"
+ unified "^11.0.0"
+
+remark-rehype@^11.0.0, remark-rehype@^11.1.2:
+ version "11.1.2"
+ resolved "https://registry.yarnpkg.com/remark-rehype/-/remark-rehype-11.1.2.tgz#2addaadda80ca9bd9aa0da763e74d16327683b37"
+ integrity sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==
+ dependencies:
+ "@types/hast" "^3.0.0"
+ "@types/mdast" "^4.0.0"
+ mdast-util-to-hast "^13.0.0"
+ unified "^11.0.0"
+ vfile "^6.0.0"
+
+remark-smartypants@^3.0.2:
+ version "3.0.2"
+ resolved "https://registry.yarnpkg.com/remark-smartypants/-/remark-smartypants-3.0.2.tgz#cbaf2b39624c78fcbd6efa224678c1d2e9bc1dfb"
+ integrity sha512-ILTWeOriIluwEvPjv67v7Blgrcx+LZOkAUVtKI3putuhlZm84FnqDORNXPPm+HY3NdZOMhyDwZ1E+eZB/Df5dA==
+ dependencies:
+ retext "^9.0.0"
+ retext-smartypants "^6.0.0"
+ unified "^11.0.4"
+ unist-util-visit "^5.0.0"
+
+remark-stringify@^11.0.0:
+ version "11.0.0"
+ resolved "https://registry.yarnpkg.com/remark-stringify/-/remark-stringify-11.0.0.tgz#4c5b01dd711c269df1aaae11743eb7e2e7636fd3"
+ integrity sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==
+ dependencies:
+ "@types/mdast" "^4.0.0"
+ mdast-util-to-markdown "^2.0.0"
+ unified "^11.0.0"
+
+restructure@^3.0.0:
+ version "3.0.2"
+ resolved "https://registry.yarnpkg.com/restructure/-/restructure-3.0.2.tgz#e6b2fad214f78edee21797fa8160fef50eb9b49a"
+ integrity sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw==
+
+retext-latin@^4.0.0:
+ version "4.0.0"
+ resolved "https://registry.yarnpkg.com/retext-latin/-/retext-latin-4.0.0.tgz#d02498aa1fd39f1bf00e2ff59b1384c05d0c7ce3"
+ integrity sha512-hv9woG7Fy0M9IlRQloq/N6atV82NxLGveq+3H2WOi79dtIYWN8OaxogDm77f8YnVXJL2VD3bbqowu5E3EMhBYA==
+ dependencies:
+ "@types/nlcst" "^2.0.0"
+ parse-latin "^7.0.0"
+ unified "^11.0.0"
+
+retext-smartypants@^6.0.0:
+ version "6.2.0"
+ resolved "https://registry.yarnpkg.com/retext-smartypants/-/retext-smartypants-6.2.0.tgz#4e852c2974cf2cfa253eeec427c97efc43b5d158"
+ integrity sha512-kk0jOU7+zGv//kfjXEBjdIryL1Acl4i9XNkHxtM7Tm5lFiCog576fjNC9hjoR7LTKQ0DsPWy09JummSsH1uqfQ==
+ dependencies:
+ "@types/nlcst" "^2.0.0"
+ nlcst-to-string "^4.0.0"
+ unist-util-visit "^5.0.0"
+
+retext-stringify@^4.0.0:
+ version "4.0.0"
+ resolved "https://registry.yarnpkg.com/retext-stringify/-/retext-stringify-4.0.0.tgz#501d5440bd4d121e351c7c509f8507de9611e159"
+ integrity sha512-rtfN/0o8kL1e+78+uxPTqu1Klt0yPzKuQ2BfWwwfgIUSayyzxpM1PJzkKt4V8803uB9qSy32MvI7Xep9khTpiA==
+ dependencies:
+ "@types/nlcst" "^2.0.0"
+ nlcst-to-string "^4.0.0"
+ unified "^11.0.0"
+
+retext@^9.0.0:
+ version "9.0.0"
+ resolved "https://registry.yarnpkg.com/retext/-/retext-9.0.0.tgz#ab5cd72836894167b0ca6ae70fdcfaa166267f7a"
+ integrity sha512-sbMDcpHCNjvlheSgMfEcVrZko3cDzdbe1x/e7G66dFp0Ff7Mldvi2uv6JkJQzdRcvLYE8CA8Oe8siQx8ZOgTcA==
+ dependencies:
+ "@types/nlcst" "^2.0.0"
+ retext-latin "^4.0.0"
+ retext-stringify "^4.0.0"
+ unified "^11.0.0"
+
+rollup@^4.34.9:
+ version "4.54.0"
+ resolved "https://registry.yarnpkg.com/rollup/-/rollup-4.54.0.tgz#930f4dfc41ff94d720006f9f62503612a6c319b8"
+ integrity sha512-3nk8Y3a9Ea8szgKhinMlGMhGMw89mqule3KWczxhIzqudyHdCIOHw8WJlj/r329fACjKLEh13ZSk7oE22kyeIw==
+ dependencies:
+ "@types/estree" "1.0.8"
+ optionalDependencies:
+ "@rollup/rollup-android-arm-eabi" "4.54.0"
+ "@rollup/rollup-android-arm64" "4.54.0"
+ "@rollup/rollup-darwin-arm64" "4.54.0"
+ "@rollup/rollup-darwin-x64" "4.54.0"
+ "@rollup/rollup-freebsd-arm64" "4.54.0"
+ "@rollup/rollup-freebsd-x64" "4.54.0"
+ "@rollup/rollup-linux-arm-gnueabihf" "4.54.0"
+ "@rollup/rollup-linux-arm-musleabihf" "4.54.0"
+ "@rollup/rollup-linux-arm64-gnu" "4.54.0"
+ "@rollup/rollup-linux-arm64-musl" "4.54.0"
+ "@rollup/rollup-linux-loong64-gnu" "4.54.0"
+ "@rollup/rollup-linux-ppc64-gnu" "4.54.0"
+ "@rollup/rollup-linux-riscv64-gnu" "4.54.0"
+ "@rollup/rollup-linux-riscv64-musl" "4.54.0"
+ "@rollup/rollup-linux-s390x-gnu" "4.54.0"
+ "@rollup/rollup-linux-x64-gnu" "4.54.0"
+ "@rollup/rollup-linux-x64-musl" "4.54.0"
+ "@rollup/rollup-openharmony-arm64" "4.54.0"
+ "@rollup/rollup-win32-arm64-msvc" "4.54.0"
+ "@rollup/rollup-win32-ia32-msvc" "4.54.0"
+ "@rollup/rollup-win32-x64-gnu" "4.54.0"
+ "@rollup/rollup-win32-x64-msvc" "4.54.0"
+ fsevents "~2.3.2"
+
+sax@^1.4.1:
+ version "1.4.3"
+ resolved "https://registry.yarnpkg.com/sax/-/sax-1.4.3.tgz#fcebae3b756cdc8428321805f4b70f16ec0ab5db"
+ integrity sha512-yqYn1JhPczigF94DMS+shiDMjDowYO6y9+wB/4WgO0Y19jWYk0lQ4tuG5KI7kj4FTp1wxPj5IFfcrz/s1c3jjQ==
+
+semver@^7.6.3, semver@^7.7.3:
+ version "7.7.3"
+ resolved "https://registry.yarnpkg.com/semver/-/semver-7.7.3.tgz#4b5f4143d007633a8dc671cd0a6ef9147b8bb946"
+ integrity sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==
+
+sharp@^0.33.5:
+ version "0.33.5"
+ resolved "https://registry.yarnpkg.com/sharp/-/sharp-0.33.5.tgz#13e0e4130cc309d6a9497596715240b2ec0c594e"
+ integrity sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==
+ dependencies:
+ color "^4.2.3"
+ detect-libc "^2.0.3"
+ semver "^7.6.3"
+ optionalDependencies:
+ "@img/sharp-darwin-arm64" "0.33.5"
+ "@img/sharp-darwin-x64" "0.33.5"
+ "@img/sharp-libvips-darwin-arm64" "1.0.4"
+ "@img/sharp-libvips-darwin-x64" "1.0.4"
+ "@img/sharp-libvips-linux-arm" "1.0.5"
+ "@img/sharp-libvips-linux-arm64" "1.0.4"
+ "@img/sharp-libvips-linux-s390x" "1.0.4"
+ "@img/sharp-libvips-linux-x64" "1.0.4"
+ "@img/sharp-libvips-linuxmusl-arm64" "1.0.4"
+ "@img/sharp-libvips-linuxmusl-x64" "1.0.4"
+ "@img/sharp-linux-arm" "0.33.5"
+ "@img/sharp-linux-arm64" "0.33.5"
+ "@img/sharp-linux-s390x" "0.33.5"
+ "@img/sharp-linux-x64" "0.33.5"
+ "@img/sharp-linuxmusl-arm64" "0.33.5"
+ "@img/sharp-linuxmusl-x64" "0.33.5"
+ "@img/sharp-wasm32" "0.33.5"
+ "@img/sharp-win32-ia32" "0.33.5"
+ "@img/sharp-win32-x64" "0.33.5"
+
+sharp@^0.34.0:
+ version "0.34.5"
+ resolved "https://registry.yarnpkg.com/sharp/-/sharp-0.34.5.tgz#b6f148e4b8c61f1797bde11a9d1cfebbae2c57b0"
+ integrity sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==
+ dependencies:
+ "@img/colour" "^1.0.0"
+ detect-libc "^2.1.2"
+ semver "^7.7.3"
+ optionalDependencies:
+ "@img/sharp-darwin-arm64" "0.34.5"
+ "@img/sharp-darwin-x64" "0.34.5"
+ "@img/sharp-libvips-darwin-arm64" "1.2.4"
+ "@img/sharp-libvips-darwin-x64" "1.2.4"
+ "@img/sharp-libvips-linux-arm" "1.2.4"
+ "@img/sharp-libvips-linux-arm64" "1.2.4"
+ "@img/sharp-libvips-linux-ppc64" "1.2.4"
+ "@img/sharp-libvips-linux-riscv64" "1.2.4"
+ "@img/sharp-libvips-linux-s390x" "1.2.4"
+ "@img/sharp-libvips-linux-x64" "1.2.4"
+ "@img/sharp-libvips-linuxmusl-arm64" "1.2.4"
+ "@img/sharp-libvips-linuxmusl-x64" "1.2.4"
+ "@img/sharp-linux-arm" "0.34.5"
+ "@img/sharp-linux-arm64" "0.34.5"
+ "@img/sharp-linux-ppc64" "0.34.5"
+ "@img/sharp-linux-riscv64" "0.34.5"
+ "@img/sharp-linux-s390x" "0.34.5"
+ "@img/sharp-linux-x64" "0.34.5"
+ "@img/sharp-linuxmusl-arm64" "0.34.5"
+ "@img/sharp-linuxmusl-x64" "0.34.5"
+ "@img/sharp-wasm32" "0.34.5"
+ "@img/sharp-win32-arm64" "0.34.5"
+ "@img/sharp-win32-ia32" "0.34.5"
+ "@img/sharp-win32-x64" "0.34.5"
+
+shiki@^1.26.1:
+ version "1.29.2"
+ resolved "https://registry.yarnpkg.com/shiki/-/shiki-1.29.2.tgz#5c93771f2d5305ce9c05975c33689116a27dc657"
+ integrity sha512-njXuliz/cP+67jU2hukkxCNuH1yUi4QfdZZY+sMr5PPrIyXSu5iTb/qYC4BiWWB0vZ+7TbdvYUCeL23zpwCfbg==
+ dependencies:
+ "@shikijs/core" "1.29.2"
+ "@shikijs/engine-javascript" "1.29.2"
+ "@shikijs/engine-oniguruma" "1.29.2"
+ "@shikijs/langs" "1.29.2"
+ "@shikijs/themes" "1.29.2"
+ "@shikijs/types" "1.29.2"
+ "@shikijs/vscode-textmate" "^10.0.1"
+ "@types/hast" "^3.0.4"
+
+shiki@^3.15.0, shiki@^3.19.0:
+ version "3.20.0"
+ resolved "https://registry.yarnpkg.com/shiki/-/shiki-3.20.0.tgz#1eb8669857373d74e90822e03663a86b5b1f9a24"
+ integrity sha512-kgCOlsnyWb+p0WU+01RjkCH+eBVsjL1jOwUYWv0YDWkM2/A46+LDKVs5yZCUXjJG6bj4ndFoAg5iLIIue6dulg==
+ dependencies:
+ "@shikijs/core" "3.20.0"
+ "@shikijs/engine-javascript" "3.20.0"
+ "@shikijs/engine-oniguruma" "3.20.0"
+ "@shikijs/langs" "3.20.0"
+ "@shikijs/themes" "3.20.0"
+ "@shikijs/types" "3.20.0"
+ "@shikijs/vscode-textmate" "^10.0.2"
+ "@types/hast" "^3.0.4"
+
+simple-swizzle@^0.2.2:
+ version "0.2.4"
+ resolved "https://registry.yarnpkg.com/simple-swizzle/-/simple-swizzle-0.2.4.tgz#a8d11a45a11600d6a1ecdff6363329e3648c3667"
+ integrity sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==
+ dependencies:
+ is-arrayish "^0.3.1"
+
+sisteransi@^1.0.5:
+ version "1.0.5"
+ resolved "https://registry.yarnpkg.com/sisteransi/-/sisteransi-1.0.5.tgz#134d681297756437cc05ca01370d3a7a571075ed"
+ integrity sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==
+
+sitemap@^8.0.0:
+ version "8.0.2"
+ resolved "https://registry.yarnpkg.com/sitemap/-/sitemap-8.0.2.tgz#27bddb5fc2c61a1cf8f0194674cd89d762c9f5ae"
+ integrity sha512-LwktpJcyZDoa0IL6KT++lQ53pbSrx2c9ge41/SeLTyqy2XUNA6uR4+P9u5IVo5lPeL2arAcOKn1aZAxoYbCKlQ==
+ dependencies:
+ "@types/node" "^17.0.5"
+ "@types/sax" "^1.2.1"
+ arg "^5.0.0"
+ sax "^1.4.1"
+
+smol-toml@^1.5.2:
+ version "1.6.0"
+ resolved "https://registry.yarnpkg.com/smol-toml/-/smol-toml-1.6.0.tgz#7911830b47bb3e87be536f939453e10c9e1dfd36"
+ integrity sha512-4zemZi0HvTnYwLfrpk/CF9LOd9Lt87kAt50GnqhMpyF9U3poDAP2+iukq2bZsO/ufegbYehBkqINbsWxj4l4cw==
+
+source-map-js@^1.0.1, source-map-js@^1.2.1:
+ version "1.2.1"
+ resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.2.1.tgz#1ce5650fddd87abc099eda37dcff024c2667ae46"
+ integrity sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==
+
+source-map@^0.7.0, source-map@^0.7.6:
+ version "0.7.6"
+ resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.7.6.tgz#a3658ab87e5b6429c8a1f3ba0083d4c61ca3ef02"
+ integrity sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==
+
+space-separated-tokens@^2.0.0:
+ version "2.0.2"
+ resolved "https://registry.yarnpkg.com/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz#1ecd9d2350a3844572c3f4a312bceb018348859f"
+ integrity sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==
+
+stream-replace-string@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/stream-replace-string/-/stream-replace-string-2.0.0.tgz#e49fd584bd1c633613e010bc73b9db49cb5024ad"
+ integrity sha512-TlnjJ1C0QrmxRNrON00JvaFFlNh5TTG00APw23j74ET7gkQpTASi6/L2fuiav8pzK715HXtUeClpBTw2NPSn6w==
+
+string-width@^4.1.0:
+ version "4.2.3"
+ resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
+ integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
+ dependencies:
+ emoji-regex "^8.0.0"
+ is-fullwidth-code-point "^3.0.0"
+ strip-ansi "^6.0.1"
+
+string-width@^7.0.0, string-width@^7.2.0:
+ version "7.2.0"
+ resolved "https://registry.yarnpkg.com/string-width/-/string-width-7.2.0.tgz#b5bb8e2165ce275d4d43476dd2700ad9091db6dc"
+ integrity sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==
+ dependencies:
+ emoji-regex "^10.3.0"
+ get-east-asian-width "^1.0.0"
+ strip-ansi "^7.1.0"
+
+stringify-entities@^4.0.0:
+ version "4.0.4"
+ resolved "https://registry.yarnpkg.com/stringify-entities/-/stringify-entities-4.0.4.tgz#b3b79ef5f277cc4ac73caeb0236c5ba939b3a4f3"
+ integrity sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==
+ dependencies:
+ character-entities-html4 "^2.0.0"
+ character-entities-legacy "^3.0.0"
+
+strip-ansi@^6.0.1:
+ version "6.0.1"
+ resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
+ integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
+ dependencies:
+ ansi-regex "^5.0.1"
+
+strip-ansi@^7.1.0:
+ version "7.1.2"
+ resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.2.tgz#132875abde678c7ea8d691533f2e7e22bb744dba"
+ integrity sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==
+ dependencies:
+ ansi-regex "^6.0.1"
+
+style-to-js@^1.0.0:
+ version "1.1.21"
+ resolved "https://registry.yarnpkg.com/style-to-js/-/style-to-js-1.1.21.tgz#2908941187f857e79e28e9cd78008b9a0b3e0e8d"
+ integrity sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==
+ dependencies:
+ style-to-object "1.0.14"
+
+style-to-object@1.0.14:
+ version "1.0.14"
+ resolved "https://registry.yarnpkg.com/style-to-object/-/style-to-object-1.0.14.tgz#1d22f0e7266bb8c6d8cae5caf4ec4f005e08f611"
+ integrity sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==
+ dependencies:
+ inline-style-parser "0.2.7"
+
+svgo@^4.0.0:
+ version "4.0.0"
+ resolved "https://registry.yarnpkg.com/svgo/-/svgo-4.0.0.tgz#17e0fa2eaccf429e0ec0d2179169abde9ba8ad3d"
+ integrity sha512-VvrHQ+9uniE+Mvx3+C9IEe/lWasXCU0nXMY2kZeLrHNICuRiC8uMPyM14UEaMOFA5mhyQqEkB02VoQ16n3DLaw==
+ dependencies:
+ commander "^11.1.0"
+ css-select "^5.1.0"
+ css-tree "^3.0.1"
+ css-what "^6.1.0"
+ csso "^5.0.5"
+ picocolors "^1.1.1"
+ sax "^1.4.1"
+
+tiny-inflate@^1.0.0, tiny-inflate@^1.0.3:
+ version "1.0.3"
+ resolved "https://registry.yarnpkg.com/tiny-inflate/-/tiny-inflate-1.0.3.tgz#122715494913a1805166aaf7c93467933eea26c4"
+ integrity sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==
+
+tinyexec@^1.0.2:
+ version "1.0.2"
+ resolved "https://registry.yarnpkg.com/tinyexec/-/tinyexec-1.0.2.tgz#bdd2737fe2ba40bd6f918ae26642f264b99ca251"
+ integrity sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==
+
+tinyglobby@^0.2.13, tinyglobby@^0.2.15:
+ version "0.2.15"
+ resolved "https://registry.yarnpkg.com/tinyglobby/-/tinyglobby-0.2.15.tgz#e228dd1e638cea993d2fdb4fcd2d4602a79951c2"
+ integrity sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==
+ dependencies:
+ fdir "^6.5.0"
+ picomatch "^4.0.3"
+
+trim-lines@^3.0.0:
+ version "3.0.1"
+ resolved "https://registry.yarnpkg.com/trim-lines/-/trim-lines-3.0.1.tgz#d802e332a07df861c48802c04321017b1bd87338"
+ integrity sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==
+
+trough@^2.0.0:
+ version "2.2.0"
+ resolved "https://registry.yarnpkg.com/trough/-/trough-2.2.0.tgz#94a60bd6bd375c152c1df911a4b11d5b0256f50f"
+ integrity sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==
+
+tsconfck@^3.1.6:
+ version "3.1.6"
+ resolved "https://registry.yarnpkg.com/tsconfck/-/tsconfck-3.1.6.tgz#da1f0b10d82237ac23422374b3fce1edb23c3ead"
+ integrity sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w==
+
+tslib@^2.4.0, tslib@^2.8.0:
+ version "2.8.1"
+ resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f"
+ integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==
+
+type-fest@^4.21.0:
+ version "4.41.0"
+ resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-4.41.0.tgz#6ae1c8e5731273c2bf1f58ad39cbae2c91a46c58"
+ integrity sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==
+
+ufo@^1.6.1:
+ version "1.6.1"
+ resolved "https://registry.yarnpkg.com/ufo/-/ufo-1.6.1.tgz#ac2db1d54614d1b22c1d603e3aef44a85d8f146b"
+ integrity sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==
+
+ultrahtml@^1.6.0:
+ version "1.6.0"
+ resolved "https://registry.yarnpkg.com/ultrahtml/-/ultrahtml-1.6.0.tgz#0d1aad7bbfeae512438d30e799c11622127a1ac8"
+ integrity sha512-R9fBn90VTJrqqLDwyMph+HGne8eqY1iPfYhPzZrvKpIfwkWZbcYlfpsb8B9dTvBfpy1/hqAD7Wi8EKfP9e8zdw==
+
+uncrypto@^0.1.3:
+ version "0.1.3"
+ resolved "https://registry.yarnpkg.com/uncrypto/-/uncrypto-0.1.3.tgz#e1288d609226f2d02d8d69ee861fa20d8348ef2b"
+ integrity sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==
+
+undici-types@~7.16.0:
+ version "7.16.0"
+ resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-7.16.0.tgz#ffccdff36aea4884cbfce9a750a0580224f58a46"
+ integrity sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==
+
+unicode-properties@^1.4.0:
+ version "1.4.1"
+ resolved "https://registry.yarnpkg.com/unicode-properties/-/unicode-properties-1.4.1.tgz#96a9cffb7e619a0dc7368c28da27e05fc8f9be5f"
+ integrity sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==
+ dependencies:
+ base64-js "^1.3.0"
+ unicode-trie "^2.0.0"
+
+unicode-trie@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/unicode-trie/-/unicode-trie-2.0.0.tgz#8fd8845696e2e14a8b67d78fa9e0dd2cad62fec8"
+ integrity sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==
+ dependencies:
+ pako "^0.2.5"
+ tiny-inflate "^1.0.0"
+
+unified@^11.0.0, unified@^11.0.4, unified@^11.0.5:
+ version "11.0.5"
+ resolved "https://registry.yarnpkg.com/unified/-/unified-11.0.5.tgz#f66677610a5c0a9ee90cab2b8d4d66037026d9e1"
+ integrity sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==
+ dependencies:
+ "@types/unist" "^3.0.0"
+ bail "^2.0.0"
+ devlop "^1.0.0"
+ extend "^3.0.0"
+ is-plain-obj "^4.0.0"
+ trough "^2.0.0"
+ vfile "^6.0.0"
+
+unifont@~0.6.0:
+ version "0.6.0"
+ resolved "https://registry.yarnpkg.com/unifont/-/unifont-0.6.0.tgz#c0ddd6411f1917f934907d989b4566842c5b482b"
+ integrity sha512-5Fx50fFQMQL5aeHyWnZX9122sSLckcDvcfFiBf3QYeHa7a1MKJooUy52b67moi2MJYkrfo/TWY+CoLdr/w0tTA==
+ dependencies:
+ css-tree "^3.0.0"
+ ofetch "^1.4.1"
+ ohash "^2.0.0"
+
+unist-util-find-after@^5.0.0:
+ version "5.0.0"
+ resolved "https://registry.yarnpkg.com/unist-util-find-after/-/unist-util-find-after-5.0.0.tgz#3fccc1b086b56f34c8b798e1ff90b5c54468e896"
+ integrity sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ==
+ dependencies:
+ "@types/unist" "^3.0.0"
+ unist-util-is "^6.0.0"
+
+unist-util-is@^6.0.0:
+ version "6.0.1"
+ resolved "https://registry.yarnpkg.com/unist-util-is/-/unist-util-is-6.0.1.tgz#d0a3f86f2dd0db7acd7d8c2478080b5c67f9c6a9"
+ integrity sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==
+ dependencies:
+ "@types/unist" "^3.0.0"
+
+unist-util-modify-children@^4.0.0:
+ version "4.0.0"
+ resolved "https://registry.yarnpkg.com/unist-util-modify-children/-/unist-util-modify-children-4.0.0.tgz#981d6308e887b005d1f491811d3cbcc254b315e9"
+ integrity sha512-+tdN5fGNddvsQdIzUF3Xx82CU9sMM+fA0dLgR9vOmT0oPT2jH+P1nd5lSqfCfXAw+93NhcXNY2qqvTUtE4cQkw==
+ dependencies:
+ "@types/unist" "^3.0.0"
+ array-iterate "^2.0.0"
+
+unist-util-position-from-estree@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/unist-util-position-from-estree/-/unist-util-position-from-estree-2.0.0.tgz#d94da4df596529d1faa3de506202f0c9a23f2200"
+ integrity sha512-KaFVRjoqLyF6YXCbVLNad/eS4+OfPQQn2yOd7zF/h5T/CSL2v8NpN6a5TPvtbXthAGw5nG+PuTtq+DdIZr+cRQ==
+ dependencies:
+ "@types/unist" "^3.0.0"
+
+unist-util-position@^5.0.0:
+ version "5.0.0"
+ resolved "https://registry.yarnpkg.com/unist-util-position/-/unist-util-position-5.0.0.tgz#678f20ab5ca1207a97d7ea8a388373c9cf896be4"
+ integrity sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==
+ dependencies:
+ "@types/unist" "^3.0.0"
+
+unist-util-remove-position@^5.0.0:
+ version "5.0.0"
+ resolved "https://registry.yarnpkg.com/unist-util-remove-position/-/unist-util-remove-position-5.0.0.tgz#fea68a25658409c9460408bc6b4991b965b52163"
+ integrity sha512-Hp5Kh3wLxv0PHj9m2yZhhLt58KzPtEYKQQ4yxfYFEO7EvHwzyDYnduhHnY1mDxoqr7VUwVuHXk9RXKIiYS1N8Q==
+ dependencies:
+ "@types/unist" "^3.0.0"
+ unist-util-visit "^5.0.0"
+
+unist-util-stringify-position@^4.0.0:
+ version "4.0.0"
+ resolved "https://registry.yarnpkg.com/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz#449c6e21a880e0855bf5aabadeb3a740314abac2"
+ integrity sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==
+ dependencies:
+ "@types/unist" "^3.0.0"
+
+unist-util-visit-children@^3.0.0:
+ version "3.0.0"
+ resolved "https://registry.yarnpkg.com/unist-util-visit-children/-/unist-util-visit-children-3.0.0.tgz#4bced199b71d7f3c397543ea6cc39e7a7f37dc7e"
+ integrity sha512-RgmdTfSBOg04sdPcpTSD1jzoNBjt9a80/ZCzp5cI9n1qPzLZWF9YdvWGN2zmTumP1HWhXKdUWexjy/Wy/lJ7tA==
+ dependencies:
+ "@types/unist" "^3.0.0"
+
+unist-util-visit-parents@^6.0.0, unist-util-visit-parents@^6.0.1, unist-util-visit-parents@^6.0.2:
+ version "6.0.2"
+ resolved "https://registry.yarnpkg.com/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz#777df7fb98652ce16b4b7cd999d0a1a40efa3a02"
+ integrity sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==
+ dependencies:
+ "@types/unist" "^3.0.0"
+ unist-util-is "^6.0.0"
+
+unist-util-visit@^5.0.0:
+ version "5.0.0"
+ resolved "https://registry.yarnpkg.com/unist-util-visit/-/unist-util-visit-5.0.0.tgz#a7de1f31f72ffd3519ea71814cccf5fd6a9217d6"
+ integrity sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg==
+ dependencies:
+ "@types/unist" "^3.0.0"
+ unist-util-is "^6.0.0"
+ unist-util-visit-parents "^6.0.0"
+
+unstorage@^1.17.3:
+ version "1.17.3"
+ resolved "https://registry.yarnpkg.com/unstorage/-/unstorage-1.17.3.tgz#805acbeab7f7b97f0d0492427af18e650eda4e57"
+ integrity sha512-i+JYyy0DoKmQ3FximTHbGadmIYb8JEpq7lxUjnjeB702bCPum0vzo6oy5Mfu0lpqISw7hCyMW2yj4nWC8bqJ3Q==
+ dependencies:
+ anymatch "^3.1.3"
+ chokidar "^4.0.3"
+ destr "^2.0.5"
+ h3 "^1.15.4"
+ lru-cache "^10.4.3"
+ node-fetch-native "^1.6.7"
+ ofetch "^1.5.1"
+ ufo "^1.6.1"
+
+util-deprecate@^1.0.2:
+ version "1.0.2"
+ resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf"
+ integrity sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==
+
+vfile-location@^5.0.0:
+ version "5.0.3"
+ resolved "https://registry.yarnpkg.com/vfile-location/-/vfile-location-5.0.3.tgz#cb9eacd20f2b6426d19451e0eafa3d0a846225c3"
+ integrity sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==
+ dependencies:
+ "@types/unist" "^3.0.0"
+ vfile "^6.0.0"
+
+vfile-message@^4.0.0:
+ version "4.0.3"
+ resolved "https://registry.yarnpkg.com/vfile-message/-/vfile-message-4.0.3.tgz#87b44dddd7b70f0641c2e3ed0864ba73e2ea8df4"
+ integrity sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==
+ dependencies:
+ "@types/unist" "^3.0.0"
+ unist-util-stringify-position "^4.0.0"
+
+vfile@^6.0.0, vfile@^6.0.2, vfile@^6.0.3:
+ version "6.0.3"
+ resolved "https://registry.yarnpkg.com/vfile/-/vfile-6.0.3.tgz#3652ab1c496531852bf55a6bac57af981ebc38ab"
+ integrity sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==
+ dependencies:
+ "@types/unist" "^3.0.0"
+ vfile-message "^4.0.0"
+
+vite@^6.4.1:
+ version "6.4.1"
+ resolved "https://registry.yarnpkg.com/vite/-/vite-6.4.1.tgz#afbe14518cdd6887e240a4b0221ab6d0ce733f96"
+ integrity sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==
+ dependencies:
+ esbuild "^0.25.0"
+ fdir "^6.4.4"
+ picomatch "^4.0.2"
+ postcss "^8.5.3"
+ rollup "^4.34.9"
+ tinyglobby "^0.2.13"
+ optionalDependencies:
+ fsevents "~2.3.3"
+
+vitefu@^1.1.1:
+ version "1.1.1"
+ resolved "https://registry.yarnpkg.com/vitefu/-/vitefu-1.1.1.tgz#c39b7e4c91bf2f6c590fb96e0758f394dff5795b"
+ integrity sha512-B/Fegf3i8zh0yFbpzZ21amWzHmuNlLlmJT6n7bu5e+pCHUKQIfXSYokrqOBGEMMe9UG2sostKQF9mml/vYaWJQ==
+
+web-namespaces@^2.0.0:
+ version "2.0.1"
+ resolved "https://registry.yarnpkg.com/web-namespaces/-/web-namespaces-2.0.1.tgz#1010ff7c650eccb2592cebeeaf9a1b253fd40692"
+ integrity sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==
+
+which-pm-runs@^1.1.0:
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/which-pm-runs/-/which-pm-runs-1.1.0.tgz#35ccf7b1a0fce87bd8b92a478c9d045785d3bf35"
+ integrity sha512-n1brCuqClxfFfq/Rb0ICg9giSZqCS+pLtccdag6C2HyufBrh3fBOiy9nb6ggRMvWOVH5GrdJskj5iGTZNxd7SA==
+
+widest-line@^5.0.0:
+ version "5.0.0"
+ resolved "https://registry.yarnpkg.com/widest-line/-/widest-line-5.0.0.tgz#b74826a1e480783345f0cd9061b49753c9da70d0"
+ integrity sha512-c9bZp7b5YtRj2wOe6dlj32MK+Bx/M/d+9VB2SHM1OtsUHR0aV0tdP6DWh/iMt0kWi1t5g1Iudu6hQRNd1A4PVA==
+ dependencies:
+ string-width "^7.0.0"
+
+wrap-ansi@^9.0.0:
+ version "9.0.2"
+ resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-9.0.2.tgz#956832dea9494306e6d209eb871643bb873d7c98"
+ integrity sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==
+ dependencies:
+ ansi-styles "^6.2.1"
+ string-width "^7.0.0"
+ strip-ansi "^7.1.0"
+
+xxhash-wasm@^1.1.0:
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/xxhash-wasm/-/xxhash-wasm-1.1.0.tgz#ffe7f0b98220a4afac171e3fb9b6d1f8771f015e"
+ integrity sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA==
+
+yargs-parser@^21.1.1:
+ version "21.1.1"
+ resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-21.1.1.tgz#9096bceebf990d21bb31fa9516e0ede294a77d35"
+ integrity sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==
+
+yocto-queue@^1.1.1:
+ version "1.2.2"
+ resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-1.2.2.tgz#3e09c95d3f1aa89a58c114c99223edf639152c00"
+ integrity sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==
+
+yocto-spinner@^0.2.3:
+ version "0.2.3"
+ resolved "https://registry.yarnpkg.com/yocto-spinner/-/yocto-spinner-0.2.3.tgz#e803d2f267c7f0c3188645878522066764263a13"
+ integrity sha512-sqBChb33loEnkoXte1bLg45bEBsOP9N1kzQh5JZNKj/0rik4zAPTNSAVPj3uQAdc6slYJ0Ksc403G2XgxsJQFQ==
+ dependencies:
+ yoctocolors "^2.1.1"
+
+yoctocolors@^2.1.1:
+ version "2.1.2"
+ resolved "https://registry.yarnpkg.com/yoctocolors/-/yoctocolors-2.1.2.tgz#d795f54d173494e7d8db93150cec0ed7f678c83a"
+ integrity sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==
+
+zod-to-json-schema@^3.25.0:
+ version "3.25.0"
+ resolved "https://registry.yarnpkg.com/zod-to-json-schema/-/zod-to-json-schema-3.25.0.tgz#df504c957c4fb0feff467c74d03e6aab0b013e1c"
+ integrity sha512-HvWtU2UG41LALjajJrML6uQejQhNJx+JBO9IflpSja4R03iNWfKXrj6W2h7ljuLyc1nKS+9yDyL/9tD1U/yBnQ==
+
+zod-to-ts@^1.2.0:
+ version "1.2.0"
+ resolved "https://registry.yarnpkg.com/zod-to-ts/-/zod-to-ts-1.2.0.tgz#873a2fd8242d7b649237be97e0c64d7954ae0c51"
+ integrity sha512-x30XE43V+InwGpvTySRNz9kB7qFU8DlyEy7BsSTCHPH1R0QasMmHWZDCzYm6bVXtj/9NNJAZF3jW8rzFvH5OFA==
+
+zod@^3.25.76:
+ version "3.25.76"
+ resolved "https://registry.yarnpkg.com/zod/-/zod-3.25.76.tgz#26841c3f6fd22a6a2760e7ccb719179768471e34"
+ integrity sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==
+
+zwitch@^2.0.0, zwitch@^2.0.4:
+ version "2.0.4"
+ resolved "https://registry.yarnpkg.com/zwitch/-/zwitch-2.0.4.tgz#c827d4b0acb76fc3e685a4c6ec2902d51070e9d7"
+ integrity sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==
diff --git a/install/action.yml b/install/action.yml
new file mode 100644
index 00000000..e84c4bfd
--- /dev/null
+++ b/install/action.yml
@@ -0,0 +1,71 @@
+name: "Install Craft"
+description: "Install Craft CLI (from build artifact for dogfooding, build from source, or from release)"
+
+inputs:
+ craft-version:
+ description: 'Version of Craft to install (tag or "latest"). Only used when installing from release.'
+ required: false
+ default: 'latest'
+
+runs:
+ using: "composite"
+ steps:
+ - name: Download Craft from build artifact
+ id: artifact
+ if: github.repository == 'getsentry/craft'
+ uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4
+ continue-on-error: true
+ with:
+ name: ${{ github.sha }}
+ path: /tmp/craft-artifact
+
+ - name: Install Craft from artifact
+ if: steps.artifact.outcome == 'success'
+ shell: bash
+ run: |
+ echo "Installing Craft from build artifact..."
+ sudo install -m 755 /tmp/craft-artifact/dist/craft /usr/local/bin/craft
+
+ # For getsentry/craft repo: build from source if no artifact available
+ - name: Setup Node.js (for building from source)
+ if: github.repository == 'getsentry/craft' && steps.artifact.outcome != 'success'
+ uses: actions/setup-node@v4
+ with:
+ node-version: '22'
+
+ - name: Build Craft from source
+ id: build
+ if: github.repository == 'getsentry/craft' && steps.artifact.outcome != 'success'
+ shell: bash
+ run: |
+ echo "Building Craft from source..."
+ yarn install --frozen-lockfile
+ yarn build
+ sudo install -m 755 dist/craft /usr/local/bin/craft
+
+ - name: Install Craft from release
+ if: steps.artifact.outcome != 'success' && steps.build.outcome != 'success'
+ shell: bash
+ env:
+ CRAFT_VERSION: ${{ inputs.craft-version }}
+ run: |
+ if [[ "$CRAFT_VERSION" == "latest" || -z "$CRAFT_VERSION" ]]; then
+ # Try action ref first (e.g., v2, 2.15.0)
+ ACTION_REF="${{ github.action_ref }}"
+ CRAFT_URL="https://github.com/getsentry/craft/releases/download/${ACTION_REF}/craft"
+
+ echo "Trying to download Craft from: ${CRAFT_URL}"
+
+ # Fallback to latest if ref doesn't have a release
+ if ! curl -sfI "$CRAFT_URL" >/dev/null 2>&1; then
+ echo "Release not found for ref '${ACTION_REF}', falling back to latest..."
+ CRAFT_URL=$(curl -s "https://api.github.com/repos/getsentry/craft/releases/latest" \
+ | jq -r '.assets[] | select(.name == "craft") | .browser_download_url')
+ fi
+ else
+ CRAFT_URL="https://github.com/getsentry/craft/releases/download/${CRAFT_VERSION}/craft"
+ fi
+
+ echo "Installing Craft from: ${CRAFT_URL}"
+ sudo curl -sL -o /usr/local/bin/craft "$CRAFT_URL"
+ sudo chmod +x /usr/local/bin/craft
diff --git a/package.json b/package.json
index 445cf43d..16eda59d 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@sentry/craft",
- "version": "2.12.1",
+ "version": "2.16.0-dev.0",
"description": "The universal sentry workflow CLI",
"main": "dist/craft",
"repository": "https://github.com/getsentry/craft",
@@ -17,7 +17,7 @@
"**/dot-prop": "^5.3.0",
"**/kind-of": ">=6.0.3",
"**/node-fetch": "^2.6.7",
- "**/yargs-parser": "~18.1.3",
+ "**/yargs-parser": ">=18.1.3",
"**/parse-url": ">=5.0.3",
"**/ansi-regex": ">=5.0.1 < 6.0.0",
"@jest/reporters/**/strip-ansi": "^6.0.1"
@@ -47,7 +47,7 @@
"@types/shell-quote": "^1.6.0",
"@types/tar": "^4.0.0",
"@types/tmp": "^0.0.33",
- "@types/yargs": "^15.0.3",
+ "@types/yargs": "^17",
"@typescript-eslint/eslint-plugin": "^5.19.0",
"@typescript-eslint/parser": "^5.19.0",
"ajv": "6.12.6",
@@ -63,6 +63,7 @@
"extract-zip": "^2.0.1",
"fast-xml-parser": "^4.2.4",
"git-url-parse": "^16.1.0",
+ "glob": "^11.0.0",
"is-ci": "^2.0.0",
"jest": "^29.7.0",
"js-yaml": "4.1.1",
@@ -85,12 +86,12 @@
"tmp": "0.2.4",
"ts-jest": "^29.1.1",
"typescript": "^5.1.6",
- "yargs": "15.4.1"
+ "yargs": "^18"
},
"scripts": {
"build:fat": "yarn run compile-config-schema && tsc -p tsconfig.build.json",
"build:watch": "yarn run compile-config-schema && tsc -p tsconfig.build.json --watch",
- "build": "yarn compile-config-schema && esbuild src/index.ts --sourcemap --bundle --platform=node --target=node20 --inject:./src/utils/import-meta-url.js --define:import.meta.url=import_meta_url --outfile=dist/craft --minify",
+ "build": "yarn compile-config-schema && esbuild src/index.ts --sourcemap --bundle --platform=node --target=node22 --inject:./src/utils/import-meta-url.js --define:import.meta.url=import_meta_url --outfile=dist/craft",
"precli": "yarn build",
"cli": "node -r source-map-support/register dist/craft",
"clean": "rimraf dist coverage",
@@ -98,7 +99,9 @@
"fix": "yarn lint --fix",
"test": "jest",
"test:watch": "jest --watch --notify",
- "compile-config-schema": "node ./scripts/config-json-schema-to-ts.js"
+ "compile-config-schema": "node ./scripts/config-json-schema-to-ts.js",
+ "docs:dev": "cd docs && yarn dev",
+ "docs:build": "cd docs && yarn build"
},
"volta": {
"node": "22.12.0",
diff --git a/src/__tests__/index.test.ts b/src/__tests__/index.test.ts
index 642eadb6..a9cb9e7e 100644
--- a/src/__tests__/index.test.ts
+++ b/src/__tests__/index.test.ts
@@ -1,3 +1,79 @@
-test('it works', () => {
- expect(true).toBeTruthy();
+import { execFile } from 'child_process';
+import { promisify } from 'util';
+import { resolve } from 'path';
+
+const execFileAsync = promisify(execFile);
+
+// Path to the TypeScript source file - we use ts-node to run it directly
+const CLI_ENTRY = resolve(__dirname, '../index.ts');
+
+describe('CLI smoke tests', () => {
+ // Increase timeout for CLI tests as they spawn processes
+ jest.setTimeout(30000);
+
+ test('CLI starts and shows help without runtime errors', async () => {
+ // This catches issues like:
+ // - Missing dependencies
+ // - Syntax errors
+ // - Runtime initialization errors (e.g., yargs singleton usage in v18)
+ const { stdout, stderr } = await execFileAsync(
+ 'npx',
+ ['ts-node', '--transpile-only', CLI_ENTRY, '--help'],
+ { env: { ...process.env, NODE_ENV: 'test' } }
+ );
+
+ expect(stdout).toMatch(//);
+ expect(stdout).toContain('prepare NEW-VERSION');
+ expect(stdout).toContain('publish NEW-VERSION');
+ expect(stdout).toContain('--help');
+ // Ensure no error output (warnings are acceptable)
+ expect(stderr).not.toContain('Error');
+ expect(stderr).not.toContain('TypeError');
+ });
+
+ test('CLI shows version without errors', async () => {
+ const { stdout } = await execFileAsync(
+ 'npx',
+ ['ts-node', '--transpile-only', CLI_ENTRY, '--version'],
+ { env: { ...process.env, NODE_ENV: 'test' } }
+ );
+
+ // Version should be a semver-like string
+ expect(stdout.trim()).toMatch(/^\d+\.\d+\.\d+/);
+ });
+
+ test('CLI exits with error for unknown command', async () => {
+ // This ensures yargs command parsing works and async handlers are awaited
+ await expect(
+ execFileAsync(
+ 'npx',
+ ['ts-node', '--transpile-only', CLI_ENTRY, 'nonexistent-command'],
+ { env: { ...process.env, NODE_ENV: 'test' } }
+ )
+ ).rejects.toMatchObject({
+ code: 1,
+ });
+ });
+
+ test('async command handler completes properly', async () => {
+ // The 'targets' command has an async handler and requires a .craft.yml
+ // Without proper await on parse(), this would exit before completing
+ // We expect it to fail due to missing config, but it should fail gracefully
+ // not due to premature exit
+ try {
+ await execFileAsync(
+ 'npx',
+ ['ts-node', '--transpile-only', CLI_ENTRY, 'targets'],
+ {
+ env: { ...process.env, NODE_ENV: 'test' },
+ cwd: '/tmp', // No .craft.yml here
+ }
+ );
+ } catch (error: any) {
+ // Should fail with a config error, not a silent exit or unhandled promise
+ expect(error.stderr || error.stdout).toMatch(
+ /Cannot find configuration file|craft\.yml|config/i
+ );
+ }
+ });
});
diff --git a/src/commands/__tests__/prepare.test.ts b/src/commands/__tests__/prepare.test.ts
index e5baaad1..d280fcde 100644
--- a/src/commands/__tests__/prepare.test.ts
+++ b/src/commands/__tests__/prepare.test.ts
@@ -69,6 +69,31 @@ describe('checkVersionOrPart', () => {
}
});
+ test('return true for auto version', () => {
+ expect(
+ checkVersionOrPart(
+ {
+ newVersion: 'auto',
+ },
+ null
+ )
+ ).toBe(true);
+ });
+
+ test('return true for version bump types', () => {
+ const bumpTypes = ['major', 'minor', 'patch'];
+ for (const bumpType of bumpTypes) {
+ expect(
+ checkVersionOrPart(
+ {
+ newVersion: bumpType,
+ },
+ null
+ )
+ ).toBe(true);
+ }
+ });
+
test('throw an error for invalid version', () => {
const invalidVersions = [
{
@@ -80,9 +105,6 @@ describe('checkVersionOrPart', () => {
e:
'Invalid version or version part specified: "v2.3.3". Removing the "v" prefix will likely fix the issue',
},
- { v: 'major', e: 'Version part is not supported yet' },
- { v: 'minor', e: 'Version part is not supported yet' },
- { v: 'patch', e: 'Version part is not supported yet' },
];
for (const t of invalidVersions) {
const fn = () => {
diff --git a/src/commands/__tests__/targets.test.ts b/src/commands/__tests__/targets.test.ts
new file mode 100644
index 00000000..2f2e6d8b
--- /dev/null
+++ b/src/commands/__tests__/targets.test.ts
@@ -0,0 +1,117 @@
+import { handler } from '../targets';
+
+jest.mock('../../config', () => ({
+ getConfiguration: jest.fn(),
+ expandWorkspaceTargets: jest.fn(),
+}));
+
+jest.mock('../../targets', () => ({
+ getAllTargetNames: jest.fn(),
+}));
+
+import { getConfiguration, expandWorkspaceTargets } from '../../config';
+import { getAllTargetNames } from '../../targets';
+
+describe('targets command', () => {
+ const mockedGetConfiguration = getConfiguration as jest.Mock;
+ const mockedExpandWorkspaceTargets = expandWorkspaceTargets as jest.Mock;
+ const mockedGetAllTargetNames = getAllTargetNames as jest.Mock;
+ let consoleSpy: jest.SpyInstance;
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ consoleSpy = jest.spyOn(console, 'log').mockImplementation();
+ });
+
+ afterEach(() => {
+ consoleSpy.mockRestore();
+ });
+
+ test('lists targets without expansion when no workspaces', async () => {
+ const targets = [
+ { name: 'npm' },
+ { name: 'github' },
+ ];
+
+ mockedGetConfiguration.mockReturnValue({ targets });
+ mockedExpandWorkspaceTargets.mockResolvedValue(targets);
+ mockedGetAllTargetNames.mockReturnValue(['npm', 'github', 'pypi']);
+
+ await handler();
+
+ expect(mockedExpandWorkspaceTargets).toHaveBeenCalledWith(targets);
+ expect(consoleSpy).toHaveBeenCalled();
+ const output = JSON.parse(consoleSpy.mock.calls[0][0]);
+ expect(output).toEqual(['npm', 'github']);
+ });
+
+ test('lists expanded workspace targets', async () => {
+ const originalTargets = [
+ { name: 'npm', workspaces: true },
+ { name: 'github' },
+ ];
+
+ const expandedTargets = [
+ { name: 'npm', id: '@sentry/core' },
+ { name: 'npm', id: '@sentry/browser' },
+ { name: 'github' },
+ ];
+
+ mockedGetConfiguration.mockReturnValue({ targets: originalTargets });
+ mockedExpandWorkspaceTargets.mockResolvedValue(expandedTargets);
+ mockedGetAllTargetNames.mockReturnValue(['npm', 'github']);
+
+ await handler();
+
+ expect(mockedExpandWorkspaceTargets).toHaveBeenCalledWith(originalTargets);
+ expect(consoleSpy).toHaveBeenCalled();
+ const output = JSON.parse(consoleSpy.mock.calls[0][0]);
+ expect(output).toEqual([
+ 'npm[@sentry/core]',
+ 'npm[@sentry/browser]',
+ 'github',
+ ]);
+ });
+
+ test('filters out unknown target names', async () => {
+ const targets = [
+ { name: 'npm' },
+ { name: 'unknown-target' },
+ { name: 'github' },
+ ];
+
+ mockedGetConfiguration.mockReturnValue({ targets });
+ mockedExpandWorkspaceTargets.mockResolvedValue(targets);
+ mockedGetAllTargetNames.mockReturnValue(['npm', 'github']);
+
+ await handler();
+
+ expect(consoleSpy).toHaveBeenCalled();
+ const output = JSON.parse(consoleSpy.mock.calls[0][0]);
+ expect(output).toEqual(['npm', 'github']);
+ });
+
+ test('handles empty targets list', async () => {
+ mockedGetConfiguration.mockReturnValue({ targets: [] });
+ mockedExpandWorkspaceTargets.mockResolvedValue([]);
+ mockedGetAllTargetNames.mockReturnValue(['npm', 'github']);
+
+ await handler();
+
+ expect(consoleSpy).toHaveBeenCalled();
+ const output = JSON.parse(consoleSpy.mock.calls[0][0]);
+ expect(output).toEqual([]);
+ });
+
+ test('handles undefined targets', async () => {
+ mockedGetConfiguration.mockReturnValue({});
+ mockedExpandWorkspaceTargets.mockResolvedValue([]);
+ mockedGetAllTargetNames.mockReturnValue(['npm', 'github']);
+
+ await handler();
+
+ expect(consoleSpy).toHaveBeenCalled();
+ const output = JSON.parse(consoleSpy.mock.calls[0][0]);
+ expect(output).toEqual([]);
+ });
+});
diff --git a/src/commands/changelog.ts b/src/commands/changelog.ts
new file mode 100644
index 00000000..53cfed7b
--- /dev/null
+++ b/src/commands/changelog.ts
@@ -0,0 +1,96 @@
+import { Argv, CommandBuilder } from 'yargs';
+
+import { logger } from '../logger';
+import { getGitClient, getLatestTag } from '../utils/git';
+import {
+ generateChangesetFromGit,
+ generateChangelogWithHighlight,
+} from '../utils/changelog';
+import { handleGlobalError } from '../utils/errors';
+
+export const command = ['changelog'];
+export const description = 'Generate changelog from git history';
+
+/** Output format options */
+type OutputFormat = 'text' | 'json';
+
+/** Command line options */
+interface ChangelogOptions {
+ /** Base revision to generate changelog from (defaults to latest tag) */
+ since?: string;
+ /** PR number for the current (unmerged) PR */
+ pr?: number;
+ /** Output format: text (default) or json */
+ format?: OutputFormat;
+}
+
+export const builder: CommandBuilder = (yargs: Argv) =>
+ yargs
+ .option('since', {
+ alias: 's',
+ description:
+ 'Base revision (tag or SHA) to generate changelog from. Defaults to latest tag.',
+ type: 'string',
+ })
+ .option('pr', {
+ description:
+ 'PR number for the current (unmerged) PR. The PR info will be fetched from GitHub API and the PR included in the changelog with highlighting.',
+ type: 'number',
+ })
+ .option('format', {
+ alias: 'f',
+ description: 'Output format: text (default) or json',
+ type: 'string',
+ choices: ['text', 'json'] as const,
+ default: 'text',
+ });
+
+/**
+ * Body of 'changelog' command
+ */
+export async function changelogMain(argv: ChangelogOptions): Promise {
+ const git = await getGitClient();
+
+ // Determine base revision for changelog generation
+ let since = argv.since;
+ if (!since) {
+ since = await getLatestTag(git);
+ if (since) {
+ logger.debug(`Using latest tag as base revision: ${since}`);
+ } else {
+ logger.debug('No tags found, generating changelog from beginning of history');
+ }
+ }
+
+ // Generate changelog - use different function depending on whether PR is specified
+ const result = argv.pr
+ ? await generateChangelogWithHighlight(git, since, argv.pr)
+ : await generateChangesetFromGit(git, since);
+
+ // Output based on format
+ if (argv.format === 'json') {
+ const output = {
+ changelog: result.changelog || '',
+ bumpType: result.bumpType,
+ totalCommits: result.totalCommits,
+ matchedCommitsWithSemver: result.matchedCommitsWithSemver,
+ };
+ console.log(JSON.stringify(output, null, 2));
+ } else {
+ if (!result.changelog) {
+ console.log('No changelog entries found.');
+ return;
+ }
+ console.log(result.changelog);
+ }
+}
+
+export const handler = async (args: {
+ [argName: string]: any;
+}): Promise => {
+ try {
+ return await changelogMain(args as ChangelogOptions);
+ } catch (e) {
+ handleGlobalError(e);
+ }
+};
diff --git a/src/commands/prepare.ts b/src/commands/prepare.ts
index d9429410..d2ed758f 100644
--- a/src/commands/prepare.ts
+++ b/src/commands/prepare.ts
@@ -8,9 +8,14 @@ import {
getConfiguration,
DEFAULT_RELEASE_BRANCH_NAME,
getGlobalGitHubConfig,
+ requiresMinVersion,
+ loadConfigurationFromString,
+ CONFIG_FILE_NAME,
+ getVersioningPolicy,
} from '../config';
import { logger } from '../logger';
-import { ChangelogPolicy } from '../schemas/project_config';
+import { ChangelogPolicy, VersioningPolicy } from '../schemas/project_config';
+import { calculateCalVer, DEFAULT_CALVER_CONFIG } from '../utils/calver';
import { sleep } from '../utils/async';
import {
DEFAULT_CHANGELOG_PATH,
@@ -26,7 +31,18 @@ import {
reportError,
} from '../utils/errors';
import { getGitClient, getDefaultBranch, getLatestTag } from '../utils/git';
-import { isDryRun, promptConfirmation } from '../utils/helpers';
+import {
+ getChangelogWithBumpType,
+ calculateNextVersion,
+ validateBumpType,
+ isBumpType,
+ type BumpType,
+} from '../utils/autoVersion';
+import {
+ isDryRun,
+ promptConfirmation,
+ setGitHubActionsOutput,
+} from '../utils/helpers';
import { formatJson } from '../utils/strings';
import { spawnProcess } from '../utils/system';
import { isValidVersion } from '../utils/version';
@@ -40,10 +56,17 @@ export const description = '🚢 Prepare a new release branch';
/** Default path to bump-version script, relative to project root */
const DEFAULT_BUMP_VERSION_PATH = join('scripts', 'bump-version.sh');
+/** Minimum craft version required for auto-versioning */
+const AUTO_VERSION_MIN_VERSION = '2.14.0';
+
export const builder: CommandBuilder = (yargs: Argv) =>
yargs
.positional('NEW-VERSION', {
- description: 'The new version you want to release',
+ description:
+ 'The new version to release. Can be: a semver string (e.g., "1.2.3"), ' +
+ 'a bump type ("major", "minor", or "patch"), "auto" to determine automatically ' +
+ 'from conventional commits, or "calver" for calendar versioning. ' +
+ 'If omitted, uses the versioning.policy from .craft.yml',
type: 'string',
})
.option('rev', {
@@ -77,12 +100,20 @@ export const builder: CommandBuilder = (yargs: Argv) =>
description: 'The git remote to use when pushing',
type: 'string',
})
+ .option('config-from', {
+ description: 'Load .craft.yml from the specified remote branch instead of local file',
+ type: 'string',
+ })
+ .option('calver-offset', {
+ description: 'Days to go back for CalVer date calculation (overrides config)',
+ type: 'number',
+ })
.check(checkVersionOrPart);
/** Command line options. */
interface PrepareOptions {
- /** The new version to release */
- newVersion: string;
+ /** The new version to release (optional if versioning.policy is configured) */
+ newVersion?: string;
/** The base revision to release */
rev: string;
/** The git remote to use when pushing */
@@ -95,6 +126,10 @@ interface PrepareOptions {
noPush: boolean;
/** Run publish right after */
publish: boolean;
+ /** Load config from specified remote branch */
+ configFrom?: string;
+ /** Override CalVer offset (days to go back) */
+ calverOffset?: number;
}
/**
@@ -106,17 +141,38 @@ const SLEEP_BEFORE_PUBLISH_SECONDS = 30;
/**
* Checks the provided version argument for validity
*
- * We check that the argument is either a valid version string, or a valid
- * semantic version part.
+ * We check that the argument is either a valid version string, 'auto' for
+ * automatic version detection, 'calver' for calendar versioning, a version
+ * bump type (major/minor/patch), or a valid semantic version.
+ * Empty/undefined is also allowed (will use versioning.policy from config).
*
* @param argv Parsed yargs arguments
* @param _opt A list of options and aliases
*/
export function checkVersionOrPart(argv: Arguments, _opt: any): boolean {
const version = argv.newVersion;
- if (['major', 'minor', 'patch'].indexOf(version) > -1) {
- throw Error('Version part is not supported yet');
- } else if (isValidVersion(version)) {
+
+ // Allow empty version (will use versioning.policy from config)
+ if (!version) {
+ return true;
+ }
+
+ // Allow 'auto' for automatic version detection
+ if (version === 'auto') {
+ return true;
+ }
+
+ // Allow 'calver' for calendar versioning
+ if (version === 'calver') {
+ return true;
+ }
+
+ // Allow version bump types (major, minor, patch)
+ if (isBumpType(version)) {
+ return true;
+ }
+
+ if (isValidVersion(version)) {
return true;
} else {
let errMsg = `Invalid version or version part specified: "${version}"`;
@@ -349,6 +405,7 @@ async function execPublish(remote: string, newVersion: string): Promise {
* @param newVersion The new version we are releasing
* @param changelogPolicy One of the changelog policies, such as "none", "simple", etc.
* @param changelogPath Path to the changelog file
+ * @returns The changelog body for this version, or undefined if no changelog
*/
async function prepareChangelog(
git: SimpleGit,
@@ -356,12 +413,12 @@ async function prepareChangelog(
newVersion: string,
changelogPolicy: ChangelogPolicy = ChangelogPolicy.None,
changelogPath: string = DEFAULT_CHANGELOG_PATH
-): Promise {
+): Promise {
if (changelogPolicy === ChangelogPolicy.None) {
logger.debug(
`Changelog policy is set to "${changelogPolicy}", nothing to do.`
);
- return;
+ return undefined;
}
if (
@@ -403,7 +460,9 @@ async function prepareChangelog(
}
if (!changeset.body) {
replaceSection = changeset.name;
- changeset.body = await generateChangesetFromGit(git, oldVersion);
+ // generateChangesetFromGit is memoized, so this won't duplicate API calls
+ const result = await generateChangesetFromGit(git, oldVersion);
+ changeset.body = result.changelog;
}
if (changeset.name === DEFAULT_UNRELEASED_TITLE) {
replaceSection = changeset.name;
@@ -436,6 +495,7 @@ async function prepareChangelog(
logger.debug('Changelog entry found:', changeset.name);
logger.trace(changeset.body);
+ return changeset?.body;
}
/**
@@ -460,18 +520,145 @@ async function switchToDefaultBranch(
}
}
+interface ResolveVersionOptions {
+ /** The raw version input from CLI (may be undefined, 'auto', 'calver', bump type, or semver) */
+ versionArg?: string;
+ /** Override for CalVer offset (days to go back) */
+ calverOffset?: number;
+}
+
+/**
+ * Resolves the final semver version string from various input types.
+ *
+ * Handles:
+ * - No input: uses versioning.policy from config
+ * - 'calver': calculates calendar version
+ * - 'auto': analyzes commits to determine bump type
+ * - 'major'/'minor'/'patch': applies bump to latest tag
+ * - Explicit semver: returns as-is
+ *
+ * @param git Local git client
+ * @param options Version resolution options
+ * @returns The resolved semver version string
+ */
+async function resolveVersion(
+ git: SimpleGit,
+ options: ResolveVersionOptions
+): Promise {
+ const config = getConfiguration();
+ let version = options.versionArg;
+
+ // If no version specified, use the versioning policy from config
+ if (!version) {
+ const policy = getVersioningPolicy();
+ logger.debug(`No version specified, using versioning policy: ${policy}`);
+
+ if (policy === VersioningPolicy.Manual) {
+ throw new ConfigurationError(
+ 'Version is required. Either specify a version argument or set ' +
+ 'versioning.policy to "auto" or "calver" in .craft.yml'
+ );
+ }
+
+ // Use the policy as the version type
+ version = policy;
+ }
+
+ // Handle CalVer versioning
+ if (version === 'calver') {
+ if (!requiresMinVersion(AUTO_VERSION_MIN_VERSION)) {
+ throw new ConfigurationError(
+ `CalVer versioning requires minVersion >= ${AUTO_VERSION_MIN_VERSION} in .craft.yml. ` +
+ 'Please update your configuration or specify the version explicitly.'
+ );
+ }
+
+ // Build CalVer config with overrides
+ const calverOffset =
+ options.calverOffset ??
+ (process.env.CRAFT_CALVER_OFFSET
+ ? parseInt(process.env.CRAFT_CALVER_OFFSET, 10)
+ : undefined) ??
+ config.versioning?.calver?.offset ??
+ DEFAULT_CALVER_CONFIG.offset;
+
+ const calverFormat =
+ config.versioning?.calver?.format ?? DEFAULT_CALVER_CONFIG.format;
+
+ return calculateCalVer(git, {
+ offset: calverOffset,
+ format: calverFormat,
+ });
+ }
+
+ // Handle automatic version detection or version bump types
+ if (version === 'auto' || isBumpType(version)) {
+ if (!requiresMinVersion(AUTO_VERSION_MIN_VERSION)) {
+ const featureName = isBumpType(version)
+ ? 'Version bump types'
+ : 'Auto-versioning';
+ throw new ConfigurationError(
+ `${featureName} requires minVersion >= ${AUTO_VERSION_MIN_VERSION} in .craft.yml. ` +
+ 'Please update your configuration or specify the version explicitly.'
+ );
+ }
+
+ const latestTag = await getLatestTag(git);
+
+ // Determine bump type - either from arg or from commit analysis
+ let bumpType: BumpType;
+ if (version === 'auto') {
+ const changelogResult = await getChangelogWithBumpType(git, latestTag);
+ validateBumpType(changelogResult);
+ bumpType = changelogResult.bumpType;
+ } else {
+ bumpType = version as BumpType;
+ }
+
+ // Calculate new version from latest tag
+ const currentVersion =
+ latestTag && latestTag.replace(/^v/, '').match(/^\d/)
+ ? latestTag.replace(/^v/, '')
+ : '0.0.0';
+
+ const newVersion = calculateNextVersion(currentVersion, bumpType);
+ logger.info(
+ `Version bump: ${currentVersion} -> ${newVersion} (${bumpType} bump)`
+ );
+ return newVersion;
+ }
+
+ // Explicit semver version - return as-is
+ return version;
+}
+
/**
* Body of 'prepare' command
*
* @param argv Command-line arguments
*/
export async function prepareMain(argv: PrepareOptions): Promise {
+ const git = await getGitClient();
+
+ // Handle --config-from: load config from remote branch
+ if (argv.configFrom) {
+ logger.info(`Loading configuration from remote branch: ${argv.configFrom}`);
+ try {
+ await git.fetch([argv.remote, argv.configFrom]);
+ const configContent = await git.show([
+ `${argv.remote}/${argv.configFrom}:${CONFIG_FILE_NAME}`,
+ ]);
+ loadConfigurationFromString(configContent);
+ } catch (error: any) {
+ throw new ConfigurationError(
+ `Failed to load ${CONFIG_FILE_NAME} from branch "${argv.configFrom}": ${error.message}`
+ );
+ }
+ }
+
// Get repo configuration
const config = getConfiguration();
const githubConfig = await getGlobalGitHubConfig();
- const newVersion = argv.newVersion;
-
- const git = await getGitClient();
const defaultBranch = await getDefaultBranch(git, argv.remote);
logger.debug(`Default branch for the repo:`, defaultBranch);
@@ -485,6 +672,15 @@ export async function prepareMain(argv: PrepareOptions): Promise {
checkGitStatus(repoStatus, rev);
}
+ // Resolve version from input, policy, or automatic detection
+ const newVersion = await resolveVersion(git, {
+ versionArg: argv.newVersion,
+ calverOffset: argv.calverOffset,
+ });
+
+ // Emit resolved version for GitHub Actions
+ setGitHubActionsOutput('version', newVersion);
+
logger.info(`Releasing version ${newVersion} from ${rev}`);
if (!argv.rev && rev !== defaultBranch) {
logger.warn("You're not on your default branch, so I have to ask...");
@@ -521,7 +717,7 @@ export async function prepareMain(argv: PrepareOptions): Promise {
? config.changelog.policy
: config.changelogPolicy
) as ChangelogPolicy | undefined;
- await prepareChangelog(
+ const changelogBody = await prepareChangelog(
git,
oldVersion,
newVersion,
@@ -546,6 +742,15 @@ export async function prepareMain(argv: PrepareOptions): Promise {
// Push the release branch
await pushReleaseBranch(git, branchName, argv.remote, !argv.noPush);
+ // Emit GitHub Actions outputs for downstream steps
+ const releaseSha = await git.revparse(['HEAD']);
+ setGitHubActionsOutput('branch', branchName);
+ setGitHubActionsOutput('sha', releaseSha);
+ setGitHubActionsOutput('previous_tag', oldVersion || '');
+ if (changelogBody) {
+ setGitHubActionsOutput('changelog', changelogBody);
+ }
+
logger.info(
`View diff at: https://github.com/${githubConfig.owner}/${githubConfig.repo}/compare/${branchName}`
);
diff --git a/src/commands/publish.ts b/src/commands/publish.ts
index 27d40f78..b24a01a5 100644
--- a/src/commands/publish.ts
+++ b/src/commands/publish.ts
@@ -16,6 +16,7 @@ import {
getArtifactProviderFromConfig,
DEFAULT_RELEASE_BRANCH_NAME,
getGlobalGitHubConfig,
+ expandWorkspaceTargets,
} from '../config';
import { formatTable, logger } from '../logger';
import { TargetConfig } from '../schemas/project_config';
@@ -501,7 +502,8 @@ export async function publishMain(argv: PublishOptions): Promise {
}
}
- let targetConfigList = config.targets || [];
+ // Expand any npm workspace targets into individual package targets
+ let targetConfigList = await expandWorkspaceTargets(config.targets || []);
logger.info(`Looking for publish state file for ${newVersion}...`);
const publishStateFile = `.craft-publish-${newVersion}.json`;
diff --git a/src/commands/targets.ts b/src/commands/targets.ts
index f0aef8fa..57e6574c 100644
--- a/src/commands/targets.ts
+++ b/src/commands/targets.ts
@@ -1,4 +1,4 @@
-import { getConfiguration } from '../config';
+import { getConfiguration, expandWorkspaceTargets } from '../config';
import { formatJson } from '../utils/strings';
import { getAllTargetNames } from '../targets';
import { BaseTarget } from '../targets/base';
@@ -6,8 +6,12 @@ import { BaseTarget } from '../targets/base';
export const command = ['targets'];
export const description = 'List defined targets as JSON array';
-export function handler(): any {
- const definedTargets = getConfiguration().targets || [];
+export async function handler(): Promise {
+ let definedTargets = getConfiguration().targets || [];
+
+ // Expand workspace targets (e.g., npm workspaces)
+ definedTargets = await expandWorkspaceTargets(definedTargets);
+
const possibleTargetNames = new Set(getAllTargetNames());
const allowedTargetNames = definedTargets
.filter(target => target.name && possibleTargetNames.has(target.name))
diff --git a/src/config.ts b/src/config.ts
index 05cd34eb..890cc659 100644
--- a/src/config.ts
+++ b/src/config.ts
@@ -12,7 +12,9 @@ import {
GitHubGlobalConfig,
ArtifactProviderName,
StatusProviderName,
+ TargetConfig,
ChangelogPolicy,
+ VersioningPolicy,
} from './schemas/project_config';
import { ConfigurationError } from './utils/errors';
import {
@@ -20,6 +22,8 @@ import {
parseVersion,
versionGreaterOrEqualThan,
} from './utils/version';
+// Note: We import getTargetByName lazily in expandWorkspaceTargets to avoid
+// circular dependency: config -> targets -> registry -> utils/registry -> symlink -> version -> config
import { BaseArtifactProvider } from './artifact_providers/base';
import { GitHubArtifactProvider } from './artifact_providers/github';
import { NoneArtifactProvider } from './artifact_providers/none';
@@ -159,6 +163,21 @@ export function getConfiguration(clearCache = false): CraftProjectConfig {
return _configCache;
}
+/**
+ * Loads and caches configuration from a YAML string.
+ *
+ * This is used by --config-from to load config from a remote branch.
+ *
+ * @param configContent The raw YAML configuration content
+ */
+export function loadConfigurationFromString(configContent: string): CraftProjectConfig {
+ logger.debug('Loading configuration from provided content...');
+ const rawConfig = load(configContent) as Record;
+ _configCache = validateConfiguration(rawConfig);
+ checkMinimalConfigVersion(_configCache);
+ return _configCache;
+}
+
/**
* Checks that the current "craft" version is compatible with the configuration
*
@@ -199,6 +218,66 @@ function checkMinimalConfigVersion(config: CraftProjectConfig): void {
}
}
+/**
+ * Checks if the project's minVersion configuration meets a required minimum.
+ *
+ * This is used to gate features that require a certain version of craft.
+ * For example, auto-versioning requires minVersion >= 2.14.0.
+ *
+ * @param requiredVersion The minimum version required for the feature
+ * @returns true if the project's minVersion is >= requiredVersion, false otherwise
+ */
+export function requiresMinVersion(requiredVersion: string): boolean {
+ const config = getConfiguration();
+ const minVersionRaw = config.minVersion;
+
+ if (!minVersionRaw) {
+ // If no minVersion is configured, the feature is not available
+ return false;
+ }
+
+ const configuredMinVersion = parseVersion(minVersionRaw);
+ const required = parseVersion(requiredVersion);
+
+ if (!configuredMinVersion || !required) {
+ return false;
+ }
+
+ return versionGreaterOrEqualThan(configuredMinVersion, required);
+}
+
+/** Minimum craft version required for auto-versioning and CalVer */
+const AUTO_VERSION_MIN_VERSION = '2.14.0';
+
+/**
+ * Returns the effective versioning policy for the project.
+ *
+ * The policy determines how versions are resolved when no explicit version
+ * is provided to `craft prepare`:
+ * - 'auto': Analyze commits to determine the bump type
+ * - 'manual': Require an explicit version argument
+ * - 'calver': Use calendar versioning
+ *
+ * If not explicitly configured, defaults to:
+ * - 'auto' if minVersion >= 2.14.0
+ * - 'manual' otherwise (for backward compatibility)
+ *
+ * @returns The versioning policy
+ */
+export function getVersioningPolicy(): VersioningPolicy {
+ const config = getConfiguration();
+
+ // Use explicitly configured policy if available
+ if (config.versioning?.policy) {
+ return config.versioning.policy;
+ }
+
+ // Default based on minVersion
+ return requiresMinVersion(AUTO_VERSION_MIN_VERSION)
+ ? VersioningPolicy.Auto
+ : VersioningPolicy.Manual;
+}
+
/**
* Return the parsed global GitHub configuration
*/
@@ -388,3 +467,57 @@ export function getChangelogConfig(): NormalizedChangelogConfig {
scopeGrouping,
};
}
+
+/**
+ * Type for target classes that support expansion
+ */
+interface ExpandableTargetClass {
+ expand(config: TargetConfig, rootDir: string): Promise;
+}
+
+/**
+ * Check if a target class has an expand method
+ */
+function isExpandableTarget(
+ targetClass: unknown
+): targetClass is ExpandableTargetClass {
+ return (
+ typeof targetClass === 'function' &&
+ 'expand' in targetClass &&
+ typeof targetClass.expand === 'function'
+ );
+}
+
+/**
+ * Expand all expandable targets in the target list
+ *
+ * This function takes a list of target configs and expands any targets
+ * whose target class has an `expand` static method. This allows targets
+ * to implement their own expansion logic (e.g., npm workspace expansion).
+ *
+ * @param targets The original list of target configs
+ * @returns The expanded list of target configs
+ */
+export async function expandWorkspaceTargets(
+ targets: TargetConfig[]
+): Promise {
+ // Lazy import to avoid circular dependency
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
+ const { getTargetByName } = require('./targets');
+
+ const rootDir = getConfigFileDir() || process.cwd();
+ const expandedTargets: TargetConfig[] = [];
+
+ for (const target of targets) {
+ const targetClass = getTargetByName(target.name);
+
+ if (targetClass && isExpandableTarget(targetClass)) {
+ const expanded = await targetClass.expand(target, rootDir);
+ expandedTargets.push(...expanded);
+ } else {
+ expandedTargets.push(target);
+ }
+ }
+
+ return expandedTargets;
+}
diff --git a/src/index.ts b/src/index.ts
index fb6c2e50..acb7efe4 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -14,6 +14,7 @@ import * as publish from './commands/publish';
import * as targets from './commands/targets';
import * as config from './commands/config';
import * as artifacts from './commands/artifacts';
+import * as changelog from './commands/changelog';
function printVersion(): void {
if (!process.argv.includes('-v') && !process.argv.includes('--version')) {
@@ -65,7 +66,7 @@ function fixGlobalBooleanFlags(argv: string[]): string[] {
/**
* Main entrypoint
*/
-function main(): void {
+async function main(): Promise {
printVersion();
readEnvironmentConfig();
@@ -74,7 +75,7 @@ function main(): void {
const argv = fixGlobalBooleanFlags(process.argv.slice(2));
- yargs
+ await yargs()
.parserConfiguration({
'boolean-negation': false,
})
@@ -84,6 +85,7 @@ function main(): void {
.command(targets)
.command(config)
.command(artifacts)
+ .command(changelog)
.demandCommand()
.version(getPackageVersion())
.alias('v', 'version')
diff --git a/src/logger.ts b/src/logger.ts
index 5d7bb5de..b1026aed 100644
--- a/src/logger.ts
+++ b/src/logger.ts
@@ -7,6 +7,17 @@ import consola, {
LogLevel,
} from 'consola';
+/** Reporter that writes all output to stderr (so JSON on stdout isn't polluted) */
+class StderrReporter extends BasicReporter {
+ public log(logObj: ConsolaReporterLogObject) {
+ const output = this.formatLogObj(logObj);
+ process.stderr.write(output + '\n');
+ }
+}
+
+// Redirect all console output to stderr so it doesn't interfere with JSON output on stdout
+consola.setReporters([new StderrReporter()]);
+
/**
* Format a list as a table
*
diff --git a/src/schemas/projectConfig.schema.ts b/src/schemas/projectConfig.schema.ts
index d0bbf9d0..8d433141 100644
--- a/src/schemas/projectConfig.schema.ts
+++ b/src/schemas/projectConfig.schema.ts
@@ -106,6 +106,43 @@ const projectConfigJsonSchema = {
additionalProperties: false,
required: ['name'],
},
+ versioning: {
+ title: 'VersioningConfig',
+ description: 'Version resolution configuration',
+ type: 'object',
+ properties: {
+ policy: {
+ title: 'VersioningPolicy',
+ description:
+ 'Default versioning policy when no version argument is provided. ' +
+ 'auto: analyze commits to determine bump type, ' +
+ 'manual: require explicit version, ' +
+ 'calver: use calendar versioning',
+ type: 'string',
+ enum: ['auto', 'manual', 'calver'],
+ tsEnumNames: ['Auto', 'Manual', 'CalVer'],
+ },
+ calver: {
+ title: 'CalVerConfig',
+ description: 'Calendar versioning configuration',
+ type: 'object',
+ properties: {
+ offset: {
+ type: 'number',
+ description: 'Days to go back for date calculation (default: 14)',
+ },
+ format: {
+ type: 'string',
+ description:
+ 'strftime-like format for date part (default: %y.%-m). ' +
+ 'Supports: %y (2-digit year), %m (zero-padded month), %-m (month without padding)',
+ },
+ },
+ additionalProperties: false,
+ },
+ },
+ additionalProperties: false,
+ },
},
additionalProperties: false,
@@ -172,6 +209,32 @@ const projectConfigJsonSchema = {
properties: {
access: {
type: 'string',
+ description: 'NPM access level (public or restricted)',
+ },
+ checkPackageName: {
+ type: 'string',
+ description:
+ 'Package name to check for latest version on the registry',
+ },
+ workspaces: {
+ type: 'boolean',
+ description:
+ 'Enable workspace discovery to auto-generate npm targets for all workspace packages',
+ },
+ includeWorkspaces: {
+ type: 'string',
+ description:
+ 'Regex pattern to filter which workspace packages to include',
+ },
+ excludeWorkspaces: {
+ type: 'string',
+ description:
+ 'Regex pattern to filter which workspace packages to exclude',
+ },
+ artifactTemplate: {
+ type: 'string',
+ description:
+ 'Template for artifact filenames. Variables: {{name}}, {{simpleName}}, {{version}}',
},
},
additionalProperties: false,
diff --git a/src/schemas/project_config.ts b/src/schemas/project_config.ts
index b22e58a3..8785c7c2 100644
--- a/src/schemas/project_config.ts
+++ b/src/schemas/project_config.ts
@@ -25,6 +25,7 @@ export interface CraftProjectConfig {
requireNames?: string[];
statusProvider?: BaseStatusProvider;
artifactProvider?: BaseArtifactProvider;
+ versioning?: VersioningConfig;
}
/**
* Global (non-target!) GitHub configuration for the project
@@ -62,6 +63,26 @@ export interface BaseArtifactProvider {
[k: string]: any;
};
}
+/**
+ * Version resolution configuration
+ */
+export interface VersioningConfig {
+ policy?: VersioningPolicy;
+ calver?: CalVerConfig;
+}
+/**
+ * Calendar versioning configuration
+ */
+export interface CalVerConfig {
+ /**
+ * Days to go back for date calculation (default: 14)
+ */
+ offset?: number;
+ /**
+ * strftime-like format for date part (default: %y.%-m). Supports: %y (2-digit year), %m (zero-padded month), %-m (month without padding)
+ */
+ format?: string;
+}
/**
* DEPRECATED: Use changelog.policy instead. Different policies for changelog management
@@ -85,3 +106,11 @@ export const enum ArtifactProviderName {
GitHub = 'github',
None = 'none',
}
+/**
+ * Default versioning policy when no version argument is provided. auto: analyze commits to determine bump type, manual: require explicit version, calver: use calendar versioning
+ */
+export const enum VersioningPolicy {
+ Auto = 'auto',
+ Manual = 'manual',
+ CalVer = 'calver',
+}
diff --git a/src/targets/__tests__/docker.test.ts b/src/targets/__tests__/docker.test.ts
new file mode 100644
index 00000000..543f274a
--- /dev/null
+++ b/src/targets/__tests__/docker.test.ts
@@ -0,0 +1,1217 @@
+import * as fs from 'node:fs';
+import * as os from 'node:os';
+
+import {
+ DockerTarget,
+ extractRegistry,
+ registryToEnvPrefix,
+ normalizeImageRef,
+ isGoogleCloudRegistry,
+ hasGcloudCredentials,
+} from '../docker';
+import { NoneArtifactProvider } from '../../artifact_providers/none';
+import * as system from '../../utils/system';
+
+jest.mock('../../utils/system', () => ({
+ ...jest.requireActual('../../utils/system'),
+ checkExecutableIsPresent: jest.fn(),
+ spawnProcess: jest.fn().mockResolvedValue(Buffer.from('')),
+}));
+
+jest.mock('node:fs');
+jest.mock('node:os');
+
+describe('normalizeImageRef', () => {
+ it('normalizes string source to object with image property', () => {
+ const config = { source: 'ghcr.io/org/image' };
+ const result = normalizeImageRef(config, 'source');
+ expect(result).toEqual({
+ image: 'ghcr.io/org/image',
+ format: undefined,
+ registry: undefined,
+ usernameVar: undefined,
+ passwordVar: undefined,
+ });
+ });
+
+ it('normalizes string target to object with image property', () => {
+ const config = { target: 'getsentry/craft' };
+ const result = normalizeImageRef(config, 'target');
+ expect(result).toEqual({
+ image: 'getsentry/craft',
+ format: undefined,
+ registry: undefined,
+ usernameVar: undefined,
+ passwordVar: undefined,
+ });
+ });
+
+ it('passes through object form', () => {
+ const config = {
+ source: {
+ image: 'ghcr.io/org/image',
+ registry: 'ghcr.io',
+ format: '{{{source}}}:latest',
+ usernameVar: 'MY_USER',
+ passwordVar: 'MY_PASS',
+ },
+ };
+ const result = normalizeImageRef(config, 'source');
+ expect(result).toEqual({
+ image: 'ghcr.io/org/image',
+ registry: 'ghcr.io',
+ format: '{{{source}}}:latest',
+ usernameVar: 'MY_USER',
+ passwordVar: 'MY_PASS',
+ });
+ });
+
+ it('uses legacy source params as fallback for string form', () => {
+ const config = {
+ source: 'ghcr.io/org/image',
+ sourceFormat: '{{{source}}}:custom',
+ sourceRegistry: 'custom.registry.io',
+ sourceUsernameVar: 'LEGACY_USER',
+ sourcePasswordVar: 'LEGACY_PASS',
+ };
+ const result = normalizeImageRef(config, 'source');
+ expect(result).toEqual({
+ image: 'ghcr.io/org/image',
+ format: '{{{source}}}:custom',
+ registry: 'custom.registry.io',
+ usernameVar: 'LEGACY_USER',
+ passwordVar: 'LEGACY_PASS',
+ });
+ });
+
+ it('uses legacy target params as fallback for string form', () => {
+ const config = {
+ target: 'getsentry/craft',
+ targetFormat: '{{{target}}}:v{{{version}}}',
+ registry: 'docker.io',
+ usernameVar: 'LEGACY_USER',
+ passwordVar: 'LEGACY_PASS',
+ };
+ const result = normalizeImageRef(config, 'target');
+ expect(result).toEqual({
+ image: 'getsentry/craft',
+ format: '{{{target}}}:v{{{version}}}',
+ registry: 'docker.io',
+ usernameVar: 'LEGACY_USER',
+ passwordVar: 'LEGACY_PASS',
+ });
+ });
+
+ it('prefers object properties over legacy params', () => {
+ const config = {
+ source: {
+ image: 'ghcr.io/org/image',
+ registry: 'new.registry.io',
+ format: '{{{source}}}:new',
+ },
+ sourceFormat: '{{{source}}}:legacy',
+ sourceRegistry: 'legacy.registry.io',
+ sourceUsernameVar: 'LEGACY_USER',
+ sourcePasswordVar: 'LEGACY_PASS',
+ };
+ const result = normalizeImageRef(config, 'source');
+ expect(result).toEqual({
+ image: 'ghcr.io/org/image',
+ registry: 'new.registry.io',
+ format: '{{{source}}}:new',
+ usernameVar: 'LEGACY_USER', // Falls back to legacy since not in object
+ passwordVar: 'LEGACY_PASS',
+ });
+ });
+
+ it('allows partial object with legacy fallback', () => {
+ const config = {
+ source: { image: 'ghcr.io/org/image' },
+ sourceFormat: '{{{source}}}:legacy',
+ sourceRegistry: 'legacy.registry.io',
+ };
+ const result = normalizeImageRef(config, 'source');
+ expect(result).toEqual({
+ image: 'ghcr.io/org/image',
+ format: '{{{source}}}:legacy',
+ registry: 'legacy.registry.io',
+ usernameVar: undefined,
+ passwordVar: undefined,
+ });
+ });
+
+ it('throws ConfigurationError when source is missing', () => {
+ const config = { target: 'getsentry/craft' };
+ expect(() => normalizeImageRef(config, 'source')).toThrow(
+ "Docker target requires a 'source' property. Please specify the source image."
+ );
+ });
+
+ it('throws ConfigurationError when target is missing', () => {
+ const config = { source: 'ghcr.io/org/image' };
+ expect(() => normalizeImageRef(config, 'target')).toThrow(
+ "Docker target requires a 'target' property. Please specify the target image."
+ );
+ });
+});
+
+describe('extractRegistry', () => {
+ it('returns undefined for Docker Hub images (user/image)', () => {
+ expect(extractRegistry('user/image')).toBeUndefined();
+ expect(extractRegistry('getsentry/craft')).toBeUndefined();
+ });
+
+ it('returns undefined for simple image names', () => {
+ expect(extractRegistry('nginx')).toBeUndefined();
+ expect(extractRegistry('ubuntu')).toBeUndefined();
+ });
+
+ it('extracts ghcr.io registry', () => {
+ expect(extractRegistry('ghcr.io/user/image')).toBe('ghcr.io');
+ expect(extractRegistry('ghcr.io/getsentry/craft')).toBe('ghcr.io');
+ });
+
+ it('extracts gcr.io and regional variants', () => {
+ expect(extractRegistry('gcr.io/project/image')).toBe('gcr.io');
+ expect(extractRegistry('us.gcr.io/project/image')).toBe('us.gcr.io');
+ expect(extractRegistry('eu.gcr.io/project/image')).toBe('eu.gcr.io');
+ expect(extractRegistry('asia.gcr.io/project/image')).toBe('asia.gcr.io');
+ });
+
+ it('extracts other registries with dots', () => {
+ expect(extractRegistry('registry.example.com/image')).toBe(
+ 'registry.example.com'
+ );
+ });
+
+ it('treats docker.io variants as Docker Hub (returns undefined)', () => {
+ // docker.io is the canonical Docker Hub registry
+ expect(extractRegistry('docker.io/library/nginx')).toBeUndefined();
+ expect(extractRegistry('docker.io/getsentry/craft')).toBeUndefined();
+ // index.docker.io is the legacy Docker Hub registry
+ expect(extractRegistry('index.docker.io/library/nginx')).toBeUndefined();
+ // registry-1.docker.io is another Docker Hub alias
+ expect(extractRegistry('registry-1.docker.io/user/image')).toBeUndefined();
+ });
+
+ it('extracts registries with ports', () => {
+ expect(extractRegistry('localhost:5000/image')).toBe('localhost:5000');
+ expect(extractRegistry('myregistry:8080/user/image')).toBe(
+ 'myregistry:8080'
+ );
+ });
+});
+
+describe('registryToEnvPrefix', () => {
+ it('converts ghcr.io to GHCR_IO', () => {
+ expect(registryToEnvPrefix('ghcr.io')).toBe('GHCR_IO');
+ });
+
+ it('converts gcr.io to GCR_IO', () => {
+ expect(registryToEnvPrefix('gcr.io')).toBe('GCR_IO');
+ });
+
+ it('converts regional GCR to correct prefix', () => {
+ expect(registryToEnvPrefix('us.gcr.io')).toBe('US_GCR_IO');
+ expect(registryToEnvPrefix('eu.gcr.io')).toBe('EU_GCR_IO');
+ expect(registryToEnvPrefix('asia.gcr.io')).toBe('ASIA_GCR_IO');
+ });
+
+ it('handles hyphens in registry names', () => {
+ expect(registryToEnvPrefix('my-registry.example.com')).toBe(
+ 'MY_REGISTRY_EXAMPLE_COM'
+ );
+ });
+
+ it('handles ports in registry names', () => {
+ expect(registryToEnvPrefix('localhost:5000')).toBe('LOCALHOST_5000');
+ });
+});
+
+describe('isGoogleCloudRegistry', () => {
+ it('returns true for gcr.io', () => {
+ expect(isGoogleCloudRegistry('gcr.io')).toBe(true);
+ });
+
+ it('returns true for regional GCR variants', () => {
+ expect(isGoogleCloudRegistry('us.gcr.io')).toBe(true);
+ expect(isGoogleCloudRegistry('eu.gcr.io')).toBe(true);
+ expect(isGoogleCloudRegistry('asia.gcr.io')).toBe(true);
+ });
+
+ it('returns true for Artifact Registry multi-region (pkg.dev)', () => {
+ expect(isGoogleCloudRegistry('us-docker.pkg.dev')).toBe(true);
+ expect(isGoogleCloudRegistry('europe-docker.pkg.dev')).toBe(true);
+ expect(isGoogleCloudRegistry('asia-docker.pkg.dev')).toBe(true);
+ });
+
+ it('returns true for Artifact Registry regional endpoints (pkg.dev)', () => {
+ expect(isGoogleCloudRegistry('us-west1-docker.pkg.dev')).toBe(true);
+ expect(isGoogleCloudRegistry('us-central1-docker.pkg.dev')).toBe(true);
+ expect(isGoogleCloudRegistry('us-east4-docker.pkg.dev')).toBe(true);
+ expect(isGoogleCloudRegistry('europe-west1-docker.pkg.dev')).toBe(true);
+ expect(isGoogleCloudRegistry('asia-east1-docker.pkg.dev')).toBe(true);
+ expect(isGoogleCloudRegistry('australia-southeast1-docker.pkg.dev')).toBe(true);
+ });
+
+ it('returns false for non-Google registries', () => {
+ expect(isGoogleCloudRegistry('ghcr.io')).toBe(false);
+ expect(isGoogleCloudRegistry('docker.io')).toBe(false);
+ expect(isGoogleCloudRegistry('custom.registry.io')).toBe(false);
+ });
+
+ it('returns false for undefined', () => {
+ expect(isGoogleCloudRegistry(undefined)).toBe(false);
+ });
+});
+
+describe('hasGcloudCredentials', () => {
+ const mockFs = fs as jest.Mocked;
+ const mockOs = os as jest.Mocked;
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockFs.existsSync.mockReturnValue(false);
+ mockOs.homedir.mockReturnValue('/home/user');
+ });
+
+ afterEach(() => {
+ delete process.env.GOOGLE_APPLICATION_CREDENTIALS;
+ delete process.env.GOOGLE_GHA_CREDS_PATH;
+ delete process.env.CLOUDSDK_AUTH_CREDENTIAL_FILE_OVERRIDE;
+ });
+
+ it('returns true when GOOGLE_APPLICATION_CREDENTIALS points to existing file', () => {
+ process.env.GOOGLE_APPLICATION_CREDENTIALS = '/path/to/creds.json';
+ mockFs.existsSync.mockImplementation(
+ (p: fs.PathLike) => p === '/path/to/creds.json'
+ );
+
+ expect(hasGcloudCredentials()).toBe(true);
+ });
+
+ it('returns true when GOOGLE_GHA_CREDS_PATH points to existing file', () => {
+ process.env.GOOGLE_GHA_CREDS_PATH = '/tmp/gha-creds.json';
+ mockFs.existsSync.mockImplementation(
+ (p: fs.PathLike) => p === '/tmp/gha-creds.json'
+ );
+
+ expect(hasGcloudCredentials()).toBe(true);
+ });
+
+ it('returns true when CLOUDSDK_AUTH_CREDENTIAL_FILE_OVERRIDE points to existing file', () => {
+ process.env.CLOUDSDK_AUTH_CREDENTIAL_FILE_OVERRIDE = '/override/creds.json';
+ mockFs.existsSync.mockImplementation(
+ (p: fs.PathLike) => p === '/override/creds.json'
+ );
+
+ expect(hasGcloudCredentials()).toBe(true);
+ });
+
+ it('returns true when default ADC file exists', () => {
+ mockFs.existsSync.mockImplementation(
+ (p: fs.PathLike) =>
+ p === '/home/user/.config/gcloud/application_default_credentials.json'
+ );
+
+ expect(hasGcloudCredentials()).toBe(true);
+ });
+
+ it('returns false when no credentials are found', () => {
+ expect(hasGcloudCredentials()).toBe(false);
+ });
+});
+
+describe('DockerTarget', () => {
+ const oldEnv = { ...process.env };
+ const mockFs = fs as jest.Mocked;
+ const mockOs = os as jest.Mocked;
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockFs.existsSync.mockReturnValue(false);
+ mockOs.homedir.mockReturnValue('/home/user');
+ // Clear all Docker-related env vars
+ delete process.env.DOCKER_USERNAME;
+ delete process.env.DOCKER_PASSWORD;
+ delete process.env.DOCKER_GHCR_IO_USERNAME;
+ delete process.env.DOCKER_GHCR_IO_PASSWORD;
+ delete process.env.DOCKER_GCR_IO_USERNAME;
+ delete process.env.DOCKER_GCR_IO_PASSWORD;
+ delete process.env.GITHUB_ACTOR;
+ delete process.env.GITHUB_TOKEN;
+ });
+
+ afterAll(() => {
+ process.env = { ...oldEnv };
+ });
+
+ describe('target credential resolution', () => {
+ describe('Mode A: explicit usernameVar/passwordVar', () => {
+ it('uses explicit env vars when both are specified', () => {
+ process.env.MY_USER = 'custom-user';
+ process.env.MY_PASS = 'custom-pass';
+
+ const target = new DockerTarget(
+ {
+ name: 'docker',
+ source: 'ghcr.io/org/image',
+ target: 'ghcr.io/org/image',
+ usernameVar: 'MY_USER',
+ passwordVar: 'MY_PASS',
+ },
+ new NoneArtifactProvider()
+ );
+
+ expect(target.dockerConfig.target.credentials!.username).toBe('custom-user');
+ expect(target.dockerConfig.target.credentials!.password).toBe('custom-pass');
+ });
+
+ it('throws if only usernameVar is specified', () => {
+ process.env.MY_USER = 'custom-user';
+
+ expect(
+ () =>
+ new DockerTarget(
+ {
+ name: 'docker',
+ source: 'ghcr.io/org/image',
+ target: 'ghcr.io/org/image',
+ usernameVar: 'MY_USER',
+ },
+ new NoneArtifactProvider()
+ )
+ ).toThrow('Both usernameVar and passwordVar must be specified together');
+ });
+
+ it('throws if only passwordVar is specified', () => {
+ process.env.MY_PASS = 'custom-pass';
+
+ expect(
+ () =>
+ new DockerTarget(
+ {
+ name: 'docker',
+ source: 'ghcr.io/org/image',
+ target: 'ghcr.io/org/image',
+ passwordVar: 'MY_PASS',
+ },
+ new NoneArtifactProvider()
+ )
+ ).toThrow('Both usernameVar and passwordVar must be specified together');
+ });
+
+ it('throws if explicit env vars are not set (no fallback)', () => {
+ // Ensure fallback vars are set but should NOT be used
+ process.env.DOCKER_USERNAME = 'fallback-user';
+ process.env.DOCKER_PASSWORD = 'fallback-pass';
+
+ expect(
+ () =>
+ new DockerTarget(
+ {
+ name: 'docker',
+ source: 'ghcr.io/org/image',
+ target: 'ghcr.io/org/image',
+ usernameVar: 'NONEXISTENT_USER',
+ passwordVar: 'NONEXISTENT_PASS',
+ },
+ new NoneArtifactProvider()
+ )
+ ).toThrow(
+ 'Missing credentials: NONEXISTENT_USER and/or NONEXISTENT_PASS environment variable(s) not set'
+ );
+ });
+ });
+
+ describe('Mode B: automatic resolution', () => {
+ it('uses registry-derived env vars first', () => {
+ process.env.DOCKER_GHCR_IO_USERNAME = 'ghcr-user';
+ process.env.DOCKER_GHCR_IO_PASSWORD = 'ghcr-pass';
+ process.env.DOCKER_USERNAME = 'default-user';
+ process.env.DOCKER_PASSWORD = 'default-pass';
+
+ const target = new DockerTarget(
+ {
+ name: 'docker',
+ source: 'ghcr.io/org/image',
+ target: 'ghcr.io/org/image',
+ },
+ new NoneArtifactProvider()
+ );
+
+ expect(target.dockerConfig.target.credentials!.username).toBe('ghcr-user');
+ expect(target.dockerConfig.target.credentials!.password).toBe('ghcr-pass');
+ });
+
+ it('falls back to GHCR defaults (GITHUB_ACTOR/GITHUB_TOKEN) for ghcr.io', () => {
+ process.env.GITHUB_ACTOR = 'github-actor';
+ process.env.GITHUB_TOKEN = 'github-token';
+
+ const target = new DockerTarget(
+ {
+ name: 'docker',
+ source: 'ghcr.io/org/image',
+ target: 'ghcr.io/org/image',
+ },
+ new NoneArtifactProvider()
+ );
+
+ expect(target.dockerConfig.target.credentials!.username).toBe('github-actor');
+ expect(target.dockerConfig.target.credentials!.password).toBe('github-token');
+ });
+
+ it('uses default DOCKER_* env vars for Docker Hub', () => {
+ process.env.DOCKER_USERNAME = 'dockerhub-user';
+ process.env.DOCKER_PASSWORD = 'dockerhub-pass';
+
+ const target = new DockerTarget(
+ {
+ name: 'docker',
+ source: 'ghcr.io/org/image',
+ target: 'getsentry/craft',
+ },
+ new NoneArtifactProvider()
+ );
+
+ expect(target.dockerConfig.target.credentials!.username).toBe('dockerhub-user');
+ expect(target.dockerConfig.target.credentials!.password).toBe('dockerhub-pass');
+ expect(target.dockerConfig.target.credentials!.registry).toBeUndefined();
+ });
+
+ it('treats docker.io as Docker Hub and uses default credentials', () => {
+ process.env.DOCKER_USERNAME = 'dockerhub-user';
+ process.env.DOCKER_PASSWORD = 'dockerhub-pass';
+
+ const target = new DockerTarget(
+ {
+ name: 'docker',
+ source: 'ghcr.io/org/image',
+ target: 'docker.io/getsentry/craft',
+ },
+ new NoneArtifactProvider()
+ );
+
+ expect(target.dockerConfig.target.credentials!.username).toBe('dockerhub-user');
+ expect(target.dockerConfig.target.credentials!.password).toBe('dockerhub-pass');
+ expect(target.dockerConfig.target.credentials!.registry).toBeUndefined();
+ });
+
+ it('treats index.docker.io as Docker Hub and uses default credentials', () => {
+ process.env.DOCKER_USERNAME = 'dockerhub-user';
+ process.env.DOCKER_PASSWORD = 'dockerhub-pass';
+
+ const target = new DockerTarget(
+ {
+ name: 'docker',
+ source: 'ghcr.io/org/image',
+ target: 'index.docker.io/getsentry/craft',
+ },
+ new NoneArtifactProvider()
+ );
+
+ expect(target.dockerConfig.target.credentials!.username).toBe('dockerhub-user');
+ expect(target.dockerConfig.target.credentials!.password).toBe('dockerhub-pass');
+ expect(target.dockerConfig.target.credentials!.registry).toBeUndefined();
+ });
+
+ it('falls back to DOCKER_* when registry-specific vars are not set', () => {
+ process.env.DOCKER_USERNAME = 'default-user';
+ process.env.DOCKER_PASSWORD = 'default-pass';
+
+ const target = new DockerTarget(
+ {
+ name: 'docker',
+ source: 'ghcr.io/org/image',
+ target: 'gcr.io/project/image',
+ },
+ new NoneArtifactProvider()
+ );
+
+ expect(target.dockerConfig.target.credentials!.username).toBe('default-user');
+ expect(target.dockerConfig.target.credentials!.password).toBe('default-pass');
+ });
+
+ it('throws when no credentials are available', () => {
+ expect(
+ () =>
+ new DockerTarget(
+ {
+ name: 'docker',
+ source: 'ghcr.io/org/image',
+ target: 'getsentry/craft',
+ },
+ new NoneArtifactProvider()
+ )
+ ).toThrow('Cannot perform Docker release: missing credentials');
+ });
+
+ it('includes registry-specific hint in error message', () => {
+ // Use a non-Google Cloud registry that will require credentials
+ expect(
+ () =>
+ new DockerTarget(
+ {
+ name: 'docker',
+ source: 'ghcr.io/org/image',
+ target: 'custom.registry.io/project/image',
+ },
+ new NoneArtifactProvider()
+ )
+ ).toThrow('DOCKER_CUSTOM_REGISTRY_IO_USERNAME/PASSWORD');
+ });
+ });
+
+ describe('registry config override', () => {
+ it('uses explicit registry config over auto-detection', () => {
+ process.env.DOCKER_GCR_IO_USERNAME = 'gcr-user';
+ process.env.DOCKER_GCR_IO_PASSWORD = 'gcr-pass';
+
+ const target = new DockerTarget(
+ {
+ name: 'docker',
+ source: 'ghcr.io/org/image',
+ target: 'us.gcr.io/project/image',
+ registry: 'gcr.io', // Override to share creds across regions
+ },
+ new NoneArtifactProvider()
+ );
+
+ expect(target.dockerConfig.target.credentials!.registry).toBe('gcr.io');
+ expect(target.dockerConfig.target.credentials!.username).toBe('gcr-user');
+ expect(target.dockerConfig.target.credentials!.password).toBe('gcr-pass');
+ });
+ });
+ });
+
+ describe('source credential resolution', () => {
+ it('resolves source credentials when source registry differs from target', () => {
+ process.env.DOCKER_GHCR_IO_USERNAME = 'ghcr-user';
+ process.env.DOCKER_GHCR_IO_PASSWORD = 'ghcr-pass';
+ process.env.DOCKER_USERNAME = 'dockerhub-user';
+ process.env.DOCKER_PASSWORD = 'dockerhub-pass';
+
+ const target = new DockerTarget(
+ {
+ name: 'docker',
+ source: 'ghcr.io/org/image',
+ target: 'getsentry/craft',
+ },
+ new NoneArtifactProvider()
+ );
+
+ // Target should use Docker Hub credentials
+ expect(target.dockerConfig.target.credentials!.username).toBe('dockerhub-user');
+ expect(target.dockerConfig.target.credentials!.password).toBe('dockerhub-pass');
+ expect(target.dockerConfig.target.credentials!.registry).toBeUndefined();
+
+ // Source should use GHCR credentials
+ expect(target.dockerConfig.source.credentials).toBeDefined();
+ expect(target.dockerConfig.source.credentials?.username).toBe('ghcr-user');
+ expect(target.dockerConfig.source.credentials?.password).toBe('ghcr-pass');
+ expect(target.dockerConfig.source.credentials?.registry).toBe('ghcr.io');
+ });
+
+ it('does not set source credentials when source and target registries are the same', () => {
+ process.env.DOCKER_GHCR_IO_USERNAME = 'ghcr-user';
+ process.env.DOCKER_GHCR_IO_PASSWORD = 'ghcr-pass';
+
+ const target = new DockerTarget(
+ {
+ name: 'docker',
+ source: 'ghcr.io/org/source-image',
+ target: 'ghcr.io/org/target-image',
+ },
+ new NoneArtifactProvider()
+ );
+
+ expect(target.dockerConfig.source.credentials).toBeUndefined();
+ });
+
+ it('uses explicit sourceUsernameVar/sourcePasswordVar for source credentials', () => {
+ process.env.MY_SOURCE_USER = 'source-user';
+ process.env.MY_SOURCE_PASS = 'source-pass';
+ process.env.DOCKER_USERNAME = 'dockerhub-user';
+ process.env.DOCKER_PASSWORD = 'dockerhub-pass';
+
+ const target = new DockerTarget(
+ {
+ name: 'docker',
+ source: 'ghcr.io/org/image',
+ target: 'getsentry/craft',
+ sourceUsernameVar: 'MY_SOURCE_USER',
+ sourcePasswordVar: 'MY_SOURCE_PASS',
+ },
+ new NoneArtifactProvider()
+ );
+
+ expect(target.dockerConfig.source.credentials?.username).toBe('source-user');
+ expect(target.dockerConfig.source.credentials?.password).toBe('source-pass');
+ });
+
+ it('throws if only sourceUsernameVar is specified', () => {
+ process.env.MY_SOURCE_USER = 'source-user';
+ process.env.DOCKER_USERNAME = 'dockerhub-user';
+ process.env.DOCKER_PASSWORD = 'dockerhub-pass';
+
+ expect(
+ () =>
+ new DockerTarget(
+ {
+ name: 'docker',
+ source: 'ghcr.io/org/image',
+ target: 'getsentry/craft',
+ sourceUsernameVar: 'MY_SOURCE_USER',
+ },
+ new NoneArtifactProvider()
+ )
+ ).toThrow('Both usernameVar and passwordVar must be specified together');
+ });
+
+ it('does not require source credentials if source is assumed public', () => {
+ process.env.DOCKER_USERNAME = 'dockerhub-user';
+ process.env.DOCKER_PASSWORD = 'dockerhub-pass';
+ // No GHCR credentials set - source assumed to be public
+
+ const target = new DockerTarget(
+ {
+ name: 'docker',
+ source: 'ghcr.io/org/public-image',
+ target: 'getsentry/craft',
+ },
+ new NoneArtifactProvider()
+ );
+
+ // Should not throw, source credentials are optional
+ expect(target.dockerConfig.source.credentials).toBeUndefined();
+ });
+
+ it('uses sourceRegistry config override', () => {
+ process.env.DOCKER_GCR_IO_USERNAME = 'gcr-user';
+ process.env.DOCKER_GCR_IO_PASSWORD = 'gcr-pass';
+ process.env.DOCKER_USERNAME = 'dockerhub-user';
+ process.env.DOCKER_PASSWORD = 'dockerhub-pass';
+
+ const target = new DockerTarget(
+ {
+ name: 'docker',
+ source: 'us.gcr.io/project/image',
+ target: 'getsentry/craft',
+ sourceRegistry: 'gcr.io', // Use gcr.io creds for us.gcr.io
+ },
+ new NoneArtifactProvider()
+ );
+
+ expect(target.dockerConfig.source.credentials?.registry).toBe('gcr.io');
+ expect(target.dockerConfig.source.credentials?.username).toBe('gcr-user');
+ expect(target.dockerConfig.source.credentials?.password).toBe('gcr-pass');
+ });
+ });
+
+ describe('nested object config format', () => {
+ it('supports target as object with image property', () => {
+ process.env.DOCKER_GHCR_IO_USERNAME = 'ghcr-user';
+ process.env.DOCKER_GHCR_IO_PASSWORD = 'ghcr-pass';
+
+ const target = new DockerTarget(
+ {
+ name: 'docker',
+ source: 'ghcr.io/org/source-image',
+ target: {
+ image: 'ghcr.io/org/target-image',
+ },
+ },
+ new NoneArtifactProvider()
+ );
+
+ expect(target.dockerConfig.target.image).toBe('ghcr.io/org/target-image');
+ expect(target.dockerConfig.target.credentials!.registry).toBe('ghcr.io');
+ });
+
+ it('supports source as object with image property', () => {
+ process.env.DOCKER_GHCR_IO_USERNAME = 'ghcr-user';
+ process.env.DOCKER_GHCR_IO_PASSWORD = 'ghcr-pass';
+
+ const target = new DockerTarget(
+ {
+ name: 'docker',
+ source: {
+ image: 'ghcr.io/org/source-image',
+ },
+ target: 'ghcr.io/org/target-image',
+ },
+ new NoneArtifactProvider()
+ );
+
+ expect(target.dockerConfig.source.image).toBe('ghcr.io/org/source-image');
+ });
+
+ it('supports both source and target as objects', () => {
+ process.env.DOCKER_GHCR_IO_USERNAME = 'ghcr-user';
+ process.env.DOCKER_GHCR_IO_PASSWORD = 'ghcr-pass';
+ process.env.DOCKER_USERNAME = 'dockerhub-user';
+ process.env.DOCKER_PASSWORD = 'dockerhub-pass';
+
+ const target = new DockerTarget(
+ {
+ name: 'docker',
+ source: {
+ image: 'ghcr.io/org/source-image',
+ },
+ target: {
+ image: 'getsentry/craft',
+ },
+ },
+ new NoneArtifactProvider()
+ );
+
+ expect(target.dockerConfig.source.image).toBe('ghcr.io/org/source-image');
+ expect(target.dockerConfig.target.image).toBe('getsentry/craft');
+ });
+
+ it('uses registry from object config', () => {
+ process.env.DOCKER_GCR_IO_USERNAME = 'gcr-user';
+ process.env.DOCKER_GCR_IO_PASSWORD = 'gcr-pass';
+
+ const target = new DockerTarget(
+ {
+ name: 'docker',
+ source: 'ghcr.io/org/source-image',
+ target: {
+ image: 'us.gcr.io/project/image',
+ registry: 'gcr.io', // Override to share creds across regions
+ },
+ },
+ new NoneArtifactProvider()
+ );
+
+ expect(target.dockerConfig.target.credentials!.registry).toBe('gcr.io');
+ expect(target.dockerConfig.target.credentials!.username).toBe('gcr-user');
+ });
+
+ it('uses usernameVar/passwordVar from object config', () => {
+ process.env.MY_TARGET_USER = 'target-user';
+ process.env.MY_TARGET_PASS = 'target-pass';
+
+ const target = new DockerTarget(
+ {
+ name: 'docker',
+ source: 'ghcr.io/org/source-image',
+ target: {
+ image: 'getsentry/craft',
+ usernameVar: 'MY_TARGET_USER',
+ passwordVar: 'MY_TARGET_PASS',
+ },
+ },
+ new NoneArtifactProvider()
+ );
+
+ expect(target.dockerConfig.target.credentials!.username).toBe('target-user');
+ expect(target.dockerConfig.target.credentials!.password).toBe('target-pass');
+ });
+
+ it('uses format from object config', () => {
+ process.env.DOCKER_USERNAME = 'user';
+ process.env.DOCKER_PASSWORD = 'pass';
+
+ const target = new DockerTarget(
+ {
+ name: 'docker',
+ source: {
+ image: 'ghcr.io/org/source-image',
+ format: '{{{source}}}:sha-{{{revision}}}',
+ },
+ target: {
+ image: 'getsentry/craft',
+ format: '{{{target}}}:v{{{version}}}',
+ },
+ },
+ new NoneArtifactProvider()
+ );
+
+ expect(target.dockerConfig.source.format).toBe(
+ '{{{source}}}:sha-{{{revision}}}'
+ );
+ expect(target.dockerConfig.target.format).toBe(
+ '{{{target}}}:v{{{version}}}'
+ );
+ });
+
+ it('supports source object with credentials for cross-registry publishing', () => {
+ process.env.MY_SOURCE_USER = 'source-user';
+ process.env.MY_SOURCE_PASS = 'source-pass';
+ process.env.DOCKER_USERNAME = 'dockerhub-user';
+ process.env.DOCKER_PASSWORD = 'dockerhub-pass';
+
+ const target = new DockerTarget(
+ {
+ name: 'docker',
+ source: {
+ image: 'ghcr.io/org/private-image',
+ usernameVar: 'MY_SOURCE_USER',
+ passwordVar: 'MY_SOURCE_PASS',
+ },
+ target: 'getsentry/craft',
+ },
+ new NoneArtifactProvider()
+ );
+
+ expect(target.dockerConfig.source.credentials?.username).toBe('source-user');
+ expect(target.dockerConfig.source.credentials?.password).toBe('source-pass');
+ expect(target.dockerConfig.target.credentials!.username).toBe('dockerhub-user');
+ });
+ });
+
+ describe('login', () => {
+ it('passes registry to docker login command', async () => {
+ process.env.DOCKER_GHCR_IO_USERNAME = 'user';
+ process.env.DOCKER_GHCR_IO_PASSWORD = 'pass';
+
+ const target = new DockerTarget(
+ {
+ name: 'docker',
+ source: 'ghcr.io/org/image',
+ target: 'ghcr.io/org/image',
+ },
+ new NoneArtifactProvider()
+ );
+
+ await target.login();
+
+ expect(system.spawnProcess).toHaveBeenCalledWith(
+ 'docker',
+ ['login', '--username=user', '--password-stdin', 'ghcr.io'],
+ {},
+ { stdin: 'pass' }
+ );
+ });
+
+ it('omits registry for Docker Hub', async () => {
+ process.env.DOCKER_USERNAME = 'user';
+ process.env.DOCKER_PASSWORD = 'pass';
+
+ const target = new DockerTarget(
+ {
+ name: 'docker',
+ source: 'ghcr.io/org/image',
+ target: 'getsentry/craft',
+ },
+ new NoneArtifactProvider()
+ );
+
+ await target.login();
+
+ expect(system.spawnProcess).toHaveBeenCalledWith(
+ 'docker',
+ ['login', '--username=user', '--password-stdin'],
+ {},
+ { stdin: 'pass' }
+ );
+ });
+
+ it('uses password-stdin for security', async () => {
+ process.env.DOCKER_USERNAME = 'user';
+ process.env.DOCKER_PASSWORD = 'secret-password';
+
+ const target = new DockerTarget(
+ {
+ name: 'docker',
+ source: 'ghcr.io/org/image',
+ target: 'getsentry/craft',
+ },
+ new NoneArtifactProvider()
+ );
+
+ await target.login();
+
+ // Verify password is passed via stdin, not command line
+ const callArgs = (system.spawnProcess as jest.Mock).mock.calls[0];
+ expect(callArgs[1]).not.toContain('--password=secret-password');
+ expect(callArgs[1]).toContain('--password-stdin');
+ expect(callArgs[3]).toEqual({ stdin: 'secret-password' });
+ });
+
+ it('logs into both source and target registries for cross-registry publishing', async () => {
+ process.env.DOCKER_GHCR_IO_USERNAME = 'ghcr-user';
+ process.env.DOCKER_GHCR_IO_PASSWORD = 'ghcr-pass';
+ process.env.DOCKER_USERNAME = 'dockerhub-user';
+ process.env.DOCKER_PASSWORD = 'dockerhub-pass';
+
+ const target = new DockerTarget(
+ {
+ name: 'docker',
+ source: 'ghcr.io/org/image',
+ target: 'getsentry/craft',
+ },
+ new NoneArtifactProvider()
+ );
+
+ await target.login();
+
+ // Should login to both registries
+ expect(system.spawnProcess).toHaveBeenCalledTimes(2);
+
+ // First call: login to source (GHCR)
+ expect(system.spawnProcess).toHaveBeenNthCalledWith(
+ 1,
+ 'docker',
+ ['login', '--username=ghcr-user', '--password-stdin', 'ghcr.io'],
+ {},
+ { stdin: 'ghcr-pass' }
+ );
+
+ // Second call: login to target (Docker Hub)
+ expect(system.spawnProcess).toHaveBeenNthCalledWith(
+ 2,
+ 'docker',
+ ['login', '--username=dockerhub-user', '--password-stdin'],
+ {},
+ { stdin: 'dockerhub-pass' }
+ );
+ });
+
+ it('only logs into target when source has no credentials (public source)', async () => {
+ process.env.DOCKER_USERNAME = 'dockerhub-user';
+ process.env.DOCKER_PASSWORD = 'dockerhub-pass';
+ // No GHCR credentials - source is public
+
+ const target = new DockerTarget(
+ {
+ name: 'docker',
+ source: 'ghcr.io/org/public-image',
+ target: 'getsentry/craft',
+ },
+ new NoneArtifactProvider()
+ );
+
+ await target.login();
+
+ // Should only login to Docker Hub
+ expect(system.spawnProcess).toHaveBeenCalledTimes(1);
+ expect(system.spawnProcess).toHaveBeenCalledWith(
+ 'docker',
+ ['login', '--username=dockerhub-user', '--password-stdin'],
+ {},
+ { stdin: 'dockerhub-pass' }
+ );
+ });
+
+ it('only logs in once when source and target are same registry', async () => {
+ process.env.DOCKER_GHCR_IO_USERNAME = 'ghcr-user';
+ process.env.DOCKER_GHCR_IO_PASSWORD = 'ghcr-pass';
+
+ const target = new DockerTarget(
+ {
+ name: 'docker',
+ source: 'ghcr.io/org/source-image',
+ target: 'ghcr.io/org/target-image',
+ },
+ new NoneArtifactProvider()
+ );
+
+ await target.login();
+
+ // Should only login once
+ expect(system.spawnProcess).toHaveBeenCalledTimes(1);
+ expect(system.spawnProcess).toHaveBeenCalledWith(
+ 'docker',
+ ['login', '--username=ghcr-user', '--password-stdin', 'ghcr.io'],
+ {},
+ { stdin: 'ghcr-pass' }
+ );
+ });
+
+ it('skips login when target.skipLogin is true', async () => {
+ // No credentials set - would normally throw
+
+ const target = new DockerTarget(
+ {
+ name: 'docker',
+ source: 'ghcr.io/org/image',
+ target: {
+ image: 'us.gcr.io/project/image',
+ skipLogin: true, // Auth handled externally (e.g., gcloud workload identity)
+ },
+ },
+ new NoneArtifactProvider()
+ );
+
+ await target.login();
+
+ // Should not attempt any login
+ expect(system.spawnProcess).not.toHaveBeenCalled();
+ });
+
+ it('skips login when source.skipLogin is true', async () => {
+ process.env.DOCKER_USERNAME = 'dockerhub-user';
+ process.env.DOCKER_PASSWORD = 'dockerhub-pass';
+
+ const target = new DockerTarget(
+ {
+ name: 'docker',
+ source: {
+ image: 'us.gcr.io/project/image',
+ skipLogin: true, // Auth handled externally
+ },
+ target: 'getsentry/craft',
+ },
+ new NoneArtifactProvider()
+ );
+
+ await target.login();
+
+ // Should only login to target (Docker Hub)
+ expect(system.spawnProcess).toHaveBeenCalledTimes(1);
+ expect(system.spawnProcess).toHaveBeenCalledWith(
+ 'docker',
+ ['login', '--username=dockerhub-user', '--password-stdin'],
+ {},
+ { stdin: 'dockerhub-pass' }
+ );
+ });
+
+ it('skips login for both when both have skipLogin', async () => {
+ const target = new DockerTarget(
+ {
+ name: 'docker',
+ source: {
+ image: 'us.gcr.io/project/source',
+ skipLogin: true,
+ },
+ target: {
+ image: 'us.gcr.io/project/target',
+ skipLogin: true,
+ },
+ },
+ new NoneArtifactProvider()
+ );
+
+ await target.login();
+
+ // Should not attempt any login
+ expect(system.spawnProcess).not.toHaveBeenCalled();
+ });
+
+ it('auto-configures gcloud for GCR registries when credentials are available', async () => {
+ // Set up gcloud credentials
+ process.env.GOOGLE_APPLICATION_CREDENTIALS = '/path/to/creds.json';
+ mockFs.existsSync.mockReturnValue(true);
+
+ const target = new DockerTarget(
+ {
+ name: 'docker',
+ source: 'ghcr.io/org/image',
+ target: 'gcr.io/project/image',
+ },
+ new NoneArtifactProvider()
+ );
+
+ await target.login();
+
+ // Should call gcloud auth configure-docker
+ expect(system.spawnProcess).toHaveBeenCalledWith(
+ 'gcloud',
+ ['auth', 'configure-docker', 'gcr.io', '--quiet'],
+ {},
+ {}
+ );
+ });
+
+ it('auto-configures gcloud for Artifact Registry (pkg.dev)', async () => {
+ process.env.GOOGLE_APPLICATION_CREDENTIALS = '/path/to/creds.json';
+ mockFs.existsSync.mockReturnValue(true);
+
+ const target = new DockerTarget(
+ {
+ name: 'docker',
+ source: 'ghcr.io/org/image',
+ target: 'us-docker.pkg.dev/project/repo/image',
+ },
+ new NoneArtifactProvider()
+ );
+
+ await target.login();
+
+ // Should call gcloud auth configure-docker with Artifact Registry
+ expect(system.spawnProcess).toHaveBeenCalledWith(
+ 'gcloud',
+ ['auth', 'configure-docker', 'us-docker.pkg.dev', '--quiet'],
+ {},
+ {}
+ );
+ });
+
+ it('configures multiple GCR registries in one call', async () => {
+ process.env.GOOGLE_APPLICATION_CREDENTIALS = '/path/to/creds.json';
+ mockFs.existsSync.mockReturnValue(true);
+
+ const target = new DockerTarget(
+ {
+ name: 'docker',
+ source: 'us.gcr.io/project/source',
+ target: 'eu.gcr.io/project/target',
+ },
+ new NoneArtifactProvider()
+ );
+
+ await target.login();
+
+ // Should configure both registries in one call
+ expect(system.spawnProcess).toHaveBeenCalledWith(
+ 'gcloud',
+ ['auth', 'configure-docker', 'us.gcr.io,eu.gcr.io', '--quiet'],
+ {},
+ {}
+ );
+ });
+
+ it('skips gcloud configuration when no credentials are available', async () => {
+ // No credentials set, fs.existsSync returns false
+ mockFs.existsSync.mockReturnValue(false);
+
+ // Use Docker Hub as target (requires DOCKER_USERNAME/PASSWORD)
+ process.env.DOCKER_USERNAME = 'user';
+ process.env.DOCKER_PASSWORD = 'pass';
+
+ const target = new DockerTarget(
+ {
+ name: 'docker',
+ source: 'gcr.io/project/image',
+ target: 'getsentry/craft',
+ },
+ new NoneArtifactProvider()
+ );
+
+ await target.login();
+
+ // Should not call gcloud, only docker login
+ expect(system.spawnProcess).not.toHaveBeenCalledWith(
+ 'gcloud',
+ expect.any(Array),
+ expect.any(Object),
+ expect.any(Object)
+ );
+ expect(system.spawnProcess).toHaveBeenCalledWith(
+ 'docker',
+ expect.arrayContaining(['login']),
+ {},
+ expect.any(Object)
+ );
+ });
+
+ it('does not require credentials for GCR registries at config time', () => {
+ // This should not throw even though no credentials are set
+ // because GCR registries can use gcloud auth
+ const target = new DockerTarget(
+ {
+ name: 'docker',
+ source: 'ghcr.io/org/image',
+ target: 'gcr.io/project/image',
+ },
+ new NoneArtifactProvider()
+ );
+
+ expect(target.dockerConfig.target.credentials).toBeUndefined();
+ });
+ });
+});
diff --git a/src/targets/__tests__/npm.test.ts b/src/targets/__tests__/npm.test.ts
index cd34da08..bafe3402 100644
--- a/src/targets/__tests__/npm.test.ts
+++ b/src/targets/__tests__/npm.test.ts
@@ -1,5 +1,11 @@
-import { getPublishTag, getLatestVersion } from '../npm';
+import {
+ getPublishTag,
+ getLatestVersion,
+ NpmTarget,
+ NpmPackageAccess,
+} from '../npm';
import * as system from '../../utils/system';
+import * as workspaces from '../../utils/workspaces';
const defaultNpmConfig = {
useYarn: false,
@@ -171,3 +177,160 @@ describe('getPublishTag', () => {
expect(spawnProcessMock).toBeCalledTimes(1);
});
});
+
+describe('NpmTarget.expand', () => {
+ let discoverWorkspacesMock: jest.SpyInstance;
+
+ afterEach(() => {
+ discoverWorkspacesMock?.mockRestore();
+ });
+
+ it('returns config as-is when workspaces is not enabled', async () => {
+ const config = { name: 'npm', id: '@sentry/browser' };
+ const result = await NpmTarget.expand(config, '/root');
+
+ expect(result).toEqual([config]);
+ });
+
+ it('throws error when public package depends on private workspace package', async () => {
+ discoverWorkspacesMock = jest
+ .spyOn(workspaces, 'discoverWorkspaces')
+ .mockResolvedValue({
+ type: 'npm',
+ packages: [
+ {
+ name: '@sentry/browser',
+ location: '/root/packages/browser',
+ private: false,
+ hasPublicAccess: true,
+ workspaceDependencies: ['@sentry/core', '@sentry-internal/utils'],
+ },
+ {
+ name: '@sentry/core',
+ location: '/root/packages/core',
+ private: false,
+ hasPublicAccess: true,
+ workspaceDependencies: [],
+ },
+ {
+ name: '@sentry-internal/utils',
+ location: '/root/packages/utils',
+ private: true, // This is private!
+ hasPublicAccess: false,
+ workspaceDependencies: [],
+ },
+ ],
+ });
+
+ const config = { name: 'npm', workspaces: true };
+
+ await expect(NpmTarget.expand(config, '/root')).rejects.toThrow(
+ /Public package "@sentry\/browser" depends on private workspace package\(s\): @sentry-internal\/utils/
+ );
+ });
+
+ it('allows public packages to depend on other public packages', async () => {
+ discoverWorkspacesMock = jest
+ .spyOn(workspaces, 'discoverWorkspaces')
+ .mockResolvedValue({
+ type: 'npm',
+ packages: [
+ {
+ name: '@sentry/browser',
+ location: '/root/packages/browser',
+ private: false,
+ hasPublicAccess: true,
+ workspaceDependencies: ['@sentry/core'],
+ },
+ {
+ name: '@sentry/core',
+ location: '/root/packages/core',
+ private: false,
+ hasPublicAccess: true,
+ workspaceDependencies: [],
+ },
+ ],
+ });
+
+ const config = { name: 'npm', workspaces: true };
+ const result = await NpmTarget.expand(config, '/root');
+
+ // Should return targets in dependency order (core before browser)
+ expect(result).toHaveLength(2);
+ expect(result[0].id).toBe('@sentry/core');
+ expect(result[1].id).toBe('@sentry/browser');
+ });
+
+ it('excludes private packages from expanded targets', async () => {
+ discoverWorkspacesMock = jest
+ .spyOn(workspaces, 'discoverWorkspaces')
+ .mockResolvedValue({
+ type: 'npm',
+ packages: [
+ {
+ name: '@sentry/browser',
+ location: '/root/packages/browser',
+ private: false,
+ hasPublicAccess: true,
+ workspaceDependencies: [],
+ },
+ {
+ name: '@sentry-internal/test-utils',
+ location: '/root/packages/test-utils',
+ private: true,
+ hasPublicAccess: false,
+ workspaceDependencies: [],
+ },
+ ],
+ });
+
+ const config = { name: 'npm', workspaces: true };
+ const result = await NpmTarget.expand(config, '/root');
+
+ // Should only include the public package
+ expect(result).toHaveLength(1);
+ expect(result[0].id).toBe('@sentry/browser');
+ });
+
+ it('propagates excludeNames and other options to expanded targets', async () => {
+ discoverWorkspacesMock = jest
+ .spyOn(workspaces, 'discoverWorkspaces')
+ .mockResolvedValue({
+ type: 'npm',
+ packages: [
+ {
+ name: '@sentry/browser',
+ location: '/root/packages/browser',
+ private: false,
+ hasPublicAccess: true,
+ workspaceDependencies: [],
+ },
+ {
+ name: '@sentry/node',
+ location: '/root/packages/node',
+ private: false,
+ hasPublicAccess: true,
+ workspaceDependencies: [],
+ },
+ ],
+ });
+
+ const config = {
+ name: 'npm',
+ workspaces: true,
+ excludeNames: '/.*-debug\\.tgz$/',
+ access: NpmPackageAccess.PUBLIC,
+ checkPackageName: '@sentry/browser',
+ };
+ const result = await NpmTarget.expand(config, '/root');
+
+ expect(result).toHaveLength(2);
+
+ // Both expanded targets should have the propagated options
+ for (const target of result) {
+ expect(target.excludeNames).toBe('/.*-debug\\.tgz$/');
+ expect(target.access).toBe(NpmPackageAccess.PUBLIC);
+ expect(target.checkPackageName).toBe('@sentry/browser');
+ }
+ });
+});
diff --git a/src/targets/docker.ts b/src/targets/docker.ts
index 3545fb7b..6d773514 100644
--- a/src/targets/docker.ts
+++ b/src/targets/docker.ts
@@ -1,3 +1,7 @@
+import { existsSync } from 'node:fs';
+import { homedir } from 'node:os';
+import { join } from 'node:path';
+
import { TargetConfig } from '../schemas/project_config';
import { BaseArtifactProvider } from '../artifact_providers/base';
import { ConfigurationError } from '../utils/errors';
@@ -12,22 +16,241 @@ const DEFAULT_DOCKER_BIN = 'docker';
*/
const DOCKER_BIN = process.env.DOCKER_BIN || DEFAULT_DOCKER_BIN;
-/** Options for "docker" target */
-export interface DockerTargetOptions {
+/** Docker Hub registry hostnames that should be treated as the default registry */
+const DOCKER_HUB_REGISTRIES = ['docker.io', 'index.docker.io', 'registry-1.docker.io'];
+
+/**
+ * Google Cloud registry patterns.
+ * - gcr.io and regional variants (Container Registry - being deprecated)
+ * - *.pkg.dev (Artifact Registry - recommended)
+ */
+const GCR_REGISTRY_PATTERNS = [
+ /^gcr\.io$/,
+ /^[a-z]+-gcr\.io$/, // us-gcr.io, eu-gcr.io, asia-gcr.io, etc.
+ /^[a-z]+\.gcr\.io$/, // us.gcr.io, eu.gcr.io, asia.gcr.io, etc.
+ /^[a-z][a-z0-9-]*-docker\.pkg\.dev$/, // us-docker.pkg.dev, us-west1-docker.pkg.dev, europe-west1-docker.pkg.dev, etc.
+];
+
+/**
+ * Checks if a registry is a Google Cloud registry (GCR or Artifact Registry).
+ */
+export function isGoogleCloudRegistry(registry: string | undefined): boolean {
+ if (!registry) return false;
+ return GCR_REGISTRY_PATTERNS.some(pattern => pattern.test(registry));
+}
+
+/**
+ * Checks if gcloud credentials are available in the environment.
+ * These are typically set by google-github-actions/auth or `gcloud auth login`.
+ *
+ * Detection methods:
+ * 1. GOOGLE_APPLICATION_CREDENTIALS env var pointing to a valid file
+ * 2. GOOGLE_GHA_CREDS_PATH env var (set by google-github-actions/auth)
+ * 3. CLOUDSDK_AUTH_CREDENTIAL_FILE_OVERRIDE env var
+ * 4. Default ADC location: ~/.config/gcloud/application_default_credentials.json
+ */
+export function hasGcloudCredentials(): boolean {
+ // Check environment variables that point to credential files
+ const credPaths = [
+ process.env.GOOGLE_APPLICATION_CREDENTIALS,
+ process.env.GOOGLE_GHA_CREDS_PATH,
+ process.env.CLOUDSDK_AUTH_CREDENTIAL_FILE_OVERRIDE,
+ ];
+
+ for (const credPath of credPaths) {
+ if (credPath && existsSync(credPath)) {
+ return true;
+ }
+ }
+
+ // Check default Application Default Credentials location
+ const defaultAdcPath = join(
+ homedir(),
+ '.config',
+ 'gcloud',
+ 'application_default_credentials.json'
+ );
+ if (existsSync(defaultAdcPath)) {
+ return true;
+ }
+
+ return false;
+}
+
+/**
+ * Checks if the gcloud CLI is available.
+ */
+export async function isGcloudAvailable(): Promise {
+ try {
+ await spawnProcess('gcloud', ['--version'], {}, {});
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+/**
+ * Extracts the registry host from a Docker image path.
+ *
+ * @param imagePath Docker image path (e.g., "ghcr.io/user/image" or "user/image")
+ * @returns The registry host if present (e.g., "ghcr.io"), undefined for Docker Hub
+ */
+export function extractRegistry(imagePath: string): string | undefined {
+ const parts = imagePath.split('/');
+ // Registry hosts contain dots (ghcr.io, gcr.io, us.gcr.io, etc.)
+ // or colons for ports (localhost:5000)
+ if (parts.length >= 2 && (parts[0].includes('.') || parts[0].includes(':'))) {
+ const registry = parts[0];
+ // Treat Docker Hub registries as the default (return undefined)
+ if (DOCKER_HUB_REGISTRIES.includes(registry)) {
+ return undefined;
+ }
+ return registry;
+ }
+ return undefined;
+}
+
+/**
+ * Converts a registry hostname to an environment variable prefix.
+ *
+ * @param registry Registry hostname (e.g., "ghcr.io", "us.gcr.io")
+ * @returns Environment variable prefix (e.g., "GHCR_IO", "US_GCR_IO")
+ */
+export function registryToEnvPrefix(registry: string): string {
+ return registry.toUpperCase().replace(/[.\-:]/g, '_');
+}
+
+/** Credentials for a Docker registry */
+export interface RegistryCredentials {
username: string;
password: string;
- /** Source image path, like `us.gcr.io/sentryio/craft` */
- source: string;
- /** Full name template for the source image path, defaults to `{{{source}}}:{{{revision}}}` */
- sourceTemplate: string;
- /** Full name template for the target image path, defaults to `{{{target}}}:{{{version}}}` */
- targetTemplate: string;
- /** Target image path, like `getsentry/craft` */
- target: string;
+ registry?: string;
+}
+
+/**
+ * Image reference configuration (object form).
+ * Can also be specified as a string shorthand for just the image path.
+ */
+export interface ImageRefConfig {
+ /** Docker image path (e.g., "ghcr.io/user/image" or "user/image") */
+ image: string;
+ /** Override the registry for credentials (auto-detected from image if not specified) */
+ registry?: string;
+ /** Format template for the image name */
+ format?: string;
+ /** Env var name for username (must be used with passwordVar) */
+ usernameVar?: string;
+ /** Env var name for password (must be used with usernameVar) */
+ passwordVar?: string;
+ /**
+ * Skip docker login for this registry.
+ * Use when auth is configured externally (e.g., gcloud workload identity, service account).
+ * When true, craft assumes Docker is already authenticated to access this registry.
+ */
+ skipLogin?: boolean;
+}
+
+/** Image reference can be a string (image path) or full config object */
+export type ImageRef = string | ImageRefConfig;
+
+/** Legacy config keys for source and target */
+interface LegacyConfigKeys {
+ format: string;
+ registry: string;
+ usernameVar: string;
+ passwordVar: string;
+ skipLogin: string;
}
+const LEGACY_KEYS: Record<'source' | 'target', LegacyConfigKeys> = {
+ source: {
+ format: 'sourceFormat',
+ registry: 'sourceRegistry',
+ usernameVar: 'sourceUsernameVar',
+ passwordVar: 'sourcePasswordVar',
+ skipLogin: 'sourceSkipLogin',
+ },
+ target: {
+ format: 'targetFormat',
+ registry: 'registry',
+ usernameVar: 'usernameVar',
+ passwordVar: 'passwordVar',
+ skipLogin: 'skipLogin',
+ },
+};
+
/**
- * Target responsible for publishing releases on Docker Hub (https://hub.docker.com)
+ * Normalizes an image reference to object form.
+ * Handles backwards compatibility with legacy flat config.
+ *
+ * @param config The full target config object
+ * @param type Whether this is 'source' or 'target' image reference
+ */
+export function normalizeImageRef(
+ config: Record,
+ type: 'source' | 'target'
+): ImageRefConfig {
+ const ref = config[type] as ImageRef;
+
+ // Validate that the required field is present
+ if (ref === undefined || ref === null) {
+ throw new ConfigurationError(
+ `Docker target requires a '${type}' property. Please specify the ${type} image.`
+ );
+ }
+
+ const keys = LEGACY_KEYS[type];
+
+ // Get legacy values from config
+ const legacyFormat = config[keys.format] as string | undefined;
+ const legacyRegistry = config[keys.registry] as string | undefined;
+ const legacyUsernameVar = config[keys.usernameVar] as string | undefined;
+ const legacyPasswordVar = config[keys.passwordVar] as string | undefined;
+ const legacySkipLogin = config[keys.skipLogin] as boolean | undefined;
+
+ if (typeof ref === 'string') {
+ return {
+ image: ref,
+ format: legacyFormat,
+ registry: legacyRegistry,
+ usernameVar: legacyUsernameVar,
+ passwordVar: legacyPasswordVar,
+ skipLogin: legacySkipLogin,
+ };
+ }
+
+ // Object form - prefer object properties over legacy, but allow legacy as fallback
+ return {
+ image: ref.image,
+ format: ref.format ?? legacyFormat,
+ registry: ref.registry ?? legacyRegistry,
+ usernameVar: ref.usernameVar ?? legacyUsernameVar,
+ passwordVar: ref.passwordVar ?? legacyPasswordVar,
+ skipLogin: ref.skipLogin ?? legacySkipLogin,
+ };
+}
+
+/** Resolved image configuration with credentials */
+export interface ResolvedImageConfig extends ImageRefConfig {
+ /** Resolved format template (with defaults applied) */
+ format: string;
+ /** Resolved credentials for this registry (undefined if public/same as other) */
+ credentials?: RegistryCredentials;
+}
+
+/** Options for "docker" target */
+export interface DockerTargetOptions {
+ /** Source image configuration with resolved credentials */
+ source: ResolvedImageConfig;
+ /** Target image configuration with resolved credentials (or skipLogin for external auth) */
+ target: ResolvedImageConfig;
+}
+
+/**
+ * Target responsible for publishing releases to Docker registries.
+ *
+ * Supports multiple registries including Docker Hub, GitHub Container Registry (ghcr.io),
+ * Google Container Registry (gcr.io), and other OCI-compliant registries.
*/
export class DockerTarget extends BaseTarget {
/** Target name */
@@ -45,41 +268,303 @@ export class DockerTarget extends BaseTarget {
}
/**
- * Extracts Docker target options from the environment
+ * Resolves credentials for a registry.
+ *
+ * Credential resolution follows two modes:
+ *
+ * Mode A (explicit env vars): If usernameVar and passwordVar are provided,
+ * only those env vars are used. Throws if either is missing.
+ *
+ * Mode B (automatic resolution): Tries in order:
+ * 1. Registry-derived env vars: DOCKER__USERNAME / DOCKER__PASSWORD
+ * 2. Built-in defaults for known registries (GHCR: GITHUB_ACTOR / GITHUB_TOKEN)
+ * 3. Default: DOCKER_USERNAME / DOCKER_PASSWORD (only if useDefaultFallback is true)
+ *
+ * @param registry The registry host (e.g., "ghcr.io"), undefined for Docker Hub
+ * @param usernameVar Optional explicit env var name for username
+ * @param passwordVar Optional explicit env var name for password
+ * @param required Whether credentials are required (throws if missing)
+ * @param useDefaultFallback Whether to fall back to DOCKER_USERNAME/PASSWORD defaults
+ * @returns Credentials if found, undefined if not required and not found
*/
- public getDockerConfig(): DockerTargetOptions {
- if (!process.env.DOCKER_USERNAME || !process.env.DOCKER_PASSWORD) {
+ private resolveCredentials(
+ registry: string | undefined,
+ usernameVar?: string,
+ passwordVar?: string,
+ required = true,
+ useDefaultFallback = true
+ ): RegistryCredentials | undefined {
+ let username: string | undefined;
+ let password: string | undefined;
+
+ // Mode A: Explicit env var override - no fallback for security
+ if (usernameVar || passwordVar) {
+ if (!usernameVar || !passwordVar) {
+ throw new ConfigurationError(
+ 'Both usernameVar and passwordVar must be specified together'
+ );
+ }
+ username = process.env[usernameVar];
+ password = process.env[passwordVar];
+
+ if (!username || !password) {
+ if (required) {
+ throw new ConfigurationError(
+ `Missing credentials: ${usernameVar} and/or ${passwordVar} environment variable(s) not set`
+ );
+ }
+ return undefined;
+ }
+ } else {
+ // Mode B: Automatic resolution with fallback chain
+
+ // 1. Registry-derived env vars
+ if (registry) {
+ const prefix = `DOCKER_${registryToEnvPrefix(registry)}_`;
+ username = process.env[`${prefix}USERNAME`];
+ password = process.env[`${prefix}PASSWORD`];
+ }
+
+ // 2. Built-in defaults for known registries
+ if (!username || !password) {
+ if (registry === 'ghcr.io') {
+ // GHCR defaults: use GitHub Actions built-in env vars
+ // GITHUB_ACTOR and GITHUB_TOKEN are available by default in GitHub Actions
+ // See: https://docs.github.com/en/actions/reference/workflows-and-actions/variables
+ username = username ?? process.env.GITHUB_ACTOR;
+ password = password ?? process.env.GITHUB_TOKEN;
+ }
+ }
+
+ // 3. Fallback to defaults (only for target registry, not for source)
+ if (useDefaultFallback) {
+ username = username ?? process.env.DOCKER_USERNAME;
+ password = password ?? process.env.DOCKER_PASSWORD;
+ }
+ }
+
+ if (!username || !password) {
+ if (required) {
+ const registryHint = registry
+ ? `DOCKER_${registryToEnvPrefix(registry)}_USERNAME/PASSWORD or `
+ : '';
throw new ConfigurationError(
`Cannot perform Docker release: missing credentials.
- Please use DOCKER_USERNAME and DOCKER_PASSWORD environment variables.`.replace(
+Please use ${registryHint}DOCKER_USERNAME and DOCKER_PASSWORD environment variables.`.replace(
/^\s+/gm,
''
)
+ );
+ }
+ return undefined;
+ }
+
+ return { username, password, registry };
+ }
+
+ /**
+ * Extracts Docker target options from the environment.
+ *
+ * Supports both new nested config format and legacy flat format:
+ *
+ * New format:
+ * source: { image: "ghcr.io/org/image", registry: "ghcr.io", usernameVar: "X" }
+ * target: "getsentry/craft" # string shorthand
+ *
+ * Legacy format:
+ * source: "ghcr.io/org/image"
+ * sourceRegistry: "ghcr.io"
+ * sourceUsernameVar: "X"
+ */
+ public getDockerConfig(): DockerTargetOptions {
+ // Normalize source and target configs (handles string vs object, legacy vs new)
+ const source = normalizeImageRef(this.config, 'source');
+ const target = normalizeImageRef(this.config, 'target');
+
+ // Resolve registries (explicit config > auto-detected from image)
+ const targetRegistry = target.registry ?? extractRegistry(target.image);
+ const sourceRegistry = source.registry ?? extractRegistry(source.image);
+
+ // Resolve target credentials
+ // - Skip if skipLogin is set (auth configured externally)
+ // - For Google Cloud registries, credentials are optional (can use gcloud auth)
+ // - For other registries, credentials are required
+ let targetCredentials: RegistryCredentials | undefined;
+ if (!target.skipLogin) {
+ const isGcrTarget = isGoogleCloudRegistry(targetRegistry);
+ targetCredentials = this.resolveCredentials(
+ targetRegistry,
+ target.usernameVar,
+ target.passwordVar,
+ // Required unless it's a GCR registry (which can use gcloud auth)
+ !isGcrTarget
+ );
+ }
+
+ // Resolve source credentials if source registry differs from target
+ // Source credentials are optional - if not found, we assume the source is public
+ // We don't fall back to default DOCKER_* credentials for source (those are for target)
+ let sourceCredentials: RegistryCredentials | undefined;
+ if (!source.skipLogin && sourceRegistry !== targetRegistry) {
+ sourceCredentials = this.resolveCredentials(
+ sourceRegistry,
+ source.usernameVar,
+ source.passwordVar,
+ // Only required if explicit source env vars are specified
+ !!(source.usernameVar || source.passwordVar),
+ // Don't fall back to DOCKER_USERNAME/PASSWORD for source
+ false
);
}
return {
- password: process.env.DOCKER_PASSWORD,
- source: this.config.source,
- target: this.config.target,
- sourceTemplate: this.config.sourceFormat || '{{{source}}}:{{{revision}}}',
- targetTemplate: this.config.targetFormat || '{{{target}}}:{{{version}}}',
- username: process.env.DOCKER_USERNAME,
+ source: {
+ ...source,
+ format: source.format || '{{{source}}}:{{{revision}}}',
+ credentials: sourceCredentials,
+ },
+ target: {
+ ...target,
+ format: target.format || '{{{target}}}:{{{version}}}',
+ credentials: targetCredentials,
+ },
};
}
/**
- * Logs into docker client with the provided username and password in config
+ * Logs into a Docker registry with the provided credentials.
*
* NOTE: This may change the globally logged in Docker user on the system
+ *
+ * @param credentials The registry credentials to use
*/
- public async login(): Promise {
- const { username, password } = this.dockerConfig;
- return spawnProcess(DOCKER_BIN, [
- 'login',
- `--username=${username}`,
- `--password=${password}`,
- ]);
+ private async loginToRegistry(credentials: RegistryCredentials): Promise {
+ const { username, password, registry } = credentials;
+ const args = ['login', `--username=${username}`, '--password-stdin'];
+ if (registry) {
+ args.push(registry);
+ }
+ const registryName = registry || 'Docker Hub';
+ this.logger.debug(`Logging into ${registryName}...`);
+ // Pass password via stdin for security (avoids exposure in ps/process list)
+ await spawnProcess(DOCKER_BIN, args, {}, { stdin: password });
+ }
+
+ /**
+ * Configures Docker to use gcloud for authentication to Google Cloud registries.
+ * This runs `gcloud auth configure-docker` which sets up the credential helper.
+ *
+ * @param registries List of Google Cloud registries to configure
+ * @returns true if configuration was successful, false otherwise
+ */
+ private async configureGcloudDocker(registries: string[]): Promise {
+ if (registries.length === 0) {
+ return false;
+ }
+
+ // Check if gcloud credentials are available
+ if (!hasGcloudCredentials()) {
+ this.logger.debug('No gcloud credentials detected, skipping gcloud auth configure-docker');
+ return false;
+ }
+
+ // Check if gcloud is available
+ if (!(await isGcloudAvailable())) {
+ this.logger.debug('gcloud CLI not available, skipping gcloud auth configure-docker');
+ return false;
+ }
+
+ const registryList = registries.join(',');
+ this.logger.debug(`Configuring Docker for Google Cloud registries: ${registryList}`);
+
+ try {
+ // Run gcloud auth configure-docker with the registries
+ // This configures Docker's credential helper to use gcloud for these registries
+ await spawnProcess('gcloud', ['auth', 'configure-docker', registryList, '--quiet'], {}, {});
+ this.logger.info(`Configured Docker authentication for: ${registryList}`);
+ return true;
+ } catch (error) {
+ this.logger.warn(`Failed to configure gcloud Docker auth: ${error}`);
+ return false;
+ }
+ }
+
+ /**
+ * Logs into all required Docker registries (source and target).
+ *
+ * For Google Cloud registries (gcr.io, *.pkg.dev), automatically uses
+ * `gcloud auth configure-docker` if gcloud credentials are available.
+ *
+ * If the source registry differs from target and has credentials configured,
+ * logs into both. Otherwise, only logs into the target registry.
+ */
+ public async login(): Promise {
+ const { source, target } = this.dockerConfig;
+
+ // Resolve registries from the config
+ const sourceRegistry = source.registry ?? extractRegistry(source.image);
+ const targetRegistry = target.registry ?? extractRegistry(target.image);
+
+ // Collect Google Cloud registries that need authentication
+ const gcrRegistries: string[] = [];
+ const gcrConfiguredRegistries = new Set();
+
+ // Check if source registry is a Google Cloud registry and needs auth
+ if (
+ !source.skipLogin &&
+ !source.credentials &&
+ sourceRegistry &&
+ isGoogleCloudRegistry(sourceRegistry)
+ ) {
+ gcrRegistries.push(sourceRegistry);
+ }
+
+ // Check if target registry is a Google Cloud registry and needs auth
+ if (
+ !target.skipLogin &&
+ !target.credentials &&
+ targetRegistry &&
+ isGoogleCloudRegistry(targetRegistry)
+ ) {
+ // Avoid duplicates
+ if (!gcrRegistries.includes(targetRegistry)) {
+ gcrRegistries.push(targetRegistry);
+ }
+ }
+
+ // Try to configure gcloud for Google Cloud registries
+ if (gcrRegistries.length > 0) {
+ const configured = await this.configureGcloudDocker(gcrRegistries);
+ if (configured) {
+ gcrRegistries.forEach(r => gcrConfiguredRegistries.add(r));
+ }
+ }
+
+ // Login to source registry (if needed and not already configured via gcloud)
+ if (source.credentials) {
+ await this.loginToRegistry(source.credentials);
+ } else if (
+ sourceRegistry &&
+ !source.skipLogin &&
+ !gcrConfiguredRegistries.has(sourceRegistry)
+ ) {
+ // Source registry needs auth but we couldn't configure it
+ // This is okay - source might be public or already authenticated
+ this.logger.debug(`No credentials for source registry ${sourceRegistry}, assuming public`);
+ }
+
+ // Login to target registry (if needed and not already configured via gcloud)
+ if (target.credentials) {
+ await this.loginToRegistry(target.credentials);
+ } else if (!target.skipLogin && !gcrConfiguredRegistries.has(targetRegistry || '')) {
+ // Target registry needs auth but we have no credentials and couldn't configure gcloud
+ // This will likely fail when pushing, but we let it proceed
+ if (targetRegistry) {
+ this.logger.warn(
+ `No credentials for target registry ${targetRegistry}. Push may fail.`
+ );
+ }
+ }
}
/**
@@ -91,12 +576,16 @@ export class DockerTarget extends BaseTarget {
* @param version The release version for the target image
*/
async copy(sourceRevision: string, version: string): Promise {
- const sourceImage = renderTemplateSafe(this.dockerConfig.sourceTemplate, {
- ...this.dockerConfig,
+ const { source, target } = this.dockerConfig;
+
+ const sourceImage = renderTemplateSafe(source.format, {
+ source: source.image,
+ target: target.image,
revision: sourceRevision,
});
- const targetImage = renderTemplateSafe(this.dockerConfig.targetTemplate, {
- ...this.dockerConfig,
+ const targetImage = renderTemplateSafe(target.format, {
+ source: source.image,
+ target: target.image,
version,
});
@@ -110,12 +599,12 @@ export class DockerTarget extends BaseTarget {
}
/**
- * Pushes a source image to Docker Hub
+ * Publishes a source image to the target registry
*
* @param version The new version
* @param revision The SHA revision of the new version
*/
- public async publish(version: string, revision: string): Promise {
+ public async publish(version: string, revision: string): Promise {
await this.login();
await this.copy(revision, version);
diff --git a/src/targets/github.ts b/src/targets/github.ts
index 563f118f..d4103bc5 100644
--- a/src/targets/github.ts
+++ b/src/targets/github.ts
@@ -18,6 +18,7 @@ import { isDryRun } from '../utils/helpers';
import {
isPreviewRelease,
parseVersion,
+ SemVer,
versionGreaterOrEqualThan,
versionToTag,
} from '../utils/version';
@@ -42,6 +43,12 @@ export interface GitHubTargetConfig extends GitHubGlobalConfig {
previewReleases: boolean;
/** Do not create a full GitHub release, only push a git tag */
tagOnly: boolean;
+ /**
+ * Floating tags to create/update when publishing a release.
+ * Supports placeholders: {major}, {minor}, {patch}
+ * Example: "v{major}" creates a "v2" tag for version "2.15.0"
+ */
+ floatingTags: string[];
}
/**
@@ -96,6 +103,7 @@ export class GitHubTarget extends BaseTarget {
!!this.config.previewReleases,
tagPrefix: this.config.tagPrefix || '',
tagOnly: !!this.config.tagOnly,
+ floatingTags: this.config.floatingTags || [],
};
this.github = getGitHubClient();
}
@@ -376,6 +384,86 @@ export class GitHubTarget extends BaseTarget {
}
}
+ /**
+ * Resolves a floating tag pattern by replacing placeholders with version components.
+ *
+ * @param pattern The pattern string (e.g., "v{major}")
+ * @param parsedVersion The parsed semantic version
+ * @returns The resolved tag name (e.g., "v2")
+ */
+ protected resolveFloatingTag(pattern: string, parsedVersion: SemVer): string {
+ return pattern
+ .replace('{major}', String(parsedVersion.major))
+ .replace('{minor}', String(parsedVersion.minor))
+ .replace('{patch}', String(parsedVersion.patch));
+ }
+
+ /**
+ * Creates or updates floating tags for the release.
+ *
+ * Floating tags (like "v2") point to the latest release in a major version line.
+ * They are force-updated if they already exist.
+ *
+ * @param version The version being released
+ * @param revision Git commit SHA to point the tags to
+ */
+ protected async updateFloatingTags(
+ version: string,
+ revision: string
+ ): Promise {
+ const floatingTags = this.githubConfig.floatingTags;
+ if (!floatingTags || floatingTags.length === 0) {
+ return;
+ }
+
+ const parsedVersion = parseVersion(version);
+ if (!parsedVersion) {
+ this.logger.warn(
+ `Cannot parse version "${version}" for floating tags, skipping`
+ );
+ return;
+ }
+
+ for (const pattern of floatingTags) {
+ const tag = this.resolveFloatingTag(pattern, parsedVersion);
+ const tagRef = `refs/tags/${tag}`;
+
+ if (isDryRun()) {
+ this.logger.info(
+ `[dry-run] Not updating floating tag: "${tag}" (from pattern "${pattern}")`
+ );
+ continue;
+ }
+
+ this.logger.info(`Updating floating tag: "${tag}"...`);
+
+ try {
+ // Try to update existing tag
+ await this.github.rest.git.updateRef({
+ owner: this.githubConfig.owner,
+ repo: this.githubConfig.repo,
+ ref: `tags/${tag}`,
+ sha: revision,
+ force: true,
+ });
+ this.logger.debug(`Updated existing floating tag: "${tag}"`);
+ } catch (error) {
+ // Tag doesn't exist, create it
+ if (error.status === 422) {
+ await this.github.rest.git.createRef({
+ owner: this.githubConfig.owner,
+ repo: this.githubConfig.repo,
+ ref: tagRef,
+ sha: revision,
+ });
+ this.logger.debug(`Created new floating tag: "${tag}"`);
+ } else {
+ throw error;
+ }
+ }
+ }
+ }
+
/**
* Creates a new GitHub release and publish all available artifacts.
*
@@ -389,7 +477,9 @@ export class GitHubTarget extends BaseTarget {
this.logger.info(
`Not creating a GitHub release because "tagOnly" flag was set.`
);
- return this.createGitTag(version, revision);
+ await this.createGitTag(version, revision);
+ await this.updateFloatingTags(version, revision);
+ return;
}
const config = getConfiguration();
@@ -449,6 +539,9 @@ export class GitHubTarget extends BaseTarget {
);
await this.publishRelease(draftRelease, { makeLatest });
+
+ // Update floating tags (e.g., v2 for version 2.15.0)
+ await this.updateFloatingTags(version, revision);
}
}
diff --git a/src/targets/npm.ts b/src/targets/npm.ts
index fc9d7288..7db3d956 100644
--- a/src/targets/npm.ts
+++ b/src/targets/npm.ts
@@ -3,6 +3,7 @@ import prompts from 'prompts';
import { TargetConfig } from '../schemas/project_config';
import { ConfigurationError, reportError } from '../utils/errors';
+import { stringToRegexp } from '../utils/filters';
import { isDryRun } from '../utils/helpers';
import { hasExecutable, spawnProcess } from '../utils/system';
import {
@@ -10,6 +11,13 @@ import {
parseVersion,
versionGreaterOrEqualThan,
} from '../utils/version';
+import {
+ discoverWorkspaces,
+ filterWorkspacePackages,
+ packageNameToArtifactPattern,
+ packageNameToArtifactFromTemplate,
+ topologicalSortPackages,
+} from '../utils/workspaces';
import { BaseTarget } from './base';
import {
BaseArtifactProvider,
@@ -17,6 +25,7 @@ import {
} from '../artifact_providers/base';
import { withTempFile } from '../utils/files';
import { writeFileSync } from 'fs';
+import { logger } from '../logger';
/** Command to launch "npm" */
export const NPM_BIN = process.env.NPM_BIN || 'npm';
@@ -44,6 +53,29 @@ export interface NpmTargetConfig extends TargetConfig {
access?: NpmPackageAccess;
/** If defined, lookup this package name on the registry to get the current latest version. */
checkPackageName?: string;
+ /**
+ * Enable workspace discovery to auto-generate npm targets for all workspace packages.
+ * When enabled, this target will be expanded into multiple targets, one per workspace package.
+ */
+ workspaces?: boolean;
+ /**
+ * Regex pattern to filter which workspace packages to include.
+ * Only packages matching this pattern will be published.
+ * Example: '/^@sentry\\//'
+ */
+ includeWorkspaces?: string;
+ /**
+ * Regex pattern to filter which workspace packages to exclude.
+ * Packages matching this pattern will not be published.
+ * Example: '/^@sentry-internal\\//'
+ */
+ excludeWorkspaces?: string;
+ /**
+ * Template for generating artifact filenames from package names.
+ * Variables: {{name}} (full package name), {{simpleName}} (without @scope/), {{version}}
+ * Default convention: @sentry/browser -> sentry-browser-{version}.tgz
+ */
+ artifactTemplate?: string;
}
/** NPM target configuration options */
@@ -77,6 +109,139 @@ export class NpmTarget extends BaseTarget {
/** Target options */
public readonly npmConfig: NpmTargetOptions;
+ /**
+ * Expand an npm target config into multiple targets if workspaces is enabled.
+ * This static method is called during config loading to expand workspace targets.
+ *
+ * @param config The npm target config
+ * @param rootDir The root directory of the project
+ * @returns Array of expanded target configs, or the original config in an array
+ */
+ public static async expand(
+ config: NpmTargetConfig,
+ rootDir: string
+ ): Promise {
+ // If workspaces is not enabled, return the config as-is
+ if (!config.workspaces) {
+ return [config];
+ }
+
+ const result = await discoverWorkspaces(rootDir);
+
+ if (result.type === 'none' || result.packages.length === 0) {
+ logger.warn(
+ 'npm target has workspaces enabled but no workspace packages were found'
+ );
+ return [];
+ }
+ // Filter packages based on include/exclude patterns
+ let includePattern: RegExp | undefined;
+ let excludePattern: RegExp | undefined;
+
+ if (config.includeWorkspaces) {
+ includePattern = stringToRegexp(config.includeWorkspaces);
+ }
+ if (config.excludeWorkspaces) {
+ excludePattern = stringToRegexp(config.excludeWorkspaces);
+ }
+
+ const filteredPackages = filterWorkspacePackages(
+ result.packages,
+ includePattern,
+ excludePattern
+ );
+
+ // Also filter out private packages by default (they shouldn't be published)
+ const publishablePackages = filteredPackages.filter(pkg => !pkg.private);
+ const privatePackageNames = new Set(
+ filteredPackages.filter(pkg => pkg.private).map(pkg => pkg.name)
+ );
+
+ // Validate: public packages should not depend on private workspace packages
+ for (const pkg of publishablePackages) {
+ const privateDeps = pkg.workspaceDependencies.filter(dep =>
+ privatePackageNames.has(dep)
+ );
+ if (privateDeps.length > 0) {
+ throw new ConfigurationError(
+ `Public package "${
+ pkg.name
+ }" depends on private workspace package(s): ${privateDeps.join(
+ ', '
+ )}. ` +
+ `Private packages cannot be published to npm, so this dependency cannot be resolved by consumers.`
+ );
+ }
+
+ // Warn about scoped packages without publishConfig.access: 'public'
+ const isScoped = pkg.name.startsWith('@');
+ if (isScoped && !pkg.hasPublicAccess) {
+ logger.warn(
+ `Scoped package "${pkg.name}" does not have publishConfig.access set to 'public'. ` +
+ `This may cause npm publish to fail for public packages.`
+ );
+ }
+ }
+
+ if (publishablePackages.length === 0) {
+ logger.warn('No publishable workspace packages found after filtering');
+ return [];
+ }
+
+ logger.info(
+ `Discovered ${publishablePackages.length} publishable ${result.type} workspace packages`
+ );
+
+
+
+ // Sort packages by dependency order (dependencies first, then dependents)
+ const sortedPackages = topologicalSortPackages(publishablePackages);
+
+ logger.debug(
+ `Expanding npm workspace target to ${
+ sortedPackages.length
+ } packages (dependency order): ${sortedPackages
+ .map(p => p.name)
+ .join(', ')}`
+ );
+
+ // Generate a target config for each package
+ return sortedPackages.map(pkg => {
+ // Generate the artifact pattern
+ let includeNames: string;
+ if (config.artifactTemplate) {
+ includeNames = packageNameToArtifactFromTemplate(
+ pkg.name,
+ config.artifactTemplate
+ );
+ } else {
+ includeNames = packageNameToArtifactPattern(pkg.name);
+ }
+
+ // Create the expanded target config
+ const expandedTarget: TargetConfig = {
+ name: 'npm',
+ id: pkg.name,
+ includeNames,
+ };
+
+ // Copy over common target options
+ if (config.excludeNames) {
+ expandedTarget.excludeNames = config.excludeNames;
+ }
+
+ // Copy over npm-specific target options
+ if (config.access) {
+ expandedTarget.access = config.access;
+ }
+ if (config.checkPackageName) {
+ expandedTarget.checkPackageName = config.checkPackageName;
+ }
+
+ return expandedTarget;
+ });
+ }
+
public constructor(
config: NpmTargetConfig,
artifactProvider: BaseArtifactProvider
diff --git a/src/utils/__fixtures__/workspaces/no-workspace/package.json b/src/utils/__fixtures__/workspaces/no-workspace/package.json
new file mode 100644
index 00000000..e46b4225
--- /dev/null
+++ b/src/utils/__fixtures__/workspaces/no-workspace/package.json
@@ -0,0 +1,4 @@
+{
+ "name": "single-package",
+ "version": "1.0.0"
+}
diff --git a/src/utils/__fixtures__/workspaces/npm-workspace/package.json b/src/utils/__fixtures__/workspaces/npm-workspace/package.json
new file mode 100644
index 00000000..4114dcf9
--- /dev/null
+++ b/src/utils/__fixtures__/workspaces/npm-workspace/package.json
@@ -0,0 +1,5 @@
+{
+ "name": "npm-workspace-root",
+ "private": true,
+ "workspaces": ["packages/*"]
+}
diff --git a/src/utils/__fixtures__/workspaces/npm-workspace/packages/pkg-a/package.json b/src/utils/__fixtures__/workspaces/npm-workspace/packages/pkg-a/package.json
new file mode 100644
index 00000000..ee465ff7
--- /dev/null
+++ b/src/utils/__fixtures__/workspaces/npm-workspace/packages/pkg-a/package.json
@@ -0,0 +1,4 @@
+{
+ "name": "@test/pkg-a",
+ "version": "1.0.0"
+}
diff --git a/src/utils/__fixtures__/workspaces/npm-workspace/packages/pkg-b/package.json b/src/utils/__fixtures__/workspaces/npm-workspace/packages/pkg-b/package.json
new file mode 100644
index 00000000..5b6dfa4d
--- /dev/null
+++ b/src/utils/__fixtures__/workspaces/npm-workspace/packages/pkg-b/package.json
@@ -0,0 +1,8 @@
+{
+ "name": "@test/pkg-b",
+ "version": "1.0.0",
+ "private": true,
+ "dependencies": {
+ "@test/pkg-a": "^1.0.0"
+ }
+}
diff --git a/src/utils/__fixtures__/workspaces/pnpm-workspace/package.json b/src/utils/__fixtures__/workspaces/pnpm-workspace/package.json
new file mode 100644
index 00000000..5e5ba2b2
--- /dev/null
+++ b/src/utils/__fixtures__/workspaces/pnpm-workspace/package.json
@@ -0,0 +1,4 @@
+{
+ "name": "pnpm-workspace-root",
+ "private": true
+}
diff --git a/src/utils/__fixtures__/workspaces/pnpm-workspace/packages/pkg-a/package.json b/src/utils/__fixtures__/workspaces/pnpm-workspace/packages/pkg-a/package.json
new file mode 100644
index 00000000..db7c4e70
--- /dev/null
+++ b/src/utils/__fixtures__/workspaces/pnpm-workspace/packages/pkg-a/package.json
@@ -0,0 +1,4 @@
+{
+ "name": "@pnpm/pkg-a",
+ "version": "1.0.0"
+}
diff --git a/src/utils/__fixtures__/workspaces/pnpm-workspace/packages/pkg-b/package.json b/src/utils/__fixtures__/workspaces/pnpm-workspace/packages/pkg-b/package.json
new file mode 100644
index 00000000..1983967a
--- /dev/null
+++ b/src/utils/__fixtures__/workspaces/pnpm-workspace/packages/pkg-b/package.json
@@ -0,0 +1,4 @@
+{
+ "name": "@pnpm/pkg-b",
+ "version": "1.0.0"
+}
diff --git a/src/utils/__fixtures__/workspaces/pnpm-workspace/pnpm-workspace.yaml b/src/utils/__fixtures__/workspaces/pnpm-workspace/pnpm-workspace.yaml
new file mode 100644
index 00000000..924b55f4
--- /dev/null
+++ b/src/utils/__fixtures__/workspaces/pnpm-workspace/pnpm-workspace.yaml
@@ -0,0 +1,2 @@
+packages:
+ - packages/*
diff --git a/src/utils/__tests__/autoVersion.test.ts b/src/utils/__tests__/autoVersion.test.ts
new file mode 100644
index 00000000..20fdbe19
--- /dev/null
+++ b/src/utils/__tests__/autoVersion.test.ts
@@ -0,0 +1,250 @@
+/* eslint-env jest */
+
+jest.mock('../githubApi.ts');
+jest.mock('../git');
+jest.mock('fs', () => ({
+ ...jest.requireActual('fs'),
+ readFileSync: jest.fn(),
+}));
+jest.mock('../../config', () => ({
+ ...jest.requireActual('../../config'),
+ getConfigFileDir: jest.fn(),
+ getGlobalGitHubConfig: jest.fn(),
+}));
+
+import { readFileSync } from 'fs';
+import type { SimpleGit } from 'simple-git';
+
+import * as config from '../../config';
+import { getChangesSince } from '../git';
+import { getGitHubClient } from '../githubApi';
+import {
+ calculateNextVersion,
+ getChangelogWithBumpType,
+ validateBumpType,
+} from '../autoVersion';
+import { clearChangesetCache } from '../changelog';
+
+const getConfigFileDirMock = config.getConfigFileDir as jest.MockedFunction<
+ typeof config.getConfigFileDir
+>;
+const getGlobalGitHubConfigMock =
+ config.getGlobalGitHubConfig as jest.MockedFunction<
+ typeof config.getGlobalGitHubConfig
+ >;
+const readFileSyncMock = readFileSync as jest.MockedFunction<
+ typeof readFileSync
+>;
+const getChangesSinceMock = getChangesSince as jest.MockedFunction<
+ typeof getChangesSince
+>;
+
+describe('calculateNextVersion', () => {
+ test('increments major version', () => {
+ expect(calculateNextVersion('1.2.3', 'major')).toBe('2.0.0');
+ });
+
+ test('increments minor version', () => {
+ expect(calculateNextVersion('1.2.3', 'minor')).toBe('1.3.0');
+ });
+
+ test('increments patch version', () => {
+ expect(calculateNextVersion('1.2.3', 'patch')).toBe('1.2.4');
+ });
+
+ test('handles empty version as 0.0.0', () => {
+ expect(calculateNextVersion('', 'patch')).toBe('0.0.1');
+ expect(calculateNextVersion('', 'minor')).toBe('0.1.0');
+ expect(calculateNextVersion('', 'major')).toBe('1.0.0');
+ });
+
+ test('handles prerelease versions', () => {
+ // Semver patch on prerelease "releases" it (removes prerelease suffix)
+ expect(calculateNextVersion('1.2.3-beta.1', 'patch')).toBe('1.2.3');
+ // Minor bump on prerelease increments minor and removes prerelease
+ expect(calculateNextVersion('1.2.3-rc.0', 'minor')).toBe('1.3.0');
+ });
+});
+
+describe('validateBumpType', () => {
+ test('throws error when no commits found', () => {
+ const result = {
+ changelog: '',
+ bumpType: null,
+ totalCommits: 0,
+ matchedCommitsWithSemver: 0,
+ };
+
+ expect(() => validateBumpType(result)).toThrow(
+ 'Cannot determine version automatically: no commits found since the last release.'
+ );
+ });
+
+ test('throws error when no commits match semver categories', () => {
+ const result = {
+ changelog: '',
+ bumpType: null,
+ totalCommits: 5,
+ matchedCommitsWithSemver: 0,
+ };
+
+ expect(() => validateBumpType(result)).toThrow(
+ 'Cannot determine version automatically'
+ );
+ });
+
+ test('does not throw when bumpType is present', () => {
+ const result = {
+ changelog: '### Features\n- feat: new feature',
+ bumpType: 'minor' as const,
+ totalCommits: 1,
+ matchedCommitsWithSemver: 1,
+ };
+
+ expect(() => validateBumpType(result)).not.toThrow();
+ });
+});
+
+describe('getChangelogWithBumpType', () => {
+ const mockGit = {} as SimpleGit;
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ clearChangesetCache(); // Clear memoization cache between tests
+ getConfigFileDirMock.mockReturnValue('/test/repo');
+ readFileSyncMock.mockImplementation(() => {
+ const error: NodeJS.ErrnoException = new Error('ENOENT');
+ error.code = 'ENOENT';
+ throw error;
+ });
+ getGlobalGitHubConfigMock.mockResolvedValue({
+ owner: 'testowner',
+ repo: 'testrepo',
+ });
+ });
+
+ test('returns changelog and minor bump type for feature commits', async () => {
+ getChangesSinceMock.mockResolvedValue([
+ { hash: 'abc123', title: 'feat: new feature', body: '', pr: '123' },
+ ]);
+ (getGitHubClient as jest.Mock).mockReturnValue({
+ graphql: jest.fn().mockResolvedValue({
+ repository: {
+ Cabc123: {
+ author: { user: { login: 'testuser' } },
+ associatedPullRequests: {
+ nodes: [
+ {
+ number: '123',
+ title: 'feat: new feature',
+ body: '',
+ labels: { nodes: [] },
+ },
+ ],
+ },
+ },
+ },
+ }),
+ });
+
+ const result = await getChangelogWithBumpType(mockGit, 'v1.0.0');
+
+ expect(result.bumpType).toBe('minor');
+ expect(result.changelog).toBeDefined();
+ expect(result.totalCommits).toBe(1);
+ });
+
+ test('returns null bumpType when no commits found', async () => {
+ getChangesSinceMock.mockResolvedValue([]);
+
+ const result = await getChangelogWithBumpType(mockGit, 'v1.0.0');
+
+ expect(result.bumpType).toBeNull();
+ expect(result.totalCommits).toBe(0);
+ });
+
+ test('returns null bumpType when no commits match semver categories', async () => {
+ getChangesSinceMock.mockResolvedValue([
+ {
+ hash: 'abc123',
+ title: 'random commit without conventional format',
+ body: '',
+ pr: null,
+ },
+ ]);
+ (getGitHubClient as jest.Mock).mockReturnValue({
+ graphql: jest.fn().mockResolvedValue({
+ repository: {
+ Cabc123: {
+ author: { user: { login: 'testuser' } },
+ associatedPullRequests: { nodes: [] },
+ },
+ },
+ }),
+ });
+
+ const result = await getChangelogWithBumpType(mockGit, 'v1.0.0');
+
+ expect(result.bumpType).toBeNull();
+ expect(result.totalCommits).toBe(1);
+ expect(result.matchedCommitsWithSemver).toBe(0);
+ });
+
+ test('returns patch bump type for fix commits', async () => {
+ getChangesSinceMock.mockResolvedValue([
+ { hash: 'abc123', title: 'fix: bug fix', body: '', pr: '456' },
+ ]);
+ (getGitHubClient as jest.Mock).mockReturnValue({
+ graphql: jest.fn().mockResolvedValue({
+ repository: {
+ Cabc123: {
+ author: { user: { login: 'testuser' } },
+ associatedPullRequests: {
+ nodes: [
+ {
+ number: '456',
+ title: 'fix: bug fix',
+ body: '',
+ labels: { nodes: [] },
+ },
+ ],
+ },
+ },
+ },
+ }),
+ });
+
+ const result = await getChangelogWithBumpType(mockGit, 'v2.0.0');
+
+ expect(result.bumpType).toBe('patch');
+ });
+
+ test('returns major bump type for breaking changes', async () => {
+ getChangesSinceMock.mockResolvedValue([
+ { hash: 'abc123', title: 'feat!: breaking change', body: '', pr: '789' },
+ ]);
+ (getGitHubClient as jest.Mock).mockReturnValue({
+ graphql: jest.fn().mockResolvedValue({
+ repository: {
+ Cabc123: {
+ author: { user: { login: 'testuser' } },
+ associatedPullRequests: {
+ nodes: [
+ {
+ number: '789',
+ title: 'feat!: breaking change',
+ body: '',
+ labels: { nodes: [] },
+ },
+ ],
+ },
+ },
+ },
+ }),
+ });
+
+ const result = await getChangelogWithBumpType(mockGit, '');
+
+ expect(result.bumpType).toBe('major');
+ });
+});
diff --git a/src/utils/__tests__/calver.test.ts b/src/utils/__tests__/calver.test.ts
new file mode 100644
index 00000000..2afb073e
--- /dev/null
+++ b/src/utils/__tests__/calver.test.ts
@@ -0,0 +1,199 @@
+import { formatCalVerDate, calculateCalVer, DEFAULT_CALVER_CONFIG } from '../calver';
+
+// Mock the config module to control tagPrefix
+jest.mock('../../config', () => ({
+ getGitTagPrefix: jest.fn(() => ''),
+}));
+
+import { getGitTagPrefix } from '../../config';
+
+const mockGetGitTagPrefix = getGitTagPrefix as jest.Mock;
+
+describe('formatCalVerDate', () => {
+ it('formats %y as 2-digit year', () => {
+ const date = new Date('2024-12-15');
+ expect(formatCalVerDate(date, '%y')).toBe('24');
+ });
+
+ it('formats %Y as 4-digit year', () => {
+ const date = new Date('2024-12-15');
+ expect(formatCalVerDate(date, '%Y')).toBe('2024');
+ });
+
+ it('formats %m as zero-padded month', () => {
+ const date = new Date('2024-01-15');
+ expect(formatCalVerDate(date, '%m')).toBe('01');
+
+ const date2 = new Date('2024-12-15');
+ expect(formatCalVerDate(date2, '%m')).toBe('12');
+ });
+
+ it('formats %-m as month without padding', () => {
+ const date = new Date('2024-01-15');
+ expect(formatCalVerDate(date, '%-m')).toBe('1');
+
+ const date2 = new Date('2024-12-15');
+ expect(formatCalVerDate(date2, '%-m')).toBe('12');
+ });
+
+ it('formats %d as zero-padded day', () => {
+ const date = new Date('2024-12-05');
+ expect(formatCalVerDate(date, '%d')).toBe('05');
+
+ const date2 = new Date('2024-12-25');
+ expect(formatCalVerDate(date2, '%d')).toBe('25');
+ });
+
+ it('formats %-d as day without padding', () => {
+ const date = new Date('2024-12-05');
+ expect(formatCalVerDate(date, '%-d')).toBe('5');
+
+ const date2 = new Date('2024-12-25');
+ expect(formatCalVerDate(date2, '%-d')).toBe('25');
+ });
+
+ it('handles the default format %y.%-m', () => {
+ const date = new Date('2024-12-15');
+ expect(formatCalVerDate(date, '%y.%-m')).toBe('24.12');
+
+ const date2 = new Date('2024-01-15');
+ expect(formatCalVerDate(date2, '%y.%-m')).toBe('24.1');
+ });
+
+ it('handles complex format strings', () => {
+ const date = new Date('2024-03-05');
+ expect(formatCalVerDate(date, '%Y.%m.%d')).toBe('2024.03.05');
+ expect(formatCalVerDate(date, '%y.%-m.%-d')).toBe('24.3.5');
+ });
+});
+
+describe('calculateCalVer', () => {
+ const mockGit = {
+ tags: jest.fn(),
+ };
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockGetGitTagPrefix.mockReturnValue('');
+ // Mock Date to return a fixed date
+ jest.useFakeTimers();
+ jest.setSystemTime(new Date('2024-12-23'));
+ });
+
+ afterEach(() => {
+ jest.useRealTimers();
+ });
+
+ it('returns first patch version when no tags exist', async () => {
+ mockGit.tags.mockResolvedValue({ all: [] });
+
+ const version = await calculateCalVer(mockGit as any, {
+ offset: 0,
+ format: '%y.%-m',
+ });
+
+ expect(version).toBe('24.12.0');
+ });
+
+ it('increments patch version when tag exists', async () => {
+ mockGit.tags.mockResolvedValue({ all: ['24.12.0'] });
+
+ const version = await calculateCalVer(mockGit as any, {
+ offset: 0,
+ format: '%y.%-m',
+ });
+
+ expect(version).toBe('24.12.1');
+ });
+
+ it('finds the highest patch and increments', async () => {
+ mockGit.tags.mockResolvedValue({ all: ['24.12.0', '24.12.1', '24.12.2'] });
+
+ const version = await calculateCalVer(mockGit as any, {
+ offset: 0,
+ format: '%y.%-m',
+ });
+
+ expect(version).toBe('24.12.3');
+ });
+
+ it('ignores tags from different date parts', async () => {
+ mockGit.tags.mockResolvedValue({ all: ['24.11.0', '24.11.1', '23.12.0'] });
+
+ const version = await calculateCalVer(mockGit as any, {
+ offset: 0,
+ format: '%y.%-m',
+ });
+
+ expect(version).toBe('24.12.0');
+ });
+
+ it('applies offset correctly', async () => {
+ // Date is 2024-12-23, with 14 day offset should be 2024-12-09 (still December)
+ mockGit.tags.mockResolvedValue({ all: [] });
+
+ const version = await calculateCalVer(mockGit as any, {
+ offset: 14,
+ format: '%y.%-m',
+ });
+
+ expect(version).toBe('24.12.0');
+ });
+
+ it('applies large offset that changes month', async () => {
+ // Date is 2024-12-23, with 30 day offset should be 2024-11-23
+ mockGit.tags.mockResolvedValue({ all: [] });
+
+ const version = await calculateCalVer(mockGit as any, {
+ offset: 30,
+ format: '%y.%-m',
+ });
+
+ expect(version).toBe('24.11.0');
+ });
+
+ it('handles non-numeric patch suffixes gracefully', async () => {
+ mockGit.tags.mockResolvedValue({ all: ['24.12.0', '24.12.beta', '24.12.1'] });
+
+ const version = await calculateCalVer(mockGit as any, {
+ offset: 0,
+ format: '%y.%-m',
+ });
+
+ expect(version).toBe('24.12.2');
+ });
+
+ it('uses default config values', () => {
+ expect(DEFAULT_CALVER_CONFIG.offset).toBe(14);
+ expect(DEFAULT_CALVER_CONFIG.format).toBe('%y.%-m');
+ });
+
+ it('accounts for git tag prefix when searching for existing tags', async () => {
+ // When tagPrefix is 'v', tags are like 'v24.12.0'
+ mockGetGitTagPrefix.mockReturnValue('v');
+ mockGit.tags.mockResolvedValue({ all: ['v24.12.0', 'v24.12.1'] });
+
+ const version = await calculateCalVer(mockGit as any, {
+ offset: 0,
+ format: '%y.%-m',
+ });
+
+ // Should find v24.12.1 and increment to 24.12.2
+ expect(version).toBe('24.12.2');
+ });
+
+ it('ignores tags without the configured prefix', async () => {
+ mockGetGitTagPrefix.mockReturnValue('v');
+ // Mix of prefixed and non-prefixed tags
+ mockGit.tags.mockResolvedValue({ all: ['24.12.5', 'v24.12.0', 'v24.12.1'] });
+
+ const version = await calculateCalVer(mockGit as any, {
+ offset: 0,
+ format: '%y.%-m',
+ });
+
+ // Should only find v24.12.0 and v24.12.1, increment to 24.12.2
+ // The non-prefixed '24.12.5' should be ignored
+ expect(version).toBe('24.12.2');
+ });
+});
diff --git a/src/utils/__tests__/changelog.test.ts b/src/utils/__tests__/changelog.test.ts
index 6155e48d..595ef0f9 100644
--- a/src/utils/__tests__/changelog.test.ts
+++ b/src/utils/__tests__/changelog.test.ts
@@ -26,6 +26,7 @@ import {
extractScope,
formatScopeTitle,
extractChangelogEntry,
+ clearChangesetCache,
SKIP_CHANGELOG_MAGIC_WORD,
BODY_IN_CHANGELOG_MAGIC_WORD,
} from '../changelog';
@@ -332,6 +333,9 @@ describe('generateChangesetFromGit', () => {
commits: TestCommit[],
releaseConfig?: string | null
): void {
+ // Clear memoization cache to ensure fresh results
+ clearChangesetCache();
+
mockGetChangesSince.mockResolvedValueOnce(
commits.map(commit => ({
hash: commit.hash,
@@ -805,7 +809,8 @@ describe('generateChangesetFromGit', () => {
output: string
) => {
setup(commits, releaseConfig);
- const changes = await generateChangesetFromGit(dummyGit, '1.0.0', 3);
+ const result = await generateChangesetFromGit(dummyGit, '1.0.0', 3);
+ const changes = result.changelog;
expect(changes).toBe(output);
}
);
@@ -855,7 +860,8 @@ describe('generateChangesetFromGit', () => {
expect(getConfigFileDirMock).toBeDefined();
expect(readFileSyncMock).toBeDefined();
- const changes = await generateChangesetFromGit(dummyGit, '1.0.0', 3);
+ const result = await generateChangesetFromGit(dummyGit, '1.0.0', 3);
+ const changes = result.changelog;
// Verify getConfigFileDir was called
expect(getConfigFileDirMock).toHaveBeenCalled();
@@ -910,7 +916,8 @@ describe('generateChangesetFromGit', () => {
- feature`
);
- const changes = await generateChangesetFromGit(dummyGit, '1.0.0', 3);
+ const result = await generateChangesetFromGit(dummyGit, '1.0.0', 3);
+ const changes = result.changelog;
expect(changes).not.toContain('#1');
expect(changes).toContain('#2');
});
@@ -953,7 +960,8 @@ describe('generateChangesetFromGit', () => {
- skip-release`
);
- const changes = await generateChangesetFromGit(dummyGit, '1.0.0', 3);
+ const result = await generateChangesetFromGit(dummyGit, '1.0.0', 3);
+ const changes = result.changelog;
// PR #1 is excluded from Features category but should appear in Other
// (category-level exclusions only exclude from that specific category)
expect(changes).toContain('#1');
@@ -990,7 +998,8 @@ describe('generateChangesetFromGit', () => {
- '*'`
);
- const changes = await generateChangesetFromGit(dummyGit, '1.0.0', 3);
+ const result = await generateChangesetFromGit(dummyGit, '1.0.0', 3);
+ const changes = result.changelog;
expect(changes).toContain('### All Changes');
expect(changes).toContain(
'Any PR by @alice in [#1](https://github.com/test-owner/test-repo/pull/1)'
@@ -1028,7 +1037,8 @@ describe('generateChangesetFromGit', () => {
null
);
- const changes = await generateChangesetFromGit(dummyGit, '1.0.0', 3);
+ const result = await generateChangesetFromGit(dummyGit, '1.0.0', 3);
+ const changes = result.changelog;
// When no config exists, default conventional commits patterns are used
expect(changes).toContain('### New Features');
expect(changes).toContain('### Bug Fixes');
@@ -1059,7 +1069,8 @@ describe('generateChangesetFromGit', () => {
- feature`
);
- const changes = await generateChangesetFromGit(dummyGit, '1.0.0', 3);
+ const result = await generateChangesetFromGit(dummyGit, '1.0.0', 3);
+ const changes = result.changelog;
expect(changes).toContain('### Features');
expect(changes).not.toContain('### Other');
expect(changes).toContain(
@@ -1087,7 +1098,8 @@ describe('generateChangesetFromGit', () => {
categories: "this is a string, not an array"`
);
- const changes = await generateChangesetFromGit(dummyGit, '1.0.0', 3);
+ const result = await generateChangesetFromGit(dummyGit, '1.0.0', 3);
+ const changes = result.changelog;
// Should not crash, and PR should appear in output (no categories applied)
expect(changes).toContain('#1');
});
@@ -1134,7 +1146,8 @@ describe('generateChangesetFromGit', () => {
releaseConfigYaml
);
- const changes = await generateChangesetFromGit(dummyGit, '1.0.0', 3);
+ const result = await generateChangesetFromGit(dummyGit, '1.0.0', 3);
+ const changes = result.changelog;
expect(changes).toContain('### Features');
expect(changes).toContain('### Bug Fixes');
@@ -1186,7 +1199,8 @@ describe('generateChangesetFromGit', () => {
releaseConfigYaml
);
- const changes = await generateChangesetFromGit(dummyGit, '1.0.0', 3);
+ const result = await generateChangesetFromGit(dummyGit, '1.0.0', 3);
+ const changes = result.changelog;
expect(changes).toContain('### Labeled Features');
expect(changes).toContain('### Pattern Features');
@@ -1265,7 +1279,8 @@ describe('generateChangesetFromGit', () => {
null // No release.yml - should use default config
);
- const changes = await generateChangesetFromGit(dummyGit, '1.0.0', 10);
+ const result = await generateChangesetFromGit(dummyGit, '1.0.0', 10);
+ const changes = result.changelog;
expect(changes).toContain('### New Features');
expect(changes).toContain('### Bug Fixes');
@@ -1301,7 +1316,8 @@ describe('generateChangesetFromGit', () => {
);
// Should not crash, and valid pattern should still work
- const changes = await generateChangesetFromGit(dummyGit, '1.0.0', 3);
+ const result = await generateChangesetFromGit(dummyGit, '1.0.0', 3);
+ const changes = result.changelog;
expect(changes).toContain('### Features');
expect(changes).toContain('feat: new feature');
});
@@ -1345,7 +1361,8 @@ describe('generateChangesetFromGit', () => {
releaseConfigYaml
);
- const changes = await generateChangesetFromGit(dummyGit, '1.0.0', 3);
+ const result = await generateChangesetFromGit(dummyGit, '1.0.0', 3);
+ const changes = result.changelog;
expect(changes).toContain('### Features');
expect(changes).not.toContain('### Other');
@@ -1394,7 +1411,8 @@ describe('generateChangesetFromGit', () => {
releaseConfigYaml
);
- const changes = await generateChangesetFromGit(dummyGit, '1.0.0', 3);
+ const result = await generateChangesetFromGit(dummyGit, '1.0.0', 3);
+ const changes = result.changelog;
expect(changes).toContain('### Features');
// PR #1 should be excluded from Features (but appear in Other)
@@ -1441,7 +1459,8 @@ describe('generateChangesetFromGit', () => {
releaseConfigYaml
);
- const changes = await generateChangesetFromGit(dummyGit, '1.0.0', 3);
+ const result = await generateChangesetFromGit(dummyGit, '1.0.0', 3);
+ const changes = result.changelog;
expect(changes).toContain('### Features');
expect(changes).not.toContain('### Other');
@@ -1486,7 +1505,8 @@ describe('generateChangesetFromGit', () => {
releaseConfigYaml
);
- const changes = await generateChangesetFromGit(dummyGit, '1.0.0', 3);
+ const result = await generateChangesetFromGit(dummyGit, '1.0.0', 3);
+ const changes = result.changelog;
expect(changes).toContain('### Features');
expect(changes).toContain('feat(api): add endpoint');
@@ -1536,7 +1556,8 @@ describe('generateChangesetFromGit', () => {
null // Use default config
);
- const changes = await generateChangesetFromGit(dummyGit, '1.0.0', 10);
+ const result = await generateChangesetFromGit(dummyGit, '1.0.0', 10);
+ const changes = result.changelog;
expect(changes).toContain('### Bug Fixes');
expect(changes).toContain('### New Features');
@@ -1591,7 +1612,8 @@ describe('generateChangesetFromGit', () => {
null // Use default config
);
- const changes = await generateChangesetFromGit(dummyGit, '1.0.0', 10);
+ const result = await generateChangesetFromGit(dummyGit, '1.0.0', 10);
+ const changes = result.changelog;
expect(changes).toContain('### Build / dependencies / internal');
expect(changes).toContain('refactor: clean up code');
@@ -1632,12 +1654,72 @@ describe('generateChangesetFromGit', () => {
null // Use default config
);
- const changes = await generateChangesetFromGit(dummyGit, '1.0.0', 10);
+ const result = await generateChangesetFromGit(dummyGit, '1.0.0', 10);
+ const changes = result.changelog;
expect(changes).toContain('### Breaking Changes');
expect(changes).toContain('feat(my-api)!: breaking api change');
expect(changes).toContain('fix!: breaking fix');
});
+
+ it('should trim leading and trailing whitespace from PR titles before pattern matching', async () => {
+ const releaseConfigYaml = `changelog:
+ categories:
+ - title: Features
+ commit_patterns:
+ - "^feat:"
+ - title: Bug Fixes
+ commit_patterns:
+ - "^fix:"`;
+
+ setup(
+ [
+ {
+ hash: 'abc123',
+ title: ' feat: feature with leading space',
+ body: '',
+ pr: {
+ remote: {
+ number: '1',
+ author: { login: 'alice' },
+ labels: [],
+ title: ' feat: feature with leading space', // PR title from GitHub has leading space
+ },
+ },
+ },
+ {
+ hash: 'def456',
+ title: 'fix: bug fix with trailing space ',
+ body: '',
+ pr: {
+ remote: {
+ number: '2',
+ author: { login: 'bob' },
+ labels: [],
+ title: 'fix: bug fix with trailing space ', // PR title from GitHub has trailing space
+ },
+ },
+ },
+ ],
+ releaseConfigYaml
+ );
+
+ const result = await generateChangesetFromGit(dummyGit, '1.0.0', 3);
+ const changes = result.changelog;
+
+ // Both should be properly categorized despite whitespace
+ expect(changes).toContain('### Features');
+ expect(changes).toContain('### Bug Fixes');
+ // Titles should be trimmed in output (no leading/trailing spaces)
+ expect(changes).toContain(
+ 'feat: feature with leading space by @alice in [#1](https://github.com/test-owner/test-repo/pull/1)'
+ );
+ expect(changes).toContain(
+ 'fix: bug fix with trailing space by @bob in [#2](https://github.com/test-owner/test-repo/pull/2)'
+ );
+ // Should NOT go to Other section
+ expect(changes).not.toContain('### Other');
+ });
});
describe('section ordering', () => {
@@ -1701,7 +1783,8 @@ describe('generateChangesetFromGit', () => {
releaseConfigYaml
);
- const changes = await generateChangesetFromGit(dummyGit, '1.0.0', 10);
+ const result = await generateChangesetFromGit(dummyGit, '1.0.0', 10);
+ const changes = result.changelog;
// Sections should appear in config order, not encounter order
const featuresIndex = changes.indexOf('### Features');
@@ -1782,7 +1865,8 @@ describe('generateChangesetFromGit', () => {
releaseConfigYaml
);
- const changes = await generateChangesetFromGit(dummyGit, '1.0.0', 10);
+ const result = await generateChangesetFromGit(dummyGit, '1.0.0', 10);
+ const changes = result.changelog;
// Features should still come before Bug Fixes per config order
const featuresIndex = changes.indexOf('### Features');
@@ -1870,7 +1954,8 @@ describe('generateChangesetFromGit', () => {
null // No config - use defaults
);
- const changes = await generateChangesetFromGit(dummyGit, '1.0.0', 10);
+ const result = await generateChangesetFromGit(dummyGit, '1.0.0', 10);
+ const changes = result.changelog;
// Default order from DEFAULT_RELEASE_CONFIG:
// Breaking Changes, Build/deps, Bug Fixes, Documentation, New Features
@@ -1972,7 +2057,8 @@ describe('generateChangesetFromGit', () => {
null // Use default config
);
- const changes = await generateChangesetFromGit(dummyGit, '1.0.0', 10);
+ const result = await generateChangesetFromGit(dummyGit, '1.0.0', 10);
+ const changes = result.changelog;
// Verify Api scope header exists (has 2 entries)
const apiSection = getSectionContent(changes, /#### Api\n/);
@@ -1989,7 +2075,7 @@ describe('generateChangesetFromGit', () => {
expect(uiSection).not.toContain('feat(api):');
});
- it('should place scopeless entries at the bottom without sub-header', async () => {
+ it('should place scopeless entries at the bottom under "Other" header when scoped entries exist', async () => {
setup(
[
{
@@ -2032,35 +2118,131 @@ describe('generateChangesetFromGit', () => {
null
);
- const changes = await generateChangesetFromGit(dummyGit, '1.0.0', 10);
+ const result = await generateChangesetFromGit(dummyGit, '1.0.0', 10);
+ const changes = result.changelog;
// Should have Api scope header (has 2 entries)
expect(changes).toContain('#### Api');
- // Scopeless entry should appear after all scope headers (at the bottom)
- const lastScopeHeaderIndex = changes.lastIndexOf('#### ');
- const scopelessIndex = changes.indexOf('feat: add feature without scope');
- expect(scopelessIndex).toBeGreaterThan(lastScopeHeaderIndex);
+ // Should have an "Other" header for scopeless entries (since Api has a header)
+ expect(changes).toContain('#### Other');
- // Verify the scopeless entry doesn't have its own #### header before it
- // by checking that the line immediately before it is not a scope header
- const lines = changes.split('\n');
- const scopelessLineIndex = lines.findIndex(line =>
- line.includes('feat: add feature without scope')
- );
- // Find the closest non-empty line before the scopeless entry
- let prevLineIndex = scopelessLineIndex - 1;
- while (prevLineIndex >= 0 && lines[prevLineIndex].trim() === '') {
- prevLineIndex--;
- }
- // The previous non-empty line should not be a #### header
- expect(lines[prevLineIndex]).not.toMatch(/^#### /);
+ // Scopeless entry should appear after the "Other" header
+ const otherHeaderIndex = changes.indexOf('#### Other');
+ const scopelessIndex = changes.indexOf('feat: add feature without scope');
+ expect(scopelessIndex).toBeGreaterThan(otherHeaderIndex);
// Verify Api scope entry comes before scopeless entry
const apiEntryIndex = changes.indexOf('feat(api): add endpoint');
expect(apiEntryIndex).toBeLessThan(scopelessIndex);
});
+ it('should not add "Other" header when no scoped entries have headers', async () => {
+ setup(
+ [
+ {
+ hash: 'abc123',
+ title: 'feat(api): single api feature',
+ body: '',
+ pr: {
+ remote: {
+ number: '1',
+ author: { login: 'alice' },
+ labels: [],
+ },
+ },
+ },
+ {
+ hash: 'def456',
+ title: 'feat: feature without scope',
+ body: '',
+ pr: {
+ remote: {
+ number: '2',
+ author: { login: 'bob' },
+ labels: [],
+ },
+ },
+ },
+ ],
+ null
+ );
+
+ const result = await generateChangesetFromGit(dummyGit, '1.0.0', 10);
+ const changes = result.changelog;
+
+ // Neither scope should have a header (both have only 1 entry)
+ expect(changes).not.toContain('#### Api');
+ // No "Other" header should be added since there are no other scope headers
+ expect(changes).not.toContain('#### Other');
+
+ // But both PRs should still appear in the output
+ expect(changes).toContain('feat(api): single api feature');
+ expect(changes).toContain('feat: feature without scope');
+ });
+
+ it('should not add extra newlines between entries without scope headers', async () => {
+ setup(
+ [
+ {
+ hash: 'abc123',
+ title: 'feat(docker): add docker feature',
+ body: '',
+ pr: {
+ remote: {
+ number: '1',
+ author: { login: 'alice' },
+ labels: [],
+ },
+ },
+ },
+ {
+ hash: 'def456',
+ title: 'feat: add feature without scope 1',
+ body: '',
+ pr: {
+ remote: {
+ number: '2',
+ author: { login: 'bob' },
+ labels: [],
+ },
+ },
+ },
+ {
+ hash: 'ghi789',
+ title: 'feat: add feature without scope 2',
+ body: '',
+ pr: {
+ remote: {
+ number: '3',
+ author: { login: 'charlie' },
+ labels: [],
+ },
+ },
+ },
+ ],
+ null
+ );
+
+ const result = await generateChangesetFromGit(dummyGit, '1.0.0', 10);
+ const changes = result.changelog;
+
+ // All three should appear without extra blank lines between them
+ // (no scope headers since docker only has 1 entry and scopeless don't trigger headers)
+ expect(changes).not.toContain('#### Docker');
+ expect(changes).not.toContain('#### Other');
+
+ // Verify no double newlines between entries (which would indicate separate sections)
+ const featuresSection = getSectionContent(changes, /### New Features[^\n]*\n/);
+ expect(featuresSection).not.toBeNull();
+ // There should be no blank lines between the three entries
+ expect(featuresSection).not.toMatch(/\n\n-/);
+ // All entries should be present
+ expect(featuresSection).toContain('feat(docker): add docker feature');
+ expect(featuresSection).toContain('feat: add feature without scope 1');
+ expect(featuresSection).toContain('feat: add feature without scope 2');
+ });
+
it('should skip scope header for scopes with only one entry', async () => {
setup(
[
@@ -2092,7 +2274,8 @@ describe('generateChangesetFromGit', () => {
null
);
- const changes = await generateChangesetFromGit(dummyGit, '1.0.0', 10);
+ const result = await generateChangesetFromGit(dummyGit, '1.0.0', 10);
+ const changes = result.changelog;
// Neither scope should have a header (both have only 1 entry)
expect(changes).not.toContain('#### Api');
@@ -2146,7 +2329,8 @@ describe('generateChangesetFromGit', () => {
null
);
- const changes = await generateChangesetFromGit(dummyGit, '1.0.0', 10);
+ const result = await generateChangesetFromGit(dummyGit, '1.0.0', 10);
+ const changes = result.changelog;
// Should only have one Api header (all merged)
const apiMatches = changes.match(/#### Api/gi);
@@ -2239,7 +2423,8 @@ describe('generateChangesetFromGit', () => {
null
);
- const changes = await generateChangesetFromGit(dummyGit, '1.0.0', 10);
+ const result = await generateChangesetFromGit(dummyGit, '1.0.0', 10);
+ const changes = result.changelog;
const alphaIndex = changes.indexOf('#### Alpha');
const betaIndex = changes.indexOf('#### Beta');
@@ -2311,7 +2496,8 @@ describe('generateChangesetFromGit', () => {
null
);
- const changes = await generateChangesetFromGit(dummyGit, '1.0.0', 10);
+ const result = await generateChangesetFromGit(dummyGit, '1.0.0', 10);
+ const changes = result.changelog;
// Verify scope headers are formatted correctly (each has 2 entries)
expect(changes).toContain('#### Another Component');
@@ -2398,7 +2584,8 @@ describe('generateChangesetFromGit', () => {
- enhancement`
);
- const changes = await generateChangesetFromGit(dummyGit, '1.0.0', 10);
+ const result = await generateChangesetFromGit(dummyGit, '1.0.0', 10);
+ const changes = result.changelog;
expect(changes).toContain('### Features');
@@ -2471,7 +2658,8 @@ describe('generateChangesetFromGit', () => {
null
);
- const changes = await generateChangesetFromGit(dummyGit, '1.0.0', 10);
+ const result = await generateChangesetFromGit(dummyGit, '1.0.0', 10);
+ const changes = result.changelog;
expect(changes).toContain('### Breaking Changes');
@@ -2520,7 +2708,8 @@ describe('generateChangesetFromGit', () => {
null
);
- const changes = await generateChangesetFromGit(dummyGit, '1.0.0', 10);
+ const result = await generateChangesetFromGit(dummyGit, '1.0.0', 10);
+ const changes = result.changelog;
// Should only have one "My Component" header (merged via normalization)
const myComponentMatches = changes.match(/#### My Component/gi);
@@ -2565,7 +2754,8 @@ Closes #123`,
null
);
- const changes = await generateChangesetFromGit(dummyGit, '1.0.0', 10);
+ const result = await generateChangesetFromGit(dummyGit, '1.0.0', 10);
+ const changes = result.changelog;
// Should use the custom changelog entry, not the PR title
expect(changes).toContain(
@@ -2600,7 +2790,8 @@ Closes #456`,
null
);
- const changes = await generateChangesetFromGit(dummyGit, '1.0.0', 10);
+ const result = await generateChangesetFromGit(dummyGit, '1.0.0', 10);
+ const changes = result.changelog;
// Should use PR title when no custom changelog entry exists
expect(changes).toContain('feat: Add bar function');
@@ -2656,7 +2847,8 @@ Custom entry for bug fix C`,
null
);
- const changes = await generateChangesetFromGit(dummyGit, '1.0.0', 10);
+ const result = await generateChangesetFromGit(dummyGit, '1.0.0', 10);
+ const changes = result.changelog;
expect(changes).toContain('Custom entry for feature A');
expect(changes).toContain('feat: Add feature B');
@@ -2705,7 +2897,8 @@ Custom entry for bug fix C`,
- bug`
);
- const changes = await generateChangesetFromGit(dummyGit, '1.0.0', 10);
+ const result = await generateChangesetFromGit(dummyGit, '1.0.0', 10);
+ const changes = result.changelog;
expect(changes).toContain('### Features');
expect(changes).toContain('### Bug Fixes');
@@ -2756,7 +2949,8 @@ Add endpoint for data export`,
null
);
- const changes = await generateChangesetFromGit(dummyGit, '1.0.0', 10);
+ const result = await generateChangesetFromGit(dummyGit, '1.0.0', 10);
+ const changes = result.changelog;
// Should still group by scope even with custom changelog entries
expect(changes).toContain('#### Api');
@@ -2786,7 +2980,8 @@ Update all dependencies to their latest versions for improved security`,
null
);
- const changes = await generateChangesetFromGit(dummyGit, '1.0.0', 10);
+ const result = await generateChangesetFromGit(dummyGit, '1.0.0', 10);
+ const changes = result.changelog;
expect(changes).toContain(
'Update all dependencies to their latest versions for improved security'
@@ -2819,7 +3014,8 @@ Update all dependencies to their latest versions for improved security`,
null
);
- const changes = await generateChangesetFromGit(dummyGit, '1.0.0', 10);
+ const result = await generateChangesetFromGit(dummyGit, '1.0.0', 10);
+ const changes = result.changelog;
expect(changes).toContain(
'Add comprehensive user authentication system by @alice in [#1]'
@@ -2853,7 +3049,8 @@ Update all dependencies to their latest versions for improved security`,
null
);
- const changes = await generateChangesetFromGit(dummyGit, '1.0.0', 10);
+ const result = await generateChangesetFromGit(dummyGit, '1.0.0', 10);
+ const changes = result.changelog;
// Should have 3 separate changelog entries from the same PR
expect(changes).toContain('Add OAuth2 authentication by @alice in [#1]');
@@ -2888,7 +3085,8 @@ Update all dependencies to their latest versions for improved security`,
null
);
- const changes = await generateChangesetFromGit(dummyGit, '1.0.0', 10);
+ const result = await generateChangesetFromGit(dummyGit, '1.0.0', 10);
+ const changes = result.changelog;
// First entry with nested content
expect(changes).toContain('Add authentication by @alice in [#1]');
@@ -2925,7 +3123,8 @@ Closes #123`,
null
);
- const changes = await generateChangesetFromGit(dummyGit, '1.0.0', 10);
+ const result = await generateChangesetFromGit(dummyGit, '1.0.0', 10);
+ const changes = result.changelog;
// Should fall back to PR title when changelog entry is empty
expect(changes).toContain('feat: Add feature');
diff --git a/src/utils/__tests__/workspaces.test.ts b/src/utils/__tests__/workspaces.test.ts
new file mode 100644
index 00000000..be73dccd
--- /dev/null
+++ b/src/utils/__tests__/workspaces.test.ts
@@ -0,0 +1,265 @@
+import { resolve } from 'path';
+
+import {
+ discoverWorkspaces,
+ filterWorkspacePackages,
+ packageNameToArtifactPattern,
+ packageNameToArtifactFromTemplate,
+ topologicalSortPackages,
+ WorkspacePackage,
+} from '../workspaces';
+
+const fixturesDir = resolve(__dirname, '../__fixtures__/workspaces');
+
+describe('discoverWorkspaces', () => {
+ test('discovers npm workspaces', async () => {
+ const result = await discoverWorkspaces(resolve(fixturesDir, 'npm-workspace'));
+
+ expect(result.type).toBe('npm');
+ expect(result.packages).toHaveLength(2);
+
+ const packageNames = result.packages.map(p => p.name).sort();
+ expect(packageNames).toEqual(['@test/pkg-a', '@test/pkg-b']);
+
+ // Check that pkg-b is marked as private
+ const pkgB = result.packages.find(p => p.name === '@test/pkg-b');
+ expect(pkgB?.private).toBe(true);
+ // Check that pkg-b has pkg-a as a workspace dependency
+ expect(pkgB?.workspaceDependencies).toEqual(['@test/pkg-a']);
+
+ const pkgA = result.packages.find(p => p.name === '@test/pkg-a');
+ expect(pkgA?.private).toBe(false);
+ expect(pkgA?.workspaceDependencies).toEqual([]);
+ });
+
+ test('discovers pnpm workspaces', async () => {
+ const result = await discoverWorkspaces(resolve(fixturesDir, 'pnpm-workspace'));
+
+ expect(result.type).toBe('pnpm');
+ expect(result.packages).toHaveLength(2);
+
+ const packageNames = result.packages.map(p => p.name).sort();
+ expect(packageNames).toEqual(['@pnpm/pkg-a', '@pnpm/pkg-b']);
+ });
+
+ test('returns none type when no workspaces found', async () => {
+ const result = await discoverWorkspaces(resolve(fixturesDir, 'no-workspace'));
+
+ expect(result.type).toBe('none');
+ expect(result.packages).toHaveLength(0);
+ });
+
+ test('returns none type for non-existent directory', async () => {
+ const result = await discoverWorkspaces(resolve(fixturesDir, 'does-not-exist'));
+
+ expect(result.type).toBe('none');
+ expect(result.packages).toHaveLength(0);
+ });
+});
+
+describe('filterWorkspacePackages', () => {
+ const testPackages: WorkspacePackage[] = [
+ { name: '@sentry/browser', location: '/path/browser', private: false, hasPublicAccess: true, workspaceDependencies: ['@sentry-internal/utils'] },
+ { name: '@sentry/node', location: '/path/node', private: false, hasPublicAccess: true, workspaceDependencies: ['@sentry-internal/utils'] },
+ { name: '@sentry-internal/utils', location: '/path/utils', private: false, hasPublicAccess: true, workspaceDependencies: [] },
+ { name: '@other/package', location: '/path/other', private: false, hasPublicAccess: false, workspaceDependencies: [] },
+ ];
+
+ test('returns all packages when no filters provided', () => {
+ const result = filterWorkspacePackages(testPackages);
+ expect(result).toHaveLength(4);
+ });
+
+ test('filters packages by include pattern', () => {
+ const result = filterWorkspacePackages(
+ testPackages,
+ /^@sentry\//
+ );
+
+ expect(result).toHaveLength(2);
+ expect(result.map(p => p.name)).toEqual(['@sentry/browser', '@sentry/node']);
+ });
+
+ test('filters packages by exclude pattern', () => {
+ const result = filterWorkspacePackages(
+ testPackages,
+ undefined,
+ /^@sentry-internal\//
+ );
+
+ expect(result).toHaveLength(3);
+ expect(result.map(p => p.name)).toEqual([
+ '@sentry/browser',
+ '@sentry/node',
+ '@other/package',
+ ]);
+ });
+
+ test('applies both include and exclude patterns', () => {
+ const result = filterWorkspacePackages(
+ testPackages,
+ /^@sentry/,
+ /^@sentry-internal\//
+ );
+
+ expect(result).toHaveLength(2);
+ expect(result.map(p => p.name)).toEqual(['@sentry/browser', '@sentry/node']);
+ });
+});
+
+describe('packageNameToArtifactPattern', () => {
+ test('converts scoped package name to pattern', () => {
+ const pattern = packageNameToArtifactPattern('@sentry/browser');
+ expect(pattern).toBe('/^sentry-browser-\\d.*\\.tgz$/');
+ });
+
+ test('converts nested scoped package name to pattern', () => {
+ const pattern = packageNameToArtifactPattern('@sentry-internal/browser-utils');
+ expect(pattern).toBe('/^sentry-internal-browser-utils-\\d.*\\.tgz$/');
+ });
+
+ test('converts unscoped package name to pattern', () => {
+ const pattern = packageNameToArtifactPattern('my-package');
+ expect(pattern).toBe('/^my-package-\\d.*\\.tgz$/');
+ });
+});
+
+describe('packageNameToArtifactFromTemplate', () => {
+ test('replaces {{name}} with full package name', () => {
+ const result = packageNameToArtifactFromTemplate(
+ '@sentry/browser',
+ '{{name}}.tgz'
+ );
+ expect(result).toBe('/^@sentry\\/browser\\.tgz$/');
+ });
+
+ test('replaces {{simpleName}} with normalized name', () => {
+ const result = packageNameToArtifactFromTemplate(
+ '@sentry/browser',
+ '{{simpleName}}.tgz'
+ );
+ expect(result).toBe('/^sentry-browser\\.tgz$/');
+ });
+
+ test('replaces {{version}} with version placeholder', () => {
+ const result = packageNameToArtifactFromTemplate(
+ '@sentry/browser',
+ '{{simpleName}}-{{version}}.tgz'
+ );
+ expect(result).toBe('/^sentry-browser-\\d.*\\.tgz$/');
+ });
+
+ test('replaces {{version}} with specific version', () => {
+ const result = packageNameToArtifactFromTemplate(
+ '@sentry/browser',
+ '{{simpleName}}-{{version}}.tgz',
+ '1.0.0'
+ );
+ expect(result).toBe('/^sentry-browser-1\\.0\\.0\\.tgz$/');
+ });
+
+ test('handles complex templates', () => {
+ const result = packageNameToArtifactFromTemplate(
+ '@sentry/browser',
+ 'dist/{{simpleName}}/{{simpleName}}-{{version}}.tgz'
+ );
+ expect(result).toBe('/^dist\\/sentry-browser\\/sentry-browser-\\d.*\\.tgz$/');
+ });
+});
+
+describe('topologicalSortPackages', () => {
+ test('returns packages in dependency order', () => {
+ const packages: WorkspacePackage[] = [
+ { name: '@sentry/browser', location: '/path/browser', private: false, hasPublicAccess: true, workspaceDependencies: ['@sentry/core'] },
+ { name: '@sentry/core', location: '/path/core', private: false, hasPublicAccess: true, workspaceDependencies: ['@sentry/types'] },
+ { name: '@sentry/types', location: '/path/types', private: false, hasPublicAccess: true, workspaceDependencies: [] },
+ ];
+
+ const sorted = topologicalSortPackages(packages);
+
+ expect(sorted.map(p => p.name)).toEqual([
+ '@sentry/types', // no dependencies, comes first
+ '@sentry/core', // depends on types
+ '@sentry/browser', // depends on core
+ ]);
+ });
+
+ test('handles packages with no dependencies', () => {
+ const packages: WorkspacePackage[] = [
+ { name: 'pkg-a', location: '/path/a', private: false, hasPublicAccess: false, workspaceDependencies: [] },
+ { name: 'pkg-b', location: '/path/b', private: false, hasPublicAccess: false, workspaceDependencies: [] },
+ { name: 'pkg-c', location: '/path/c', private: false, hasPublicAccess: false, workspaceDependencies: [] },
+ ];
+
+ const sorted = topologicalSortPackages(packages);
+
+ // All packages have no dependencies, order should be preserved
+ expect(sorted.map(p => p.name)).toEqual(['pkg-a', 'pkg-b', 'pkg-c']);
+ });
+
+ test('handles diamond dependencies', () => {
+ // Diamond: A depends on B and C, both B and C depend on D
+ const packages: WorkspacePackage[] = [
+ { name: 'A', location: '/path/a', private: false, hasPublicAccess: false, workspaceDependencies: ['B', 'C'] },
+ { name: 'B', location: '/path/b', private: false, hasPublicAccess: false, workspaceDependencies: ['D'] },
+ { name: 'C', location: '/path/c', private: false, hasPublicAccess: false, workspaceDependencies: ['D'] },
+ { name: 'D', location: '/path/d', private: false, hasPublicAccess: false, workspaceDependencies: [] },
+ ];
+
+ const sorted = topologicalSortPackages(packages);
+
+ // D must come before B and C, B and C must come before A
+ expect(sorted.map(p => p.name)).toEqual(['D', 'B', 'C', 'A']);
+ });
+
+ test('ignores dependencies not in the package list', () => {
+ const packages: WorkspacePackage[] = [
+ { name: 'pkg-a', location: '/path/a', private: false, hasPublicAccess: false, workspaceDependencies: ['external-dep', 'pkg-b'] },
+ { name: 'pkg-b', location: '/path/b', private: false, hasPublicAccess: false, workspaceDependencies: ['lodash'] },
+ ];
+
+ const sorted = topologicalSortPackages(packages);
+
+ // pkg-b comes first because pkg-a depends on it
+ expect(sorted.map(p => p.name)).toEqual(['pkg-b', 'pkg-a']);
+ });
+
+ test('throws error on circular dependencies', () => {
+ const packages: WorkspacePackage[] = [
+ { name: 'pkg-a', location: '/path/a', private: false, hasPublicAccess: false, workspaceDependencies: ['pkg-b'] },
+ { name: 'pkg-b', location: '/path/b', private: false, hasPublicAccess: false, workspaceDependencies: ['pkg-c'] },
+ { name: 'pkg-c', location: '/path/c', private: false, hasPublicAccess: false, workspaceDependencies: ['pkg-a'] },
+ ];
+
+ expect(() => topologicalSortPackages(packages)).toThrow(/Circular dependency/);
+ });
+
+ test('handles complex dependency graph with multiple branches', () => {
+ // Real-world-like setup similar to sentry-javascript:
+ // types -> core -> (browser, node-core -> node) -> nextjs (depends on browser and node)
+ const packages: WorkspacePackage[] = [
+ { name: '@sentry/nextjs', location: '/path/nextjs', private: false, hasPublicAccess: true, workspaceDependencies: ['@sentry/browser', '@sentry/node'] },
+ { name: '@sentry/browser', location: '/path/browser', private: false, hasPublicAccess: true, workspaceDependencies: ['@sentry/core'] },
+ { name: '@sentry/node', location: '/path/node', private: false, hasPublicAccess: true, workspaceDependencies: ['@sentry/node-core'] },
+ { name: '@sentry/node-core', location: '/path/node-core', private: false, hasPublicAccess: true, workspaceDependencies: ['@sentry/core'] },
+ { name: '@sentry/core', location: '/path/core', private: false, hasPublicAccess: true, workspaceDependencies: ['@sentry/types'] },
+ { name: '@sentry/types', location: '/path/types', private: false, hasPublicAccess: true, workspaceDependencies: [] },
+ ];
+
+ const sorted = topologicalSortPackages(packages);
+ const names = sorted.map(p => p.name);
+
+ // Verify exact expected order with two branches:
+ // types -> core -> browser (branch 1)
+ // types -> core -> node-core -> node (branch 2)
+ // nextjs depends on both browser and node
+ expect(names).toEqual([
+ '@sentry/types', // depth 0: no dependencies
+ '@sentry/core', // depth 1: depends on types
+ '@sentry/browser', // depth 2: depends on core
+ '@sentry/node-core', // depth 2: depends on core
+ '@sentry/node', // depth 3: depends on node-core
+ '@sentry/nextjs', // depth 4: depends on browser and node
+ ]);
+ });
+});
diff --git a/src/utils/autoVersion.ts b/src/utils/autoVersion.ts
new file mode 100644
index 00000000..7537af41
--- /dev/null
+++ b/src/utils/autoVersion.ts
@@ -0,0 +1,91 @@
+import * as semver from 'semver';
+import type { SimpleGit } from 'simple-git';
+
+import { logger } from '../logger';
+import {
+ generateChangesetFromGit,
+ BUMP_TYPES,
+ isBumpType,
+ type BumpType,
+ type ChangelogResult,
+} from './changelog';
+
+// Re-export for convenience
+export { BUMP_TYPES, isBumpType, type BumpType, type ChangelogResult };
+
+/**
+ * Calculates the next version by applying the bump type to the current version.
+ *
+ * @param currentVersion The current version string (e.g., "1.2.3")
+ * @param bumpType The type of bump to apply
+ * @returns The new version string
+ * @throws Error if the version cannot be incremented
+ */
+export function calculateNextVersion(
+ currentVersion: string,
+ bumpType: BumpType
+): string {
+ // Handle empty/missing current version (new project)
+ const versionToBump = currentVersion || '0.0.0';
+
+ const newVersion = semver.inc(versionToBump, bumpType);
+
+ if (!newVersion) {
+ throw new Error(
+ `Failed to increment version "${versionToBump}" with bump type "${bumpType}"`
+ );
+ }
+
+ return newVersion;
+}
+
+/**
+ * Generates changelog and determines version bump type from commits.
+ * This is a convenience wrapper around generateChangesetFromGit that logs progress.
+ *
+ * @param git The SimpleGit instance
+ * @param rev The revision (tag) to analyze from
+ * @returns The changelog result (bumpType may be null if no matching commits)
+ */
+export async function getChangelogWithBumpType(
+ git: SimpleGit,
+ rev: string
+): Promise {
+ logger.info(
+ `Analyzing commits since ${rev || '(beginning of history)'} for auto-versioning...`
+ );
+
+ const result = await generateChangesetFromGit(git, rev);
+
+ if (result.bumpType) {
+ logger.info(
+ `Auto-version: determined ${result.bumpType} bump ` +
+ `(${result.matchedCommitsWithSemver}/${result.totalCommits} commits matched)`
+ );
+ }
+
+ return result;
+}
+
+/**
+ * Validates that a changelog result has the required bump type for auto-versioning.
+ *
+ * @param result The changelog result to validate
+ * @throws Error if no commits found or none match categories with semver fields
+ */
+export function validateBumpType(result: ChangelogResult): asserts result is ChangelogResult & { bumpType: BumpType } {
+ if (result.totalCommits === 0) {
+ throw new Error(
+ 'Cannot determine version automatically: no commits found since the last release.'
+ );
+ }
+
+ if (result.bumpType === null) {
+ throw new Error(
+ `Cannot determine version automatically: ${result.totalCommits} commit(s) found, ` +
+ 'but none matched a category with a "semver" field in the release configuration. ' +
+ 'Please ensure your .github/release.yml categories have "semver" fields defined, ' +
+ 'or specify the version explicitly.'
+ );
+ }
+}
diff --git a/src/utils/calver.ts b/src/utils/calver.ts
new file mode 100644
index 00000000..4ecccd9f
--- /dev/null
+++ b/src/utils/calver.ts
@@ -0,0 +1,101 @@
+import type { SimpleGit } from 'simple-git';
+
+import { getGitTagPrefix } from '../config';
+import { logger } from '../logger';
+
+/**
+ * Configuration for CalVer versioning
+ */
+export interface CalVerConfig {
+ /** Days to go back for date calculation */
+ offset: number;
+ /** strftime-like format for date part */
+ format: string;
+}
+
+/**
+ * Default CalVer configuration
+ */
+export const DEFAULT_CALVER_CONFIG: CalVerConfig = {
+ offset: 14,
+ format: '%y.%-m',
+};
+
+/**
+ * Formats a date according to a strftime-like format string.
+ *
+ * Supported format specifiers:
+ * - %y: 2-digit year (e.g., "24" for 2024)
+ * - %Y: 4-digit year (e.g., "2024")
+ * - %m: Zero-padded month (e.g., "01" for January)
+ * - %-m: Month without zero padding (e.g., "1" for January)
+ * - %d: Zero-padded day (e.g., "05")
+ * - %-d: Day without zero padding (e.g., "5")
+ *
+ * @param date The date to format
+ * @param format The format string
+ * @returns The formatted date string
+ */
+export function formatCalVerDate(date: Date, format: string): string {
+ const year = date.getFullYear();
+ const month = date.getMonth() + 1;
+ const day = date.getDate();
+
+ return format
+ .replace('%Y', String(year))
+ .replace('%y', String(year).slice(-2))
+ .replace('%-m', String(month))
+ .replace('%m', String(month).padStart(2, '0'))
+ .replace('%-d', String(day))
+ .replace('%d', String(day).padStart(2, '0'));
+}
+
+/**
+ * Calculates the next CalVer version based on existing tags.
+ *
+ * The version format is: {datePart}.{patch}
+ * For example, with format '%y.%-m' and no existing tags: "24.12.0"
+ *
+ * @param git SimpleGit instance for checking existing tags
+ * @param config CalVer configuration
+ * @returns The next CalVer version string
+ */
+export async function calculateCalVer(
+ git: SimpleGit,
+ config: CalVerConfig
+): Promise {
+ // Calculate date with offset
+ const date = new Date();
+ date.setDate(date.getDate() - config.offset);
+
+ // Format date part
+ const datePart = formatCalVerDate(date, config.format);
+
+ logger.debug(`CalVer: using date ${date.toISOString()}, date part: ${datePart}`);
+
+ // Find existing tags and determine next patch version
+ // Account for git tag prefix (e.g., 'v') when searching
+ const gitTagPrefix = getGitTagPrefix();
+ const searchPrefix = `${gitTagPrefix}${datePart}.`;
+
+ logger.debug(`CalVer: searching for tags with prefix: ${searchPrefix}`);
+
+ const tags = await git.tags();
+ let patch = 0;
+
+ // Find the highest patch version for this date part
+ for (const tag of tags.all) {
+ if (tag.startsWith(searchPrefix)) {
+ const patchStr = tag.slice(searchPrefix.length);
+ const patchNum = parseInt(patchStr, 10);
+ if (!isNaN(patchNum) && patchNum >= patch) {
+ patch = patchNum + 1;
+ }
+ }
+ }
+
+ const version = `${datePart}.${patch}`;
+ logger.info(`CalVer: determined version ${version}`);
+
+ return version;
+}
diff --git a/src/utils/changelog.ts b/src/utils/changelog.ts
index 16412404..f9f92759 100644
--- a/src/utils/changelog.ts
+++ b/src/utils/changelog.ts
@@ -13,6 +13,72 @@ import { getChangesSince } from './git';
import { getGitHubClient } from './githubApi';
import { getVersion } from './version';
+/** Information about the current (unmerged) PR to inject into changelog */
+export interface CurrentPRInfo {
+ number: number;
+ title: string;
+ body: string;
+ author: string;
+ labels: string[];
+ /** Base branch ref (e.g., "master") for computing merge base */
+ baseRef: string;
+}
+
+/**
+ * Fetches PR details from GitHub API by PR number.
+ *
+ * @param prNumber The PR number to fetch
+ * @returns PR info
+ * @throws Error if PR cannot be fetched
+ */
+async function fetchPRInfo(prNumber: number): Promise {
+ const { repo, owner } = await getGlobalGitHubConfig();
+ const github = getGitHubClient();
+
+ const { data: pr } = await github.pulls.get({
+ owner,
+ repo,
+ pull_number: prNumber,
+ });
+
+ const { data: labels } = await github.issues.listLabelsOnIssue({
+ owner,
+ repo,
+ issue_number: prNumber,
+ });
+
+ return {
+ number: prNumber,
+ title: pr.title,
+ body: pr.body ?? '',
+ author: pr.user?.login ?? '',
+ labels: labels.map(l => l.name),
+ baseRef: pr.base.ref,
+ };
+}
+
+/**
+ * Version bump types.
+ */
+export type BumpType = 'major' | 'minor' | 'patch';
+
+/**
+ * Version bump type priorities (lower number = higher priority).
+ * Used for determining the highest bump type from commits.
+ */
+export const BUMP_TYPES: Map = new Map([
+ ['major', 0],
+ ['minor', 1],
+ ['patch', 2],
+]);
+
+/**
+ * Type guard to check if a string is a valid BumpType.
+ */
+export function isBumpType(value: string): value is BumpType {
+ return BUMP_TYPES.has(value as BumpType);
+}
+
/**
* Path to the changelog file in the target repository
*/
@@ -391,6 +457,8 @@ interface PullRequest {
hash: string;
body: string;
title: string;
+ /** Whether this entry should be highlighted in output */
+ highlight?: boolean;
}
interface Commit {
@@ -404,22 +472,48 @@ interface Commit {
prBody?: string | null;
labels: string[];
category: string | null;
+ /** Whether this entry should be highlighted in output */
+ highlight?: boolean;
+}
+
+/**
+ * Raw commit/PR info before categorization.
+ * This is the input to the categorization step.
+ */
+interface RawCommitInfo {
+ hash: string;
+ title: string;
+ body: string;
+ author?: string;
+ pr?: string;
+ prTitle?: string;
+ prBody?: string;
+ labels: string[];
+ /** Whether this entry should be highlighted in output */
+ highlight?: boolean;
}
+/**
+ * Valid semver bump types for auto-versioning
+ */
+export type SemverBumpType = 'major' | 'minor' | 'patch';
+
/**
* Release configuration structure matching GitHub's release.yml format
*/
-interface ReleaseConfigCategory {
+export interface ReleaseConfigCategory {
title: string;
labels?: string[];
commit_patterns?: string[];
+ /** Semver bump type when commits match this category (for auto-versioning) */
+ semver?: SemverBumpType;
exclude?: {
labels?: string[];
authors?: string[];
};
}
-interface ReleaseConfig {
+export interface ReleaseConfig {
changelog?: {
exclude?: {
labels?: string[];
@@ -433,28 +527,36 @@ interface ReleaseConfig {
* Default release configuration based on conventional commits
* Used when .github/release.yml doesn't exist
*/
-const DEFAULT_RELEASE_CONFIG: ReleaseConfig = {
+export const DEFAULT_RELEASE_CONFIG: ReleaseConfig = {
changelog: {
+ exclude: {
+ labels: ['skip-changelog'],
+ },
categories: [
{
title: 'Breaking Changes 🛠',
commit_patterns: ['^\\w+(?:\\([^)]+\\))?!:'],
+ semver: 'major',
},
{
title: 'New Features ✨',
commit_patterns: ['^feat\\b'],
+ semver: 'minor',
},
{
title: 'Bug Fixes 🐛',
commit_patterns: ['^fix\\b'],
+ semver: 'patch',
},
{
title: 'Documentation 📚',
commit_patterns: ['^docs?\\b'],
+ semver: 'patch',
},
{
title: 'Build / dependencies / internal 🔧',
- commit_patterns: ['^(?:build|refactor|meta|chore|ci)\\b'],
+ commit_patterns: ['^(?:build|refactor|meta|chore|ci|ref|perf)\\b'],
+ semver: 'patch',
},
],
},
@@ -464,7 +566,7 @@ const DEFAULT_RELEASE_CONFIG: ReleaseConfig = {
* Normalized release config with Sets for efficient lookups
* All fields are non-optional - use empty sets/arrays when not present
*/
-interface NormalizedReleaseConfig {
+export interface NormalizedReleaseConfig {
changelog: {
exclude: {
labels: Set;
@@ -474,10 +576,12 @@ interface NormalizedReleaseConfig {
};
}
-interface NormalizedCategory {
+export interface NormalizedCategory {
title: string;
labels: string[];
commitLogPatterns: RegExp[];
+ /** Semver bump type when commits match this category (for auto-versioning) */
+ semver?: SemverBumpType;
exclude: {
labels: Set;
authors: Set;
@@ -493,7 +597,7 @@ type CategoryWithPRs = {
* Reads and parses .github/release.yml from the repository root
* @returns Parsed release configuration, or the default config if file doesn't exist
*/
-function readReleaseConfig(): ReleaseConfig {
+export function readReleaseConfig(): ReleaseConfig {
const configFileDir = getConfigFileDir();
if (!configFileDir) {
return DEFAULT_RELEASE_CONFIG;
@@ -520,7 +624,7 @@ function readReleaseConfig(): ReleaseConfig {
/**
* Normalizes the release config by converting arrays to Sets and compiling regex patterns
*/
-function normalizeReleaseConfig(
+export function normalizeReleaseConfig(
config: ReleaseConfig
): NormalizedReleaseConfig | null {
if (!config?.changelog) {
@@ -577,6 +681,7 @@ function normalizeReleaseConfig(
}
})
.filter((r): r is RegExp => r !== null),
+ semver: category.semver,
exclude: {
labels: new Set(),
authors: new Set(),
@@ -607,7 +712,7 @@ function normalizeReleaseConfig(
/**
* Checks if a PR should be excluded globally based on release config
*/
-function shouldExcludePR(
+export function shouldExcludePR(
labels: Set,
author: string | undefined,
config: NormalizedReleaseConfig | null
@@ -634,7 +739,7 @@ function shouldExcludePR(
/**
* Checks if a category excludes the given PR based on labels and author
*/
-function isCategoryExcluded(
+export function isCategoryExcluded(
category: NormalizedCategory,
labels: Set,
author: string | undefined
@@ -655,18 +760,18 @@ function isCategoryExcluded(
}
/**
- * Matches a PR's labels or commit title to a category from release config
+ * Matches a PR's labels or commit title to a category from release config.
* Labels take precedence over commit log pattern matching.
* Category-level exclusions are checked here - they exclude the PR from matching this specific category,
* allowing it to potentially match other categories or fall through to "Other"
- * @returns Category title or null if no match or excluded from this category
+ * @returns The matched category or null if no match or excluded from all categories
*/
-function matchPRToCategory(
+export function matchCommitToCategory(
labels: Set,
author: string | undefined,
title: string,
config: NormalizedReleaseConfig | null
-): string | null {
+): NormalizedCategory | null {
if (!config?.changelog || config.changelog.categories.length === 0) {
return null;
}
@@ -696,7 +801,7 @@ function matchPRToCategory(
for (const category of regularCategories) {
const matchesCategory = category.labels.some(label => labels.has(label));
if (matchesCategory && !isCategoryExcluded(category, labels, author)) {
- return category.title;
+ return category;
}
}
}
@@ -707,7 +812,7 @@ function matchPRToCategory(
re.test(title)
);
if (matchesPattern && !isCategoryExcluded(category, labels, author)) {
- return category.title;
+ return category;
}
}
@@ -715,7 +820,7 @@ function matchPRToCategory(
if (isCategoryExcluded(wildcardCategory, labels, author)) {
return null;
}
- return wildcardCategory.title;
+ return wildcardCategory;
}
return null;
@@ -733,11 +838,14 @@ interface ChangelogEntry {
body?: string;
/** Base URL for the repository, e.g. https://github.com/owner/repo */
repoUrl: string;
+ /** Whether this entry should be highlighted (rendered as blockquote) */
+ highlight?: boolean;
}
/**
* Formats a single changelog entry with consistent full markdown link format.
* Format: `- Title by @author in [#123](pr-url)` or `- Title in [abcdef12](commit-url)`
+ * When highlight is true, the entry is prefixed with `> ` (blockquote).
*/
function formatChangelogEntry(entry: ChangelogEntry): string {
let title = entry.title;
@@ -792,18 +900,173 @@ function formatChangelogEntry(entry: ChangelogEntry): string {
}
}
+ // Apply blockquote highlighting if requested
+ if (entry.highlight) {
+ text = text
+ .split('\n')
+ .map(line => `> ${line}`)
+ .join('\n');
+ }
+
return text;
}
+/**
+ * Result of changelog generation, includes both the formatted changelog
+ * and the determined version bump type based on commit categories.
+ */
+export interface ChangelogResult {
+ /** The formatted changelog string */
+ changelog: string;
+ /** The highest version bump type found, or null if no commits matched categories with semver */
+ bumpType: BumpType | null;
+ /** Number of commits analyzed */
+ totalCommits: number;
+ /** Number of commits that matched a category with a semver field */
+ matchedCommitsWithSemver: number;
+}
+
+/**
+ * Raw changelog data before serialization to markdown.
+ * This intermediate representation allows manipulation of entries
+ * before final formatting.
+ */
+export interface RawChangelogData {
+ /** Categories with their PR entries, keyed by category title */
+ categories: Map;
+ /** Commits that didn't match any category */
+ leftovers: Commit[];
+ /** Release config for serialization */
+ releaseConfig: NormalizedReleaseConfig | null;
+}
+
+/**
+ * Statistics from changelog generation, used for auto-versioning.
+ */
+interface ChangelogStats {
+ /** The highest version bump type found */
+ bumpType: BumpType | null;
+ /** Number of commits analyzed */
+ totalCommits: number;
+ /** Number of commits that matched a category with a semver field */
+ matchedCommitsWithSemver: number;
+}
+
+/**
+ * Result from raw changelog generation, includes both data and stats.
+ */
+interface RawChangelogResult {
+ data: RawChangelogData;
+ stats: ChangelogStats;
+}
+
+// Memoization cache for generateChangesetFromGit
+// Caches promises to coalesce concurrent calls with the same arguments
+const changesetCache = new Map>();
+
+function getChangesetCacheKey(rev: string, maxLeftovers: number): string {
+ return `${rev}:${maxLeftovers}`;
+}
+
+/**
+ * Clears the memoization cache for generateChangesetFromGit.
+ * Primarily used for testing.
+ */
+export function clearChangesetCache(): void {
+ changesetCache.clear();
+}
+
export async function generateChangesetFromGit(
git: SimpleGit,
rev: string,
maxLeftovers: number = MAX_LEFTOVERS
-): Promise {
- const rawConfig = readReleaseConfig();
- const releaseConfig = normalizeReleaseConfig(rawConfig);
+): Promise {
+ const cacheKey = getChangesetCacheKey(rev, maxLeftovers);
+
+ // Return cached promise if available (coalesces concurrent calls)
+ const cached = changesetCache.get(cacheKey);
+ if (cached) {
+ return cached;
+ }
+
+ // Create and cache the promise
+ const promise = generateChangesetFromGitImpl(git, rev, maxLeftovers);
+ changesetCache.set(cacheKey, promise);
+
+ return promise;
+}
+
+/**
+ * Generates a changelog preview for a PR, showing how it will appear in the changelog.
+ * This function:
+ * 1. Fetches PR info from GitHub API (including base branch)
+ * 2. Fetches all commit/PR info up to base branch
+ * 3. Adds the current PR to the list with highlight flag
+ * 4. Runs categorization on the combined list
+ * 5. Serializes to markdown
+ *
+ * @param git Local git client
+ * @param rev Base revision (tag or SHA) to generate changelog from
+ * @param currentPRNumber PR number to fetch from GitHub and include (highlighted)
+ * @returns The changelog result with formatted markdown
+ */
+export async function generateChangelogWithHighlight(
+ git: SimpleGit,
+ rev: string,
+ currentPRNumber: number
+): Promise {
+ // Step 1: Fetch PR info from GitHub
+ const prInfo = await fetchPRInfo(currentPRNumber);
+
+ // Step 2: Fetch the base branch to get current state
+ await git.fetch('origin', prInfo.baseRef);
+ const baseRef = `origin/${prInfo.baseRef}`;
+ logger.debug(`Using PR base branch "${prInfo.baseRef}" for changelog`);
+
+ // Step 3: Fetch raw commit info up to base branch
+ const rawCommits = await fetchRawCommitInfo(git, rev, baseRef);
+
+ // Step 4: Add current PR to the list with highlight flag (at the beginning)
+ const currentPRCommit: RawCommitInfo = {
+ hash: '',
+ title: prInfo.title.trim(),
+ body: prInfo.body,
+ author: prInfo.author,
+ pr: String(prInfo.number),
+ prTitle: prInfo.title,
+ prBody: prInfo.body,
+ labels: prInfo.labels,
+ highlight: true,
+ };
+ const allCommits = [currentPRCommit, ...rawCommits];
+
+ // Step 5: Run categorization on combined list
+ const { data: rawData, stats } = categorizeCommits(allCommits);
- const gitCommits = (await getChangesSince(git, rev)).filter(
+ // Step 6: Serialize to markdown
+ const changelog = await serializeChangelog(rawData, MAX_LEFTOVERS);
+
+ return {
+ changelog,
+ ...stats,
+ };
+}
+
+/**
+ * Fetches raw commit/PR info from git history and GitHub.
+ * This is the first step - just gathering data, no categorization.
+ *
+ * @param git Local git client
+ * @param rev Base revision (tag or SHA) to start from
+ * @param until Optional end revision (defaults to HEAD)
+ * @returns Array of raw commit info
+ */
+async function fetchRawCommitInfo(
+ git: SimpleGit,
+ rev: string,
+ until?: string
+): Promise {
+ const gitCommits = (await getChangesSince(git, rev, until)).filter(
({ body }) => !body.includes(SKIP_CHANGELOG_MAGIC_WORD)
);
@@ -811,105 +1074,152 @@ export async function generateChangesetFromGit(
gitCommits.map(({ hash }) => hash)
);
- const categories = new Map();
- const commits: Record*hash*/ string, Commit> = {};
- const leftovers: Commit[] = [];
- const missing: Commit[] = [];
+ const result: RawCommitInfo[] = [];
for (const gitCommit of gitCommits) {
- const hash = gitCommit.hash;
+ const githubCommit = githubCommits[gitCommit.hash];
- const githubCommit = githubCommits[hash];
+ // Skip if PR body has skip marker
if (githubCommit?.prBody?.includes(SKIP_CHANGELOG_MAGIC_WORD)) {
continue;
}
- const labelsArray = githubCommit?.labels ?? [];
- const labels = new Set(labelsArray);
- const author = githubCommit?.author;
+ result.push({
+ hash: gitCommit.hash,
+ title: gitCommit.title,
+ body: gitCommit.body,
+ author: githubCommit?.author,
+ pr: githubCommit?.pr ?? gitCommit.pr ?? undefined,
+ prTitle: githubCommit?.prTitle ?? undefined,
+ prBody: githubCommit?.prBody ?? undefined,
+ labels: githubCommit?.labels ?? [],
+ });
+ }
+
+ return result;
+}
+
+/**
+ * Categorizes raw commits into changelog structure.
+ * This is the second step - grouping by category and scope.
+ *
+ * @param rawCommits Array of raw commit info to categorize
+ * @returns Categorized changelog data and stats
+ */
+function categorizeCommits(rawCommits: RawCommitInfo[]): RawChangelogResult {
+ const rawConfig = readReleaseConfig();
+ const releaseConfig = normalizeReleaseConfig(rawConfig);
+
+ const categories = new Map();
+ const leftovers: Commit[] = [];
+ const missing: RawCommitInfo[] = [];
+
+ // Track bump type for auto-versioning (lower priority value = higher bump)
+ let bumpPriority: number | null = null;
+ let matchedCommitsWithSemver = 0;
- if (shouldExcludePR(labels, author, releaseConfig)) {
+ for (const raw of rawCommits) {
+ const labels = new Set(raw.labels);
+
+ if (shouldExcludePR(labels, raw.author, releaseConfig)) {
continue;
}
// Use PR title if available, otherwise use commit title for pattern matching
- const titleForMatching = githubCommit?.prTitle ?? gitCommit.title;
- const categoryTitle = matchPRToCategory(
+ const titleForMatching = (raw.prTitle ?? raw.title).trim();
+ const matchedCategory = matchCommitToCategory(
labels,
- author,
+ raw.author,
titleForMatching,
releaseConfig
);
+ const categoryTitle = matchedCategory?.title ?? null;
+
+ // Track bump type if category has semver field
+ if (matchedCategory?.semver) {
+ const priority = BUMP_TYPES.get(matchedCategory.semver);
+ if (priority !== undefined) {
+ matchedCommitsWithSemver++;
+ bumpPriority = Math.min(bumpPriority ?? priority, priority);
+ }
+ }
- const commit: Commit = {
- author: author,
- hash: hash,
- title: gitCommit.title,
- body: gitCommit.body,
- hasPRinTitle: Boolean(gitCommit.pr),
- // Use GitHub PR number, falling back to locally parsed PR from title
- pr: githubCommit?.pr ?? gitCommit.pr ?? null,
- prTitle: githubCommit?.prTitle ?? null,
- prBody: githubCommit?.prBody ?? null,
- labels: labelsArray,
- category: categoryTitle,
- };
- commits[hash] = commit;
-
- if (!githubCommit) {
- missing.push(commit);
+ // Track commits not found on GitHub (for warning)
+ if (!raw.pr && raw.hash) {
+ missing.push(raw);
}
- if (!categoryTitle) {
- leftovers.push(commit);
+ if (!categoryTitle || !raw.pr) {
+ // No category match or no PR - goes to leftovers
+ leftovers.push({
+ author: raw.author,
+ hash: raw.hash,
+ title: raw.title,
+ body: raw.body,
+ hasPRinTitle: Boolean(raw.pr),
+ pr: raw.pr ?? null,
+ prTitle: raw.prTitle ?? null,
+ prBody: raw.prBody ?? null,
+ labels: raw.labels,
+ category: categoryTitle,
+ highlight: raw.highlight,
+ });
} else {
- if (!commit.pr) {
- leftovers.push(commit);
- } else {
- let category = categories.get(categoryTitle);
- if (!category) {
- category = {
- title: categoryTitle,
- scopeGroups: new Map(),
- };
- categories.set(categoryTitle, category);
- }
+ // Has category and PR - add to category
+ let category = categories.get(categoryTitle);
+ if (!category) {
+ category = {
+ title: categoryTitle,
+ scopeGroups: new Map(),
+ };
+ categories.set(categoryTitle, category);
+ }
- // Extract and normalize scope from PR title
- const prTitle = commit.prTitle ?? commit.title;
- const scope = extractScope(prTitle);
+ const prTitle = (raw.prTitle ?? raw.title).trim();
+ const scope = extractScope(prTitle);
- // Get or create the scope group
- let scopeGroup = category.scopeGroups.get(scope);
- if (!scopeGroup) {
- scopeGroup = [];
- category.scopeGroups.set(scope, scopeGroup);
- }
+ let scopeGroup = category.scopeGroups.get(scope);
+ if (!scopeGroup) {
+ scopeGroup = [];
+ category.scopeGroups.set(scope, scopeGroup);
+ }
- // Check for custom changelog entries in the PR body
- const customChangelogEntries = extractChangelogEntry(commit.prBody);
-
- if (customChangelogEntries) {
- // If there are multiple changelog entries, add each as a separate item
- for (const entry of customChangelogEntries) {
- scopeGroup.push({
- author: commit.author,
- number: commit.pr,
- hash: commit.hash,
- body: entry.nestedContent ?? '',
- title: entry.text,
- });
- }
- } else {
- // No custom entry, use PR title as before
+ // Check for custom changelog entries in the PR body
+ const customChangelogEntries = extractChangelogEntry(raw.prBody);
+
+ if (customChangelogEntries) {
+ // If there are multiple changelog entries, add each as a separate item
+ for (const entry of customChangelogEntries) {
scopeGroup.push({
- author: commit.author,
- number: commit.pr,
- hash: commit.hash,
- body: commit.prBody ?? '',
- title: prTitle,
+ author: raw.author,
+ number: raw.pr,
+ hash: raw.hash,
+ body: entry.nestedContent ?? '',
+ title: entry.text,
+ highlight: raw.highlight,
});
}
+ } else {
+ // No custom entry, use PR title as before
+ scopeGroup.push({
+ author: raw.author,
+ number: raw.pr,
+ hash: raw.hash,
+ body: raw.prBody ?? '',
+ title: prTitle,
+ highlight: raw.highlight,
+ });
+ }
+ }
+ }
+
+ // Convert priority back to bump type
+ let bumpType: BumpType | null = null;
+ if (bumpPriority !== null) {
+ for (const [type, priority] of BUMP_TYPES) {
+ if (priority === bumpPriority) {
+ bumpType = type;
+ break;
}
}
}
@@ -917,11 +1227,57 @@ export async function generateChangesetFromGit(
if (missing.length > 0) {
logger.warn(
'The following commits were not found on GitHub:',
- missing.map(commit => `${commit.hash.slice(0, 8)} ${commit.title}`)
+ missing.map(c => `${c.hash.slice(0, 8)} ${c.title}`)
);
}
- const changelogSections = [];
+ return {
+ data: {
+ categories,
+ leftovers,
+ releaseConfig,
+ },
+ stats: {
+ bumpType,
+ totalCommits: rawCommits.length,
+ matchedCommitsWithSemver,
+ },
+ };
+}
+
+/**
+ * Generates raw changelog data from git history.
+ * Convenience function that fetches commits and categorizes them.
+ *
+ * @param git Local git client
+ * @param rev Base revision (tag or SHA) to generate changelog from
+ * @param until Optional end revision (defaults to HEAD)
+ * @returns Raw changelog data structure
+ */
+async function generateRawChangelog(
+ git: SimpleGit,
+ rev: string,
+ until?: string
+): Promise {
+ const rawCommits = await fetchRawCommitInfo(git, rev, until);
+ return categorizeCommits(rawCommits);
+}
+
+/**
+ * Serializes raw changelog data to markdown format.
+ * Entries with `highlight: true` are rendered as blockquotes.
+ *
+ * @param rawData The raw changelog data to serialize
+ * @param maxLeftovers Maximum number of leftover entries to include
+ * @returns Formatted markdown changelog string
+ */
+async function serializeChangelog(
+ rawData: RawChangelogData,
+ maxLeftovers: number
+): Promise {
+ const { categories, leftovers, releaseConfig } = rawData;
+
+ const changelogSections: string[] = [];
const { repo, owner } = await getGlobalGitHubConfig();
const repoUrl = `https://github.com/${owner}/${repo}`;
@@ -936,7 +1292,7 @@ export async function generateChangesetFromGit(
// Sort categories by the order defined in release config
const categoryOrder =
- releaseConfig?.changelog.categories.map(c => c.title) ?? [];
+ releaseConfig?.changelog?.categories?.map(c => c.title) ?? [];
const sortedCategories = [...categories.entries()].sort((a, b) => {
const aIndex = categoryOrder.indexOf(a[1].title);
const bIndex = categoryOrder.indexOf(b[1].title);
@@ -968,17 +1324,15 @@ export async function generateChangesetFromGit(
return scopeA.localeCompare(scopeB);
});
- for (const [scope, prs] of sortedScopes) {
- // Add scope header if:
- // - scope grouping is enabled AND
- // - scope exists (not null) AND
- // - there's more than one entry in this scope (single entry headers aren't useful)
- if (scopeGroupingEnabled && scope !== null && prs.length > 1) {
- changelogSections.push(
- markdownHeader(SCOPE_HEADER_LEVEL, formatScopeTitle(scope))
- );
- }
+ // Check if any scope has multiple entries (would get a header)
+ const hasScopeHeaders = [...category.scopeGroups.entries()].some(
+ ([s, entries]) => s !== null && entries.length > 1
+ );
+
+ // Collect entries without headers to combine them into a single section
+ const entriesWithoutHeaders: string[] = [];
+ for (const [scope, prs] of sortedScopes) {
const prEntries = prs.map(pr =>
formatChangelogEntry({
title: pr.title,
@@ -987,10 +1341,35 @@ export async function generateChangesetFromGit(
hash: pr.hash,
body: pr.body,
repoUrl,
+ highlight: pr.highlight,
})
);
- changelogSections.push(prEntries.join('\n'));
+ // Determine scope header:
+ // - Scoped entries with multiple PRs get formatted scope title
+ // - Scopeless entries get "Other" header when other scope headers exist
+ // - Otherwise no header (entries collected for later)
+ let scopeHeader: string | null = null;
+ if (scopeGroupingEnabled) {
+ if (scope !== null && prs.length > 1) {
+ scopeHeader = formatScopeTitle(scope);
+ } else if (scope === null && hasScopeHeaders) {
+ scopeHeader = 'Other';
+ }
+ }
+
+ if (scopeHeader) {
+ changelogSections.push(markdownHeader(SCOPE_HEADER_LEVEL, scopeHeader));
+ changelogSections.push(prEntries.join('\n'));
+ } else {
+ // No header for this scope group - collect entries to combine later
+ entriesWithoutHeaders.push(...prEntries);
+ }
+ }
+
+ // Push all entries without headers as a single section to avoid extra newlines
+ if (entriesWithoutHeaders.length > 0) {
+ changelogSections.push(entriesWithoutHeaders.join('\n'));
}
}
@@ -1023,7 +1402,7 @@ export async function generateChangesetFromGit(
// No custom entry, use PR title or commit title as before
leftoverEntries.push(
formatChangelogEntry({
- title: commit.prTitle ?? commit.title,
+ title: (commit.prTitle ?? commit.title).trim(),
author: commit.author,
prNumber: commit.pr ?? undefined,
hash: commit.hash,
@@ -1034,6 +1413,7 @@ export async function generateChangesetFromGit(
: commit.body.includes(BODY_IN_CHANGELOG_MAGIC_WORD)
? commit.body
: undefined,
+ highlight: commit.highlight,
})
);
}
@@ -1047,6 +1427,24 @@ export async function generateChangesetFromGit(
return changelogSections.join('\n\n');
}
+/**
+ * Implementation of changelog generation that uses the new architecture.
+ * Generates raw data, then serializes to markdown.
+ */
+async function generateChangesetFromGitImpl(
+ git: SimpleGit,
+ rev: string,
+ maxLeftovers: number
+): Promise {
+ const { data: rawData, stats } = await generateRawChangelog(git, rev);
+ const changelog = await serializeChangelog(rawData, maxLeftovers);
+
+ return {
+ changelog,
+ ...stats,
+ };
+}
+
interface CommitInfo {
author: {
user?: { login: string };
@@ -1076,7 +1474,7 @@ interface CommitInfoResult {
repository: CommitInfoMap;
}
-async function getPRAndLabelsFromCommit(hashes: string[]): Promise<
+export async function getPRAndLabelsFromCommit(hashes: string[]): Promise<
Record<
/* hash */ string,
{
diff --git a/src/utils/git.ts b/src/utils/git.ts
index 35ff3470..e74273dc 100644
--- a/src/utils/git.ts
+++ b/src/utils/git.ts
@@ -55,10 +55,11 @@ export async function getLatestTag(git: SimpleGit): Promise {
export async function getChangesSince(
git: SimpleGit,
- rev: string
+ rev: string,
+ until?: string
): Promise {
const gitLogArgs: Options | LogOptions = {
- to: 'HEAD',
+ to: until || 'HEAD',
// The symmetric option defaults to true, giving us all the different commits
// reachable from both `from` and `to` whereas what we are interested in is only the ones
// reachable from `to` and _not_ from `from` so we get a "changelog" kind of list.
diff --git a/src/utils/helpers.ts b/src/utils/helpers.ts
index 6df104f7..da95e731 100644
--- a/src/utils/helpers.ts
+++ b/src/utils/helpers.ts
@@ -1,3 +1,5 @@
+import { appendFileSync } from 'fs';
+
import prompts from 'prompts';
import { logger, LogLevel, setLevel } from '../logger';
@@ -59,3 +61,26 @@ export async function promptConfirmation(): Promise {
export function hasInput(): boolean {
return !GLOBAL_FLAGS['no-input'];
}
+
+/**
+ * Sets a GitHub Actions output variable.
+ * Automatically uses heredoc-style delimiter syntax for multiline values.
+ * No-op when not running in GitHub Actions.
+ */
+export function setGitHubActionsOutput(name: string, value: string): void {
+ const outputFile = process.env.GITHUB_OUTPUT;
+ if (!outputFile) {
+ return;
+ }
+
+ if (value.includes('\n')) {
+ // Use heredoc-style delimiter for multiline values
+ const delimiter = `EOF_${Date.now()}_${Math.random().toString(36).slice(2)}`;
+ appendFileSync(
+ outputFile,
+ `${name}<<${delimiter}\n${value}\n${delimiter}\n`
+ );
+ } else {
+ appendFileSync(outputFile, `${name}=${value}\n`);
+ }
+}
diff --git a/src/utils/system.ts b/src/utils/system.ts
index 9c443c77..4ef1f10d 100644
--- a/src/utils/system.ts
+++ b/src/utils/system.ts
@@ -106,6 +106,8 @@ export interface SpawnProcessOptions {
showStdout?: boolean;
/** Force the process to run in dry-run mode */
enableInDryRunMode?: boolean;
+ /** Data to write to stdin (process will receive 'pipe' for stdin instead of 'inherit') */
+ stdin?: string;
}
/**
@@ -159,13 +161,23 @@ export async function spawnProcess(
replaceEnvVariable(arg, { ...process.env, ...options.env })
);
- // Allow child to accept input
- options.stdio = ['inherit', 'pipe', 'pipe'];
+ // Allow child to accept input (use 'pipe' for stdin if we need to write to it)
+ options.stdio = [
+ spawnProcessOptions.stdin !== undefined ? 'pipe' : 'inherit',
+ 'pipe',
+ 'pipe',
+ ];
child = spawn(command, processedArgs, options);
if (!child.stdout || !child.stderr) {
throw new Error('Invalid standard output or error for child process');
}
+
+ // Write stdin data if provided
+ if (spawnProcessOptions.stdin !== undefined && child.stdin) {
+ child.stdin.write(spawnProcessOptions.stdin);
+ child.stdin.end();
+ }
child.on('exit', code => (code === 0 ? succeed() : fail({ code })));
child.on('error', fail);
diff --git a/src/utils/workspaces.ts b/src/utils/workspaces.ts
new file mode 100644
index 00000000..b4fa5bfa
--- /dev/null
+++ b/src/utils/workspaces.ts
@@ -0,0 +1,422 @@
+import { readFileSync } from 'fs';
+import * as path from 'path';
+import { load } from 'js-yaml';
+import { glob } from 'glob';
+
+import { logger } from '../logger';
+
+/**
+ * Check if an error is a "file not found" error
+ */
+function isNotFoundError(err: unknown): boolean {
+ return err instanceof Error && 'code' in err && err.code === 'ENOENT';
+}
+
+/** Information about a workspace package */
+export interface WorkspacePackage {
+ /** The package name from package.json */
+ name: string;
+ /** Absolute path to the package directory */
+ location: string;
+ /** Whether the package is private */
+ private: boolean;
+ /** Whether the package has publishConfig.access set to 'public' */
+ hasPublicAccess: boolean;
+ /** Dependencies that are also workspace packages */
+ workspaceDependencies: string[];
+}
+
+/** Result of workspace discovery */
+export interface WorkspaceDiscoveryResult {
+ /** The type of workspace manager detected */
+ type: 'npm' | 'yarn' | 'pnpm' | 'none';
+ /** List of discovered packages */
+ packages: WorkspacePackage[];
+}
+
+/** Structure of pnpm-workspace.yaml */
+interface PnpmWorkspaceConfig {
+ packages?: string[];
+}
+
+/** Parsed package.json structure */
+interface PackageJson {
+ name?: string;
+ workspaces?: string[] | { packages?: string[] };
+ private?: boolean;
+ publishConfig?: {
+ access?: 'public' | 'restricted';
+ };
+ dependencies?: Record;
+ devDependencies?: Record;
+ peerDependencies?: Record;
+ optionalDependencies?: Record;
+}
+
+/**
+ * Read and parse a package.json file
+ */
+function readPackageJson(packagePath: string): PackageJson | null {
+ const packageJsonPath = path.join(packagePath, 'package.json');
+ try {
+ return JSON.parse(readFileSync(packageJsonPath, 'utf-8'));
+ } catch (err) {
+ if (!isNotFoundError(err)) {
+ logger.warn(`Failed to parse ${packageJsonPath}:`, err);
+ }
+ return null;
+ }
+}
+
+/**
+ * Get all dependency names from a package.json
+ * Includes dependencies, peerDependencies, and optionalDependencies
+ * (not devDependencies as those don't need to be published first)
+ */
+function getAllDependencyNames(packageJson: PackageJson): string[] {
+ const deps = new Set();
+
+ for (const dep of Object.keys(packageJson.dependencies || {})) {
+ deps.add(dep);
+ }
+ for (const dep of Object.keys(packageJson.peerDependencies || {})) {
+ deps.add(dep);
+ }
+ for (const dep of Object.keys(packageJson.optionalDependencies || {})) {
+ deps.add(dep);
+ }
+
+ return Array.from(deps);
+}
+
+/**
+ * Extract workspaces array from package.json workspaces field
+ * Handles both array format and object format with packages property
+ */
+function extractWorkspacesGlobs(
+ workspaces: string[] | { packages?: string[] } | undefined
+): string[] {
+ if (!workspaces) {
+ return [];
+ }
+ if (Array.isArray(workspaces)) {
+ return workspaces;
+ }
+ return workspaces.packages || [];
+}
+
+/**
+ * Resolve glob patterns to actual package directories
+ */
+async function resolveWorkspaceGlobs(
+ rootDir: string,
+ patterns: string[]
+): Promise {
+ // First: collect all workspace package names and locations
+ const workspaceLocations: Array<{ location: string; packageJson: PackageJson }> = [];
+ const workspaceNames = new Set();
+
+ for (const pattern of patterns) {
+ const matches = await glob(pattern, {
+ cwd: rootDir,
+ absolute: true,
+ ignore: ['**/node_modules/**'],
+ });
+
+ for (const match of matches) {
+ const packageJson = readPackageJson(match);
+ if (packageJson?.name) {
+ workspaceLocations.push({ location: match, packageJson });
+ workspaceNames.add(packageJson.name);
+ }
+ }
+ }
+
+ // Now resolve dependencies in a single pass, filtering against known workspace names
+ return workspaceLocations.map(({ location, packageJson }) => ({
+ name: packageJson.name as string,
+ location,
+ private: packageJson.private ?? false,
+ hasPublicAccess: packageJson.publishConfig?.access === 'public',
+ workspaceDependencies: getAllDependencyNames(packageJson).filter(dep =>
+ workspaceNames.has(dep)
+ ),
+ }));
+}
+
+/**
+ * Check if a file exists by trying to read it
+ */
+function fileExists(filePath: string): boolean {
+ try {
+ readFileSync(filePath);
+ return true;
+ } catch (err) {
+ return false;
+ }
+}
+
+/**
+ * Discover npm/yarn workspaces from package.json
+ */
+async function discoverNpmYarnWorkspaces(
+ rootDir: string
+): Promise {
+ const packageJson = readPackageJson(rootDir);
+ if (!packageJson) {
+ return null;
+ }
+
+ const workspacesGlobs = extractWorkspacesGlobs(packageJson.workspaces);
+ if (workspacesGlobs.length === 0) {
+ return null;
+ }
+
+ // Detect if it's yarn or npm based on lock files
+ const type = fileExists(path.join(rootDir, 'yarn.lock')) ? 'yarn' : 'npm';
+
+ const packages = await resolveWorkspaceGlobs(rootDir, workspacesGlobs);
+
+ logger.debug(
+ `Discovered ${
+ packages.length
+ } ${type} workspace packages from ${workspacesGlobs.join(', ')}`
+ );
+
+ return { type, packages };
+}
+
+/**
+ * Discover pnpm workspaces from pnpm-workspace.yaml
+ */
+async function discoverPnpmWorkspaces(
+ rootDir: string
+): Promise {
+ const pnpmWorkspacePath = path.join(rootDir, 'pnpm-workspace.yaml');
+
+ let config: PnpmWorkspaceConfig;
+ try {
+ const content = readFileSync(pnpmWorkspacePath, 'utf-8');
+ config = load(content) as PnpmWorkspaceConfig;
+ } catch (err) {
+ if (!isNotFoundError(err)) {
+ logger.warn(`Failed to parse ${pnpmWorkspacePath}:`, err);
+ }
+ return null;
+ }
+
+ const patterns = config.packages || [];
+ if (patterns.length === 0) {
+ return null;
+ }
+
+ const packages = await resolveWorkspaceGlobs(rootDir, patterns);
+
+ logger.debug(
+ `Discovered ${packages.length} pnpm workspace packages from ${patterns.join(
+ ', '
+ )}`
+ );
+
+ return { type: 'pnpm', packages };
+}
+
+/**
+ * Discover all workspace packages in a monorepo
+ *
+ * Supports:
+ * - npm workspaces (package.json "workspaces" field)
+ * - yarn workspaces (package.json "workspaces" field)
+ * - pnpm workspaces (pnpm-workspace.yaml)
+ *
+ * @param rootDir Root directory of the monorepo
+ * @returns Discovery result with type and packages, or null if not a workspace
+ */
+export async function discoverWorkspaces(
+ rootDir: string
+): Promise {
+ // Try pnpm first (more specific)
+ const pnpmResult = await discoverPnpmWorkspaces(rootDir);
+ if (pnpmResult) {
+ return pnpmResult;
+ }
+
+ // Try npm/yarn workspaces
+ const npmYarnResult = await discoverNpmYarnWorkspaces(rootDir);
+ if (npmYarnResult) {
+ return npmYarnResult;
+ }
+
+ // No workspaces found
+ return { type: 'none', packages: [] };
+}
+
+/**
+ * Convert a package name to an artifact filename pattern
+ *
+ * Default convention:
+ * - @sentry/browser -> sentry-browser-\d.*\.tgz
+ * - @sentry-internal/browser-utils -> sentry-internal-browser-utils-\d.*\.tgz
+ *
+ * @param packageName The npm package name
+ * @returns A regex pattern string to match the artifact
+ */
+export function packageNameToArtifactPattern(packageName: string): string {
+ // Remove @ prefix, replace / with -
+ const normalized = packageName.replace(/^@/, '').replace(/\//g, '-');
+ // Create a regex pattern that matches the artifact filename
+ return `/^${normalized}-\\d.*\\.tgz$/`;
+}
+
+/**
+ * Escape special regex characters in a string.
+ * Only escapes characters that have special meaning in regex.
+ */
+function escapeRegex(str: string): string {
+ return str.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&');
+}
+
+/**
+ * Convert a package name to an artifact filename using a template
+ *
+ * Template variables:
+ * - {{name}}: The package name (e.g., @sentry/browser)
+ * - {{simpleName}}: Simplified name (e.g., sentry-browser)
+ * - {{version}}: The version string
+ *
+ * @param packageName The npm package name
+ * @param template The artifact template string
+ * @param version Optional version to substitute
+ * @returns The artifact filename pattern
+ */
+export function packageNameToArtifactFromTemplate(
+ packageName: string,
+ template: string,
+ version = '\\d.*'
+): string {
+ const simpleName = packageName.replace(/^@/, '').replace(/\//g, '-');
+
+ // Use placeholders to preserve template markers during escaping
+ const NAME_PLACEHOLDER = '\x00NAME\x00';
+ const SIMPLE_PLACEHOLDER = '\x00SIMPLE\x00';
+ const VERSION_PLACEHOLDER = '\x00VERSION\x00';
+
+ // Replace template markers with placeholders
+ let result = template
+ .replace(/\{\{name\}\}/g, NAME_PLACEHOLDER)
+ .replace(/\{\{simpleName\}\}/g, SIMPLE_PLACEHOLDER)
+ .replace(/\{\{version\}\}/g, VERSION_PLACEHOLDER);
+
+ // Escape regex special characters in the template
+ result = escapeRegex(result);
+
+ // Replace placeholders with escaped values (or regex pattern for version)
+ // If version is the default regex pattern, use it as-is; otherwise escape it
+ const versionValue = version === '\\d.*' ? version : escapeRegex(version);
+ result = result
+ .replace(
+ new RegExp(escapeRegex(NAME_PLACEHOLDER), 'g'),
+ escapeRegex(packageName)
+ )
+ .replace(
+ new RegExp(escapeRegex(SIMPLE_PLACEHOLDER), 'g'),
+ escapeRegex(simpleName)
+ )
+ .replace(new RegExp(escapeRegex(VERSION_PLACEHOLDER), 'g'), versionValue);
+
+ return `/^${result}$/`;
+}
+
+/**
+ * Filter workspace packages based on include/exclude patterns
+ *
+ * @param packages List of workspace packages
+ * @param includePattern Optional regex pattern to include packages
+ * @param excludePattern Optional regex pattern to exclude packages
+ * @returns Filtered list of packages
+ */
+export function filterWorkspacePackages(
+ packages: WorkspacePackage[],
+ includePattern?: RegExp,
+ excludePattern?: RegExp
+): WorkspacePackage[] {
+ return packages.filter(pkg => {
+ // Check exclude pattern first
+ if (excludePattern && excludePattern.test(pkg.name)) {
+ return false;
+ }
+ // Check include pattern
+ if (includePattern && !includePattern.test(pkg.name)) {
+ return false;
+ }
+ return true;
+ });
+}
+
+/**
+ * Topologically sort workspace packages based on their dependencies.
+ * Packages with no dependencies come first, then packages that depend on them, etc.
+ *
+ * Computes depth for each package (depth = 1 + max depth of dependencies)
+ * and sorts by depth ascending.
+ *
+ * @param packages List of workspace packages
+ * @returns Sorted list of packages (dependencies before dependents)
+ * @throws Error if there's a circular dependency
+ */
+export function topologicalSortPackages(
+ packages: WorkspacePackage[]
+): WorkspacePackage[] {
+ // Map package name to its workspace dependencies
+ const depsMap = new Map();
+ for (const pkg of packages) {
+ depsMap.set(pkg.name, pkg.workspaceDependencies);
+ }
+
+ // Compute depth for each package using memoization
+ // Depth = 1 + max(depth of dependencies), or 0 if no dependencies
+ const depths = new Map();
+ const computing = new Set(); // Tracks recursion stack for cycle detection
+
+ function computeDepth(name: string): number {
+ const cached = depths.get(name);
+ if (cached !== undefined) {
+ return cached;
+ }
+
+ if (computing.has(name)) {
+ const cyclePackages = Array.from(computing);
+ throw new Error(
+ `Circular dependency detected among workspace packages: ${cyclePackages.join(', ')}`
+ );
+ }
+
+ computing.add(name);
+
+ let maxDepDepth = -1;
+ for (const dep of depsMap.get(name) || []) {
+ // Only consider dependencies that are in our package list
+ if (depsMap.has(dep)) {
+ maxDepDepth = Math.max(maxDepDepth, computeDepth(dep));
+ }
+ }
+
+ computing.delete(name);
+
+ const depth = maxDepDepth + 1;
+ depths.set(name, depth);
+ return depth;
+ }
+
+ // Compute depths for all packages
+ for (const name of depsMap.keys()) {
+ computeDepth(name);
+ }
+
+ // Sort by depth (packages with lower depth come first)
+ return [...packages].sort((a, b) => {
+ const depthA = depths.get(a.name) ?? 0;
+ const depthB = depths.get(b.name) ?? 0;
+ return depthA - depthB;
+ });
+}
diff --git a/yarn.lock b/yarn.lock
index 4d6bfcd3..e654e11e 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -985,6 +985,18 @@
dependencies:
"@isaacs/balanced-match" "^4.0.1"
+"@isaacs/cliui@^8.0.2":
+ version "8.0.2"
+ resolved "https://registry.yarnpkg.com/@isaacs/cliui/-/cliui-8.0.2.tgz#b37667b7bc181c168782259bab42474fbf52b550"
+ integrity sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==
+ dependencies:
+ string-width "^5.1.2"
+ string-width-cjs "npm:string-width@^4.2.0"
+ strip-ansi "^7.0.1"
+ strip-ansi-cjs "npm:strip-ansi@^6.0.1"
+ wrap-ansi "^8.1.0"
+ wrap-ansi-cjs "npm:wrap-ansi@^7.0.0"
+
"@istanbuljs/load-nyc-config@^1.0.0":
version "1.1.0"
resolved "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz"
@@ -2480,14 +2492,7 @@
resolved "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz"
integrity sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==
-"@types/yargs@^15.0.3":
- version "15.0.20"
- resolved "https://registry.npmjs.org/@types/yargs/-/yargs-15.0.20.tgz"
- integrity sha512-KIkX+/GgfFitlASYCGoSF+T4XRXhOubJLhkLVtSfsRTe9jWMmuM2g28zQ41BtPTG7TRBb2xHW+LCNVE9QR/vsg==
- dependencies:
- "@types/yargs-parser" "*"
-
-"@types/yargs@^17.0.8":
+"@types/yargs@^17", "@types/yargs@^17.0.8":
version "17.0.35"
resolved "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz"
integrity sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==
@@ -2656,7 +2661,7 @@ ansi-escapes@^4.2.1:
dependencies:
type-fest "^0.21.3"
-"ansi-regex@>=5.0.1 < 6.0.0", ansi-regex@^2.1.1, ansi-regex@^4.1.0, ansi-regex@^5.0.1:
+"ansi-regex@>=5.0.1 < 6.0.0", ansi-regex@^2.1.1, ansi-regex@^4.1.0, ansi-regex@^5.0.1, ansi-regex@^6.0.1:
version "5.0.1"
resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.1.tgz#082cb2c89c9fe8659a311a53bd6a4dc5301db304"
integrity sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==
@@ -2680,6 +2685,11 @@ ansi-styles@^5.0.0:
resolved "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz"
integrity sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==
+ansi-styles@^6.1.0, ansi-styles@^6.2.1:
+ version "6.2.3"
+ resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-6.2.3.tgz#c044d5dcc521a076413472597a1acb1f103c4041"
+ integrity sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==
+
any-promise@^1.0.0:
version "1.3.0"
resolved "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz"
@@ -2950,7 +2960,7 @@ callsites@^3.0.0:
resolved "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz"
integrity sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==
-camelcase@^5.0.0, camelcase@^5.3.1:
+camelcase@^5.3.1:
version "5.3.1"
resolved "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz"
integrity sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==
@@ -3043,15 +3053,6 @@ cli-table@0.3.1:
dependencies:
colors "1.0.3"
-cliui@^6.0.0:
- version "6.0.0"
- resolved "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz"
- integrity sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==
- dependencies:
- string-width "^4.2.0"
- strip-ansi "^6.0.0"
- wrap-ansi "^6.2.0"
-
cliui@^8.0.1:
version "8.0.1"
resolved "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz"
@@ -3061,6 +3062,15 @@ cliui@^8.0.1:
strip-ansi "^6.0.1"
wrap-ansi "^7.0.0"
+cliui@^9.0.1:
+ version "9.0.1"
+ resolved "https://registry.yarnpkg.com/cliui/-/cliui-9.0.1.tgz#6f7890f386f6f1f79953adc1f78dec46fcc2d291"
+ integrity sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==
+ dependencies:
+ string-width "^7.2.0"
+ strip-ansi "^7.1.0"
+ wrap-ansi "^9.0.0"
+
clone@^1.0.2:
version "1.0.4"
resolved "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz"
@@ -3159,7 +3169,7 @@ create-jest@^29.7.0:
jest-util "^29.7.0"
prompts "^2.0.1"
-cross-spawn@^7.0.2, cross-spawn@^7.0.3:
+cross-spawn@^7.0.2, cross-spawn@^7.0.3, cross-spawn@^7.0.6:
version "7.0.6"
resolved "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz"
integrity sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==
@@ -3195,11 +3205,6 @@ debug@^3.1.0:
dependencies:
ms "^2.1.1"
-decamelize@^1.2.0:
- version "1.2.0"
- resolved "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz"
- integrity sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==
-
dedent@^1.0.0:
version "1.7.0"
resolved "https://registry.npmjs.org/dedent/-/dedent-1.7.0.tgz"
@@ -3282,6 +3287,11 @@ duplexify@^4.0.0, duplexify@^4.1.1:
readable-stream "^3.1.1"
stream-shift "^1.0.2"
+eastasianwidth@^0.2.0:
+ version "0.2.0"
+ resolved "https://registry.yarnpkg.com/eastasianwidth/-/eastasianwidth-0.2.0.tgz#696ce2ec0aa0e6ea93a397ffcf24aa7840c827cb"
+ integrity sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==
+
ecdsa-sig-formatter@1.0.11, ecdsa-sig-formatter@^1.0.11:
version "1.0.11"
resolved "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz"
@@ -3299,11 +3309,21 @@ emittery@^0.13.1:
resolved "https://registry.npmjs.org/emittery/-/emittery-0.13.1.tgz"
integrity sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==
+emoji-regex@^10.3.0:
+ version "10.6.0"
+ resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-10.6.0.tgz#bf3d6e8f7f8fd22a65d9703475bc0147357a6b0d"
+ integrity sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==
+
emoji-regex@^8.0.0:
version "8.0.0"
resolved "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz"
integrity sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==
+emoji-regex@^9.2.2:
+ version "9.2.2"
+ resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-9.2.2.tgz#840c8803b0d8047f4ff0cf963176b32d4ef3ed72"
+ integrity sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==
+
end-of-stream@^1.1.0, end-of-stream@^1.4.1:
version "1.4.5"
resolved "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz"
@@ -3767,6 +3787,14 @@ flatted@^3.2.9:
resolved "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz"
integrity sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==
+foreground-child@^3.3.1:
+ version "3.3.1"
+ resolved "https://registry.yarnpkg.com/foreground-child/-/foreground-child-3.3.1.tgz#32e8e9ed1b68a3497befb9ac2b6adf92a638576f"
+ integrity sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==
+ dependencies:
+ cross-spawn "^7.0.6"
+ signal-exit "^4.0.1"
+
form-data@^4.0.4:
version "4.0.4"
resolved "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz"
@@ -3839,11 +3867,16 @@ gensync@^1.0.0-beta.2:
resolved "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz"
integrity sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==
-get-caller-file@^2.0.0, get-caller-file@^2.0.1, get-caller-file@^2.0.5:
+get-caller-file@^2.0.0, get-caller-file@^2.0.5:
version "2.0.5"
resolved "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz"
integrity sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==
+get-east-asian-width@^1.0.0:
+ version "1.4.0"
+ resolved "https://registry.yarnpkg.com/get-east-asian-width/-/get-east-asian-width-1.4.0.tgz#9bc4caa131702b4b61729cb7e42735bc550c9ee6"
+ integrity sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q==
+
get-intrinsic@^1.2.6, get-intrinsic@^1.3.0:
version "1.3.0"
resolved "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz"
@@ -3921,6 +3954,18 @@ glob@*:
minipass "^7.1.2"
path-scurry "^2.0.0"
+glob@^11.0.0:
+ version "11.1.0"
+ resolved "https://registry.yarnpkg.com/glob/-/glob-11.1.0.tgz#4f826576e4eb99c7dad383793d2f9f08f67e50a6"
+ integrity sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==
+ dependencies:
+ foreground-child "^3.3.1"
+ jackspeak "^4.1.1"
+ minimatch "^10.1.1"
+ minipass "^7.1.2"
+ package-json-from-dist "^1.0.0"
+ path-scurry "^2.0.0"
+
glob@^7.1.3, glob@^7.1.4:
version "7.2.3"
resolved "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz"
@@ -4308,6 +4353,13 @@ istanbul-reports@^3.1.3:
html-escaper "^2.0.0"
istanbul-lib-report "^3.0.0"
+jackspeak@^4.1.1:
+ version "4.1.1"
+ resolved "https://registry.yarnpkg.com/jackspeak/-/jackspeak-4.1.1.tgz#96876030f450502047fc7e8c7fcf8ce8124e43ae"
+ integrity sha512-zptv57P3GpL+O0I7VdMJNBZCu+BPHVQUk55Ft8/QCJjTVxrnJHuVuX/0Bl2A6/+2oyR/ZMEuFKwmzqqZ/U5nPQ==
+ dependencies:
+ "@isaacs/cliui" "^8.0.2"
+
jest-changed-files@^29.7.0:
version "29.7.0"
resolved "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-29.7.0.tgz"
@@ -4764,9 +4816,9 @@ json5@^2.2.3:
resolved "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz"
integrity sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==
-jwa@^2.0.0:
+jwa@^2.0.1:
version "2.0.1"
- resolved "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz"
+ resolved "https://registry.yarnpkg.com/jwa/-/jwa-2.0.1.tgz#bf8176d1ad0cd72e0f3f58338595a13e110bc804"
integrity sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==
dependencies:
buffer-equal-constant-time "^1.0.1"
@@ -4774,11 +4826,11 @@ jwa@^2.0.0:
safe-buffer "^5.0.1"
jws@^4.0.0:
- version "4.0.0"
- resolved "https://registry.npmjs.org/jws/-/jws-4.0.0.tgz"
- integrity sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg==
+ version "4.0.1"
+ resolved "https://registry.yarnpkg.com/jws/-/jws-4.0.1.tgz#07edc1be8fac20e677b283ece261498bd38f0690"
+ integrity sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==
dependencies:
- jwa "^2.0.0"
+ jwa "^2.0.1"
safe-buffer "^5.0.1"
keyv@^4.5.3:
@@ -5194,6 +5246,11 @@ p-try@^2.0.0:
resolved "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz"
integrity sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==
+package-json-from-dist@^1.0.0:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz#4f1471a010827a86f94cfd9b0727e36d267de505"
+ integrity sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==
+
parent-module@^1.0.0:
version "1.0.1"
resolved "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz"
@@ -5454,11 +5511,6 @@ require-in-the-middle@^8.0.0:
debug "^4.3.5"
module-details-from-path "^1.0.3"
-require-main-filename@^2.0.0:
- version "2.0.0"
- resolved "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz"
- integrity sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==
-
resolve-cwd@^3.0.0:
version "3.0.0"
resolved "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz"
@@ -5561,11 +5613,6 @@ semver@^7.2.1, semver@^7.3.7, semver@^7.5.3, semver@^7.5.4, semver@^7.7.3:
resolved "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz"
integrity sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==
-set-blocking@^2.0.0:
- version "2.0.0"
- resolved "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz"
- integrity sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==
-
set-value@>=2.0.1:
version "4.1.0"
resolved "https://registry.yarnpkg.com/set-value/-/set-value-4.1.0.tgz#aa433662d87081b75ad88a4743bd450f044e7d09"
@@ -5596,6 +5643,11 @@ signal-exit@^3.0.2, signal-exit@^3.0.3, signal-exit@^3.0.7:
resolved "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz"
integrity sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==
+signal-exit@^4.0.1:
+ version "4.1.0"
+ resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-4.1.0.tgz#952188c1cbd546070e2dd20d0f41c0ae0530cb04"
+ integrity sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==
+
simple-git@^3.6.0:
version "3.30.0"
resolved "https://registry.npmjs.org/simple-git/-/simple-git-3.30.0.tgz"
@@ -5697,6 +5749,15 @@ string-length@^4.0.1:
char-regex "^1.0.2"
strip-ansi "^6.0.0"
+"string-width-cjs@npm:string-width@^4.2.0":
+ version "4.2.3"
+ resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
+ integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
+ dependencies:
+ emoji-regex "^8.0.0"
+ is-fullwidth-code-point "^3.0.0"
+ strip-ansi "^6.0.1"
+
string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3:
version "4.2.3"
resolved "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz"
@@ -5706,6 +5767,24 @@ string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3:
is-fullwidth-code-point "^3.0.0"
strip-ansi "^6.0.1"
+string-width@^5.0.1, string-width@^5.1.2:
+ version "5.1.2"
+ resolved "https://registry.yarnpkg.com/string-width/-/string-width-5.1.2.tgz#14f8daec6d81e7221d2a357e668cab73bdbca794"
+ integrity sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==
+ dependencies:
+ eastasianwidth "^0.2.0"
+ emoji-regex "^9.2.2"
+ strip-ansi "^7.0.1"
+
+string-width@^7.0.0, string-width@^7.2.0:
+ version "7.2.0"
+ resolved "https://registry.yarnpkg.com/string-width/-/string-width-7.2.0.tgz#b5bb8e2165ce275d4d43476dd2700ad9091db6dc"
+ integrity sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==
+ dependencies:
+ emoji-regex "^10.3.0"
+ get-east-asian-width "^1.0.0"
+ strip-ansi "^7.1.0"
+
string_decoder@^1.1.1:
version "1.3.0"
resolved "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz"
@@ -5713,6 +5792,13 @@ string_decoder@^1.1.1:
dependencies:
safe-buffer "~5.2.0"
+"strip-ansi-cjs@npm:strip-ansi@^6.0.1":
+ version "6.0.1"
+ resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
+ integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
+ dependencies:
+ ansi-regex "^5.0.1"
+
strip-ansi@^5.2.0:
version "5.2.0"
resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz"
@@ -5727,6 +5813,13 @@ strip-ansi@^6.0.0, strip-ansi@^6.0.1:
dependencies:
ansi-regex "^5.0.1"
+strip-ansi@^7.0.1, strip-ansi@^7.1.0:
+ version "7.1.2"
+ resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.2.tgz#132875abde678c7ea8d691533f2e7e22bb744dba"
+ integrity sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==
+ dependencies:
+ ansi-regex "^6.0.1"
+
strip-bom@^4.0.0:
version "4.0.0"
resolved "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz"
@@ -6065,11 +6158,6 @@ whatwg-url@^5.0.0:
tr46 "~0.0.3"
webidl-conversions "^3.0.0"
-which-module@^2.0.0:
- version "2.0.1"
- resolved "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz"
- integrity sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==
-
which@^2.0.1:
version "2.0.2"
resolved "https://registry.npmjs.org/which/-/which-2.0.2.tgz"
@@ -6087,10 +6175,10 @@ wordwrap@^1.0.0:
resolved "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz"
integrity sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==
-wrap-ansi@^6.2.0:
- version "6.2.0"
- resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz"
- integrity sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==
+"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0":
+ version "7.0.0"
+ resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
+ integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
dependencies:
ansi-styles "^4.0.0"
string-width "^4.1.0"
@@ -6105,6 +6193,24 @@ wrap-ansi@^7.0.0:
string-width "^4.1.0"
strip-ansi "^6.0.0"
+wrap-ansi@^8.1.0:
+ version "8.1.0"
+ resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214"
+ integrity sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==
+ dependencies:
+ ansi-styles "^6.1.0"
+ string-width "^5.0.1"
+ strip-ansi "^7.0.1"
+
+wrap-ansi@^9.0.0:
+ version "9.0.2"
+ resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-9.0.2.tgz#956832dea9494306e6d209eb871643bb873d7c98"
+ integrity sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==
+ dependencies:
+ ansi-styles "^6.2.1"
+ string-width "^7.0.0"
+ strip-ansi "^7.1.0"
+
wrappy@1:
version "1.0.2"
resolved "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz"
@@ -6138,11 +6244,6 @@ xtend@^4.0.0:
resolved "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz"
integrity sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==
-y18n@^4.0.0:
- version "4.0.3"
- resolved "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz"
- integrity sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==
-
y18n@^5.0.5:
version "5.0.8"
resolved "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz"
@@ -6158,30 +6259,10 @@ yallist@^4.0.0:
resolved "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz"
integrity sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==
-yargs-parser@^18.1.2, yargs-parser@^21.1.1, yargs-parser@~18.1.3:
- version "18.1.3"
- resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-18.1.3.tgz#be68c4975c6b2abf469236b0c870362fab09a7b0"
- integrity sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==
- dependencies:
- camelcase "^5.0.0"
- decamelize "^1.2.0"
-
-yargs@15.4.1:
- version "15.4.1"
- resolved "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz"
- integrity sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==
- dependencies:
- cliui "^6.0.0"
- decamelize "^1.2.0"
- find-up "^4.1.0"
- get-caller-file "^2.0.1"
- require-directory "^2.1.1"
- require-main-filename "^2.0.0"
- set-blocking "^2.0.0"
- string-width "^4.2.0"
- which-module "^2.0.0"
- y18n "^4.0.0"
- yargs-parser "^18.1.2"
+yargs-parser@>=18.1.3, yargs-parser@^21.1.1, yargs-parser@^22.0.0:
+ version "22.0.0"
+ resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-22.0.0.tgz#87b82094051b0567717346ecd00fd14804b357c8"
+ integrity sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==
yargs@^17.3.1:
version "17.7.2"
@@ -6196,6 +6277,18 @@ yargs@^17.3.1:
y18n "^5.0.5"
yargs-parser "^21.1.1"
+yargs@^18:
+ version "18.0.0"
+ resolved "https://registry.yarnpkg.com/yargs/-/yargs-18.0.0.tgz#6c84259806273a746b09f579087b68a3c2d25bd1"
+ integrity sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==
+ dependencies:
+ cliui "^9.0.1"
+ escalade "^3.1.1"
+ get-caller-file "^2.0.5"
+ string-width "^7.2.0"
+ y18n "^5.0.5"
+ yargs-parser "^22.0.0"
+
yauzl@^2.10.0:
version "2.10.0"
resolved "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz"
From 281a1d6b5b6c6748e8714d6302394665b5fddd1f Mon Sep 17 00:00:00 2001
From: Burak Yigit Kaya
Date: Sat, 27 Dec 2025 01:14:00 +0300
Subject: [PATCH 05/15] prettier
---
docs/src/content/docs/configuration.md | 6 +-
src/utils/__tests__/changelog.test.ts | 121 ++++++++++++++++---------
2 files changed, 80 insertions(+), 47 deletions(-)
diff --git a/docs/src/content/docs/configuration.md b/docs/src/content/docs/configuration.md
index 18e6c4f2..10d4e65c 100644
--- a/docs/src/content/docs/configuration.md
+++ b/docs/src/content/docs/configuration.md
@@ -1,4 +1,4 @@
----
+t---
title: Configuration
description: Complete reference for .craft.yml configuration
---
@@ -149,7 +149,7 @@ of the PR title. If no such section is present, the PR title is used as usual.
```markdown
### Changelog Entry
-
+
- Add OAuth2 authentication
- Add two-factor authentication
- Add session management
@@ -160,7 +160,7 @@ of the PR title. If no such section is present, the PR title is used as usual.
```markdown
### Changelog Entry
-
+
- Add authentication system
- OAuth2 support
- Two-factor authentication
diff --git a/src/utils/__tests__/changelog.test.ts b/src/utils/__tests__/changelog.test.ts
index 856daacc..c5f84287 100644
--- a/src/utils/__tests__/changelog.test.ts
+++ b/src/utils/__tests__/changelog.test.ts
@@ -299,10 +299,12 @@ describe('generateChangesetFromGit', () => {
beforeEach(() => {
jest.resetAllMocks();
mockClient = jest.fn();
- (getGitHubClient as jest.MockedFunction<
- typeof getGitHubClient
- // @ts-ignore we only need to mock a subset
- >).mockReturnValue({ graphql: mockClient });
+ (
+ getGitHubClient as jest.MockedFunction<
+ typeof getGitHubClient
+ // @ts-ignore we only need to mock a subset
+ >
+ ).mockReturnValue({ graphql: mockClient });
// Default: no config file
getConfigFileDirMock.mockReturnValue(undefined);
getGlobalGitHubConfigMock.mockResolvedValue({
@@ -333,10 +335,7 @@ describe('generateChangesetFromGit', () => {
};
}
- function setup(
- commits: TestCommit[],
- releaseConfig?: string | null
- ): void {
+ function setup(commits: TestCommit[], releaseConfig?: string | null): void {
// Clear memoization cache to ensure fresh results
clearChangesetCache();
@@ -389,7 +388,10 @@ describe('generateChangesetFromGit', () => {
} else {
getConfigFileDirMock.mockReturnValue('/workspace');
readFileSyncMock.mockImplementation((path: any) => {
- if (typeof path === 'string' && path.includes('.github/release.yml')) {
+ if (
+ typeof path === 'string' &&
+ path.includes('.github/release.yml')
+ ) {
return releaseConfig;
}
const error: any = new Error('ENOENT');
@@ -1999,9 +2001,10 @@ describe('generateChangesetFromGit', () => {
// Find the next header (### or ####)
const restOfContent = markdown.slice(startIndex);
const nextHeaderMatch = restOfContent.match(/^#{3,4} /m);
- const endIndex = nextHeaderMatch && nextHeaderMatch.index !== undefined
- ? startIndex + nextHeaderMatch.index
- : markdown.length;
+ const endIndex =
+ nextHeaderMatch && nextHeaderMatch.index !== undefined
+ ? startIndex + nextHeaderMatch.index
+ : markdown.length;
return markdown.slice(startIndex, endIndex).trim();
}
@@ -2237,7 +2240,10 @@ describe('generateChangesetFromGit', () => {
expect(changes).not.toContain('#### Other');
// Verify no double newlines between entries (which would indicate separate sections)
- const featuresSection = getSectionContent(changes, /### New Features[^\n]*\n/);
+ const featuresSection = getSectionContent(
+ changes,
+ /### New Features[^\n]*\n/
+ );
expect(featuresSection).not.toBeNull();
// There should be no blank lines between the three entries
expect(featuresSection).not.toMatch(/\n\n-/);
@@ -2512,8 +2518,12 @@ describe('generateChangesetFromGit', () => {
changes,
/#### My Component\n/
);
- expect(myComponentSection).toContain('feat(my-component): feature with dashes 1');
- expect(myComponentSection).toContain('feat(my-component): feature with dashes 2');
+ expect(myComponentSection).toContain(
+ 'feat(my-component): feature with dashes 1'
+ );
+ expect(myComponentSection).toContain(
+ 'feat(my-component): feature with dashes 2'
+ );
expect(myComponentSection).not.toContain('feat(another_component)');
const anotherComponentSection = getSectionContent(
@@ -2720,10 +2730,17 @@ describe('generateChangesetFromGit', () => {
expect(myComponentMatches).toHaveLength(1);
// Both PRs should appear under the same scope section
- const myComponentSection = getSectionContent(changes, /#### My Component\n/);
+ const myComponentSection = getSectionContent(
+ changes,
+ /#### My Component\n/
+ );
expect(myComponentSection).not.toBeNull();
- expect(myComponentSection).toContain('feat(my-component): feature with dashes');
- expect(myComponentSection).toContain('feat(my_component): feature with underscores');
+ expect(myComponentSection).toContain(
+ 'feat(my-component): feature with dashes'
+ );
+ expect(myComponentSection).toContain(
+ 'feat(my_component): feature with underscores'
+ );
});
});
@@ -2958,7 +2975,9 @@ Add endpoint for data export`,
// Should still group by scope even with custom changelog entries
expect(changes).toContain('#### Api');
- expect(changes).toContain('Add powerful new endpoint for user management');
+ expect(changes).toContain(
+ 'Add powerful new endpoint for user management'
+ );
expect(changes).toContain('Add endpoint for data export');
});
@@ -3058,7 +3077,9 @@ Update all dependencies to their latest versions for improved security`,
// Should have 3 separate changelog entries from the same PR
expect(changes).toContain('Add OAuth2 authentication by @alice in [#1]');
- expect(changes).toContain('Add two-factor authentication by @alice in [#1]');
+ expect(changes).toContain(
+ 'Add two-factor authentication by @alice in [#1]'
+ );
expect(changes).toContain('Add session management by @alice in [#1]');
});
@@ -3096,7 +3117,7 @@ Update all dependencies to their latest versions for improved security`,
expect(changes).toContain('Add authentication by @alice in [#1]');
expect(changes).toContain(' OAuth2');
expect(changes).toContain(' 2FA');
-
+
// Second entry with nested content
expect(changes).toContain('Add user profiles by @alice in [#1]');
expect(changes).toContain(' Avatar upload');
@@ -3193,12 +3214,14 @@ Add a new function called \`foo\` which prints "Hello, world!"
### Issues
Closes #123`;
-
+
const result = extractChangelogEntry(prBody);
expect(result).toHaveLength(1);
expect(result).not.toBeNull();
if (result) {
- expect(result[0].text).toBe('Add a new function called `foo` which prints "Hello, world!"');
+ expect(result[0].text).toBe(
+ 'Add a new function called `foo` which prints "Hello, world!"'
+ );
expect(result[0].nestedContent).toBeUndefined();
}
});
@@ -3215,12 +3238,14 @@ Add a new function called \`foo\` which prints "Hello, world!"
## Issues
Closes #123`;
-
+
const result = extractChangelogEntry(prBody);
expect(result).toHaveLength(1);
expect(result).not.toBeNull();
if (result) {
- expect(result[0].text).toBe('Add a new function called `foo` which prints "Hello, world!"');
+ expect(result[0].text).toBe(
+ 'Add a new function called `foo` which prints "Hello, world!"'
+ );
}
});
@@ -3232,12 +3257,14 @@ This PR adds a new feature.
### Changelog Entry
This is the last section with no sections after it.`;
-
+
const result = extractChangelogEntry(prBody);
expect(result).toHaveLength(1);
expect(result).not.toBeNull();
if (result) {
- expect(result[0].text).toBe('This is the last section with no sections after it.');
+ expect(result[0].text).toBe(
+ 'This is the last section with no sections after it.'
+ );
}
});
@@ -3253,7 +3280,7 @@ Custom changelog text here
### Issues
Closes #123`;
-
+
const result = extractChangelogEntry(prBody);
expect(result).toHaveLength(1);
expect(result).not.toBeNull();
@@ -3276,7 +3303,7 @@ spans several lines.
### Issues
Closes #123`;
-
+
const result = extractChangelogEntry(prBody);
expect(result).toHaveLength(1);
expect(result).not.toBeNull();
@@ -3293,7 +3320,7 @@ Closes #123`;
- Add **bold** feature
- Add *italic* feature
- Add \`code\` feature`;
-
+
const result = extractChangelogEntry(prBody);
expect(result).toHaveLength(3);
expect(result).not.toBeNull();
@@ -3312,7 +3339,7 @@ This PR adds a new feature.
### Issues
Closes #123`;
-
+
expect(extractChangelogEntry(prBody)).toBeNull();
});
@@ -3334,17 +3361,17 @@ This PR adds a new feature.
### Issues
Closes #123`;
-
+
expect(extractChangelogEntry(prBody)).toBeNull();
});
it('should handle changelog entry with trailing/leading whitespace', () => {
const prBody = `### Changelog Entry
- This has leading whitespace
+ This has leading whitespace
### Issues`;
-
+
const result = extractChangelogEntry(prBody);
expect(result).toHaveLength(1);
expect(result).not.toBeNull();
@@ -3363,7 +3390,7 @@ This PR adds a new feature.
Custom changelog text
### Issues`;
-
+
const result = extractChangelogEntry(prBody);
expect(result).toHaveLength(1);
expect(result).not.toBeNull();
@@ -3380,7 +3407,7 @@ This PR adds Changelog Entry functionality.
### Issues
Closes #123`;
-
+
expect(extractChangelogEntry(prBody)).toBeNull();
});
@@ -3396,7 +3423,7 @@ This should not be included.
### More Sections
Neither should this.`;
-
+
const result = extractChangelogEntry(prBody);
expect(result).toHaveLength(1);
expect(result).not.toBeNull();
@@ -3412,7 +3439,7 @@ Neither should this.`;
- OAuth2 support
- Two-factor authentication
- Session management`;
-
+
const result = extractChangelogEntry(prBody);
expect(result).toHaveLength(1);
expect(result).not.toBeNull();
@@ -3434,13 +3461,15 @@ Neither should this.`;
- Avatar upload
- Bio editing
- Add settings panel`;
-
+
const result = extractChangelogEntry(prBody);
expect(result).toHaveLength(3);
expect(result).not.toBeNull();
if (result) {
expect(result[0].text).toBe('Add authentication system');
- expect(result[0].nestedContent).toBe(' OAuth2 support\n Two-factor authentication');
+ expect(result[0].nestedContent).toBe(
+ ' OAuth2 support\n Two-factor authentication'
+ );
expect(result[1].text).toBe('Add user profile page');
expect(result[1].nestedContent).toBe(' Avatar upload\n Bio editing');
expect(result[2].text).toBe('Add settings panel');
@@ -3455,12 +3484,14 @@ Comprehensive authentication system with the following features:
- OAuth2 support
- Two-factor authentication
- Session management`;
-
+
const result = extractChangelogEntry(prBody);
expect(result).toHaveLength(1);
expect(result).not.toBeNull();
if (result) {
- expect(result[0].text).toBe('Comprehensive authentication system with the following features:');
+ expect(result[0].text).toBe(
+ 'Comprehensive authentication system with the following features:'
+ );
expect(result[0].nestedContent).toBeDefined();
expect(result[0].nestedContent).toContain('OAuth2 support');
}
@@ -3481,7 +3512,7 @@ This should not be included.
This should also not be included.
Closes #123`;
-
+
const result = extractChangelogEntry(prBody);
expect(result).toHaveLength(2);
expect(result).not.toBeNull();
@@ -3489,7 +3520,9 @@ Closes #123`;
expect(result[0].text).toBe('Add feature A');
expect(result[1].text).toBe('Add feature B');
// Make sure content from other sections isn't included
- const allText = result.map(e => e.text + (e.nestedContent || '')).join('');
+ const allText = result
+ .map(e => e.text + (e.nestedContent || ''))
+ .join('');
expect(allText).not.toContain('This should not be included');
expect(allText).not.toContain('This should also not be included');
expect(allText).not.toContain('Closes #123');
From 746b4b4e4363cd805ae7d65d4aeb209c348043ed Mon Sep 17 00:00:00 2001
From: Burak Yigit Kaya
Date: Sat, 27 Dec 2025 01:28:39 +0300
Subject: [PATCH 06/15] fix: Only include PR body when magic word is present
- Fix TypeScript error in test mock with 'as any'
- Fix bug where all PR bodies were being included in changelog entries
- Body should only be included when #body-in-changelog magic word is present
---
src/utils/__tests__/changelog.test.ts | 2 +-
src/utils/changelog.ts | 12 ++++++------
2 files changed, 7 insertions(+), 7 deletions(-)
diff --git a/src/utils/__tests__/changelog.test.ts b/src/utils/__tests__/changelog.test.ts
index c5f84287..c6d59fd9 100644
--- a/src/utils/__tests__/changelog.test.ts
+++ b/src/utils/__tests__/changelog.test.ts
@@ -304,7 +304,7 @@ describe('generateChangesetFromGit', () => {
typeof getGitHubClient
// @ts-ignore we only need to mock a subset
>
- ).mockReturnValue({ graphql: mockClient });
+ ).mockReturnValue({ graphql: mockClient } as any);
// Default: no config file
getConfigFileDirMock.mockReturnValue(undefined);
getGlobalGitHubConfigMock.mockResolvedValue({
diff --git a/src/utils/changelog.ts b/src/utils/changelog.ts
index 52f40ab0..9a17789f 100644
--- a/src/utils/changelog.ts
+++ b/src/utils/changelog.ts
@@ -495,12 +495,12 @@ function createPREntriesFromRaw(
}));
}
- // For default entries, check prBody first, then fallbackBody for magic word
- let body = raw.prBody ?? '';
- if (fallbackBody && !raw.prBody?.includes(BODY_IN_CHANGELOG_MAGIC_WORD)) {
- if (fallbackBody.includes(BODY_IN_CHANGELOG_MAGIC_WORD)) {
- body = fallbackBody;
- }
+ // For default entries, only include body if it contains the magic word
+ let body = '';
+ if (raw.prBody?.includes(BODY_IN_CHANGELOG_MAGIC_WORD)) {
+ body = raw.prBody;
+ } else if (fallbackBody?.includes(BODY_IN_CHANGELOG_MAGIC_WORD)) {
+ body = fallbackBody;
}
return [
From f2078806dcb8a3d5372478865415f14fc2038096 Mon Sep 17 00:00:00 2001
From: Burak Yigit Kaya
Date: Sat, 27 Dec 2025 01:31:06 +0300
Subject: [PATCH 07/15] fix: Fix frontmatter typo in configuration.md
---
docs/src/content/docs/configuration.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/docs/src/content/docs/configuration.md b/docs/src/content/docs/configuration.md
index 10d4e65c..3624494d 100644
--- a/docs/src/content/docs/configuration.md
+++ b/docs/src/content/docs/configuration.md
@@ -1,4 +1,4 @@
-t---
+---
title: Configuration
description: Complete reference for .craft.yml configuration
---
From 244c652227c4cb6a04017b706d1b536ac92507bd Mon Sep 17 00:00:00 2001
From: Burak Yigit Kaya
Date: Sat, 27 Dec 2025 01:35:11 +0300
Subject: [PATCH 08/15] fix: Pass commit body as fallback for
#body-in-changelog magic word in categorized commits
Previously, categorized commits would not check the commit message body
for the #body-in-changelog magic word, only the PR body. This was
inconsistent with uncategorized commits (leftovers) which correctly
checked both. Now both code paths behave consistently.
---
src/utils/changelog.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/utils/changelog.ts b/src/utils/changelog.ts
index 9a17789f..5e1681ac 100644
--- a/src/utils/changelog.ts
+++ b/src/utils/changelog.ts
@@ -1303,7 +1303,7 @@ function categorizeCommits(rawCommits: RawCommitInfo[]): RawChangelogResult {
}
// Create PR entries (handles custom changelog entries if present)
- const prEntries = createPREntriesFromRaw(raw, prTitle);
+ const prEntries = createPREntriesFromRaw(raw, prTitle, raw.body);
scopeGroup.push(...prEntries);
}
}
From 2b5a7f27e415ada2e5c10af1f1628eabacb71649 Mon Sep 17 00:00:00 2001
From: Burak Yigit Kaya
Date: Sat, 27 Dec 2025 01:40:10 +0300
Subject: [PATCH 09/15] test: Add tests for body inclusion and magic word
fallback behavior
- Test that PR body is NOT included without magic word (both categorized and uncategorized)
- Test that PR body IS included with magic word
- Test that commit body is used as fallback for magic word in both categorized and uncategorized commits
- Test that PR body takes precedence over commit body when both have magic word
- Test consistency between categorized and uncategorized commits
---
src/utils/__tests__/changelog.test.ts | 343 ++++++++++++++++++++++++++
1 file changed, 343 insertions(+)
diff --git a/src/utils/__tests__/changelog.test.ts b/src/utils/__tests__/changelog.test.ts
index c6d59fd9..b9a06293 100644
--- a/src/utils/__tests__/changelog.test.ts
+++ b/src/utils/__tests__/changelog.test.ts
@@ -3872,3 +3872,346 @@ describe('getBumpTypeForPR', () => {
expect(getBumpTypeForPR(prInfo)).toBe('patch');
});
});
+
+describe('body inclusion behavior', () => {
+ let mockClient: jest.Mock;
+ const mockGetChangesSince = getChangesSince as jest.MockedFunction<
+ typeof getChangesSince
+ >;
+ const dummyGit = {} as SimpleGit;
+
+ interface TestCommit {
+ author?: string;
+ hash: string;
+ title: string;
+ body: string;
+ pr?: {
+ local?: string;
+ remote?: {
+ author?: { login: string };
+ number: string;
+ body?: string;
+ labels?: string[];
+ };
+ };
+ }
+
+ function setup(commits: TestCommit[], releaseConfigYaml: string | null) {
+ jest.resetAllMocks();
+ clearChangesetCache();
+ mockClient = jest.fn();
+ (
+ getGitHubClient as jest.MockedFunction<
+ typeof getGitHubClient
+ // @ts-ignore we only need to mock a subset
+ >
+ ).mockReturnValue({ graphql: mockClient } as any);
+ getGlobalGitHubConfigMock.mockResolvedValue({
+ repo: 'test-repo',
+ owner: 'test-owner',
+ });
+
+ // Mock getChangesSince
+ mockGetChangesSince.mockResolvedValueOnce(
+ commits.map(commit => ({
+ hash: commit.hash,
+ title: commit.title,
+ body: commit.body,
+ pr: commit.pr?.local || null,
+ }))
+ );
+
+ // Mock GitHub API response
+ mockClient.mockResolvedValueOnce({
+ repository: Object.fromEntries(
+ commits.map(({ hash, author, title, pr }: TestCommit) => [
+ `C${hash}`,
+ {
+ author: { user: author },
+ associatedPullRequests: {
+ nodes: pr?.remote
+ ? [
+ {
+ author: pr.remote.author,
+ number: pr.remote.number,
+ title: title,
+ body: pr.remote.body || '',
+ labels: {
+ nodes: (pr.remote.labels || []).map(label => ({
+ name: label,
+ })),
+ },
+ },
+ ]
+ : [],
+ },
+ },
+ ])
+ ),
+ });
+
+ // Mock release config
+ if (releaseConfigYaml === null) {
+ getConfigFileDirMock.mockReturnValue(undefined);
+ readFileSyncMock.mockImplementation(() => {
+ const error: any = new Error('ENOENT');
+ error.code = 'ENOENT';
+ throw error;
+ });
+ } else {
+ getConfigFileDirMock.mockReturnValue('/workspace');
+ readFileSyncMock.mockImplementation((path: any) => {
+ if (
+ typeof path === 'string' &&
+ path.includes('.github/release.yml')
+ ) {
+ return releaseConfigYaml;
+ }
+ const error: any = new Error('ENOENT');
+ error.code = 'ENOENT';
+ throw error;
+ });
+ }
+ }
+
+ it('should NOT include PR body without magic word in categorized commits', async () => {
+ const releaseConfig = `changelog:
+ categories:
+ - title: Features
+ labels:
+ - feature`;
+
+ setup(
+ [
+ {
+ hash: 'abc123',
+ title: 'Add new feature (#1)',
+ body: '',
+ pr: {
+ local: '1',
+ remote: {
+ number: '1',
+ author: { login: 'alice' },
+ body: 'This is a PR body WITHOUT the magic word.',
+ labels: ['feature'],
+ },
+ },
+ },
+ ],
+ releaseConfig
+ );
+
+ const result = await generateChangesetFromGit(dummyGit, '1.0.0', 3);
+ // Body should NOT appear in output (no indented line after the title)
+ expect(result.changelog).not.toContain(
+ 'This is a PR body WITHOUT the magic word'
+ );
+ expect(result.changelog).toContain('Add new feature');
+ });
+
+ it('should include PR body WITH magic word in categorized commits', async () => {
+ const releaseConfig = `changelog:
+ categories:
+ - title: Features
+ labels:
+ - feature`;
+
+ setup(
+ [
+ {
+ hash: 'abc123',
+ title: 'Add new feature (#1)',
+ body: '',
+ pr: {
+ local: '1',
+ remote: {
+ number: '1',
+ author: { login: 'alice' },
+ body: `This is important context. ${BODY_IN_CHANGELOG_MAGIC_WORD}`,
+ labels: ['feature'],
+ },
+ },
+ },
+ ],
+ releaseConfig
+ );
+
+ const result = await generateChangesetFromGit(dummyGit, '1.0.0', 3);
+ expect(result.changelog).toContain('This is important context.');
+ });
+
+ it('should NOT include PR body without magic word in uncategorized commits', async () => {
+ const releaseConfig = `changelog:
+ categories:
+ - title: Features
+ labels:
+ - feature`;
+
+ setup(
+ [
+ {
+ hash: 'abc123',
+ title: 'Some uncategorized change (#1)',
+ body: '',
+ pr: {
+ local: '1',
+ remote: {
+ number: '1',
+ author: { login: 'alice' },
+ body: 'This PR body should NOT appear.',
+ labels: [], // No matching labels -> goes to "Other"
+ },
+ },
+ },
+ ],
+ releaseConfig
+ );
+
+ const result = await generateChangesetFromGit(dummyGit, '1.0.0', 3);
+ expect(result.changelog).not.toContain('This PR body should NOT appear');
+ expect(result.changelog).toContain('Some uncategorized change');
+ });
+
+ it('should use commit body as fallback for magic word in categorized commits', async () => {
+ const releaseConfig = `changelog:
+ categories:
+ - title: Features
+ labels:
+ - feature`;
+
+ setup(
+ [
+ {
+ hash: 'abc123',
+ title: 'Add new feature (#1)',
+ body: `Important commit context ${BODY_IN_CHANGELOG_MAGIC_WORD}`,
+ pr: {
+ local: '1',
+ remote: {
+ number: '1',
+ author: { login: 'alice' },
+ body: 'PR body without magic word',
+ labels: ['feature'],
+ },
+ },
+ },
+ ],
+ releaseConfig
+ );
+
+ const result = await generateChangesetFromGit(dummyGit, '1.0.0', 3);
+ // The commit body with magic word should be included as fallback
+ expect(result.changelog).toContain('Important commit context');
+ });
+
+ it('should use commit body as fallback for magic word in uncategorized commits', async () => {
+ const releaseConfig = `changelog:
+ categories:
+ - title: Features
+ labels:
+ - feature`;
+
+ setup(
+ [
+ {
+ hash: 'abc123',
+ title: 'Some change (#1)',
+ body: `Commit body with context ${BODY_IN_CHANGELOG_MAGIC_WORD}`,
+ pr: {
+ local: '1',
+ remote: {
+ number: '1',
+ author: { login: 'alice' },
+ body: 'PR body without magic word',
+ labels: [], // Goes to "Other"
+ },
+ },
+ },
+ ],
+ releaseConfig
+ );
+
+ const result = await generateChangesetFromGit(dummyGit, '1.0.0', 3);
+ expect(result.changelog).toContain('Commit body with context');
+ });
+
+ it('should prefer PR body over commit body when both have magic word', async () => {
+ const releaseConfig = `changelog:
+ categories:
+ - title: Features
+ labels:
+ - feature`;
+
+ setup(
+ [
+ {
+ hash: 'abc123',
+ title: 'Add new feature (#1)',
+ body: `Commit body content ${BODY_IN_CHANGELOG_MAGIC_WORD}`,
+ pr: {
+ local: '1',
+ remote: {
+ number: '1',
+ author: { login: 'alice' },
+ body: `PR body content ${BODY_IN_CHANGELOG_MAGIC_WORD}`,
+ labels: ['feature'],
+ },
+ },
+ },
+ ],
+ releaseConfig
+ );
+
+ const result = await generateChangesetFromGit(dummyGit, '1.0.0', 3);
+ // PR body should take precedence
+ expect(result.changelog).toContain('PR body content');
+ expect(result.changelog).not.toContain('Commit body content');
+ });
+
+ it('should behave consistently between categorized and uncategorized commits with commit body magic word', async () => {
+ const releaseConfig = `changelog:
+ categories:
+ - title: Features
+ labels:
+ - feature`;
+
+ setup(
+ [
+ {
+ hash: 'abc123',
+ title: 'Categorized change (#1)',
+ body: `Categorized commit body ${BODY_IN_CHANGELOG_MAGIC_WORD}`,
+ pr: {
+ local: '1',
+ remote: {
+ number: '1',
+ author: { login: 'alice' },
+ body: 'PR body no magic',
+ labels: ['feature'],
+ },
+ },
+ },
+ {
+ hash: 'def456',
+ title: 'Uncategorized change (#2)',
+ body: `Uncategorized commit body ${BODY_IN_CHANGELOG_MAGIC_WORD}`,
+ pr: {
+ local: '2',
+ remote: {
+ number: '2',
+ author: { login: 'bob' },
+ body: 'PR body no magic',
+ labels: [], // Goes to "Other"
+ },
+ },
+ },
+ ],
+ releaseConfig
+ );
+
+ const result = await generateChangesetFromGit(dummyGit, '1.0.0', 3);
+ // BOTH should include their commit body since the magic word is present
+ expect(result.changelog).toContain('Categorized commit body');
+ expect(result.changelog).toContain('Uncategorized commit body');
+ });
+});
From 38932f7a44a3ff97163e140216b3792c6d479732 Mon Sep 17 00:00:00 2001
From: Burak Yigit Kaya
Date: Sat, 27 Dec 2025 01:46:28 +0300
Subject: [PATCH 10/15] fix: Join multi-line plain text changelog entries to
avoid broken markdown
When a Changelog Entry section contains plain text (no bullets), multi-line
content is now joined with spaces to produce valid markdown output.
Previously, newlines were preserved which resulted in broken output like:
- This is a multi-line
changelog entry that
spans several lines. by @user in [#1](url)
Now produces:
- This is a multi-line changelog entry that spans several lines. by @user in [#1](url)
Also adds regression test for this case.
---
docs/src/content/docs/configuration.md | 3 +-
src/utils/__tests__/changelog.test.ts | 42 ++++++++++++++++++++++++--
src/utils/changelog.ts | 9 +++++-
3 files changed, 50 insertions(+), 4 deletions(-)
diff --git a/docs/src/content/docs/configuration.md b/docs/src/content/docs/configuration.md
index 3624494d..f4710fc0 100644
--- a/docs/src/content/docs/configuration.md
+++ b/docs/src/content/docs/configuration.md
@@ -176,7 +176,8 @@ of the PR title. If no such section is present, the PR title is used as usual.
```
3. **Plain Text**: If no bullets are used, the entire content is treated as a
- single changelog entry. Multi-line text is supported.
+ single changelog entry. Multi-line text is automatically joined with spaces
+ to ensure valid markdown output.
4. **Content Isolation**: Only content within the "Changelog Entry" section is
included in the changelog. Other sections (Description, Issues, etc.) are
diff --git a/src/utils/__tests__/changelog.test.ts b/src/utils/__tests__/changelog.test.ts
index b9a06293..b80e12ad 100644
--- a/src/utils/__tests__/changelog.test.ts
+++ b/src/utils/__tests__/changelog.test.ts
@@ -3154,6 +3154,43 @@ Closes #123`,
// Should fall back to PR title when changelog entry is empty
expect(changes).toContain('feat: Add feature');
});
+
+ it('should handle multi-line plain text as single-line entry to avoid broken markdown', async () => {
+ setup(
+ [
+ {
+ hash: 'abc123',
+ title: 'chore: Update dependencies',
+ body: '',
+ pr: {
+ remote: {
+ number: '1',
+ author: { login: 'alice' },
+ body: `### Changelog Entry
+
+This is a multi-line
+changelog entry that
+spans several lines.`,
+ labels: [],
+ },
+ },
+ },
+ ],
+ null
+ );
+
+ const result = await generateChangesetFromGit(dummyGit, '1.0.0', 10);
+ const changes = result.changelog;
+
+ // The entry should start with "- " and have author/link on the same line
+ // It should NOT have broken formatting like:
+ // - This is a multi-line
+ // changelog entry that
+ // spans several lines. by @alice in [#1](...)
+ expect(changes).toMatch(/^- .+by @alice in \[#1\]/m);
+ // The title should be joined or formatted properly
+ expect(changes).not.toContain('\nchangelog entry that');
+ });
});
});
@@ -3289,7 +3326,7 @@ Closes #123`;
}
});
- it('should handle multiple lines in plain text as single entry', () => {
+ it('should handle multiple lines in plain text as single entry joined with spaces', () => {
const prBody = `### Description
Description here
@@ -3308,8 +3345,9 @@ Closes #123`;
expect(result).toHaveLength(1);
expect(result).not.toBeNull();
if (result) {
+ // Multi-line plain text is joined with spaces to avoid broken markdown
expect(result[0].text).toBe(
- 'This is a multi-line\nchangelog entry that\nspans several lines.'
+ 'This is a multi-line changelog entry that spans several lines.'
);
}
});
diff --git a/src/utils/changelog.ts b/src/utils/changelog.ts
index 5e1681ac..209580cc 100644
--- a/src/utils/changelog.ts
+++ b/src/utils/changelog.ts
@@ -228,9 +228,16 @@ function parseChangelogContent(content: string): ChangelogEntryItem[] {
const hasIndentedBullets = /^(\s{4,}|\t+)[-*+]\s+/m.test(content);
// If no bullets found at all, treat entire content as a single entry
+ // Join multiple lines with spaces to avoid broken markdown
if (!hasTopLevelBullets && !hasIndentedBullets) {
+ // Join lines with spaces, collapsing multiple whitespace
+ const singleLine = content
+ .split('\n')
+ .map(line => line.trim())
+ .filter(line => line.length > 0)
+ .join(' ');
return [{
- text: content.trim(),
+ text: singleLine,
}];
}
From 501cc585d690f617774edcae71bae42b8bd3ac47 Mon Sep 17 00:00:00 2001
From: Burak Yigit Kaya
Date: Sat, 27 Dec 2025 01:58:21 +0300
Subject: [PATCH 11/15] fix: Normalize CRLF line endings when parsing changelog
entries
PR descriptions from GitHub may have Windows line endings (CRLF).
The regex patterns using $ to match end-of-line don't match before
\r characters, causing bullet points to not be recognized as
separate entries.
This fix normalizes all line endings to LF before parsing.
---
src/utils/__tests__/changelog.test.ts | 21 +++++++++++++++++++++
src/utils/changelog.ts | 7 +++++--
2 files changed, 26 insertions(+), 2 deletions(-)
diff --git a/src/utils/__tests__/changelog.test.ts b/src/utils/__tests__/changelog.test.ts
index b80e12ad..aa3220c6 100644
--- a/src/utils/__tests__/changelog.test.ts
+++ b/src/utils/__tests__/changelog.test.ts
@@ -3369,6 +3369,27 @@ Closes #123`;
}
});
+ it('should handle CRLF line endings (Windows)', () => {
+ // Simulate Windows line endings from GitHub PR body
+ const prBody =
+ '## Changelog Entry\r\n' +
+ '\r\n' +
+ ' - Add first feature\r\n' +
+ ' - Add second feature\r\n' +
+ ' - Add third feature\r\n' +
+ '\r\n' +
+ '## Description\r\n';
+
+ const result = extractChangelogEntry(prBody);
+ expect(result).toHaveLength(3);
+ expect(result).not.toBeNull();
+ if (result) {
+ expect(result[0].text).toBe('Add first feature');
+ expect(result[1].text).toBe('Add second feature');
+ expect(result[2].text).toBe('Add third feature');
+ }
+ });
+
it('should return null when no changelog entry section exists', () => {
const prBody = `### Description
diff --git a/src/utils/changelog.ts b/src/utils/changelog.ts
index 209580cc..0aa20d77 100644
--- a/src/utils/changelog.ts
+++ b/src/utils/changelog.ts
@@ -189,10 +189,13 @@ export function extractChangelogEntry(prBody: string | null | undefined): Change
return null;
}
+ // Normalize line endings (CRLF -> LF) to ensure consistent regex matching
+ const normalizedBody = prBody.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
+
// Match markdown headings (## or ###) followed by "Changelog Entry" (case-insensitive)
// This matches both with and without the # at the end (e.g., "## Changelog Entry ##" or "## Changelog Entry")
const headerRegex = /^#{2,3}\s+Changelog Entry\s*(?:#{2,3})?\s*$/im;
- const match = prBody.match(headerRegex);
+ const match = normalizedBody.match(headerRegex);
if (!match || match.index === undefined) {
return null;
@@ -200,7 +203,7 @@ export function extractChangelogEntry(prBody: string | null | undefined): Change
// Find the start of the content (after the heading line)
const startIndex = match.index + match[0].length;
- const restOfBody = prBody.slice(startIndex);
+ const restOfBody = normalizedBody.slice(startIndex);
// Find the next heading of level 2 or 3 (## or ###)
const nextHeaderMatch = restOfBody.match(/^#{2,3}\s+/m);
From 28121acfcceeb1748370178cd7a1fd4e129f4dfe Mon Sep 17 00:00:00 2001
From: Burak Yigit Kaya
Date: Sat, 27 Dec 2025 02:19:53 +0300
Subject: [PATCH 12/15] fix: Preserve bullet markers for nested items in
changelog entries
Previously, nested bullets like:
- Add feature
- Sub-item 1
- Sub-item 2
Would render as:
- Add feature by @user in [#1]
Sub-item 1
Sub-item 2
Now correctly renders as:
- Add feature by @user in [#1]
- Sub-item 1
- Sub-item 2
Nested items do NOT get author/PR attribution - only the top-level entry does.
---
docs/src/content/docs/configuration.md | 2 ++
src/utils/__tests__/changelog.test.ts | 27 ++++++++++++++------------
src/utils/changelog.ts | 3 ++-
3 files changed, 19 insertions(+), 13 deletions(-)
diff --git a/docs/src/content/docs/configuration.md b/docs/src/content/docs/configuration.md
index f4710fc0..8f69e2b2 100644
--- a/docs/src/content/docs/configuration.md
+++ b/docs/src/content/docs/configuration.md
@@ -175,6 +175,8 @@ of the PR title. If no such section is present, the PR title is used as usual.
- Session management
```
+ Note: Nested items do NOT get author/PR attribution - only the top-level entry does.
+
3. **Plain Text**: If no bullets are used, the entire content is treated as a
single changelog entry. Multi-line text is automatically joined with spaces
to ensure valid markdown output.
diff --git a/src/utils/__tests__/changelog.test.ts b/src/utils/__tests__/changelog.test.ts
index aa3220c6..5c7a8457 100644
--- a/src/utils/__tests__/changelog.test.ts
+++ b/src/utils/__tests__/changelog.test.ts
@@ -3043,9 +3043,10 @@ Update all dependencies to their latest versions for improved security`,
expect(changes).toContain(
'Add comprehensive user authentication system by @alice in [#1]'
);
- expect(changes).toContain(' OAuth2 support');
- expect(changes).toContain(' Two-factor authentication');
- expect(changes).toContain(' Session management');
+ // Nested bullets preserve the bullet marker
+ expect(changes).toContain(' - OAuth2 support');
+ expect(changes).toContain(' - Two-factor authentication');
+ expect(changes).toContain(' - Session management');
});
it('should create multiple changelog entries from multiple bullets in PR', async () => {
@@ -3113,15 +3114,15 @@ Update all dependencies to their latest versions for improved security`,
const result = await generateChangesetFromGit(dummyGit, '1.0.0', 10);
const changes = result.changelog;
- // First entry with nested content
+ // First entry with nested content (bullets preserved)
expect(changes).toContain('Add authentication by @alice in [#1]');
- expect(changes).toContain(' OAuth2');
- expect(changes).toContain(' 2FA');
+ expect(changes).toContain(' - OAuth2');
+ expect(changes).toContain(' - 2FA');
- // Second entry with nested content
+ // Second entry with nested content (bullets preserved)
expect(changes).toContain('Add user profiles by @alice in [#1]');
- expect(changes).toContain(' Avatar upload');
- expect(changes).toContain(' Bio editing');
+ expect(changes).toContain(' - Avatar upload');
+ expect(changes).toContain(' - Bio editing');
});
it('should ignore empty changelog entry sections', async () => {
@@ -3504,8 +3505,9 @@ Neither should this.`;
expect(result).not.toBeNull();
if (result) {
expect(result[0].text).toBe('Add authentication system');
+ // Nested bullets preserve the bullet marker
expect(result[0].nestedContent).toBe(
- ' OAuth2 support\n Two-factor authentication\n Session management'
+ ' - OAuth2 support\n - Two-factor authentication\n - Session management'
);
}
});
@@ -3526,11 +3528,12 @@ Neither should this.`;
expect(result).not.toBeNull();
if (result) {
expect(result[0].text).toBe('Add authentication system');
+ // Nested bullets preserve the bullet marker
expect(result[0].nestedContent).toBe(
- ' OAuth2 support\n Two-factor authentication'
+ ' - OAuth2 support\n - Two-factor authentication'
);
expect(result[1].text).toBe('Add user profile page');
- expect(result[1].nestedContent).toBe(' Avatar upload\n Bio editing');
+ expect(result[1].nestedContent).toBe(' - Avatar upload\n - Bio editing');
expect(result[2].text).toBe('Add settings panel');
expect(result[2].nestedContent).toBeUndefined();
}
diff --git a/src/utils/changelog.ts b/src/utils/changelog.ts
index 0aa20d77..9f902b16 100644
--- a/src/utils/changelog.ts
+++ b/src/utils/changelog.ts
@@ -271,7 +271,8 @@ function parseChangelogContent(content: string): ChangelogEntryItem[] {
// Check if this is a nested bullet (more than 3 spaces of indentation, or tab)
const nestedMatch = line.match(/^(\s{4,}|\t+)[-*+]\s+(.+)$/);
if (nestedMatch) {
- nestedLines.push(` ${nestedMatch[2].trim()}`);
+ // Preserve the bullet marker for nested items
+ nestedLines.push(` - ${nestedMatch[2].trim()}`);
} else if (line.trim()) {
// Non-empty line that's not a bullet - could be continuation text or nested content
// Add to nested content if it has any indentation or follows other nested content
From 79770fed80d2da3535da37d718fbd4cf2dd09223 Mon Sep 17 00:00:00 2001
From: Burak Yigit Kaya
Date: Sat, 27 Dec 2025 02:40:01 +0300
Subject: [PATCH 13/15] refactor: replace regex-based changelog parser with
marked
- Use marked library for proper markdown parsing instead of complex regex
- Correctly handles CRLF line endings, nested lists, and edge cases
- No dependencies (marked has zero dependencies, ~430KB unpacked)
- Added Jest moduleNameMapper to use UMD build for compatibility
- Updated tests to reflect correct markdown behavior (indented bullets
after a paragraph without blank line are paragraph text, not a list)
---
jest.config.js | 3 +
package.json | 3 +
src/utils/__tests__/changelog.test.ts | 37 ++++-
src/utils/changelog.ts | 198 +++++++++++++-------------
yarn.lock | 5 +
5 files changed, 140 insertions(+), 106 deletions(-)
diff --git a/jest.config.js b/jest.config.js
index e31d8458..bb07a7a5 100644
--- a/jest.config.js
+++ b/jest.config.js
@@ -6,4 +6,7 @@ module.exports = {
transformIgnorePatterns: [
'node_modules/(?!(dot-prop|configstore)/)',
],
+ moduleNameMapper: {
+ '^marked$': '/node_modules/marked/lib/marked.umd.js',
+ },
};
diff --git a/package.json b/package.json
index 16eda59d..25bce0df 100644
--- a/package.json
+++ b/package.json
@@ -106,5 +106,8 @@
"volta": {
"node": "22.12.0",
"yarn": "1.22.19"
+ },
+ "dependencies": {
+ "marked": "^17.0.1"
}
}
diff --git a/src/utils/__tests__/changelog.test.ts b/src/utils/__tests__/changelog.test.ts
index 5c7a8457..182f62a9 100644
--- a/src/utils/__tests__/changelog.test.ts
+++ b/src/utils/__tests__/changelog.test.ts
@@ -3539,23 +3539,46 @@ Neither should this.`;
}
});
- it('should handle paragraph followed by nested bullets', () => {
+ it('should handle paragraph followed by proper list', () => {
+ // In valid markdown, a list needs a blank line after a paragraph
const prBody = `### Changelog Entry
Comprehensive authentication system with the following features:
- - OAuth2 support
- - Two-factor authentication
- - Session management`;
+
+- OAuth2 support
+- Two-factor authentication
+- Session management`;
const result = extractChangelogEntry(prBody);
- expect(result).toHaveLength(1);
expect(result).not.toBeNull();
+ expect(result).toHaveLength(4); // 1 paragraph + 3 list items
if (result) {
expect(result[0].text).toBe(
'Comprehensive authentication system with the following features:'
);
- expect(result[0].nestedContent).toBeDefined();
- expect(result[0].nestedContent).toContain('OAuth2 support');
+ expect(result[1].text).toBe('OAuth2 support');
+ expect(result[2].text).toBe('Two-factor authentication');
+ expect(result[3].text).toBe('Session management');
+ }
+ });
+
+ it('should treat indented bullets after paragraph as paragraph text (markdown behavior)', () => {
+ // In markdown, indented bullets after a paragraph without blank line
+ // are NOT a list - they're part of the paragraph text
+ const prBody = `### Changelog Entry
+
+Comprehensive authentication system with the following features:
+ - OAuth2 support
+ - Two-factor authentication
+ - Session management`;
+
+ const result = extractChangelogEntry(prBody);
+ expect(result).not.toBeNull();
+ expect(result).toHaveLength(1);
+ if (result) {
+ // The entire text (including the "- " prefixes) is joined as one paragraph
+ expect(result[0].text).toContain('Comprehensive authentication system');
+ expect(result[0].text).toContain('OAuth2 support');
}
});
diff --git a/src/utils/changelog.ts b/src/utils/changelog.ts
index 9f902b16..9ae28fbb 100644
--- a/src/utils/changelog.ts
+++ b/src/utils/changelog.ts
@@ -2,6 +2,7 @@ import type { SimpleGit } from 'simple-git';
import { readFileSync } from 'fs';
import { join } from 'path';
import { load } from 'js-yaml';
+import { marked, type Token, type Tokens } from 'marked';
import { logger } from '../logger';
import {
@@ -109,9 +110,9 @@ export interface Changeset {
}
/**
- * A changeset location based on RegExpExecArrays
+ * A changeset location based on regex matching
*/
-export interface ChangesetLoc {
+interface ChangesetLoc {
start: RegExpExecArray;
end: RegExpExecArray | null;
padding: string;
@@ -189,124 +190,123 @@ export function extractChangelogEntry(prBody: string | null | undefined): Change
return null;
}
- // Normalize line endings (CRLF -> LF) to ensure consistent regex matching
- const normalizedBody = prBody.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
+ // Use marked's lexer to properly parse the markdown
+ const tokens = marked.lexer(prBody);
- // Match markdown headings (## or ###) followed by "Changelog Entry" (case-insensitive)
- // This matches both with and without the # at the end (e.g., "## Changelog Entry ##" or "## Changelog Entry")
- const headerRegex = /^#{2,3}\s+Changelog Entry\s*(?:#{2,3})?\s*$/im;
- const match = normalizedBody.match(headerRegex);
+ // Find the "Changelog Entry" heading (level 2 or 3, case-insensitive)
+ const headingIndex = tokens.findIndex(
+ (t): t is Tokens.Heading =>
+ t.type === 'heading' &&
+ (t.depth === 2 || t.depth === 3) &&
+ t.text.toLowerCase() === 'changelog entry'
+ );
- if (!match || match.index === undefined) {
+ if (headingIndex === -1) {
return null;
}
- // Find the start of the content (after the heading line)
- const startIndex = match.index + match[0].length;
- const restOfBody = normalizedBody.slice(startIndex);
-
- // Find the next heading of level 2 or 3 (## or ###)
- const nextHeaderMatch = restOfBody.match(/^#{2,3}\s+/m);
- const endIndex = nextHeaderMatch?.index ?? restOfBody.length;
+ // Collect tokens between this heading and the next heading of same or higher level
+ const headingDepth = (tokens[headingIndex] as Tokens.Heading).depth;
+ const contentTokens: Token[] = [];
- // Extract and trim the content
- const content = restOfBody.slice(0, endIndex).trim();
+ for (let i = headingIndex + 1; i < tokens.length; i++) {
+ const token = tokens[i];
+ // Stop at next heading of same or higher level
+ if (token.type === 'heading' && (token as Tokens.Heading).depth <= headingDepth) {
+ break;
+ }
+ contentTokens.push(token);
+ }
- // Return null if the section is empty
- if (!content) {
+ // If no content tokens, return null
+ if (contentTokens.length === 0) {
return null;
}
- // Parse the content into structured entries
- return parseChangelogContent(content);
+ // Process the content tokens into changelog entries
+ return parseTokensToEntries(contentTokens);
}
/**
- * Parses changelog content into structured entries.
- * Handles multiple top-level bullets and nested content.
+ * Recursively extracts nested content from a list item's tokens.
*/
-function parseChangelogContent(content: string): ChangelogEntryItem[] {
- // First, check if the content has any bullet points at all
- const hasTopLevelBullets = /^(\s{0,3})[-*+]\s+/m.test(content);
- const hasIndentedBullets = /^(\s{4,}|\t+)[-*+]\s+/m.test(content);
-
- // If no bullets found at all, treat entire content as a single entry
- // Join multiple lines with spaces to avoid broken markdown
- if (!hasTopLevelBullets && !hasIndentedBullets) {
- // Join lines with spaces, collapsing multiple whitespace
- const singleLine = content
- .split('\n')
- .map(line => line.trim())
- .filter(line => line.length > 0)
- .join(' ');
- return [{
- text: singleLine,
- }];
- }
-
- const lines = content.split('\n');
- const entries: ChangelogEntryItem[] = [];
- let currentEntry: ChangelogEntryItem | null = null;
- let nestedLines: string[] = [];
-
- for (const line of lines) {
- // Match top-level bullets (-, *, or + at the start of line, possibly with leading spaces)
- const topLevelBulletMatch = line.match(/^(\s{0,3})[-*+]\s+(.+)$/);
-
- if (topLevelBulletMatch) {
- // Save previous entry if exists
- if (currentEntry) {
- if (nestedLines.length > 0) {
- currentEntry.nestedContent = nestedLines.join('\n');
- nestedLines = [];
+function extractNestedContent(tokens: Token[]): string {
+ const nestedLines: string[] = [];
+
+ for (const token of tokens) {
+ if (token.type === 'list') {
+ const listToken = token as Tokens.List;
+ for (const item of listToken.items) {
+ // Get the text of this nested item
+ const itemText = getListItemText(item);
+ nestedLines.push(` - ${itemText}`);
+
+ // Recursively get any deeper nested content
+ const deeperNested = extractNestedContent(item.tokens);
+ if (deeperNested) {
+ // Indent deeper nested content further
+ const indentedDeeper = deeperNested
+ .split('\n')
+ .map(line => ' ' + line)
+ .join('\n');
+ nestedLines.push(indentedDeeper);
}
- entries.push(currentEntry);
- }
-
- // Start new entry
- currentEntry = {
- text: topLevelBulletMatch[2].trim(),
- };
- } else if (currentEntry) {
- // Check if this is a nested bullet (more than 3 spaces of indentation, or tab)
- const nestedMatch = line.match(/^(\s{4,}|\t+)[-*+]\s+(.+)$/);
- if (nestedMatch) {
- // Preserve the bullet marker for nested items
- nestedLines.push(` - ${nestedMatch[2].trim()}`);
- } else if (line.trim()) {
- // Non-empty line that's not a bullet - could be continuation text or nested content
- // Add to nested content if it has any indentation or follows other nested content
- if (nestedLines.length > 0 || line.match(/^\s+/)) {
- nestedLines.push(line.trimEnd());
- }
- }
- } else {
- // No current entry yet - check if this is a paragraph that might have nested bullets after it
- if (line.trim() && !line.match(/^(\s{4,}|\t+)[-*+]\s+/)) {
- // Non-indented, non-bullet line - start a new entry
- currentEntry = {
- text: line.trim(),
- };
}
}
}
- // Save the last entry
- if (currentEntry) {
- if (nestedLines.length > 0) {
- currentEntry.nestedContent = nestedLines.join('\n');
- }
- entries.push(currentEntry);
+ return nestedLines.join('\n');
+}
+
+/**
+ * Gets the text content of a list item, excluding nested lists.
+ */
+function getListItemText(item: Tokens.ListItem): string {
+ // The item.text contains the raw text, but we want just the first line
+ // (before any nested lists)
+ const firstToken = item.tokens.find(t => t.type === 'text' || t.type === 'paragraph');
+ if (firstToken && 'text' in firstToken) {
+ return firstToken.text.split('\n')[0].trim();
}
+ return item.text.split('\n')[0].trim();
+}
- // If we have indented bullets but no entries, treat entire content as a single entry
- if (entries.length === 0 && content.trim()) {
- return [{
- text: content.trim(),
- }];
+/**
+ * Parses content tokens into structured changelog entries.
+ */
+function parseTokensToEntries(tokens: Token[]): ChangelogEntryItem[] | null {
+ const entries: ChangelogEntryItem[] = [];
+
+ for (const token of tokens) {
+ if (token.type === 'list') {
+ // Each top-level list item becomes a changelog entry
+ const listToken = token as Tokens.List;
+ for (const item of listToken.items) {
+ const text = getListItemText(item);
+ const nestedContent = extractNestedContent(item.tokens);
+
+ entries.push({
+ text,
+ ...(nestedContent ? { nestedContent } : {}),
+ });
+ }
+ } else if (token.type === 'paragraph') {
+ // Paragraph text becomes a single entry
+ // Join multiple lines with spaces to avoid broken markdown
+ const paragraphToken = token as Tokens.Paragraph;
+ const text = paragraphToken.text
+ .split('\n')
+ .map(line => line.trim())
+ .filter(line => line.length > 0)
+ .join(' ');
+
+ if (text) {
+ entries.push({ text });
+ }
+ }
}
- return entries;
+ return entries.length > 0 ? entries : null;
}
/**
@@ -339,9 +339,9 @@ function extractChangeset(markdown: string, location: ChangesetLoc): Changeset {
* @param markdown The full changelog markdown
* @param predicate A callback that takes the found title and returns true if
* this is a match, false otherwise
- * @returns A ChangesetLoc object where "start" has the matche for the header,
+ * @returns A ChangesetLoc object where "start" has the match for the header,
* and "end" has the match for the next header so the contents
- * inbetween can be extracted
+ * in between can be extracted
*/
function locateChangeset(
markdown: string,
diff --git a/yarn.lock b/yarn.lock
index e654e11e..bb55d9bb 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -4955,6 +4955,11 @@ makeerror@1.0.12:
dependencies:
tmpl "1.0.5"
+marked@^17.0.1:
+ version "17.0.1"
+ resolved "https://registry.yarnpkg.com/marked/-/marked-17.0.1.tgz#9db34197ac145e5929572ee49ef701e37ee9b2e6"
+ integrity sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg==
+
math-intrinsics@^1.1.0:
version "1.1.0"
resolved "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz"
From f55a91a08498b4bbd561d99f0bf65284136d6aa1 Mon Sep 17 00:00:00 2001
From: Burak Yigit Kaya
Date: Sat, 27 Dec 2025 03:04:26 +0300
Subject: [PATCH 14/15] refactor: use marked for all markdown parsing, extract
test fixtures
- Convert locateChangeset, extractChangeset, removeChangeset, and
prependChangeset to use marked lexer instead of complex regex
- Extract common test fixtures to src/utils/__tests__/fixtures/changelog.ts
- Remove duplicate TestCommit interface
- Simplify test markdown to use proper non-indented strings
- Reduce test file size by ~100 lines
- Update jest config to exclude fixtures directory from test discovery
---
jest.config.js | 6 +-
src/utils/__tests__/changelog.test.ts | 409 +++++++++-------------
src/utils/__tests__/fixtures/changelog.ts | 240 +++++++++++++
src/utils/changelog.ts | 124 ++++---
4 files changed, 485 insertions(+), 294 deletions(-)
create mode 100644 src/utils/__tests__/fixtures/changelog.ts
diff --git a/jest.config.js b/jest.config.js
index bb07a7a5..3af717ff 100644
--- a/jest.config.js
+++ b/jest.config.js
@@ -1,7 +1,11 @@
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
- testPathIgnorePatterns: ['/dist/', '/node_modules/'],
+ testPathIgnorePatterns: [
+ '/dist/',
+ '/node_modules/',
+ '/src/.*/fixtures/',
+ ],
modulePathIgnorePatterns: ['/dist/'],
transformIgnorePatterns: [
'node_modules/(?!(dot-prop|configstore)/)',
diff --git a/src/utils/__tests__/changelog.test.ts b/src/utils/__tests__/changelog.test.ts
index 182f62a9..d467d424 100644
--- a/src/utils/__tests__/changelog.test.ts
+++ b/src/utils/__tests__/changelog.test.ts
@@ -34,258 +34,210 @@ import {
BODY_IN_CHANGELOG_MAGIC_WORD,
CurrentPRInfo,
} from '../changelog';
+import {
+ SAMPLE_CHANGESET,
+ SAMPLE_CHANGESET_WITH_SUBHEADING,
+ createFullChangelog,
+ type TestCommit,
+} from './fixtures/changelog';
const getConfigFileDirMock = config.getConfigFileDir as jest.MockedFunction;
const getGlobalGitHubConfigMock = config.getGlobalGitHubConfig as jest.MockedFunction;
const readFileSyncMock = readFileSync as jest.MockedFunction;
describe('findChangeset', () => {
- const sampleChangeset = {
- body: '- this is a test',
- name: 'Version 1.0.0',
- };
-
test.each([
[
- 'regular',
- `# Changelog\n## ${sampleChangeset.name}\n${sampleChangeset.body}\n`,
+ 'regular ATX heading',
+ `# Changelog\n## ${SAMPLE_CHANGESET.name}\n${SAMPLE_CHANGESET.body}\n`,
],
[
- 'ignore date in parentheses',
- `# Changelog
- ## 1.0.1
- newer
-
- ## ${sampleChangeset.name} (2019-02-02)
- ${sampleChangeset.body}
-
- ## 0.9.0
- older
- `,
+ 'with date in parentheses',
+ createFullChangelog('Changelog', [
+ { version: '1.0.1', body: 'newer' },
+ { version: `${SAMPLE_CHANGESET.name} (2019-02-02)`, body: SAMPLE_CHANGESET.body },
+ { version: '0.9.0', body: 'older' },
+ ]),
],
[
- 'extracts a change between headings',
- `# Changelog
- ## 1.0.1
- newer
-
- ## ${sampleChangeset.name}
- ${sampleChangeset.body}
-
- ## 0.9.0
- older
- `,
+ 'between other headings',
+ createFullChangelog('Changelog', [
+ { version: '1.0.1', body: 'newer' },
+ { version: SAMPLE_CHANGESET.name, body: SAMPLE_CHANGESET.body },
+ { version: '0.9.0', body: 'older' },
+ ]),
],
[
- 'extracts changes from underlined headings',
- `Changelog\n====\n${sampleChangeset.name}\n----\n${sampleChangeset.body}\n`,
- ],
- [
- 'extracts changes from alternating headings',
- `# Changelog
- ## 1.0.1
- newer
-
- ${sampleChangeset.name}
- -------
- ${sampleChangeset.body}
-
- ## 0.9.0
- older
- `,
+ 'setext-style headings',
+ `Changelog\n====\n${SAMPLE_CHANGESET.name}\n----\n${SAMPLE_CHANGESET.body}\n`,
],
])('should extract %s', (_testName, markdown) => {
- expect(findChangeset(markdown, 'v1.0.0')).toEqual(sampleChangeset);
+ expect(findChangeset(markdown, 'v1.0.0')).toEqual(SAMPLE_CHANGESET);
});
- test('supports sub-headings', () => {
- const changeset = {
- body: '### Features\nthis is a test',
- name: 'Version 1.0.0',
- };
-
- const markdown = `# Changelog
- ## ${changeset.name}
- ${changeset.body}
- `;
-
- expect(findChangeset(markdown, 'v1.0.0')).toEqual(changeset);
+ test('supports sub-headings within version section', () => {
+ const markdown = createFullChangelog('Changelog', [
+ { version: SAMPLE_CHANGESET_WITH_SUBHEADING.name, body: SAMPLE_CHANGESET_WITH_SUBHEADING.body },
+ ]);
+ expect(findChangeset(markdown, 'v1.0.0')).toEqual(SAMPLE_CHANGESET_WITH_SUBHEADING);
});
test.each([
['changeset cannot be found', 'v1.0.0'],
['invalid version', 'not a version'],
])('should return null on %s', (_testName, version) => {
- const markdown = `# Changelog
- ## 1.0.1
- newer
-
- ## 0.9.0
- older
- `;
+ const markdown = createFullChangelog('Changelog', [
+ { version: '1.0.1', body: 'newer' },
+ { version: '0.9.0', body: 'older' },
+ ]);
expect(findChangeset(markdown, version)).toEqual(null);
});
});
-test.each([
- [
- 'remove from the top',
- '1.0.1',
- `# Changelog
- 1.0.0
- -------
- this is a test
-
- ## 0.9.1
- slightly older
-
- ## 0.9.0
- older
- `,
- ],
- [
- 'remove from the middle',
- '0.9.1',
- `# Changelog
- ## 1.0.1
- newer
-
- 1.0.0
- -------
- this is a test
-
- ## 0.9.0
- older
- `,
- ],
- [
- 'remove from underlined',
+describe('removeChangeset', () => {
+ // Use non-indented markdown to enable proper marked parsing
+ const fullChangelog = [
+ '# Changelog',
+ '',
+ '## 1.0.1',
+ '',
+ 'newer',
+ '',
'1.0.0',
- `# Changelog
- ## 1.0.1
- newer
-
- ## 0.9.1
- slightly older
-
- ## 0.9.0
- older
- `,
- ],
- [
- 'remove from the bottom',
- '0.9.0',
- `# Changelog
- ## 1.0.1
- newer
-
- 1.0.0
- -------
- this is a test
-
- ## 0.9.1
- slightly older
-
-`,
- ],
- [
- 'not remove missing',
- 'non-existent version',
- `# Changelog
- ## 1.0.1
- newer
-
- 1.0.0
- -------
- this is a test
-
- ## 0.9.1
- slightly older
-
- ## 0.9.0
- older
- `,
- ],
- [
- 'not remove empty',
+ '-------',
+ '',
+ 'this is a test',
+ '',
+ '## 0.9.1',
+ '',
+ 'slightly older',
'',
- `# Changelog
- ## 1.0.1
- newer
-
- 1.0.0
- -------
- this is a test
-
- ## 0.9.1
- slightly older
-
- ## 0.9.0
- older
- `,
- ],
-])('remove changeset should %s', (_testName, header, expected) => {
- const markdown = `# Changelog
- ## 1.0.1
- newer
-
- 1.0.0
- -------
- this is a test
-
- ## 0.9.1
- slightly older
-
- ## 0.9.0
- older
- `;
-
- expect(removeChangeset(markdown, header)).toEqual(expected);
+ '## 0.9.0',
+ '',
+ 'older',
+ ].join('\n');
+
+ test('removes from the top', () => {
+ const expected = [
+ '# Changelog',
+ '',
+ '1.0.0',
+ '-------',
+ '',
+ 'this is a test',
+ '',
+ '## 0.9.1',
+ '',
+ 'slightly older',
+ '',
+ '## 0.9.0',
+ '',
+ 'older',
+ ].join('\n');
+ expect(removeChangeset(fullChangelog, '1.0.1')).toEqual(expected);
+ });
+
+ test('removes from the middle', () => {
+ const expected = [
+ '# Changelog',
+ '',
+ '## 1.0.1',
+ '',
+ 'newer',
+ '',
+ '1.0.0',
+ '-------',
+ '',
+ 'this is a test',
+ '',
+ '## 0.9.0',
+ '',
+ 'older',
+ ].join('\n');
+ expect(removeChangeset(fullChangelog, '0.9.1')).toEqual(expected);
+ });
+
+ test('removes setext-style heading', () => {
+ const expected = [
+ '# Changelog',
+ '',
+ '## 1.0.1',
+ '',
+ 'newer',
+ '',
+ '## 0.9.1',
+ '',
+ 'slightly older',
+ '',
+ '## 0.9.0',
+ '',
+ 'older',
+ ].join('\n');
+ expect(removeChangeset(fullChangelog, '1.0.0')).toEqual(expected);
+ });
+
+ test('removes from the bottom', () => {
+ const expected = [
+ '# Changelog',
+ '',
+ '## 1.0.1',
+ '',
+ 'newer',
+ '',
+ '1.0.0',
+ '-------',
+ '',
+ 'this is a test',
+ '',
+ '## 0.9.1',
+ '',
+ 'slightly older',
+ '',
+ '', // trailing newline from removing last section
+ ].join('\n');
+ expect(removeChangeset(fullChangelog, '0.9.0')).toEqual(expected);
+ });
+
+ test('returns unchanged when header not found', () => {
+ expect(removeChangeset(fullChangelog, 'non-existent version')).toEqual(fullChangelog);
+ });
+
+ test('returns unchanged when header is empty', () => {
+ expect(removeChangeset(fullChangelog, '')).toEqual(fullChangelog);
+ });
});
-test.each([
- [
- 'prepend to empty text',
- '',
- '## 2.0.0\n\n- rewrote everything from scratch\n- with multiple lines\n\n',
- ],
- [
- 'prepend without top-level header',
- '## 1.0.0\n\nthis is a test\n',
- '## 2.0.0\n\n- rewrote everything from scratch\n- with multiple lines\n\n## 1.0.0\n\nthis is a test\n',
- ],
- [
- 'prepend after top-level header (empty body)',
- '# Changelog\n',
- '# Changelog\n## 2.0.0\n\n- rewrote everything from scratch\n- with multiple lines\n\n',
- ],
- [
- 'prepend after top-level header',
- '# Changelog\n\n## 1.0.0\n\nthis is a test\n',
- '# Changelog\n\n## 2.0.0\n\n- rewrote everything from scratch\n- with multiple lines\n\n## 1.0.0\n\nthis is a test\n',
- ],
- [
- 'prepend with underlined when detected',
- '# Changelog\n\n1.0.0\n-----\n\nthis is a test\n',
- '# Changelog\n\n2.0.0\n-----\n\n- rewrote everything from scratch\n- with multiple lines\n\n1.0.0\n-----\n\nthis is a test\n',
- ],
- [
- 'prepend with consistent padding with the rest',
- '# Changelog\n\n ## 1.0.0\n\n this is a test\n',
- '# Changelog\n\n ## 2.0.0\n\n - rewrote everything from scratch\n - with multiple lines\n\n ## 1.0.0\n\n this is a test\n',
- ],
- [
- 'prepend with consistent padding with the rest (underlined)',
- '# Changelog\n\n 1.0.0\n-----\n\n this is a test\n',
- '# Changelog\n\n 2.0.0\n-----\n\n - rewrote everything from scratch\n - with multiple lines\n\n 1.0.0\n-----\n\n this is a test\n',
- ],
-])('prependChangeset should %s', (_testName, markdown, expected) => {
- expect(
- prependChangeset(markdown, {
- body: '- rewrote everything from scratch\n- with multiple lines',
- name: '2.0.0',
- })
- ).toEqual(expected);
+describe('prependChangeset', () => {
+ const newChangeset = {
+ body: '- rewrote everything from scratch\n- with multiple lines',
+ name: '2.0.0',
+ };
+
+ test.each([
+ ['to empty text', '', '## 2.0.0\n\n- rewrote everything from scratch\n- with multiple lines\n\n'],
+ [
+ 'without top-level header',
+ '## 1.0.0\n\nthis is a test\n',
+ '## 2.0.0\n\n- rewrote everything from scratch\n- with multiple lines\n\n## 1.0.0\n\nthis is a test\n',
+ ],
+ [
+ 'after top-level header (empty body)',
+ '# Changelog\n',
+ '# Changelog\n## 2.0.0\n\n- rewrote everything from scratch\n- with multiple lines\n\n',
+ ],
+ [
+ 'after top-level header',
+ '# Changelog\n\n## 1.0.0\n\nthis is a test\n',
+ '# Changelog\n\n## 2.0.0\n\n- rewrote everything from scratch\n- with multiple lines\n\n## 1.0.0\n\nthis is a test\n',
+ ],
+ [
+ 'matching setext style when detected',
+ '# Changelog\n\n1.0.0\n-----\n\nthis is a test\n',
+ '# Changelog\n\n2.0.0\n-----\n\n- rewrote everything from scratch\n- with multiple lines\n\n1.0.0\n-----\n\nthis is a test\n',
+ ],
+ ])('prepends %s', (_testName, markdown, expected) => {
+ expect(prependChangeset(markdown, newChangeset)).toEqual(expected);
+ });
});
describe('generateChangesetFromGit', () => {
@@ -318,23 +270,6 @@ describe('generateChangesetFromGit', () => {
});
});
- interface TestCommit {
- author?: string;
- hash: string;
- title: string;
- body: string;
- pr?: {
- local?: string;
- remote?: {
- author?: { login: string };
- number: string;
- title?: string;
- body?: string;
- labels?: string[];
- };
- };
- }
-
function setup(commits: TestCommit[], releaseConfig?: string | null): void {
// Clear memoization cache to ensure fresh results
clearChangesetCache();
diff --git a/src/utils/__tests__/fixtures/changelog.ts b/src/utils/__tests__/fixtures/changelog.ts
new file mode 100644
index 00000000..a322fcbc
--- /dev/null
+++ b/src/utils/__tests__/fixtures/changelog.ts
@@ -0,0 +1,240 @@
+/**
+ * Common test fixtures and helpers for changelog tests.
+ * Extracted to reduce test file size and improve maintainability.
+ */
+
+// ============================================================================
+// Markdown Helpers - create markdown without template literal indentation issues
+// ============================================================================
+
+/**
+ * Creates a changelog markdown string with proper formatting.
+ * Avoids template literal indentation issues.
+ */
+export function createChangelog(
+ sections: Array<{ version: string; body: string; style?: 'atx' | 'setext' }>
+): string {
+ return sections
+ .map(({ version, body, style = 'atx' }) => {
+ if (style === 'setext') {
+ return `${version}\n${'-'.repeat(version.length)}\n\n${body}`;
+ }
+ return `## ${version}\n\n${body}`;
+ })
+ .join('\n\n');
+}
+
+/**
+ * Creates a full changelog with title.
+ */
+export function createFullChangelog(
+ title: string,
+ sections: Array<{ version: string; body: string; style?: 'atx' | 'setext' }>
+): string {
+ return `# ${title}\n\n${createChangelog(sections)}`;
+}
+
+// ============================================================================
+// Sample Changesets
+// ============================================================================
+
+export const SAMPLE_CHANGESET = {
+ body: '- this is a test',
+ name: 'Version 1.0.0',
+};
+
+export const SAMPLE_CHANGESET_WITH_SUBHEADING = {
+ body: '### Features\nthis is a test',
+ name: 'Version 1.0.0',
+};
+
+// ============================================================================
+// Test Commit Types - reusable commit definitions
+// ============================================================================
+
+export interface TestCommit {
+ author?: string;
+ hash: string;
+ title: string;
+ body: string;
+ pr?: {
+ local?: string;
+ remote?: {
+ author?: { login: string };
+ number: string;
+ title?: string;
+ body?: string;
+ labels?: string[];
+ };
+ };
+}
+
+/**
+ * Creates a simple local commit (no PR).
+ */
+export function localCommit(
+ hash: string,
+ title: string,
+ body = ''
+): TestCommit {
+ return { hash, title, body };
+}
+
+/**
+ * Creates a commit with a linked PR.
+ */
+export function prCommit(
+ hash: string,
+ title: string,
+ prNumber: string,
+ options: {
+ author?: string;
+ body?: string;
+ labels?: string[];
+ prTitle?: string;
+ prBody?: string;
+ } = {}
+): TestCommit {
+ return {
+ hash,
+ title,
+ body: options.body ?? '',
+ author: options.author,
+ pr: {
+ local: prNumber,
+ remote: {
+ author: options.author ? { login: options.author } : undefined,
+ number: prNumber,
+ title: options.prTitle ?? title,
+ body: options.prBody ?? '',
+ labels: options.labels ?? [],
+ },
+ },
+ };
+}
+
+// ============================================================================
+// Common Release Configs
+// ============================================================================
+
+export const BASIC_RELEASE_CONFIG = `
+changelog:
+ categories:
+ - title: Features
+ labels:
+ - enhancement
+ - title: Bug Fixes
+ labels:
+ - bug
+`;
+
+export const RELEASE_CONFIG_WITH_PATTERNS = `
+changelog:
+ categories:
+ - title: Features
+ labels:
+ - enhancement
+ commit_patterns:
+ - "^feat(\\\\([^)]+\\\\))?:"
+ - title: Bug Fixes
+ labels:
+ - bug
+ commit_patterns:
+ - "^fix(\\\\([^)]+\\\\))?:"
+`;
+
+export const RELEASE_CONFIG_WITH_EXCLUSIONS = `
+changelog:
+ exclude:
+ labels:
+ - skip-changelog
+ authors:
+ - dependabot
+ - renovate
+ categories:
+ - title: Features
+ labels:
+ - enhancement
+ - title: Bug Fixes
+ labels:
+ - bug
+`;
+
+export const RELEASE_CONFIG_WITH_WILDCARD = `
+changelog:
+ categories:
+ - title: Changes
+ labels:
+ - "*"
+`;
+
+export const RELEASE_CONFIG_WITH_SCOPE_GROUPING = `
+changelog:
+ scopeGrouping: true
+ categories:
+ - title: Features
+ labels:
+ - enhancement
+ commit_patterns:
+ - "^feat(\\\\([^)]+\\\\))?:"
+ - title: Bug Fixes
+ labels:
+ - bug
+ commit_patterns:
+ - "^fix(\\\\([^)]+\\\\))?:"
+`;
+
+// ============================================================================
+// Expected Output Helpers
+// ============================================================================
+
+const BASE_URL = 'https://github.com/test-owner/test-repo';
+
+/**
+ * Creates an expected PR link.
+ */
+export function prLink(number: string): string {
+ return `[#${number}](${BASE_URL}/pull/${number})`;
+}
+
+/**
+ * Creates an expected commit link.
+ */
+export function commitLink(hash: string, shortHash?: string): string {
+ const display = shortHash ?? hash.slice(0, 8);
+ return `[${display}](${BASE_URL}/commit/${hash})`;
+}
+
+/**
+ * Creates an expected changelog entry line.
+ */
+export function changelogEntry(
+ title: string,
+ options: { author?: string; prNumber?: string; hash?: string } = {}
+): string {
+ const parts = [title];
+
+ if (options.author) {
+ parts.push(`by @${options.author}`);
+ }
+
+ if (options.prNumber) {
+ parts.push(`in ${prLink(options.prNumber)}`);
+ } else if (options.hash) {
+ parts.push(`in ${commitLink(options.hash)}`);
+ }
+
+ return `- ${parts.join(' ')}`;
+}
+
+/**
+ * Creates a changelog section with title.
+ */
+export function changelogSection(
+ title: string,
+ emoji: string,
+ entries: string[]
+): string {
+ return `### ${title} ${emoji}\n\n${entries.join('\n')}`;
+}
+
diff --git a/src/utils/changelog.ts b/src/utils/changelog.ts
index 9ae28fbb..6f4d116b 100644
--- a/src/utils/changelog.ts
+++ b/src/utils/changelog.ts
@@ -110,12 +110,19 @@ export interface Changeset {
}
/**
- * A changeset location based on regex matching
+ * A changeset location with position info for slicing
*/
interface ChangesetLoc {
- start: RegExpExecArray;
- end: RegExpExecArray | null;
- padding: string;
+ /** Start index in the original markdown */
+ startIndex: number;
+ /** End index (start of next heading, or end of document) */
+ endIndex: number;
+ /** The heading title text */
+ title: string;
+ /** Length of the raw heading including newlines */
+ headingLength: number;
+ /** Whether this was a setext-style heading */
+ isSetext: boolean;
}
function escapeMarkdownPound(text: string): string {
@@ -310,63 +317,75 @@ function parseTokensToEntries(tokens: Token[]): ChangelogEntryItem[] | null {
}
/**
- * Extracts a specific changeset from a markdown document
- *
- * The changes are bounded by a header preceding the changes and an optional
- * header at the end. If the latter is omitted, the markdown document will be
- * read until its end. The title of the changes will be extracted from the
- * given header.
+ * Extracts a specific changeset from a markdown document using the location info.
*
* @param markdown The full changelog markdown
- * @param location The start & end location for the section
- * @returns The extracted changes
+ * @param location The changeset location
+ * @returns The extracted changeset
*/
function extractChangeset(markdown: string, location: ChangesetLoc): Changeset {
- const start = location.start.index + location.start[0].length;
- const end = location.end ? location.end.index : undefined;
- const body = markdown.substring(start, end).trim();
- const name = (location.start[2] || location.start[3])
- .replace(/\(.*\)$/, '')
- .trim();
+ const bodyStart = location.startIndex + location.headingLength;
+ const body = markdown.substring(bodyStart, location.endIndex).trim();
+ // Remove trailing parenthetical content (e.g., dates) from the title
+ const name = location.title.replace(/\(.*\)$/, '').trim();
return { name, body };
}
/**
- * Locates and returns a changeset section with the title passed in header.
- * Supports an optional "predicate" callback used to compare the expected title
- * and the title found in text. Useful for normalizing versions.
+ * Locates a changeset section matching the predicate using marked tokenizer.
+ * Supports both ATX-style (## Header) and Setext-style (Header\n---) headings.
*
* @param markdown The full changelog markdown
- * @param predicate A callback that takes the found title and returns true if
- * this is a match, false otherwise
- * @returns A ChangesetLoc object where "start" has the match for the header,
- * and "end" has the match for the next header so the contents
- * in between can be extracted
+ * @param predicate A callback that takes the found title and returns true if match
+ * @returns A ChangesetLoc object or null if not found
*/
function locateChangeset(
markdown: string,
predicate: (match: string) => boolean
): ChangesetLoc | null {
- const HEADER_REGEX = new RegExp(
- `^( *)(?:#{${VERSION_HEADER_LEVEL}} +([^\\n]+?) *(?:#{${VERSION_HEADER_LEVEL}})?|([^\\n]+)\\n *(?:-){2,}) *(?:\\n+|$)`,
- 'gm'
- );
+ const tokens = marked.lexer(markdown);
- for (
- let match = HEADER_REGEX.exec(markdown);
- match !== null;
- match = HEADER_REGEX.exec(markdown)
- ) {
- const matchedTitle = match[2] || match[3];
- if (predicate(matchedTitle)) {
- const padSize = match?.[1]?.length || 0;
- return {
- end: HEADER_REGEX.exec(markdown),
- start: match,
- padding: new Array(padSize + 1).join(' '),
- };
+ // Track position by accumulating raw lengths
+ let pos = 0;
+ for (let i = 0; i < tokens.length; i++) {
+ const token = tokens[i];
+
+ if (token.type === 'heading' && token.depth === VERSION_HEADER_LEVEL) {
+ const headingToken = token as Tokens.Heading;
+
+ if (predicate(headingToken.text)) {
+ // Find the end position (start of next same-level or higher heading)
+ let endIndex = markdown.length;
+ let searchPos = pos + headingToken.raw.length;
+
+ for (let j = i + 1; j < tokens.length; j++) {
+ const nextToken = tokens[j];
+ if (
+ nextToken.type === 'heading' &&
+ (nextToken as Tokens.Heading).depth <= VERSION_HEADER_LEVEL
+ ) {
+ endIndex = searchPos;
+ break;
+ }
+ searchPos += nextToken.raw.length;
+ }
+
+ // Detect setext-style headings (raw contains \n followed by dashes)
+ const isSetext = /\n\s*-{2,}/.test(headingToken.raw);
+
+ return {
+ startIndex: pos,
+ endIndex,
+ title: headingToken.text,
+ headingLength: headingToken.raw.length,
+ isSetext,
+ };
+ }
}
+
+ pos += token.raw.length;
}
+
return null;
}
@@ -421,9 +440,7 @@ export function removeChangeset(markdown: string, header: string): string {
return markdown;
}
- const start = location.start.index;
- const end = location.end?.index ?? markdown.length;
- return markdown.slice(0, start) + markdown.slice(end);
+ return markdown.slice(0, location.startIndex) + markdown.slice(location.endIndex);
}
/**
@@ -442,22 +459,17 @@ export function prependChangeset(
changeset: Changeset
): string {
// Try to locate the top-most non-empty header, no matter what is inside
- const { start, padding } = locateChangeset(markdown, Boolean) || {
- padding: '',
- };
- const body = changeset.body || `${padding}${DEFAULT_CHANGESET_BODY}`;
+ const firstHeading = locateChangeset(markdown, Boolean);
+ const body = changeset.body || DEFAULT_CHANGESET_BODY;
let header;
- if (start?.[3]) {
+ if (firstHeading?.isSetext) {
const underline = new Array(changeset.name.length + 1).join('-');
header = `${changeset.name}\n${underline}`;
} else {
header = markdownHeader(VERSION_HEADER_LEVEL, changeset.name);
}
- const newSection = `${padding}${header}\n\n${body.replace(
- /^/gm,
- padding
- )}\n\n`;
- const startIdx = start?.index ?? markdown.length;
+ const newSection = `${header}\n\n${body}\n\n`;
+ const startIdx = firstHeading?.startIndex ?? markdown.length;
return markdown.slice(0, startIdx) + newSection + markdown.slice(startIdx);
}
From 51e1f623b9687a2d54b21a356cfd98d30a3c0104 Mon Sep 17 00:00:00 2001
From: Burak Yigit Kaya
Date: Sat, 27 Dec 2025 03:18:11 +0300
Subject: [PATCH 15/15] refactor: split changelog tests into focused files with
snapshots
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
BREAKING: Removed changelog.test.ts (4237 lines) and replaced with:
- changelog-file-ops.test.ts (210 lines) - findChangeset, removeChangeset, prependChangeset
- changelog-generate.test.ts (535 lines) - generateChangesetFromGit with snapshot testing
- changelog-extract.test.ts (195 lines) - extractScope, formatScopeTitle, extractChangelogEntry
- changelog-utils.test.ts (144 lines) - shouldExcludePR, shouldSkipCurrentPR, getBumpTypeForPR
Total reduction: 4237 → 1444 lines (~66% reduction)
The snapshot tests capture the full output format without inline expected strings,
making the tests much more maintainable and readable.
---
.../changelog-extract.test.ts.snap | 104 +
.../changelog-generate.test.ts.snap | 127 +
src/utils/__tests__/changelog-extract.test.ts | 195 +
.../__tests__/changelog-file-ops.test.ts | 210 +
.../__tests__/changelog-generate.test.ts | 535 +++
src/utils/__tests__/changelog-utils.test.ts | 144 +
src/utils/__tests__/changelog.test.ts | 4237 -----------------
.../__tests__/fixtures/changelog-mocks.ts | 120 +
8 files changed, 1435 insertions(+), 4237 deletions(-)
create mode 100644 src/utils/__tests__/__snapshots__/changelog-extract.test.ts.snap
create mode 100644 src/utils/__tests__/__snapshots__/changelog-generate.test.ts.snap
create mode 100644 src/utils/__tests__/changelog-extract.test.ts
create mode 100644 src/utils/__tests__/changelog-file-ops.test.ts
create mode 100644 src/utils/__tests__/changelog-generate.test.ts
create mode 100644 src/utils/__tests__/changelog-utils.test.ts
delete mode 100644 src/utils/__tests__/changelog.test.ts
create mode 100644 src/utils/__tests__/fixtures/changelog-mocks.ts
diff --git a/src/utils/__tests__/__snapshots__/changelog-extract.test.ts.snap b/src/utils/__tests__/__snapshots__/changelog-extract.test.ts.snap
new file mode 100644
index 00000000..950e4fe7
--- /dev/null
+++ b/src/utils/__tests__/__snapshots__/changelog-extract.test.ts.snap
@@ -0,0 +1,104 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`extractChangelogEntry basic extraction extracts from ## Changelog Entry section 1`] = `
+[
+ {
+ "text": "Add a new function called \`foo\` which prints "Hello, world!"",
+ },
+]
+`;
+
+exports[`extractChangelogEntry basic extraction extracts from ### Changelog Entry section 1`] = `
+[
+ {
+ "text": "Add a new function called \`foo\` which prints "Hello, world!"",
+ },
+]
+`;
+
+exports[`extractChangelogEntry basic extraction handles changelog entry at end of body 1`] = `
+[
+ {
+ "text": "This is the last section with no sections after it.",
+ },
+]
+`;
+
+exports[`extractChangelogEntry bullet point handling handles multiple top-level bullets with nested content 1`] = `
+[
+ {
+ "nestedContent": " - Detail A
+ - Detail B",
+ "text": "First feature",
+ },
+ {
+ "nestedContent": " - Detail C",
+ "text": "Second feature",
+ },
+]
+`;
+
+exports[`extractChangelogEntry bullet point handling handles nested bullets 1`] = `
+[
+ {
+ "nestedContent": " - Nested item 1
+ - Nested item 2",
+ "text": "Main entry",
+ },
+]
+`;
+
+exports[`extractChangelogEntry bullet point handling parses multiple bullets as separate entries 1`] = `
+[
+ {
+ "text": "First entry",
+ },
+ {
+ "text": "Second entry",
+ },
+ {
+ "text": "Third entry",
+ },
+]
+`;
+
+exports[`extractChangelogEntry edge cases case-insensitive heading match 1`] = `
+[
+ {
+ "text": "This should still be extracted.",
+ },
+]
+`;
+
+exports[`extractChangelogEntry edge cases handles CRLF line endings 1`] = `
+[
+ {
+ "text": "Entry with CRLF",
+ },
+ {
+ "text": "Another entry",
+ },
+]
+`;
+
+exports[`extractChangelogEntry edge cases treats multi-line plain text as single entry 1`] = `
+[
+ {
+ "text": "This is a multi-line changelog entry that spans several lines.",
+ },
+]
+`;
+
+exports[`extractChangelogEntry paragraph and list combinations handles paragraph followed by list with blank line 1`] = `
+[
+ {
+ "text": "Intro paragraph:",
+ },
+ {
+ "text": "First item",
+ },
+ {
+ "text": "Second item",
+ },
+]
+`;
diff --git a/src/utils/__tests__/__snapshots__/changelog-generate.test.ts.snap b/src/utils/__tests__/__snapshots__/changelog-generate.test.ts.snap
new file mode 100644
index 00000000..bc7d1158
--- /dev/null
+++ b/src/utils/__tests__/__snapshots__/changelog-generate.test.ts.snap
@@ -0,0 +1,127 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`generateChangesetFromGit category matching applies global exclusions 1`] = `
+"### Features
+
+- Normal feature by @alice in [#1](https://github.com/test-owner/test-repo/pull/1)"
+`;
+
+exports[`generateChangesetFromGit category matching matches PRs to categories based on labels 1`] = `
+"### Features
+
+- Feature PR by @alice in [#1](https://github.com/test-owner/test-repo/pull/1)
+
+### Bug Fixes
+
+- Bug fix PR by @bob in [#2](https://github.com/test-owner/test-repo/pull/2)"
+`;
+
+exports[`generateChangesetFromGit category matching supports wildcard category matching 1`] = `
+"### Changes
+
+- Any PR by @alice in [#1](https://github.com/test-owner/test-repo/pull/1)"
+`;
+
+exports[`generateChangesetFromGit commit patterns labels take precedence over commit_patterns 1`] = `
+"### Labeled Features
+
+- feat: labeled feature by @alice in [#1](https://github.com/test-owner/test-repo/pull/1)
+
+### Pattern Features
+
+- feat: pattern-only feature by @bob in [#2](https://github.com/test-owner/test-repo/pull/2)"
+`;
+
+exports[`generateChangesetFromGit commit patterns matches PRs based on commit_patterns 1`] = `
+"### Features
+
+- feat: add new feature by @alice in [#1](https://github.com/test-owner/test-repo/pull/1)
+
+### Bug Fixes
+
+- fix: fix bug by @bob in [#2](https://github.com/test-owner/test-repo/pull/2)"
+`;
+
+exports[`generateChangesetFromGit commit patterns uses default conventional commits config when no config exists 1`] = `
+"### New Features ✨
+
+- feat: new feature by @alice in [#1](https://github.com/test-owner/test-repo/pull/1)
+
+### Bug Fixes 🐛
+
+- fix: bug fix by @bob in [#2](https://github.com/test-owner/test-repo/pull/2)
+
+### Documentation 📚
+
+- docs: update readme by @charlie in [#3](https://github.com/test-owner/test-repo/pull/3)"
+`;
+
+exports[`generateChangesetFromGit custom changelog entries handles multiple bullets in changelog entry 1`] = `
+"### New Features ✨
+
+- First entry by @alice in [#1](https://github.com/test-owner/test-repo/pull/1)
+- Second entry by @alice in [#1](https://github.com/test-owner/test-repo/pull/1)
+- Third entry by @alice in [#1](https://github.com/test-owner/test-repo/pull/1)"
+`;
+
+exports[`generateChangesetFromGit custom changelog entries handles nested bullets in changelog entry 1`] = `
+"### New Features ✨
+
+- Main entry by @alice in [#1](https://github.com/test-owner/test-repo/pull/1)
+ - Nested item 1
+ - Nested item 2"
+`;
+
+exports[`generateChangesetFromGit custom changelog entries uses custom entry from PR body 1`] = `
+"### New Features ✨
+
+- Custom changelog entry by @alice in [#1](https://github.com/test-owner/test-repo/pull/1)"
+`;
+
+exports[`generateChangesetFromGit output formatting escapes underscores in titles 1`] = `"- Serialized \\_meta in [#123](https://github.com/test-owner/test-repo/pull/123)"`;
+
+exports[`generateChangesetFromGit output formatting formats local commit with short SHA 1`] = `"- Upgraded the kernel in [abcdef12](https://github.com/test-owner/test-repo/commit/abcdef1234567890)"`;
+
+exports[`generateChangesetFromGit output formatting handles multiple commits 1`] = `
+"- Upgraded the kernel in [abcdef12](https://github.com/test-owner/test-repo/commit/abcdef1234567890)
+- Upgraded the manifold by @alice in [#123](https://github.com/test-owner/test-repo/pull/123)
+- Refactored the crankshaft by @bob in [#456](https://github.com/test-owner/test-repo/pull/456)
+
+_Plus 1 more_"
+`;
+
+exports[`generateChangesetFromGit output formatting handles null PR author gracefully 1`] = `"- Upgraded the kernel in [#123](https://github.com/test-owner/test-repo/pull/123)"`;
+
+exports[`generateChangesetFromGit output formatting uses PR number and author from remote 1`] = `"- Upgraded the kernel by @sentry in [#123](https://github.com/test-owner/test-repo/pull/123)"`;
+
+exports[`generateChangesetFromGit output formatting uses PR number when available locally 1`] = `"- Upgraded the kernel in [#123](https://github.com/test-owner/test-repo/pull/123)"`;
+
+exports[`generateChangesetFromGit output formatting uses PR title from GitHub instead of commit message 1`] = `
+"### New Features ✨
+
+- feat: A much better PR title with more context by @sentry in [#123](https://github.com/test-owner/test-repo/pull/123)"
+`;
+
+exports[`generateChangesetFromGit scope grouping groups PRs by scope when multiple entries exist 1`] = `
+"### Features
+
+#### Api
+
+- feat(api): add endpoint 1 by @alice in [#1](https://github.com/test-owner/test-repo/pull/1)
+- feat(api): add endpoint 2 by @bob in [#2](https://github.com/test-owner/test-repo/pull/2)
+
+- feat(ui): add button by @charlie in [#3](https://github.com/test-owner/test-repo/pull/3)"
+`;
+
+exports[`generateChangesetFromGit scope grouping places scopeless entries at bottom 1`] = `
+"### Features
+
+#### Api
+
+- feat(api): scoped feature 1 by @alice in [#1](https://github.com/test-owner/test-repo/pull/1)
+- feat(api): scoped feature 2 by @bob in [#2](https://github.com/test-owner/test-repo/pull/2)
+
+#### Other
+
+- feat: scopeless feature by @charlie in [#3](https://github.com/test-owner/test-repo/pull/3)"
+`;
diff --git a/src/utils/__tests__/changelog-extract.test.ts b/src/utils/__tests__/changelog-extract.test.ts
new file mode 100644
index 00000000..7ed236d0
--- /dev/null
+++ b/src/utils/__tests__/changelog-extract.test.ts
@@ -0,0 +1,195 @@
+/**
+ * Tests for changelog extraction and parsing functions.
+ * - extractScope: Extracts scope from conventional commit titles
+ * - formatScopeTitle: Formats scope for display
+ * - extractChangelogEntry: Extracts custom changelog entries from PR bodies
+ */
+
+import { extractScope, formatScopeTitle, extractChangelogEntry } from '../changelog';
+
+describe('extractScope', () => {
+ it.each([
+ ['feat(api): add endpoint', 'api'],
+ ['fix(ui): fix button', 'ui'],
+ ['feat(my-component): add feature', 'my-component'],
+ ['feat(my_component): add feature', 'my-component'],
+ ['feat(API): uppercase scope', 'api'],
+ ['feat(MyComponent): mixed case', 'mycomponent'],
+ ['feat(scope)!: breaking change', 'scope'],
+ ['fix(core)!: another breaking', 'core'],
+ ['docs(readme): update docs', 'readme'],
+ ['chore(deps): update dependencies', 'deps'],
+ ['feat(my-long_scope): mixed separators', 'my-long-scope'],
+ ])('extracts scope from "%s" as "%s"', (title, expected) => {
+ expect(extractScope(title)).toBe(expected);
+ });
+
+ it.each([
+ ['feat: no scope', null],
+ ['fix: simple fix', null],
+ ['random commit message', null],
+ ['feat!: breaking without scope', null],
+ ['(scope): missing type', null],
+ ['feat(): empty scope', null],
+ ])('returns null for "%s"', (title, expected) => {
+ expect(extractScope(title)).toBe(expected);
+ });
+});
+
+describe('formatScopeTitle', () => {
+ it.each([
+ ['api', 'Api'],
+ ['ui', 'Ui'],
+ ['my-component', 'My Component'],
+ ['my_component', 'My Component'],
+ ['multi-word-scope', 'Multi Word Scope'],
+ ['multi_word_scope', 'Multi Word Scope'],
+ ['API', 'API'],
+ ['mycomponent', 'Mycomponent'],
+ ])('formats "%s" as "%s"', (scope, expected) => {
+ expect(formatScopeTitle(scope)).toBe(expected);
+ });
+});
+
+describe('extractChangelogEntry', () => {
+ describe('basic extraction', () => {
+ it('extracts from ### Changelog Entry section', () => {
+ const prBody = `### Description
+
+This PR adds a new feature.
+
+### Changelog Entry
+
+Add a new function called \`foo\` which prints "Hello, world!"
+
+### Issues
+
+Closes #123`;
+
+ expect(extractChangelogEntry(prBody)).toMatchSnapshot();
+ });
+
+ it('extracts from ## Changelog Entry section', () => {
+ const prBody = `## Description
+
+This PR adds a new feature.
+
+## Changelog Entry
+
+Add a new function called \`foo\` which prints "Hello, world!"
+
+## Issues
+
+Closes #123`;
+
+ expect(extractChangelogEntry(prBody)).toMatchSnapshot();
+ });
+
+ it('handles changelog entry at end of body', () => {
+ const prBody = `### Description
+
+This PR adds a new feature.
+
+### Changelog Entry
+
+This is the last section with no sections after it.`;
+
+ expect(extractChangelogEntry(prBody)).toMatchSnapshot();
+ });
+ });
+
+ describe('bullet point handling', () => {
+ it('parses multiple bullets as separate entries', () => {
+ const prBody = `### Changelog Entry
+
+- First entry
+- Second entry
+- Third entry`;
+
+ expect(extractChangelogEntry(prBody)).toMatchSnapshot();
+ });
+
+ it('handles nested bullets', () => {
+ const prBody = `### Changelog Entry
+
+- Main entry
+ - Nested item 1
+ - Nested item 2`;
+
+ expect(extractChangelogEntry(prBody)).toMatchSnapshot();
+ });
+
+ it('handles multiple top-level bullets with nested content', () => {
+ const prBody = `### Changelog Entry
+
+- First feature
+ - Detail A
+ - Detail B
+- Second feature
+ - Detail C`;
+
+ expect(extractChangelogEntry(prBody)).toMatchSnapshot();
+ });
+ });
+
+ describe('edge cases', () => {
+ it('returns null for null/undefined input', () => {
+ expect(extractChangelogEntry(null)).toBeNull();
+ expect(extractChangelogEntry(undefined)).toBeNull();
+ expect(extractChangelogEntry('')).toBeNull();
+ });
+
+ it('returns null when no changelog entry section exists', () => {
+ const prBody = `### Description
+
+This PR has no changelog entry section.`;
+
+ expect(extractChangelogEntry(prBody)).toBeNull();
+ });
+
+ it('returns null for empty changelog entry section', () => {
+ const prBody = `### Changelog Entry
+
+### Next Section`;
+
+ expect(extractChangelogEntry(prBody)).toBeNull();
+ });
+
+ it('handles CRLF line endings', () => {
+ const prBody = '### Changelog Entry\r\n\r\n- Entry with CRLF\r\n- Another entry\r\n\r\n### Next';
+ expect(extractChangelogEntry(prBody)).toMatchSnapshot();
+ });
+
+ it('treats multi-line plain text as single entry', () => {
+ const prBody = `### Changelog Entry
+
+This is a multi-line
+changelog entry that
+spans several lines.`;
+
+ expect(extractChangelogEntry(prBody)).toMatchSnapshot();
+ });
+
+ it('case-insensitive heading match', () => {
+ const prBody = `### CHANGELOG ENTRY
+
+This should still be extracted.`;
+
+ expect(extractChangelogEntry(prBody)).toMatchSnapshot();
+ });
+ });
+
+ describe('paragraph and list combinations', () => {
+ it('handles paragraph followed by list with blank line', () => {
+ const prBody = `### Changelog Entry
+
+Intro paragraph:
+
+- First item
+- Second item`;
+
+ expect(extractChangelogEntry(prBody)).toMatchSnapshot();
+ });
+ });
+});
+
diff --git a/src/utils/__tests__/changelog-file-ops.test.ts b/src/utils/__tests__/changelog-file-ops.test.ts
new file mode 100644
index 00000000..0b00da17
--- /dev/null
+++ b/src/utils/__tests__/changelog-file-ops.test.ts
@@ -0,0 +1,210 @@
+/**
+ * Tests for changelog file operations: findChangeset, removeChangeset, prependChangeset.
+ * These functions work with CHANGELOG.md file content.
+ */
+
+import {
+ findChangeset,
+ removeChangeset,
+ prependChangeset,
+} from '../changelog';
+import {
+ SAMPLE_CHANGESET,
+ SAMPLE_CHANGESET_WITH_SUBHEADING,
+ createFullChangelog,
+} from './fixtures/changelog';
+
+describe('findChangeset', () => {
+ test.each([
+ [
+ 'regular ATX heading',
+ `# Changelog\n## ${SAMPLE_CHANGESET.name}\n${SAMPLE_CHANGESET.body}\n`,
+ ],
+ [
+ 'with date in parentheses',
+ createFullChangelog('Changelog', [
+ { version: '1.0.1', body: 'newer' },
+ { version: `${SAMPLE_CHANGESET.name} (2019-02-02)`, body: SAMPLE_CHANGESET.body },
+ { version: '0.9.0', body: 'older' },
+ ]),
+ ],
+ [
+ 'between other headings',
+ createFullChangelog('Changelog', [
+ { version: '1.0.1', body: 'newer' },
+ { version: SAMPLE_CHANGESET.name, body: SAMPLE_CHANGESET.body },
+ { version: '0.9.0', body: 'older' },
+ ]),
+ ],
+ [
+ 'setext-style headings',
+ `Changelog\n====\n${SAMPLE_CHANGESET.name}\n----\n${SAMPLE_CHANGESET.body}\n`,
+ ],
+ ])('extracts %s', (_testName, markdown) => {
+ expect(findChangeset(markdown, 'v1.0.0')).toEqual(SAMPLE_CHANGESET);
+ });
+
+ test('supports sub-headings within version section', () => {
+ const markdown = createFullChangelog('Changelog', [
+ { version: SAMPLE_CHANGESET_WITH_SUBHEADING.name, body: SAMPLE_CHANGESET_WITH_SUBHEADING.body },
+ ]);
+ expect(findChangeset(markdown, 'v1.0.0')).toEqual(SAMPLE_CHANGESET_WITH_SUBHEADING);
+ });
+
+ test.each([
+ ['changeset cannot be found', 'v1.0.0'],
+ ['invalid version', 'not a version'],
+ ])('returns null when %s', (_testName, version) => {
+ const markdown = createFullChangelog('Changelog', [
+ { version: '1.0.1', body: 'newer' },
+ { version: '0.9.0', body: 'older' },
+ ]);
+ expect(findChangeset(markdown, version)).toEqual(null);
+ });
+});
+
+describe('removeChangeset', () => {
+ const fullChangelog = [
+ '# Changelog',
+ '',
+ '## 1.0.1',
+ '',
+ 'newer',
+ '',
+ '1.0.0',
+ '-------',
+ '',
+ 'this is a test',
+ '',
+ '## 0.9.1',
+ '',
+ 'slightly older',
+ '',
+ '## 0.9.0',
+ '',
+ 'older',
+ ].join('\n');
+
+ test('removes from the top', () => {
+ const expected = [
+ '# Changelog',
+ '',
+ '1.0.0',
+ '-------',
+ '',
+ 'this is a test',
+ '',
+ '## 0.9.1',
+ '',
+ 'slightly older',
+ '',
+ '## 0.9.0',
+ '',
+ 'older',
+ ].join('\n');
+ expect(removeChangeset(fullChangelog, '1.0.1')).toEqual(expected);
+ });
+
+ test('removes from the middle', () => {
+ const expected = [
+ '# Changelog',
+ '',
+ '## 1.0.1',
+ '',
+ 'newer',
+ '',
+ '1.0.0',
+ '-------',
+ '',
+ 'this is a test',
+ '',
+ '## 0.9.0',
+ '',
+ 'older',
+ ].join('\n');
+ expect(removeChangeset(fullChangelog, '0.9.1')).toEqual(expected);
+ });
+
+ test('removes setext-style heading', () => {
+ const expected = [
+ '# Changelog',
+ '',
+ '## 1.0.1',
+ '',
+ 'newer',
+ '',
+ '## 0.9.1',
+ '',
+ 'slightly older',
+ '',
+ '## 0.9.0',
+ '',
+ 'older',
+ ].join('\n');
+ expect(removeChangeset(fullChangelog, '1.0.0')).toEqual(expected);
+ });
+
+ test('removes from the bottom', () => {
+ const expected = [
+ '# Changelog',
+ '',
+ '## 1.0.1',
+ '',
+ 'newer',
+ '',
+ '1.0.0',
+ '-------',
+ '',
+ 'this is a test',
+ '',
+ '## 0.9.1',
+ '',
+ 'slightly older',
+ '',
+ '',
+ ].join('\n');
+ expect(removeChangeset(fullChangelog, '0.9.0')).toEqual(expected);
+ });
+
+ test('returns unchanged when header not found', () => {
+ expect(removeChangeset(fullChangelog, 'non-existent version')).toEqual(fullChangelog);
+ });
+
+ test('returns unchanged when header is empty', () => {
+ expect(removeChangeset(fullChangelog, '')).toEqual(fullChangelog);
+ });
+});
+
+describe('prependChangeset', () => {
+ const newChangeset = {
+ body: '- rewrote everything from scratch\n- with multiple lines',
+ name: '2.0.0',
+ };
+
+ test.each([
+ ['to empty text', '', '## 2.0.0\n\n- rewrote everything from scratch\n- with multiple lines\n\n'],
+ [
+ 'without top-level header',
+ '## 1.0.0\n\nthis is a test\n',
+ '## 2.0.0\n\n- rewrote everything from scratch\n- with multiple lines\n\n## 1.0.0\n\nthis is a test\n',
+ ],
+ [
+ 'after top-level header (empty body)',
+ '# Changelog\n',
+ '# Changelog\n## 2.0.0\n\n- rewrote everything from scratch\n- with multiple lines\n\n',
+ ],
+ [
+ 'after top-level header',
+ '# Changelog\n\n## 1.0.0\n\nthis is a test\n',
+ '# Changelog\n\n## 2.0.0\n\n- rewrote everything from scratch\n- with multiple lines\n\n## 1.0.0\n\nthis is a test\n',
+ ],
+ [
+ 'matching setext style when detected',
+ '# Changelog\n\n1.0.0\n-----\n\nthis is a test\n',
+ '# Changelog\n\n2.0.0\n-----\n\n- rewrote everything from scratch\n- with multiple lines\n\n1.0.0\n-----\n\nthis is a test\n',
+ ],
+ ])('prepends %s', (_testName, markdown, expected) => {
+ expect(prependChangeset(markdown, newChangeset)).toEqual(expected);
+ });
+});
+
diff --git a/src/utils/__tests__/changelog-generate.test.ts b/src/utils/__tests__/changelog-generate.test.ts
new file mode 100644
index 00000000..eadd6840
--- /dev/null
+++ b/src/utils/__tests__/changelog-generate.test.ts
@@ -0,0 +1,535 @@
+/**
+ * Tests for generateChangesetFromGit - the main changelog generation function.
+ * Uses snapshot testing for output validation to reduce test file size.
+ */
+
+/* eslint-env jest */
+
+jest.mock('../githubApi.ts');
+import { getGitHubClient } from '../githubApi';
+jest.mock('../git');
+import { getChangesSince } from '../git';
+jest.mock('fs', () => ({
+ ...jest.requireActual('fs'),
+ readFileSync: jest.fn(),
+}));
+jest.mock('../../config', () => ({
+ ...jest.requireActual('../../config'),
+ getConfigFileDir: jest.fn(),
+ getGlobalGitHubConfig: jest.fn(),
+}));
+import * as config from '../../config';
+import { readFileSync } from 'fs';
+import type { SimpleGit } from 'simple-git';
+
+import { generateChangesetFromGit, clearChangesetCache } from '../changelog';
+import { type TestCommit } from './fixtures/changelog';
+
+const getConfigFileDirMock = config.getConfigFileDir as jest.MockedFunction;
+const getGlobalGitHubConfigMock = config.getGlobalGitHubConfig as jest.MockedFunction;
+const readFileSyncMock = readFileSync as jest.MockedFunction;
+
+describe('generateChangesetFromGit', () => {
+ let mockClient: jest.Mock;
+ const mockGetChangesSince = getChangesSince as jest.MockedFunction;
+ const dummyGit = {} as SimpleGit;
+
+ beforeEach(() => {
+ jest.resetAllMocks();
+ clearChangesetCache();
+ mockClient = jest.fn();
+ (getGitHubClient as jest.MockedFunction).mockReturnValue({
+ graphql: mockClient,
+ } as any);
+ getConfigFileDirMock.mockReturnValue(undefined);
+ getGlobalGitHubConfigMock.mockResolvedValue({
+ repo: 'test-repo',
+ owner: 'test-owner',
+ });
+ readFileSyncMock.mockImplementation(() => {
+ const error: any = new Error('ENOENT');
+ error.code = 'ENOENT';
+ throw error;
+ });
+ });
+
+ function setup(commits: TestCommit[], releaseConfig?: string | null): void {
+ mockGetChangesSince.mockResolvedValueOnce(
+ commits.map(commit => ({
+ hash: commit.hash,
+ title: commit.title,
+ body: commit.body,
+ pr: commit.pr?.local || null,
+ }))
+ );
+
+ mockClient.mockResolvedValueOnce({
+ repository: Object.fromEntries(
+ commits.map(({ hash, author, title, pr }: TestCommit) => [
+ `C${hash}`,
+ {
+ author: { user: author },
+ associatedPullRequests: {
+ nodes: pr?.remote
+ ? [
+ {
+ author: pr.remote.author,
+ number: pr.remote.number,
+ title: pr.remote.title ?? title,
+ body: pr.remote.body || '',
+ labels: {
+ nodes: (pr.remote.labels || []).map(label => ({
+ name: label,
+ })),
+ },
+ },
+ ]
+ : [],
+ },
+ },
+ ])
+ ),
+ });
+
+ if (releaseConfig !== undefined) {
+ if (releaseConfig === null) {
+ getConfigFileDirMock.mockReturnValue(undefined);
+ readFileSyncMock.mockImplementation(() => {
+ const error: any = new Error('ENOENT');
+ error.code = 'ENOENT';
+ throw error;
+ });
+ } else {
+ getConfigFileDirMock.mockReturnValue('/workspace');
+ readFileSyncMock.mockImplementation((path: any) => {
+ if (typeof path === 'string' && path.includes('.github/release.yml')) {
+ return releaseConfig;
+ }
+ const error: any = new Error('ENOENT');
+ error.code = 'ENOENT';
+ throw error;
+ });
+ }
+ }
+ }
+
+ // ============================================================================
+ // Basic output formatting tests - use snapshots
+ // ============================================================================
+
+ describe('output formatting', () => {
+ it('returns empty string for empty changeset', async () => {
+ setup([], null);
+ const result = await generateChangesetFromGit(dummyGit, '1.0.0', 3);
+ expect(result.changelog).toBe('');
+ });
+
+ it('formats local commit with short SHA', async () => {
+ setup([{ hash: 'abcdef1234567890', title: 'Upgraded the kernel', body: '' }], null);
+ const result = await generateChangesetFromGit(dummyGit, '1.0.0', 3);
+ expect(result.changelog).toMatchSnapshot();
+ });
+
+ it('uses PR number when available locally', async () => {
+ setup([
+ { hash: 'abcdef1234567890', title: 'Upgraded the kernel (#123)', body: '', pr: { local: '123' } },
+ ], null);
+ const result = await generateChangesetFromGit(dummyGit, '1.0.0', 3);
+ expect(result.changelog).toMatchSnapshot();
+ });
+
+ it('uses PR number and author from remote', async () => {
+ setup([
+ {
+ hash: 'abcdef1234567890',
+ title: 'Upgraded the kernel',
+ body: '',
+ pr: { remote: { number: '123', author: { login: 'sentry' } } },
+ },
+ ], null);
+ const result = await generateChangesetFromGit(dummyGit, '1.0.0', 3);
+ expect(result.changelog).toMatchSnapshot();
+ });
+
+ it('handles null PR author gracefully', async () => {
+ setup([
+ { hash: 'abcdef1234567890', title: 'Upgraded the kernel', body: '', pr: { remote: { number: '123' } } },
+ ], null);
+ const result = await generateChangesetFromGit(dummyGit, '1.0.0', 3);
+ expect(result.changelog).toMatchSnapshot();
+ });
+
+ it('uses PR title from GitHub instead of commit message', async () => {
+ setup([
+ {
+ hash: 'abcdef1234567890',
+ title: 'fix: quick fix for issue',
+ body: '',
+ pr: {
+ remote: {
+ number: '123',
+ title: 'feat: A much better PR title with more context',
+ author: { login: 'sentry' },
+ },
+ },
+ },
+ ], null);
+ const result = await generateChangesetFromGit(dummyGit, '1.0.0', 3);
+ expect(result.changelog).toMatchSnapshot();
+ });
+
+ it('handles multiple commits', async () => {
+ setup([
+ { hash: 'abcdef1234567890', title: 'Upgraded the kernel', body: '' },
+ {
+ hash: 'bcdef1234567890a',
+ title: 'Upgraded the manifold (#123)',
+ body: '',
+ pr: { local: '123', remote: { number: '123', author: { login: 'alice' } } },
+ },
+ {
+ hash: 'cdef1234567890ab',
+ title: 'Refactored the crankshaft',
+ body: '',
+ pr: { remote: { number: '456', author: { login: 'bob' } } },
+ },
+ {
+ hash: 'cdef1234567890ad',
+ title: 'Refactored the crankshaft again',
+ body: '',
+ pr: { remote: { number: '458', author: { login: 'bob' } } },
+ },
+ ], null);
+ const result = await generateChangesetFromGit(dummyGit, '1.0.0', 3);
+ expect(result.changelog).toMatchSnapshot();
+ });
+
+ it('escapes underscores in titles', async () => {
+ setup([
+ {
+ hash: 'abcdef1234567890',
+ title: 'Serialized _meta',
+ body: '',
+ pr: { remote: { number: '123' } },
+ },
+ ], null);
+ const result = await generateChangesetFromGit(dummyGit, '1.0.0', 3);
+ expect(result.changelog).toMatchSnapshot();
+ });
+ });
+
+ // ============================================================================
+ // Category matching tests
+ // ============================================================================
+
+ describe('category matching', () => {
+ const BASIC_CONFIG = `
+changelog:
+ categories:
+ - title: Features
+ labels:
+ - enhancement
+ - title: Bug Fixes
+ labels:
+ - bug
+`;
+
+ it('matches PRs to categories based on labels', async () => {
+ setup([
+ {
+ hash: 'abc123',
+ title: 'Feature PR',
+ body: '',
+ pr: { local: '1', remote: { number: '1', author: { login: 'alice' }, labels: ['enhancement'] } },
+ },
+ {
+ hash: 'def456',
+ title: 'Bug fix PR',
+ body: '',
+ pr: { local: '2', remote: { number: '2', author: { login: 'bob' }, labels: ['bug'] } },
+ },
+ ], BASIC_CONFIG);
+ const result = await generateChangesetFromGit(dummyGit, '1.0.0', 3);
+ expect(result.changelog).toMatchSnapshot();
+ });
+
+ it('applies global exclusions', async () => {
+ const configWithExclusions = `
+changelog:
+ exclude:
+ labels:
+ - skip-changelog
+ authors:
+ - dependabot
+ categories:
+ - title: Features
+ labels:
+ - enhancement
+`;
+ setup([
+ {
+ hash: 'abc123',
+ title: 'Normal feature',
+ body: '',
+ pr: { local: '1', remote: { number: '1', author: { login: 'alice' }, labels: ['enhancement'] } },
+ },
+ {
+ hash: 'def456',
+ title: 'Should be excluded by label',
+ body: '',
+ pr: { local: '2', remote: { number: '2', author: { login: 'bob' }, labels: ['enhancement', 'skip-changelog'] } },
+ },
+ {
+ hash: 'ghi789',
+ title: 'Should be excluded by author',
+ body: '',
+ pr: { local: '3', remote: { number: '3', author: { login: 'dependabot' }, labels: ['enhancement'] } },
+ },
+ ], configWithExclusions);
+ const result = await generateChangesetFromGit(dummyGit, '1.0.0', 10);
+ expect(result.changelog).toMatchSnapshot();
+ });
+
+ it('supports wildcard category matching', async () => {
+ const wildcardConfig = `
+changelog:
+ categories:
+ - title: Changes
+ labels:
+ - "*"
+`;
+ setup([
+ {
+ hash: 'abc123',
+ title: 'Any PR',
+ body: '',
+ pr: { local: '1', remote: { number: '1', author: { login: 'alice' }, labels: ['random-label'] } },
+ },
+ ], wildcardConfig);
+ const result = await generateChangesetFromGit(dummyGit, '1.0.0', 3);
+ expect(result.changelog).toMatchSnapshot();
+ });
+ });
+
+ // ============================================================================
+ // Commit patterns matching tests
+ // ============================================================================
+
+ describe('commit patterns', () => {
+ const PATTERN_CONFIG = `
+changelog:
+ categories:
+ - title: Features
+ commit_patterns:
+ - "^feat(\\\\([^)]+\\\\))?:"
+ - title: Bug Fixes
+ commit_patterns:
+ - "^fix(\\\\([^)]+\\\\))?:"
+`;
+
+ it('matches PRs based on commit_patterns', async () => {
+ setup([
+ {
+ hash: 'abc123',
+ title: 'feat: add new feature',
+ body: '',
+ pr: { local: '1', remote: { number: '1', author: { login: 'alice' } } },
+ },
+ {
+ hash: 'def456',
+ title: 'fix: fix bug',
+ body: '',
+ pr: { local: '2', remote: { number: '2', author: { login: 'bob' } } },
+ },
+ ], PATTERN_CONFIG);
+ const result = await generateChangesetFromGit(dummyGit, '1.0.0', 3);
+ expect(result.changelog).toMatchSnapshot();
+ });
+
+ it('labels take precedence over commit_patterns', async () => {
+ const mixedConfig = `
+changelog:
+ categories:
+ - title: Labeled Features
+ labels:
+ - enhancement
+ - title: Pattern Features
+ commit_patterns:
+ - "^feat:"
+`;
+ setup([
+ {
+ hash: 'abc123',
+ title: 'feat: labeled feature',
+ body: '',
+ pr: { local: '1', remote: { number: '1', author: { login: 'alice' }, labels: ['enhancement'] } },
+ },
+ {
+ hash: 'def456',
+ title: 'feat: pattern-only feature',
+ body: '',
+ pr: { local: '2', remote: { number: '2', author: { login: 'bob' } } },
+ },
+ ], mixedConfig);
+ const result = await generateChangesetFromGit(dummyGit, '1.0.0', 3);
+ expect(result.changelog).toMatchSnapshot();
+ });
+
+ it('uses default conventional commits config when no config exists', async () => {
+ setup([
+ {
+ hash: 'abc123',
+ title: 'feat: new feature',
+ body: '',
+ pr: { local: '1', remote: { number: '1', author: { login: 'alice' } } },
+ },
+ {
+ hash: 'def456',
+ title: 'fix: bug fix',
+ body: '',
+ pr: { local: '2', remote: { number: '2', author: { login: 'bob' } } },
+ },
+ {
+ hash: 'ghi789',
+ title: 'docs: update readme',
+ body: '',
+ pr: { local: '3', remote: { number: '3', author: { login: 'charlie' } } },
+ },
+ ], null);
+ const result = await generateChangesetFromGit(dummyGit, '1.0.0', 10);
+ expect(result.changelog).toMatchSnapshot();
+ });
+ });
+
+ // ============================================================================
+ // Scope grouping tests
+ // ============================================================================
+
+ describe('scope grouping', () => {
+ const SCOPE_CONFIG = `
+changelog:
+ scopeGrouping: true
+ categories:
+ - title: Features
+ commit_patterns:
+ - "^feat(\\\\([^)]+\\\\))?:"
+`;
+
+ it('groups PRs by scope when multiple entries exist', async () => {
+ setup([
+ {
+ hash: 'abc123',
+ title: 'feat(api): add endpoint 1',
+ body: '',
+ pr: { local: '1', remote: { number: '1', author: { login: 'alice' } } },
+ },
+ {
+ hash: 'def456',
+ title: 'feat(api): add endpoint 2',
+ body: '',
+ pr: { local: '2', remote: { number: '2', author: { login: 'bob' } } },
+ },
+ {
+ hash: 'ghi789',
+ title: 'feat(ui): add button',
+ body: '',
+ pr: { local: '3', remote: { number: '3', author: { login: 'charlie' } } },
+ },
+ ], SCOPE_CONFIG);
+ const result = await generateChangesetFromGit(dummyGit, '1.0.0', 10);
+ expect(result.changelog).toMatchSnapshot();
+ });
+
+ it('places scopeless entries at bottom', async () => {
+ setup([
+ {
+ hash: 'abc123',
+ title: 'feat(api): scoped feature 1',
+ body: '',
+ pr: { local: '1', remote: { number: '1', author: { login: 'alice' } } },
+ },
+ {
+ hash: 'def456',
+ title: 'feat(api): scoped feature 2',
+ body: '',
+ pr: { local: '2', remote: { number: '2', author: { login: 'bob' } } },
+ },
+ {
+ hash: 'ghi789',
+ title: 'feat: scopeless feature',
+ body: '',
+ pr: { local: '3', remote: { number: '3', author: { login: 'charlie' } } },
+ },
+ ], SCOPE_CONFIG);
+ const result = await generateChangesetFromGit(dummyGit, '1.0.0', 10);
+ expect(result.changelog).toMatchSnapshot();
+ });
+ });
+
+ // ============================================================================
+ // Custom changelog entries tests
+ // ============================================================================
+
+ describe('custom changelog entries', () => {
+ it('uses custom entry from PR body', async () => {
+ setup([
+ {
+ hash: 'abc123',
+ title: 'feat: original title',
+ body: '',
+ pr: {
+ local: '1',
+ remote: {
+ number: '1',
+ author: { login: 'alice' },
+ body: '## Changelog Entry\n\n- Custom changelog entry',
+ },
+ },
+ },
+ ], null);
+ const result = await generateChangesetFromGit(dummyGit, '1.0.0', 3);
+ expect(result.changelog).toMatchSnapshot();
+ });
+
+ it('handles multiple bullets in changelog entry', async () => {
+ setup([
+ {
+ hash: 'abc123',
+ title: 'feat: original title',
+ body: '',
+ pr: {
+ local: '1',
+ remote: {
+ number: '1',
+ author: { login: 'alice' },
+ body: '## Changelog Entry\n\n- First entry\n- Second entry\n- Third entry',
+ },
+ },
+ },
+ ], null);
+ const result = await generateChangesetFromGit(dummyGit, '1.0.0', 10);
+ expect(result.changelog).toMatchSnapshot();
+ });
+
+ it('handles nested bullets in changelog entry', async () => {
+ setup([
+ {
+ hash: 'abc123',
+ title: 'feat: original title',
+ body: '',
+ pr: {
+ local: '1',
+ remote: {
+ number: '1',
+ author: { login: 'alice' },
+ body: '## Changelog Entry\n\n- Main entry\n - Nested item 1\n - Nested item 2',
+ },
+ },
+ },
+ ], null);
+ const result = await generateChangesetFromGit(dummyGit, '1.0.0', 10);
+ expect(result.changelog).toMatchSnapshot();
+ });
+ });
+});
+
diff --git a/src/utils/__tests__/changelog-utils.test.ts b/src/utils/__tests__/changelog-utils.test.ts
new file mode 100644
index 00000000..0a7be1f5
--- /dev/null
+++ b/src/utils/__tests__/changelog-utils.test.ts
@@ -0,0 +1,144 @@
+/**
+ * Tests for changelog utility functions.
+ * - shouldExcludePR: Checks if a PR should be excluded from changelog
+ * - shouldSkipCurrentPR: Checks if current PR should skip changelog generation
+ * - getBumpTypeForPR: Determines the version bump type for a PR
+ *
+ * Note: shouldSkipCurrentPR and getBumpTypeForPR read config internally,
+ * so they only take the PRInfo argument. More comprehensive tests are
+ * in the main changelog.test.ts file with proper config mocking.
+ */
+
+import {
+ shouldExcludePR,
+ shouldSkipCurrentPR,
+ getBumpTypeForPR,
+ SKIP_CHANGELOG_MAGIC_WORD,
+ BODY_IN_CHANGELOG_MAGIC_WORD,
+ type CurrentPRInfo,
+} from '../changelog';
+
+describe('shouldExcludePR', () => {
+ // Config must match NormalizedReleaseConfig structure
+ const baseConfig = {
+ changelog: {
+ exclude: {
+ labels: ['skip-changelog', 'no-changelog'],
+ authors: new Set(['dependabot', 'renovate']),
+ },
+ },
+ };
+
+ it('returns true when PR has excluded label', () => {
+ expect(shouldExcludePR(
+ new Set(['bug', 'skip-changelog']),
+ 'alice',
+ baseConfig as any,
+ ''
+ )).toBe(true);
+ });
+
+ it('returns true when PR has excluded author', () => {
+ expect(shouldExcludePR(
+ new Set(['bug']),
+ 'dependabot',
+ baseConfig as any,
+ ''
+ )).toBe(true);
+ });
+
+ it('returns false when PR has no exclusion criteria', () => {
+ expect(shouldExcludePR(
+ new Set(['bug']),
+ 'alice',
+ baseConfig as any,
+ ''
+ )).toBe(false);
+ });
+
+ it('returns true when body contains skip magic word', () => {
+ expect(shouldExcludePR(
+ new Set(['bug']),
+ 'alice',
+ baseConfig as any,
+ `Some text\n${SKIP_CHANGELOG_MAGIC_WORD}\nMore text`
+ )).toBe(true);
+ });
+
+ it('returns false when config is null', () => {
+ expect(shouldExcludePR(
+ new Set(['bug']),
+ 'alice',
+ null,
+ ''
+ )).toBe(false);
+ });
+});
+
+describe('shouldSkipCurrentPR', () => {
+ // Note: This function reads config internally, so we can only test
+ // the skip magic word behavior without mocking
+ const basePRInfo: CurrentPRInfo = {
+ number: 123,
+ title: 'Test PR',
+ body: '',
+ author: 'alice',
+ labels: [],
+ baseRef: 'main',
+ };
+
+ it('returns false when PR has no skip magic word', () => {
+ expect(shouldSkipCurrentPR(basePRInfo)).toBe(false);
+ });
+
+ it('returns true when PR body contains skip magic word', () => {
+ const prInfo = { ...basePRInfo, body: `Some text\n${SKIP_CHANGELOG_MAGIC_WORD}` };
+ expect(shouldSkipCurrentPR(prInfo)).toBe(true);
+ });
+});
+
+describe('getBumpTypeForPR', () => {
+ // Note: This function reads config internally and uses default
+ // conventional commits patterns
+ const basePRInfo: CurrentPRInfo = {
+ number: 123,
+ title: 'feat: new feature',
+ body: '',
+ author: 'alice',
+ labels: [],
+ baseRef: 'main',
+ };
+
+ it('returns major for breaking changes', () => {
+ const prInfo = { ...basePRInfo, title: 'feat!: breaking change' };
+ expect(getBumpTypeForPR(prInfo)).toBe('major');
+ });
+
+ it('returns minor for feat commits', () => {
+ const prInfo = { ...basePRInfo, title: 'feat: new feature' };
+ expect(getBumpTypeForPR(prInfo)).toBe('minor');
+ });
+
+ it('returns patch for fix commits', () => {
+ const prInfo = { ...basePRInfo, title: 'fix: bug fix' };
+ expect(getBumpTypeForPR(prInfo)).toBe('patch');
+ });
+
+ it('returns null for unrecognized commit types', () => {
+ const prInfo = { ...basePRInfo, title: 'random commit' };
+ expect(getBumpTypeForPR(prInfo)).toBeNull();
+ });
+});
+
+describe('magic word constants', () => {
+ it('SKIP_CHANGELOG_MAGIC_WORD is defined', () => {
+ expect(SKIP_CHANGELOG_MAGIC_WORD).toBeDefined();
+ expect(typeof SKIP_CHANGELOG_MAGIC_WORD).toBe('string');
+ });
+
+ it('BODY_IN_CHANGELOG_MAGIC_WORD is defined', () => {
+ expect(BODY_IN_CHANGELOG_MAGIC_WORD).toBeDefined();
+ expect(typeof BODY_IN_CHANGELOG_MAGIC_WORD).toBe('string');
+ });
+});
+
diff --git a/src/utils/__tests__/changelog.test.ts b/src/utils/__tests__/changelog.test.ts
deleted file mode 100644
index d467d424..00000000
--- a/src/utils/__tests__/changelog.test.ts
+++ /dev/null
@@ -1,4237 +0,0 @@
-/* eslint-env jest */
-
-jest.mock('../githubApi.ts');
-import { getGitHubClient } from '../githubApi';
-jest.mock('../git');
-import { getChangesSince } from '../git';
-jest.mock('fs', () => ({
- ...jest.requireActual('fs'),
- readFileSync: jest.fn(),
-}));
-jest.mock('../../config', () => ({
- ...jest.requireActual('../../config'),
- getConfigFileDir: jest.fn(),
- getGlobalGitHubConfig: jest.fn(),
-}));
-import * as config from '../../config';
-
-import { readFileSync } from 'fs';
-import type { SimpleGit } from 'simple-git';
-
-import {
- findChangeset,
- removeChangeset,
- prependChangeset,
- generateChangesetFromGit,
- extractScope,
- formatScopeTitle,
- extractChangelogEntry,
- clearChangesetCache,
- shouldExcludePR,
- shouldSkipCurrentPR,
- getBumpTypeForPR,
- SKIP_CHANGELOG_MAGIC_WORD,
- BODY_IN_CHANGELOG_MAGIC_WORD,
- CurrentPRInfo,
-} from '../changelog';
-import {
- SAMPLE_CHANGESET,
- SAMPLE_CHANGESET_WITH_SUBHEADING,
- createFullChangelog,
- type TestCommit,
-} from './fixtures/changelog';
-
-const getConfigFileDirMock = config.getConfigFileDir as jest.MockedFunction;
-const getGlobalGitHubConfigMock = config.getGlobalGitHubConfig as jest.MockedFunction;
-const readFileSyncMock = readFileSync as jest.MockedFunction;
-
-describe('findChangeset', () => {
- test.each([
- [
- 'regular ATX heading',
- `# Changelog\n## ${SAMPLE_CHANGESET.name}\n${SAMPLE_CHANGESET.body}\n`,
- ],
- [
- 'with date in parentheses',
- createFullChangelog('Changelog', [
- { version: '1.0.1', body: 'newer' },
- { version: `${SAMPLE_CHANGESET.name} (2019-02-02)`, body: SAMPLE_CHANGESET.body },
- { version: '0.9.0', body: 'older' },
- ]),
- ],
- [
- 'between other headings',
- createFullChangelog('Changelog', [
- { version: '1.0.1', body: 'newer' },
- { version: SAMPLE_CHANGESET.name, body: SAMPLE_CHANGESET.body },
- { version: '0.9.0', body: 'older' },
- ]),
- ],
- [
- 'setext-style headings',
- `Changelog\n====\n${SAMPLE_CHANGESET.name}\n----\n${SAMPLE_CHANGESET.body}\n`,
- ],
- ])('should extract %s', (_testName, markdown) => {
- expect(findChangeset(markdown, 'v1.0.0')).toEqual(SAMPLE_CHANGESET);
- });
-
- test('supports sub-headings within version section', () => {
- const markdown = createFullChangelog('Changelog', [
- { version: SAMPLE_CHANGESET_WITH_SUBHEADING.name, body: SAMPLE_CHANGESET_WITH_SUBHEADING.body },
- ]);
- expect(findChangeset(markdown, 'v1.0.0')).toEqual(SAMPLE_CHANGESET_WITH_SUBHEADING);
- });
-
- test.each([
- ['changeset cannot be found', 'v1.0.0'],
- ['invalid version', 'not a version'],
- ])('should return null on %s', (_testName, version) => {
- const markdown = createFullChangelog('Changelog', [
- { version: '1.0.1', body: 'newer' },
- { version: '0.9.0', body: 'older' },
- ]);
- expect(findChangeset(markdown, version)).toEqual(null);
- });
-});
-
-describe('removeChangeset', () => {
- // Use non-indented markdown to enable proper marked parsing
- const fullChangelog = [
- '# Changelog',
- '',
- '## 1.0.1',
- '',
- 'newer',
- '',
- '1.0.0',
- '-------',
- '',
- 'this is a test',
- '',
- '## 0.9.1',
- '',
- 'slightly older',
- '',
- '## 0.9.0',
- '',
- 'older',
- ].join('\n');
-
- test('removes from the top', () => {
- const expected = [
- '# Changelog',
- '',
- '1.0.0',
- '-------',
- '',
- 'this is a test',
- '',
- '## 0.9.1',
- '',
- 'slightly older',
- '',
- '## 0.9.0',
- '',
- 'older',
- ].join('\n');
- expect(removeChangeset(fullChangelog, '1.0.1')).toEqual(expected);
- });
-
- test('removes from the middle', () => {
- const expected = [
- '# Changelog',
- '',
- '## 1.0.1',
- '',
- 'newer',
- '',
- '1.0.0',
- '-------',
- '',
- 'this is a test',
- '',
- '## 0.9.0',
- '',
- 'older',
- ].join('\n');
- expect(removeChangeset(fullChangelog, '0.9.1')).toEqual(expected);
- });
-
- test('removes setext-style heading', () => {
- const expected = [
- '# Changelog',
- '',
- '## 1.0.1',
- '',
- 'newer',
- '',
- '## 0.9.1',
- '',
- 'slightly older',
- '',
- '## 0.9.0',
- '',
- 'older',
- ].join('\n');
- expect(removeChangeset(fullChangelog, '1.0.0')).toEqual(expected);
- });
-
- test('removes from the bottom', () => {
- const expected = [
- '# Changelog',
- '',
- '## 1.0.1',
- '',
- 'newer',
- '',
- '1.0.0',
- '-------',
- '',
- 'this is a test',
- '',
- '## 0.9.1',
- '',
- 'slightly older',
- '',
- '', // trailing newline from removing last section
- ].join('\n');
- expect(removeChangeset(fullChangelog, '0.9.0')).toEqual(expected);
- });
-
- test('returns unchanged when header not found', () => {
- expect(removeChangeset(fullChangelog, 'non-existent version')).toEqual(fullChangelog);
- });
-
- test('returns unchanged when header is empty', () => {
- expect(removeChangeset(fullChangelog, '')).toEqual(fullChangelog);
- });
-});
-
-describe('prependChangeset', () => {
- const newChangeset = {
- body: '- rewrote everything from scratch\n- with multiple lines',
- name: '2.0.0',
- };
-
- test.each([
- ['to empty text', '', '## 2.0.0\n\n- rewrote everything from scratch\n- with multiple lines\n\n'],
- [
- 'without top-level header',
- '## 1.0.0\n\nthis is a test\n',
- '## 2.0.0\n\n- rewrote everything from scratch\n- with multiple lines\n\n## 1.0.0\n\nthis is a test\n',
- ],
- [
- 'after top-level header (empty body)',
- '# Changelog\n',
- '# Changelog\n## 2.0.0\n\n- rewrote everything from scratch\n- with multiple lines\n\n',
- ],
- [
- 'after top-level header',
- '# Changelog\n\n## 1.0.0\n\nthis is a test\n',
- '# Changelog\n\n## 2.0.0\n\n- rewrote everything from scratch\n- with multiple lines\n\n## 1.0.0\n\nthis is a test\n',
- ],
- [
- 'matching setext style when detected',
- '# Changelog\n\n1.0.0\n-----\n\nthis is a test\n',
- '# Changelog\n\n2.0.0\n-----\n\n- rewrote everything from scratch\n- with multiple lines\n\n1.0.0\n-----\n\nthis is a test\n',
- ],
- ])('prepends %s', (_testName, markdown, expected) => {
- expect(prependChangeset(markdown, newChangeset)).toEqual(expected);
- });
-});
-
-describe('generateChangesetFromGit', () => {
- let mockClient: jest.Mock;
-
- const mockGetChangesSince = getChangesSince as jest.MockedFunction<
- typeof getChangesSince
- >;
- const dummyGit = {} as SimpleGit;
-
- beforeEach(() => {
- jest.resetAllMocks();
- mockClient = jest.fn();
- (
- getGitHubClient as jest.MockedFunction<
- typeof getGitHubClient
- // @ts-ignore we only need to mock a subset
- >
- ).mockReturnValue({ graphql: mockClient } as any);
- // Default: no config file
- getConfigFileDirMock.mockReturnValue(undefined);
- getGlobalGitHubConfigMock.mockResolvedValue({
- repo: 'test-repo',
- owner: 'test-owner',
- });
- readFileSyncMock.mockImplementation(() => {
- const error: any = new Error('ENOENT');
- error.code = 'ENOENT';
- throw error;
- });
- });
-
- function setup(commits: TestCommit[], releaseConfig?: string | null): void {
- // Clear memoization cache to ensure fresh results
- clearChangesetCache();
-
- mockGetChangesSince.mockResolvedValueOnce(
- commits.map(commit => ({
- hash: commit.hash,
- title: commit.title,
- body: commit.body,
- pr: commit.pr?.local || null,
- }))
- );
-
- mockClient.mockResolvedValueOnce({
- repository: Object.fromEntries(
- commits.map(({ hash, author, title, pr }: TestCommit) => [
- `C${hash}`,
- {
- author: { user: author },
- associatedPullRequests: {
- nodes: pr?.remote
- ? [
- {
- author: pr.remote.author,
- number: pr.remote.number,
- title: pr.remote.title ?? title,
- body: pr.remote.body || '',
- labels: {
- nodes: (pr.remote.labels || []).map(label => ({
- name: label,
- })),
- },
- },
- ]
- : [],
- },
- },
- ])
- ),
- });
-
- // Mock release config file reading
- if (releaseConfig !== undefined) {
- if (releaseConfig === null) {
- getConfigFileDirMock.mockReturnValue(undefined);
- readFileSyncMock.mockImplementation(() => {
- const error: any = new Error('ENOENT');
- error.code = 'ENOENT';
- throw error;
- });
- } else {
- getConfigFileDirMock.mockReturnValue('/workspace');
- readFileSyncMock.mockImplementation((path: any) => {
- if (
- typeof path === 'string' &&
- path.includes('.github/release.yml')
- ) {
- return releaseConfig;
- }
- const error: any = new Error('ENOENT');
- error.code = 'ENOENT';
- throw error;
- });
- }
- }
- }
-
- it.each([
- ['empty changeset', [], null, ''],
- [
- 'short commit SHA for local commits w/o pull requests',
- [
- {
- hash: 'abcdef1234567890',
- title: 'Upgraded the kernel',
- body: '',
- },
- ],
- null,
- '- Upgraded the kernel in [abcdef12](https://github.com/test-owner/test-repo/commit/abcdef1234567890)',
- ],
- [
- 'use pull request number when available locally',
- [
- {
- hash: 'abcdef1234567890',
- title: 'Upgraded the kernel (#123)',
- body: '',
- pr: { local: '123' },
- },
- ],
- null,
- // Local PR: links to the PR (strips duplicate PR number from title)
- '- Upgraded the kernel in [#123](https://github.com/test-owner/test-repo/pull/123)',
- ],
- [
- 'use pull request number when available remotely',
- [
- {
- hash: 'abcdef1234567890',
- title: 'Upgraded the kernel',
- body: '',
- pr: { remote: { number: '123', author: { login: 'sentry' } } },
- },
- ],
- null,
- '- Upgraded the kernel by @sentry in [#123](https://github.com/test-owner/test-repo/pull/123)',
- ],
- [
- 'Does not error when PR author is null',
- [
- {
- hash: 'abcdef1234567890',
- title: 'Upgraded the kernel',
- body: '',
- pr: { remote: { number: '123' } },
- },
- ],
- null,
- '- Upgraded the kernel in [#123](https://github.com/test-owner/test-repo/pull/123)',
- ],
- [
- 'use PR title from GitHub instead of commit message',
- [
- {
- hash: 'abcdef1234567890',
- title: 'fix: quick fix for issue', // commit message
- body: '',
- pr: {
- remote: {
- number: '123',
- title: 'feat: A much better PR title with more context', // actual PR title
- author: { login: 'sentry' },
- },
- },
- },
- ],
- null,
- // Default config matches "feat:" prefix, so it goes to "New Features" category
- '### New Features ✨\n\n- feat: A much better PR title with more context by @sentry in [#123](https://github.com/test-owner/test-repo/pull/123)',
- ],
- [
- 'handle multiple commits properly',
- [
- {
- hash: 'abcdef1234567890',
- title: 'Upgraded the kernel',
- body: '',
- },
- {
- hash: 'bcdef1234567890a',
- title: 'Upgraded the manifold (#123)',
- body: '',
- pr: {
- local: '123',
- remote: { number: '123', author: { login: 'alice' } },
- },
- },
- {
- hash: 'cdef1234567890ab',
- title: 'Refactored the crankshaft',
- body: '',
- pr: { remote: { number: '456', author: { login: 'bob' } } },
- },
- {
- hash: 'cdef1234567890ad',
- title: 'Refactored the crankshaft again',
- body: '',
- pr: { remote: { number: '458', author: { login: 'bob' } } },
- },
- ],
- null,
- [
- '- Upgraded the kernel in [abcdef12](https://github.com/test-owner/test-repo/commit/abcdef1234567890)',
- '- Upgraded the manifold by @alice in [#123](https://github.com/test-owner/test-repo/pull/123)',
- '- Refactored the crankshaft by @bob in [#456](https://github.com/test-owner/test-repo/pull/456)',
- '',
- '_Plus 1 more_',
- ].join('\n'),
- ],
- [
- 'group prs under categories',
- [
- {
- hash: 'bcdef1234567890a',
- title: 'Upgraded the manifold (#123)',
- body: '',
- pr: {
- local: '123',
- remote: {
- number: '123',
- author: { login: 'alice' },
- labels: ['drivetrain'],
- },
- },
- },
- {
- hash: 'cdef1234567890ab',
- title: 'Refactored the crankshaft',
- body: '',
- pr: {
- remote: {
- number: '456',
- author: { login: 'bob' },
- labels: ['drivetrain'],
- },
- },
- },
- {
- hash: 'def1234567890abc',
- title: 'Upgrade the HUD (#789)',
- body: '',
- pr: {
- local: '789',
- remote: {
- number: '789',
- author: { login: 'charlie' },
- labels: ['driver-experience'],
- },
- },
- },
- {
- hash: 'ef1234567890abcd',
- title: 'Upgrade the steering wheel (#900)',
- body: '',
- pr: {
- local: '900',
- remote: {
- number: '900',
- author: { login: 'charlie' },
- labels: ['driver-experience'],
- },
- },
- },
- {
- hash: 'f1234567890abcde',
- title: 'Fix the clacking sound on gear changes (#950)',
- body: '',
- pr: {
- local: '950',
- remote: { number: '950', author: { login: 'bob' } },
- },
- },
- ],
- `changelog:
- categories:
- - title: Better drivetrain
- labels:
- - drivetrain
- - title: Better driver experience
- labels:
- - driver-experience`,
- [
- '### Better drivetrain',
- '',
- '- Upgraded the manifold by @alice in [#123](https://github.com/test-owner/test-repo/pull/123)',
- '- Refactored the crankshaft by @bob in [#456](https://github.com/test-owner/test-repo/pull/456)',
- '',
- '### Better driver experience',
- '',
- '- Upgrade the HUD by @charlie in [#789](https://github.com/test-owner/test-repo/pull/789)',
- '- Upgrade the steering wheel by @charlie in [#900](https://github.com/test-owner/test-repo/pull/900)',
- '',
- '### Other',
- '',
- '- Fix the clacking sound on gear changes by @bob in [#950](https://github.com/test-owner/test-repo/pull/950)',
- ].join('\n'),
- ],
- [
- 'should escape # signs on category titles',
- [
- {
- hash: 'abcdef1234567890',
- title: 'Upgraded the kernel',
- body: '',
- pr: {
- local: '123',
- remote: {
- number: '123',
- author: { login: 'sentry' },
- labels: ['drivetrain'],
- },
- },
- },
- ],
- `changelog:
- categories:
- - title: "Drivetrain #1 in town"
- labels:
- - drivetrain`,
- [
- '### Drivetrain #1 in town',
- '',
- '- Upgraded the kernel by @sentry in [#123](https://github.com/test-owner/test-repo/pull/123)',
- ].join('\n'),
- ],
- [
- 'should escape leading underscores in changelog entries',
- [
- {
- hash: 'abcdef1234567890',
- title: 'Serialized _meta (#123)',
- body: '',
- pr: { local: '123' },
- },
- ],
- null,
- // Local PR: links to the PR (strips duplicate PR number from title)
- '- Serialized \\_meta in [#123](https://github.com/test-owner/test-repo/pull/123)',
- ],
- // NOTE: #skip-changelog is now redundant as we can skip PRs with certain labels
- // via .github/release.yml configuration (changelog.exclude.labels)
- [
- `should skip commits & prs with the magic ${SKIP_CHANGELOG_MAGIC_WORD}`,
- [
- {
- hash: 'bcdef1234567890a',
- title: 'Upgraded the manifold (#123)',
- body: '',
- pr: {
- local: '123',
- remote: {
- number: '123',
- author: { login: 'alice' },
- labels: ['drivetrain'],
- },
- },
- },
- {
- hash: 'cdef1234567890ab',
- title: 'Refactored the crankshaft',
- body: '',
- pr: {
- remote: {
- number: '456',
- author: { login: 'bob' },
- body: `This is important but we'll ${SKIP_CHANGELOG_MAGIC_WORD} for internal.`,
- labels: ['drivetrain'],
- },
- },
- },
- {
- hash: 'def1234567890abc',
- title: 'Upgrade the HUD (#789)',
- body: '',
- pr: {
- local: '789',
- remote: {
- number: '789',
- author: { login: 'charlie' },
- labels: ['driver-experience'],
- },
- },
- },
- {
- hash: 'f1234567890abcde',
- title: 'Fix the clacking sound on gear changes (#950)',
- body: '',
- pr: {
- local: '950',
- remote: { number: '950', author: { login: 'alice' } },
- },
- },
- ],
- `changelog:
- categories:
- - title: Better drivetrain
- labels:
- - drivetrain
- - title: Better driver experience
- labels:
- - driver-experience`,
- [
- '### Better drivetrain',
- '',
- '- Upgraded the manifold by @alice in [#123](https://github.com/test-owner/test-repo/pull/123)',
- '',
- '### Better driver experience',
- '',
- '- Upgrade the HUD by @charlie in [#789](https://github.com/test-owner/test-repo/pull/789)',
- '',
- '### Other',
- '',
- '- Fix the clacking sound on gear changes by @alice in [#950](https://github.com/test-owner/test-repo/pull/950)',
- ].join('\n'),
- ],
- [
- `should expand commits & prs with the magic ${BODY_IN_CHANGELOG_MAGIC_WORD}`,
- [
- {
- hash: 'bcdef1234567890a',
- title: 'Upgraded the manifold (#123)',
- body: '',
- pr: {
- local: '123',
- remote: {
- number: '123',
- author: { login: 'alice' },
- labels: ['drivetrain'],
- },
- },
- },
- {
- hash: 'cdef1234567890ab',
- title: 'Refactored the crankshaft',
- body: '',
- pr: {
- remote: {
- number: '456',
- author: { login: 'bob' },
- body: `This is important and we'll include the __body__ for attention. ${BODY_IN_CHANGELOG_MAGIC_WORD}`,
- labels: ['drivetrain'],
- },
- },
- },
- {
- hash: 'def1234567890abc',
- title: 'Upgrade the HUD (#789)',
- body: '',
- pr: {
- local: '789',
- remote: {
- number: '789',
- author: { login: 'charlie' },
- labels: ['driver-experience'],
- },
- },
- },
- {
- hash: 'ef1234567890abcd',
- title: 'Upgrade the steering wheel (#900)',
- body: `Some very important update ${BODY_IN_CHANGELOG_MAGIC_WORD}`,
- pr: { local: '900' },
- },
- {
- hash: 'f1234567890abcde',
- title: 'Fix the clacking sound on gear changes (#950)',
- body: '',
- pr: {
- local: '950',
- remote: { number: '950', author: { login: 'alice' } },
- },
- },
- ],
- `changelog:
- categories:
- - title: Better drivetrain
- labels:
- - drivetrain
- - title: Better driver experience
- labels:
- - driver-experience`,
- [
- '### Better drivetrain',
- '',
- '- Upgraded the manifold by @alice in [#123](https://github.com/test-owner/test-repo/pull/123)',
- '- Refactored the crankshaft by @bob in [#456](https://github.com/test-owner/test-repo/pull/456)',
- " This is important and we'll include the __body__ for attention.",
- '',
- '### Better driver experience',
- '',
- '- Upgrade the HUD by @charlie in [#789](https://github.com/test-owner/test-repo/pull/789)',
- '',
- '### Other',
- '',
- // Local PR: links to the PR (strips duplicate PR number from title)
- '- Upgrade the steering wheel in [#900](https://github.com/test-owner/test-repo/pull/900)',
- ' Some very important update',
- '- Fix the clacking sound on gear changes by @alice in [#950](https://github.com/test-owner/test-repo/pull/950)',
- ].join('\n'),
- ],
- ])(
- '%s',
- async (
- _name: string,
- commits: TestCommit[],
- releaseConfig: string | null,
- output: string
- ) => {
- setup(commits, releaseConfig);
- const result = await generateChangesetFromGit(dummyGit, '1.0.0', 3);
- const changes = result.changelog;
- expect(changes).toBe(output);
- }
- );
-
- describe('category matching', () => {
- it('should match PRs to categories based on labels', async () => {
- const releaseConfigYaml = `changelog:
- categories:
- - title: Features
- labels:
- - feature
- - title: Bug Fixes
- labels:
- - bug`;
-
- setup(
- [
- {
- hash: 'abc123',
- title: 'Feature PR',
- body: '',
- pr: {
- remote: {
- number: '1',
- author: { login: 'alice' },
- labels: ['feature'],
- },
- },
- },
- {
- hash: 'def456',
- title: 'Bug fix PR',
- body: '',
- pr: {
- remote: {
- number: '2',
- author: { login: 'bob' },
- labels: ['bug'],
- },
- },
- },
- ],
- releaseConfigYaml
- );
-
- // Verify mocks are set up before calling generateChangesetFromGit
- expect(getConfigFileDirMock).toBeDefined();
- expect(readFileSyncMock).toBeDefined();
-
- const result = await generateChangesetFromGit(dummyGit, '1.0.0', 3);
- const changes = result.changelog;
-
- // Verify getConfigFileDir was called
- expect(getConfigFileDirMock).toHaveBeenCalled();
- // Verify readFileSync was called to read the config
- expect(readFileSyncMock).toHaveBeenCalled();
-
- expect(changes).toContain('### Features');
- expect(changes).toContain('### Bug Fixes');
- expect(changes).toContain(
- 'Feature PR by @alice in [#1](https://github.com/test-owner/test-repo/pull/1)'
- );
- expect(changes).toContain(
- 'Bug fix PR by @bob in [#2](https://github.com/test-owner/test-repo/pull/2)'
- );
- });
-
- it('should apply global exclusions', async () => {
- setup(
- [
- {
- hash: 'abc123',
- title: 'Internal PR (#1)',
- body: '',
- pr: {
- remote: {
- number: '1',
- author: { login: 'alice' },
- labels: ['internal'],
- },
- },
- },
- {
- hash: 'def456',
- title: 'Public PR (#2)',
- body: '',
- pr: {
- remote: {
- number: '2',
- author: { login: 'bob' },
- labels: ['feature'],
- },
- },
- },
- ],
- `changelog:
- exclude:
- labels:
- - internal
- categories:
- - title: Features
- labels:
- - feature`
- );
-
- const result = await generateChangesetFromGit(dummyGit, '1.0.0', 3);
- const changes = result.changelog;
- expect(changes).not.toContain('#1');
- expect(changes).toContain('#2');
- });
-
- it('should apply category-level exclusions', async () => {
- setup(
- [
- {
- hash: 'abc123',
- title: 'Feature PR (#1)',
- body: '',
- pr: {
- remote: {
- number: '1',
- author: { login: 'alice' },
- labels: ['feature', 'skip-release'],
- },
- },
- },
- {
- hash: 'def456',
- title: 'Another Feature PR (#2)',
- body: '',
- pr: {
- remote: {
- number: '2',
- author: { login: 'bob' },
- labels: ['feature'],
- },
- },
- },
- ],
- `changelog:
- categories:
- - title: Features
- labels:
- - feature
- exclude:
- labels:
- - skip-release`
- );
-
- const result = await generateChangesetFromGit(dummyGit, '1.0.0', 3);
- const changes = result.changelog;
- // PR #1 is excluded from Features category but should appear in Other
- // (category-level exclusions only exclude from that specific category)
- expect(changes).toContain('#1');
- // PR #1 should NOT be in the Features section
- const featuresSection = changes.split('### Other')[0];
- expect(featuresSection).not.toContain('Feature PR by @alice');
- // But it should be in the Other section
- const otherSection = changes.split('### Other')[1];
- expect(otherSection).toContain('Feature PR by @alice in [#1]');
- expect(changes).toContain('#2');
- expect(changes).toContain('### Features');
- });
-
- it('should support wildcard category matching', async () => {
- setup(
- [
- {
- hash: 'abc123',
- title: 'Any PR (#1)',
- body: '',
- pr: {
- remote: {
- number: '1',
- author: { login: 'alice' },
- labels: ['random-label'],
- },
- },
- },
- ],
- `changelog:
- categories:
- - title: All Changes
- labels:
- - '*'`
- );
-
- const result = await generateChangesetFromGit(dummyGit, '1.0.0', 3);
- const changes = result.changelog;
- expect(changes).toContain('### All Changes');
- expect(changes).toContain(
- 'Any PR by @alice in [#1](https://github.com/test-owner/test-repo/pull/1)'
- );
- });
-
- it('should use default conventional commits config when no config exists', async () => {
- setup(
- [
- {
- hash: 'abc123',
- title: 'feat: Add new feature (#1)',
- body: '',
- pr: {
- remote: {
- number: '1',
- author: { login: 'alice' },
- labels: [],
- },
- },
- },
- {
- hash: 'def456',
- title: 'fix: Bug fix (#2)',
- body: '',
- pr: {
- remote: {
- number: '2',
- author: { login: 'bob' },
- labels: [],
- },
- },
- },
- ],
- null
- );
-
- const result = await generateChangesetFromGit(dummyGit, '1.0.0', 3);
- const changes = result.changelog;
- // When no config exists, default conventional commits patterns are used
- expect(changes).toContain('### New Features');
- expect(changes).toContain('### Bug Fixes');
- expect(changes).toContain('#1');
- expect(changes).toContain('#2');
- });
-
- it('should categorize PRs without author in their designated category', async () => {
- setup(
- [
- {
- hash: 'abc123',
- title: 'Feature PR without author',
- body: '',
- pr: {
- remote: {
- number: '1',
- // No author - simulates deleted GitHub user
- labels: ['feature'],
- },
- },
- },
- ],
- `changelog:
- categories:
- - title: Features
- labels:
- - feature`
- );
-
- const result = await generateChangesetFromGit(dummyGit, '1.0.0', 3);
- const changes = result.changelog;
- expect(changes).toContain('### Features');
- expect(changes).not.toContain('### Other');
- expect(changes).toContain(
- 'Feature PR without author in [#1](https://github.com/test-owner/test-repo/pull/1)'
- );
- });
-
- it('should handle malformed release config gracefully (non-array categories)', async () => {
- setup(
- [
- {
- hash: 'abc123',
- title: 'Some PR (#1)',
- body: '',
- pr: {
- remote: {
- number: '1',
- author: { login: 'alice' },
- labels: ['feature'],
- },
- },
- },
- ],
- `changelog:
- categories: "this is a string, not an array"`
- );
-
- const result = await generateChangesetFromGit(dummyGit, '1.0.0', 3);
- const changes = result.changelog;
- // Should not crash, and PR should appear in output (no categories applied)
- expect(changes).toContain('#1');
- });
- });
-
- describe('commit_patterns matching', () => {
- it('should match PRs to categories based on commit_patterns', async () => {
- const releaseConfigYaml = `changelog:
- categories:
- - title: Features
- commit_patterns:
- - "^feat:"
- - title: Bug Fixes
- commit_patterns:
- - "^fix:"`;
-
- setup(
- [
- {
- hash: 'abc123',
- title: 'feat: add new feature',
- body: '',
- pr: {
- remote: {
- number: '1',
- author: { login: 'alice' },
- labels: [],
- },
- },
- },
- {
- hash: 'def456',
- title: 'fix: resolve bug',
- body: '',
- pr: {
- remote: {
- number: '2',
- author: { login: 'bob' },
- labels: [],
- },
- },
- },
- ],
- releaseConfigYaml
- );
-
- const result = await generateChangesetFromGit(dummyGit, '1.0.0', 3);
- const changes = result.changelog;
-
- expect(changes).toContain('### Features');
- expect(changes).toContain('### Bug Fixes');
- expect(changes).toContain(
- 'feat: add new feature by @alice in [#1](https://github.com/test-owner/test-repo/pull/1)'
- );
- expect(changes).toContain(
- 'fix: resolve bug by @bob in [#2](https://github.com/test-owner/test-repo/pull/2)'
- );
- });
-
- it('should give labels precedence over commit_patterns', async () => {
- const releaseConfigYaml = `changelog:
- categories:
- - title: Labeled Features
- labels:
- - feature
- - title: Pattern Features
- commit_patterns:
- - "^feat:"`;
-
- setup(
- [
- {
- hash: 'abc123',
- title: 'feat: labeled feature',
- body: '',
- pr: {
- remote: {
- number: '1',
- author: { login: 'alice' },
- labels: ['feature'],
- },
- },
- },
- {
- hash: 'def456',
- title: 'feat: unlabeled feature',
- body: '',
- pr: {
- remote: {
- number: '2',
- author: { login: 'bob' },
- labels: [],
- },
- },
- },
- ],
- releaseConfigYaml
- );
-
- const result = await generateChangesetFromGit(dummyGit, '1.0.0', 3);
- const changes = result.changelog;
-
- expect(changes).toContain('### Labeled Features');
- expect(changes).toContain('### Pattern Features');
- // PR with label should be in Labeled Features (labels take precedence)
- const labeledSection = changes.split('### Pattern Features')[0];
- expect(labeledSection).toContain('feat: labeled feature by @alice');
- // PR without label should be in Pattern Features
- const patternSection = changes.split('### Pattern Features')[1];
- expect(patternSection).toContain('feat: unlabeled feature by @bob');
- });
-
- it('should use default conventional commits config when no release.yml exists', async () => {
- setup(
- [
- {
- hash: 'abc123',
- title: 'feat: new feature',
- body: '',
- pr: {
- remote: {
- number: '1',
- author: { login: 'alice' },
- labels: [],
- },
- },
- },
- {
- hash: 'def456',
- title: 'fix: bug fix',
- body: '',
- pr: {
- remote: {
- number: '2',
- author: { login: 'bob' },
- labels: [],
- },
- },
- },
- {
- hash: 'ghi789',
- title: 'docs: update readme',
- body: '',
- pr: {
- remote: {
- number: '3',
- author: { login: 'charlie' },
- labels: [],
- },
- },
- },
- {
- hash: 'jkl012',
- title: 'chore: update deps',
- body: '',
- pr: {
- remote: {
- number: '4',
- author: { login: 'dave' },
- labels: [],
- },
- },
- },
- {
- hash: 'mno345',
- title: 'feat(scope)!: breaking change',
- body: '',
- pr: {
- remote: {
- number: '5',
- author: { login: 'eve' },
- labels: [],
- },
- },
- },
- ],
- null // No release.yml - should use default config
- );
-
- const result = await generateChangesetFromGit(dummyGit, '1.0.0', 10);
- const changes = result.changelog;
-
- expect(changes).toContain('### New Features');
- expect(changes).toContain('### Bug Fixes');
- expect(changes).toContain('### Documentation');
- expect(changes).toContain('### Build / dependencies / internal');
- expect(changes).toContain('### Breaking Changes');
- });
-
- it('should handle invalid regex patterns gracefully', async () => {
- const releaseConfigYaml = `changelog:
- categories:
- - title: Features
- commit_patterns:
- - "^feat:"
- - "[invalid(regex"`;
-
- setup(
- [
- {
- hash: 'abc123',
- title: 'feat: new feature',
- body: '',
- pr: {
- remote: {
- number: '1',
- author: { login: 'alice' },
- labels: [],
- },
- },
- },
- ],
- releaseConfigYaml
- );
-
- // Should not crash, and valid pattern should still work
- const result = await generateChangesetFromGit(dummyGit, '1.0.0', 3);
- const changes = result.changelog;
- expect(changes).toContain('### Features');
- expect(changes).toContain('feat: new feature');
- });
-
- it('should support combined labels and commit_patterns in same category', async () => {
- const releaseConfigYaml = `changelog:
- categories:
- - title: Features
- labels:
- - enhancement
- commit_patterns:
- - "^feat:"`;
-
- setup(
- [
- {
- hash: 'abc123',
- title: 'add cool thing',
- body: '',
- pr: {
- remote: {
- number: '1',
- author: { login: 'alice' },
- labels: ['enhancement'],
- },
- },
- },
- {
- hash: 'def456',
- title: 'feat: another cool thing',
- body: '',
- pr: {
- remote: {
- number: '2',
- author: { login: 'bob' },
- labels: [],
- },
- },
- },
- ],
- releaseConfigYaml
- );
-
- const result = await generateChangesetFromGit(dummyGit, '1.0.0', 3);
- const changes = result.changelog;
-
- expect(changes).toContain('### Features');
- expect(changes).not.toContain('### Other');
- // Both PRs should be in Features
- expect(changes).toContain('add cool thing by @alice');
- expect(changes).toContain('feat: another cool thing by @bob');
- });
-
- it('should apply category exclusions to pattern-matched PRs', async () => {
- const releaseConfigYaml = `changelog:
- categories:
- - title: Features
- commit_patterns:
- - "^feat:"
- exclude:
- labels:
- - skip-release`;
-
- setup(
- [
- {
- hash: 'abc123',
- title: 'feat: feature to skip',
- body: '',
- pr: {
- remote: {
- number: '1',
- author: { login: 'alice' },
- labels: ['skip-release'],
- },
- },
- },
- {
- hash: 'def456',
- title: 'feat: normal feature',
- body: '',
- pr: {
- remote: {
- number: '2',
- author: { login: 'bob' },
- labels: [],
- },
- },
- },
- ],
- releaseConfigYaml
- );
-
- const result = await generateChangesetFromGit(dummyGit, '1.0.0', 3);
- const changes = result.changelog;
-
- expect(changes).toContain('### Features');
- // PR #1 should be excluded from Features (but appear in Other)
- expect(changes).toContain('### Other');
- const featuresSection = changes.split('### Other')[0];
- expect(featuresSection).not.toContain('feat: feature to skip');
- expect(featuresSection).toContain('feat: normal feature');
- });
-
- it('should match pattern case-insensitively', async () => {
- const releaseConfigYaml = `changelog:
- categories:
- - title: Features
- commit_patterns:
- - "^feat:"`;
-
- setup(
- [
- {
- hash: 'abc123',
- title: 'FEAT: uppercase feature',
- body: '',
- pr: {
- remote: {
- number: '1',
- author: { login: 'alice' },
- labels: [],
- },
- },
- },
- {
- hash: 'def456',
- title: 'Feat: mixed case feature',
- body: '',
- pr: {
- remote: {
- number: '2',
- author: { login: 'bob' },
- labels: [],
- },
- },
- },
- ],
- releaseConfigYaml
- );
-
- const result = await generateChangesetFromGit(dummyGit, '1.0.0', 3);
- const changes = result.changelog;
-
- expect(changes).toContain('### Features');
- expect(changes).not.toContain('### Other');
- expect(changes).toContain('FEAT: uppercase feature');
- expect(changes).toContain('Feat: mixed case feature');
- });
-
- it('should match PR title from commit log pattern with scope', async () => {
- const releaseConfigYaml = `changelog:
- categories:
- - title: Features
- commit_patterns:
- - "^feat(\\\\(\\\\w+\\\\))?:"`;
-
- setup(
- [
- {
- hash: 'abc123',
- title: 'feat(api): add endpoint',
- body: '',
- pr: {
- remote: {
- number: '1',
- author: { login: 'alice' },
- labels: [],
- },
- },
- },
- {
- hash: 'def456',
- title: 'feat: no scope',
- body: '',
- pr: {
- remote: {
- number: '2',
- author: { login: 'bob' },
- labels: [],
- },
- },
- },
- ],
- releaseConfigYaml
- );
-
- const result = await generateChangesetFromGit(dummyGit, '1.0.0', 3);
- const changes = result.changelog;
-
- expect(changes).toContain('### Features');
- expect(changes).toContain('feat(api): add endpoint');
- expect(changes).toContain('feat: no scope');
- });
-
- it('should match conventional commit scopes with dashes', async () => {
- setup(
- [
- {
- hash: 'abc123',
- title: 'fix(my-component): resolve issue',
- body: '',
- pr: {
- remote: {
- number: '1',
- author: { login: 'alice' },
- labels: [],
- },
- },
- },
- {
- hash: 'def456',
- title: 'feat(some-other-scope): add feature',
- body: '',
- pr: {
- remote: {
- number: '2',
- author: { login: 'bob' },
- labels: [],
- },
- },
- },
- {
- hash: 'ghi789',
- title: 'docs(multi-part-scope): update docs',
- body: '',
- pr: {
- remote: {
- number: '3',
- author: { login: 'charlie' },
- labels: [],
- },
- },
- },
- ],
- null // Use default config
- );
-
- const result = await generateChangesetFromGit(dummyGit, '1.0.0', 10);
- const changes = result.changelog;
-
- expect(changes).toContain('### Bug Fixes');
- expect(changes).toContain('### New Features');
- expect(changes).toContain('### Documentation');
- expect(changes).toContain('fix(my-component): resolve issue');
- expect(changes).toContain('feat(some-other-scope): add feature');
- expect(changes).toContain('docs(multi-part-scope): update docs');
- // Should NOT appear in Other section - all commits should be categorized
- expect(changes).not.toContain('### Other');
- });
-
- it('should match refactor and meta types in internal category', async () => {
- setup(
- [
- {
- hash: 'abc123',
- title: 'refactor: clean up code',
- body: '',
- pr: {
- remote: {
- number: '1',
- author: { login: 'alice' },
- labels: [],
- },
- },
- },
- {
- hash: 'def456',
- title: 'meta: update project config',
- body: '',
- pr: {
- remote: {
- number: '2',
- author: { login: 'bob' },
- labels: [],
- },
- },
- },
- {
- hash: 'ghi789',
- title: 'refactor(utils): restructure helpers',
- body: '',
- pr: {
- remote: {
- number: '3',
- author: { login: 'charlie' },
- labels: [],
- },
- },
- },
- ],
- null // Use default config
- );
-
- const result = await generateChangesetFromGit(dummyGit, '1.0.0', 10);
- const changes = result.changelog;
-
- expect(changes).toContain('### Build / dependencies / internal');
- expect(changes).toContain('refactor: clean up code');
- expect(changes).toContain('meta: update project config');
- expect(changes).toContain('refactor(utils): restructure helpers');
- // Should NOT appear in Other section
- expect(changes).not.toContain('### Other');
- });
-
- it('should match breaking changes with scopes containing dashes', async () => {
- setup(
- [
- {
- hash: 'abc123',
- title: 'feat(my-api)!: breaking api change',
- body: '',
- pr: {
- remote: {
- number: '1',
- author: { login: 'alice' },
- labels: [],
- },
- },
- },
- {
- hash: 'def456',
- title: 'fix!: breaking fix',
- body: '',
- pr: {
- remote: {
- number: '2',
- author: { login: 'bob' },
- labels: [],
- },
- },
- },
- ],
- null // Use default config
- );
-
- const result = await generateChangesetFromGit(dummyGit, '1.0.0', 10);
- const changes = result.changelog;
-
- expect(changes).toContain('### Breaking Changes');
- expect(changes).toContain('feat(my-api)!: breaking api change');
- expect(changes).toContain('fix!: breaking fix');
- });
-
- it('should trim leading and trailing whitespace from PR titles before pattern matching', async () => {
- const releaseConfigYaml = `changelog:
- categories:
- - title: Features
- commit_patterns:
- - "^feat:"
- - title: Bug Fixes
- commit_patterns:
- - "^fix:"`;
-
- setup(
- [
- {
- hash: 'abc123',
- title: ' feat: feature with leading space',
- body: '',
- pr: {
- remote: {
- number: '1',
- author: { login: 'alice' },
- labels: [],
- title: ' feat: feature with leading space', // PR title from GitHub has leading space
- },
- },
- },
- {
- hash: 'def456',
- title: 'fix: bug fix with trailing space ',
- body: '',
- pr: {
- remote: {
- number: '2',
- author: { login: 'bob' },
- labels: [],
- title: 'fix: bug fix with trailing space ', // PR title from GitHub has trailing space
- },
- },
- },
- ],
- releaseConfigYaml
- );
-
- const result = await generateChangesetFromGit(dummyGit, '1.0.0', 3);
- const changes = result.changelog;
-
- // Both should be properly categorized despite whitespace
- expect(changes).toContain('### Features');
- expect(changes).toContain('### Bug Fixes');
- // Titles should be trimmed in output (no leading/trailing spaces)
- expect(changes).toContain(
- 'feat: feature with leading space by @alice in [#1](https://github.com/test-owner/test-repo/pull/1)'
- );
- expect(changes).toContain(
- 'fix: bug fix with trailing space by @bob in [#2](https://github.com/test-owner/test-repo/pull/2)'
- );
- // Should NOT go to Other section
- expect(changes).not.toContain('### Other');
- });
- });
-
- describe('section ordering', () => {
- it('should sort sections by config order regardless of PR encounter order', async () => {
- // Config defines order: Features, Bug Fixes, Documentation
- // But PRs are encountered in order: Bug Fix, Documentation, Feature
- const releaseConfigYaml = `changelog:
- categories:
- - title: Features
- labels:
- - feature
- - title: Bug Fixes
- labels:
- - bug
- - title: Documentation
- labels:
- - docs`;
-
- setup(
- [
- // First PR encountered is a bug fix
- {
- hash: 'abc123',
- title: 'Fix critical bug',
- body: '',
- pr: {
- remote: {
- number: '1',
- author: { login: 'alice' },
- labels: ['bug'],
- },
- },
- },
- // Second PR is documentation
- {
- hash: 'def456',
- title: 'Update README',
- body: '',
- pr: {
- remote: {
- number: '2',
- author: { login: 'bob' },
- labels: ['docs'],
- },
- },
- },
- // Third PR is a feature
- {
- hash: 'ghi789',
- title: 'Add new feature',
- body: '',
- pr: {
- remote: {
- number: '3',
- author: { login: 'charlie' },
- labels: ['feature'],
- },
- },
- },
- ],
- releaseConfigYaml
- );
-
- const result = await generateChangesetFromGit(dummyGit, '1.0.0', 10);
- const changes = result.changelog;
-
- // Sections should appear in config order, not encounter order
- const featuresIndex = changes.indexOf('### Features');
- const bugFixesIndex = changes.indexOf('### Bug Fixes');
- const docsIndex = changes.indexOf('### Documentation');
-
- expect(featuresIndex).toBeGreaterThan(-1);
- expect(bugFixesIndex).toBeGreaterThan(-1);
- expect(docsIndex).toBeGreaterThan(-1);
-
- // Features should come before Bug Fixes, which should come before Documentation
- expect(featuresIndex).toBeLessThan(bugFixesIndex);
- expect(bugFixesIndex).toBeLessThan(docsIndex);
- });
-
- it('should maintain stable ordering with multiple PRs per category', async () => {
- const releaseConfigYaml = `changelog:
- categories:
- - title: Features
- labels:
- - feature
- - title: Bug Fixes
- labels:
- - bug`;
-
- setup(
- [
- // Mix of bug fixes and features, bug fixes encountered first
- {
- hash: 'abc123',
- title: 'First bug fix',
- body: '',
- pr: {
- remote: {
- number: '1',
- author: { login: 'alice' },
- labels: ['bug'],
- },
- },
- },
- {
- hash: 'def456',
- title: 'First feature',
- body: '',
- pr: {
- remote: {
- number: '2',
- author: { login: 'bob' },
- labels: ['feature'],
- },
- },
- },
- {
- hash: 'ghi789',
- title: 'Second bug fix',
- body: '',
- pr: {
- remote: {
- number: '3',
- author: { login: 'charlie' },
- labels: ['bug'],
- },
- },
- },
- {
- hash: 'jkl012',
- title: 'Second feature',
- body: '',
- pr: {
- remote: {
- number: '4',
- author: { login: 'dave' },
- labels: ['feature'],
- },
- },
- },
- ],
- releaseConfigYaml
- );
-
- const result = await generateChangesetFromGit(dummyGit, '1.0.0', 10);
- const changes = result.changelog;
-
- // Features should still come before Bug Fixes per config order
- const featuresIndex = changes.indexOf('### Features');
- const bugFixesIndex = changes.indexOf('### Bug Fixes');
-
- expect(featuresIndex).toBeGreaterThan(-1);
- expect(bugFixesIndex).toBeGreaterThan(-1);
- expect(featuresIndex).toBeLessThan(bugFixesIndex);
-
- // Both features should be in Features section
- expect(changes).toContain('First feature');
- expect(changes).toContain('Second feature');
- // Both bug fixes should be in Bug Fixes section
- expect(changes).toContain('First bug fix');
- expect(changes).toContain('Second bug fix');
- });
-
- it('should maintain default conventional commits order', async () => {
- // No config - uses default conventional commits categories
- // Order should be: Breaking Changes, Build/deps, Bug Fixes, Documentation, New Features
- setup(
- [
- // Encounter order: feat, fix, breaking, docs, chore
- {
- hash: 'abc123',
- title: 'feat: new feature',
- body: '',
- pr: {
- remote: {
- number: '1',
- author: { login: 'alice' },
- labels: [],
- },
- },
- },
- {
- hash: 'def456',
- title: 'fix: bug fix',
- body: '',
- pr: {
- remote: {
- number: '2',
- author: { login: 'bob' },
- labels: [],
- },
- },
- },
- {
- hash: 'ghi789',
- title: 'feat!: breaking change',
- body: '',
- pr: {
- remote: {
- number: '3',
- author: { login: 'charlie' },
- labels: [],
- },
- },
- },
- {
- hash: 'jkl012',
- title: 'docs: update docs',
- body: '',
- pr: {
- remote: {
- number: '4',
- author: { login: 'dave' },
- labels: [],
- },
- },
- },
- {
- hash: 'mno345',
- title: 'chore: update deps',
- body: '',
- pr: {
- remote: {
- number: '5',
- author: { login: 'eve' },
- labels: [],
- },
- },
- },
- ],
- null // No config - use defaults
- );
-
- const result = await generateChangesetFromGit(dummyGit, '1.0.0', 10);
- const changes = result.changelog;
-
- // Default order from DEFAULT_RELEASE_CONFIG:
- // Breaking Changes, Build/deps, Bug Fixes, Documentation, New Features
- const breakingIndex = changes.indexOf('### Breaking Changes');
- const buildIndex = changes.indexOf('### Build / dependencies / internal');
- const bugFixesIndex = changes.indexOf('### Bug Fixes');
- const docsIndex = changes.indexOf('### Documentation');
- const featuresIndex = changes.indexOf('### New Features');
-
- expect(breakingIndex).toBeGreaterThan(-1);
- expect(buildIndex).toBeGreaterThan(-1);
- expect(bugFixesIndex).toBeGreaterThan(-1);
- expect(docsIndex).toBeGreaterThan(-1);
- expect(featuresIndex).toBeGreaterThan(-1);
-
- // Verify order matches default config order
- expect(breakingIndex).toBeLessThan(buildIndex);
- expect(featuresIndex).toBeLessThan(bugFixesIndex);
- expect(bugFixesIndex).toBeLessThan(docsIndex);
- expect(docsIndex).toBeLessThan(buildIndex);
- });
- });
-
- describe('scope grouping', () => {
- /**
- * Helper to extract content between two headers (or to end of string).
- * Returns the content after the start header and before the next header of same or higher level.
- */
- function getSectionContent(
- markdown: string,
- headerPattern: RegExp
- ): string | null {
- const match = markdown.match(headerPattern);
- if (!match || match.index === undefined) return null;
-
- const startIndex = match.index + match[0].length;
- // Find the next header (### or ####)
- const restOfContent = markdown.slice(startIndex);
- const nextHeaderMatch = restOfContent.match(/^#{3,4} /m);
- const endIndex =
- nextHeaderMatch && nextHeaderMatch.index !== undefined
- ? startIndex + nextHeaderMatch.index
- : markdown.length;
-
- return markdown.slice(startIndex, endIndex).trim();
- }
-
- it('should group PRs by scope within categories when scope has multiple entries', async () => {
- setup(
- [
- {
- hash: 'abc123',
- title: 'feat(api): add endpoint',
- body: '',
- pr: {
- remote: {
- number: '1',
- author: { login: 'alice' },
- labels: [],
- },
- },
- },
- {
- hash: 'def456',
- title: 'feat(ui): add button 1',
- body: '',
- pr: {
- remote: {
- number: '2',
- author: { login: 'bob' },
- labels: [],
- },
- },
- },
- {
- hash: 'ghi789',
- title: 'feat(api): add another endpoint',
- body: '',
- pr: {
- remote: {
- number: '3',
- author: { login: 'charlie' },
- labels: [],
- },
- },
- },
- {
- hash: 'jkl012',
- title: 'feat(ui): add button 2',
- body: '',
- pr: {
- remote: {
- number: '4',
- author: { login: 'dave' },
- labels: [],
- },
- },
- },
- ],
- null // Use default config
- );
-
- const result = await generateChangesetFromGit(dummyGit, '1.0.0', 10);
- const changes = result.changelog;
-
- // Verify Api scope header exists (has 2 entries)
- const apiSection = getSectionContent(changes, /#### Api\n/);
- expect(apiSection).not.toBeNull();
- expect(apiSection).toContain('feat(api): add endpoint');
- expect(apiSection).toContain('feat(api): add another endpoint');
- expect(apiSection).not.toContain('feat(ui):');
-
- // Ui scope has 2 entries, so header should be shown
- const uiSection = getSectionContent(changes, /#### Ui\n/);
- expect(uiSection).not.toBeNull();
- expect(uiSection).toContain('feat(ui): add button 1');
- expect(uiSection).toContain('feat(ui): add button 2');
- expect(uiSection).not.toContain('feat(api):');
- });
-
- it('should place scopeless entries at the bottom under "Other" header when scoped entries exist', async () => {
- setup(
- [
- {
- hash: 'abc123',
- title: 'feat(api): add endpoint',
- body: '',
- pr: {
- remote: {
- number: '1',
- author: { login: 'alice' },
- labels: [],
- },
- },
- },
- {
- hash: 'def456',
- title: 'feat(api): add another endpoint',
- body: '',
- pr: {
- remote: {
- number: '2',
- author: { login: 'bob' },
- labels: [],
- },
- },
- },
- {
- hash: 'ghi789',
- title: 'feat: add feature without scope',
- body: '',
- pr: {
- remote: {
- number: '3',
- author: { login: 'charlie' },
- labels: [],
- },
- },
- },
- ],
- null
- );
-
- const result = await generateChangesetFromGit(dummyGit, '1.0.0', 10);
- const changes = result.changelog;
-
- // Should have Api scope header (has 2 entries)
- expect(changes).toContain('#### Api');
-
- // Should have an "Other" header for scopeless entries (since Api has a header)
- expect(changes).toContain('#### Other');
-
- // Scopeless entry should appear after the "Other" header
- const otherHeaderIndex = changes.indexOf('#### Other');
- const scopelessIndex = changes.indexOf('feat: add feature without scope');
- expect(scopelessIndex).toBeGreaterThan(otherHeaderIndex);
-
- // Verify Api scope entry comes before scopeless entry
- const apiEntryIndex = changes.indexOf('feat(api): add endpoint');
- expect(apiEntryIndex).toBeLessThan(scopelessIndex);
- });
-
- it('should not add "Other" header when no scoped entries have headers', async () => {
- setup(
- [
- {
- hash: 'abc123',
- title: 'feat(api): single api feature',
- body: '',
- pr: {
- remote: {
- number: '1',
- author: { login: 'alice' },
- labels: [],
- },
- },
- },
- {
- hash: 'def456',
- title: 'feat: feature without scope',
- body: '',
- pr: {
- remote: {
- number: '2',
- author: { login: 'bob' },
- labels: [],
- },
- },
- },
- ],
- null
- );
-
- const result = await generateChangesetFromGit(dummyGit, '1.0.0', 10);
- const changes = result.changelog;
-
- // Neither scope should have a header (both have only 1 entry)
- expect(changes).not.toContain('#### Api');
- // No "Other" header should be added since there are no other scope headers
- expect(changes).not.toContain('#### Other');
-
- // But both PRs should still appear in the output
- expect(changes).toContain('feat(api): single api feature');
- expect(changes).toContain('feat: feature without scope');
- });
-
- it('should not add extra newlines between entries without scope headers', async () => {
- setup(
- [
- {
- hash: 'abc123',
- title: 'feat(docker): add docker feature',
- body: '',
- pr: {
- remote: {
- number: '1',
- author: { login: 'alice' },
- labels: [],
- },
- },
- },
- {
- hash: 'def456',
- title: 'feat: add feature without scope 1',
- body: '',
- pr: {
- remote: {
- number: '2',
- author: { login: 'bob' },
- labels: [],
- },
- },
- },
- {
- hash: 'ghi789',
- title: 'feat: add feature without scope 2',
- body: '',
- pr: {
- remote: {
- number: '3',
- author: { login: 'charlie' },
- labels: [],
- },
- },
- },
- ],
- null
- );
-
- const result = await generateChangesetFromGit(dummyGit, '1.0.0', 10);
- const changes = result.changelog;
-
- // All three should appear without extra blank lines between them
- // (no scope headers since docker only has 1 entry and scopeless don't trigger headers)
- expect(changes).not.toContain('#### Docker');
- expect(changes).not.toContain('#### Other');
-
- // Verify no double newlines between entries (which would indicate separate sections)
- const featuresSection = getSectionContent(
- changes,
- /### New Features[^\n]*\n/
- );
- expect(featuresSection).not.toBeNull();
- // There should be no blank lines between the three entries
- expect(featuresSection).not.toMatch(/\n\n-/);
- // All entries should be present
- expect(featuresSection).toContain('feat(docker): add docker feature');
- expect(featuresSection).toContain('feat: add feature without scope 1');
- expect(featuresSection).toContain('feat: add feature without scope 2');
- });
-
- it('should skip scope header for scopes with only one entry', async () => {
- setup(
- [
- {
- hash: 'abc123',
- title: 'feat(api): add endpoint',
- body: '',
- pr: {
- remote: {
- number: '1',
- author: { login: 'alice' },
- labels: [],
- },
- },
- },
- {
- hash: 'def456',
- title: 'feat(ui): single ui feature',
- body: '',
- pr: {
- remote: {
- number: '2',
- author: { login: 'bob' },
- labels: [],
- },
- },
- },
- ],
- null
- );
-
- const result = await generateChangesetFromGit(dummyGit, '1.0.0', 10);
- const changes = result.changelog;
-
- // Neither scope should have a header (both have only 1 entry)
- expect(changes).not.toContain('#### Api');
- expect(changes).not.toContain('#### Ui');
-
- // But both PRs should still appear in the output
- expect(changes).toContain('feat(api): add endpoint');
- expect(changes).toContain('feat(ui): single ui feature');
- });
-
- it('should merge scopes with different casing', async () => {
- setup(
- [
- {
- hash: 'abc123',
- title: 'feat(API): uppercase scope',
- body: '',
- pr: {
- remote: {
- number: '1',
- author: { login: 'alice' },
- labels: [],
- },
- },
- },
- {
- hash: 'def456',
- title: 'feat(api): lowercase scope',
- body: '',
- pr: {
- remote: {
- number: '2',
- author: { login: 'bob' },
- labels: [],
- },
- },
- },
- {
- hash: 'ghi789',
- title: 'feat(Api): mixed case scope',
- body: '',
- pr: {
- remote: {
- number: '3',
- author: { login: 'charlie' },
- labels: [],
- },
- },
- },
- ],
- null
- );
-
- const result = await generateChangesetFromGit(dummyGit, '1.0.0', 10);
- const changes = result.changelog;
-
- // Should only have one Api header (all merged)
- const apiMatches = changes.match(/#### Api/gi);
- expect(apiMatches).toHaveLength(1);
-
- // All three PRs should be under the same Api scope section
- const apiSection = getSectionContent(changes, /#### Api\n/);
- expect(apiSection).not.toBeNull();
- expect(apiSection).toContain('feat(API): uppercase scope');
- expect(apiSection).toContain('feat(api): lowercase scope');
- expect(apiSection).toContain('feat(Api): mixed case scope');
- });
-
- it('should sort scope groups alphabetically', async () => {
- setup(
- [
- {
- hash: 'abc123',
- title: 'feat(zulu): z feature 1',
- body: '',
- pr: {
- remote: {
- number: '1',
- author: { login: 'alice' },
- labels: [],
- },
- },
- },
- {
- hash: 'def456',
- title: 'feat(zulu): z feature 2',
- body: '',
- pr: {
- remote: {
- number: '2',
- author: { login: 'bob' },
- labels: [],
- },
- },
- },
- {
- hash: 'ghi789',
- title: 'feat(alpha): a feature 1',
- body: '',
- pr: {
- remote: {
- number: '3',
- author: { login: 'charlie' },
- labels: [],
- },
- },
- },
- {
- hash: 'jkl012',
- title: 'feat(alpha): a feature 2',
- body: '',
- pr: {
- remote: {
- number: '4',
- author: { login: 'dave' },
- labels: [],
- },
- },
- },
- {
- hash: 'mno345',
- title: 'feat(beta): b feature 1',
- body: '',
- pr: {
- remote: {
- number: '5',
- author: { login: 'eve' },
- labels: [],
- },
- },
- },
- {
- hash: 'pqr678',
- title: 'feat(beta): b feature 2',
- body: '',
- pr: {
- remote: {
- number: '6',
- author: { login: 'frank' },
- labels: [],
- },
- },
- },
- ],
- null
- );
-
- const result = await generateChangesetFromGit(dummyGit, '1.0.0', 10);
- const changes = result.changelog;
-
- const alphaIndex = changes.indexOf('#### Alpha');
- const betaIndex = changes.indexOf('#### Beta');
- const zuluIndex = changes.indexOf('#### Zulu');
-
- expect(alphaIndex).toBeLessThan(betaIndex);
- expect(betaIndex).toBeLessThan(zuluIndex);
-
- // Also verify each section contains the correct PR
- const alphaSection = getSectionContent(changes, /#### Alpha\n/);
- expect(alphaSection).toContain('feat(alpha): a feature 1');
- expect(alphaSection).toContain('feat(alpha): a feature 2');
- expect(alphaSection).not.toContain('feat(beta)');
- expect(alphaSection).not.toContain('feat(zulu)');
- });
-
- it('should format scope with dashes and underscores as title case', async () => {
- setup(
- [
- {
- hash: 'abc123',
- title: 'feat(my-component): feature with dashes 1',
- body: '',
- pr: {
- remote: {
- number: '1',
- author: { login: 'alice' },
- labels: [],
- },
- },
- },
- {
- hash: 'def456',
- title: 'feat(my-component): feature with dashes 2',
- body: '',
- pr: {
- remote: {
- number: '2',
- author: { login: 'bob' },
- labels: [],
- },
- },
- },
- {
- hash: 'ghi789',
- title: 'feat(another_component): feature with underscores 1',
- body: '',
- pr: {
- remote: {
- number: '3',
- author: { login: 'charlie' },
- labels: [],
- },
- },
- },
- {
- hash: 'jkl012',
- title: 'feat(another_component): feature with underscores 2',
- body: '',
- pr: {
- remote: {
- number: '4',
- author: { login: 'dave' },
- labels: [],
- },
- },
- },
- ],
- null
- );
-
- const result = await generateChangesetFromGit(dummyGit, '1.0.0', 10);
- const changes = result.changelog;
-
- // Verify scope headers are formatted correctly (each has 2 entries)
- expect(changes).toContain('#### Another Component');
- expect(changes).toContain('#### My Component');
-
- // Verify PRs are under correct scope sections
- const myComponentSection = getSectionContent(
- changes,
- /#### My Component\n/
- );
- expect(myComponentSection).toContain(
- 'feat(my-component): feature with dashes 1'
- );
- expect(myComponentSection).toContain(
- 'feat(my-component): feature with dashes 2'
- );
- expect(myComponentSection).not.toContain('feat(another_component)');
-
- const anotherComponentSection = getSectionContent(
- changes,
- /#### Another Component\n/
- );
- expect(anotherComponentSection).toContain(
- 'feat(another_component): feature with underscores 1'
- );
- expect(anotherComponentSection).toContain(
- 'feat(another_component): feature with underscores 2'
- );
- expect(anotherComponentSection).not.toContain('feat(my-component)');
- });
-
- it('should apply scope grouping to label-categorized PRs', async () => {
- setup(
- [
- {
- hash: 'abc123',
- title: 'feat(api): add endpoint',
- body: '',
- pr: {
- remote: {
- number: '1',
- author: { login: 'alice' },
- labels: ['enhancement'],
- },
- },
- },
- {
- hash: 'def456',
- title: 'feat(api): add another endpoint',
- body: '',
- pr: {
- remote: {
- number: '2',
- author: { login: 'bob' },
- labels: ['enhancement'],
- },
- },
- },
- {
- hash: 'ghi789',
- title: 'feat(ui): add button',
- body: '',
- pr: {
- remote: {
- number: '3',
- author: { login: 'charlie' },
- labels: ['enhancement'],
- },
- },
- },
- {
- hash: 'jkl012',
- title: 'feat(ui): add dialog',
- body: '',
- pr: {
- remote: {
- number: '4',
- author: { login: 'dave' },
- labels: ['enhancement'],
- },
- },
- },
- ],
- `changelog:
- categories:
- - title: Features
- labels:
- - enhancement`
- );
-
- const result = await generateChangesetFromGit(dummyGit, '1.0.0', 10);
- const changes = result.changelog;
-
- expect(changes).toContain('### Features');
-
- // Verify PRs are grouped under correct scopes (each scope has 2 entries)
- const apiSection = getSectionContent(changes, /#### Api\n/);
- expect(apiSection).not.toBeNull();
- expect(apiSection).toContain('feat(api): add endpoint');
- expect(apiSection).toContain('feat(api): add another endpoint');
- expect(apiSection).not.toContain('feat(ui)');
-
- const uiSection = getSectionContent(changes, /#### Ui\n/);
- expect(uiSection).not.toBeNull();
- expect(uiSection).toContain('feat(ui): add button');
- expect(uiSection).toContain('feat(ui): add dialog');
- expect(uiSection).not.toContain('feat(api)');
- });
-
- it('should handle breaking changes with scopes', async () => {
- setup(
- [
- {
- hash: 'abc123',
- title: 'feat(api)!: breaking api change',
- body: '',
- pr: {
- remote: {
- number: '1',
- author: { login: 'alice' },
- labels: [],
- },
- },
- },
- {
- hash: 'def456',
- title: 'feat(api)!: another breaking api change',
- body: '',
- pr: {
- remote: {
- number: '2',
- author: { login: 'bob' },
- labels: [],
- },
- },
- },
- {
- hash: 'ghi789',
- title: 'fix(core)!: breaking core fix',
- body: '',
- pr: {
- remote: {
- number: '3',
- author: { login: 'charlie' },
- labels: [],
- },
- },
- },
- {
- hash: 'jkl012',
- title: 'fix(core)!: another breaking core fix',
- body: '',
- pr: {
- remote: {
- number: '4',
- author: { login: 'dave' },
- labels: [],
- },
- },
- },
- ],
- null
- );
-
- const result = await generateChangesetFromGit(dummyGit, '1.0.0', 10);
- const changes = result.changelog;
-
- expect(changes).toContain('### Breaking Changes');
-
- // Verify PRs are grouped under correct scopes (each scope has 2 entries)
- const apiSection = getSectionContent(changes, /#### Api\n/);
- expect(apiSection).not.toBeNull();
- expect(apiSection).toContain('feat(api)!: breaking api change');
- expect(apiSection).toContain('feat(api)!: another breaking api change');
- expect(apiSection).not.toContain('fix(core)');
-
- const coreSection = getSectionContent(changes, /#### Core\n/);
- expect(coreSection).not.toBeNull();
- expect(coreSection).toContain('fix(core)!: breaking core fix');
- expect(coreSection).toContain('fix(core)!: another breaking core fix');
- expect(coreSection).not.toContain('feat(api)');
- });
-
- it('should merge scopes with dashes and underscores as equivalent', async () => {
- setup(
- [
- {
- hash: 'abc123',
- title: 'feat(my-component): feature with dashes',
- body: '',
- pr: {
- remote: {
- number: '1',
- author: { login: 'alice' },
- labels: [],
- },
- },
- },
- {
- hash: 'def456',
- title: 'feat(my_component): feature with underscores',
- body: '',
- pr: {
- remote: {
- number: '2',
- author: { login: 'bob' },
- labels: [],
- },
- },
- },
- ],
- null
- );
-
- const result = await generateChangesetFromGit(dummyGit, '1.0.0', 10);
- const changes = result.changelog;
-
- // Should only have one "My Component" header (merged via normalization)
- const myComponentMatches = changes.match(/#### My Component/gi);
- expect(myComponentMatches).toHaveLength(1);
-
- // Both PRs should appear under the same scope section
- const myComponentSection = getSectionContent(
- changes,
- /#### My Component\n/
- );
- expect(myComponentSection).not.toBeNull();
- expect(myComponentSection).toContain(
- 'feat(my-component): feature with dashes'
- );
- expect(myComponentSection).toContain(
- 'feat(my_component): feature with underscores'
- );
- });
- });
-
- describe('custom changelog entries', () => {
- it('should use custom changelog entry from PR body instead of PR title', async () => {
- setup(
- [
- {
- hash: 'abc123',
- title: 'feat: Add `foo` function',
- body: '',
- pr: {
- remote: {
- number: '1',
- author: { login: 'alice' },
- body: `### Description
-
-Add \`foo\` function, and add unit tests to thoroughly check all edge cases.
-
-### Changelog Entry
-
-Add a new function called \`foo\` which prints "Hello, world!"
-
-### Issues
-
-Closes #123`,
- labels: [],
- },
- },
- },
- ],
- null
- );
-
- const result = await generateChangesetFromGit(dummyGit, '1.0.0', 10);
- const changes = result.changelog;
-
- // Should use the custom changelog entry, not the PR title
- expect(changes).toContain(
- 'Add a new function called `foo` which prints "Hello, world!"'
- );
- expect(changes).not.toContain('feat: Add `foo` function');
- });
-
- it('should fall back to PR title when no changelog entry section exists', async () => {
- setup(
- [
- {
- hash: 'abc123',
- title: 'feat: Add bar function',
- body: '',
- pr: {
- remote: {
- number: '1',
- author: { login: 'alice' },
- body: `### Description
-
-Add bar function with tests.
-
-### Issues
-
-Closes #456`,
- labels: [],
- },
- },
- },
- ],
- null
- );
-
- const result = await generateChangesetFromGit(dummyGit, '1.0.0', 10);
- const changes = result.changelog;
-
- // Should use PR title when no custom changelog entry exists
- expect(changes).toContain('feat: Add bar function');
- });
-
- it('should handle multiple PRs with mixed custom and default changelog entries', async () => {
- setup(
- [
- {
- hash: 'abc123',
- title: 'feat: Add feature A',
- body: '',
- pr: {
- remote: {
- number: '1',
- author: { login: 'alice' },
- body: `### Changelog Entry
-
-Custom entry for feature A`,
- labels: [],
- },
- },
- },
- {
- hash: 'def456',
- title: 'feat: Add feature B',
- body: '',
- pr: {
- remote: {
- number: '2',
- author: { login: 'bob' },
- body: 'No changelog entry section here',
- labels: [],
- },
- },
- },
- {
- hash: 'ghi789',
- title: 'fix: Fix bug C',
- body: '',
- pr: {
- remote: {
- number: '3',
- author: { login: 'charlie' },
- body: `## Changelog Entry
-
-Custom entry for bug fix C`,
- labels: [],
- },
- },
- },
- ],
- null
- );
-
- const result = await generateChangesetFromGit(dummyGit, '1.0.0', 10);
- const changes = result.changelog;
-
- expect(changes).toContain('Custom entry for feature A');
- expect(changes).toContain('feat: Add feature B');
- expect(changes).toContain('Custom entry for bug fix C');
- expect(changes).not.toContain('feat: Add feature A');
- expect(changes).not.toContain('fix: Fix bug C');
- });
-
- it('should work with changelog entry in categorized PRs', async () => {
- setup(
- [
- {
- hash: 'abc123',
- title: 'Add new API endpoint',
- body: '',
- pr: {
- remote: {
- number: '1',
- author: { login: 'alice' },
- body: '### Changelog Entry\n\nIntroduce a powerful new /api/v2/users endpoint with filtering support',
- labels: ['feature'],
- },
- },
- },
- {
- hash: 'def456',
- title: 'Fix memory leak',
- body: '',
- pr: {
- remote: {
- number: '2',
- author: { login: 'bob' },
- body: '### Changelog Entry\n\nResolve critical memory leak that occurred during large file uploads',
- labels: ['bug'],
- },
- },
- },
- ],
- `changelog:
- categories:
- - title: Features
- labels:
- - feature
- - title: Bug Fixes
- labels:
- - bug`
- );
-
- const result = await generateChangesetFromGit(dummyGit, '1.0.0', 10);
- const changes = result.changelog;
-
- expect(changes).toContain('### Features');
- expect(changes).toContain('### Bug Fixes');
- expect(changes).toContain(
- 'Introduce a powerful new /api/v2/users endpoint with filtering support'
- );
- expect(changes).toContain(
- 'Resolve critical memory leak that occurred during large file uploads'
- );
- expect(changes).not.toContain('Add new API endpoint');
- expect(changes).not.toContain('Fix memory leak');
- });
-
- it('should preserve scope extraction when using custom changelog entry', async () => {
- setup(
- [
- {
- hash: 'abc123',
- title: 'feat(api): Add endpoint',
- body: '',
- pr: {
- remote: {
- number: '1',
- author: { login: 'alice' },
- body: `### Changelog Entry
-
-Add powerful new endpoint for user management`,
- labels: [],
- },
- },
- },
- {
- hash: 'def456',
- title: 'feat(api): Add another endpoint',
- body: '',
- pr: {
- remote: {
- number: '2',
- author: { login: 'bob' },
- body: `### Changelog Entry
-
-Add endpoint for data export`,
- labels: [],
- },
- },
- },
- ],
- null
- );
-
- const result = await generateChangesetFromGit(dummyGit, '1.0.0', 10);
- const changes = result.changelog;
-
- // Should still group by scope even with custom changelog entries
- expect(changes).toContain('#### Api');
- expect(changes).toContain(
- 'Add powerful new endpoint for user management'
- );
- expect(changes).toContain('Add endpoint for data export');
- });
-
- it('should work with changelog entry in "Other" section', async () => {
- setup(
- [
- {
- hash: 'abc123',
- title: 'Update dependencies',
- body: '',
- pr: {
- remote: {
- number: '1',
- author: { login: 'alice' },
- body: `### Changelog Entry
-
-Update all dependencies to their latest versions for improved security`,
- labels: [],
- },
- },
- },
- ],
- null
- );
-
- const result = await generateChangesetFromGit(dummyGit, '1.0.0', 10);
- const changes = result.changelog;
-
- expect(changes).toContain(
- 'Update all dependencies to their latest versions for improved security'
- );
- expect(changes).not.toContain('Update dependencies');
- });
-
- it('should handle multi-line custom changelog entries with nested bullets', async () => {
- setup(
- [
- {
- hash: 'abc123',
- title: 'feat: Big feature',
- body: '',
- pr: {
- remote: {
- number: '1',
- author: { login: 'alice' },
- body: `### Changelog Entry
-
-- Add comprehensive user authentication system
- - OAuth2 support
- - Two-factor authentication
- - Session management`,
- labels: [],
- },
- },
- },
- ],
- null
- );
-
- const result = await generateChangesetFromGit(dummyGit, '1.0.0', 10);
- const changes = result.changelog;
-
- expect(changes).toContain(
- 'Add comprehensive user authentication system by @alice in [#1]'
- );
- // Nested bullets preserve the bullet marker
- expect(changes).toContain(' - OAuth2 support');
- expect(changes).toContain(' - Two-factor authentication');
- expect(changes).toContain(' - Session management');
- });
-
- it('should create multiple changelog entries from multiple bullets in PR', async () => {
- setup(
- [
- {
- hash: 'abc123',
- title: 'feat: Multiple features',
- body: '',
- pr: {
- remote: {
- number: '1',
- author: { login: 'alice' },
- body: `### Changelog Entry
-
-- Add OAuth2 authentication
-- Add two-factor authentication
-- Add session management`,
- labels: [],
- },
- },
- },
- ],
- null
- );
-
- const result = await generateChangesetFromGit(dummyGit, '1.0.0', 10);
- const changes = result.changelog;
-
- // Should have 3 separate changelog entries from the same PR
- expect(changes).toContain('Add OAuth2 authentication by @alice in [#1]');
- expect(changes).toContain(
- 'Add two-factor authentication by @alice in [#1]'
- );
- expect(changes).toContain('Add session management by @alice in [#1]');
- });
-
- it('should handle multiple bullets with nested content in changelog entries', async () => {
- setup(
- [
- {
- hash: 'abc123',
- title: 'feat: Big update',
- body: '',
- pr: {
- remote: {
- number: '1',
- author: { login: 'alice' },
- body: `### Changelog Entry
-
-- Add authentication
- - OAuth2
- - 2FA
-- Add user profiles
- - Avatar upload
- - Bio editing`,
- labels: [],
- },
- },
- },
- ],
- null
- );
-
- const result = await generateChangesetFromGit(dummyGit, '1.0.0', 10);
- const changes = result.changelog;
-
- // First entry with nested content (bullets preserved)
- expect(changes).toContain('Add authentication by @alice in [#1]');
- expect(changes).toContain(' - OAuth2');
- expect(changes).toContain(' - 2FA');
-
- // Second entry with nested content (bullets preserved)
- expect(changes).toContain('Add user profiles by @alice in [#1]');
- expect(changes).toContain(' - Avatar upload');
- expect(changes).toContain(' - Bio editing');
- });
-
- it('should ignore empty changelog entry sections', async () => {
- setup(
- [
- {
- hash: 'abc123',
- title: 'feat: Add feature',
- body: '',
- pr: {
- remote: {
- number: '1',
- author: { login: 'alice' },
- body: `### Changelog Entry
-
-### Issues
-
-Closes #123`,
- labels: [],
- },
- },
- },
- ],
- null
- );
-
- const result = await generateChangesetFromGit(dummyGit, '1.0.0', 10);
- const changes = result.changelog;
-
- // Should fall back to PR title when changelog entry is empty
- expect(changes).toContain('feat: Add feature');
- });
-
- it('should handle multi-line plain text as single-line entry to avoid broken markdown', async () => {
- setup(
- [
- {
- hash: 'abc123',
- title: 'chore: Update dependencies',
- body: '',
- pr: {
- remote: {
- number: '1',
- author: { login: 'alice' },
- body: `### Changelog Entry
-
-This is a multi-line
-changelog entry that
-spans several lines.`,
- labels: [],
- },
- },
- },
- ],
- null
- );
-
- const result = await generateChangesetFromGit(dummyGit, '1.0.0', 10);
- const changes = result.changelog;
-
- // The entry should start with "- " and have author/link on the same line
- // It should NOT have broken formatting like:
- // - This is a multi-line
- // changelog entry that
- // spans several lines. by @alice in [#1](...)
- expect(changes).toMatch(/^- .+by @alice in \[#1\]/m);
- // The title should be joined or formatted properly
- expect(changes).not.toContain('\nchangelog entry that');
- });
- });
-});
-
-describe('extractScope', () => {
- it.each([
- ['feat(api): add endpoint', 'api'],
- ['fix(ui): fix button', 'ui'],
- ['feat(my-component): add feature', 'my-component'],
- ['feat(my_component): add feature', 'my-component'], // underscores normalized to dashes
- ['feat(API): uppercase scope', 'api'],
- ['feat(MyComponent): mixed case', 'mycomponent'],
- ['feat(scope)!: breaking change', 'scope'],
- ['fix(core)!: another breaking', 'core'],
- ['docs(readme): update docs', 'readme'],
- ['chore(deps): update dependencies', 'deps'],
- ['feat(my-long_scope): mixed separators', 'my-long-scope'], // underscores normalized to dashes
- ])('should extract scope from "%s" as "%s"', (title, expected) => {
- expect(extractScope(title)).toBe(expected);
- });
-
- it.each([
- ['feat: no scope', null],
- ['fix: simple fix', null],
- ['random commit message', null],
- ['feat!: breaking without scope', null],
- ['(scope): missing type', null],
- ['feat(): empty scope', null],
- ])('should return null for "%s"', (title, expected) => {
- expect(extractScope(title)).toBe(expected);
- });
-});
-
-describe('formatScopeTitle', () => {
- it.each([
- ['api', 'Api'],
- ['ui', 'Ui'],
- ['my-component', 'My Component'],
- ['my_component', 'My Component'],
- ['multi-word-scope', 'Multi Word Scope'],
- ['multi_word_scope', 'Multi Word Scope'],
- ['API', 'API'],
- ['mycomponent', 'Mycomponent'],
- ])('should format "%s" as "%s"', (scope, expected) => {
- expect(formatScopeTitle(scope)).toBe(expected);
- });
-});
-
-describe('extractChangelogEntry', () => {
- it('should extract content from "### Changelog Entry" section as single entry', () => {
- const prBody = `### Description
-
-This PR adds a new feature.
-
-### Changelog Entry
-
-Add a new function called \`foo\` which prints "Hello, world!"
-
-### Issues
-
-Closes #123`;
-
- const result = extractChangelogEntry(prBody);
- expect(result).toHaveLength(1);
- expect(result).not.toBeNull();
- if (result) {
- expect(result[0].text).toBe(
- 'Add a new function called `foo` which prints "Hello, world!"'
- );
- expect(result[0].nestedContent).toBeUndefined();
- }
- });
-
- it('should extract content from "## Changelog Entry" section', () => {
- const prBody = `## Description
-
-This PR adds a new feature.
-
-## Changelog Entry
-
-Add a new function called \`foo\` which prints "Hello, world!"
-
-## Issues
-
-Closes #123`;
-
- const result = extractChangelogEntry(prBody);
- expect(result).toHaveLength(1);
- expect(result).not.toBeNull();
- if (result) {
- expect(result[0].text).toBe(
- 'Add a new function called `foo` which prints "Hello, world!"'
- );
- }
- });
-
- it('should handle changelog entry at the end of PR body', () => {
- const prBody = `### Description
-
-This PR adds a new feature.
-
-### Changelog Entry
-
-This is the last section with no sections after it.`;
-
- const result = extractChangelogEntry(prBody);
- expect(result).toHaveLength(1);
- expect(result).not.toBeNull();
- if (result) {
- expect(result[0].text).toBe(
- 'This is the last section with no sections after it.'
- );
- }
- });
-
- it('should be case-insensitive', () => {
- const prBody = `### Description
-
-This PR adds a new feature.
-
-### changelog entry
-
-Custom changelog text here
-
-### Issues
-
-Closes #123`;
-
- const result = extractChangelogEntry(prBody);
- expect(result).toHaveLength(1);
- expect(result).not.toBeNull();
- if (result) {
- expect(result[0].text).toBe('Custom changelog text here');
- }
- });
-
- it('should handle multiple lines in plain text as single entry joined with spaces', () => {
- const prBody = `### Description
-
-Description here
-
-### Changelog Entry
-
-This is a multi-line
-changelog entry that
-spans several lines.
-
-### Issues
-
-Closes #123`;
-
- const result = extractChangelogEntry(prBody);
- expect(result).toHaveLength(1);
- expect(result).not.toBeNull();
- if (result) {
- // Multi-line plain text is joined with spaces to avoid broken markdown
- expect(result[0].text).toBe(
- 'This is a multi-line changelog entry that spans several lines.'
- );
- }
- });
-
- it('should handle multiple top-level bullets as separate entries', () => {
- const prBody = `### Changelog Entry
-
-- Add **bold** feature
-- Add *italic* feature
-- Add \`code\` feature`;
-
- const result = extractChangelogEntry(prBody);
- expect(result).toHaveLength(3);
- expect(result).not.toBeNull();
- if (result) {
- expect(result[0].text).toBe('Add **bold** feature');
- expect(result[1].text).toBe('Add *italic* feature');
- expect(result[2].text).toBe('Add `code` feature');
- }
- });
-
- it('should handle CRLF line endings (Windows)', () => {
- // Simulate Windows line endings from GitHub PR body
- const prBody =
- '## Changelog Entry\r\n' +
- '\r\n' +
- ' - Add first feature\r\n' +
- ' - Add second feature\r\n' +
- ' - Add third feature\r\n' +
- '\r\n' +
- '## Description\r\n';
-
- const result = extractChangelogEntry(prBody);
- expect(result).toHaveLength(3);
- expect(result).not.toBeNull();
- if (result) {
- expect(result[0].text).toBe('Add first feature');
- expect(result[1].text).toBe('Add second feature');
- expect(result[2].text).toBe('Add third feature');
- }
- });
-
- it('should return null when no changelog entry section exists', () => {
- const prBody = `### Description
-
-This PR adds a new feature.
-
-### Issues
-
-Closes #123`;
-
- expect(extractChangelogEntry(prBody)).toBeNull();
- });
-
- it('should return null when prBody is null', () => {
- expect(extractChangelogEntry(null)).toBeNull();
- });
-
- it('should return null when prBody is empty string', () => {
- expect(extractChangelogEntry('')).toBeNull();
- });
-
- it('should return null when changelog entry section is empty', () => {
- const prBody = `### Description
-
-This PR adds a new feature.
-
-### Changelog Entry
-
-### Issues
-
-Closes #123`;
-
- expect(extractChangelogEntry(prBody)).toBeNull();
- });
-
- it('should handle changelog entry with trailing/leading whitespace', () => {
- const prBody = `### Changelog Entry
-
- This has leading whitespace
-
-### Issues`;
-
- const result = extractChangelogEntry(prBody);
- expect(result).toHaveLength(1);
- expect(result).not.toBeNull();
- if (result) {
- expect(result[0].text).toBe('This has leading whitespace');
- }
- });
-
- it('should handle changelog entry header with trailing hashes', () => {
- const prBody = `### Description
-
-This PR adds a new feature.
-
-### Changelog Entry ###
-
-Custom changelog text
-
-### Issues`;
-
- const result = extractChangelogEntry(prBody);
- expect(result).toHaveLength(1);
- expect(result).not.toBeNull();
- if (result) {
- expect(result[0].text).toBe('Custom changelog text');
- }
- });
-
- it('should not match "Changelog Entry" in regular text', () => {
- const prBody = `### Description
-
-This PR adds Changelog Entry functionality.
-
-### Issues
-
-Closes #123`;
-
- expect(extractChangelogEntry(prBody)).toBeNull();
- });
-
- it('should extract only until next heading, not all remaining text', () => {
- const prBody = `### Changelog Entry
-
-This is the changelog.
-
-### Issues
-
-This should not be included.
-
-### More Sections
-
-Neither should this.`;
-
- const result = extractChangelogEntry(prBody);
- expect(result).toHaveLength(1);
- expect(result).not.toBeNull();
- if (result) {
- expect(result[0].text).toBe('This is the changelog.');
- }
- });
-
- it('should handle nested bullets under a top-level bullet', () => {
- const prBody = `### Changelog Entry
-
-- Add authentication system
- - OAuth2 support
- - Two-factor authentication
- - Session management`;
-
- const result = extractChangelogEntry(prBody);
- expect(result).toHaveLength(1);
- expect(result).not.toBeNull();
- if (result) {
- expect(result[0].text).toBe('Add authentication system');
- // Nested bullets preserve the bullet marker
- expect(result[0].nestedContent).toBe(
- ' - OAuth2 support\n - Two-factor authentication\n - Session management'
- );
- }
- });
-
- it('should handle multiple top-level bullets with nested content', () => {
- const prBody = `### Changelog Entry
-
-- Add authentication system
- - OAuth2 support
- - Two-factor authentication
-- Add user profile page
- - Avatar upload
- - Bio editing
-- Add settings panel`;
-
- const result = extractChangelogEntry(prBody);
- expect(result).toHaveLength(3);
- expect(result).not.toBeNull();
- if (result) {
- expect(result[0].text).toBe('Add authentication system');
- // Nested bullets preserve the bullet marker
- expect(result[0].nestedContent).toBe(
- ' - OAuth2 support\n - Two-factor authentication'
- );
- expect(result[1].text).toBe('Add user profile page');
- expect(result[1].nestedContent).toBe(' - Avatar upload\n - Bio editing');
- expect(result[2].text).toBe('Add settings panel');
- expect(result[2].nestedContent).toBeUndefined();
- }
- });
-
- it('should handle paragraph followed by proper list', () => {
- // In valid markdown, a list needs a blank line after a paragraph
- const prBody = `### Changelog Entry
-
-Comprehensive authentication system with the following features:
-
-- OAuth2 support
-- Two-factor authentication
-- Session management`;
-
- const result = extractChangelogEntry(prBody);
- expect(result).not.toBeNull();
- expect(result).toHaveLength(4); // 1 paragraph + 3 list items
- if (result) {
- expect(result[0].text).toBe(
- 'Comprehensive authentication system with the following features:'
- );
- expect(result[1].text).toBe('OAuth2 support');
- expect(result[2].text).toBe('Two-factor authentication');
- expect(result[3].text).toBe('Session management');
- }
- });
-
- it('should treat indented bullets after paragraph as paragraph text (markdown behavior)', () => {
- // In markdown, indented bullets after a paragraph without blank line
- // are NOT a list - they're part of the paragraph text
- const prBody = `### Changelog Entry
-
-Comprehensive authentication system with the following features:
- - OAuth2 support
- - Two-factor authentication
- - Session management`;
-
- const result = extractChangelogEntry(prBody);
- expect(result).not.toBeNull();
- expect(result).toHaveLength(1);
- if (result) {
- // The entire text (including the "- " prefixes) is joined as one paragraph
- expect(result[0].text).toContain('Comprehensive authentication system');
- expect(result[0].text).toContain('OAuth2 support');
- }
- });
-
- it('should only include content within the Changelog Entry section', () => {
- const prBody = `### Description
-
-This should not be included.
-
-### Changelog Entry
-
-- Add feature A
-- Add feature B
-
-### Issues
-
-This should also not be included.
-
-Closes #123`;
-
- const result = extractChangelogEntry(prBody);
- expect(result).toHaveLength(2);
- expect(result).not.toBeNull();
- if (result) {
- expect(result[0].text).toBe('Add feature A');
- expect(result[1].text).toBe('Add feature B');
- // Make sure content from other sections isn't included
- const allText = result
- .map(e => e.text + (e.nestedContent || ''))
- .join('');
- expect(allText).not.toContain('This should not be included');
- expect(allText).not.toContain('This should also not be included');
- expect(allText).not.toContain('Closes #123');
- }
- });
-});
-
-describe('shouldExcludePR', () => {
- it('should return true when body contains #skip-changelog', () => {
- const labels = new Set();
- expect(shouldExcludePR(labels, 'user', null, 'Some text #skip-changelog here')).toBe(true);
- });
-
- it('should return false when body does not contain magic word', () => {
- const labels = new Set();
- expect(shouldExcludePR(labels, 'user', null, 'Normal body text')).toBe(false);
- });
-
- it('should return false when body is undefined', () => {
- const labels = new Set();
- expect(shouldExcludePR(labels, 'user', null, undefined)).toBe(false);
- });
-
- it('should return false when body is empty', () => {
- const labels = new Set();
- expect(shouldExcludePR(labels, 'user', null, '')).toBe(false);
- });
-
- it('should check body before config (early exit)', () => {
- // Even with no config, magic word should cause exclusion
- const labels = new Set(['feature']);
- expect(shouldExcludePR(labels, 'user', null, '#skip-changelog')).toBe(true);
- });
-});
-
-describe('shouldSkipCurrentPR', () => {
- const basePRInfo: CurrentPRInfo = {
- number: 123,
- title: 'Test PR',
- body: '',
- author: 'testuser',
- labels: [],
- baseRef: 'main',
- };
-
- beforeEach(() => {
- clearChangesetCache();
- getConfigFileDirMock.mockReturnValue('/test');
- });
-
- it('should return true when PR body contains #skip-changelog', () => {
- const prInfo: CurrentPRInfo = {
- ...basePRInfo,
- body: 'This is a PR description\n\n#skip-changelog',
- };
- readFileSyncMock.mockImplementation(() => {
- throw { code: 'ENOENT' };
- });
-
- expect(shouldSkipCurrentPR(prInfo)).toBe(true);
- });
-
- it('should return true when PR body contains #skip-changelog inline', () => {
- const prInfo: CurrentPRInfo = {
- ...basePRInfo,
- body: 'This is internal work #skip-changelog for now',
- };
- readFileSyncMock.mockImplementation(() => {
- throw { code: 'ENOENT' };
- });
-
- expect(shouldSkipCurrentPR(prInfo)).toBe(true);
- });
-
- it('should return false when PR body does not contain skip marker', () => {
- const prInfo: CurrentPRInfo = {
- ...basePRInfo,
- body: 'This is a regular PR description',
- };
- readFileSyncMock.mockImplementation(() => {
- throw { code: 'ENOENT' };
- });
-
- expect(shouldSkipCurrentPR(prInfo)).toBe(false);
- });
-
- it('should return true when PR has an excluded label from config', () => {
- const prInfo: CurrentPRInfo = {
- ...basePRInfo,
- body: 'Normal description',
- labels: ['skip-changelog'],
- };
- readFileSyncMock.mockImplementation((path: any) => {
- if (typeof path === 'string' && path.includes('release.yml')) {
- return `changelog:
- exclude:
- labels:
- - skip-changelog
- categories:
- - title: Features
- labels:
- - feature`;
- }
- throw { code: 'ENOENT' };
- });
-
- expect(shouldSkipCurrentPR(prInfo)).toBe(true);
- });
-
- it('should return true when PR author is excluded in config', () => {
- const prInfo: CurrentPRInfo = {
- ...basePRInfo,
- body: 'Normal description',
- author: 'dependabot[bot]',
- };
- readFileSyncMock.mockImplementation((path: any) => {
- if (typeof path === 'string' && path.includes('release.yml')) {
- return `changelog:
- exclude:
- authors:
- - dependabot[bot]
- categories:
- - title: Features
- labels:
- - feature`;
- }
- throw { code: 'ENOENT' };
- });
-
- expect(shouldSkipCurrentPR(prInfo)).toBe(true);
- });
-
- it('should return false when PR does not match any exclusion criteria', () => {
- const prInfo: CurrentPRInfo = {
- ...basePRInfo,
- body: 'Normal description',
- labels: ['feature'],
- author: 'regularuser',
- };
- readFileSyncMock.mockImplementation((path: any) => {
- if (typeof path === 'string' && path.includes('release.yml')) {
- return `changelog:
- exclude:
- labels:
- - skip-changelog
- authors:
- - dependabot[bot]
- categories:
- - title: Features
- labels:
- - feature`;
- }
- throw { code: 'ENOENT' };
- });
-
- expect(shouldSkipCurrentPR(prInfo)).toBe(false);
- });
-
- it('should prioritize magic word over config (skip even without config)', () => {
- const prInfo: CurrentPRInfo = {
- ...basePRInfo,
- body: 'Description with #skip-changelog',
- labels: ['feature'],
- };
- // No release config
- readFileSyncMock.mockImplementation(() => {
- throw { code: 'ENOENT' };
- });
-
- expect(shouldSkipCurrentPR(prInfo)).toBe(true);
- });
-});
-
-describe('getBumpTypeForPR', () => {
- const basePRInfo: CurrentPRInfo = {
- number: 123,
- title: 'Test PR',
- body: '',
- author: 'testuser',
- labels: [],
- baseRef: 'main',
- };
-
- beforeEach(() => {
- clearChangesetCache();
- getConfigFileDirMock.mockReturnValue('/test');
- });
-
- it('should return minor for feat: prefix with default config', () => {
- const prInfo: CurrentPRInfo = {
- ...basePRInfo,
- title: 'feat: Add new feature',
- };
- // No release config - uses default conventional commits
- readFileSyncMock.mockImplementation(() => {
- throw { code: 'ENOENT' };
- });
-
- expect(getBumpTypeForPR(prInfo)).toBe('minor');
- });
-
- it('should return patch for fix: prefix with default config', () => {
- const prInfo: CurrentPRInfo = {
- ...basePRInfo,
- title: 'fix: Fix a bug',
- };
- readFileSyncMock.mockImplementation(() => {
- throw { code: 'ENOENT' };
- });
-
- expect(getBumpTypeForPR(prInfo)).toBe('patch');
- });
-
- it('should return major for breaking change with default config', () => {
- const prInfo: CurrentPRInfo = {
- ...basePRInfo,
- title: 'feat!: Breaking change',
- };
- readFileSyncMock.mockImplementation(() => {
- throw { code: 'ENOENT' };
- });
-
- expect(getBumpTypeForPR(prInfo)).toBe('major');
- });
-
- it('should return null for unmatched title', () => {
- const prInfo: CurrentPRInfo = {
- ...basePRInfo,
- title: 'Random commit message',
- };
- readFileSyncMock.mockImplementation(() => {
- throw { code: 'ENOENT' };
- });
-
- expect(getBumpTypeForPR(prInfo)).toBeNull();
- });
-
- it('should match by label when config has label-based categories', () => {
- const prInfo: CurrentPRInfo = {
- ...basePRInfo,
- title: 'Some random title',
- labels: ['feature'],
- };
- readFileSyncMock.mockImplementation((path: any) => {
- if (typeof path === 'string' && path.includes('release.yml')) {
- return `changelog:
- categories:
- - title: Features
- labels:
- - feature
- semver: minor`;
- }
- throw { code: 'ENOENT' };
- });
-
- expect(getBumpTypeForPR(prInfo)).toBe('minor');
- });
-
- it('should work for skipped PRs (still determines bump type)', () => {
- const prInfo: CurrentPRInfo = {
- ...basePRInfo,
- title: 'feat: New feature',
- body: '#skip-changelog',
- };
- readFileSyncMock.mockImplementation(() => {
- throw { code: 'ENOENT' };
- });
-
- // PR is skipped but should still have a bump type
- expect(shouldSkipCurrentPR(prInfo)).toBe(true);
- expect(getBumpTypeForPR(prInfo)).toBe('minor');
- });
-
- it('should return minor for feat with scope', () => {
- const prInfo: CurrentPRInfo = {
- ...basePRInfo,
- title: 'feat(api): Add new endpoint',
- };
- readFileSyncMock.mockImplementation(() => {
- throw { code: 'ENOENT' };
- });
-
- expect(getBumpTypeForPR(prInfo)).toBe('minor');
- });
-
- it('should return patch for fix with scope', () => {
- const prInfo: CurrentPRInfo = {
- ...basePRInfo,
- title: 'fix(core): Fix memory leak',
- };
- readFileSyncMock.mockImplementation(() => {
- throw { code: 'ENOENT' };
- });
-
- expect(getBumpTypeForPR(prInfo)).toBe('patch');
- });
-
- it('should return patch for docs: prefix in default config', () => {
- const prInfo: CurrentPRInfo = {
- ...basePRInfo,
- title: 'docs: Update README',
- };
- readFileSyncMock.mockImplementation(() => {
- throw { code: 'ENOENT' };
- });
-
- // docs category in default config has patch semver
- expect(getBumpTypeForPR(prInfo)).toBe('patch');
- });
-
- it('should return patch for chore: prefix in default config', () => {
- const prInfo: CurrentPRInfo = {
- ...basePRInfo,
- title: 'chore: Update dependencies',
- };
- readFileSyncMock.mockImplementation(() => {
- throw { code: 'ENOENT' };
- });
-
- // chore is in the build/internal category with patch semver
- expect(getBumpTypeForPR(prInfo)).toBe('patch');
- });
-
- it('should prefer label over title pattern when both match', () => {
- const prInfo: CurrentPRInfo = {
- ...basePRInfo,
- title: 'feat: This looks like a feature',
- labels: ['bug'], // Label says bug, title says feat
- };
- readFileSyncMock.mockImplementation((path: any) => {
- if (typeof path === 'string' && path.includes('release.yml')) {
- return `changelog:
- categories:
- - title: Bug Fixes
- labels:
- - bug
- semver: patch
- - title: Features
- labels:
- - feature
- semver: minor`;
- }
- throw { code: 'ENOENT' };
- });
-
- // Label takes precedence, so should be patch (bug) not minor (feat)
- expect(getBumpTypeForPR(prInfo)).toBe('patch');
- });
-});
-
-describe('body inclusion behavior', () => {
- let mockClient: jest.Mock;
- const mockGetChangesSince = getChangesSince as jest.MockedFunction<
- typeof getChangesSince
- >;
- const dummyGit = {} as SimpleGit;
-
- interface TestCommit {
- author?: string;
- hash: string;
- title: string;
- body: string;
- pr?: {
- local?: string;
- remote?: {
- author?: { login: string };
- number: string;
- body?: string;
- labels?: string[];
- };
- };
- }
-
- function setup(commits: TestCommit[], releaseConfigYaml: string | null) {
- jest.resetAllMocks();
- clearChangesetCache();
- mockClient = jest.fn();
- (
- getGitHubClient as jest.MockedFunction<
- typeof getGitHubClient
- // @ts-ignore we only need to mock a subset
- >
- ).mockReturnValue({ graphql: mockClient } as any);
- getGlobalGitHubConfigMock.mockResolvedValue({
- repo: 'test-repo',
- owner: 'test-owner',
- });
-
- // Mock getChangesSince
- mockGetChangesSince.mockResolvedValueOnce(
- commits.map(commit => ({
- hash: commit.hash,
- title: commit.title,
- body: commit.body,
- pr: commit.pr?.local || null,
- }))
- );
-
- // Mock GitHub API response
- mockClient.mockResolvedValueOnce({
- repository: Object.fromEntries(
- commits.map(({ hash, author, title, pr }: TestCommit) => [
- `C${hash}`,
- {
- author: { user: author },
- associatedPullRequests: {
- nodes: pr?.remote
- ? [
- {
- author: pr.remote.author,
- number: pr.remote.number,
- title: title,
- body: pr.remote.body || '',
- labels: {
- nodes: (pr.remote.labels || []).map(label => ({
- name: label,
- })),
- },
- },
- ]
- : [],
- },
- },
- ])
- ),
- });
-
- // Mock release config
- if (releaseConfigYaml === null) {
- getConfigFileDirMock.mockReturnValue(undefined);
- readFileSyncMock.mockImplementation(() => {
- const error: any = new Error('ENOENT');
- error.code = 'ENOENT';
- throw error;
- });
- } else {
- getConfigFileDirMock.mockReturnValue('/workspace');
- readFileSyncMock.mockImplementation((path: any) => {
- if (
- typeof path === 'string' &&
- path.includes('.github/release.yml')
- ) {
- return releaseConfigYaml;
- }
- const error: any = new Error('ENOENT');
- error.code = 'ENOENT';
- throw error;
- });
- }
- }
-
- it('should NOT include PR body without magic word in categorized commits', async () => {
- const releaseConfig = `changelog:
- categories:
- - title: Features
- labels:
- - feature`;
-
- setup(
- [
- {
- hash: 'abc123',
- title: 'Add new feature (#1)',
- body: '',
- pr: {
- local: '1',
- remote: {
- number: '1',
- author: { login: 'alice' },
- body: 'This is a PR body WITHOUT the magic word.',
- labels: ['feature'],
- },
- },
- },
- ],
- releaseConfig
- );
-
- const result = await generateChangesetFromGit(dummyGit, '1.0.0', 3);
- // Body should NOT appear in output (no indented line after the title)
- expect(result.changelog).not.toContain(
- 'This is a PR body WITHOUT the magic word'
- );
- expect(result.changelog).toContain('Add new feature');
- });
-
- it('should include PR body WITH magic word in categorized commits', async () => {
- const releaseConfig = `changelog:
- categories:
- - title: Features
- labels:
- - feature`;
-
- setup(
- [
- {
- hash: 'abc123',
- title: 'Add new feature (#1)',
- body: '',
- pr: {
- local: '1',
- remote: {
- number: '1',
- author: { login: 'alice' },
- body: `This is important context. ${BODY_IN_CHANGELOG_MAGIC_WORD}`,
- labels: ['feature'],
- },
- },
- },
- ],
- releaseConfig
- );
-
- const result = await generateChangesetFromGit(dummyGit, '1.0.0', 3);
- expect(result.changelog).toContain('This is important context.');
- });
-
- it('should NOT include PR body without magic word in uncategorized commits', async () => {
- const releaseConfig = `changelog:
- categories:
- - title: Features
- labels:
- - feature`;
-
- setup(
- [
- {
- hash: 'abc123',
- title: 'Some uncategorized change (#1)',
- body: '',
- pr: {
- local: '1',
- remote: {
- number: '1',
- author: { login: 'alice' },
- body: 'This PR body should NOT appear.',
- labels: [], // No matching labels -> goes to "Other"
- },
- },
- },
- ],
- releaseConfig
- );
-
- const result = await generateChangesetFromGit(dummyGit, '1.0.0', 3);
- expect(result.changelog).not.toContain('This PR body should NOT appear');
- expect(result.changelog).toContain('Some uncategorized change');
- });
-
- it('should use commit body as fallback for magic word in categorized commits', async () => {
- const releaseConfig = `changelog:
- categories:
- - title: Features
- labels:
- - feature`;
-
- setup(
- [
- {
- hash: 'abc123',
- title: 'Add new feature (#1)',
- body: `Important commit context ${BODY_IN_CHANGELOG_MAGIC_WORD}`,
- pr: {
- local: '1',
- remote: {
- number: '1',
- author: { login: 'alice' },
- body: 'PR body without magic word',
- labels: ['feature'],
- },
- },
- },
- ],
- releaseConfig
- );
-
- const result = await generateChangesetFromGit(dummyGit, '1.0.0', 3);
- // The commit body with magic word should be included as fallback
- expect(result.changelog).toContain('Important commit context');
- });
-
- it('should use commit body as fallback for magic word in uncategorized commits', async () => {
- const releaseConfig = `changelog:
- categories:
- - title: Features
- labels:
- - feature`;
-
- setup(
- [
- {
- hash: 'abc123',
- title: 'Some change (#1)',
- body: `Commit body with context ${BODY_IN_CHANGELOG_MAGIC_WORD}`,
- pr: {
- local: '1',
- remote: {
- number: '1',
- author: { login: 'alice' },
- body: 'PR body without magic word',
- labels: [], // Goes to "Other"
- },
- },
- },
- ],
- releaseConfig
- );
-
- const result = await generateChangesetFromGit(dummyGit, '1.0.0', 3);
- expect(result.changelog).toContain('Commit body with context');
- });
-
- it('should prefer PR body over commit body when both have magic word', async () => {
- const releaseConfig = `changelog:
- categories:
- - title: Features
- labels:
- - feature`;
-
- setup(
- [
- {
- hash: 'abc123',
- title: 'Add new feature (#1)',
- body: `Commit body content ${BODY_IN_CHANGELOG_MAGIC_WORD}`,
- pr: {
- local: '1',
- remote: {
- number: '1',
- author: { login: 'alice' },
- body: `PR body content ${BODY_IN_CHANGELOG_MAGIC_WORD}`,
- labels: ['feature'],
- },
- },
- },
- ],
- releaseConfig
- );
-
- const result = await generateChangesetFromGit(dummyGit, '1.0.0', 3);
- // PR body should take precedence
- expect(result.changelog).toContain('PR body content');
- expect(result.changelog).not.toContain('Commit body content');
- });
-
- it('should behave consistently between categorized and uncategorized commits with commit body magic word', async () => {
- const releaseConfig = `changelog:
- categories:
- - title: Features
- labels:
- - feature`;
-
- setup(
- [
- {
- hash: 'abc123',
- title: 'Categorized change (#1)',
- body: `Categorized commit body ${BODY_IN_CHANGELOG_MAGIC_WORD}`,
- pr: {
- local: '1',
- remote: {
- number: '1',
- author: { login: 'alice' },
- body: 'PR body no magic',
- labels: ['feature'],
- },
- },
- },
- {
- hash: 'def456',
- title: 'Uncategorized change (#2)',
- body: `Uncategorized commit body ${BODY_IN_CHANGELOG_MAGIC_WORD}`,
- pr: {
- local: '2',
- remote: {
- number: '2',
- author: { login: 'bob' },
- body: 'PR body no magic',
- labels: [], // Goes to "Other"
- },
- },
- },
- ],
- releaseConfig
- );
-
- const result = await generateChangesetFromGit(dummyGit, '1.0.0', 3);
- // BOTH should include their commit body since the magic word is present
- expect(result.changelog).toContain('Categorized commit body');
- expect(result.changelog).toContain('Uncategorized commit body');
- });
-});
diff --git a/src/utils/__tests__/fixtures/changelog-mocks.ts b/src/utils/__tests__/fixtures/changelog-mocks.ts
new file mode 100644
index 00000000..21e973de
--- /dev/null
+++ b/src/utils/__tests__/fixtures/changelog-mocks.ts
@@ -0,0 +1,120 @@
+/**
+ * Shared mock setup for changelog tests.
+ */
+import type { SimpleGit } from 'simple-git';
+import type { TestCommit } from './changelog';
+
+// Re-export for convenience
+export type { TestCommit } from './changelog';
+
+// These will be set up by the test files that import this
+export let mockClient: jest.Mock;
+export let mockGetChangesSince: jest.MockedFunction;
+export let getConfigFileDirMock: jest.MockedFunction;
+export let getGlobalGitHubConfigMock: jest.MockedFunction;
+export let readFileSyncMock: jest.MockedFunction;
+
+export const dummyGit = {} as SimpleGit;
+
+/**
+ * Initialize mocks - call this in beforeEach of test files that need GitHub mocking.
+ */
+export function initMocks(
+ getGitHubClient: any,
+ getChangesSince: any,
+ config: any,
+ readFileSync: any,
+ clearChangesetCache: () => void
+): void {
+ mockClient = jest.fn();
+ mockGetChangesSince = getChangesSince as jest.MockedFunction;
+ getConfigFileDirMock = config.getConfigFileDir as jest.MockedFunction;
+ getGlobalGitHubConfigMock = config.getGlobalGitHubConfig as jest.MockedFunction;
+ readFileSyncMock = readFileSync as jest.MockedFunction;
+
+ jest.resetAllMocks();
+ clearChangesetCache();
+
+ (getGitHubClient as jest.MockedFunction).mockReturnValue({
+ graphql: mockClient,
+ } as any);
+
+ getConfigFileDirMock.mockReturnValue(undefined);
+ getGlobalGitHubConfigMock.mockResolvedValue({
+ repo: 'test-repo',
+ owner: 'test-owner',
+ });
+ readFileSyncMock.mockImplementation(() => {
+ const error: any = new Error('ENOENT');
+ error.code = 'ENOENT';
+ throw error;
+ });
+}
+
+/**
+ * Setup function for generateChangesetFromGit tests.
+ * Configures mocks for a specific test scenario.
+ */
+export function setupGenerateTest(
+ commits: TestCommit[],
+ releaseConfig?: string | null
+): void {
+ mockGetChangesSince.mockResolvedValueOnce(
+ commits.map(commit => ({
+ hash: commit.hash,
+ title: commit.title,
+ body: commit.body,
+ pr: commit.pr?.local || null,
+ }))
+ );
+
+ mockClient.mockResolvedValueOnce({
+ repository: Object.fromEntries(
+ commits.map(({ hash, author, title, pr }: TestCommit) => [
+ `C${hash}`,
+ {
+ author: { user: author },
+ associatedPullRequests: {
+ nodes: pr?.remote
+ ? [
+ {
+ author: pr.remote.author,
+ number: pr.remote.number,
+ title: pr.remote.title ?? title,
+ body: pr.remote.body || '',
+ labels: {
+ nodes: (pr.remote.labels || []).map(label => ({
+ name: label,
+ })),
+ },
+ },
+ ]
+ : [],
+ },
+ },
+ ])
+ ),
+ });
+
+ if (releaseConfig !== undefined) {
+ if (releaseConfig === null) {
+ getConfigFileDirMock.mockReturnValue(undefined);
+ readFileSyncMock.mockImplementation(() => {
+ const error: any = new Error('ENOENT');
+ error.code = 'ENOENT';
+ throw error;
+ });
+ } else {
+ getConfigFileDirMock.mockReturnValue('/workspace');
+ readFileSyncMock.mockImplementation((path: any) => {
+ if (typeof path === 'string' && path.includes('.github/release.yml')) {
+ return releaseConfig;
+ }
+ const error: any = new Error('ENOENT');
+ error.code = 'ENOENT';
+ throw error;
+ });
+ }
+ }
+}
+