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.

 · 8 min read

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:

  1. Detects a promotion label on a merged pull request.
  2. Reads the app version from the Frappe app’s __init__.py.
  3. Creates a Git tag such as v15.2.0.
  4. Creates a GitHub Release with an auto-generated title and release notes.
  5. 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"
};

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:

  1. Detect the label.
  2. Read the app version.
  3. Create the tag.
  4. Create the GitHub Release.
  5. 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.


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.

Add a comment
Ctrl+Enter to add comment