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.

One tag, four automated stepsPushing a version tag triggers the workflow. The version is derived from the tag name and written into metadata.txt, the plugin directory is packaged as a zip named after the plugin, the package is validated, and it is then attached to a GitHub release and uploaded to the QGIS plugin repository.The tag is the single source of the versiongit tag v1.4.0the only manual stepderive versionwrite metadata.txtstrip the leading vbuild the zipone top-level folderno .git, no cachespublishGitHub release assetplugins.qgis.org uploadwhat this preventsa zip whose metadata version disagrees with its filename,and a package containing the whole repository

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.

The zip structure the repository requiresA valid package contains exactly one top level directory named after the plugin package, with metadata.txt and the module files inside it. An invalid package has those files at the archive root, which the plugin manager cannot install because it has no folder name to use.Zip the folder, not its contentsacceptedmy_plugin/__init__.pymetadata.txtmy_plugin.pyone top-level folderrejected__init__.pymetadata.txtmy_plugin.py__pycache__/no folder to install into

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 versionPythonNotes
3.16 LTR3.7metadata.txt fields as described; repository upload endpoint unchanged.
3.22 LTR3.9plugin_dependencies field recognised but not enforced by QGIS.
3.28 LTR3.9Repository requires qgisMinimumVersion; qgisMaximumVersion optional.
3.34 LTR3.12Baseline for this page.
3.40+3.12Compiled 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.txt was not rewritten from the tag, or the leading v was not stripped.
  • The upload step passes but nothing appears. curl returned 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_notes needs 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.