diff --git a/.github/workflows/changelog-preview.yml b/.github/workflows/changelog-preview.yml
new file mode 100644
index 000000000..95d6bdcbe
--- /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/README.md b/README.md
index 8b5686c0a..7fc86b4e6 100644
--- a/README.md
+++ b/README.md
@@ -43,6 +43,7 @@ craft publish 1.2.3
- **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
## Configuration
@@ -83,6 +84,78 @@ See the [configuration reference](https://getsentry.github.io/craft/configuratio
See the [targets documentation](https://getsentry.github.io/craft/targets/) for configuration details.
+## GitHub Actions
+
+Craft provides GitHub Actions for automating releases and previewing changelog entries.
+
+### Prepare Release Action
+
+Automates the `craft prepare` workflow in GitHub Actions:
+
+```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 (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` |
+
+**Outputs:**
+
+| 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 |
+
+### Changelog Preview (Reusable Workflow)
+
+Posts a preview comment on PRs showing how they'll appear in the changelog:
+
+```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
+```
+
+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)
+
## Contributing
See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup and guidelines.
diff --git a/action.yml b/action.yml
index 31ebe3f00..e0aa73f7e 100644
--- a/action.yml
+++ b/action.yml
@@ -83,42 +83,8 @@ runs:
echo "GIT_AUTHOR_NAME=${GIT_USER_NAME}" >> $GITHUB_ENV
echo "EMAIL=${GIT_USER_EMAIL}" >> $GITHUB_ENV
- - 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
-
- - name: Install Craft from release
- if: steps.artifact.outcome != 'success'
- shell: bash
- run: |
- # 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
-
- echo "Installing Craft from: ${CRAFT_URL}"
- sudo curl -sL -o /usr/local/bin/craft "$CRAFT_URL"
- sudo chmod +x /usr/local/bin/craft
+ - name: Install Craft
+ uses: ./install
- name: Craft Prepare
id: craft
diff --git a/docs/astro.config.mjs b/docs/astro.config.mjs
index ee32859b0..9046114cb 100644
--- a/docs/astro.config.mjs
+++ b/docs/astro.config.mjs
@@ -23,6 +23,7 @@ export default defineConfig({
{ label: 'Introduction', slug: '' },
{ label: 'Installation', slug: 'getting-started' },
{ label: 'Configuration', slug: 'configuration' },
+ { label: 'GitHub Actions', slug: 'github-actions' },
],
},
{
diff --git a/docs/src/content/docs/github-actions.md b/docs/src/content/docs/github-actions.md
new file mode 100644
index 000000000..2cffed2b6
--- /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/install/action.yml b/install/action.yml
new file mode 100644
index 000000000..e84c4bfd4
--- /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/src/commands/changelog.ts b/src/commands/changelog.ts
new file mode 100644
index 000000000..53cfed7b7
--- /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/index.ts b/src/index.ts
index bc3c25dd8..acb7efe40 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')) {
@@ -84,6 +85,7 @@ async function main(): Promise {
.command(targets)
.command(config)
.command(artifacts)
+ .command(changelog)
.demandCommand()
.version(getPackageVersion())
.alias('v', 'version')
diff --git a/src/utils/changelog.ts b/src/utils/changelog.ts
index 2d62bc047..55348cb32 100644
--- a/src/utils/changelog.ts
+++ b/src/utils/changelog.ts
@@ -13,6 +13,50 @@ 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.
*/
@@ -272,6 +316,8 @@ interface PullRequest {
hash: string;
body: string;
title: string;
+ /** Whether this entry should be highlighted in output */
+ highlight?: boolean;
}
interface Commit {
@@ -285,6 +331,25 @@ 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;
}
/**
@@ -632,11 +697,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;
@@ -679,6 +747,14 @@ function formatChangelogEntry(entry: ChangelogEntry): string {
}
}
+ // Apply blockquote highlighting if requested
+ if (entry.highlight) {
+ text = text
+ .split('\n')
+ .map(line => `> ${line}`)
+ .join('\n');
+ }
+
return text;
}
@@ -697,6 +773,40 @@ export interface ChangelogResult {
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>();
@@ -733,15 +843,77 @@ export async function generateChangesetFromGit(
return promise;
}
-async function generateChangesetFromGitImpl(
+/**
+ * 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,
- maxLeftovers: number
+ currentPRNumber: number
): Promise {
- const rawConfig = readReleaseConfig();
- const releaseConfig = normalizeReleaseConfig(rawConfig);
+ // 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);
+
+ // Step 6: Serialize to markdown
+ const changelog = await serializeChangelog(rawData, MAX_LEFTOVERS);
+
+ return {
+ changelog,
+ ...stats,
+ };
+}
- const gitCommits = (await getChangesSince(git, rev)).filter(
+/**
+ * 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)
);
@@ -749,39 +921,62 @@ async function generateChangesetFromGitImpl(
gitCommits.map(({ hash }) => hash)
);
- const categories = new Map();
- const commits: Record*hash*/ string, Commit> = {};
- const leftovers: Commit[] = [];
- const missing: Commit[] = [];
-
- // Track bump type for auto-versioning (lower priority value = higher bump)
- let bumpPriority: number | null = null;
- let matchedCommitsWithSemver = 0;
+ 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;
+}
- if (shouldExcludePR(labels, author, releaseConfig)) {
+/**
+ * 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;
+
+ 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
- // Trim to handle any leading/trailing whitespace that could break pattern matching
- const titleForMatching = (
- githubCommit?.prTitle ?? gitCommit.title
- ).trim();
+ const titleForMatching = (raw.prTitle ?? raw.title).trim();
const matchedCategory = matchCommitToCategory(
labels,
- author,
+ raw.author,
titleForMatching,
releaseConfig
);
@@ -796,60 +991,54 @@ async function generateChangesetFromGitImpl(
}
}
- 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);
- }
-
- // Extract and normalize scope from PR title
- // Trim to handle any leading/trailing whitespace
- const prTitle = (commit.prTitle ?? commit.title).trim();
- const scope = extractScope(prTitle);
+ // Has category and PR - add to category
+ let category = categories.get(categoryTitle);
+ if (!category) {
+ category = {
+ title: categoryTitle,
+ scopeGroups: new Map(),
+ };
+ categories.set(categoryTitle, category);
+ }
- // Get or create the scope group
- let scopeGroup = category.scopeGroups.get(scope);
- if (!scopeGroup) {
- scopeGroup = [];
- category.scopeGroups.set(scope, scopeGroup);
- }
+ const prTitle = (raw.prTitle ?? raw.title).trim();
+ const scope = extractScope(prTitle);
- scopeGroup.push({
- author: commit.author,
- number: commit.pr,
- hash: commit.hash,
- body: commit.prBody ?? '',
- title: prTitle,
- });
+ let scopeGroup = category.scopeGroups.get(scope);
+ if (!scopeGroup) {
+ scopeGroup = [];
+ category.scopeGroups.set(scope, scopeGroup);
}
+
+ scopeGroup.push({
+ author: raw.author,
+ number: raw.pr,
+ hash: raw.hash,
+ body: raw.prBody ?? '',
+ title: prTitle,
+ highlight: raw.highlight,
+ });
}
}
@@ -867,11 +1056,57 @@ async function generateChangesetFromGitImpl(
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}`;
@@ -886,7 +1121,7 @@ async function generateChangesetFromGitImpl(
// 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);
@@ -935,6 +1170,7 @@ async function generateChangesetFromGitImpl(
hash: pr.hash,
body: pr.body,
repoUrl,
+ highlight: pr.highlight,
})
);
@@ -988,6 +1224,7 @@ async function generateChangesetFromGitImpl(
: commit.body.includes(BODY_IN_CHANGELOG_MAGIC_WORD)
? commit.body
: undefined,
+ highlight: commit.highlight,
})
)
.join('\n')
@@ -997,11 +1234,24 @@ async function generateChangesetFromGitImpl(
}
}
+ 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: changelogSections.join('\n\n'),
- bumpType,
- totalCommits: gitCommits.length,
- matchedCommitsWithSemver,
+ changelog,
+ ...stats,
};
}
diff --git a/src/utils/git.ts b/src/utils/git.ts
index 35ff34707..e74273dc2 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.