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
53 changes: 12 additions & 41 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,49 +34,13 @@ jobs:
- name: Build
id: build_code
run: bun run build
- name: Detect previous tag
id: previous_tag
shell: bash
run: |
current_tag="${GITHUB_REF##*/}"
echo "current=$current_tag" >> "$GITHUB_OUTPUT"
if previous_tag=$(git describe --tags --abbrev=0 "${current_tag}^" 2>/dev/null); then
echo "found=true" >> "$GITHUB_OUTPUT"
echo "value=$previous_tag" >> "$GITHUB_OUTPUT"
else
echo "found=false" >> "$GITHUB_OUTPUT"
fi
- name: Generate AI changelog
id: changelog
if: ${{ steps.previous_tag.outputs.found == 'true' }}
uses: mistricky/ccc@v0.2.6
with:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
github_token: ${{ secrets.GITHUB_TOKEN }}
model: claude-sonnet-4-5-20250929
- name: Prepare release notes
id: release_notes
shell: bash
continue-on-error: true
env:
CURRENT_TAG: ${{ steps.previous_tag.outputs.current }}
PREVIOUS_TAG: ${{ steps.previous_tag.outputs.value }}
CHANGELOG_TEXT: ${{ steps.changelog.outputs.result }}
run: |
if [[ "${{ steps.previous_tag.outputs.found }}" == "true" ]]; then
{
printf '## 🆕 Changelog\n\n'
printf '%s\n\n' "$CHANGELOG_TEXT"
printf '%s\n\n' '---'
printf '🔗 **Full Changelog**: https://github.com/${{ github.repository }}/compare/%s...%s\n' "$PREVIOUS_TAG" "$CURRENT_TAG"
} > release-notes.md
else
{
printf '## 🆕 Changelog\n\n'
printf 'Initial release.\n\n'
printf '%s\n\n' '---'
printf '🔖 **Tag**: %s\n' "$CURRENT_TAG"
} > release-notes.md
fi
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
run: bun run changelog:ai
- name: Setup Node.js for npm publish
uses: actions/setup-node@v6
with:
Expand All @@ -96,7 +60,14 @@ jobs:
id: create_release
uses: softprops/action-gh-release@v2
with:
body_path: release-notes.md
body: |
## 🆕 Changelog

${{ steps.changelog.outputs.result || '_Changelog was not generated automatically for this release._' }}

---

🔗 **Full Changelog**: https://github.com/${{ github.repository }}/compare/${{ steps.changelog.outputs.from_tag || github.ref_name }}...${{ steps.changelog.outputs.to_tag || github.ref_name }}
make_latest: true
token: '${{ secrets.PERSONAL_ACCESS_TOKEN }}'
prerelease: ${{ contains(github.ref, '-alpha.') }}
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,8 @@
"prepublishOnly": "bun run build",
"check:wiring": "node scripts/check-capacitor-plugin-wiring.mjs",
"example:install": "cd example-app && bun install --frozen-lockfile",
"example:build": "bun run build && cd example-app && bun install --frozen-lockfile && bun run build"
"example:build": "bun run build && cd example-app && bun install --frozen-lockfile && bun run build",
"changelog:ai": "node scripts/generate-ai-changelog.mjs"
},
"dependencies": {
"localforage": "^1.10.0"
Expand Down
135 changes: 135 additions & 0 deletions scripts/generate-ai-changelog.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
#!/usr/bin/env node
/**
* Generate a release changelog with Cloudflare Workers AI in CI.
* Invoked from build.yml on tag releases via `bun run changelog:ai`.
*/

import { execFileSync } from 'node:child_process';
import { appendFileSync } from 'node:fs';

const DEFAULT_MODEL = '@cf/moonshotai/kimi-k2.7-code';

function parseArgs(argv) {
const args = { fromTag: process.env.FROM_TAG, toTag: process.env.TO_TAG };
for (let i = 0; i < argv.length; i++) {
if (argv[i] === '--from-tag') args.fromTag = argv[++i];
if (argv[i] === '--to-tag') args.toTag = argv[++i];
if (argv[i] === '--model') args.model = argv[++i];
}
return args;
}

function git(args) {
return execFileSync('/usr/bin/git', args, { encoding: 'utf8' }).trim();
}

function resolveTags({ fromTag, toTag }) {
const currentTag =
toTag ??
(process.env.GITHUB_REF?.startsWith('refs/tags/') ? process.env.GITHUB_REF.replace('refs/tags/', '') : null);

if (!currentTag) {
throw new Error('Missing target tag. Set GITHUB_REF to refs/tags/<tag> or pass --to-tag.');
}

const previousTag = fromTag ?? git(['describe', '--tags', '--abbrev=0', `${currentTag}^`]);
return { previousTag, currentTag };
}

function buildPrompt(previousTag, currentTag) {
const commits = git(['log', `${previousTag}..${currentTag}`, '--pretty=format:%s']);
const diffLines = git(['diff', '--stat', `${previousTag}..${currentTag}`]).split('\n');
const diffStat = diffLines.at(-1)?.trim() ?? '';

return `You are a technical writer creating a changelog for a software project.

There are changes in the git repository between two points:
- From tag: ${previousTag}
- To tag: ${currentTag}
- Commits:
${commits || '(none)'}
- Diff summary: ${diffStat || '(no file changes)'}

## Instructions:
1. **Categorize changes** using standard changelog categories:
- **Added**: New features
- **Changed**: Changes in existing functionality
- **Deprecated**: Soon-to-be removed features
- **Removed**: Removed features
- **Fixed**: Bug fixes
- **Security**: Security improvements

2. **Write clear, user-focused descriptions** that explain:
- What changed from a user's perspective
- Why it matters
- Any breaking changes or migration notes

3. **Use consistent formatting**:
- Each item should be a concise bullet point
- Start with an action verb when possible
- Group related changes together

4. **Focus on semantic meaning** rather than technical implementation details

Output ONLY the changelog content in markdown format. Do NOT include any explanatory text, metadata, introductory phrases like "Based on the git analysis" or "Here's the changelog", or concluding remarks. Start IMMEDIATELY with the changelog categories and items (e.g., "## Added"). Do not include version numbers or dates. Do not preface your response with any text whatsoever.`;
}

async function generateChangelog(prompt, model) {
const accountId = process.env.CLOUDFLARE_ACCOUNT_ID;
const apiToken = process.env.CLOUDFLARE_API_TOKEN;

if (!accountId || !apiToken) {
throw new Error('CLOUDFLARE_ACCOUNT_ID and CLOUDFLARE_API_TOKEN are required.');
}

const url = `https://api.cloudflare.com/client/v4/accounts/${accountId}/ai/run/${model}`;
const response = await fetch(url, {
method: 'POST',
headers: {
Authorization: `Bearer ${apiToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
messages: [{ role: 'user', content: prompt }],
}),
});

const payload = await response.json();
if (!response.ok || !payload.success) {
throw new Error(`Cloudflare AI request failed with status ${response.status}`);
}

const content = payload.result?.choices?.[0]?.message?.content ?? payload.result?.response;
if (!content || typeof content !== 'string') {
throw new Error('Cloudflare AI returned an empty changelog response');
}

return content.trim();
}

function writeGithubOutput({ result, fromTag, toTag }) {
const outputFile = process.env.GITHUB_OUTPUT;
if (!outputFile) return;

const delimiter = `changelog_${Date.now()}`;
appendFileSync(outputFile, `result<<${delimiter}\n${result}\n${delimiter}\n`);
appendFileSync(outputFile, `from_tag=${fromTag}\n`);
appendFileSync(outputFile, `to_tag=${toTag}\n`);
}

const args = parseArgs(process.argv.slice(2));
const model = args.model ?? process.env.CLOUDFLARE_AI_MODEL ?? DEFAULT_MODEL;
const { previousTag, currentTag } = resolveTags(args);
const prompt = buildPrompt(previousTag, currentTag);

console.error(`Generating changelog with ${model}`);
console.error(`Range: ${previousTag}..${currentTag}`);

try {
const result = await generateChangelog(prompt, model);
writeGithubOutput({ result, fromTag: previousTag, toTag: currentTag });
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
console.error(`Changelog generation failed: ${message}`);
process.exit(1);
}
Loading