Skip to content

Commit 42a35b0

Browse files
authored
Add QGIS plugin publish workflow (#528)
1 parent cdb7c95 commit 42a35b0

3 files changed

Lines changed: 246 additions & 0 deletions

File tree

.github/workflows/publish.yml

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
name: Publish
2+
3+
on:
4+
release:
5+
types: [published]
6+
workflow_dispatch:
7+
inputs:
8+
tag:
9+
description: "Release tag to publish (must already exist on GitHub)"
10+
required: true
11+
type: string
12+
13+
permissions:
14+
contents: write
15+
16+
jobs:
17+
publish:
18+
name: Publish plugin to plugins.qgis.org
19+
runs-on: ubuntu-latest
20+
env:
21+
TAG: ${{ github.event.release.tag_name || inputs.tag }}
22+
PLUGIN_DIR: qgis-samgeo-plugin
23+
PLUGIN_NAME: samgeo_plugin
24+
ZIP_PATH: dist/samgeo_plugin.zip
25+
steps:
26+
- uses: actions/checkout@v6
27+
28+
- uses: actions/setup-python@v6
29+
with:
30+
python-version: "3.13"
31+
32+
- name: Build plugin zip
33+
run: python "package_plugin.py" --source "$PLUGIN_DIR" --name "$PLUGIN_NAME" --output "$ZIP_PATH"
34+
35+
- name: Verify metadata version matches release tag
36+
run: |
37+
metadata_version=$(grep '^version=' "$PLUGIN_DIR/metadata.txt" | cut -d'=' -f2 | tr -d '[:space:]')
38+
tag_version="${TAG#v}"
39+
if [ "$metadata_version" != "$tag_version" ]; then
40+
echo "::error::metadata.txt version ($metadata_version) does not match release tag ($tag_version)"
41+
exit 1
42+
fi
43+
44+
- name: Attach zip to GitHub release
45+
env:
46+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
47+
run: |
48+
gh release upload "$TAG" "$ZIP_PATH" --clobber
49+
50+
- name: Upload to plugins.qgis.org
51+
env:
52+
QGIS_PLUGIN_REPO_USERNAME: ${{ secrets.QGIS_PLUGIN_REPO_USERNAME }}
53+
QGIS_PLUGIN_REPO_PASSWORD: ${{ secrets.QGIS_PLUGIN_REPO_PASSWORD }}
54+
run: python scripts/upload_to_qgis_plugin_repo.py "$ZIP_PATH"

package_plugin.py

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
#!/usr/bin/env python3
2+
"""Package a QGIS plugin directory for upload to the official QGIS plugin repository."""
3+
4+
from __future__ import annotations
5+
6+
import argparse
7+
import os
8+
import re
9+
import zipfile
10+
from pathlib import Path
11+
12+
EXCLUDE_PATTERNS = [
13+
r"^ui_.*\.py$",
14+
r"^resources_rc\.py$",
15+
r"^.*_rc\.py$",
16+
r"^.*\.pyc$",
17+
r"^.*\.pyo$",
18+
r"^.*\.bak$",
19+
r"^.*~$",
20+
r"^\..*\.swp$",
21+
r"^.*\.orig$",
22+
r"^package_plugin\.py$",
23+
]
24+
25+
EXCLUDE_DIRS = {
26+
"__pycache__",
27+
"__MACOSX",
28+
".git",
29+
".svn",
30+
".hg",
31+
".github",
32+
".idea",
33+
".vscode",
34+
".pytest_cache",
35+
".mypy_cache",
36+
".tox",
37+
".eggs",
38+
"build",
39+
"dist",
40+
"node_modules",
41+
"scripts",
42+
"help",
43+
}
44+
45+
46+
def should_exclude_file(filename: str) -> bool:
47+
return any(re.match(pattern, filename) for pattern in EXCLUDE_PATTERNS)
48+
49+
50+
def should_exclude_dir(dirname: str) -> bool:
51+
return dirname.startswith(".") or dirname in EXCLUDE_DIRS or dirname.endswith(".egg-info")
52+
53+
54+
def get_version_from_metadata(plugin_dir: Path) -> str:
55+
metadata_file = plugin_dir / "metadata.txt"
56+
if metadata_file.exists():
57+
with metadata_file.open("r", encoding="utf-8") as f:
58+
for line in f:
59+
if line.startswith("version="):
60+
return line.split("=", 1)[1].strip()
61+
return "unknown"
62+
63+
64+
def package_plugin(source_dir: Path, output_path: Path | None, target_name: str) -> Path:
65+
if not source_dir.exists():
66+
raise FileNotFoundError(f"Source directory not found: {source_dir}")
67+
if not source_dir.is_dir():
68+
raise ValueError(f"Source path is not a directory: {source_dir}")
69+
70+
version = get_version_from_metadata(source_dir)
71+
if output_path is None:
72+
output_path = source_dir.parent / f"{target_name}-{version}.zip"
73+
74+
output_path.parent.mkdir(parents=True, exist_ok=True)
75+
if output_path.exists():
76+
output_path.unlink()
77+
78+
print(f"Packaging plugin from: {source_dir}")
79+
print(f"Output zip file: {output_path}")
80+
print(f"Root folder name in zip: {target_name}")
81+
print(f"Plugin version: {version}")
82+
83+
files_added = 0
84+
files_excluded = 0
85+
with zipfile.ZipFile(output_path, "w", zipfile.ZIP_DEFLATED) as zipf:
86+
for root, dirs, files in os.walk(source_dir):
87+
dirs[:] = [d for d in dirs if not should_exclude_dir(d)]
88+
for file in files:
89+
file_path = Path(root) / file
90+
if should_exclude_file(file) or file.startswith("."):
91+
files_excluded += 1
92+
continue
93+
rel_path = file_path.relative_to(source_dir)
94+
archive_name = Path(target_name) / rel_path
95+
zipf.write(file_path, archive_name)
96+
files_added += 1
97+
98+
print(f"Package created successfully: {output_path}")
99+
print(f"Files added: {files_added}")
100+
print(f"Files excluded: {files_excluded}")
101+
return output_path
102+
103+
104+
def main() -> int:
105+
parser = argparse.ArgumentParser(description=__doc__)
106+
parser.add_argument("--output", "-o", type=Path, default=None, help="Output path for the zip file")
107+
parser.add_argument("--source", "-s", type=Path, default=Path("."), help="Source plugin directory")
108+
parser.add_argument("--name", "-n", default=None, help="Target plugin folder name in the zip")
109+
args = parser.parse_args()
110+
111+
source_dir = args.source.resolve()
112+
target_name = args.name or source_dir.name
113+
package_plugin(source_dir, args.output, target_name)
114+
return 0
115+
116+
117+
if __name__ == "__main__":
118+
raise SystemExit(main())
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
#!/usr/bin/env python3
2+
"""Upload a packaged QGIS plugin zip to plugins.qgis.org via XML-RPC.
3+
4+
The official plugin repository exposes an XML-RPC endpoint at
5+
``https://plugins.qgis.org/plugins/RPC2/`` with a ``plugin.upload`` method
6+
that accepts the zipped plugin as a base64-encoded ``Binary`` payload and
7+
returns the new plugin id and version id on success.
8+
9+
Credentials must belong to a user with upload rights for the plugin and are
10+
read from the ``QGIS_PLUGIN_REPO_USERNAME`` and ``QGIS_PLUGIN_REPO_PASSWORD``
11+
environment variables so this script can be used from CI without leaking
12+
secrets onto the command line.
13+
"""
14+
15+
from __future__ import annotations
16+
17+
import argparse
18+
import os
19+
import sys
20+
from urllib.parse import quote
21+
from xmlrpc.client import Binary, Fault, ProtocolError, ServerProxy
22+
23+
REPO_URL_TEMPLATE = "https://{user}:{password}@plugins.qgis.org/plugins/RPC2/"
24+
25+
26+
def upload(zip_path: str, username: str, password: str) -> tuple[int, int]:
27+
"""Upload the given zip to plugins.qgis.org and return ``(plugin_id, version_id)``."""
28+
with open(zip_path, "rb") as fh:
29+
payload = Binary(fh.read())
30+
31+
endpoint = REPO_URL_TEMPLATE.format(
32+
user=quote(username, safe=""),
33+
password=quote(password, safe=""),
34+
)
35+
server = ServerProxy(endpoint, verbose=False)
36+
plugin_id, version_id = server.plugin.upload(payload)
37+
return plugin_id, version_id
38+
39+
40+
def main() -> int:
41+
parser = argparse.ArgumentParser(description=__doc__)
42+
parser.add_argument("zip_path", help="Path to the packaged plugin zip")
43+
args = parser.parse_args()
44+
45+
username = os.environ.get("QGIS_PLUGIN_REPO_USERNAME")
46+
password = os.environ.get("QGIS_PLUGIN_REPO_PASSWORD")
47+
if not username or not password:
48+
print(
49+
"Error: QGIS_PLUGIN_REPO_USERNAME and QGIS_PLUGIN_REPO_PASSWORD must be set.",
50+
file=sys.stderr,
51+
)
52+
return 1
53+
54+
if not os.path.isfile(args.zip_path):
55+
print(f"Error: zip file not found: {args.zip_path}", file=sys.stderr)
56+
return 1
57+
58+
try:
59+
plugin_id, version_id = upload(args.zip_path, username, password)
60+
except Fault as exc:
61+
print(
62+
f"Upload failed: {exc.faultString} (code {exc.faultCode})", file=sys.stderr
63+
)
64+
return 1
65+
except ProtocolError as exc:
66+
print(f"Upload failed: HTTP {exc.errcode} {exc.errmsg}", file=sys.stderr)
67+
return 1
68+
69+
print(f"Uploaded plugin id={plugin_id}, version id={version_id}")
70+
return 0
71+
72+
73+
if __name__ == "__main__":
74+
sys.exit(main())

0 commit comments

Comments
 (0)