From 697b38ffc21a5c5bf9a5771290db8a0b539ce0f7 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Fri, 26 Dec 2025 16:37:22 +0300 Subject: [PATCH 01/24] feat: Add changelog preview GitHub Action Add a new GitHub Action that posts changelog previews on PRs, showing contributors how their changes will appear in the changelog. Changes: - New `craft changelog` CLI command with `--pr` option for highlighting - New `changelog-preview/action.yml` that posts/updates PR comments - Shared `install/action.yml` for Craft installation (used by both actions) - Refactored main action.yml to use shared install action - Documentation in README and docs site --- README.md | 82 +++++++++ action.yml | 38 +---- changelog-preview/action.yml | 60 +++++++ docs/astro.config.mjs | 1 + docs/src/content/docs/github-actions.md | 212 ++++++++++++++++++++++++ install/action.yml | 42 +++++ src/commands/changelog.ts | 71 ++++++++ src/index.ts | 2 + src/utils/changelog.ts | 38 ++++- 9 files changed, 506 insertions(+), 40 deletions(-) create mode 100644 changelog-preview/action.yml create mode 100644 docs/src/content/docs/github-actions.md create mode 100644 install/action.yml create mode 100644 src/commands/changelog.ts diff --git a/README.md b/README.md index 8b5686c0a..e83aa8893 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,87 @@ 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 Action + +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] + +permissions: + pull-requests: write + +jobs: + preview: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: getsentry/craft/changelog-preview@v2 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} +``` + +The action 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 the comment when the PR is updated + ## Contributing See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup and guidelines. diff --git a/action.yml b/action.yml index 31ebe3f00..a468df077 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: getsentry/craft/install@master - name: Craft Prepare id: craft diff --git a/changelog-preview/action.yml b/changelog-preview/action.yml new file mode 100644 index 000000000..46b94098a --- /dev/null +++ b/changelog-preview/action.yml @@ -0,0 +1,60 @@ +name: "Craft Changelog Preview" +description: "Preview how a PR will appear in the changelog" + +runs: + using: "composite" + steps: + - name: Install Craft + uses: getsentry/craft/install@master + + - name: Generate Changelog Preview + id: changelog + shell: bash + env: + GH_TOKEN: ${{ github.token }} + CRAFT_LOG_LEVEL: Warn + run: | + PR_NUMBER="${{ github.event.pull_request.number }}" + + # Generate changelog with PR highlighting + CHANGELOG=$(craft changelog --pr "$PR_NUMBER" 2>/dev/null || echo "") + + if [[ -z "$CHANGELOG" ]]; then + CHANGELOG="_No changelog entries will be generated from this PR._" + fi + + # Build comment body with hidden marker for updates + COMMENT_BODY=" + ## 📋 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 push changes." + + # Save to file for the comment step (handles multiline properly) + echo "$COMMENT_BODY" > /tmp/changelog-comment.md + + # 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=@/tmp/changelog-comment.md + else + echo "Creating new comment..." + gh api -X POST \ + "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/comments" \ + -F body=@/tmp/changelog-comment.md + fi 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..87d778cf9 --- /dev/null +++ b/docs/src/content/docs/github-actions.md @@ -0,0 +1,212 @@ +--- +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. + +## 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 Action + +The changelog preview action 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 + +```yaml +name: Changelog Preview +on: + pull_request: + types: [opened, synchronize, reopened] + +permissions: + pull-requests: write + +jobs: + preview: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: getsentry/craft/changelog-preview@v2 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} +``` + +### How It Works + +1. **Generates the changelog** - Runs `craft changelog` to generate the upcoming changelog including all commits since the last tag +2. **Highlights PR entries** - Entries from the current PR are rendered with blockquote style (displayed with a left border in GitHub) +3. **Posts a comment** - Creates or updates a comment on the PR with the changelog preview +4. **Auto-updates** - The comment is automatically updated when new commits are pushed to the PR + +### Example Comment + +The action posts a comment like this: + +```markdown +## 📋 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 push changes. +``` + +### Requirements + +- The workflow needs `pull-requests: write` permission to post comments +- The repository should have a git history with tags for the changelog to be meaningful +- Use `fetch-depth: 0` in the checkout action to get full history + +## Tips + +### Combining Both Actions + +You can use both actions together for a complete release workflow: + +```yaml +# .github/workflows/changelog-preview.yml +name: Changelog Preview +on: + pull_request: + types: [opened, synchronize, reopened] + +permissions: + pull-requests: write + +jobs: + preview: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: getsentry/craft/changelog-preview@v2 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} +``` + +```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 }} +``` + +### Skipping Changelog Entries + +Use `#skip-changelog` in your commit message or PR body to exclude a commit from the changelog: + +``` +chore: Update dependencies + +#skip-changelog +``` diff --git a/install/action.yml b/install/action.yml new file mode 100644 index 000000000..1b36d6839 --- /dev/null +++ b/install/action.yml @@ -0,0 +1,42 @@ +name: "Install Craft" +description: "Install Craft CLI (from build artifact for dogfooding, or from release)" + +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 + + - 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 diff --git a/src/commands/changelog.ts b/src/commands/changelog.ts new file mode 100644 index 000000000..c02e14db1 --- /dev/null +++ b/src/commands/changelog.ts @@ -0,0 +1,71 @@ +import { Argv, CommandBuilder } from 'yargs'; + +import { logger } from '../logger'; +import { getGitClient, getLatestTag } from '../utils/git'; +import { generateChangelogWithHighlight } from '../utils/changelog'; +import { handleGlobalError } from '../utils/errors'; + +export const command = ['changelog']; +export const description = 'Generate changelog from git history'; + +/** Command line options */ +interface ChangelogOptions { + /** Base revision to generate changelog from (defaults to latest tag) */ + since?: string; + /** PR number to highlight in the output */ + pr?: number; +} + +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 to highlight in the output. Entries from this PR will be rendered as blockquotes.', + type: 'number', + }); + +/** + * Body of 'changelog' command + */ +export async function changelogMain(argv: ChangelogOptions): Promise { + const git = await getGitClient(); + + // Determine base revision + 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 with optional PR highlighting + const highlightPR = argv.pr ? String(argv.pr) : undefined; + const result = await generateChangelogWithHighlight(git, since, highlightPR); + + if (!result.changelog) { + console.log('No changelog entries found.'); + return; + } + + // Output to stdout + 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..af3a026e0 100644 --- a/src/utils/changelog.ts +++ b/src/utils/changelog.ts @@ -632,11 +632,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 +682,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; } @@ -733,10 +744,29 @@ export async function generateChangesetFromGit( return promise; } +/** + * Generates a changelog from git history with optional PR highlighting. + * When highlightPR is provided, entries from that PR are rendered as blockquotes. + * This function does not use caching since highlight options can vary. + * + * @param git Local git client + * @param rev Base revision (tag or SHA) to generate changelog from + * @param highlightPR Optional PR number to highlight in the output + * @returns The changelog result with formatted markdown + */ +export async function generateChangelogWithHighlight( + git: SimpleGit, + rev: string, + highlightPR?: string +): Promise { + return generateChangesetFromGitImpl(git, rev, MAX_LEFTOVERS, highlightPR); +} + async function generateChangesetFromGitImpl( git: SimpleGit, rev: string, - maxLeftovers: number + maxLeftovers: number, + highlightPR?: string ): Promise { const rawConfig = readReleaseConfig(); const releaseConfig = normalizeReleaseConfig(rawConfig); @@ -776,9 +806,7 @@ async function generateChangesetFromGitImpl( // 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 = (githubCommit?.prTitle ?? gitCommit.title).trim(); const matchedCategory = matchCommitToCategory( labels, author, @@ -935,6 +963,7 @@ async function generateChangesetFromGitImpl( hash: pr.hash, body: pr.body, repoUrl, + highlight: highlightPR !== undefined && pr.number === highlightPR, }) ); @@ -988,6 +1017,7 @@ async function generateChangesetFromGitImpl( : commit.body.includes(BODY_IN_CHANGELOG_MAGIC_WORD) ? commit.body : undefined, + highlight: highlightPR !== undefined && commit.pr === highlightPR, }) ) .join('\n') From be12666c8871fa7f0549b0edb05a09490658f72f Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Fri, 26 Dec 2025 17:06:23 +0300 Subject: [PATCH 02/24] ci: Add changelog preview workflow for testing --- .github/workflows/changelog-preview.yml | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 .github/workflows/changelog-preview.yml diff --git a/.github/workflows/changelog-preview.yml b/.github/workflows/changelog-preview.yml new file mode 100644 index 000000000..1e73f47ad --- /dev/null +++ b/.github/workflows/changelog-preview.yml @@ -0,0 +1,21 @@ +name: Changelog Preview + +on: + pull_request: + types: [opened, synchronize, reopened] + +permissions: + pull-requests: write + +jobs: + preview: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: ./changelog-preview + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + From 749a43bea9ab9d0cbdb133104745d6c6fe111666 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Fri, 26 Dec 2025 17:07:46 +0300 Subject: [PATCH 03/24] ci: Fix changelog preview workflow for testing Use local ./install action and inline the preview steps since the composite action references getsentry/craft/install@master which doesn't exist on master yet. --- .github/workflows/changelog-preview.yml | 54 ++++++++++++++++++++++++- 1 file changed, 52 insertions(+), 2 deletions(-) diff --git a/.github/workflows/changelog-preview.yml b/.github/workflows/changelog-preview.yml index 1e73f47ad..861174b35 100644 --- a/.github/workflows/changelog-preview.yml +++ b/.github/workflows/changelog-preview.yml @@ -15,7 +15,57 @@ jobs: with: fetch-depth: 0 - - uses: ./changelog-preview + # Use local install action (since it's not on master yet) + - uses: ./install + + - name: Generate Changelog Preview + shell: bash env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + CRAFT_LOG_LEVEL: Warn + run: | + PR_NUMBER="${{ github.event.pull_request.number }}" + + # Generate changelog with PR highlighting + CHANGELOG=$(craft changelog --pr "$PR_NUMBER" 2>/dev/null || echo "") + + if [[ -z "$CHANGELOG" ]]; then + CHANGELOG="_No changelog entries will be generated from this PR._" + fi + + # Build comment body with hidden marker for updates + COMMENT_BODY=" + ## 📋 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 push changes." + + # Save to file for the comment step (handles multiline properly) + echo "$COMMENT_BODY" > /tmp/changelog-comment.md + + # 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=@/tmp/changelog-comment.md + else + echo "Creating new comment..." + gh api -X POST \ + "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/comments" \ + -F body=@/tmp/changelog-comment.md + fi From 0a776333bf3c4dee48ec697e0fd379a37bcf9a36 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Fri, 26 Dec 2025 17:13:44 +0300 Subject: [PATCH 04/24] fix: Use commit-based highlighting instead of PR number The previous approach relied on GitHub's associatedPullRequests API which only works for merged commits. Now we identify PR commits by comparing HEAD with the base branch and highlight those specific commits. Changes: - Replace --pr option with --base option for specifying the PR base branch - Highlight commits that are in HEAD but not in base (i.e., PR commits) - Update workflow and action to fetch base and use new option --- .github/workflows/changelog-preview.yml | 9 ++++-- changelog-preview/action.yml | 10 +++++-- src/commands/changelog.ts | 40 +++++++++++++++++++------ src/utils/changelog.ts | 16 +++++----- 4 files changed, 52 insertions(+), 23 deletions(-) diff --git a/.github/workflows/changelog-preview.yml b/.github/workflows/changelog-preview.yml index 861174b35..ca5800255 100644 --- a/.github/workflows/changelog-preview.yml +++ b/.github/workflows/changelog-preview.yml @@ -25,9 +25,13 @@ jobs: CRAFT_LOG_LEVEL: Warn run: | PR_NUMBER="${{ github.event.pull_request.number }}" + BASE_REF="${{ github.event.pull_request.base.ref }}" - # Generate changelog with PR highlighting - CHANGELOG=$(craft changelog --pr "$PR_NUMBER" 2>/dev/null || echo "") + # Fetch base branch for comparison + git fetch origin "$BASE_REF" --depth=1 2>/dev/null || true + + # Generate changelog with PR commits highlighted + CHANGELOG=$(craft changelog --base "origin/$BASE_REF" 2>/dev/null || echo "") if [[ -z "$CHANGELOG" ]]; then CHANGELOG="_No changelog entries will be generated from this PR._" @@ -68,4 +72,3 @@ jobs: "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/comments" \ -F body=@/tmp/changelog-comment.md fi - diff --git a/changelog-preview/action.yml b/changelog-preview/action.yml index 46b94098a..d4369d27d 100644 --- a/changelog-preview/action.yml +++ b/changelog-preview/action.yml @@ -15,9 +15,13 @@ runs: CRAFT_LOG_LEVEL: Warn run: | PR_NUMBER="${{ github.event.pull_request.number }}" - - # Generate changelog with PR highlighting - CHANGELOG=$(craft changelog --pr "$PR_NUMBER" 2>/dev/null || echo "") + BASE_REF="${{ github.event.pull_request.base.ref }}" + + # Fetch base branch for comparison + git fetch origin "$BASE_REF" --depth=1 2>/dev/null || true + + # Generate changelog with PR commits highlighted + CHANGELOG=$(craft changelog --base "origin/$BASE_REF" 2>/dev/null || echo "") if [[ -z "$CHANGELOG" ]]; then CHANGELOG="_No changelog entries will be generated from this PR._" diff --git a/src/commands/changelog.ts b/src/commands/changelog.ts index c02e14db1..d191a4973 100644 --- a/src/commands/changelog.ts +++ b/src/commands/changelog.ts @@ -12,8 +12,8 @@ export const description = 'Generate changelog from git history'; interface ChangelogOptions { /** Base revision to generate changelog from (defaults to latest tag) */ since?: string; - /** PR number to highlight in the output */ - pr?: number; + /** Base branch/ref for PR comparison (to identify which commits to highlight) */ + base?: string; } export const builder: CommandBuilder = (yargs: Argv) => @@ -24,10 +24,11 @@ export const builder: CommandBuilder = (yargs: Argv) => 'Base revision (tag or SHA) to generate changelog from. Defaults to latest tag.', type: 'string', }) - .option('pr', { + .option('base', { + alias: 'b', description: - 'PR number to highlight in the output. Entries from this PR will be rendered as blockquotes.', - type: 'number', + 'Base branch/ref for highlighting PR commits. Commits between --base and HEAD will be highlighted.', + type: 'string', }); /** @@ -36,7 +37,7 @@ export const builder: CommandBuilder = (yargs: Argv) => export async function changelogMain(argv: ChangelogOptions): Promise { const git = await getGitClient(); - // Determine base revision + // Determine base revision for changelog generation let since = argv.since; if (!since) { since = await getLatestTag(git); @@ -47,9 +48,30 @@ export async function changelogMain(argv: ChangelogOptions): Promise { } } - // Generate changelog with optional PR highlighting - const highlightPR = argv.pr ? String(argv.pr) : undefined; - const result = await generateChangelogWithHighlight(git, since, highlightPR); + // Get commits to highlight (commits in HEAD but not in base) + let highlightCommits: Set | undefined; + if (argv.base) { + try { + // Get commits that are in HEAD but not in base (i.e., PR-specific commits) + const logOutput = await git.raw([ + 'log', + '--format=%H', + `${argv.base}..HEAD`, + '--', + '.', + ]); + const commits = logOutput.trim().split('\n').filter(Boolean); + if (commits.length > 0) { + highlightCommits = new Set(commits); + logger.debug(`Found ${commits.length} commits to highlight from PR`); + } + } catch (error) { + logger.warn(`Failed to get PR commits from base "${argv.base}":`, error); + } + } + + // Generate changelog with optional commit highlighting + const result = await generateChangelogWithHighlight(git, since, highlightCommits); if (!result.changelog) { console.log('No changelog entries found.'); diff --git a/src/utils/changelog.ts b/src/utils/changelog.ts index af3a026e0..a95fe4a9e 100644 --- a/src/utils/changelog.ts +++ b/src/utils/changelog.ts @@ -745,28 +745,28 @@ export async function generateChangesetFromGit( } /** - * Generates a changelog from git history with optional PR highlighting. - * When highlightPR is provided, entries from that PR are rendered as blockquotes. + * Generates a changelog from git history with optional commit highlighting. + * When highlightCommits is provided, entries from those commits are rendered as blockquotes. * This function does not use caching since highlight options can vary. * * @param git Local git client * @param rev Base revision (tag or SHA) to generate changelog from - * @param highlightPR Optional PR number to highlight in the output + * @param highlightCommits Optional set of commit hashes to highlight in the output * @returns The changelog result with formatted markdown */ export async function generateChangelogWithHighlight( git: SimpleGit, rev: string, - highlightPR?: string + highlightCommits?: Set ): Promise { - return generateChangesetFromGitImpl(git, rev, MAX_LEFTOVERS, highlightPR); + return generateChangesetFromGitImpl(git, rev, MAX_LEFTOVERS, highlightCommits); } async function generateChangesetFromGitImpl( git: SimpleGit, rev: string, maxLeftovers: number, - highlightPR?: string + highlightCommits?: Set ): Promise { const rawConfig = readReleaseConfig(); const releaseConfig = normalizeReleaseConfig(rawConfig); @@ -963,7 +963,7 @@ async function generateChangesetFromGitImpl( hash: pr.hash, body: pr.body, repoUrl, - highlight: highlightPR !== undefined && pr.number === highlightPR, + highlight: highlightCommits?.has(pr.hash) ?? false, }) ); @@ -1017,7 +1017,7 @@ async function generateChangesetFromGitImpl( : commit.body.includes(BODY_IN_CHANGELOG_MAGIC_WORD) ? commit.body : undefined, - highlight: highlightPR !== undefined && commit.pr === highlightPR, + highlight: highlightCommits?.has(commit.hash) ?? false, }) ) .join('\n') From de8d3eee1d2fd505355eded751f3b271c848904f Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Fri, 26 Dec 2025 17:14:50 +0300 Subject: [PATCH 05/24] debug: Add verbose output to changelog preview workflow --- .github/workflows/changelog-preview.yml | 11 ++++++++--- changelog-preview/action.yml | 4 ++-- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/.github/workflows/changelog-preview.yml b/.github/workflows/changelog-preview.yml index ca5800255..f59f8fcfd 100644 --- a/.github/workflows/changelog-preview.yml +++ b/.github/workflows/changelog-preview.yml @@ -28,10 +28,15 @@ jobs: BASE_REF="${{ github.event.pull_request.base.ref }}" # Fetch base branch for comparison - git fetch origin "$BASE_REF" --depth=1 2>/dev/null || true - + git fetch origin "$BASE_REF" --depth=1 + + # Debug: show commits between base and HEAD + echo "Commits in this PR:" + git log --oneline "origin/$BASE_REF..HEAD" -- . || echo "No commits found" + # Generate changelog with PR commits highlighted - CHANGELOG=$(craft changelog --base "origin/$BASE_REF" 2>/dev/null || echo "") + echo "Running craft changelog..." + CHANGELOG=$(craft changelog --base "origin/$BASE_REF" || echo "") if [[ -z "$CHANGELOG" ]]; then CHANGELOG="_No changelog entries will be generated from this PR._" diff --git a/changelog-preview/action.yml b/changelog-preview/action.yml index d4369d27d..4c8dd5b9b 100644 --- a/changelog-preview/action.yml +++ b/changelog-preview/action.yml @@ -16,10 +16,10 @@ runs: run: | PR_NUMBER="${{ github.event.pull_request.number }}" BASE_REF="${{ github.event.pull_request.base.ref }}" - + # Fetch base branch for comparison git fetch origin "$BASE_REF" --depth=1 2>/dev/null || true - + # Generate changelog with PR commits highlighted CHANGELOG=$(craft changelog --base "origin/$BASE_REF" 2>/dev/null || echo "") From 3f80c2b03aa50837af90dd6f88f3f4f41afba396 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Fri, 26 Dec 2025 17:15:53 +0300 Subject: [PATCH 06/24] ci: Build craft from source for testing (changelog cmd not released) --- .github/workflows/changelog-preview.yml | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/.github/workflows/changelog-preview.yml b/.github/workflows/changelog-preview.yml index f59f8fcfd..393798243 100644 --- a/.github/workflows/changelog-preview.yml +++ b/.github/workflows/changelog-preview.yml @@ -15,8 +15,15 @@ jobs: with: fetch-depth: 0 - # Use local install action (since it's not on master yet) - - uses: ./install + # Build craft from source (since changelog command isn't released yet) + - uses: actions/setup-node@v4 + with: + node-version: '22' + - name: Build Craft + run: | + yarn install --frozen-lockfile + yarn build + sudo install -m 755 dist/craft /usr/local/bin/craft - name: Generate Changelog Preview shell: bash @@ -29,11 +36,11 @@ jobs: # Fetch base branch for comparison git fetch origin "$BASE_REF" --depth=1 - + # Debug: show commits between base and HEAD echo "Commits in this PR:" git log --oneline "origin/$BASE_REF..HEAD" -- . || echo "No commits found" - + # Generate changelog with PR commits highlighted echo "Running craft changelog..." CHANGELOG=$(craft changelog --base "origin/$BASE_REF" || echo "") From 4391678c0bd1a8a51f0ed8333f34c08a0804f23a Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Fri, 26 Dec 2025 17:26:57 +0300 Subject: [PATCH 07/24] fix: Generate changelog up to merge base and inject current PR - Add --until option to limit changelog range - Fetch current PR info from GitHub API - Add PR to appropriate category with highlighting - Uses blockquote style for highlighted entries --- .github/workflows/changelog-preview.yml | 22 ++-- src/commands/changelog.ts | 46 ++++---- src/utils/changelog.ts | 145 ++++++++++++++++++++++-- src/utils/git.ts | 5 +- 4 files changed, 169 insertions(+), 49 deletions(-) diff --git a/.github/workflows/changelog-preview.yml b/.github/workflows/changelog-preview.yml index 393798243..90d608055 100644 --- a/.github/workflows/changelog-preview.yml +++ b/.github/workflows/changelog-preview.yml @@ -34,16 +34,18 @@ jobs: PR_NUMBER="${{ github.event.pull_request.number }}" BASE_REF="${{ github.event.pull_request.base.ref }}" - # Fetch base branch for comparison - git fetch origin "$BASE_REF" --depth=1 - - # Debug: show commits between base and HEAD - echo "Commits in this PR:" - git log --oneline "origin/$BASE_REF..HEAD" -- . || echo "No commits found" - - # Generate changelog with PR commits highlighted - echo "Running craft changelog..." - CHANGELOG=$(craft changelog --base "origin/$BASE_REF" || echo "") + # Fetch base branch to compute merge base + git fetch origin "$BASE_REF" + + # Find the merge base (where PR branched from base) + MERGE_BASE=$(git merge-base HEAD "origin/$BASE_REF") + echo "Merge base: $MERGE_BASE" + + # Generate changelog: + # - From latest tag (default) to merge base (excludes PR commits) + # - Then inject current PR info from GitHub API + echo "Running craft changelog --until $MERGE_BASE --pr $PR_NUMBER..." + CHANGELOG=$(craft changelog --until "$MERGE_BASE" --pr "$PR_NUMBER" 2>&1 || echo "") if [[ -z "$CHANGELOG" ]]; then CHANGELOG="_No changelog entries will be generated from this PR._" diff --git a/src/commands/changelog.ts b/src/commands/changelog.ts index d191a4973..c15b2e348 100644 --- a/src/commands/changelog.ts +++ b/src/commands/changelog.ts @@ -12,8 +12,10 @@ export const description = 'Generate changelog from git history'; interface ChangelogOptions { /** Base revision to generate changelog from (defaults to latest tag) */ since?: string; - /** Base branch/ref for PR comparison (to identify which commits to highlight) */ - base?: string; + /** End revision to generate changelog to (defaults to HEAD) */ + until?: string; + /** PR number for the current (unmerged) PR */ + pr?: number; } export const builder: CommandBuilder = (yargs: Argv) => @@ -24,11 +26,16 @@ export const builder: CommandBuilder = (yargs: Argv) => 'Base revision (tag or SHA) to generate changelog from. Defaults to latest tag.', type: 'string', }) - .option('base', { - alias: 'b', + .option('until', { + alias: 'u', description: - 'Base branch/ref for highlighting PR commits. Commits between --base and HEAD will be highlighted.', + 'End revision to generate changelog to. Defaults to HEAD. Use with --pr to exclude PR commits.', type: 'string', + }) + .option('pr', { + description: + 'PR number for the current (unmerged) PR. The PR info will be fetched from GitHub API and included in the changelog with highlighting.', + type: 'number', }); /** @@ -48,30 +55,15 @@ export async function changelogMain(argv: ChangelogOptions): Promise { } } - // Get commits to highlight (commits in HEAD but not in base) - let highlightCommits: Set | undefined; - if (argv.base) { - try { - // Get commits that are in HEAD but not in base (i.e., PR-specific commits) - const logOutput = await git.raw([ - 'log', - '--format=%H', - `${argv.base}..HEAD`, - '--', - '.', - ]); - const commits = logOutput.trim().split('\n').filter(Boolean); - if (commits.length > 0) { - highlightCommits = new Set(commits); - logger.debug(`Found ${commits.length} commits to highlight from PR`); - } - } catch (error) { - logger.warn(`Failed to get PR commits from base "${argv.base}":`, error); - } + // Use --until if provided (for PR preview, excludes PR commits) + const until = argv.until; + if (until) { + logger.debug(`Generating changelog up to: ${until}`); } - // Generate changelog with optional commit highlighting - const result = await generateChangelogWithHighlight(git, since, highlightCommits); + // Generate changelog with optional current PR + const currentPRNumber = argv.pr ? String(argv.pr) : undefined; + const result = await generateChangelogWithHighlight(git, since, currentPRNumber, until); if (!result.changelog) { console.log('No changelog entries found.'); diff --git a/src/utils/changelog.ts b/src/utils/changelog.ts index a95fe4a9e..d921a67e5 100644 --- a/src/utils/changelog.ts +++ b/src/utils/changelog.ts @@ -13,6 +13,51 @@ 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: string; + title: string; + body: string; + author: string; + labels: string[]; +} + +/** + * Fetches PR details from GitHub API by PR number. + * + * @param prNumber The PR number to fetch + * @returns PR info or null if not found + */ +async function fetchPRInfo(prNumber: string): Promise { + try { + const { repo, owner } = await getGlobalGitHubConfig(); + const github = getGitHubClient(); + + const { data: pr } = await github.pulls.get({ + owner, + repo, + pull_number: parseInt(prNumber, 10), + }); + + const { data: labels } = await github.issues.listLabelsOnIssue({ + owner, + repo, + issue_number: parseInt(prNumber, 10), + }); + + return { + number: prNumber, + title: pr.title, + body: pr.body ?? '', + author: pr.user?.login ?? '', + labels: labels.map(l => l.name), + }; + } catch (error) { + logger.warn(`Failed to fetch PR #${prNumber}:`, error); + return null; + } +} + /** * Version bump types. */ @@ -272,6 +317,8 @@ interface PullRequest { hash: string; body: string; title: string; + /** Whether this PR should be highlighted (from current unmerged PR) */ + highlight?: boolean; } interface Commit { @@ -745,33 +792,39 @@ export async function generateChangesetFromGit( } /** - * Generates a changelog from git history with optional commit highlighting. - * When highlightCommits is provided, entries from those commits are rendered as blockquotes. - * This function does not use caching since highlight options can vary. + * Generates a changelog from git history with optional current PR injection. + * When currentPRNumber is provided: + * - PR info is fetched from GitHub API + * - The PR is added to the changelog entries + * - The PR entry is highlighted (rendered as blockquote) + * This function does not use caching since options can vary. * * @param git Local git client * @param rev Base revision (tag or SHA) to generate changelog from - * @param highlightCommits Optional set of commit hashes to highlight in the output + * @param currentPRNumber Optional PR number to fetch from GitHub and include (highlighted) + * @param until Optional end revision (defaults to HEAD). Use to exclude PR commits. * @returns The changelog result with formatted markdown */ export async function generateChangelogWithHighlight( git: SimpleGit, rev: string, - highlightCommits?: Set + currentPRNumber?: string, + until?: string ): Promise { - return generateChangesetFromGitImpl(git, rev, MAX_LEFTOVERS, highlightCommits); + return generateChangesetFromGitImpl(git, rev, MAX_LEFTOVERS, currentPRNumber, until); } async function generateChangesetFromGitImpl( git: SimpleGit, rev: string, maxLeftovers: number, - highlightCommits?: Set + currentPR?: string, + until?: string ): Promise { const rawConfig = readReleaseConfig(); const releaseConfig = normalizeReleaseConfig(rawConfig); - const gitCommits = (await getChangesSince(git, rev)).filter( + const gitCommits = (await getChangesSince(git, rev, until)).filter( ({ body }) => !body.includes(SKIP_CHANGELOG_MAGIC_WORD) ); @@ -881,6 +934,77 @@ async function generateChangesetFromGitImpl( } } + // Inject current (unmerged) PR if provided + if (currentPR) { + const prInfo = await fetchPRInfo(currentPR); + if (prInfo) { + // Check if PR should be excluded + const prLabels = new Set(prInfo.labels); + if ( + !prInfo.body.includes(SKIP_CHANGELOG_MAGIC_WORD) && + !shouldExcludePR(prLabels, prInfo.author, releaseConfig) + ) { + // Match PR to category using same logic as commits + const matchedCategory = matchCommitToCategory( + prLabels, + prInfo.author, + prInfo.title.trim(), + releaseConfig + ); + const categoryTitle = matchedCategory?.title ?? null; + + if (categoryTitle) { + let category = categories.get(categoryTitle); + if (!category) { + category = { + title: categoryTitle, + scopeGroups: new Map(), + }; + categories.set(categoryTitle, category); + } + + const scope = extractScope(prInfo.title.trim()); + let scopeGroup = category.scopeGroups.get(scope); + if (!scopeGroup) { + scopeGroup = []; + category.scopeGroups.set(scope, scopeGroup); + } + + // Add current PR with highlight flag + scopeGroup.push({ + author: prInfo.author, + number: prInfo.number, + hash: '', // No commit hash for unmerged PR + body: prInfo.body, + title: prInfo.title.trim(), + highlight: true, + }); + + logger.debug( + `Injected current PR #${prInfo.number} into category "${categoryTitle}"` + ); + } else { + // PR doesn't match any category, add to leftovers section + leftovers.unshift({ + author: prInfo.author, + hash: '', + title: prInfo.title.trim(), + body: prInfo.body, + hasPRinTitle: false, + pr: prInfo.number, + prTitle: prInfo.title, + prBody: prInfo.body, + labels: prInfo.labels, + category: null, + }); + logger.debug( + `Current PR #${prInfo.number} doesn't match any category, added to leftovers` + ); + } + } + } + } + // Convert priority back to bump type let bumpType: BumpType | null = null; if (bumpPriority !== null) { @@ -963,7 +1087,7 @@ async function generateChangesetFromGitImpl( hash: pr.hash, body: pr.body, repoUrl, - highlight: highlightCommits?.has(pr.hash) ?? false, + highlight: pr.highlight, }) ); @@ -1017,7 +1141,8 @@ async function generateChangesetFromGitImpl( : commit.body.includes(BODY_IN_CHANGELOG_MAGIC_WORD) ? commit.body : undefined, - highlight: highlightCommits?.has(commit.hash) ?? false, + // Highlight if this is the current PR + highlight: currentPR !== undefined && commit.pr === currentPR, }) ) .join('\n') 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. From f2aefe9cedd91b04fd207f902ca21360b51a87e3 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Fri, 26 Dec 2025 17:29:10 +0300 Subject: [PATCH 08/24] fix: Add GITHUB_TOKEN env for Craft API access --- .github/workflows/changelog-preview.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/changelog-preview.yml b/.github/workflows/changelog-preview.yml index 90d608055..64b5d96ca 100644 --- a/.github/workflows/changelog-preview.yml +++ b/.github/workflows/changelog-preview.yml @@ -29,6 +29,7 @@ jobs: 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 }}" From 9647967b8857619a18d93a040b5a3f4de9c94aa5 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Fri, 26 Dec 2025 17:30:51 +0300 Subject: [PATCH 09/24] fix: Suppress stderr to hide node deprecation warning --- .github/workflows/changelog-preview.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/changelog-preview.yml b/.github/workflows/changelog-preview.yml index 64b5d96ca..8924beea7 100644 --- a/.github/workflows/changelog-preview.yml +++ b/.github/workflows/changelog-preview.yml @@ -46,7 +46,7 @@ jobs: # - From latest tag (default) to merge base (excludes PR commits) # - Then inject current PR info from GitHub API echo "Running craft changelog --until $MERGE_BASE --pr $PR_NUMBER..." - CHANGELOG=$(craft changelog --until "$MERGE_BASE" --pr "$PR_NUMBER" 2>&1 || echo "") + CHANGELOG=$(craft changelog --until "$MERGE_BASE" --pr "$PR_NUMBER" 2>/dev/null || echo "") if [[ -z "$CHANGELOG" ]]; then CHANGELOG="_No changelog entries will be generated from this PR._" From 06a9014e95a1550dbb3e42f9eb3ab896a94a9728 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Fri, 26 Dec 2025 17:36:20 +0300 Subject: [PATCH 10/24] refactor: Remove --until flag, compute merge base from PR's base branch Craft now fetches PR info from GitHub API which includes the base branch, then computes the merge base internally. This simplifies the workflow. --- .github/workflows/changelog-preview.yml | 17 +-- src/commands/changelog.ts | 18 +-- src/utils/changelog.ts | 165 +++++++++++++----------- 3 files changed, 99 insertions(+), 101 deletions(-) diff --git a/.github/workflows/changelog-preview.yml b/.github/workflows/changelog-preview.yml index 8924beea7..5e466d088 100644 --- a/.github/workflows/changelog-preview.yml +++ b/.github/workflows/changelog-preview.yml @@ -33,20 +33,11 @@ jobs: CRAFT_LOG_LEVEL: Warn run: | PR_NUMBER="${{ github.event.pull_request.number }}" - BASE_REF="${{ github.event.pull_request.base.ref }}" - # Fetch base branch to compute merge base - git fetch origin "$BASE_REF" - - # Find the merge base (where PR branched from base) - MERGE_BASE=$(git merge-base HEAD "origin/$BASE_REF") - echo "Merge base: $MERGE_BASE" - - # Generate changelog: - # - From latest tag (default) to merge base (excludes PR commits) - # - Then inject current PR info from GitHub API - echo "Running craft changelog --until $MERGE_BASE --pr $PR_NUMBER..." - CHANGELOG=$(craft changelog --until "$MERGE_BASE" --pr "$PR_NUMBER" 2>/dev/null || echo "") + # Generate changelog with current PR injected and highlighted + # Craft fetches PR info from GitHub API, computes merge base from PR's base branch + echo "Running craft changelog --pr $PR_NUMBER..." + CHANGELOG=$(craft changelog --pr "$PR_NUMBER" 2>/dev/null || echo "") if [[ -z "$CHANGELOG" ]]; then CHANGELOG="_No changelog entries will be generated from this PR._" diff --git a/src/commands/changelog.ts b/src/commands/changelog.ts index c15b2e348..2a4f5e1f7 100644 --- a/src/commands/changelog.ts +++ b/src/commands/changelog.ts @@ -12,8 +12,6 @@ export const description = 'Generate changelog from git history'; interface ChangelogOptions { /** Base revision to generate changelog from (defaults to latest tag) */ since?: string; - /** End revision to generate changelog to (defaults to HEAD) */ - until?: string; /** PR number for the current (unmerged) PR */ pr?: number; } @@ -26,15 +24,9 @@ export const builder: CommandBuilder = (yargs: Argv) => 'Base revision (tag or SHA) to generate changelog from. Defaults to latest tag.', type: 'string', }) - .option('until', { - alias: 'u', - description: - 'End revision to generate changelog to. Defaults to HEAD. Use with --pr to exclude PR commits.', - type: 'string', - }) .option('pr', { description: - 'PR number for the current (unmerged) PR. The PR info will be fetched from GitHub API and included in the changelog with highlighting.', + 'PR number for the current (unmerged) PR. The PR info will be fetched from GitHub API, merge base computed from base branch, and the PR included in the changelog with highlighting.', type: 'number', }); @@ -55,15 +47,9 @@ export async function changelogMain(argv: ChangelogOptions): Promise { } } - // Use --until if provided (for PR preview, excludes PR commits) - const until = argv.until; - if (until) { - logger.debug(`Generating changelog up to: ${until}`); - } - // Generate changelog with optional current PR const currentPRNumber = argv.pr ? String(argv.pr) : undefined; - const result = await generateChangelogWithHighlight(git, since, currentPRNumber, until); + const result = await generateChangelogWithHighlight(git, since, currentPRNumber); if (!result.changelog) { console.log('No changelog entries found.'); diff --git a/src/utils/changelog.ts b/src/utils/changelog.ts index d921a67e5..dfeb61974 100644 --- a/src/utils/changelog.ts +++ b/src/utils/changelog.ts @@ -20,6 +20,8 @@ export interface CurrentPRInfo { body: string; author: string; labels: string[]; + /** Base branch ref (e.g., "master") for computing merge base */ + baseRef: string; } /** @@ -51,6 +53,7 @@ async function fetchPRInfo(prNumber: string): Promise { body: pr.body ?? '', author: pr.user?.login ?? '', labels: labels.map(l => l.name), + baseRef: pr.base.ref, }; } catch (error) { logger.warn(`Failed to fetch PR #${prNumber}:`, error); @@ -794,31 +797,52 @@ export async function generateChangesetFromGit( /** * Generates a changelog from git history with optional current PR injection. * When currentPRNumber is provided: - * - PR info is fetched from GitHub API - * - The PR is added to the changelog entries - * - The PR entry is highlighted (rendered as blockquote) + * - PR info is fetched from GitHub API (including base branch) + * - Merge base is computed from the PR's base branch + * - Changelog is generated up to merge base (excludes PR commits) + * - The PR is added to the changelog entries with highlighting * This function does not use caching since options can vary. * * @param git Local git client * @param rev Base revision (tag or SHA) to generate changelog from * @param currentPRNumber Optional PR number to fetch from GitHub and include (highlighted) - * @param until Optional end revision (defaults to HEAD). Use to exclude PR commits. * @returns The changelog result with formatted markdown */ export async function generateChangelogWithHighlight( git: SimpleGit, rev: string, - currentPRNumber?: string, - until?: string + currentPRNumber?: string ): Promise { - return generateChangesetFromGitImpl(git, rev, MAX_LEFTOVERS, currentPRNumber, until); + // If a PR number is provided, fetch PR info first to get base branch + let until: string | undefined; + let prInfo: CurrentPRInfo | null = null; + + if (currentPRNumber) { + prInfo = await fetchPRInfo(currentPRNumber); + if (prInfo) { + // Fetch the base branch and compute merge base + try { + await git.fetch('origin', prInfo.baseRef); + until = ( + await git.raw(['merge-base', 'HEAD', `origin/${prInfo.baseRef}`]) + ).trim(); + logger.debug( + `Computed merge base from PR base branch "${prInfo.baseRef}": ${until}` + ); + } catch (error) { + logger.warn(`Failed to compute merge base for PR #${currentPRNumber}:`, error); + } + } + } + + return generateChangesetFromGitImpl(git, rev, MAX_LEFTOVERS, prInfo, until); } async function generateChangesetFromGitImpl( git: SimpleGit, rev: string, maxLeftovers: number, - currentPR?: string, + currentPRInfo?: CurrentPRInfo | null, until?: string ): Promise { const rawConfig = readReleaseConfig(); @@ -935,72 +959,69 @@ async function generateChangesetFromGitImpl( } // Inject current (unmerged) PR if provided - if (currentPR) { - const prInfo = await fetchPRInfo(currentPR); - if (prInfo) { - // Check if PR should be excluded - const prLabels = new Set(prInfo.labels); - if ( - !prInfo.body.includes(SKIP_CHANGELOG_MAGIC_WORD) && - !shouldExcludePR(prLabels, prInfo.author, releaseConfig) - ) { - // Match PR to category using same logic as commits - const matchedCategory = matchCommitToCategory( - prLabels, - prInfo.author, - prInfo.title.trim(), - releaseConfig - ); - const categoryTitle = matchedCategory?.title ?? null; - - if (categoryTitle) { - let category = categories.get(categoryTitle); - if (!category) { - category = { - title: categoryTitle, - scopeGroups: new Map(), - }; - categories.set(categoryTitle, category); - } + if (currentPRInfo) { + // Check if PR should be excluded + const prLabels = new Set(currentPRInfo.labels); + if ( + !currentPRInfo.body.includes(SKIP_CHANGELOG_MAGIC_WORD) && + !shouldExcludePR(prLabels, currentPRInfo.author, releaseConfig) + ) { + // Match PR to category using same logic as commits + const matchedCategory = matchCommitToCategory( + prLabels, + currentPRInfo.author, + currentPRInfo.title.trim(), + releaseConfig + ); + const categoryTitle = matchedCategory?.title ?? null; - const scope = extractScope(prInfo.title.trim()); - let scopeGroup = category.scopeGroups.get(scope); - if (!scopeGroup) { - scopeGroup = []; - category.scopeGroups.set(scope, scopeGroup); - } + if (categoryTitle) { + let category = categories.get(categoryTitle); + if (!category) { + category = { + title: categoryTitle, + scopeGroups: new Map(), + }; + categories.set(categoryTitle, category); + } - // Add current PR with highlight flag - scopeGroup.push({ - author: prInfo.author, - number: prInfo.number, - hash: '', // No commit hash for unmerged PR - body: prInfo.body, - title: prInfo.title.trim(), - highlight: true, - }); - - logger.debug( - `Injected current PR #${prInfo.number} into category "${categoryTitle}"` - ); - } else { - // PR doesn't match any category, add to leftovers section - leftovers.unshift({ - author: prInfo.author, - hash: '', - title: prInfo.title.trim(), - body: prInfo.body, - hasPRinTitle: false, - pr: prInfo.number, - prTitle: prInfo.title, - prBody: prInfo.body, - labels: prInfo.labels, - category: null, - }); - logger.debug( - `Current PR #${prInfo.number} doesn't match any category, added to leftovers` - ); + const scope = extractScope(currentPRInfo.title.trim()); + let scopeGroup = category.scopeGroups.get(scope); + if (!scopeGroup) { + scopeGroup = []; + category.scopeGroups.set(scope, scopeGroup); } + + // Add current PR with highlight flag + scopeGroup.push({ + author: currentPRInfo.author, + number: currentPRInfo.number, + hash: '', // No commit hash for unmerged PR + body: currentPRInfo.body, + title: currentPRInfo.title.trim(), + highlight: true, + }); + + logger.debug( + `Injected current PR #${currentPRInfo.number} into category "${categoryTitle}"` + ); + } else { + // PR doesn't match any category, add to leftovers section + leftovers.unshift({ + author: currentPRInfo.author, + hash: '', + title: currentPRInfo.title.trim(), + body: currentPRInfo.body, + hasPRinTitle: false, + pr: currentPRInfo.number, + prTitle: currentPRInfo.title, + prBody: currentPRInfo.body, + labels: currentPRInfo.labels, + category: null, + }); + logger.debug( + `Current PR #${currentPRInfo.number} doesn't match any category, added to leftovers` + ); } } } @@ -1142,7 +1163,7 @@ async function generateChangesetFromGitImpl( ? commit.body : undefined, // Highlight if this is the current PR - highlight: currentPR !== undefined && commit.pr === currentPR, + highlight: currentPRInfo != null && commit.pr === currentPRInfo.number, }) ) .join('\n') From 4ea8507bd6671c486b1460f88997e2a5fab3159c Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Fri, 26 Dec 2025 17:51:03 +0300 Subject: [PATCH 11/24] feat: Make changelog-preview a reusable workflow Other repositories can now call this workflow directly: ```yaml jobs: changelog-preview: uses: getsentry/craft/.github/workflows/changelog-preview.yml@v2 secrets: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} ``` - Added workflow_call trigger with optional craft-version input - Install Craft from release for external repos - Build from source only for getsentry/craft (dogfooding) --- .github/workflows/changelog-preview.yml | 41 ++++++++++++++++++++++--- 1 file changed, 36 insertions(+), 5 deletions(-) diff --git a/.github/workflows/changelog-preview.yml b/.github/workflows/changelog-preview.yml index 5e466d088..034e68a15 100644 --- a/.github/workflows/changelog-preview.yml +++ b/.github/workflows/changelog-preview.yml @@ -1,6 +1,20 @@ 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' + secrets: + GITHUB_TOKEN: + description: 'GitHub token for API access' + required: true + + # Also run on PRs in this repository (for dogfooding) pull_request: types: [opened, synchronize, reopened] @@ -15,12 +29,29 @@ jobs: with: fetch-depth: 0 - # Build craft from source (since changelog command isn't released yet) - - uses: actions/setup-node@v4 - with: - node-version: '22' - - name: Build Craft + - name: Install Craft + shell: bash + env: + CRAFT_VERSION: ${{ inputs.craft-version || 'latest' }} + run: | + if [[ "$CRAFT_VERSION" == "latest" ]]; then + CRAFT_URL=$(curl -s "https://api.github.com/repos/getsentry/craft/releases/latest" \ + | jq -r '.assets[] | select(.name == "craft") | .browser_download_url') + 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 + + # For getsentry/craft repo: build from source to test unreleased changes + - name: Build Craft from source (dogfooding) + if: github.repository == 'getsentry/craft' run: | + # Check if yarn is available, install if not + if ! command -v yarn &> /dev/null; then + npm install -g yarn + fi yarn install --frozen-lockfile yarn build sudo install -m 755 dist/craft /usr/local/bin/craft From e1756f3702fefddb3a90c06448b1a23177e62c64 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Fri, 26 Dec 2025 17:53:34 +0300 Subject: [PATCH 12/24] fix: Add setup-node step for dogfooding --- .github/workflows/changelog-preview.yml | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/.github/workflows/changelog-preview.yml b/.github/workflows/changelog-preview.yml index 034e68a15..cfc000364 100644 --- a/.github/workflows/changelog-preview.yml +++ b/.github/workflows/changelog-preview.yml @@ -45,13 +45,15 @@ jobs: sudo chmod +x /usr/local/bin/craft # For getsentry/craft repo: build from source to test unreleased changes + - name: Setup Node.js (for dogfooding) + if: github.repository == 'getsentry/craft' + uses: actions/setup-node@v4 + with: + node-version: '22' + - name: Build Craft from source (dogfooding) if: github.repository == 'getsentry/craft' run: | - # Check if yarn is available, install if not - if ! command -v yarn &> /dev/null; then - npm install -g yarn - fi yarn install --frozen-lockfile yarn build sudo install -m 755 dist/craft /usr/local/bin/craft From 8f7e9d235a7a687d9795a3f8db866a8b4eddc739 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Fri, 26 Dec 2025 17:56:04 +0300 Subject: [PATCH 13/24] fix: Remove explicit secrets requirement from workflow_call Callers should use 'secrets: inherit' to pass secrets --- .github/workflows/changelog-preview.yml | 4 ---- 1 file changed, 4 deletions(-) diff --git a/.github/workflows/changelog-preview.yml b/.github/workflows/changelog-preview.yml index cfc000364..72889af5f 100644 --- a/.github/workflows/changelog-preview.yml +++ b/.github/workflows/changelog-preview.yml @@ -9,10 +9,6 @@ on: required: false type: string default: 'latest' - secrets: - GITHUB_TOKEN: - description: 'GitHub token for API access' - required: true # Also run on PRs in this repository (for dogfooding) pull_request: From 5aa67f1f38c8d5da20e23c599fa1ccf88dd5bce2 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Fri, 26 Dec 2025 18:31:51 +0300 Subject: [PATCH 14/24] refactor: Address PR review comments - Use install/action.yml instead of inline installation in workflow - Add build-from-source support to install action for craft repo - Remove duplicate changelog-preview/action.yml (use reusable workflow) - Add 'edited' and 'labeled' PR trigger types for title/label changes - Change 'push changes' to 'update the PR' in comment text - Use heredoc instead of temp file for gh api call - Reference getsentry/publish repo in docs as example - Document label exclusion option for skipping changelog entries --- .github/workflows/changelog-preview.yml | 48 +++------ README.md | 23 ++--- changelog-preview/action.yml | 64 ------------ docs/src/content/docs/github-actions.md | 125 +++++++++++++++--------- install/action.yml | 51 +++++++--- 5 files changed, 139 insertions(+), 172 deletions(-) delete mode 100644 changelog-preview/action.yml diff --git a/.github/workflows/changelog-preview.yml b/.github/workflows/changelog-preview.yml index 72889af5f..e0cbbdc83 100644 --- a/.github/workflows/changelog-preview.yml +++ b/.github/workflows/changelog-preview.yml @@ -11,8 +11,9 @@ on: 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] + types: [opened, synchronize, reopened, edited, labeled] permissions: pull-requests: write @@ -26,33 +27,9 @@ jobs: fetch-depth: 0 - name: Install Craft - shell: bash - env: - CRAFT_VERSION: ${{ inputs.craft-version || 'latest' }} - run: | - if [[ "$CRAFT_VERSION" == "latest" ]]; then - CRAFT_URL=$(curl -s "https://api.github.com/repos/getsentry/craft/releases/latest" \ - | jq -r '.assets[] | select(.name == "craft") | .browser_download_url') - 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 - - # For getsentry/craft repo: build from source to test unreleased changes - - name: Setup Node.js (for dogfooding) - if: github.repository == 'getsentry/craft' - uses: actions/setup-node@v4 + uses: getsentry/craft/install@master with: - node-version: '22' - - - name: Build Craft from source (dogfooding) - if: github.repository == 'getsentry/craft' - run: | - yarn install --frozen-lockfile - yarn build - sudo install -m 755 dist/craft /usr/local/bin/craft + craft-version: ${{ inputs.craft-version || 'latest' }} - name: Generate Changelog Preview shell: bash @@ -72,8 +49,9 @@ jobs: CHANGELOG="_No changelog entries will be generated from this PR._" fi - # Build comment body with hidden marker for updates - COMMENT_BODY=" + # Build comment body + read -r -d '' COMMENT_BODY << 'EOF' || true + ## 📋 Changelog Preview This is how your changes will appear in the changelog. @@ -81,14 +59,14 @@ jobs: --- + EOF + + COMMENT_BODY="${COMMENT_BODY} ${CHANGELOG} --- - 🤖 This preview updates automatically when you push changes." - - # Save to file for the comment step (handles multiline properly) - echo "$COMMENT_BODY" > /tmp/changelog-comment.md + 🤖 This preview updates automatically when you update the PR." # Find existing comment with our marker COMMENT_ID=$(gh api \ @@ -100,10 +78,10 @@ jobs: echo "Updating existing comment $COMMENT_ID..." gh api -X PATCH \ "repos/$GITHUB_REPOSITORY/issues/comments/$COMMENT_ID" \ - -F body=@/tmp/changelog-comment.md + -f body="$COMMENT_BODY" else echo "Creating new comment..." gh api -X POST \ "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/comments" \ - -F body=@/tmp/changelog-comment.md + -f body="$COMMENT_BODY" fi diff --git a/README.md b/README.md index e83aa8893..7fc86b4e6 100644 --- a/README.md +++ b/README.md @@ -134,7 +134,7 @@ jobs: | `sha` | The commit SHA on the release branch | | `changelog` | The changelog for this release | -### Changelog Preview Action +### Changelog Preview (Reusable Workflow) Posts a preview comment on PRs showing how they'll appear in the changelog: @@ -142,28 +142,19 @@ Posts a preview comment on PRs showing how they'll appear in the changelog: name: Changelog Preview on: pull_request: - types: [opened, synchronize, reopened] - -permissions: - pull-requests: write + types: [opened, synchronize, reopened, edited, labeled] jobs: - preview: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - uses: getsentry/craft/changelog-preview@v2 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + changelog-preview: + uses: getsentry/craft/.github/workflows/changelog-preview.yml@v2 + secrets: inherit ``` -The action will: +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 the comment when the PR is updated +- Automatically update when you update the PR (push, edit title/description, or change labels) ## Contributing diff --git a/changelog-preview/action.yml b/changelog-preview/action.yml deleted file mode 100644 index 4c8dd5b9b..000000000 --- a/changelog-preview/action.yml +++ /dev/null @@ -1,64 +0,0 @@ -name: "Craft Changelog Preview" -description: "Preview how a PR will appear in the changelog" - -runs: - using: "composite" - steps: - - name: Install Craft - uses: getsentry/craft/install@master - - - name: Generate Changelog Preview - id: changelog - shell: bash - env: - GH_TOKEN: ${{ github.token }} - CRAFT_LOG_LEVEL: Warn - run: | - PR_NUMBER="${{ github.event.pull_request.number }}" - BASE_REF="${{ github.event.pull_request.base.ref }}" - - # Fetch base branch for comparison - git fetch origin "$BASE_REF" --depth=1 2>/dev/null || true - - # Generate changelog with PR commits highlighted - CHANGELOG=$(craft changelog --base "origin/$BASE_REF" 2>/dev/null || echo "") - - if [[ -z "$CHANGELOG" ]]; then - CHANGELOG="_No changelog entries will be generated from this PR._" - fi - - # Build comment body with hidden marker for updates - COMMENT_BODY=" - ## 📋 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 push changes." - - # Save to file for the comment step (handles multiline properly) - echo "$COMMENT_BODY" > /tmp/changelog-comment.md - - # 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=@/tmp/changelog-comment.md - else - echo "Creating new comment..." - gh api -X POST \ - "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/comments" \ - -F body=@/tmp/changelog-comment.md - fi diff --git a/docs/src/content/docs/github-actions.md b/docs/src/content/docs/github-actions.md index 87d778cf9..64761ca64 100644 --- a/docs/src/content/docs/github-actions.md +++ b/docs/src/content/docs/github-actions.md @@ -5,6 +5,8 @@ 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. @@ -82,43 +84,55 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} ``` -## Changelog Preview Action +## Changelog Preview (Reusable Workflow) -The changelog preview action posts a comment on pull requests showing how they will appear in the changelog. This helps contributors understand the impact of their changes. +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] + types: [opened, synchronize, reopened, edited, labeled] + +jobs: + changelog-preview: + uses: getsentry/craft/.github/workflows/changelog-preview.yml@v2 + secrets: inherit +``` + +### Inputs -permissions: - pull-requests: write +| Input | Description | Default | +|-------|-------------|---------| +| `craft-version` | Version of Craft to use (tag or "latest") | `latest` | +### Pinning a Specific Version + +```yaml jobs: - preview: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - uses: getsentry/craft/changelog-preview@v2 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + 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` to generate the upcoming changelog including all commits since the last tag -2. **Highlights PR entries** - Entries from the current PR are rendered with blockquote style (displayed with a left border in GitHub) -3. **Posts a comment** - Creates or updates a comment on the PR with the changelog preview -4. **Auto-updates** - The comment is automatically updated when new commits are pushed to the PR +1. **Generates the changelog** - Runs `craft changelog --pr ` to generate the upcoming changelog +2. **Fetches PR info** - Gets PR title, body, labels, and base branch from GitHub API +3. **Computes merge base** - Determines the merge base to exclude unmerged PR commits +4. **Highlights PR entries** - The current PR is rendered with blockquote style (displayed with a left border in GitHub) +5. **Posts a comment** - Creates or updates a comment on the PR with the changelog preview +6. **Auto-updates** - The comment is automatically updated when you update the PR (push commits, edit title/description, or change labels) ### Example Comment -The action posts a comment like this: +The workflow posts a comment like this: ```markdown ## 📋 Changelog Preview @@ -140,41 +154,70 @@ Entries from this PR are highlighted with a left border (blockquote style). --- -🤖 This preview updates automatically when you push changes. +🤖 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 -- The workflow needs `pull-requests: write` permission to post comments +- Use `secrets: inherit` to pass the GitHub token - The repository should have a git history with tags for the changelog to be meaningful -- Use `fetch-depth: 0` in the checkout action to get full history + +## 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 actions together for a complete release workflow: +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] - -permissions: - pull-requests: write + types: [opened, synchronize, reopened, edited, labeled] jobs: - preview: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - uses: getsentry/craft/changelog-preview@v2 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + changelog-preview: + uses: getsentry/craft/.github/workflows/changelog-preview.yml@v2 + secrets: inherit ``` ```yaml @@ -200,13 +243,3 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} ``` - -### Skipping Changelog Entries - -Use `#skip-changelog` in your commit message or PR body to exclude a commit from the changelog: - -``` -chore: Update dependencies - -#skip-changelog -``` diff --git a/install/action.yml b/install/action.yml index 1b36d6839..e84c4bfd4 100644 --- a/install/action.yml +++ b/install/action.yml @@ -1,5 +1,11 @@ name: "Install Craft" -description: "Install Craft CLI (from build artifact for dogfooding, or from release)" +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" @@ -20,21 +26,44 @@ runs: 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' + if: steps.artifact.outcome != 'success' && steps.build.outcome != 'success' shell: bash + env: + CRAFT_VERSION: ${{ inputs.craft-version }} 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" + 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}" + 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') + # 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}" From e5ecfc3cf7cd1f3576bd0b3ef0d566ad312bbf27 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Fri, 26 Dec 2025 18:33:42 +0300 Subject: [PATCH 15/24] fix: Inline install logic in reusable workflow Can't reference install action from master as it's not merged yet. For external repos, install from release. For craft repo, build from source. --- .github/workflows/changelog-preview.yml | 34 ++++++++++++++++++++++--- 1 file changed, 31 insertions(+), 3 deletions(-) diff --git a/.github/workflows/changelog-preview.yml b/.github/workflows/changelog-preview.yml index e0cbbdc83..eb4b65f15 100644 --- a/.github/workflows/changelog-preview.yml +++ b/.github/workflows/changelog-preview.yml @@ -26,10 +26,38 @@ jobs: with: fetch-depth: 0 - - name: Install Craft - uses: getsentry/craft/install@master + # For getsentry/craft repo: build from source to test unreleased changes + - name: Setup Node.js (for building from source) + if: github.repository == 'getsentry/craft' + uses: actions/setup-node@v4 with: - craft-version: ${{ inputs.craft-version || 'latest' }} + node-version: '22' + + - name: Build Craft from source + id: build + if: github.repository == 'getsentry/craft' + 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: github.repository != 'getsentry/craft' + shell: bash + env: + CRAFT_VERSION: ${{ inputs.craft-version || 'latest' }} + run: | + if [[ "$CRAFT_VERSION" == "latest" ]]; then + CRAFT_URL=$(curl -s "https://api.github.com/repos/getsentry/craft/releases/latest" \ + | jq -r '.assets[] | select(.name == "craft") | .browser_download_url') + 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 - name: Generate Changelog Preview shell: bash From 324d340d07e32cc7d0fb7c8f49ca54400954f23d Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Fri, 26 Dec 2025 19:21:57 +0300 Subject: [PATCH 16/24] refactor: Address additional PR review comments - Use install action with PR ref in changelog-preview workflow - Use relative path ./install in main action.yml - Change CurrentPRInfo.number from string to number type - Make fetchPRInfo errors fatal (remove try/catch) - Make merge base computation errors fatal - Update all prInfo.number usages to convert to string where needed --- .github/workflows/changelog-preview.yml | 36 ++-------- action.yml | 2 +- src/commands/changelog.ts | 3 +- src/utils/changelog.ts | 87 +++++++++++-------------- 4 files changed, 46 insertions(+), 82 deletions(-) diff --git a/.github/workflows/changelog-preview.yml b/.github/workflows/changelog-preview.yml index eb4b65f15..938c9067c 100644 --- a/.github/workflows/changelog-preview.yml +++ b/.github/workflows/changelog-preview.yml @@ -26,38 +26,12 @@ jobs: with: fetch-depth: 0 - # For getsentry/craft repo: build from source to test unreleased changes - - name: Setup Node.js (for building from source) - if: github.repository == 'getsentry/craft' - uses: actions/setup-node@v4 + # 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: - node-version: '22' - - - name: Build Craft from source - id: build - if: github.repository == 'getsentry/craft' - 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: github.repository != 'getsentry/craft' - shell: bash - env: - CRAFT_VERSION: ${{ inputs.craft-version || 'latest' }} - run: | - if [[ "$CRAFT_VERSION" == "latest" ]]; then - CRAFT_URL=$(curl -s "https://api.github.com/repos/getsentry/craft/releases/latest" \ - | jq -r '.assets[] | select(.name == "craft") | .browser_download_url') - 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 + craft-version: ${{ inputs.craft-version || 'latest' }} - name: Generate Changelog Preview shell: bash diff --git a/action.yml b/action.yml index a468df077..e0aa73f7e 100644 --- a/action.yml +++ b/action.yml @@ -84,7 +84,7 @@ runs: echo "EMAIL=${GIT_USER_EMAIL}" >> $GITHUB_ENV - name: Install Craft - uses: getsentry/craft/install@master + uses: ./install - name: Craft Prepare id: craft diff --git a/src/commands/changelog.ts b/src/commands/changelog.ts index 2a4f5e1f7..df975472f 100644 --- a/src/commands/changelog.ts +++ b/src/commands/changelog.ts @@ -48,8 +48,7 @@ export async function changelogMain(argv: ChangelogOptions): Promise { } // Generate changelog with optional current PR - const currentPRNumber = argv.pr ? String(argv.pr) : undefined; - const result = await generateChangelogWithHighlight(git, since, currentPRNumber); + const result = await generateChangelogWithHighlight(git, since, argv.pr); if (!result.changelog) { console.log('No changelog entries found.'); diff --git a/src/utils/changelog.ts b/src/utils/changelog.ts index dfeb61974..88cd58741 100644 --- a/src/utils/changelog.ts +++ b/src/utils/changelog.ts @@ -15,7 +15,7 @@ import { getVersion } from './version'; /** Information about the current (unmerged) PR to inject into changelog */ export interface CurrentPRInfo { - number: string; + number: number; title: string; body: string; author: string; @@ -28,37 +28,33 @@ export interface CurrentPRInfo { * Fetches PR details from GitHub API by PR number. * * @param prNumber The PR number to fetch - * @returns PR info or null if not found + * @returns PR info + * @throws Error if PR cannot be fetched */ -async function fetchPRInfo(prNumber: string): Promise { - try { - const { repo, owner } = await getGlobalGitHubConfig(); - const github = getGitHubClient(); +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: parseInt(prNumber, 10), - }); + const { data: pr } = await github.pulls.get({ + owner, + repo, + pull_number: prNumber, + }); - const { data: labels } = await github.issues.listLabelsOnIssue({ - owner, - repo, - issue_number: parseInt(prNumber, 10), - }); + 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, - }; - } catch (error) { - logger.warn(`Failed to fetch PR #${prNumber}:`, error); - return null; - } + return { + number: prNumber, + title: pr.title, + body: pr.body ?? '', + author: pr.user?.login ?? '', + labels: labels.map(l => l.name), + baseRef: pr.base.ref, + }; } /** @@ -811,28 +807,23 @@ export async function generateChangesetFromGit( export async function generateChangelogWithHighlight( git: SimpleGit, rev: string, - currentPRNumber?: string + currentPRNumber?: number ): Promise { // If a PR number is provided, fetch PR info first to get base branch let until: string | undefined; - let prInfo: CurrentPRInfo | null = null; + let prInfo: CurrentPRInfo | undefined; if (currentPRNumber) { prInfo = await fetchPRInfo(currentPRNumber); - if (prInfo) { - // Fetch the base branch and compute merge base - try { - await git.fetch('origin', prInfo.baseRef); - until = ( - await git.raw(['merge-base', 'HEAD', `origin/${prInfo.baseRef}`]) - ).trim(); - logger.debug( - `Computed merge base from PR base branch "${prInfo.baseRef}": ${until}` - ); - } catch (error) { - logger.warn(`Failed to compute merge base for PR #${currentPRNumber}:`, error); - } - } + + // Fetch the base branch and compute merge base + await git.fetch('origin', prInfo.baseRef); + until = ( + await git.raw(['merge-base', 'HEAD', `origin/${prInfo.baseRef}`]) + ).trim(); + logger.debug( + `Computed merge base from PR base branch "${prInfo.baseRef}": ${until}` + ); } return generateChangesetFromGitImpl(git, rev, MAX_LEFTOVERS, prInfo, until); @@ -842,7 +833,7 @@ async function generateChangesetFromGitImpl( git: SimpleGit, rev: string, maxLeftovers: number, - currentPRInfo?: CurrentPRInfo | null, + currentPRInfo?: CurrentPRInfo, until?: string ): Promise { const rawConfig = readReleaseConfig(); @@ -995,7 +986,7 @@ async function generateChangesetFromGitImpl( // Add current PR with highlight flag scopeGroup.push({ author: currentPRInfo.author, - number: currentPRInfo.number, + number: String(currentPRInfo.number), hash: '', // No commit hash for unmerged PR body: currentPRInfo.body, title: currentPRInfo.title.trim(), @@ -1013,7 +1004,7 @@ async function generateChangesetFromGitImpl( title: currentPRInfo.title.trim(), body: currentPRInfo.body, hasPRinTitle: false, - pr: currentPRInfo.number, + pr: String(currentPRInfo.number), prTitle: currentPRInfo.title, prBody: currentPRInfo.body, labels: currentPRInfo.labels, @@ -1163,7 +1154,7 @@ async function generateChangesetFromGitImpl( ? commit.body : undefined, // Highlight if this is the current PR - highlight: currentPRInfo != null && commit.pr === currentPRInfo.number, + highlight: currentPRInfo != null && commit.pr === String(currentPRInfo.number), }) ) .join('\n') From 5fab25c8610f3e9c93d92987173d28c15a75c86c Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Fri, 26 Dec 2025 19:35:40 +0300 Subject: [PATCH 17/24] refactor: Split changelog generation into raw data + serialization - Add RawChangelogData interface for intermediate representation - Create generateRawChangelog function for data gathering - Create injectCurrentPR function to add PR to raw data - Create serializeChangelog function for markdown formatting - Update generateChangelogWithHighlight to use sandwich approach: 1. Generate raw changelog up to merge base 2. Inject current PR into the data 3. Serialize with PR highlighting - Remove highlight property from PullRequest interface - Make currentPRNumber required in generateChangelogWithHighlight - Use generateChangesetFromGit when --pr not specified --- src/commands/changelog.ts | 11 +- src/utils/changelog.ts | 303 ++++++++++++++++++++++++-------------- 2 files changed, 201 insertions(+), 113 deletions(-) diff --git a/src/commands/changelog.ts b/src/commands/changelog.ts index df975472f..5fba059b1 100644 --- a/src/commands/changelog.ts +++ b/src/commands/changelog.ts @@ -2,7 +2,10 @@ import { Argv, CommandBuilder } from 'yargs'; import { logger } from '../logger'; import { getGitClient, getLatestTag } from '../utils/git'; -import { generateChangelogWithHighlight } from '../utils/changelog'; +import { + generateChangesetFromGit, + generateChangelogWithHighlight, +} from '../utils/changelog'; import { handleGlobalError } from '../utils/errors'; export const command = ['changelog']; @@ -47,8 +50,10 @@ export async function changelogMain(argv: ChangelogOptions): Promise { } } - // Generate changelog with optional current PR - const result = await generateChangelogWithHighlight(git, since, argv.pr); + // 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); if (!result.changelog) { console.log('No changelog entries found.'); diff --git a/src/utils/changelog.ts b/src/utils/changelog.ts index 88cd58741..50550065d 100644 --- a/src/utils/changelog.ts +++ b/src/utils/changelog.ts @@ -316,8 +316,6 @@ interface PullRequest { hash: string; body: string; title: string; - /** Whether this PR should be highlighted (from current unmerged PR) */ - highlight?: boolean; } interface Commit { @@ -754,6 +752,26 @@ 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[]; + /** 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; + /** Release config for serialization */ + releaseConfig: NormalizedReleaseConfig | null; +} + // Memoization cache for generateChangesetFromGit // Caches promises to coalesce concurrent calls with the same arguments const changesetCache = new Map>(); @@ -791,51 +809,68 @@ export async function generateChangesetFromGit( } /** - * Generates a changelog from git history with optional current PR injection. - * When currentPRNumber is provided: - * - PR info is fetched from GitHub API (including base branch) - * - Merge base is computed from the PR's base branch - * - Changelog is generated up to merge base (excludes PR commits) - * - The PR is added to the changelog entries with highlighting - * This function does not use caching since options can vary. + * 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. Computes merge base from the PR's base branch + * 3. Generates raw changelog data up to merge base (excludes PR commits) + * 4. Injects the current PR into the raw data + * 5. Serializes to markdown with the current PR highlighted * * @param git Local git client * @param rev Base revision (tag or SHA) to generate changelog from - * @param currentPRNumber Optional PR number to fetch from GitHub and include (highlighted) + * @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 + currentPRNumber: number ): Promise { - // If a PR number is provided, fetch PR info first to get base branch - let until: string | undefined; - let prInfo: CurrentPRInfo | undefined; - - if (currentPRNumber) { - prInfo = await fetchPRInfo(currentPRNumber); - - // Fetch the base branch and compute merge base - await git.fetch('origin', prInfo.baseRef); - until = ( - await git.raw(['merge-base', 'HEAD', `origin/${prInfo.baseRef}`]) - ).trim(); - logger.debug( - `Computed merge base from PR base branch "${prInfo.baseRef}": ${until}` - ); - } + // Step 1: Fetch PR info from GitHub + const prInfo = await fetchPRInfo(currentPRNumber); + + // Step 2: Fetch the base branch and compute merge base + await git.fetch('origin', prInfo.baseRef); + const until = ( + await git.raw(['merge-base', 'HEAD', `origin/${prInfo.baseRef}`]) + ).trim(); + logger.debug( + `Computed merge base from PR base branch "${prInfo.baseRef}": ${until}` + ); + + // Step 3: Generate raw changelog data up to merge base (excludes PR commits) + const rawData = await generateRawChangelog(git, rev, until); + + // Step 4: Inject the current PR into the raw data + injectCurrentPR(rawData, prInfo); + + // Step 5: Serialize to markdown with highlighting for the current PR + const changelog = await serializeChangelog(rawData, MAX_LEFTOVERS, String(currentPRNumber)); - return generateChangesetFromGitImpl(git, rev, MAX_LEFTOVERS, prInfo, until); + return { + changelog, + bumpType: rawData.bumpType, + totalCommits: rawData.totalCommits, + matchedCommitsWithSemver: rawData.matchedCommitsWithSemver, + }; } -async function generateChangesetFromGitImpl( +/** + * Generates raw changelog data from git history. + * This returns an intermediate representation that can be manipulated + * before serialization to markdown. + * + * @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, - maxLeftovers: number, - currentPRInfo?: CurrentPRInfo, until?: string -): Promise { +): Promise { const rawConfig = readReleaseConfig(); const releaseConfig = normalizeReleaseConfig(rawConfig); @@ -848,7 +883,6 @@ async function generateChangesetFromGitImpl( ); const categories = new Map(); - const commits: Record = {}; const leftovers: Commit[] = []; const missing: Commit[] = []; @@ -905,7 +939,6 @@ async function generateChangesetFromGitImpl( labels: labelsArray, category: categoryTitle, }; - commits[hash] = commit; if (!githubCommit) { missing.push(commit); @@ -949,74 +982,6 @@ async function generateChangesetFromGitImpl( } } - // Inject current (unmerged) PR if provided - if (currentPRInfo) { - // Check if PR should be excluded - const prLabels = new Set(currentPRInfo.labels); - if ( - !currentPRInfo.body.includes(SKIP_CHANGELOG_MAGIC_WORD) && - !shouldExcludePR(prLabels, currentPRInfo.author, releaseConfig) - ) { - // Match PR to category using same logic as commits - const matchedCategory = matchCommitToCategory( - prLabels, - currentPRInfo.author, - currentPRInfo.title.trim(), - releaseConfig - ); - const categoryTitle = matchedCategory?.title ?? null; - - if (categoryTitle) { - let category = categories.get(categoryTitle); - if (!category) { - category = { - title: categoryTitle, - scopeGroups: new Map(), - }; - categories.set(categoryTitle, category); - } - - const scope = extractScope(currentPRInfo.title.trim()); - let scopeGroup = category.scopeGroups.get(scope); - if (!scopeGroup) { - scopeGroup = []; - category.scopeGroups.set(scope, scopeGroup); - } - - // Add current PR with highlight flag - scopeGroup.push({ - author: currentPRInfo.author, - number: String(currentPRInfo.number), - hash: '', // No commit hash for unmerged PR - body: currentPRInfo.body, - title: currentPRInfo.title.trim(), - highlight: true, - }); - - logger.debug( - `Injected current PR #${currentPRInfo.number} into category "${categoryTitle}"` - ); - } else { - // PR doesn't match any category, add to leftovers section - leftovers.unshift({ - author: currentPRInfo.author, - hash: '', - title: currentPRInfo.title.trim(), - body: currentPRInfo.body, - hasPRinTitle: false, - pr: String(currentPRInfo.number), - prTitle: currentPRInfo.title, - prBody: currentPRInfo.body, - labels: currentPRInfo.labels, - category: null, - }); - logger.debug( - `Current PR #${currentPRInfo.number} doesn't match any category, added to leftovers` - ); - } - } - } - // Convert priority back to bump type let bumpType: BumpType | null = null; if (bumpPriority !== null) { @@ -1035,7 +1000,110 @@ async function generateChangesetFromGitImpl( ); } - const changelogSections = []; + return { + categories, + leftovers, + bumpType, + totalCommits: gitCommits.length, + matchedCommitsWithSemver, + releaseConfig, + }; +} + +/** + * Injects a PR into raw changelog data. + * The PR is added to the appropriate category based on labels/patterns, + * or to leftovers if no category matches. + * + * @param rawData The raw changelog data to modify (mutated in place) + * @param prInfo The PR info to inject + */ +function injectCurrentPR(rawData: RawChangelogData, prInfo: CurrentPRInfo): void { + const { categories, leftovers, releaseConfig } = rawData; + + // Check if PR should be excluded + const prLabels = new Set(prInfo.labels); + if ( + prInfo.body.includes(SKIP_CHANGELOG_MAGIC_WORD) || + shouldExcludePR(prLabels, prInfo.author, releaseConfig) + ) { + return; + } + + // Match PR to category using same logic as commits + const matchedCategory = matchCommitToCategory( + prLabels, + prInfo.author, + prInfo.title.trim(), + releaseConfig + ); + const categoryTitle = matchedCategory?.title ?? null; + + if (categoryTitle) { + let category = categories.get(categoryTitle); + if (!category) { + category = { + title: categoryTitle, + scopeGroups: new Map(), + }; + categories.set(categoryTitle, category); + } + + const scope = extractScope(prInfo.title.trim()); + let scopeGroup = category.scopeGroups.get(scope); + if (!scopeGroup) { + scopeGroup = []; + category.scopeGroups.set(scope, scopeGroup); + } + + // Add current PR + scopeGroup.push({ + author: prInfo.author, + number: String(prInfo.number), + hash: '', // No commit hash for unmerged PR + body: prInfo.body, + title: prInfo.title.trim(), + }); + + logger.debug( + `Injected current PR #${prInfo.number} into category "${categoryTitle}"` + ); + } else { + // PR doesn't match any category, add to leftovers section + leftovers.unshift({ + author: prInfo.author, + hash: '', + title: prInfo.title.trim(), + body: prInfo.body, + hasPRinTitle: false, + pr: String(prInfo.number), + prTitle: prInfo.title, + prBody: prInfo.body, + labels: prInfo.labels, + category: null, + }); + logger.debug( + `Current PR #${prInfo.number} doesn't match any category, added to leftovers` + ); + } +} + +/** + * Serializes raw changelog data to markdown format. + * + * @param rawData The raw changelog data to serialize + * @param maxLeftovers Maximum number of leftover entries to include + * @param highlightPR Optional PR number to highlight (rendered as blockquote) + * @returns Formatted markdown changelog string + */ +async function serializeChangelog( + rawData: RawChangelogData, + maxLeftovers: number, + highlightPR?: string +): Promise { + const { categories, leftovers, releaseConfig } = rawData; + + const changelogSections: string[] = []; const { repo, owner } = await getGlobalGitHubConfig(); const repoUrl = `https://github.com/${owner}/${repo}`; @@ -1050,7 +1118,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); @@ -1099,7 +1167,7 @@ async function generateChangesetFromGitImpl( hash: pr.hash, body: pr.body, repoUrl, - highlight: pr.highlight, + highlight: highlightPR === pr.number, }) ); @@ -1154,7 +1222,7 @@ async function generateChangesetFromGitImpl( ? commit.body : undefined, // Highlight if this is the current PR - highlight: currentPRInfo != null && commit.pr === String(currentPRInfo.number), + highlight: highlightPR != null && commit.pr === highlightPR, }) ) .join('\n') @@ -1164,11 +1232,26 @@ 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 rawData = await generateRawChangelog(git, rev); + const changelog = await serializeChangelog(rawData, maxLeftovers); + return { - changelog: changelogSections.join('\n\n'), - bumpType, - totalCommits: gitCommits.length, - matchedCommitsWithSemver, + changelog, + bumpType: rawData.bumpType, + totalCommits: rawData.totalCommits, + matchedCommitsWithSemver: rawData.matchedCommitsWithSemver, }; } From 491804bb32ce05ebdaedef838a5aa60933d3461d Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Fri, 26 Dec 2025 19:42:28 +0300 Subject: [PATCH 18/24] refactor: Use base branch tip instead of merge-base Using the PR's base branch tip directly (origin/{base.ref}) gives a more accurate changelog preview, since it includes all commits currently on the base branch that will be part of the final changelog when the PR is merged. --- src/utils/changelog.ts | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/src/utils/changelog.ts b/src/utils/changelog.ts index 50550065d..d215536fc 100644 --- a/src/utils/changelog.ts +++ b/src/utils/changelog.ts @@ -830,17 +830,15 @@ export async function generateChangelogWithHighlight( // Step 1: Fetch PR info from GitHub const prInfo = await fetchPRInfo(currentPRNumber); - // Step 2: Fetch the base branch and compute merge base + // Step 2: Fetch the base branch to get current state await git.fetch('origin', prInfo.baseRef); - const until = ( - await git.raw(['merge-base', 'HEAD', `origin/${prInfo.baseRef}`]) - ).trim(); - logger.debug( - `Computed merge base from PR base branch "${prInfo.baseRef}": ${until}` - ); + const baseRef = `origin/${prInfo.baseRef}`; + logger.debug(`Using PR base branch "${prInfo.baseRef}" for changelog`); - // Step 3: Generate raw changelog data up to merge base (excludes PR commits) - const rawData = await generateRawChangelog(git, rev, until); + // Step 3: Generate raw changelog data up to base branch tip + // This includes all commits on the base branch, which is what the + // final changelog will contain when this PR is merged + const rawData = await generateRawChangelog(git, rev, baseRef); // Step 4: Inject the current PR into the raw data injectCurrentPR(rawData, prInfo); From 368750f1e6489e3470b2eb2b4d8b8c3937741663 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Fri, 26 Dec 2025 19:45:59 +0300 Subject: [PATCH 19/24] refactor: Separate RawChangelogData from ChangelogStats Split RawChangelogData into: - RawChangelogData: just the changelog entries (categories, leftovers, config) - ChangelogStats: metrics for auto-versioning (bumpType, totalCommits, etc.) generateRawChangelog now returns RawChangelogResult containing both. --- src/utils/changelog.ts | 48 +++++++++++++++++++++++++++--------------- 1 file changed, 31 insertions(+), 17 deletions(-) diff --git a/src/utils/changelog.ts b/src/utils/changelog.ts index d215536fc..5d2f1239e 100644 --- a/src/utils/changelog.ts +++ b/src/utils/changelog.ts @@ -762,14 +762,28 @@ export interface RawChangelogData { 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; - /** Release config for serialization */ - releaseConfig: NormalizedReleaseConfig | null; +} + +/** + * Result from raw changelog generation, includes both data and stats. + */ +interface RawChangelogResult { + data: RawChangelogData; + stats: ChangelogStats; } // Memoization cache for generateChangesetFromGit @@ -838,7 +852,7 @@ export async function generateChangelogWithHighlight( // Step 3: Generate raw changelog data up to base branch tip // This includes all commits on the base branch, which is what the // final changelog will contain when this PR is merged - const rawData = await generateRawChangelog(git, rev, baseRef); + const { data: rawData, stats } = await generateRawChangelog(git, rev, baseRef); // Step 4: Inject the current PR into the raw data injectCurrentPR(rawData, prInfo); @@ -848,9 +862,7 @@ export async function generateChangelogWithHighlight( return { changelog, - bumpType: rawData.bumpType, - totalCommits: rawData.totalCommits, - matchedCommitsWithSemver: rawData.matchedCommitsWithSemver, + ...stats, }; } @@ -868,7 +880,7 @@ async function generateRawChangelog( git: SimpleGit, rev: string, until?: string -): Promise { +): Promise { const rawConfig = readReleaseConfig(); const releaseConfig = normalizeReleaseConfig(rawConfig); @@ -999,12 +1011,16 @@ async function generateRawChangelog( } return { - categories, - leftovers, - bumpType, - totalCommits: gitCommits.length, - matchedCommitsWithSemver, - releaseConfig, + data: { + categories, + leftovers, + releaseConfig, + }, + stats: { + bumpType, + totalCommits: gitCommits.length, + matchedCommitsWithSemver, + }, }; } @@ -1242,14 +1258,12 @@ async function generateChangesetFromGitImpl( rev: string, maxLeftovers: number ): Promise { - const rawData = await generateRawChangelog(git, rev); + const { data: rawData, stats } = await generateRawChangelog(git, rev); const changelog = await serializeChangelog(rawData, maxLeftovers); return { changelog, - bumpType: rawData.bumpType, - totalCommits: rawData.totalCommits, - matchedCommitsWithSemver: rawData.matchedCommitsWithSemver, + ...stats, }; } From 2c0f6d8384fb3d4b66d811e414ed25d287ccf072 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Fri, 26 Dec 2025 19:47:13 +0300 Subject: [PATCH 20/24] refactor: Inline injectCurrentPR into generateChangelogWithHighlight The function was only used in one place, so inline the logic directly. --- src/utils/changelog.ts | 141 ++++++++++++++++++----------------------- 1 file changed, 61 insertions(+), 80 deletions(-) diff --git a/src/utils/changelog.ts b/src/utils/changelog.ts index 5d2f1239e..892c0fab7 100644 --- a/src/utils/changelog.ts +++ b/src/utils/changelog.ts @@ -854,8 +854,67 @@ export async function generateChangelogWithHighlight( // final changelog will contain when this PR is merged const { data: rawData, stats } = await generateRawChangelog(git, rev, baseRef); - // Step 4: Inject the current PR into the raw data - injectCurrentPR(rawData, prInfo); + // Step 4: Inject the current PR into the raw data (if not excluded) + const { categories, leftovers, releaseConfig } = rawData; + const prLabels = new Set(prInfo.labels); + if ( + !prInfo.body.includes(SKIP_CHANGELOG_MAGIC_WORD) && + !shouldExcludePR(prLabels, prInfo.author, releaseConfig) + ) { + const matchedCategory = matchCommitToCategory( + prLabels, + prInfo.author, + prInfo.title.trim(), + releaseConfig + ); + const categoryTitle = matchedCategory?.title ?? null; + + if (categoryTitle) { + let category = categories.get(categoryTitle); + if (!category) { + category = { + title: categoryTitle, + scopeGroups: new Map(), + }; + categories.set(categoryTitle, category); + } + + const scope = extractScope(prInfo.title.trim()); + let scopeGroup = category.scopeGroups.get(scope); + if (!scopeGroup) { + scopeGroup = []; + category.scopeGroups.set(scope, scopeGroup); + } + + scopeGroup.push({ + author: prInfo.author, + number: String(prInfo.number), + hash: '', + body: prInfo.body, + title: prInfo.title.trim(), + }); + + logger.debug( + `Injected current PR #${prInfo.number} into category "${categoryTitle}"` + ); + } else { + leftovers.unshift({ + author: prInfo.author, + hash: '', + title: prInfo.title.trim(), + body: prInfo.body, + hasPRinTitle: false, + pr: String(prInfo.number), + prTitle: prInfo.title, + prBody: prInfo.body, + labels: prInfo.labels, + category: null, + }); + logger.debug( + `Current PR #${prInfo.number} doesn't match any category, added to leftovers` + ); + } + } // Step 5: Serialize to markdown with highlighting for the current PR const changelog = await serializeChangelog(rawData, MAX_LEFTOVERS, String(currentPRNumber)); @@ -1024,84 +1083,6 @@ async function generateRawChangelog( }; } -/** - * Injects a PR into raw changelog data. - * The PR is added to the appropriate category based on labels/patterns, - * or to leftovers if no category matches. - * - * @param rawData The raw changelog data to modify (mutated in place) - * @param prInfo The PR info to inject - */ -function injectCurrentPR(rawData: RawChangelogData, prInfo: CurrentPRInfo): void { - const { categories, leftovers, releaseConfig } = rawData; - - // Check if PR should be excluded - const prLabels = new Set(prInfo.labels); - if ( - prInfo.body.includes(SKIP_CHANGELOG_MAGIC_WORD) || - shouldExcludePR(prLabels, prInfo.author, releaseConfig) - ) { - return; - } - - // Match PR to category using same logic as commits - const matchedCategory = matchCommitToCategory( - prLabels, - prInfo.author, - prInfo.title.trim(), - releaseConfig - ); - const categoryTitle = matchedCategory?.title ?? null; - - if (categoryTitle) { - let category = categories.get(categoryTitle); - if (!category) { - category = { - title: categoryTitle, - scopeGroups: new Map(), - }; - categories.set(categoryTitle, category); - } - - const scope = extractScope(prInfo.title.trim()); - let scopeGroup = category.scopeGroups.get(scope); - if (!scopeGroup) { - scopeGroup = []; - category.scopeGroups.set(scope, scopeGroup); - } - - // Add current PR - scopeGroup.push({ - author: prInfo.author, - number: String(prInfo.number), - hash: '', // No commit hash for unmerged PR - body: prInfo.body, - title: prInfo.title.trim(), - }); - - logger.debug( - `Injected current PR #${prInfo.number} into category "${categoryTitle}"` - ); - } else { - // PR doesn't match any category, add to leftovers section - leftovers.unshift({ - author: prInfo.author, - hash: '', - title: prInfo.title.trim(), - body: prInfo.body, - hasPRinTitle: false, - pr: String(prInfo.number), - prTitle: prInfo.title, - prBody: prInfo.body, - labels: prInfo.labels, - category: null, - }); - logger.debug( - `Current PR #${prInfo.number} doesn't match any category, added to leftovers` - ); - } -} - /** * Serializes raw changelog data to markdown format. * From 3adcf69e8837dc97fcb337bb147ee6b170e11b0d Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Fri, 26 Dec 2025 19:57:25 +0300 Subject: [PATCH 21/24] refactor: Clean sandwich approach for changelog with PR injection - Split data gathering from categorization: - fetchRawCommitInfo: fetches commit/PR data from git and GitHub - categorizeCommits: groups commits into categories - serializeChangelog: renders to markdown - generateChangelogWithHighlight now: 1. Fetches raw commit info 2. Adds current PR with highlight flag to the list 3. Runs categorization on combined list (no duplicate logic) 4. Serializes to markdown - Add highlight flag to PullRequest and Commit interfaces - Serialization uses entry.highlight instead of comparing PR numbers - Add --format json option to changelog command: Returns { changelog, bumpType, totalCommits, matchedCommitsWithSemver } --- src/commands/changelog.ts | 35 ++++- src/utils/changelog.ts | 311 +++++++++++++++++++------------------- 2 files changed, 187 insertions(+), 159 deletions(-) diff --git a/src/commands/changelog.ts b/src/commands/changelog.ts index 5fba059b1..53cfed7b7 100644 --- a/src/commands/changelog.ts +++ b/src/commands/changelog.ts @@ -11,12 +11,17 @@ 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) => @@ -29,8 +34,15 @@ export const builder: CommandBuilder = (yargs: Argv) => }) .option('pr', { description: - 'PR number for the current (unmerged) PR. The PR info will be fetched from GitHub API, merge base computed from base branch, and the PR included in the changelog with highlighting.', + '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', }); /** @@ -55,13 +67,22 @@ export async function changelogMain(argv: ChangelogOptions): Promise { ? await generateChangelogWithHighlight(git, since, argv.pr) : await generateChangesetFromGit(git, since); - if (!result.changelog) { - console.log('No changelog entries found.'); - return; + // 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); } - - // Output to stdout - console.log(result.changelog); } export const handler = async (args: { diff --git a/src/utils/changelog.ts b/src/utils/changelog.ts index 892c0fab7..55348cb32 100644 --- a/src/utils/changelog.ts +++ b/src/utils/changelog.ts @@ -316,6 +316,8 @@ interface PullRequest { hash: string; body: string; title: string; + /** Whether this entry should be highlighted in output */ + highlight?: boolean; } interface Commit { @@ -329,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; } /** @@ -826,10 +847,10 @@ export async function generateChangesetFromGit( * 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. Computes merge base from the PR's base branch - * 3. Generates raw changelog data up to merge base (excludes PR commits) - * 4. Injects the current PR into the raw data - * 5. Serializes to markdown with the current PR highlighted + * 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 @@ -849,75 +870,28 @@ export async function generateChangelogWithHighlight( const baseRef = `origin/${prInfo.baseRef}`; logger.debug(`Using PR base branch "${prInfo.baseRef}" for changelog`); - // Step 3: Generate raw changelog data up to base branch tip - // This includes all commits on the base branch, which is what the - // final changelog will contain when this PR is merged - const { data: rawData, stats } = await generateRawChangelog(git, rev, baseRef); - - // Step 4: Inject the current PR into the raw data (if not excluded) - const { categories, leftovers, releaseConfig } = rawData; - const prLabels = new Set(prInfo.labels); - if ( - !prInfo.body.includes(SKIP_CHANGELOG_MAGIC_WORD) && - !shouldExcludePR(prLabels, prInfo.author, releaseConfig) - ) { - const matchedCategory = matchCommitToCategory( - prLabels, - prInfo.author, - prInfo.title.trim(), - releaseConfig - ); - const categoryTitle = matchedCategory?.title ?? null; - - if (categoryTitle) { - let category = categories.get(categoryTitle); - if (!category) { - category = { - title: categoryTitle, - scopeGroups: new Map(), - }; - categories.set(categoryTitle, category); - } - - const scope = extractScope(prInfo.title.trim()); - let scopeGroup = category.scopeGroups.get(scope); - if (!scopeGroup) { - scopeGroup = []; - category.scopeGroups.set(scope, scopeGroup); - } + // 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]; - scopeGroup.push({ - author: prInfo.author, - number: String(prInfo.number), - hash: '', - body: prInfo.body, - title: prInfo.title.trim(), - }); + // Step 5: Run categorization on combined list + const { data: rawData, stats } = categorizeCommits(allCommits); - logger.debug( - `Injected current PR #${prInfo.number} into category "${categoryTitle}"` - ); - } else { - leftovers.unshift({ - author: prInfo.author, - hash: '', - title: prInfo.title.trim(), - body: prInfo.body, - hasPRinTitle: false, - pr: String(prInfo.number), - prTitle: prInfo.title, - prBody: prInfo.body, - labels: prInfo.labels, - category: null, - }); - logger.debug( - `Current PR #${prInfo.number} doesn't match any category, added to leftovers` - ); - } - } - - // Step 5: Serialize to markdown with highlighting for the current PR - const changelog = await serializeChangelog(rawData, MAX_LEFTOVERS, String(currentPRNumber)); + // Step 6: Serialize to markdown + const changelog = await serializeChangelog(rawData, MAX_LEFTOVERS); return { changelog, @@ -926,23 +900,19 @@ export async function generateChangelogWithHighlight( } /** - * Generates raw changelog data from git history. - * This returns an intermediate representation that can be manipulated - * before serialization to markdown. + * 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 generate changelog from + * @param rev Base revision (tag or SHA) to start from * @param until Optional end revision (defaults to HEAD) - * @returns Raw changelog data structure + * @returns Array of raw commit info */ -async function generateRawChangelog( +async function fetchRawCommitInfo( git: SimpleGit, rev: string, until?: string -): Promise { - const rawConfig = readReleaseConfig(); - const releaseConfig = normalizeReleaseConfig(rawConfig); - +): Promise { const gitCommits = (await getChangesSince(git, rev, until)).filter( ({ body }) => !body.includes(SKIP_CHANGELOG_MAGIC_WORD) ); @@ -951,36 +921,62 @@ async function generateRawChangelog( gitCommits.map(({ hash }) => hash) ); - const categories = new Map(); - 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; +} + +/** + * 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, author, releaseConfig)) { + 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 ); @@ -995,59 +991,54 @@ async function generateRawChangelog( } } - 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, - }; - - 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, + }); } } @@ -1065,7 +1056,7 @@ async function generateRawChangelog( 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}`) ); } @@ -1077,24 +1068,41 @@ async function generateRawChangelog( }, stats: { bumpType, - totalCommits: gitCommits.length, + 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 - * @param highlightPR Optional PR number to highlight (rendered as blockquote) * @returns Formatted markdown changelog string */ async function serializeChangelog( rawData: RawChangelogData, - maxLeftovers: number, - highlightPR?: string + maxLeftovers: number ): Promise { const { categories, leftovers, releaseConfig } = rawData; @@ -1162,7 +1170,7 @@ async function serializeChangelog( hash: pr.hash, body: pr.body, repoUrl, - highlight: highlightPR === pr.number, + highlight: pr.highlight, }) ); @@ -1216,8 +1224,7 @@ async function serializeChangelog( : commit.body.includes(BODY_IN_CHANGELOG_MAGIC_WORD) ? commit.body : undefined, - // Highlight if this is the current PR - highlight: highlightPR != null && commit.pr === highlightPR, + highlight: commit.highlight, }) ) .join('\n') From abbe6271f001b9f06fbe4eae08fcf9da653f24dc Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Fri, 26 Dec 2025 20:03:43 +0300 Subject: [PATCH 22/24] feat: Display suggested version bump in changelog preview comment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Use --format json to get bumpType from craft changelog - Display bump type with colored badge (🔴 Major, 🟡 Minor, 🟢 Patch) - Update docs to reflect new comment format --- .github/workflows/changelog-preview.yml | 25 +++++++++++++++++++------ docs/src/content/docs/github-actions.md | 13 ++++++++----- 2 files changed, 27 insertions(+), 11 deletions(-) diff --git a/.github/workflows/changelog-preview.yml b/.github/workflows/changelog-preview.yml index 938c9067c..7e10cf113 100644 --- a/.github/workflows/changelog-preview.yml +++ b/.github/workflows/changelog-preview.yml @@ -42,15 +42,26 @@ jobs: run: | PR_NUMBER="${{ github.event.pull_request.number }}" - # Generate changelog with current PR injected and highlighted - # Craft fetches PR info from GitHub API, computes merge base from PR's base branch - echo "Running craft changelog --pr $PR_NUMBER..." - CHANGELOG=$(craft changelog --pr "$PR_NUMBER" 2>/dev/null || echo "") + # 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 read -r -d '' COMMENT_BODY << 'EOF' || true @@ -59,11 +70,13 @@ jobs: This is how your changes will appear in the changelog. Entries from this PR are highlighted with a left border (blockquote style). - --- - EOF COMMENT_BODY="${COMMENT_BODY} + **Suggested version bump:** ${BUMP_BADGE} + + --- + ${CHANGELOG} --- diff --git a/docs/src/content/docs/github-actions.md b/docs/src/content/docs/github-actions.md index 64761ca64..b697fe538 100644 --- a/docs/src/content/docs/github-actions.md +++ b/docs/src/content/docs/github-actions.md @@ -123,12 +123,13 @@ jobs: ### How It Works -1. **Generates the changelog** - Runs `craft changelog --pr ` to generate the upcoming changelog +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. **Computes merge base** - Determines the merge base to exclude unmerged PR commits -4. **Highlights PR entries** - The current PR is rendered with blockquote style (displayed with a left border in GitHub) -5. **Posts a comment** - Creates or updates a comment on the PR with the changelog preview -6. **Auto-updates** - The comment is automatically updated when you update the PR (push commits, edit title/description, or change labels) +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 @@ -140,6 +141,8 @@ The workflow posts a comment like this: This is how your changes will appear in the changelog. Entries from this PR are highlighted with a left border (blockquote style). +**Suggested version bump:** 🟡 **Minor** (new features) + --- ### New Features ✨ From 5777299f68a1aeb7121ba341158a324bb8013ac1 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Fri, 26 Dec 2025 20:25:38 +0300 Subject: [PATCH 23/24] style: Make version bump more prominent with its own header --- .github/workflows/changelog-preview.yml | 4 +++- docs/src/content/docs/github-actions.md | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/workflows/changelog-preview.yml b/.github/workflows/changelog-preview.yml index 7e10cf113..afa06aa42 100644 --- a/.github/workflows/changelog-preview.yml +++ b/.github/workflows/changelog-preview.yml @@ -73,7 +73,9 @@ jobs: EOF COMMENT_BODY="${COMMENT_BODY} - **Suggested version bump:** ${BUMP_BADGE} + ### Suggested Version Bump + + ${BUMP_BADGE} --- diff --git a/docs/src/content/docs/github-actions.md b/docs/src/content/docs/github-actions.md index b697fe538..a14be9a01 100644 --- a/docs/src/content/docs/github-actions.md +++ b/docs/src/content/docs/github-actions.md @@ -141,7 +141,9 @@ The workflow posts a comment like this: This is how your changes will appear in the changelog. Entries from this PR are highlighted with a left border (blockquote style). -**Suggested version bump:** 🟡 **Minor** (new features) +### Suggested Version Bump + +🟡 **Minor** (new features) --- From bd046e20b5f15fc94c8ff67da1be5e26e0af0d79 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Fri, 26 Dec 2025 20:31:19 +0300 Subject: [PATCH 24/24] refactor: Move version bump to top-level header, use temp file for comment - Version bump is now ## header appearing first - Use temp file instead of heredoc (safer with arbitrary content) - Use unique heredoc marker CRAFT_CHANGELOG_COMMENT_END - Use -F body=@file for gh api calls --- .github/workflows/changelog-preview.yml | 25 +++++++++++++------------ docs/src/content/docs/github-actions.md | 8 ++++---- 2 files changed, 17 insertions(+), 16 deletions(-) diff --git a/.github/workflows/changelog-preview.yml b/.github/workflows/changelog-preview.yml index afa06aa42..95d6bdcbe 100644 --- a/.github/workflows/changelog-preview.yml +++ b/.github/workflows/changelog-preview.yml @@ -62,28 +62,27 @@ jobs: *) BUMP_BADGE="⚪ **None** (no version bump detected)" ;; esac - # Build comment body - read -r -d '' COMMENT_BODY << 'EOF' || true + # 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). - EOF - - COMMENT_BODY="${COMMENT_BODY} - ### Suggested Version Bump - - ${BUMP_BADGE} - --- ${CHANGELOG} --- - 🤖 This preview updates automatically when you update the PR." + 🤖 This preview updates automatically when you update the PR. + CRAFT_CHANGELOG_COMMENT_END # Find existing comment with our marker COMMENT_ID=$(gh api \ @@ -95,10 +94,12 @@ jobs: echo "Updating existing comment $COMMENT_ID..." gh api -X PATCH \ "repos/$GITHUB_REPOSITORY/issues/comments/$COMMENT_ID" \ - -f body="$COMMENT_BODY" + -F body=@"$COMMENT_FILE" else echo "Creating new comment..." gh api -X POST \ "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/comments" \ - -f body="$COMMENT_BODY" + -F body=@"$COMMENT_FILE" fi + + rm -f "$COMMENT_FILE" diff --git a/docs/src/content/docs/github-actions.md b/docs/src/content/docs/github-actions.md index a14be9a01..2cffed2b6 100644 --- a/docs/src/content/docs/github-actions.md +++ b/docs/src/content/docs/github-actions.md @@ -136,15 +136,15 @@ jobs: 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). -### Suggested Version Bump - -🟡 **Minor** (new features) - --- ### New Features ✨