diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
new file mode 100644
index 0000000..1cfaf99
--- /dev/null
+++ b/.github/workflows/tests.yml
@@ -0,0 +1,23 @@
+name: Tests
+
+on:
+ push:
+ branches:
+ - master
+ pull_request:
+
+jobs:
+ python-tests:
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v5
+
+ - name: Set up Python
+ uses: actions/setup-python@v6
+ with:
+ python-version: '3.11'
+
+ - name: Run tests
+ run: python3 -m unittest discover -s tests -v
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..1b7359c
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,7 @@
+# Agent Guidelines
+
+- Keep changes small and focused.
+- Follow the existing code style.
+- Prefer simple, clear solutions.
+- Run relevant checks when possible.
+- Do not modify unrelated files.
diff --git a/README.md b/README.md
index a532e57..00b98ec 100644
--- a/README.md
+++ b/README.md
@@ -1,206 +1,106 @@
# Changelog Action
-Github Action to generate a `CHANGELOG.md` on new tags.
-This action is split into four steps:
-1. [Prepare](#prepare-action) - Collects tag data, PRs, commits, and repository statistics.
-2. [Generate](#generate-action) - Uses Claude Code AI to generate a human-readable changelog.
-3. [Notify](#notify-action) - Reformats the changelog with Claude Code AI and sends it via Telegram.
-4. [Annotate](#annotate-action) - Annotates the new tag on tracking platforms (e.g. Mixpanel).
+GitHub Action to generate a `CHANGELOG.md` on new tags, with optional Telegram notifications and Mixpanel annotations.
-## Prepare Action
+## Breaking changes in 2.x
-### Inputs
+- The action now lives at the repository root and is used as `Waveful/ChangelogAction@2`.
+- The old split actions (`/generate`, `/notify`, `/annotate`) have been removed from the default setup.
+- Changelog generation is always the default behavior.
+- Telegram notification only runs when `telegram-bot-token`, `telegram-chat-id`, and `changelog-url` are all provided.
+- Mixpanel annotation only runs when `mixpanel-project-id` and `mixpanel-token` are provided.
+
+## Inputs
| Name | Description | Value Type | Required | Default Value |
|------|-------------|------------|----------|---------------|
-| `github-token` | GitHub Token for Auth | string | Yes | - |
-
-### Outputs
+| `target-branch` | Branch where `CHANGELOG.md` will be committed | string | No | `master` |
+| `github-token` | GitHub token for checkout, PR queries, and push | string | Yes | - |
+| `github-username` | Git author name for the changelog commit | string | No | `Dolful` |
+| `github-email` | Git author email for the changelog commit | string | No | `github-bot@waveful.app` |
+| `openai-base-url` | Base URL for an OpenAI-compatible provider | string | No | `https://api.openai.com/v1` |
+| `openai-api-key` | API key for the configured provider | string | Yes | - |
+| `openai-model` | Model name exposed by the configured provider | string | Yes | - |
+| `openai-temperature` | Sampling temperature for changelog generation | string | No | `0.2` |
+| `openai-auth-header` | Authentication header name for advanced providers | string | No | `Authorization` |
+| `openai-auth-prefix` | Prefix prepended to the API key in the auth header | string | No | `Bearer ` |
+| `previous-tag` | Optional previous tag override for manual runs | string | No | empty |
+| `current-tag` | Optional current tag override for manual runs | string | No | empty |
+| `telegram-bot-token` | Telegram bot token for optional notifications | string | No | empty |
+| `telegram-chat-id` | Telegram chat ID for optional notifications | string | No | empty |
+| `changelog-url` | Public changelog URL used in optional Telegram notifications | string | No | empty |
+| `mixpanel-tag-group` | Mixpanel annotation group for optional release annotations | string | No | `github-action` |
+| `mixpanel-project-id` | Mixpanel project ID for optional release annotations | string | No | empty |
+| `mixpanel-token` | Mixpanel token (Basic Auth) for optional release annotations | string | No | empty |
+| `mixpanel-region-domain` | Mixpanel region domain for optional release annotations | string | No | `mixpanel` |
+
+## Outputs
| Name | Description |
|------|-------------|
-| `previous-tag` | Previous tag |
-| `current-tag` | Current tag |
+| `previous-tag` | Previous tag used for the release range |
+| `current-tag` | Current tag used for the release range |
| `pr-count` | Number of PRs merged |
| `commit-count` | Number of commits |
| `author-count` | Number of unique authors |
| `files-changed` | Number of files changed |
| `additions` | Number of lines added |
| `deletions` | Number of lines deleted |
-| `prs` | PR details with commits and AI summaries (base64 encoded) |
-| `commits` | All commits including those not in PRs (base64 encoded, excludes automated changelog commits) |
-
-Note: the `prepare` action automatically excludes commits generated by the `generate` action (`docs: update changelog for ...`) so they are not fed back into the next AI changelog generation.
-
-## Generate Action
-
-### Inputs
-
-| Name | Description | Value Type | Required | Default Value |
-|------|-------------|------------|----------|---------------|
-| `target-branch` | Target branch | string | No | `master` |
-| `github-token` | GitHub Token for Auth | string | Yes | - |
-| `github-username` | GitHub Username | string | No | `Dolful` |
-| `github-email` | GitHub Email | string | No | `github-bot@waveful.app` |
-| `claude-code-oauth-token` | Claude Code OAuth Token | string | Yes | - |
-| `previous-tag` | Previous tag | string | Yes | - |
-| `current-tag` | Current tag | string | Yes | - |
-| `prs` | PR details (base64 encoded, from prepare step) | string | Yes | - |
-| `commits` | All commits (base64 encoded, from prepare step) | string | Yes | - |
-
-### Outputs
-
-| Name | Description |
-|------|-------------|
-| `changelog` | Changelog (base64 encoded) |
-
-## Notify Action
-
-### Inputs
-
-| Name | Description | Value Type | Required | Default Value |
-|------|-------------|------------|----------|---------------|
-| `changelog` | Changelog (base64 encoded, from generate step) | string | Yes | - |
-| `claude-code-oauth-token` | Claude Code OAuth Token | string | Yes | - |
-| `telegram-bot-token` | Telegram Bot Token | string | Yes | - |
-| `telegram-chat-id` | Telegram Chat ID for notification | string | Yes | - |
-| `changelog-url` | URL to the changelog file | string | Yes | - |
-
-### Outputs
-
-This action does not produce any outputs. It sends a notification to Telegram with the changelog summary.
-
-## Annotate Action
-
-### Inputs
-
-| Name | Description | Value Type | Required | Default Value |
-|------|-------------|------------|----------|---------------|
-| `tag` | Tag to annotate | string | Yes | - |
-| `mixpanel-tag-group` | Mixpanel annotation group | string | No | `github-action` |
-| `mixpanel-project-id` | Mixpanel Project ID | string | No | - |
-| `mixpanel-token` | Mixpanel Token (Basic Auth) | string | No | - |
-| `mixpanel-region-domain` | Mixpanel Region Domain | string | No | `mixpanel` |
-
-### Outputs
-
-This action does not produce any outputs. It creates an annotation on the configured tracking platform(s).
-
## Usage
-###### prepare.yml
+
```yaml
-name: Prepare
+name: Changelog
on:
push:
tags:
- '*'
-permissions:
- contents: read
- pull-requests: read
- actions: write
-
-jobs:
- prepare-data:
- runs-on: ubuntu-latest
- name: Prepare PRs, Commits and Statistics
- steps:
-
- - name: Checkout
- uses: actions/checkout@v5
- with:
- fetch-depth: 0 # important! fetches full history & tags
-
- - name: Prepare Data
- id: prepare
- uses: Waveful/ChangelogAction/prepare@1.2.2
- with:
- github-token: ${{ secrets.GITHUB_TOKEN }}
-
- - name: Print Statistics
- id: statistics
- run: |
- echo "Authors: ${{ steps.prepare.outputs.author-count }}"
- echo "PRs: ${{ steps.prepare.outputs.pr-count }}"
- echo "Commits: ${{ steps.prepare.outputs.commit-count }}"
- echo "File changed: ${{ steps.prepare.outputs.files-changed }}"
- echo "Additions / Deletions: ${{ steps.prepare.outputs.additions }} / ${{ steps.prepare.outputs.deletions }}"
-
- - name: Trigger Changelog
- id: trigger-changelog
- env:
- GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- run: |
- gh workflow run changelog.yml \
- -f from-ref="${{ steps.prepare.outputs.previous-tag }}" \
- -f to-ref="${{ steps.prepare.outputs.current-tag }}" \
- -f commits="${{ steps.prepare.outputs.commits }}" \
- -f prs="${{ steps.prepare.outputs.prs }}"
-```
-
-###### changelog.yml
-```yaml
-name: Changelog
-
-on:
- workflow_dispatch:
- inputs:
- from-ref:
- required: true
- type: string
- to-ref:
- required: true
- type: string
- commits:
- required: true
- type: string
- prs:
- required: true
- type: string
-
permissions:
contents: write
- id-token: write
+ pull-requests: read
jobs:
changelog:
runs-on: ubuntu-latest
- name: Generate changelog
- steps:
-
- - name: Checkout
- uses: actions/checkout@v5
- - name: Changelog
+ steps:
+ - name: Run changelog action
id: changelog
- uses: Waveful/ChangelogAction/generate@1.2.2
+ uses: Waveful/ChangelogAction@2
with:
- target-branch: master
- claude-code-oauth-token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
github-token: ${{ secrets.GITHUB_TOKEN }}
- previous-tag: ${{ inputs.from-ref }}
- current-tag: ${{ inputs.to-ref }}
- commits: ${{ inputs.commits }}
- prs: ${{ inputs.prs }}
+ target-branch: master
+ openai-base-url: ${{ secrets.OPENAI_BASE_URL }}
+ openai-api-key: ${{ secrets.OPENAI_API_KEY }}
+ openai-model: ${{ vars.OPENAI_MODEL }}
+
+ telegram-bot-token: ${{ secrets.TELEGRAM_BOT_TOKEN }}
+ telegram-chat-id: ${{ secrets.TELEGRAM_CHAT_ID }}
+ changelog-url: 'https://github.com/your-org/your-repo/blob/master/CHANGELOG.md'
- - name: Annotate
- uses: Waveful/ChangelogAction/annotate@1.2.2
- with:
- tag: ${{ inputs.to-ref }}
mixpanel-tag-group: github-action
mixpanel-project-id: ${{ secrets.MIXPANEL_PROJECT_ID }}
mixpanel-token: ${{ secrets.MIXPANEL_TOKEN }}
- - name: Notify
- uses: Waveful/ChangelogAction/notify@1.2.2
- with:
- changelog: ${{ steps.changelog.outputs.changelog }}
- claude-code-oauth-token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
- telegram-bot-token: ${{ secrets.TELEGRAM_BOT_TOKEN }}
- telegram-chat-id: ${{ secrets.TELEGRAM_CHAT_ID }}
- changelog-url: 'https://github.com/your-org/your-repo/blob/master/CHANGELOG.md'
+ - name: Print statistics
+ run: |
+ echo "Authors: ${{ steps.changelog.outputs.author-count }}"
+ echo "PRs: ${{ steps.changelog.outputs.pr-count }}"
+ echo "Commits: ${{ steps.changelog.outputs.commit-count }}"
+ echo "Files changed: ${{ steps.changelog.outputs.files-changed }}"
+ echo "Additions / Deletions: ${{ steps.changelog.outputs.additions }} / ${{ steps.changelog.outputs.deletions }}"
```
+If you use a provider that needs a custom auth header, set `openai-auth-header` and `openai-auth-prefix` accordingly. For example, Azure-style setups can use `openai-auth-header: api-key` and an empty `openai-auth-prefix`.
+
+## Behavior
+
+- The action always generates the changelog entry and updates `CHANGELOG.md`.
+- Telegram notification is skipped unless all Telegram inputs are provided.
+- Mixpanel annotation is skipped unless both Mixpanel credentials are provided.
+- Commits generated by the action (`docs: update changelog for ...`) are excluded from future changelog prompts.
+
## Credits
+
_ChangelogAction_ is made with ♥ by [Waveful](https://github.com/Waveful) and it's released under the [Apache License 2.0](./LICENSE).
diff --git a/action.yml b/action.yml
new file mode 100644
index 0000000..1027af1
--- /dev/null
+++ b/action.yml
@@ -0,0 +1,450 @@
+name: 'Changelog'
+description: 'Generate a changelog and optionally notify Telegram or annotate Mixpanel'
+
+inputs:
+ target-branch:
+ description: 'Branch where CHANGELOG.md will be committed'
+ default: master
+ github-token:
+ description: 'GitHub token for checkout, PR queries, and push'
+ required: true
+ github-username:
+ description: 'Git author name for the changelog commit'
+ default: Dolful
+ github-email:
+ description: 'Git author email for the changelog commit'
+ default: github-bot@waveful.app
+ openai-base-url:
+ description: 'OpenAI-compatible API base URL'
+ default: https://api.openai.com/v1
+ openai-api-key:
+ description: 'OpenAI-compatible API key'
+ required: true
+ openai-model:
+ description: 'Model name exposed by the configured provider'
+ required: true
+ openai-temperature:
+ description: 'Sampling temperature for the changelog request'
+ default: '0.2'
+ openai-auth-header:
+ description: 'HTTP header used for provider authentication'
+ default: Authorization
+ openai-auth-prefix:
+ description: 'Prefix added before the API key in the auth header'
+ default: 'Bearer '
+ previous-tag:
+ description: 'Optional previous tag override'
+ required: false
+ default: ''
+ current-tag:
+ description: 'Optional current tag override'
+ required: false
+ default: ''
+ telegram-bot-token:
+ description: 'Telegram bot token for optional notifications'
+ required: false
+ default: ''
+ telegram-chat-id:
+ description: 'Telegram chat ID for optional notifications'
+ required: false
+ default: ''
+ changelog-url:
+ description: 'Public changelog URL used in optional Telegram notifications'
+ required: false
+ default: ''
+ mixpanel-tag-group:
+ description: 'Mixpanel annotation group for optional release annotations'
+ required: false
+ default: github-action
+ mixpanel-project-id:
+ description: 'Mixpanel project ID for optional release annotations'
+ required: false
+ default: ''
+ mixpanel-token:
+ description: 'Mixpanel token (Basic Auth) for optional release annotations'
+ required: false
+ default: ''
+ mixpanel-region-domain:
+ description: 'Mixpanel region domain for optional release annotations'
+ required: false
+ default: mixpanel
+
+outputs:
+ previous-tag:
+ description: 'Previous tag used for the release range'
+ value: ${{ steps.tag_range.outputs.previous-tag }}
+ current-tag:
+ description: 'Current tag used for the release range'
+ value: ${{ steps.tag_range.outputs.current-tag }}
+ pr-count:
+ description: 'Number of pull requests included in the release range'
+ value: ${{ steps.release_data.outputs.pr_count }}
+ commit-count:
+ description: 'Number of commits included in the release range'
+ value: ${{ steps.release_data.outputs.commit_count }}
+ author-count:
+ description: 'Number of unique authors included in the release range'
+ value: ${{ steps.release_data.outputs.author_count }}
+ files-changed:
+ description: 'Number of changed files in the release range'
+ value: ${{ steps.release_data.outputs.files_changed }}
+ additions:
+ description: 'Number of added lines in the release range'
+ value: ${{ steps.release_data.outputs.additions }}
+ deletions:
+ description: 'Number of deleted lines in the release range'
+ value: ${{ steps.release_data.outputs.deletions }}
+
+runs:
+ using: 'composite'
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v5
+ with:
+ fetch-depth: 0
+ token: ${{ inputs.github-token }}
+
+ - name: Resolve tag range
+ id: tag_range
+ shell: bash
+ run: |
+ set -euo pipefail
+
+ git fetch --force --tags origin
+
+ CURRENT_TAG="${{ inputs.current-tag }}"
+ if [ -z "$CURRENT_TAG" ]; then
+ if [[ "${GITHUB_REF:-}" == refs/tags/* ]]; then
+ CURRENT_TAG="${GITHUB_REF#refs/tags/}"
+ else
+ echo "current-tag input is required when the workflow is not triggered by a tag push"
+ exit 1
+ fi
+ fi
+
+ if ! git rev-parse "$CURRENT_TAG^{tag}" >/dev/null 2>&1 && ! git rev-parse "$CURRENT_TAG^{commit}" >/dev/null 2>&1; then
+ echo "Tag '$CURRENT_TAG' was not found in the repository history"
+ exit 1
+ fi
+
+ PREVIOUS_TAG="${{ inputs.previous-tag }}"
+ if [ -z "$PREVIOUS_TAG" ]; then
+ PREVIOUS_TAG=$(git tag --sort=creatordate | awk -v current="$CURRENT_TAG" '$0 == current { print previous; exit } { previous = $0 }')
+ fi
+
+ if [ -z "$PREVIOUS_TAG" ]; then
+ echo "Unable to determine a previous tag for '$CURRENT_TAG'"
+ exit 1
+ fi
+
+ echo "previous-tag=$PREVIOUS_TAG" >> "$GITHUB_OUTPUT"
+ echo "current-tag=$CURRENT_TAG" >> "$GITHUB_OUTPUT"
+
+ - name: Collect release data
+ id: release_data
+ shell: bash
+ env:
+ GH_TOKEN: ${{ inputs.github-token }}
+ run: |
+ set -euo pipefail
+
+ WORK_DIR="${RUNNER_TEMP}/changelog-action"
+ mkdir -p "$WORK_DIR"
+
+ FROM_REF="${{ steps.tag_range.outputs.previous-tag }}"
+ TO_REF="${{ steps.tag_range.outputs.current-tag }}"
+ CHANGELOG_BOT_COMMIT_PATTERN='^docs: update changelog for '
+
+ MERGE_PR_NUMBERS=$(git log "$FROM_REF..$TO_REF" --grep="^Merge pull request #" --pretty=format:"%s" | grep -oE '#[0-9]+' | tr -d '#' | sort -u || true)
+ SQUASH_PR_NUMBERS=$(git log "$FROM_REF..$TO_REF" --pretty=format:"%s" | grep -oE '\(#[0-9]+\)' | grep -oE '[0-9]+' | sort -u || true)
+ ALL_PR_NUMBERS=$(printf '%s\n%s\n' "$MERGE_PR_NUMBERS" "$SQUASH_PR_NUMBERS" | sed '/^$/d' | sort -u)
+
+ {
+ echo "# Pull Request Details"
+ echo
+ echo "Range: $FROM_REF to $TO_REF"
+ echo
+ } > "$WORK_DIR/pr_details_full.md"
+
+ PR_COUNT=0
+ if [ -n "$ALL_PR_NUMBERS" ]; then
+ for pr_num in $ALL_PR_NUMBERS; do
+ PR_DATA=$(gh pr view "$pr_num" --repo "${GITHUB_REPOSITORY}" --json number,title,author,body,labels,mergedAt,commits,files 2>/dev/null || echo "{}")
+
+ if [ "$PR_DATA" = "{}" ] || [ "$PR_DATA" = "null" ]; then
+ continue
+ fi
+
+ PR_TITLE=$(echo "$PR_DATA" | jq -r '.title // "Unknown"')
+ PR_AUTHOR=$(echo "$PR_DATA" | jq -r '.author.login // "unknown"')
+ PR_BODY=$(echo "$PR_DATA" | jq -r '.body // ""')
+ PR_LABELS=$(echo "$PR_DATA" | jq -r '(.labels // []) | map(.name) | join(", ")')
+ PR_MERGED=$(echo "$PR_DATA" | jq -r '.mergedAt // ""')
+ FILE_COUNT=$(echo "$PR_DATA" | jq -r '.files | length')
+
+ {
+ echo "## PR #$pr_num - $PR_TITLE"
+ echo "Author: @$PR_AUTHOR"
+ if [ -n "$PR_LABELS" ]; then
+ echo "Labels: $PR_LABELS"
+ fi
+ if [ -n "$PR_MERGED" ]; then
+ echo "Merged: $PR_MERGED"
+ fi
+ echo
+ if [ -n "$PR_BODY" ]; then
+ echo "Body:"
+ printf '%s\n' "$PR_BODY" | head -n 40 | sed 's/^/> /'
+ echo
+ fi
+ echo "Commits:"
+ echo "$PR_DATA" | jq -r '.commits[:20] | .[] | "- \(.messageHeadline) by \(.authors[0].name // \"unknown\")"' 2>/dev/null || echo "- Unable to fetch commits"
+ echo
+ echo "Files Changed: $FILE_COUNT"
+ echo "$PR_DATA" | jq -r '.files[:15] | .[] | "- \(.path)"' 2>/dev/null || echo "- Unable to fetch file list"
+ if [ "$FILE_COUNT" -gt 15 ]; then
+ echo "- ... and $((FILE_COUNT - 15)) more files"
+ fi
+ echo
+ echo "---"
+ echo
+ } >> "$WORK_DIR/pr_details_full.md"
+
+ PR_COUNT=$((PR_COUNT + 1))
+ done
+ else
+ echo "No merged pull requests found in this range." >> "$WORK_DIR/pr_details_full.md"
+ fi
+
+ {
+ echo "# All Commits in Range"
+ echo
+ echo "Range: $FROM_REF to $TO_REF"
+ echo
+ git log "$FROM_REF..$TO_REF" --invert-grep --grep="$CHANGELOG_BOT_COMMIT_PATTERN" --pretty=format:'- %h - %s (%an) - %ar' --no-merges
+ } > "$WORK_DIR/commits_simple.md"
+
+ DIFF_STAT=$(git diff --shortstat "$FROM_REF..$TO_REF" || true)
+ COMMIT_COUNT=$(git rev-list --count "$FROM_REF..$TO_REF" --no-merges --invert-grep --grep="$CHANGELOG_BOT_COMMIT_PATTERN")
+ AUTHOR_COUNT=$(git log "$FROM_REF..$TO_REF" --invert-grep --grep="$CHANGELOG_BOT_COMMIT_PATTERN" --pretty=format:'%an' --no-merges | sort -u | wc -l | tr -d ' ')
+ FILES_CHANGED=$(git diff --name-only "$FROM_REF..$TO_REF" | sed '/^$/d' | wc -l | tr -d ' ')
+ ADDITIONS=$(printf '%s' "$DIFF_STAT" | grep -oE '[0-9]+ insertions?' | grep -oE '[0-9]+' || true)
+ DELETIONS=$(printf '%s' "$DIFF_STAT" | grep -oE '[0-9]+ deletions?' | grep -oE '[0-9]+' || true)
+
+ if [ -z "$ADDITIONS" ]; then
+ ADDITIONS=0
+ fi
+
+ if [ -z "$DELETIONS" ]; then
+ DELETIONS=0
+ fi
+
+ echo "pr_count=$PR_COUNT" >> "$GITHUB_OUTPUT"
+ echo "commit_count=$COMMIT_COUNT" >> "$GITHUB_OUTPUT"
+ echo "author_count=$AUTHOR_COUNT" >> "$GITHUB_OUTPUT"
+ echo "files_changed=$FILES_CHANGED" >> "$GITHUB_OUTPUT"
+ echo "additions=$ADDITIONS" >> "$GITHUB_OUTPUT"
+ echo "deletions=$DELETIONS" >> "$GITHUB_OUTPUT"
+
+ - name: Build LLM prompts
+ shell: bash
+ run: |
+ set -euo pipefail
+
+ WORK_DIR="${RUNNER_TEMP}/changelog-action"
+ mkdir -p "$WORK_DIR"
+
+ cat > "$WORK_DIR/system_prompt.txt" <<'EOF'
+ You write release changelogs for end users.
+ Return markdown only.
+ Start with a level-2 heading using the current tag.
+ Use the bullet symbol "•" for every list item.
+ Keep the language simple, concise, and understandable for non-technical readers.
+ Group related changes together when it improves readability.
+ Ignore automation-only changes unless they materially affect users.
+ Do not add any introduction, analysis notes, or closing text.
+ EOF
+
+ {
+ echo "Repository: ${GITHUB_REPOSITORY}"
+ echo "Tag range: ${{ steps.tag_range.outputs.previous-tag }} -> ${{ steps.tag_range.outputs.current-tag }}"
+ echo
+ echo "Write the changelog directly in this format:"
+ echo "## ${{ steps.tag_range.outputs.current-tag }}"
+ echo "• First bullet"
+ echo "• Second bullet"
+ echo
+ echo "Use both sources below so the changelog covers PRs and direct commits."
+ echo
+ echo "Pull Request Details:"
+ cat "$WORK_DIR/pr_details_full.md"
+ echo
+ echo "All Commits:"
+ cat "$WORK_DIR/commits_simple.md"
+ } > "$WORK_DIR/user_prompt.txt"
+
+ - name: Generate changelog
+ id: changelog
+ shell: bash
+ env:
+ OPENAI_BASE_URL: ${{ inputs.openai-base-url }}
+ OPENAI_API_KEY: ${{ inputs.openai-api-key }}
+ OPENAI_MODEL: ${{ inputs.openai-model }}
+ OPENAI_TEMPERATURE: ${{ inputs.openai-temperature }}
+ OPENAI_AUTH_HEADER: ${{ inputs.openai-auth-header }}
+ OPENAI_AUTH_PREFIX: ${{ inputs.openai-auth-prefix }}
+ run: |
+ set -euo pipefail
+
+ WORK_DIR="${RUNNER_TEMP}/changelog-action"
+ CHANGELOG_FILE="$WORK_DIR/changelog_entry.md"
+
+ export CHANGELOG_FILE
+
+ python3 "${{ github.action_path }}/scripts/openai_chat.py" \
+ --system-prompt-file "$WORK_DIR/system_prompt.txt" \
+ --user-prompt-file "$WORK_DIR/user_prompt.txt" \
+ --output-file "$CHANGELOG_FILE"
+
+ python3 - <<'PY'
+ import os
+ from pathlib import Path
+
+ path = Path(os.environ["CHANGELOG_FILE"])
+ content = path.read_text(encoding="utf-8").strip()
+ if not content:
+ raise SystemExit("The configured LLM returned an empty changelog.")
+ path.write_text(content + "\n", encoding="utf-8")
+
+ with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as github_output:
+ github_output.write(f"entry-file={path.resolve()}\n")
+ PY
+
+ - name: Update CHANGELOG.md
+ shell: bash
+ run: |
+ set -euo pipefail
+
+ git fetch origin "${{ inputs.target-branch }}"
+ git checkout "${{ inputs.target-branch }}"
+ git pull --ff-only origin "${{ inputs.target-branch }}"
+
+ python3 - <<'PY'
+ import os
+ from pathlib import Path
+
+ changelog_path = Path("CHANGELOG.md")
+ entry = Path(os.environ["CHANGELOG_FILE"]).read_text(encoding="utf-8").strip()
+
+ if changelog_path.exists():
+ current = changelog_path.read_text(encoding="utf-8")
+ else:
+ current = "# Changelog\n"
+
+ if current.startswith("# Changelog"):
+ header, _, remainder = current.partition("\n")
+ remainder = remainder.lstrip("\n")
+ updated = header + "\n\n" + entry + "\n"
+ if remainder:
+ updated += "\n" + remainder.rstrip() + "\n"
+ else:
+ updated = entry + "\n"
+ if current.strip():
+ updated += "\n" + current.strip() + "\n"
+
+ changelog_path.write_text(updated, encoding="utf-8")
+ PY
+ env:
+ CHANGELOG_FILE: ${{ steps.changelog.outputs.entry-file }}
+
+ - name: Commit and push changelog
+ shell: bash
+ run: |
+ set -euo pipefail
+
+ git add CHANGELOG.md
+
+ if git diff --cached --quiet; then
+ echo "No changelog changes to commit"
+ exit 0
+ fi
+
+ RELEASE_HASH=$(git rev-parse --short=7 "${{ steps.tag_range.outputs.current-tag }}^{commit}")
+ git -c user.name="${{ inputs.github-username }}" -c user.email="${{ inputs.github-email }}" commit -m "docs: update changelog for ${{ steps.tag_range.outputs.current-tag }} (${RELEASE_HASH})"
+ git push origin "${{ inputs.target-branch }}"
+
+ - name: Render Telegram message
+ if: inputs.telegram-bot-token != '' && inputs.telegram-chat-id != '' && inputs.changelog-url != ''
+ shell: bash
+ run: |
+ set -euo pipefail
+
+ WORK_DIR="${RUNNER_TEMP}/changelog-notify"
+ mkdir -p "$WORK_DIR"
+ cp "${{ steps.changelog.outputs.entry-file }}" "$WORK_DIR/changelog.txt"
+ python3 "${{ github.action_path }}/scripts/changelog_to_telegram_html.py" --input "$WORK_DIR/changelog.txt" --output "$WORK_DIR/telegram_message.html"
+
+ - name: Send changelog to Telegram
+ if: inputs.telegram-bot-token != '' && inputs.telegram-chat-id != '' && inputs.changelog-url != ''
+ shell: bash
+ run: |
+ set -euo pipefail
+
+ WORK_DIR="${RUNNER_TEMP}/changelog-notify"
+ TELEGRAM_MESSAGE=$(cat "$WORK_DIR/telegram_message.html")
+
+ FULL_TEXT="🔄 ${TELEGRAM_MESSAGE}
+
+ More details available on CHANGELOG."
+
+ JSON_PAYLOAD=$(jq -n \
+ --arg chat_id "${{ inputs.telegram-chat-id }}" \
+ --arg text "$FULL_TEXT" \
+ '{chat_id: $chat_id, text: $text, parse_mode: "HTML"}')
+
+ curl --fail --silent --show-error -X POST "https://api.telegram.org/bot${{ inputs.telegram-bot-token }}/sendMessage" \
+ -H "Content-Type: application/json" \
+ -d "$JSON_PAYLOAD"
+
+ - name: Create Mixpanel annotation tag
+ if: inputs.mixpanel-project-id != '' && inputs.mixpanel-token != ''
+ id: create_mixpanel_tag
+ shell: bash
+ run: |
+ set -euo pipefail
+
+ RESPONSE=$(curl --silent --request POST \
+ --url "https://${{ inputs.mixpanel-region-domain }}.com/api/app/projects/${{ inputs.mixpanel-project-id }}/annotations/tags" \
+ --header "accept: application/json" \
+ --header "authorization: Basic ${{ inputs.mixpanel-token }}" \
+ --header "content-type: application/json" \
+ --data '{"name":"${{ inputs.mixpanel-tag-group }}"}')
+
+ STATUS=$(echo "$RESPONSE" | jq -r '.status')
+ if [ "$STATUS" != "ok" ]; then
+ echo "Failed to create Mixpanel tag. Status: $STATUS"
+ exit 1
+ fi
+
+ TAG_ID=$(echo "$RESPONSE" | jq -r '.results.id')
+ if [ -z "$TAG_ID" ] || [ "$TAG_ID" = "null" ]; then
+ echo "Failed to extract Mixpanel tag ID"
+ exit 1
+ fi
+
+ echo "tag-id=$TAG_ID" >> "$GITHUB_OUTPUT"
+
+ - name: Create Mixpanel annotation
+ if: inputs.mixpanel-project-id != '' && inputs.mixpanel-token != '' && steps.create_mixpanel_tag.outputs.tag-id != ''
+ shell: bash
+ run: |
+ set -euo pipefail
+
+ ANNOTATION_DATE=$(date -u +"%Y-%m-%d %H:%M:%S")
+
+ curl --fail --silent --show-error --request POST \
+ --url "https://${{ inputs.mixpanel-region-domain }}.com/api/app/projects/${{ inputs.mixpanel-project-id }}/annotations" \
+ --header "accept: application/json" \
+ --header "authorization: Basic ${{ inputs.mixpanel-token }}" \
+ --header "content-type: application/json" \
+ --data "{\"tags\": [${{ steps.create_mixpanel_tag.outputs.tag-id }}], \"date\": \"$ANNOTATION_DATE\", \"description\": \"${{ steps.tag_range.outputs.current-tag }}\"}"
diff --git a/annotate/action.yml b/annotate/action.yml
deleted file mode 100644
index 26710c8..0000000
--- a/annotate/action.yml
+++ /dev/null
@@ -1,73 +0,0 @@
-name: 'Annotate'
-description: 'Annotate a new tag (e.g. on tracking platform)'
-
-inputs:
- tag:
- description: 'Version to annotate'
- required: true
- mixpanel-tag-group:
- description: 'Mixpanel Annotation Group'
- required: false
- default: 'github-action'
- mixpanel-project-id:
- description: 'Mixpanel Project ID'
- required: false
- mixpanel-token:
- description: 'Mixpanel Token (Basic Auth)'
- required: false
- mixpanel-region-domain:
- description: 'Mixpanel Region Domain'
- required: false
- default: 'mixpanel'
-
-runs:
- using: "composite"
- steps:
-
- - name: Create Mixpanel Annotation Tag
- if: inputs.mixpanel-project-id != '' && inputs.mixpanel-token != ''
- id: create-tag
- shell: bash
- # https://developer.mixpanel.com/reference/create-annotation-tag-1
- run: |
- RESPONSE=$(curl --silent --request POST \
- --url "https://${{ inputs.mixpanel-region-domain }}.com/api/app/projects/${{ inputs.mixpanel-project-id }}/annotations/tags" \
- --header "accept: application/json" \
- --header "authorization: Basic ${{ inputs.mixpanel-token }}" \
- --header "content-type: application/json" \
- --data '{"name":"${{ inputs.mixpanel-tag-group }}"}')
-
- echo "Tag response: $RESPONSE"
-
- STATUS=$(echo "$RESPONSE" | jq -r '.status')
- if [ "$STATUS" != "ok" ]; then
- echo "Failed to create tag. Status: $STATUS"
- exit 1
- fi
-
- TAG_ID=$(echo "$RESPONSE" | jq -r '.results.id')
- if [ -z "$TAG_ID" ] || [ "$TAG_ID" = "null" ]; then
- echo "Failed to extract tag ID from response"
- exit 1
- fi
-
- echo "Created tag with ID: $TAG_ID"
- echo "tag-id=$TAG_ID" >> $GITHUB_OUTPUT
-
- - name: Create Mixpanel Annotation
- if: inputs.mixpanel-project-id != '' && inputs.mixpanel-token != '' && steps.create-tag.outputs.tag-id != ''
- shell: bash
- # https://developer.mixpanel.com/reference/create-annotation
- run: |
- ANNOTATION_DATE=$(date -u +"%Y-%m-%d %H:%M:%S")
-
- curl --fail --silent --request POST \
- --url "https://${{ inputs.mixpanel-region-domain }}.com/api/app/projects/${{ inputs.mixpanel-project-id }}/annotations" \
- --header "accept: application/json" \
- --header "authorization: Basic ${{ inputs.mixpanel-token }}" \
- --header "content-type: application/json" \
- --data "{\"tags\": [${{ steps.create-tag.outputs.tag-id }}], \"date\": \"$ANNOTATION_DATE\", \"description\": \"${{ inputs.tag }}\"}"
-
- curlExitCode=$?
- echo "Annotation created on Mixpanel for tag: ${{ inputs.tag }}"
- exit $curlExitCode
diff --git a/generate/action.yml b/generate/action.yml
deleted file mode 100644
index ea7b77e..0000000
--- a/generate/action.yml
+++ /dev/null
@@ -1,212 +0,0 @@
-name: 'Changelog'
-description: 'Generate a changelog for a given tag'
-
-inputs:
- target-branch:
- description: 'Target Branch'
- default: master
- github-token:
- description: 'GitHub Token for authentication'
- required: true
- github-username:
- description: 'GitHub Username'
- default: Dolful
- github-email:
- description: 'GitHub Email'
- default: github-bot@waveful.app
- claude-code-oauth-token:
- description: 'Claude Code OAuth Token'
- required: true
- previous-tag:
- description: 'Previous Tag'
- required: true
- current-tag:
- description: 'Current Tag'
- required: true
- prs:
- description: 'PR Details with Commits and AI Summaries'
- required: true
- commits:
- description: 'All Commits (including those not in PRs)'
- required: true
-
-outputs:
- changelog:
- description: "Changelog (base64 encoded)"
- value: ${{ steps.changelog.outputs.changelog }}
-
-runs:
- using: "composite"
- steps:
-
- - name: Decode PRs and Commits
- shell: bash
- run: |
- echo "${{ inputs.prs }}" | base64 -d > prs.txt
- echo "${{ inputs.commits }}" | base64 -d > commits.txt
-
- - name: Print PRs and Commits
- shell: bash
- run: |
- echo "PRs: $(cat prs.txt 2>/dev/null || echo "No PR details available")"
- echo "Commits: $(cat commits.txt 2>/dev/null || echo "No commit details available")"
-
- - name: Generate changelog with Claude
- id: claude
- uses: anthropics/claude-code-action@v1
- with:
- claude_code_oauth_token: ${{ inputs.claude-code-oauth-token }}
- allowed_bots: '*'
- prompt: |
- Write ONLY the changelog using Markdown formatting.
- Start directly with tag in Header 2 (##) followed by a list of changes in bullet points. See example below:
-
- Example:
-
- ## ${{ inputs.current-tag }}
- • Use bullet points with • symbol for changes (UI, features, fixes).
- • Second bullet point.
- • Third and last bullet point of the example.
-
-
- IMPORTANT:
- - DO NOT add introductions or explanations before the changelog
- - DO NOT say "Analyzing commits..." or similar phrases
- - Start directly with "## ${{ inputs.current-tag }}"
- - Use Markdown formatting: **text** for bold, • for bullet points
- - Use • symbol (not - or *) for all bullet points
- - Add blank lines between sections for better readability
- - Ignore workflow/automation commits, focus only on core changes
- - Write in simple English
- - Keep bullet points concise and understandable also for non-technical users
- - Do NOT escape Markdown characters (** ** for bold, * * for italic, etc.) - leave them as is
-
- Pull Request Details with Commits and AI Summaries:
- $(cat prs.txt 2>/dev/null || echo "No PR details available")
-
- All Commits (including those not in PRs):
- $(cat commits.txt 2>/dev/null || echo "No commit details available")
-
- IMPORTANT: You have two sources of information:
- 1. PR details with body descriptions - these explain what was changed in PRs
- 2. Complete commit list - includes ALL changes, even those not in PRs
-
- The commit list helps you catch:
- - Direct commits to the branch
- - Hotfixes not done through PRs
- - Small tweaks and adjustments
- - Configuration changes
-
- Use BOTH sources to create a complete changelog. The PR descriptions explain the major features,
- while the commit list ensures nothing is missed.
- Focus on core changes and what shouldn't be missed.
- Group related changes together for better readability.
-
- Write ONLY the direct changelog using Markdown formatting, nothing else.
-
-
- - name: Extract Claude result
- id: extract
- uses: actions/github-script@v8
- with:
- result-encoding: string
- script: |
- const fs = require('fs');
- const path = '${{ steps.claude.outputs.execution_file }}';
-
- try {
- if (fs.existsSync(path)) {
- console.log('Found Claude execution output file');
- const jsonString = fs.readFileSync(path, 'utf8');
- const data = JSON.parse(jsonString);
-
- // Debug: Show the structure of the JSON
- console.log('JSON is an array with', data.length, 'items');
-
- // Find the object with type "result" and subtype "success"
- const resultObj = data.find(item =>
- item.type === 'result' &&
- item.subtype === 'success' &&
- item.result &&
- typeof item.result === 'string'
- );
-
- if (resultObj) {
- console.log('Found result object with content length:', resultObj.result.length);
- return resultObj.result;
- } else {
- console.log('No result object found. Available types:');
- data.forEach((item, index) => {
- console.log(` ${index}: type="${item.type}", subtype="${item.subtype}"`);
- });
- return '';
- }
- } else {
- console.log('Claude execution output file not found');
- return '';
- }
- } catch (err) {
- console.error('Error reading Claude output:', err.message);
- return '';
- }
-
-
- - name: Update CHANGELOG.md
- id: changelog
- shell: bash
- run: |
-
- # Get Claude output from previous step and save to file safely
- cat > changelog_entry.txt << 'EOF'
- ${{ steps.extract.outputs.result }}
- EOF
-
- # Check if we got valid content
- if [ ! -s changelog_entry.txt ] || grep -q "automated changelog generation failed" changelog_entry.txt; then
- echo "No Claude output available, using fallback"
- cat > changelog_entry.txt << EOF
- Automated changelog generation failed
- EOF
- else
- echo "Using Claude output successfully"
- fi
-
- # Clean up trailing whitespace but keep intentional blank lines
- sed -i 's/[[:space:]]\+$//' changelog_entry.txt
-
- # Save PRs to outputs
- echo "changelog=$(base64 -i changelog_entry.txt | tr -d '\n')" >> $GITHUB_OUTPUT
-
- # Debug: Show final content
- echo "=== DEBUG: Final changelog content ==="
- cat changelog_entry.txt
- echo "====================================="
-
- # Create CHANGELOG.md if it doesn't exist.
- if [ ! -f CHANGELOG.md ]; then
- echo "# Changelog" > CHANGELOG.md
- echo "" >> CHANGELOG.md
- fi
-
- # Prepend new entry after the header.
- if grep -q "^# Changelog" CHANGELOG.md; then
- sed -i '1 r changelog_entry.txt' CHANGELOG.md
- sed -i '2d' CHANGELOG.md
- else
- cat changelog_entry.txt CHANGELOG.md > temp.md
- mv temp.md CHANGELOG.md
- fi
-
- - name: Commit and push changelog
- shell: bash
- run: |
- git config user.name "${{ inputs.github-username }}"
- git config user.email "${{ inputs.github-email }}"
- git remote set-url origin https://x-access-token:${{ inputs.github-token }}@github.com/${GITHUB_REPOSITORY}.git
- git fetch origin ${{ inputs.target-branch }} >/dev/null
- git checkout ${{ inputs.target-branch }}
- git pull origin ${{ inputs.target-branch }}
- git add CHANGELOG.md
- COMMIT_HASH=$(git rev-parse --short=7 HEAD)
- git commit -m "docs: update changelog for ${{ inputs.current-tag }} (${COMMIT_HASH})"
- git push origin ${{ inputs.target-branch }}
diff --git a/notify/action.yml b/notify/action.yml
deleted file mode 100644
index 87cc7d4..0000000
--- a/notify/action.yml
+++ /dev/null
@@ -1,125 +0,0 @@
-name: 'Notify'
-description: 'Notify about a new tag'
-
-inputs:
- changelog:
- description: 'Changelog (base64 encoded)'
- required: true
- claude-code-oauth-token:
- description: 'Claude Code OAuth Token'
- required: true
- telegram-bot-token:
- description: 'Telegram Bot Token'
- required: true
- telegram-chat-id:
- description: 'Telegram Chat ID for notification'
- required: true
- changelog-url:
- description: 'URL to the changelog file'
- required: true
-
-runs:
- using: "composite"
- steps:
-
- - name: Decode Changelog
- shell: bash
- run: |
- echo "${{ inputs.changelog }}" | base64 -d > changelog.txt
-
- - name: Print Changelog
- shell: bash
- run: |
- echo "Changelog: $(cat changelog.txt 2>/dev/null || echo "No changelog available")"
-
- - name: Summarize Changelog with Claude
- id: claude
- uses: anthropics/claude-code-action@v1
- with:
- claude_code_oauth_token: ${{ inputs.claude-code-oauth-token }}
- allowed_bots: '*'
- prompt: |
- Rewrite the changelog using simple HTML format.
- It will be used as Telegram message.
-
- IMPORTANT:
- - Avoid any character that may break the telegram message in HTML format.
- - DO NOT add introductions or explanations.
- - DO NOT say "Analyzing commits..." or similar phrases.
- - Start directly with the tag name.
- - Then write a list of bullet points about the changes.
- - Write in simple English.
- - Keep bullet points concise and understandable also for non-technical users.
-
- Changelog:
- $(cat changelog.txt 2>/dev/null || echo "No changelog available")
-
- - name: Extract Claude result
- id: extract
- uses: actions/github-script@v8
- with:
- result-encoding: string
- script: |
- const fs = require('fs');
- const path = '${{ steps.claude.outputs.execution_file }}';
-
- try {
- if (fs.existsSync(path)) {
- console.log('Found Claude execution output file');
- const jsonString = fs.readFileSync(path, 'utf8');
- const data = JSON.parse(jsonString);
-
- // Debug: Show the structure of the JSON
- console.log('JSON is an array with', data.length, 'items');
-
- // Find the object with type "result" and subtype "success"
- const resultObj = data.find(item =>
- item.type === 'result' &&
- item.subtype === 'success' &&
- item.result &&
- typeof item.result === 'string'
- );
-
- if (resultObj) {
- console.log('Found result object with content length:', resultObj.result.length);
- return resultObj.result;
- } else {
- console.log('No result object found. Available types:');
- data.forEach((item, index) => {
- console.log(` ${index}: type="${item.type}", subtype="${item.subtype}"`);
- });
- return '';
- }
- } else {
- console.log('Claude execution output file not found');
- return '';
- }
- } catch (err) {
- console.error('Error reading Claude output:', err.message);
- return '';
- }
-
- - name: Send changelog to Telegram
- if: success()
- shell: bash
- run: |
- TELEGRAM_MESSAGE=$(cat <<'ENDOFMESSAGE'
- ${{ steps.extract.outputs.result }}
- ENDOFMESSAGE
- )
-
- FULL_TEXT="🔄 ${TELEGRAM_MESSAGE}
-
- More details available on CHANGELOG."
-
- JSON_PAYLOAD=$(jq -n \
- --arg chat_id "${{ inputs.telegram-chat-id }}" \
- --arg text "$FULL_TEXT" \
- '{chat_id: $chat_id, text: $text, parse_mode: "HTML"}')
-
- curl -s -X POST "https://api.telegram.org/bot${{ inputs.telegram-bot-token }}/sendMessage" \
- -H "Content-Type: application/json" \
- -d "$JSON_PAYLOAD"
- sendExitCode=$?
- echo "Changelog sent to Telegram"
- exit $sendExitCode
diff --git a/prepare/action.yml b/prepare/action.yml
deleted file mode 100644
index 41883fe..0000000
--- a/prepare/action.yml
+++ /dev/null
@@ -1,177 +0,0 @@
-name: 'Retrieve Tag Data'
-description: 'Retrieve some useful data for a given tag'
-
-inputs:
- github-token:
- description: 'GitHub Token for authentication'
- required: true
-
-outputs:
- previous-tag:
- description: "Previous tag"
- value: ${{ steps.tag_info.outputs.previous }}
- current-tag:
- description: "Current tag"
- value: ${{ steps.tag_info.outputs.current }}
- pr-count:
- description: "PRs"
- value: ${{ steps.prs.outputs.pr_count }}
- commit-count:
- description: "Commits"
- value: ${{ steps.prs.outputs.commit_count }}
- author-count:
- description: "Authors"
- value: ${{ steps.prs.outputs.author_count }}
- files-changed:
- description: "Files changed"
- value: ${{ steps.prs.outputs.files_changed }}
- additions:
- description: "Additions"
- value: ${{ steps.prs.outputs.additions }}
- deletions:
- description: "Deletions"
- value: ${{ steps.prs.outputs.deletions }}
- prs:
- description: "PR Details with Commits and AI Summaries"
- value: ${{ steps.prs.outputs.prs }}
- commits:
- description: "All Commits (including those not in PRs)"
- value: ${{ steps.prs.outputs.commits }}
-
-runs:
- using: "composite"
- steps:
-
- - name: Get repository
- uses: actions/checkout@v5
- with:
- fetch-depth: 0
-
- - name: Get tags
- id: tag_info
- shell: bash
- run: |
- git ls-remote --tags --refs origin | awk '{print $2}' | sed 's#^refs/tags/##' | grep -v -F -x -f <(git tag -l) | xargs -r -I{} git fetch origin "refs/tags/{}:refs/tags/{}" &>/dev/null || true
- CURRENT_TAG=${GITHUB_REF#refs/tags/}
- PREVIOUS_TAG=$(git tag --sort=creatordate | grep -B 1 "^${CURRENT_TAG}$" | head -n 1)
- echo "previous=${PREVIOUS_TAG}" >> $GITHUB_OUTPUT
- echo "current=${CURRENT_TAG}" >> $GITHUB_OUTPUT
- echo "Previous tag: ${PREVIOUS_TAG}"
- echo "Current tag: ${CURRENT_TAG}"
-
- - name: Collect PRs and Commits
- id: prs
- shell: bash
- env:
- GH_TOKEN: ${{ inputs.github-token }}
- run: |
- FROM_REF="${{ steps.tag_info.outputs.previous }}"
- TO_REF="${{ steps.tag_info.outputs.current }}"
- CHANGELOG_BOT_COMMIT_PATTERN='^docs: update changelog for '
-
- echo "Fetching PRs from $FROM_REF to $TO_REF"
-
- # Get merge commits to find PR numbers
- PR_NUMBERS=$(git log $FROM_REF..$TO_REF --grep="^Merge pull request #" --pretty=format:"%s" | grep -oE '#[0-9]+' | tr -d '#' | sort -u || true)
-
- # Check for squash-merged PRs (commits with (#PR) in subject)
- SQUASH_PR_NUMBERS=$(git log $FROM_REF..$TO_REF --pretty=format:"%s" | grep -oE '\(#[0-9]+\)' | grep -oE '[0-9]+' | sort -u || true)
-
- # Combine and deduplicate PR numbers
- ALL_PR_NUMBERS=$(echo -e "$PR_NUMBERS\n$SQUASH_PR_NUMBERS" | sort -u | grep -v '^$' || true)
-
- trim() {
- local s=$1
- s="${s#"${s%%[![:space:]]*}"}" # remove leading
- s="${s%"${s##*[![:space:]]}"}" # remove trailing
- printf '%s\n' "$s"
- }
-
- ALL_PR_NUMBERS=$(trim "$ALL_PR_NUMBERS")
-
- PR_COUNT=0
- if [ -n "$ALL_PR_NUMBERS" ]; then
- echo "Found PRs: $ALL_PR_NUMBERS"
-
- # Create detailed PR information file
- echo "# Pull Request Details with Commits" > pr_details_full.md
- echo "" >> pr_details_full.md
- echo "Range: $FROM_REF to $TO_REF" >> pr_details_full.md
- echo "" >> pr_details_full.md
-
- for pr_num in $ALL_PR_NUMBERS; do
- echo "Fetching PR #$pr_num with full details..."
-
- # Get PR details including body, commits, and files
- PR_DATA=$(gh pr view $pr_num --repo ${{ github.repository }} \
- --json number,title,author,body,labels,mergedAt,commits,files \
- 2>/dev/null || echo "{}")
-
- if [ "$PR_DATA" != "{}" ] && [ "$PR_DATA" != "null" ]; then
- PR_TITLE=$(echo "$PR_DATA" | jq -r '.title // "Unknown"')
- PR_AUTHOR=$(echo "$PR_DATA" | jq -r '.author.login // "unknown"')
- PR_BODY=$(echo "$PR_DATA" | jq -r '.body // ""')
- PR_MERGED=$(echo "$PR_DATA" | jq -r '.mergedAt // ""')
-
- if [ -n "$PR_BODY" ]; then
- echo "$PR_BODY" | head -n 100 | sed 's/^/> /' >> pr_details_full.md
- fi
-
- # List commits in this PR
- echo "### Commits:" >> pr_details_full.md
- echo "$PR_DATA" | jq -r '.commits[] | "- \(.messageHeadline) by \(.authors[0].name // \"unknown\")"' >> pr_details_full.md 2>/dev/null || echo "- Unable to fetch commits" >> pr_details_full.md
- echo "" >> pr_details_full.md
-
- # List changed files (summary)
- FILE_COUNT=$(echo "$PR_DATA" | jq '.files | length' 2>/dev/null || echo "0")
- echo "### Files Changed: $FILE_COUNT" >> pr_details_full.md
-
- # Get top changed files
- echo "$PR_DATA" | jq -r '.files[:10] | .[] | "- \(.path)"' >> pr_details_full.md 2>/dev/null || echo "- Unable to fetch file list" >> pr_details_full.md
-
- if [ "$FILE_COUNT" -gt "10" ]; then
- echo "- ... and $((FILE_COUNT - 10)) more files" >> pr_details_full.md
- fi
-
- echo "" >> pr_details_full.md
- echo "---" >> pr_details_full.md
- echo "" >> pr_details_full.md
-
- PR_COUNT=$((PR_COUNT + 1))
- fi
- done
-
- echo "Total PRs processed: $PR_COUNT" >> pr_details_full.md
- else
- echo "No merged PRs found in range $FROM_REF..$TO_REF" > pr_details_full.md
- fi
-
- # Create a simple commit list for additional context
- echo "# All Commits in Range" > commits_simple.md
- echo "" >> commits_simple.md
- echo "Range: $FROM_REF to $TO_REF" >> commits_simple.md
- echo "" >> commits_simple.md
-
- # Get all commits (including those not in PRs)
- git log $FROM_REF..$TO_REF --invert-grep --grep="$CHANGELOG_BOT_COMMIT_PATTERN" --pretty=format:"%h - %s (%an) - %ar" --no-merges | while read line; do
- echo "- $line" >> commits_simple.md
- done
-
- # Get statistics
- COMMIT_COUNT=$(git rev-list --count $FROM_REF..$TO_REF --no-merges --invert-grep --grep="$CHANGELOG_BOT_COMMIT_PATTERN")
- AUTHOR_COUNT=$(git log $FROM_REF..$TO_REF --invert-grep --grep="$CHANGELOG_BOT_COMMIT_PATTERN" --pretty=format:"%an" --no-merges | sort -u | wc -l)
- FILES_CHANGED=$(git diff --name-only $FROM_REF..$TO_REF | wc -l)
- ADDITIONS=$(git diff --shortstat $FROM_REF..$TO_REF | grep -oE '[0-9]+ insertions?' | grep -oE '[0-9]+' || echo "0")
- DELETIONS=$(git diff --shortstat $FROM_REF..$TO_REF | grep -oE '[0-9]+ deletions?' | grep -oE '[0-9]+' || echo "0")
-
- echo "pr_count=$PR_COUNT" >> $GITHUB_OUTPUT
- echo "commit_count=$COMMIT_COUNT" >> $GITHUB_OUTPUT
- echo "author_count=$AUTHOR_COUNT" >> $GITHUB_OUTPUT
- echo "files_changed=$FILES_CHANGED" >> $GITHUB_OUTPUT
- echo "additions=$ADDITIONS" >> $GITHUB_OUTPUT
- echo "deletions=$DELETIONS" >> $GITHUB_OUTPUT
- echo "prs=$(base64 -i pr_details_full.md | tr -d '\n')" >> $GITHUB_OUTPUT
- echo "commits=$(base64 -i commits_simple.md | tr -d '\n')" >> $GITHUB_OUTPUT
-
- echo "All outputs OK:"
- echo "$(cat $GITHUB_OUTPUT)"
diff --git a/scripts/changelog_to_telegram_html.py b/scripts/changelog_to_telegram_html.py
new file mode 100644
index 0000000..b11bec4
--- /dev/null
+++ b/scripts/changelog_to_telegram_html.py
@@ -0,0 +1,55 @@
+#!/usr/bin/env python3
+
+import argparse
+import html
+import re
+from pathlib import Path
+
+
+def format_inline(text):
+ escaped = html.escape(text, quote=False)
+ escaped = re.sub(r"\*\*(.+?)\*\*", r"\1", escaped)
+ escaped = re.sub(r"(?\1", escaped)
+ escaped = re.sub(r"(?\1", escaped)
+ return escaped
+
+
+def main():
+ parser = argparse.ArgumentParser(description="Convert changelog markdown to Telegram HTML.")
+ parser.add_argument("--input", required=True)
+ parser.add_argument("--output", required=True)
+ args = parser.parse_args()
+
+ rendered_lines = []
+ for raw_line in Path(args.input).read_text(encoding="utf-8").splitlines():
+ line = raw_line.strip()
+
+ if not line:
+ rendered_lines.append("")
+ continue
+
+ if line.startswith("## "):
+ rendered_lines.append(f"{format_inline(line[3:].strip())}")
+ continue
+
+ if line.startswith("# "):
+ rendered_lines.append(f"{format_inline(line[2:].strip())}")
+ continue
+
+ if line.startswith("\u2022 "):
+ rendered_lines.append(f"• {format_inline(line[2:].strip())}")
+ continue
+
+ if re.match(r"^[*-]\s+", line):
+ cleaned_line = re.sub(r"^[*-]\s+", "", line)
+ rendered_lines.append(f"• {format_inline(cleaned_line)}")
+ continue
+
+ rendered_lines.append(format_inline(line))
+
+ message = "\n".join(rendered_lines).strip()
+ Path(args.output).write_text(message + "\n", encoding="utf-8")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/openai_chat.py b/scripts/openai_chat.py
new file mode 100644
index 0000000..63f9726
--- /dev/null
+++ b/scripts/openai_chat.py
@@ -0,0 +1,123 @@
+#!/usr/bin/env python3
+
+import argparse
+import json
+import os
+import sys
+import urllib.error
+import urllib.request
+from pathlib import Path
+
+
+def read_text(path):
+ return Path(path).read_text(encoding="utf-8").strip()
+
+
+def build_url(base_url):
+ normalized = base_url.rstrip("/")
+ if normalized.endswith("/chat/completions"):
+ return normalized
+ return f"{normalized}/chat/completions"
+
+
+def extract_message_text(response_json):
+ choices = response_json.get("choices") or []
+ if choices:
+ choice = choices[0] or {}
+ message = choice.get("message") or {}
+ content = message.get("content")
+
+ if isinstance(content, str):
+ return content
+
+ if isinstance(content, list):
+ parts = []
+ for item in content:
+ if not isinstance(item, dict):
+ continue
+ text = item.get("text")
+ if isinstance(text, str):
+ parts.append(text)
+ if parts:
+ return "".join(parts)
+
+ text = choice.get("text")
+ if isinstance(text, str):
+ return text
+
+ output = response_json.get("output") or []
+ if isinstance(output, list):
+ parts = []
+ for block in output:
+ if not isinstance(block, dict):
+ continue
+ for item in block.get("content") or []:
+ if not isinstance(item, dict):
+ continue
+ text = item.get("text")
+ if isinstance(text, str):
+ parts.append(text)
+ if parts:
+ return "".join(parts)
+
+ raise ValueError("Unable to extract a text response from the provider payload.")
+
+
+def main():
+ parser = argparse.ArgumentParser(description="Call an OpenAI-compatible chat endpoint.")
+ parser.add_argument("--system-prompt-file", required=True)
+ parser.add_argument("--user-prompt-file", required=True)
+ parser.add_argument("--output-file", required=True)
+ args = parser.parse_args()
+
+ base_url = os.environ.get("OPENAI_BASE_URL", "https://api.openai.com/v1").strip()
+ api_key = os.environ.get("OPENAI_API_KEY", "").strip()
+ model = os.environ.get("OPENAI_MODEL", "").strip()
+ temperature = os.environ.get("OPENAI_TEMPERATURE", "0.2").strip()
+ auth_header = os.environ.get("OPENAI_AUTH_HEADER", "Authorization").strip()
+ auth_prefix = os.environ.get("OPENAI_AUTH_PREFIX", "Bearer ")
+
+ if not api_key:
+ raise SystemExit("OPENAI_API_KEY is required.")
+
+ if not model:
+ raise SystemExit("OPENAI_MODEL is required.")
+
+ payload = {
+ "model": model,
+ "messages": [
+ {"role": "system", "content": read_text(args.system_prompt_file)},
+ {"role": "user", "content": read_text(args.user_prompt_file)},
+ ],
+ "temperature": float(temperature),
+ }
+
+ request = urllib.request.Request(
+ build_url(base_url),
+ data=json.dumps(payload).encode("utf-8"),
+ headers={
+ "Content-Type": "application/json",
+ auth_header: f"{auth_prefix}{api_key}",
+ },
+ method="POST",
+ )
+
+ try:
+ with urllib.request.urlopen(request) as response:
+ response_json = json.loads(response.read().decode("utf-8"))
+ except urllib.error.HTTPError as exc:
+ error_body = exc.read().decode("utf-8", errors="replace")
+ print(error_body, file=sys.stderr)
+ raise SystemExit(f"LLM request failed with HTTP {exc.code}.") from exc
+ except urllib.error.URLError as exc:
+ raise SystemExit(f"Unable to reach the configured LLM endpoint: {exc.reason}") from exc
+
+ message = extract_message_text(response_json).strip()
+ if not message:
+ raise SystemExit("The configured LLM endpoint returned an empty response.")
+
+ Path(args.output_file).write_text(message + "\n", encoding="utf-8")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/tests/test_changelog_to_telegram_html.py b/tests/test_changelog_to_telegram_html.py
new file mode 100644
index 0000000..14ead76
--- /dev/null
+++ b/tests/test_changelog_to_telegram_html.py
@@ -0,0 +1,70 @@
+import importlib.util
+import sys
+import tempfile
+import unittest
+from pathlib import Path
+from unittest import mock
+
+
+ROOT = Path(__file__).resolve().parents[1]
+MODULE_PATH = ROOT / "scripts" / "changelog_to_telegram_html.py"
+
+
+def load_module():
+ spec = importlib.util.spec_from_file_location(
+ "changelog_to_telegram_html", MODULE_PATH
+ )
+ module = importlib.util.module_from_spec(spec)
+ assert spec.loader is not None
+ spec.loader.exec_module(module)
+ return module
+
+
+telegram_html = load_module()
+
+
+class TelegramHtmlTests(unittest.TestCase):
+ def test_format_inline_escapes_html_and_formats_markdown(self):
+ self.assertEqual(
+ telegram_html.format_inline("Use **bold** and *italic* plus _notes_"),
+ "Use bold <tag> and italic plus notes",
+ )
+
+ def test_main_converts_markdown_to_telegram_safe_html(self):
+ with tempfile.TemporaryDirectory() as tmpdir:
+ temp_path = Path(tmpdir)
+ input_file = temp_path / "input.md"
+ output_file = temp_path / "output.html"
+
+ input_file.write_text(
+ "## v1.2.3\n"
+ "\n"
+ "\u2022 Added **support**\n"
+ "- Fixed \n"
+ "Plain _text_\n",
+ encoding="utf-8",
+ )
+
+ argv = [
+ "changelog_to_telegram_html.py",
+ "--input",
+ str(input_file),
+ "--output",
+ str(output_file),
+ ]
+
+ with mock.patch.object(sys, "argv", argv):
+ telegram_html.main()
+
+ self.assertEqual(
+ output_file.read_text(encoding="utf-8"),
+ "v1.2.3\n"
+ "\n"
+ "• Added support\n"
+ "• Fixed <bug>\n"
+ "Plain text\n",
+ )
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_openai_chat.py b/tests/test_openai_chat.py
new file mode 100644
index 0000000..52b8efa
--- /dev/null
+++ b/tests/test_openai_chat.py
@@ -0,0 +1,190 @@
+import importlib.util
+import json
+import os
+import sys
+import tempfile
+import unittest
+from pathlib import Path
+from unittest import mock
+
+
+ROOT = Path(__file__).resolve().parents[1]
+MODULE_PATH = ROOT / "scripts" / "openai_chat.py"
+
+
+def load_module():
+ spec = importlib.util.spec_from_file_location("openai_chat", MODULE_PATH)
+ module = importlib.util.module_from_spec(spec)
+ assert spec.loader is not None
+ spec.loader.exec_module(module)
+ return module
+
+
+openai_chat = load_module()
+
+
+class FakeResponse:
+ def __init__(self, payload):
+ self.payload = payload
+
+ def read(self):
+ return json.dumps(self.payload).encode("utf-8")
+
+ def __enter__(self):
+ return self
+
+ def __exit__(self, exc_type, exc, tb):
+ return False
+
+
+class OpenAIChatTests(unittest.TestCase):
+ def test_build_url_appends_chat_completions(self):
+ self.assertEqual(
+ openai_chat.build_url("https://example.com/v1"),
+ "https://example.com/v1/chat/completions",
+ )
+
+ def test_build_url_keeps_existing_chat_completions_endpoint(self):
+ self.assertEqual(
+ openai_chat.build_url("https://example.com/v1/chat/completions"),
+ "https://example.com/v1/chat/completions",
+ )
+
+ def test_extract_message_text_supports_multiple_response_shapes(self):
+ self.assertEqual(
+ openai_chat.extract_message_text(
+ {"choices": [{"message": {"content": "first response"}}]}
+ ),
+ "first response",
+ )
+
+ self.assertEqual(
+ openai_chat.extract_message_text(
+ {
+ "choices": [
+ {
+ "message": {
+ "content": [
+ {"type": "output_text", "text": "hello "},
+ {"type": "output_text", "text": "world"},
+ ]
+ }
+ }
+ ]
+ }
+ ),
+ "hello world",
+ )
+
+ self.assertEqual(
+ openai_chat.extract_message_text(
+ {
+ "output": [
+ {
+ "content": [
+ {"type": "output_text", "text": "fallback "},
+ {"type": "output_text", "text": "format"},
+ ]
+ }
+ ]
+ }
+ ),
+ "fallback format",
+ )
+
+ def test_main_posts_request_and_writes_output(self):
+ with tempfile.TemporaryDirectory() as tmpdir:
+ temp_path = Path(tmpdir)
+ system_prompt = temp_path / "system.txt"
+ user_prompt = temp_path / "user.txt"
+ output_file = temp_path / "output.txt"
+
+ system_prompt.write_text("system prompt\n", encoding="utf-8")
+ user_prompt.write_text("user prompt\n", encoding="utf-8")
+
+ captured = {}
+
+ def fake_urlopen(request):
+ captured["url"] = request.full_url
+ captured["headers"] = {
+ key.lower(): value for key, value in request.header_items()
+ }
+ captured["payload"] = json.loads(request.data.decode("utf-8"))
+ return FakeResponse(
+ {"choices": [{"message": {"content": "## v1.2.3\n\u2022 Added support"}}]}
+ )
+
+ argv = [
+ "openai_chat.py",
+ "--system-prompt-file",
+ str(system_prompt),
+ "--user-prompt-file",
+ str(user_prompt),
+ "--output-file",
+ str(output_file),
+ ]
+
+ env = {
+ "OPENAI_BASE_URL": "https://provider.example/v1",
+ "OPENAI_API_KEY": "secret-key",
+ "OPENAI_MODEL": "test-model",
+ "OPENAI_TEMPERATURE": "0.7",
+ "OPENAI_AUTH_HEADER": "api-key",
+ "OPENAI_AUTH_PREFIX": "",
+ }
+
+ with mock.patch.dict(os.environ, env, clear=True), mock.patch.object(
+ sys, "argv", argv
+ ), mock.patch("urllib.request.urlopen", side_effect=fake_urlopen):
+ openai_chat.main()
+
+ self.assertEqual(
+ output_file.read_text(encoding="utf-8"),
+ "## v1.2.3\n\u2022 Added support\n",
+ )
+ self.assertEqual(
+ captured["url"],
+ "https://provider.example/v1/chat/completions",
+ )
+ self.assertEqual(captured["headers"]["api-key"], "secret-key")
+ self.assertEqual(captured["payload"]["model"], "test-model")
+ self.assertEqual(captured["payload"]["temperature"], 0.7)
+ self.assertEqual(
+ captured["payload"]["messages"],
+ [
+ {"role": "system", "content": "system prompt"},
+ {"role": "user", "content": "user prompt"},
+ ],
+ )
+
+ def test_main_requires_api_key(self):
+ with tempfile.TemporaryDirectory() as tmpdir:
+ temp_path = Path(tmpdir)
+ system_prompt = temp_path / "system.txt"
+ user_prompt = temp_path / "user.txt"
+ output_file = temp_path / "output.txt"
+
+ system_prompt.write_text("system prompt", encoding="utf-8")
+ user_prompt.write_text("user prompt", encoding="utf-8")
+
+ argv = [
+ "openai_chat.py",
+ "--system-prompt-file",
+ str(system_prompt),
+ "--user-prompt-file",
+ str(user_prompt),
+ "--output-file",
+ str(output_file),
+ ]
+
+ with mock.patch.dict(os.environ, {"OPENAI_MODEL": "test-model"}, clear=True), mock.patch.object(
+ sys, "argv", argv
+ ):
+ with self.assertRaises(SystemExit) as error:
+ openai_chat.main()
+
+ self.assertEqual(str(error.exception), "OPENAI_API_KEY is required.")
+
+
+if __name__ == "__main__":
+ unittest.main()