Automate a QGIS Plugin Release with GitHub Actions
Releasing a QGIS plugin by hand is four steps that are individually trivial and collectively easy to get wrong: bump the version in metadata.txt, zip the right directory with the right name, create a release, upload to the plugin repository. Doing it from a tag means the version can only ever be one thing, the zip is built the same way every time, and nobody ships a package containing their .git directory.
This recipe belongs to Publishing to the QGIS Plugin Repository. It covers deriving the version from a tag, building a package the repository will accept, attaching it to a GitHub release, and uploading it to plugins.qgis.org from the same workflow.
Prerequisites
- A plugin repository whose plugin package is a single directory, as produced by Plugin Builder.
- A valid
metadata.txt; the repository rejects packages with missing required fields. See writing metadata.txt. - For the repository upload, an account on plugins.qgis.org with rights to the plugin, stored as repository secrets.
Derive the version from the tag
The version must appear in metadata.txt, and having it in two places guarantees they will disagree eventually.
name: release
on:
push:
tags: ["v*"]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Derive version
id: version
run: |
VERSION="${GITHUB_REF_NAME#v}"
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
sed -i "s/^version=.*/version=$VERSION/" my_plugin/metadata.txt
grep '^version=' my_plugin/metadata.txt
Breakdown: ${GITHUB_REF_NAME#v} strips a leading v, so the tag v1.4.0 produces the version 1.4.0 — the plugin repository expects a bare version and rejects one with a prefix. Writing it into metadata.txt at build time rather than committing it means the file in the repository can hold a placeholder and there is exactly one source of truth. The grep at the end is a two-second sanity check whose output appears in the log, which is worth having when a release later looks wrong.
Committing the modified metadata.txt back is not necessary and is best avoided: the released artefact carries the version, and the repository stays clean.
Build a package the repository accepts
The plugin repository is strict about structure, and three rules cover it.
- name: Package
run: |
mkdir -p dist
zip -r "dist/my_plugin-${{ steps.version.outputs.version }}.zip" my_plugin \
-x '*/__pycache__/*' '*.pyc' '*/.git/*' '*/tests/*'
unzip -l "dist/my_plugin-${{ steps.version.outputs.version }}.zip" | head -20
Breakdown: The zip must contain exactly one top-level directory, and that directory's name must match the plugin's package name — zipping the contents rather than the folder is the single most common rejection. Excluding __pycache__ and .pyc matters because compiled files from a different Python version confuse the loader, and excluding tests keeps the package small. Listing the archive contents into the log makes structure problems visible without downloading anything.
Validate before publishing rather than after:
- name: Validate metadata
run: |
python3 - <<'PY'
import configparser, sys
cfg = configparser.ConfigParser()
cfg.read("my_plugin/metadata.txt")
required = ["name", "qgisMinimumVersion", "description", "version", "author", "email"]
missing = [k for k in required if not cfg.get("general", k, fallback="").strip()]
if missing:
sys.exit(f"metadata.txt is missing: {missing}")
print("metadata ok:", cfg.get("general", "version"))
PY
Breakdown: metadata.txt is an INI file, so configparser reads it directly. Checking the required fields locally catches the rejection before the upload rather than after, which matters because a rejected upload still consumes the version number in some workflows. qgisMinimumVersion is the field most often wrong — it must be the oldest version actually tested, not the oldest that plausibly works.
Attach it to a release
The GitHub release is the durable artefact, and it is also what a custom plugin repository can point at.
- name: Create release
uses: softprops/action-gh-release@v2
with:
files: dist/*.zip
generate_release_notes: true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
Breakdown: generate_release_notes: true builds the notes from merged pull requests since the previous tag, which is usually better than nothing and better than a hand-written summary that nobody updates. The built-in GITHUB_TOKEN is enough for creating a release in the same repository — no personal token is needed, which removes a secret to manage. Attaching the zip means users on an air-gapped network, or organisations that mirror plugins internally, have a stable URL to fetch.
Upload to plugins.qgis.org
The repository accepts an authenticated form post, which is one curl invocation.
- name: Upload to the QGIS plugin repository
run: |
curl --fail-with-body -u "$OSGEO_USER:$OSGEO_PASSWORD" \
-F "package=@dist/my_plugin-${{ steps.version.outputs.version }}.zip" \
https://plugins.qgis.org/plugins/add/
env:
OSGEO_USER: ${{ secrets.OSGEO_USER }}
OSGEO_PASSWORD: ${{ secrets.OSGEO_PASSWORD }}
Breakdown: --fail-with-body is the flag that matters: plain curl exits zero on an HTTP error and prints the error page, so a failed upload passes CI silently. With it, the job fails and the response body — which contains the rejection reason — appears in the log. The credentials belong in repository secrets, never in the workflow file; and because they are the account's real OSGeo credentials, an account used only for publishing is a sensible precaution.
The upload endpoint changes occasionally and some plugins use the qgis-plugin-ci tool, which wraps this along with translation building and changelog extraction. For a plugin with translations or a CHANGELOG.md the repository should display, that tool saves more than it costs.
Releasing to a private repository
Not every plugin belongs on plugins.qgis.org. An organisation with internal tooling usually wants its own repository, and QGIS supports that natively — a plugin repository is an XML file listing packages and their download URLs.
- name: Update the internal repository index
run: |
python3 - <<'PY'
import configparser, os, xml.etree.ElementTree as ET
version = os.environ["VERSION"]
cfg = configparser.ConfigParser()
cfg.read("my_plugin/metadata.txt")
general = cfg["general"]
root = ET.Element("plugins")
plugin = ET.SubElement(root, "pyqgis_plugin", name=general["name"], version=version)
for tag in ("description", "about", "qgis_minimum_version", "author_name"):
key = {"qgis_minimum_version": "qgisMinimumVersion", "author_name": "author"}.get(tag, tag)
ET.SubElement(plugin, tag).text = general.get(key, "")
ET.SubElement(plugin, "download_url").text = (
f"https://plugins.internal.example.org/my_plugin-{version}.zip"
)
ET.SubElement(plugin, "file_name").text = f"my_plugin-{version}.zip"
ET.ElementTree(root).write("dist/plugins.xml", encoding="utf-8", xml_declaration=True)
PY
env:
VERSION: ${{ steps.version.outputs.version }}
Breakdown: The element names in the XML differ from the metadata.txt keys — qgis_minimum_version against qgisMinimumVersion, author_name against author — which is the detail that makes hand-written indexes wrong. Generating the index from the same metadata that goes into the package means the two cannot disagree. Publishing the XML and the zip to a static host is enough: users add the XML's URL under Plugins → Settings → Plugin repositories and the plugin appears alongside the official ones, updates included.
Guard against releasing a broken build
A release workflow that does not run the tests is a way to publish a broken plugin faster.
steps:
- uses: actions/checkout@v4
- name: Test
uses: docker://qgis/qgis:release-3_34
with:
args: bash -c "pip install pytest pytest-qgis && xvfb-run -a pytest -q tests/"
Breakdown: Running the suite as the first step of the release job — rather than relying on it having passed on the branch — closes the gap where a tag is pushed to a commit that was never tested. Using the same pinned container as the ordinary CI run keeps the two comparable; see running QGIS plugin tests in GitHub Actions. If the tests are slow, running them on push and requiring the tag to point at a commit with a green check is a reasonable alternative, but it must be enforced rather than assumed.
QGIS version compatibility
The examples target QGIS 3.34 LTR (Python 3.12).
| QGIS version | Python | Notes |
|---|---|---|
| 3.16 LTR | 3.7 | metadata.txt fields as described; repository upload endpoint unchanged. |
| 3.22 LTR | 3.9 | plugin_dependencies field recognised but not enforced by QGIS. |
| 3.28 LTR | 3.9 | Repository requires qgisMinimumVersion; qgisMaximumVersion optional. |
| 3.34 LTR | 3.12 | Baseline for this page. |
| 3.40+ | 3.12 | Compiled resources are increasingly unnecessary; ship .ui and images directly. |
Troubleshooting
- The repository rejects the package. The zip has no single top-level directory, or its name does not match the package name.
- The uploaded version is wrong.
metadata.txtwas not rewritten from the tag, or the leadingvwas not stripped. - The upload step passes but nothing appears.
curlreturned an HTTP error and exited zero. Add--fail-with-body. - The plugin fails to load for users.
__pycache__from a different Python version was included. Exclude it. - The release has no notes.
generate_release_notesneeds at least one merged pull request since the previous tag. - A tag was pushed to an untested commit. Run the tests inside the release job rather than trusting the branch.
Conclusion
Trigger on a version tag, derive the version from it and write it into metadata.txt at build time, zip the plugin folder with caches excluded, validate the metadata before uploading, and use --fail-with-body so a rejection fails the build. Run the tests in the same job, and the only manual step left is deciding to release.
Frequently Asked Questions
Should the version live in metadata.txt or the tag?
The tag, written into metadata.txt during the build. Two committed copies drift; a generated one cannot.
Can I publish an experimental version?
Yes — set experimental=True in metadata.txt and the repository lists it separately. Deriving that flag from whether the tag contains a pre-release suffix keeps it consistent.
Do I need to compile resources?
Only if the plugin uses a .qrc. Recent practice is to load images and .ui files from disk instead, which removes a build step and a Python-version-sensitive artefact. See adding a plugin icon and resources.
How do I handle the changelog?
Keep it in metadata.txt's changelog field for the plugin manager, and generate it from the tag range in CI so the two cannot disagree. See versioning and changelogs for a QGIS plugin.