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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 57 additions & 15 deletions .github/workflows/changelog-preview.yml
Original file line number Diff line number Diff line change
Expand Up @@ -165,45 +165,81 @@ jobs:
BUMP_TYPE=$(echo "$RESULT" | jq -r '.bumpType // "none"')
PR_SKIPPED=$(echo "$RESULT" | jq -r '.prSkipped // false')
VERSIONING_POLICY=$(echo "$RESULT" | jq -r '.versioningPolicy // "auto"')
# Only set when .craft.yml defines release workspaces: one entry per
# workspace whose changelog lists this PR
WORKSPACES=$(echo "$RESULT" | jq -r 'if .workspaces then .workspaces | length else "" end')

# Export for downstream steps (e.g., title enforcement)
{
echo "bump_type=${BUMP_TYPE}"
echo "pr_skipped=${PR_SKIPPED}"
echo "versioning_policy=${VERSIONING_POLICY}"
echo "workspace_count=${WORKSPACES}"
} >> "$GITHUB_OUTPUT"

if [[ "$PR_SKIPPED" == "true" ]]; then
if [[ "$WORKSPACES" == "0" ]]; then
CHANGELOG="_This PR does not change any workspace, so it will not appear in any changelog._"
elif [[ "$PR_SKIPPED" == "true" ]]; then
CHANGELOG="_This PR will not appear in the changelog._"
elif [[ -z "$CHANGELOG" ]]; then
CHANGELOG="_No changelog entries will be generated from this PR._"
fi

bump_badge() {
case "$1" in
major) echo "🔴 **Major** (breaking changes)" ;;
minor) echo "🟡 **Minor** (new features)" ;;
patch) echo "🟢 **Patch** (bug fixes)" ;;
*) echo "⚪ **None** (no version bump detected)" ;;
esac
}

bump_short() {
case "$1" in
major) echo "Major" ;;
minor) echo "Minor" ;;
patch) echo "Patch" ;;
*) echo "None" ;;
esac
}

# CalVer projects don't use semver bumps — skip the impact badge
if [[ "$VERSIONING_POLICY" == "calver" ]]; then
BUMP_BADGE=""
BUMP_SHORT="CalVer"
SECTION_HEADING="Changelog Preview"
STATUS_CONTEXT="Changelog Preview"
else
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

case "$BUMP_TYPE" in
major) BUMP_SHORT="Major" ;;
minor) BUMP_SHORT="Minor" ;;
patch) BUMP_SHORT="Patch" ;;
*) BUMP_SHORT="None" ;;
esac

BUMP_BADGE=$(bump_badge "$BUMP_TYPE")
BUMP_SHORT=$(bump_short "$BUMP_TYPE")
SECTION_HEADING="Semver Impact of This PR"
STATUS_CONTEXT="Changelog Preview / Semver Impact"
fi

# With workspaces, list each workspace the PR lands in with its own bump
if [[ "$WORKSPACES" == "0" ]]; then
BUMP_BADGE="⚪ **None** (no workspace changed)"
BUMP_SHORT="No workspace changed"
elif [[ -n "$WORKSPACES" ]]; then
BUMP_BADGE="This PR lands in these workspaces:"$'\n'
BUMP_SHORT=""
while IFS=$'\t' read -r WS_NAME WS_BUMP WS_POLICY; do
if [[ "$WS_POLICY" == "calver" ]]; then
WS_BADGE="CalVer"
WS_SHORT="CalVer"
else
WS_BADGE=$(bump_badge "$WS_BUMP")
WS_SHORT=$(bump_short "$WS_BUMP")
fi
BUMP_BADGE+=$'\n'"- \`${WS_NAME}\`: ${WS_BADGE}"
BUMP_SHORT+="${BUMP_SHORT:+, }${WS_NAME}: ${WS_SHORT}"
done < <(echo "$RESULT" | jq -r '.workspaces[] | [.name, (.bumpType // "none"), (.versioningPolicy // "auto")] | @tsv')
# GitHub rejects commit status descriptions over 140 characters
if (( ${#BUMP_SHORT} > 140 )); then
BUMP_SHORT="${BUMP_SHORT:0:139}…"
fi
fi

# Determine mode: use status check mode when comment is false OR when running internally (no input)
USE_COMMENT_MODE="${{ inputs.comment }}"
if [[ "$USE_COMMENT_MODE" == "false" ]] || [[ -z "$USE_COMMENT_MODE" ]]; then
Expand Down Expand Up @@ -308,12 +344,18 @@ jobs:
BUMP_TYPE: ${{ steps.preview.outputs.bump_type }}
PR_SKIPPED: ${{ steps.preview.outputs.pr_skipped }}
VERSIONING_POLICY: ${{ steps.preview.outputs.versioning_policy }}
WORKSPACE_COUNT: ${{ steps.preview.outputs.workspace_count }}
run: |
if [[ "$PR_SKIPPED" == "true" ]]; then
echo "PR is skip-changelog — skipping title enforcement"
exit 0
fi

if [[ "$WORKSPACE_COUNT" == "0" ]]; then
echo "PR does not change any workspace — skipping title enforcement"
exit 0
fi

if [[ "$VERSIONING_POLICY" == "manual" ]]; then
echo "Manual versioning policy — skipping title enforcement"
exit 0
Expand Down
17 changes: 17 additions & 0 deletions docs/src/content/docs/github-actions.md
Original file line number Diff line number Diff line change
Expand Up @@ -442,6 +442,23 @@ options.
Pass the `workspace` input to release one [release workspace](../workspaces/#github-actions)
at a time.

The changelog preview needs no extra input for release workspaces. It lists
each workspace the pull request lands in, together with its version bump, and
shows the pull request's entry once:

```markdown
## Semver Impact of This PR

This PR lands in these workspaces:

- `clients/python`: 🟡 **Minor** (new features)
- `clients/rust`: 🟡 **Minor** (new features)
```

A pull request lands in a workspace when its
[workspace-aware changelog](../workspaces/#workspace-aware-changelogs) would
list it.

### Combining Both Workflows

You can use both the changelog preview and release workflows together for a complete release flow:
Expand Down
9 changes: 7 additions & 2 deletions docs/src/content/docs/workspaces.md
Original file line number Diff line number Diff line change
Expand Up @@ -287,13 +287,18 @@ Publish request titles include the full workspace path, for example
`publish: getsentry/toolkit/packages/cli@1.2.3`. A workflow cannot set both
`path` and `workspace`.

The [changelog preview workflow](../github-actions/#changelog-preview) needs no
`workspace` input. It previews each pull request in every workspace whose
changelog would list it, using the same path rules as the release changelog,
and posts a single comment or status that names each of those workspaces with
its version bump. Running `craft changelog --pr <number>` without a workspace
does the same.

## Known limitations

- GitHub tracks a single "Latest" release per repository, so the badge can move
between products. See the
[GitHub target](../targets/github/#the-latest-badge-in-monorepos).
- The [changelog preview workflow](../github-actions/#changelog-preview) has no
`workspace` input yet.

## Alternative: one config per product

Expand Down
155 changes: 155 additions & 0 deletions src/__tests__/changelog-preview-workflow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,3 +102,158 @@ printf '#!/usr/bin/env bash\\nprintf "2.35.0\\\\n"\\n' > "$3"
}
},
);

const preview = workflow.jobs.preview.steps.find(
step => step.name === 'Generate Changelog Preview',
)!;

function runPreview(craftOutput: object, comment: 'true' | 'false') {
const directory = mkdtempSync(join(tmpdir(), 'craft-preview-'));
try {
const executable = (name: string, body: string) => {
const path = join(directory, name);
writeFileSync(path, '#!/usr/bin/env bash\nset -euo pipefail\n' + body);
chmodSync(path, 0o755);
};
writeFileSync(join(directory, 'result.json'), JSON.stringify(craftOutput));
executable('craft', `cat "${join(directory, 'result.json')}"\n`);
// Records each call; comment bodies are copied from their @file argument
executable(
'gh',
`
printf '%s\\n' "$*" >> "$CALLS"
for arg in "$@"; do
if [[ "$arg" == body=@* ]]; then cat "\${arg#body=@}" >> "$CALLS"; fi
done
`,
);
const files = {
output: join(directory, 'output'),
summary: join(directory, 'summary'),
calls: join(directory, 'calls'),
};
for (const file of Object.values(files)) {
writeFileSync(file, '');
}
const result = spawnSync(
'bash',
[
'-eo',
'pipefail',
'-c',
preview.run!.replace('${{ inputs.comment }}', comment),
],
{
encoding: 'utf8',
env: {
...process.env,
PATH: directory + ':' + process.env.PATH,
PR_NUMBER: '7',
HEAD_SHA: 'abc123',
GITHUB_REPOSITORY: 'getsentry/example',
GITHUB_SERVER_URL: 'https://github.com',
GITHUB_RUN_ID: '1',
GITHUB_OUTPUT: files.output,
GITHUB_STEP_SUMMARY: files.summary,
CALLS: files.calls,
},
},
);
expect(result.stderr).toBe('');
expect(result.status).toBe(0);
return {
output: readFileSync(files.output, 'utf8'),
summary: readFileSync(files.summary, 'utf8'),
calls: readFileSync(files.calls, 'utf8'),
};
} finally {
rmSync(directory, { recursive: true, force: true });
}
}

test('lists each workspace the PR lands in with its bump in one comment', () => {
const { output, calls } = runPreview(
{
changelog: '### New Features\n\n> - Add option',
bumpType: 'minor',
versioningPolicy: 'auto',
prSkipped: false,
workspaces: [
{ name: 'clients/python', bumpType: 'minor', versioningPolicy: 'auto' },
{ name: 'clients/rust', bumpType: 'patch', versioningPolicy: 'auto' },
{ name: 'server', bumpType: 'minor', versioningPolicy: 'calver' },
],
},
'true',
);

expect(output).toContain('workspace_count=3\n');
// One comment body, posted once
expect(calls.match(/^<!-- craft-changelog-preview -->$/gm)).toHaveLength(1);
expect(calls).toContain(
'This PR lands in these workspaces:\n\n' +
'- `clients/python`: 🟡 **Minor** (new features)\n' +
'- `clients/rust`: 🟢 **Patch** (bug fixes)\n' +
'- `server`: CalVer\n',
);
expect(calls.match(/> - Add option/g)).toHaveLength(1);
});

test('names each workspace and bump in a single commit status', () => {
const { calls, summary } = runPreview(
{
changelog: '',
bumpType: 'minor',
versioningPolicy: 'auto',
prSkipped: true,
workspaces: [
{ name: 'clients/python', bumpType: 'minor', versioningPolicy: 'auto' },
{ name: 'clients/rust', bumpType: null, versioningPolicy: 'auto' },
],
},
'false',
);

const statuses = calls.split('\n').filter(line => line.includes('statuses'));
expect(statuses).toHaveLength(1);
expect(statuses[0]).toContain(
'description=clients/python: Minor, clients/rust: None',
);
expect(statuses[0]).toContain('context=Changelog Preview / Semver Impact');
expect(summary).toContain('_This PR will not appear in the changelog._');
});

test('says so when the PR lands in no workspace', () => {
const { output, summary, calls } = runPreview(
{
changelog: '',
bumpType: null,
versioningPolicy: 'auto',
prSkipped: false,
workspaces: [],
},
'false',
);

expect(output).toContain('workspace_count=0\n');
expect(calls).toContain('description=No workspace changed');
expect(summary).toContain('⚪ **None** (no workspace changed)');
expect(summary).toContain('will not appear in any changelog');
});

test('keeps the single-changelog preview without workspaces', () => {
const { output, summary, calls } = runPreview(
{
changelog: '### Bug Fixes\n\n> - Fix crash',
bumpType: 'patch',
versioningPolicy: 'auto',
prSkipped: false,
},
'false',
);

expect(output).toContain('workspace_count=\n');
expect(calls).toContain('description=Patch');
expect(summary).toContain('🟢 **Patch** (bug fixes)');
expect(summary).toContain('> - Fix crash');
});
Loading
Loading