Skip to content
Draft
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
1 change: 1 addition & 0 deletions .changes/archive/.gitkeep
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@

1 change: 1 addition & 0 deletions .changes/unreleased/.gitkeep
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@

222 changes: 222 additions & 0 deletions .github/workflows/changelog-fragment-draft.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
name: Draft changelog fragment

on:
pull_request_target:
branches:
- main
- next
types: [opened, reopened, synchronize, labeled, unlabeled]

permissions:
models: read
pull-requests: write

concurrency:
group: "${{ github.workflow }}-${{ github.event.pull_request.number }}"
cancel-in-progress: true

jobs:
draft:
runs-on: ubuntu-latest
steps:
- name: Collect PR context
id: context
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
const path = require('path');

const pr = context.payload.pull_request;
const owner = context.repo.owner;
const repo = context.repo.repo;
const files = await github.paginate(github.rest.pulls.listFiles, {
owner,
repo,
pull_number: pr.number,
per_page: 100,
});

const hasNoChangelog = (pr.labels || []).some(label => label.name === 'no changelog');
const hasFragment = files.some(file => /^\.changes\/unreleased\/.+\.md$/.test(file.filename));
const shouldRun = !hasNoChangelog && !hasFragment;

const changedFiles = files.map(file => file.filename).join('\n') || '(no changed files reported)';

const diffSections = [];
let totalChars = 0;
for (const file of files) {
const patch = file.patch || '[diff omitted by GitHub]';
const truncatedPatch = patch.length > 4000 ? `${patch.slice(0, 4000)}\n...[truncated]` : patch;
const section = `### ${file.filename}\n${truncatedPatch}`;
if (totalChars + section.length > 20000) {
diffSections.push('...[additional diff omitted]');
break;
}
diffSections.push(section);
totalChars += section.length;
}

const tempDir = process.env.RUNNER_TEMP;
const inputsDir = path.join(tempDir, 'changelog-fragment-inputs');
fs.mkdirSync(inputsDir, { recursive: true });

const bodyPath = path.join(inputsDir, 'body.txt');
const titlePath = path.join(inputsDir, 'title.txt');
const changedFilesPath = path.join(inputsDir, 'changed_files.txt');
const diffPath = path.join(inputsDir, 'diff.txt');
const promptPath = path.join(inputsDir, 'prompt.prompt.yml');

fs.writeFileSync(bodyPath, pr.body || '');
fs.writeFileSync(titlePath, pr.title || '');
fs.writeFileSync(changedFilesPath, changedFiles);
fs.writeFileSync(diffPath, diffSections.join('\n\n'));
fs.writeFileSync(promptPath, `messages:
- role: system
content: |-
You draft changelog fragments for a Rust workspace.
Return JSON only.
Pick the smallest accurate kind from: breaking, change, enhancement, fix.
Write a single concise, user-facing summary sentence.
Do not mention tests, reviews, CI, or implementation details unless they matter to users.
Only set crate when a single obvious crate is central to the change; otherwise use an empty string.
- role: user
content: |-
Draft a changelog fragment from this pull request.

Pull request URL:
{{pr_url}}

Pull request title:
{{title}}

Pull request body:
{{body}}

Changed files:
{{changed_files}}

Unified diff:
{{diff}}
model: openai/gpt-4.1-mini
responseFormat: json_schema
jsonSchema: |-
{
"name": "changelog_fragment",
"strict": true,
"schema": {
"type": "object",
"properties": {
"kind": {
"type": "string",
"enum": ["breaking", "change", "enhancement", "fix"]
},
"crate": {
"type": "string"
},
"summary": {
"type": "string"
}
},
"required": ["kind", "crate", "summary"],
"additionalProperties": false
}
}
modelParameters:
temperature: 0.2
maxCompletionTokens: 250
`);

core.setOutput('should_run', shouldRun ? 'true' : 'false');
core.setOutput('pr_number', String(pr.number));
core.setOutput('pr_url', pr.html_url);
core.setOutput('body_path', bodyPath);
core.setOutput('title_path', titlePath);
core.setOutput('changed_files_path', changedFilesPath);
core.setOutput('diff_path', diffPath);
core.setOutput('prompt_path', promptPath);

- name: Run AI inference
if: ${{ steps.context.outputs.should_run == 'true' }}
id: inference
uses: actions/ai-inference@v1
with:
prompt-file: ${{ steps.context.outputs.prompt_path }}
input: |
pr_url: ${{ steps.context.outputs.pr_url }}
file_input: |
title: ${{ steps.context.outputs.title_path }}
body: ${{ steps.context.outputs.body_path }}
changed_files: ${{ steps.context.outputs.changed_files_path }}
diff: ${{ steps.context.outputs.diff_path }}

- name: Comment fragment instructions
if: ${{ steps.context.outputs.should_run == 'true' }}
env:
PR_NUMBER: ${{ steps.context.outputs.pr_number }}
PR_URL: ${{ steps.context.outputs.pr_url }}
RESPONSE_FILE: ${{ steps.inference.outputs.response-file }}
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
const prNumber = process.env.PR_NUMBER;
const prUrl = process.env.PR_URL;
const response = JSON.parse(fs.readFileSync(process.env.RESPONSE_FILE, 'utf8'));
const marker = '<!-- changelog-fragment-draft -->';
const kind = response.kind;
const crate = response.crate || '';
const summary = response.summary;
const repoFragmentPath = `.changes/unreleased/${prNumber}.${kind}.md`;

const frontMatter = [
'---',
`kind: ${kind}`,
`pr: ${prUrl}`,
...(crate ? [`crate: ${crate}`] : []),
'---',
'',
summary,
];
const fragment = frontMatter.join('\n');
const body = [
marker,
'This pull request does not include a changelog fragment and is not labeled `no changelog`.',
'',
`Please add a file at \`${repoFragmentPath}\` with content like:`,
'',
'```md',
fragment,
'```',
'',
'If no changelog entry is needed, ask a maintainer to apply the `no changelog` label.',
].join('\n');

const { owner, repo } = context.repo;
const issue_number = context.payload.pull_request.number;
const comments = await github.paginate(github.rest.issues.listComments, {
owner,
repo,
issue_number,
per_page: 100,
});

const existing = comments.find(comment =>
comment.user?.login === 'github-actions[bot]' && comment.body?.includes(marker)
);

if (existing) {
await github.rest.issues.updateComment({
owner,
repo,
comment_id: existing.id,
body,
});
} else {
await github.rest.issues.createComment({
owner,
repo,
issue_number,
body,
});
}
46 changes: 46 additions & 0 deletions .github/workflows/changelog-release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
name: Prepare changelog release

on:
workflow_dispatch:
inputs:
version:
description: Release version, e.g. 0.23.0
required: true
type: string
date:
description: Release date in YYYY-MM-DD format. Defaults to today if empty.
required: false
type: string

permissions:
contents: write

jobs:
prepare:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0

- name: Prepare changelog release
run: |
if [ -n "${{ inputs.date }}" ]; then
./scripts/prepare-changelog-release.py --version "${{ inputs.version }}" --date "${{ inputs.date }}"
else
./scripts/prepare-changelog-release.py --version "${{ inputs.version }}"
fi

- name: Commit prepared changelog
run: |
if git diff --quiet -- CHANGELOG.md .changes; then
echo "No changelog changes were produced."
exit 1
fi

git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add CHANGELOG.md .changes
git commit -m "chore(changelog): prepare ${{ inputs.version }}"
git push
3 changes: 1 addition & 2 deletions .github/workflows/changelog.yml
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
# Runs changelog related jobs.
# CI job heavily inspired by: https://github.com/tarides/changelog-check-action

name: changelog

Expand All @@ -25,5 +24,5 @@ jobs:
env:
BASE_REF: ${{ github.event.pull_request.base.ref }}
NO_CHANGELOG_LABEL: ${{ contains(github.event.pull_request.labels.*.name, 'no changelog') }}
run: ./scripts/check-changelog.sh "${{ inputs.changelog }}"
run: ./scripts/check-changelog.sh
shell: bash
42 changes: 28 additions & 14 deletions scripts/check-changelog.sh
Original file line number Diff line number Diff line change
@@ -1,21 +1,35 @@
#!/bin/bash
set -uo pipefail
set -euo pipefail

CHANGELOG_FILE="${1:-CHANGELOG.md}"
FRAGMENT_GLOB=':(glob).changes/unreleased/*.md'

if [ "${NO_CHANGELOG_LABEL}" = "true" ]; then
# 'no changelog' set, so finish successfully
if [[ "${NO_CHANGELOG_LABEL:-false}" == "true" ]]; then
echo "\"no changelog\" label has been set"
exit 0
else
# a changelog check is required
# fail if the diff is empty
if git diff --exit-code "origin/${BASE_REF}" -- "${CHANGELOG_FILE}"; then
>&2 echo "Changes should come with an entry in the \"CHANGELOG.md\" file. This behavior
can be overridden by using the \"no changelog\" label, which is used for changes
that are trivial / explicitly stated not to require a changelog entry."
exit 1
fi
fi

if [[ -z "${BASE_REF:-}" ]]; then
echo "BASE_REF must be set" >&2
exit 1
fi

echo "The \"CHANGELOG.md\" file has been updated."
fragments=()
while IFS= read -r fragment; do
[[ -n "$fragment" ]] && fragments+=("$fragment")
done < <(git diff --name-only --diff-filter=AMR "origin/${BASE_REF}...HEAD" -- "$FRAGMENT_GLOB")

if [[ "${#fragments[@]}" -eq 0 ]]; then
cat >&2 <<'EOF'
Changes should come with a changelog fragment in ".changes/unreleased/".
This behavior can be overridden by using the "no changelog" label for changes
that are trivial or explicitly stated not to require a changelog entry.
EOF
exit 1
fi

validate_args=()
for fragment in "${fragments[@]}"; do
validate_args+=(--validate-fragment "$fragment")
done

./scripts/prepare-changelog-release.py "${validate_args[@]}"
Loading
Loading