Aakvatech Limited - Automate Frappe App Releases with GitHub Actions
Automate Frappe app releases with GitHub Actions: tag versions, generate release notes, create GitHub Releases, and promote merged PRs safely using labels.
Automating Version Tagging, Promotion, and Release Notes for a Frappe App Repository Using GitHub Actions
Managing releases for a Frappe or ERPNext app can become messy when development, hotfix, version, and production branches all move at different speeds. A common pattern is to merge tested work into a hotfix or staging branch first, then promote the exact same merged commit to a version branch such as version-15, version-16, or production.
This blog post explains a practical GitHub Actions workflow that handles that process automatically.
The final solution does four useful things:
- Detects a promotion label on a merged pull request.
- Reads the app version from the Frappe app’s
__init__.py. - Creates a Git tag such as
v15.2.0. - Creates a GitHub Release with an auto-generated title and release notes.
- Promotes the exact merged commit to the target branch.
This is especially useful for Frappe app repositories where versioning is usually maintained in:
__version__ = "15.2.0"
inside:
your_app/__init__.py
Why this workflow is useful
In a Frappe app repository, you often have branches like:
develop
version-15-hotfix
version-15
version-16
production
A safe release flow might look like this:
feature/fix branch
↓
Pull Request into version-15-hotfix
↓
Test and merge
↓
Apply label: promote/version-15
↓
GitHub Action creates tag, release notes, and promotes commit
↓
version-15 receives the exact tested commit
This avoids manually running Git commands such as:
git tag -a v15.2.0 -m "Release v15.2.0"
git push origin v15.2.0
git push origin HEAD:version-15
It also avoids the risk of accidentally promoting the wrong commit.
Important GitHub Actions rule
GitHub only recognizes workflow files placed under the repository root:
.github/workflows/
So the workflow file must be here:
.github/workflows/tag-and-promote-from-pr-label.yml
It must not be inside the Frappe app folder, for example:
your_app/.github/workflows/tag-and-promote-from-pr-label.yml
That nested location will not trigger GitHub Actions.
Final workflow file
Create this file:
.github/workflows/tag-and-promote-from-pr-label.yml
Then add the following YAML:
name: Tag and promote from PR label
on:
pull_request_target:
types:
- closed
- labeled
permissions:
contents: write
pull-requests: read
jobs:
tag-and-promote:
if: >
github.event.pull_request.merged == true &&
(
github.event.action == 'closed' ||
(
github.event.action == 'labeled' &&
startsWith(github.event.label.name, 'promote/')
)
)
runs-on: ubuntu-latest
steps:
- name: Determine target branch from PR labels
id: target
uses: actions/github-script@v8
with:
script: |
const labels = context.payload.pull_request.labels.map(label => label.name);
const mapping = {
"promote/version-15": "version-15",
"promote/version-16": "version-16",
"promote/production": "production"
};
const matchedLabels = labels.filter(label => mapping[label]);
if (matchedLabels.length === 0) {
core.info(
`No promote target label found. Skipping promotion. Add one of: ${Object.keys(mapping).join(", ")}`
);
core.setOutput("should_promote", "false");
return;
}
if (matchedLabels.length > 1) {
core.setFailed(
`Multiple promote target labels found: ${matchedLabels.join(", ")}. Keep only one.`
);
return;
}
const matchedLabel = matchedLabels[0];
core.setOutput("should_promote", "true");
core.setOutput("target_branch", mapping[matchedLabel]);
core.setOutput("matched_label", matchedLabel);
- name: Checkout merged commit
if: steps.target.outputs.should_promote == 'true'
uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.merge_commit_sha }}
fetch-depth: 0
- name: Read version from propms.__version__
if: steps.target.outputs.should_promote == 'true'
id: version
shell: bash
run: |
VERSION=$(python - <<'PY'
import re
from pathlib import Path
init_file = Path("propms/__init__.py")
content = init_file.read_text()
match = re.search(r'^__version__\s*=\s*["\']([^"\']+)["\']', content, re.M)
if not match:
raise SystemExit("Could not find __version__ in propms/__init__.py")
print(match.group(1))
PY
)
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "tag=v$VERSION" >> "$GITHUB_OUTPUT"
- name: Configure git user
if: steps.target.outputs.should_promote == 'true'
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
- name: Create and push version tag
if: steps.target.outputs.should_promote == 'true'
shell: bash
run: |
git fetch --tags
TAG="${{ steps.version.outputs.tag }}"
CURRENT_COMMIT="$(git rev-parse HEAD)"
if git rev-parse "$TAG" >/dev/null 2>&1; then
TAG_COMMIT="$(git rev-list -n 1 "$TAG")"
if [ "$TAG_COMMIT" != "$CURRENT_COMMIT" ]; then
echo "Tag $TAG already exists but points to $TAG_COMMIT, not current merged commit $CURRENT_COMMIT."
exit 1
fi
echo "Tag $TAG already exists and points to the current merged commit. Skipping tag creation."
exit 0
fi
git tag -a "$TAG" -m "Release $TAG"
git push origin "$TAG"
- name: Create GitHub release with generated title and notes
if: steps.target.outputs.should_promote == 'true'
uses: actions/github-script@v8
env:
TAG_NAME: ${{ steps.version.outputs.tag }}
TARGET_COMMITISH: ${{ github.event.pull_request.merge_commit_sha }}
with:
script: |
const tagName = process.env.TAG_NAME;
const targetCommitish = process.env.TARGET_COMMITISH;
const { owner, repo } = context.repo;
try {
const existingRelease = await github.rest.repos.getReleaseByTag({
owner,
repo,
tag: tagName
});
core.info(
`Release already exists for ${tagName}: ${existingRelease.data.html_url}. Skipping release creation.`
);
return;
} catch (error) {
if (error.status !== 404) {
throw error;
}
}
const generatedNotes = await github.rest.repos.generateReleaseNotes({
owner,
repo,
tag_name: tagName,
target_commitish: targetCommitish,
previous_tag_name: undefined
});
const release = await github.rest.repos.createRelease({
owner,
repo,
tag_name: tagName,
target_commitish: targetCommitish,
name: generatedNotes.data.name,
body: generatedNotes.data.body,
draft: false,
prerelease: false
});
core.info(`Created release: ${release.data.html_url}`);
- name: Promote merged commit to target branch
if: steps.target.outputs.should_promote == 'true'
shell: bash
run: |
git push origin HEAD:${{ steps.target.outputs.target_branch }}
Customizing the app path
The example above reads the version from:
propms/__init__.py
For another Frappe app, update this line:
init_file = Path("propms/__init__.py")
For example, if your app is called my_custom_app, change it to:
init_file = Path("my_custom_app/__init__.py")
The file should contain a version declaration like this:
__version__ = "15.2.0"
The workflow will convert that into a Git tag:
v15.2.0
Setting up promotion labels
Create these labels in your GitHub repository:
promote/version-15
promote/version-16
promote/production
The workflow maps them like this:
promote/version-15 → version-15
promote/version-16 → version-16
promote/production → production
You can edit this section of the workflow to match your branch strategy:
const mapping = {
"promote/version-15": "version-15",
"promote/version-16": "version-16",
"promote/production": "production"
};
For example, if your repository uses main instead of production, change it to:
const mapping = {
"promote/version-15": "version-15",
"promote/version-16": "version-16",
"promote/main": "main"
};
Recommended release process
Step 1: Update the app version
Before creating the release PR, update the version in your Frappe app.
Example:
__version__ = "15.2.0"
Commit that change as part of your release branch.
Step 2: Create a release or hotfix branch
Example:
release/v15.2.0
This branch should contain the final tested code and the updated __version__.
Step 3: Open a PR into the hotfix branch
For a Version 15 release, open the PR into:
version-15-hotfix
Example:
release/v15.2.0 → version-15-hotfix
The PR should not directly target version-15 if your intended flow is to test or stage first, then promote.
Step 4: Review, test, and merge the PR
Once testing is complete, merge the PR into:
version-15-hotfix
At this point, the exact merged commit is available as:
github.event.pull_request.merge_commit_sha
The workflow uses this commit SHA for tagging and promotion. That is important because it ensures the promoted code is exactly the code that was merged and tested.
Step 5: Apply the promotion label
After the PR is merged, apply:
promote/version-15
The workflow will then:
- Detect the label.
- Read the app version.
- Create the tag.
- Create the GitHub Release.
- Push the merged commit to
version-15.
If the label was already present before the workflow was updated, remove it and add it again. The relabel action triggers the workflow through:
pull_request_target:
types:
- labeled
Why use pull_request_target?
This workflow uses:
on:
pull_request_target:
instead of:
on:
pull_request:
This is useful because promotion requires write access to the repository. The workflow needs permission to:
push tags
create releases
push to target branches
The workflow remains safe because it does not check out arbitrary unmerged PR code. It checks out only the merged commit:
ref: ${{ github.event.pull_request.merge_commit_sha }}
That means the workflow operates on the commit that already entered the repository through the normal merge process.
Do not change that checkout reference to the PR head branch when using pull_request_target.
What happens if the tag already exists?
The workflow handles this safely.
If the tag does not exist, it creates it:
git tag -a "$TAG" -m "Release $TAG"
git push origin "$TAG"
If the tag already exists, it checks whether the tag points to the same merged commit.
If the tag points to the same commit, the workflow continues:
Tag v15.2.0 already exists and points to the current merged commit. Skipping tag creation.
If the tag points to a different commit, the workflow fails:
Tag v15.2.0 already exists but points to another commit.
This prevents accidentally reusing the same app version for different code.
Automatic GitHub Release creation
The workflow creates a GitHub Release using GitHub’s generated release title and release notes.
This part handles release note generation:
const generatedNotes = await github.rest.repos.generateReleaseNotes({
owner,
repo,
tag_name: tagName,
target_commitish: targetCommitish,
previous_tag_name: undefined
});
Then it creates the release:
const release = await github.rest.repos.createRelease({
owner,
repo,
tag_name: tagName,
target_commitish: targetCommitish,
name: generatedNotes.data.name,
body: generatedNotes.data.body,
draft: false,
prerelease: false
});
GitHub will generate release notes based on merged pull requests and commits since the previous release tag.
This is very useful for Frappe app maintainers because it gives you a clean release history without manually writing every release note.
Recommended PR title and description format
Generated release notes are only as good as your PR titles and descriptions.
Use clear PR titles such as:
Add daily lease status automation for Upcoming, Active, and Expired leases
or:
Fix rent schedule generation for amended leases
Avoid vague titles like:
Fix issue
or:
Updates
A good PR body should include:
## Summary
Briefly explain what changed.
## Changes
- Added ...
- Fixed ...
- Updated ...
## Testing
- Tested on ...
- Verified that ...
This improves the quality of GitHub’s generated release notes.
Example release flow
Assume your app version is being released as:
v15.2.0
The flow would be:
1. Update propms/__init__.py to __version__ = "15.2.0"
2. Create branch:
release/v15.2.0
3. Open PR:
release/v15.2.0 → version-15-hotfix
4. Merge PR after testing.
5. Add label:
promote/version-15
6. GitHub Actions creates:
Git tag: v15.2.0
GitHub Release: v15.2.0 with generated release notes
7. GitHub Actions promotes:
merged commit → version-15
Troubleshooting
No workflow run appears
Check that the workflow file is at the repository root:
.github/workflows/tag-and-promote-from-pr-label.yml
Not inside the Frappe app folder:
propms/.github/workflows/tag-and-promote-from-pr-label.yml
Also confirm the workflow is committed to the repository’s default branch or the relevant base branch.
The workflow runs but says the tag already exists
That is not always a problem.
If the tag points to the same merged commit, the workflow skips tag creation and continues.
If it fails, the tag probably points to a different commit. In that case, you should investigate before deleting or moving the tag.
Check locally with:
git fetch --tags
git rev-list -n 1 v15.2.0
Compare it with the PR merge commit SHA.
Node.js 20 deprecation warning
Use the newer action versions:
uses: actions/github-script@v8
uses: actions/checkout@v6
This avoids the older Node.js 20-based action warning.
Workflow does not trigger after adding a label
Remove and re-add the label:
promote/version-15
The workflow listens for the labeled event, so a fresh label application should trigger it.
Final recommendation
For Frappe app repositories, this workflow gives a clean and repeatable release process:
merge tested PR → apply promotion label → create tag → create release notes → promote commit
It is especially helpful when maintaining multiple active version branches such as:
version-15
version-16
production
The key benefit is that the Git tag, GitHub Release, and promoted branch all point to the same reviewed and merged commit. That makes releases easier to audit, easier to roll back, and easier to explain to customers or internal teams.
Aakvatech Limited is a Frappe Gold Partner and ERPNext implementation company headquartered in Dar es Salaam, Tanzania, operating across East Africa and the UAE.
This article was co-created using AI to accelerate drafting, with final insights curated and validated by the author.
No comments yet. Login to start a new discussion Start a new discussion