Aakvatech Limited - How to Add Version-Based Release Tagging and Promotion to a Frappe App Repo

Set up GitHub Actions for a Frappe app repo: create hotfix branches, read __version__, tag releases, and promote reviewed PRs to version branches using labels. Includes a clean release checklist too!!

 · 7 min read

How to Add Version-Based Release Tagging and Promotion to a Frappe App Repo Using GitHub Actions

Many Frappe app repositories start simple: one or more development branches, no release automation, no hotfix branch, and no GitHub Actions. That works for early development, but it becomes risky when you want repeatable releases.

This guide explains how to set up a clean release flow for a Frappe app where:

  • Developers work on a feature or release branch.
  • A PR is reviewed into a hotfix/staging branch.
  • A GitHub Actions workflow reads the app version from __init__.py.
  • The workflow creates a Git tag such as v15.2.0.
  • The workflow promotes the merged commit to the correct release branch, such as version-15.

GitHub Actions workflows are YAML files stored in .github/workflows inside the repository. (GitHub Docs)


Target Release Flow

The final flow will look like this:

release/15.2.0
        ↓ PR
version-15-hotfix
        ↓ merge after review
GitHub Actions
        ↓
create tag v15.2.0
        ↓
promote commit to version-15

For Frappe v16, the same idea can be used:

release/16.0.1
        ↓ PR
version-16-hotfix
        ↓ merge after review
GitHub Actions
        ↓
create tag v16.0.1
        ↓
promote commit to version-16

1. Add the App Version in __init__.py

In a Frappe app, keep the app version in the package __init__.py.

Example:

propms/__init__.py

Add or update:

__version__ = "15.2.0"

This is the value the workflow will read.

The workflow will convert:

__version__ = "15.2.0"

into this Git tag:

v15.2.0

Recommended convention:

App version: 15.2.0
Git tag:     v15.2.0
Branch:      release/15.2.0

Avoid naming the branch v15.2.0, because that makes the branch name and tag name identical.


2. Create Hotfix and Release Branches

If your repository does not already have hotfix branches, create them once.

For Frappe v15:

git checkout main
git pull origin main

git checkout -b version-15-hotfix
git push origin version-15-hotfix

git checkout main
git checkout -b version-15
git push origin version-15

For Frappe v16:

git checkout main
git pull origin main

git checkout -b version-16-hotfix
git push origin version-16-hotfix

git checkout main
git checkout -b version-16
git push origin version-16

Branch purpose:

Branch Purpose
release/15.2.0 Development/release preparation branch
version-15-hotfix Review and staging branch for v15 releases
version-15 Final release branch used by deployments
version-16-hotfix Review and staging branch for v16 releases
version-16 Final release branch for v16 deployments

3. Create Promotion Labels in GitHub

GitHub labels can be applied to pull requests, issues, and discussions, and they are created at the repository level. (GitHub Docs)

Create these labels:

promote/version-15
promote/version-16

In GitHub:

Repository → Issues → Labels → New label

Create:

promote/version-15

and, if needed:

promote/version-16

These labels tell the workflow where to promote the merged commit.


4. Create the GitHub Actions Workflow File

Create this file:

.github/workflows/tag-and-promote-from-pr-label.yml

From the GitHub website, you can create the folders by typing the full file path when creating a new file:

.github/workflows/tag-and-promote-from-pr-label.yml

GitHub will automatically create .github and workflows.


5. Add the Workflow YAML

Paste this into:

.github/workflows/tag-and-promote-from-pr-label.yml
name: Tag and promote from PR label

on:
  pull_request:
    types:
      - closed

permissions:
  contents: write
  pull-requests: read

jobs:
  tag-and-promote:
    if: github.event.pull_request.merged == true
    runs-on: ubuntu-latest

    steps:
      - name: Determine target branch from PR labels
        id: target
        uses: actions/github-script@v7
        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"
            };

            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@v4
        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'
        run: |
          git fetch --tags

          if git rev-parse "${{ steps.version.outputs.tag }}" >/dev/null 2>&1; then
            echo "Tag ${{ steps.version.outputs.tag }} already exists."
            exit 1
          fi

          git tag -a "${{ steps.version.outputs.tag }}" -m "Release ${{ steps.version.outputs.tag }}"
          git push origin "${{ steps.version.outputs.tag }}"

      - name: Promote merged commit to target branch
        if: steps.target.outputs.should_promote == 'true'
        run: |
          git push origin HEAD:${{ steps.target.outputs.target_branch }}

GitHub documents the pull_request closed event with a merged-condition check as the standard way to run automation after a PR is merged. (GitHub Docs)

The workflow uses GITHUB_TOKEN implicitly to push the tag and branch. For that, the workflow declares:

permissions:
  contents: write

GitHub documents GITHUB_TOKEN as the built-in authentication token for workflows, and permissions can be configured in the workflow file. (GitHub Docs)


6. Adjust the App Path if Needed

The workflow reads:

propms/__init__.py

If your Frappe app package is not propms, update this line:

init_file = Path("propms/__init__.py")

Examples:

init_file = Path("my_app/__init__.py")

or:

init_file = Path("property_management_solution/__init__.py")

The correct folder is usually the package folder that contains:

hooks.py

Example structure:

apps/propms/
├── propms/
│   ├── __init__.py
│   ├── hooks.py
│   └── property_management_solution/
├── pyproject.toml
└── README.md

In this case, use:

init_file = Path("propms/__init__.py")

7. Commit the Workflow

Commit the workflow to the repository.

git add .github/workflows/tag-and-promote-from-pr-label.yml
git commit -m "ci: add PR label based release tagging and promotion"
git push origin main

It is best to have the workflow file present on the default branch so GitHub can reliably trigger it for PR events.


8. Create a Release Branch for the Change

For a new release:

git checkout main
git pull origin main

git checkout -b release/15.2.0

Update:

propms/__init__.py

Set:

__version__ = "15.2.0"

Commit:

git add propms/__init__.py
git commit -m "chore: bump version to 15.2.0"
git push origin release/15.2.0

9. Open a PR to the Hotfix Branch

Open a pull request:

base:    version-15-hotfix
compare: release/15.2.0

Add the label:

promote/version-15

Review the PR as usual.


10. Merge the PR

When the PR is merged into:

version-15-hotfix

the workflow runs automatically.

If the PR has:

promote/version-15

and the app has:

__version__ = "15.2.0"

then the workflow will:

1. Read propms/__init__.py
2. Extract 15.2.0
3. Create Git tag v15.2.0
4. Push tag v15.2.0
5. Push the merged commit to version-15

Final result:

version-15-hotfix   contains reviewed release code
version-15          contains promoted release code
v15.2.0             points to the promoted release commit

11. Repeat for Frappe v16

For a v16 release:

base:    version-16-hotfix
compare: release/16.0.1
label:   promote/version-16

In propms/__init__.py:

__version__ = "16.0.1"

After merge, the workflow creates:

v16.0.1

and promotes the commit to:

version-16

Protect the final release branches:

version-15
version-16

At minimum, restrict who can push to them.

If branch protection blocks GitHub Actions from pushing to version-15, then either:

  1. Allow GitHub Actions to push to the branch.
  2. Use a GitHub App token with permission to bypass restrictions.
  3. Change the workflow to create a PR into version-15 instead of pushing directly.

For a small team, direct promotion to version-15 is usually fine if only maintainers can merge into version-15-hotfix.


13. What Happens If the Tag Already Exists?

The workflow intentionally fails if the tag already exists:

if git rev-parse "${{ steps.version.outputs.tag }}" >/dev/null 2>&1; then
  echo "Tag ${{ steps.version.outputs.tag }} already exists."
  exit 1
fi

This protects you from accidentally releasing two different commits with the same version.

If a release needs a fix, bump the patch version:

__version__ = "15.2.1"

Then release:

v15.2.1

Do not reuse:

v15.2.0

14. Suggested Release Checklist

Before opening the PR:

[ ] Version updated in propms/__init__.py
[ ] Branch name is release/x.y.z
[ ] PR is opened against version-15-hotfix or version-16-hotfix
[ ] Correct promote label is added
[ ] Tests/review completed
[ ] PR merged
[ ] GitHub Action succeeded
[ ] Tag created
[ ] Release branch updated

Example for v15:

[ ] propms/__init__.py has __version__ = "15.2.0"
[ ] PR: release/15.2.0 → version-15-hotfix
[ ] Label: promote/version-15
[ ] Tag created: v15.2.0
[ ] Branch updated: version-15

Conclusion

This setup gives a Frappe app repository a clean and auditable release process without requiring manual tagging or direct pushes to release branches.

The key ideas are:

__init__.py controls the app version
PR labels control the release target
GitHub Actions creates the tag
GitHub Actions promotes reviewed code to the release branch

For most Frappe app repositories, this is a practical middle ground between fully manual releases and a complex CI/CD pipeline.


No comments yet.

Add a comment
Ctrl+Enter to add comment