Publishing a Plugin to the QGIS Plugin Repository

Publish a QGIS plugin to plugins.qgis.org: validate the zip and metadata.txt, create an author account, upload a first release, and manage version updates…

TL;DR: Build a zip whose single top-level folder matches the plugin package name, register an account on plugins.qgis.org, upload the zip through the site (or the XML-RPC endpoint), and for every subsequent release bump the version field in metadata.txt so QGIS Plugin Manager offers the update to installed users.

This page is part of the Packaging and Distributing QGIS Plugins guide, which sits inside the broader Headless Automation, CI/CD & Testing for PyQGIS reference. It assumes you already have a working plugin folder and a complete metadata.txt; if the metadata is still rough, read structuring plugin metadata.txt for distribution first, because the official repository rejects packages with missing or malformed fields before a human ever reviews them.

Complete Runnable Build and Validation

The repository is strict about one thing above all: the zip must contain exactly one top-level directory, and that directory name must be a valid Python package name matching the module QGIS imports. The script below builds that zip deterministically from a clean checkout, excludes development cruft, and runs a minimal pre-flight validation of metadata.txt so you never upload a package that will bounce.

bash
#!/usr/bin/env bash
# build_plugin_zip.sh — produce a repository-ready plugin zip.
# Usage: ./build_plugin_zip.sh my_plugin 1.2.0
set -euo pipefail

PLUGIN_DIR="${1:?plugin package folder name required}"
VERSION="${2:?version string required, e.g. 1.2.0}"

if [[ ! -f "${PLUGIN_DIR}/metadata.txt" ]]; then
  echo "ERROR: ${PLUGIN_DIR}/metadata.txt not found" >&2
  exit 1
fi

# Assert the version in metadata.txt matches the release version we are cutting.
META_VERSION="$(grep -E '^version=' "${PLUGIN_DIR}/metadata.txt" | cut -d= -f2 | tr -d '[:space:]')"
if [[ "${META_VERSION}" != "${VERSION}" ]]; then
  echo "ERROR: metadata.txt version=${META_VERSION} does not match ${VERSION}" >&2
  exit 1
fi

OUT="${PLUGIN_DIR}-${VERSION}.zip"
rm -f "${OUT}"

# Zip the single top-level folder; exclude caches, VCS, tests and editor files.
zip -r "${OUT}" "${PLUGIN_DIR}" \
  -x "*/__pycache__/*" \
  -x "*.pyc" \
  -x "*/.git/*" \
  -x "*/tests/*" \
  -x "*/.mypy_cache/*"

# Confirm the archive has exactly one top-level directory (repository rule).
TOP_DIRS="$(unzip -Z1 "${OUT}" | cut -d/ -f1 | sort -u | wc -l)"
if [[ "${TOP_DIRS}" -ne 1 ]]; then
  echo "ERROR: zip must contain exactly one top-level folder (found ${TOP_DIRS})" >&2
  exit 1
fi

echo "Built ${OUT} ($(du -h "${OUT}" | cut -f1))"

With a validated zip in hand you can upload it manually, or automate the upload. The QGIS plugins site exposes an XML-RPC endpoint at https://plugins.qgis.org/plugins/RPC2/, which is what an automated release pipeline should target. The Python client below authenticates with your plugins.qgis.org credentials and pushes a package.

python
"""
upload_plugin.py — push a plugin zip to plugins.qgis.org via XML-RPC.

The endpoint mirrors the web upload form: it re-validates metadata.txt,
detects the version, and either creates a new plugin or a new version of
an existing one. Requires an account on plugins.qgis.org with rights on
the target plugin.

Usage:
    QGIS_USER=alice QGIS_PASSWORD=secret \\
        python upload_plugin.py my_plugin-1.2.0.zip
"""
from __future__ import annotations

import os
import sys
import xmlrpc.client
from pathlib import Path
from urllib.parse import quote

ENDPOINT = "https://plugins.qgis.org/plugins/RPC2/"


def upload(zip_path: Path, username: str, password: str) -> int:
    """
    Upload a plugin zip and return the assigned plugin/version id.

    Raises:
        FileNotFoundError: the zip does not exist.
        xmlrpc.client.Fault: the server rejected the package (bad
            metadata, duplicate version, or insufficient permissions).
    """
    if not zip_path.is_file():
        raise FileNotFoundError(f"zip not found: {zip_path}")

    # Credentials are embedded in the URL for XML-RPC basic auth; quote
    # them so special characters survive the URL parsing step.
    auth_url = ENDPOINT.replace(
        "https://",
        f"https://{quote(username, safe='')}:{quote(password, safe='')}@",
    )
    server = xmlrpc.client.ServerProxy(auth_url, verbose=False)

    payload = xmlrpc.client.Binary(zip_path.read_bytes())
    plugin_id: int = server.plugin.upload(payload)
    return plugin_id


def main() -> None:
    """Entry point: read credentials from the environment and upload."""
    if len(sys.argv) != 2:
        sys.exit("usage: python upload_plugin.py <plugin.zip>")

    user = os.environ.get("QGIS_USER")
    pwd = os.environ.get("QGIS_PASSWORD")
    if not user or not pwd:
        sys.exit("QGIS_USER and QGIS_PASSWORD must be set")

    try:
        new_id = upload(Path(sys.argv[1]), user, pwd)
    except xmlrpc.client.Fault as fault:
        sys.exit(f"Server rejected upload: {fault.faultString}")
    print(f"Uploaded successfully; plugin/version id = {new_id}")


if __name__ == "__main__":
    main()

If you would rather keep the release step in plain shell — for example inside a minimal CI image without a Python interpreter configured — the same endpoint accepts the zip as a base64-encoded XML-RPC methodCall. The curl example below is functionally equivalent to the Python client:

bash
# Upload via XML-RPC with curl. The methodCall body wraps the zip bytes
# as a base64 <base64> value, which the endpoint decodes on ingest.
B64="$(base64 -w0 my_plugin-1.2.0.zip)"

curl -sS -u "${QGIS_USER}:${QGIS_PASSWORD}" \
  -H 'Content-Type: text/xml' \
  --data-binary @- \
  https://plugins.qgis.org/plugins/RPC2/ <<XML
<?xml version="1.0"?>
<methodCall>
  <methodName>plugin.upload</methodName>
  <params>
    <param><value><base64>${B64}</base64></value></param>
  </params>
</methodCall>
XML

The Publish Flow

Publishing is a short pipeline with two branch points: the repository’s own validation on ingest, and whether you mark the release experimental. Everything downstream — whether users see the update in Plugin Manager — hinges on the version string you set.

QGIS Plugin Publish Flow Flow from building the zip, through repository validation, to either an experimental or stable release, ending with users receiving the update through Plugin Manager. Build zip one top folder metadata valid? no Rejected — fix yes Upload release stable / experimental experimental Opt-in users only "show experimental" stable Users update Plugin Manager

Architecture Breakdown

Repository validation rules

The repository re-runs its own validation on every upload, so passing the local build script is necessary but not sufficient. Three rules cause the overwhelming majority of rejections. First, the single top-level folder: the zip must expand to exactly one directory, and that directory name must be a valid Python identifier because QGIS imports it as import <folder>. A zip that expands its files directly at the root, or contains a stray __MACOSX folder, is rejected immediately.

Second, required metadata.txt fields must all be present and non-empty: name, qgisMinimumVersion, description, version, author, and email. The email and author values are used to attribute the plugin and to contact the maintainer, so they are validated for basic sanity. The qgisMinimumVersion gates which QGIS builds even offer the plugin, so an incorrect value here silently hides your plugin from part of the user base.

Third, the folder name must match the plugin’s package name exactly. If your metadata.txt sits in my_plugin/ but the code does from myplugin import ..., QGIS loads nothing after install. The repository cannot catch every internal import, but it does key the plugin’s stored identity on that top folder, so getting it wrong on the first upload is painful to correct later.

Approval and the experimental flag

New plugins are not published the instant they upload. A first-time plugin enters a queue and is reviewed by the plugin approval team before it becomes visible in the default Plugin Manager listing; this is a lightweight sanity and safety check, not a full code audit. Once a plugin is approved, subsequent versions from an authorised maintainer publish without re-review.

The experimental=True flag in metadata.txt is the mechanism for shipping pre-release builds without disrupting your stable audience. Experimental versions are only visible to users who have ticked “Show also experimental plugins” in Plugin Manager settings. This lets you upload a release candidate, have beta testers install it, and gather feedback while the general population continues to see your last stable version. When the build is ready, you flip experimental=False, bump the version, and re-upload. Treat the flag as a release channel, not a quality disclaimer — a stable channel that ships broken code erodes trust far faster than a quiet experimental one.

Versioning and update semantics

Plugin Manager decides whether to offer an update purely by comparing the installed version string against the highest version in the repository. There is no build hash, no timestamp comparison, and no content diff — the version field is the entire contract. Two consequences follow. First, you must monotonically increase the version on every upload; re-uploading the same version string is rejected as a duplicate, and a lower version is ignored by installed clients. Second, the comparison is version-aware (it understands 1.10.0 as newer than 1.9.0), so keep to a clean numeric scheme such as MAJOR.MINOR.PATCH and avoid mixing in non-numeric suffixes that make ordering ambiguous.

Because stable and experimental versions live in the same version space, a common mistake is to publish an experimental 2.0.0 and then be unable to publish a stable 2.0.0 — you have already consumed that string. Reserve a clear band for pre-releases (for example, ship experimental builds as 2.0.0-rc style entries only if your scheme sorts them below the eventual stable release) or simply bump the patch number when the experimental build graduates to stable.

Automating Upload from CI

The upload endpoint exists precisely so releases can be cut from a pipeline rather than a browser. The natural place to wire this in is at the end of a tag-triggered workflow that has already run your test suite — see running QGIS plugin tests in GitHub Actions for the test job this release step depends on. Store your plugins.qgis.org credentials as encrypted secrets and reference them as environment variables; never commit them.

yaml
# .github/workflows/release.yml (release job only)
name: release-plugin
on:
  push:
    tags: ["v*"]

jobs:
  publish:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Build zip
        run: ./build_plugin_zip.sh my_plugin "${GITHUB_REF_NAME#v}"
      - name: Upload to plugins.qgis.org
        env:
          QGIS_USER: ${{ secrets.QGIS_USER }}
          QGIS_PASSWORD: ${{ secrets.QGIS_PASSWORD }}
        run: python upload_plugin.py "my_plugin-${GITHUB_REF_NAME#v}.zip"

The key discipline here is that the git tag (v1.2.0) drives both the zip name and the metadata check, so the release is fully reproducible from the tag alone. If metadata.txt still carries the previous version, the build script fails fast before anything reaches the repository, which is exactly the guard rail you want in an unattended pipeline.

Production Best Practices

  • Keep the top-level folder name stable forever. The repository keys your plugin’s identity to it; renaming means orphaning your install base and starting a new listing.
  • Bump version in the same commit that changes code. Tie the version string to the tag so releases are reproducible and duplicate-version rejections become impossible.
  • Use the experimental flag as a real release channel. Ship release candidates as experimental, gather feedback, then graduate to stable with a fresh version bump.
  • Set qgisMinimumVersion to the oldest build you actually test against, not an aspirational floor — an incorrect value silently hides the plugin from users on older QGIS.
  • Store credentials as CI secrets, never in the repo. The XML-RPC endpoint uses basic auth, so leaked credentials let anyone publish under your name.
  • Validate the zip locally before every upload. Assert exactly one top-level folder and a matching version; the repository rejects violations, but a local check saves a round trip.
  • Tag maintainers, not just uploaders. Grant repository permissions to a second trusted maintainer so releases are not blocked by a single account.