[{"data":1,"prerenderedAt":1435},["ShallowReactive",2],{"doc:\u002Fqgis-plugin-development\u002Fpublishing-to-the-qgis-plugin-repository\u002Fautomate-plugin-release-with-github-actions":3},{"id":4,"title":5,"body":6,"description":1424,"extension":1425,"meta":1426,"navigation":295,"path":1431,"seo":1432,"stem":1433,"__hash__":1434},"docs\u002Fqgis-plugin-development\u002Fpublishing-to-the-qgis-plugin-repository\u002Fautomate-plugin-release-with-github-actions\u002Findex.md","Automate a QGIS Plugin Release with GitHub Actions",{"type":7,"value":8,"toc":1410},"minimark",[9,13,26,35,192,197,222,226,232,410,440,446,450,453,497,515,518,592,607,719,723,726,796,808,812,819,884,895,906,910,913,1042,1068,1072,1075,1132,1142,1146,1153,1245,1249,1301,1305,1314,1318,1327,1340,1357,1374,1378,1406],[10,11,5],"h1",{"id":12},"automate-a-qgis-plugin-release-with-github-actions",[14,15,16,17,21,22,25],"p",{},"Releasing a QGIS plugin by hand is four steps that are individually trivial and collectively easy to get wrong: bump the version in ",[18,19,20],"code",{},"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 ",[18,23,24],{},".git"," directory.",[14,27,28,29,34],{},"This recipe belongs to ",[30,31,33],"a",{"href":32},"\u002Fqgis-plugin-development\u002Fpublishing-to-the-qgis-plugin-repository\u002F","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.",[14,36,37],{},[38,39,44,48,52,59,76,85,94,100,105,112,118,124,129,133,137,140,144,147,150,154,159,163,166,169,178,183,188],"svg",{"viewBox":40,"role":41,"ariaLabel":42,"xmlns":43},"0 0 760 300","img","A release pipeline triggered by a version tag, deriving the version, building the zip, validating it, creating a release and uploading to the plugin repository","http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg",[45,46,47],"title",{},"One tag, four automated steps",[49,50,51],"desc",{},"Pushing 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.",[53,54],"rect",{"x":55,"y":55,"width":56,"height":57,"fill":58},"0","760","300","#f6f3ea",[60,61,62],"defs",{},[63,64,71],"marker",{"id":65,"viewBox":66,"refX":67,"refY":68,"markerWidth":69,"markerHeight":69,"orient":70},"relArrow","0 0 10 10","8","5","7","auto-start-reverse",[72,73],"path",{"d":74,"fill":75},"M0 0 L10 5 L0 10 z","#2f3b35",[77,78,84],"text",{"x":79,"y":80,"style":81,"fill":82,"textAnchor":83},"380","28","text-anchor:middle;font-size:14px;font-weight:bold;font-family:sans-serif","#17211d","middle","The tag is the single source of the version",[53,86],{"x":87,"y":88,"width":89,"height":90,"rx":67,"fill":91,"stroke":92,"style":93},"20","76","136","72","#eff3ff","#2563eb","stroke-width:2.5",[77,95,99],{"x":96,"y":97,"style":98,"fill":92,"textAnchor":83},"88","104","text-anchor:middle;font-size:10.5px;font-weight:bold;font-family:monospace","git tag v1.4.0",[77,101,104],{"x":96,"y":102,"style":103,"fill":75,"textAnchor":83},"128","text-anchor:middle;font-size:10px;font-family:sans-serif","the only manual step",[106,107],"line",{"x1":108,"y1":109,"x2":110,"y2":109,"stroke":75,"style":111},"162","112","188","stroke-width:2;marker-end:url(#relArrow)",[53,113],{"x":114,"y":88,"width":89,"height":90,"rx":67,"fill":115,"stroke":116,"style":117},"196","#eef7f4","#0f766e","stroke-width:2",[77,119,123],{"x":120,"y":121,"style":122,"fill":116,"textAnchor":83},"264","100","text-anchor:middle;font-size:10px;font-weight:bold;font-family:sans-serif","derive version",[77,125,128],{"x":120,"y":126,"style":127,"fill":75,"textAnchor":83},"122","text-anchor:middle;font-size:9.5px;font-family:sans-serif","write metadata.txt",[77,130,132],{"x":120,"y":131,"style":127,"fill":75,"textAnchor":83},"140","strip the leading v",[106,134],{"x1":135,"y1":109,"x2":136,"y2":109,"stroke":75,"style":111},"338","364",[53,138],{"x":139,"y":88,"width":89,"height":90,"rx":67,"fill":115,"stroke":116,"style":117},"372",[77,141,143],{"x":142,"y":121,"style":122,"fill":116,"textAnchor":83},"440","build the zip",[77,145,146],{"x":142,"y":126,"style":127,"fill":75,"textAnchor":83},"one top-level folder",[77,148,149],{"x":142,"y":131,"style":127,"fill":75,"textAnchor":83},"no .git, no caches",[106,151],{"x1":152,"y1":109,"x2":153,"y2":109,"stroke":75,"style":111},"514","540",[53,155],{"x":156,"y":88,"width":110,"height":90,"rx":67,"fill":157,"stroke":158,"style":93},"548","#edf8e9","#15803d",[77,160,162],{"x":161,"y":121,"style":122,"fill":158,"textAnchor":83},"642","publish",[77,164,165],{"x":161,"y":126,"style":127,"fill":75,"textAnchor":83},"GitHub release asset",[77,167,168],{"x":161,"y":131,"style":127,"fill":75,"textAnchor":83},"plugins.qgis.org upload",[53,170],{"x":171,"y":172,"width":173,"height":174,"rx":175,"fill":176,"stroke":177,"style":93},"120","192","520","80","10","#fffdf7","#b91c1c",[77,179,182],{"x":79,"y":180,"style":181,"fill":177,"textAnchor":83},"218","text-anchor:middle;font-size:11.5px;font-weight:bold;font-family:sans-serif","what this prevents",[77,184,187],{"x":79,"y":185,"style":186,"fill":75,"textAnchor":83},"242","text-anchor:middle;font-size:10.5px;font-family:sans-serif","a zip whose metadata version disagrees with its filename,",[77,189,191],{"x":79,"y":190,"style":186,"fill":75,"textAnchor":83},"260","and a package containing the whole repository",[193,194,196],"h2",{"id":195},"prerequisites","Prerequisites",[198,199,200,209,219],"ul",{},[201,202,203,204,208],"li",{},"A plugin repository whose plugin package is a single directory, as produced by ",[30,205,207],{"href":206},"\u002Fqgis-plugin-development\u002Fplugin-boilerplate-structure\u002Fcreate-qgis-plugin-with-plugin-builder\u002F","Plugin Builder",".",[201,210,211,212,214,215,208],{},"A valid ",[18,213,20],{},"; the repository rejects packages with missing required fields. See ",[30,216,218],{"href":217},"\u002Fqgis-plugin-development\u002Fpublishing-to-the-qgis-plugin-repository\u002Fwrite-metadata-txt-qgis-plugin\u002F","writing metadata.txt",[201,220,221],{},"For the repository upload, an account on plugins.qgis.org with rights to the plugin, stored as repository secrets.",[193,223,225],{"id":224},"derive-the-version-from-the-tag","Derive the version from the tag",[14,227,228,229,231],{},"The version must appear in ",[18,230,20],{},", and having it in two places guarantees they will disagree eventually.",[233,234,239],"pre",{"className":235,"code":236,"language":237,"meta":238,"style":238},"language-yaml shiki shiki-themes github-dark","name: release\non:\n  push:\n    tags: [\"v*\"]\n\njobs:\n  build:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions\u002Fcheckout@v4\n\n      - name: Derive version\n        id: version\n        run: |\n          VERSION=\"${GITHUB_REF_NAME#v}\"\n          echo \"version=$VERSION\" >> \"$GITHUB_OUTPUT\"\n          sed -i \"s\u002F^version=.*\u002Fversion=$VERSION\u002F\" my_plugin\u002Fmetadata.txt\n          grep '^version=' my_plugin\u002Fmetadata.txt\n","yaml","",[18,240,241,257,267,275,290,297,305,313,324,332,346,351,363,374,386,392,398,404],{"__ignoreMap":238},[242,243,245,249,253],"span",{"class":106,"line":244},1,[242,246,248],{"class":247},"s4JwU","name",[242,250,252],{"class":251},"s95oV",": ",[242,254,256],{"class":255},"sU2Wk","release\n",[242,258,260,264],{"class":106,"line":259},2,[242,261,263],{"class":262},"sDLfK","on",[242,265,266],{"class":251},":\n",[242,268,270,273],{"class":106,"line":269},3,[242,271,272],{"class":247},"  push",[242,274,266],{"class":251},[242,276,278,281,284,287],{"class":106,"line":277},4,[242,279,280],{"class":247},"    tags",[242,282,283],{"class":251},": [",[242,285,286],{"class":255},"\"v*\"",[242,288,289],{"class":251},"]\n",[242,291,293],{"class":106,"line":292},5,[242,294,296],{"emptyLinePlaceholder":295},true,"\n",[242,298,300,303],{"class":106,"line":299},6,[242,301,302],{"class":247},"jobs",[242,304,266],{"class":251},[242,306,308,311],{"class":106,"line":307},7,[242,309,310],{"class":247},"  build",[242,312,266],{"class":251},[242,314,316,319,321],{"class":106,"line":315},8,[242,317,318],{"class":247},"    runs-on",[242,320,252],{"class":251},[242,322,323],{"class":255},"ubuntu-latest\n",[242,325,327,330],{"class":106,"line":326},9,[242,328,329],{"class":247},"    steps",[242,331,266],{"class":251},[242,333,335,338,341,343],{"class":106,"line":334},10,[242,336,337],{"class":251},"      - ",[242,339,340],{"class":247},"uses",[242,342,252],{"class":251},[242,344,345],{"class":255},"actions\u002Fcheckout@v4\n",[242,347,349],{"class":106,"line":348},11,[242,350,296],{"emptyLinePlaceholder":295},[242,352,354,356,358,360],{"class":106,"line":353},12,[242,355,337],{"class":251},[242,357,248],{"class":247},[242,359,252],{"class":251},[242,361,362],{"class":255},"Derive version\n",[242,364,366,369,371],{"class":106,"line":365},13,[242,367,368],{"class":247},"        id",[242,370,252],{"class":251},[242,372,373],{"class":255},"version\n",[242,375,377,380,382],{"class":106,"line":376},14,[242,378,379],{"class":247},"        run",[242,381,252],{"class":251},[242,383,385],{"class":384},"snl16","|\n",[242,387,389],{"class":106,"line":388},15,[242,390,391],{"class":255},"          VERSION=\"${GITHUB_REF_NAME#v}\"\n",[242,393,395],{"class":106,"line":394},16,[242,396,397],{"class":255},"          echo \"version=$VERSION\" >> \"$GITHUB_OUTPUT\"\n",[242,399,401],{"class":106,"line":400},17,[242,402,403],{"class":255},"          sed -i \"s\u002F^version=.*\u002Fversion=$VERSION\u002F\" my_plugin\u002Fmetadata.txt\n",[242,405,407],{"class":106,"line":406},18,[242,408,409],{"class":255},"          grep '^version=' my_plugin\u002Fmetadata.txt\n",[14,411,412,416,417,420,421,424,425,428,429,432,433,435,436,439],{},[413,414,415],"strong",{},"Breakdown:"," ",[18,418,419],{},"${GITHUB_REF_NAME#v}"," strips a leading ",[18,422,423],{},"v",", so the tag ",[18,426,427],{},"v1.4.0"," produces the version ",[18,430,431],{},"1.4.0"," — the plugin repository expects a bare version and rejects one with a prefix. Writing it into ",[18,434,20],{}," 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 ",[18,437,438],{},"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.",[14,441,442,443,445],{},"Committing the modified ",[18,444,20],{}," back is not necessary and is best avoided: the released artefact carries the version, and the repository stays clean.",[193,447,449],{"id":448},"build-a-package-the-repository-accepts","Build a package the repository accepts",[14,451,452],{},"The plugin repository is strict about structure, and three rules cover it.",[233,454,456],{"className":235,"code":455,"language":237,"meta":238,"style":238},"      - name: Package\n        run: |\n          mkdir -p dist\n          zip -r \"dist\u002Fmy_plugin-${{ steps.version.outputs.version }}.zip\" my_plugin \\\n            -x '*\u002F__pycache__\u002F*' '*.pyc' '*\u002F.git\u002F*' '*\u002Ftests\u002F*'\n          unzip -l \"dist\u002Fmy_plugin-${{ steps.version.outputs.version }}.zip\" | head -20\n",[18,457,458,469,477,482,487,492],{"__ignoreMap":238},[242,459,460,462,464,466],{"class":106,"line":244},[242,461,337],{"class":251},[242,463,248],{"class":247},[242,465,252],{"class":251},[242,467,468],{"class":255},"Package\n",[242,470,471,473,475],{"class":106,"line":259},[242,472,379],{"class":247},[242,474,252],{"class":251},[242,476,385],{"class":384},[242,478,479],{"class":106,"line":269},[242,480,481],{"class":255},"          mkdir -p dist\n",[242,483,484],{"class":106,"line":277},[242,485,486],{"class":255},"          zip -r \"dist\u002Fmy_plugin-${{ steps.version.outputs.version }}.zip\" my_plugin \\\n",[242,488,489],{"class":106,"line":292},[242,490,491],{"class":255},"            -x '*\u002F__pycache__\u002F*' '*.pyc' '*\u002F.git\u002F*' '*\u002Ftests\u002F*'\n",[242,493,494],{"class":106,"line":299},[242,495,496],{"class":255},"          unzip -l \"dist\u002Fmy_plugin-${{ steps.version.outputs.version }}.zip\" | head -20\n",[14,498,499,501,502,506,507,510,511,514],{},[413,500,415],{}," The zip must contain exactly one top-level directory, and that directory's name must match the plugin's package name — zipping the ",[503,504,505],"em",{},"contents"," rather than the folder is the single most common rejection. Excluding ",[18,508,509],{},"__pycache__"," and ",[18,512,513],{},".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.",[14,516,517],{},"Validate before publishing rather than after:",[233,519,521],{"className":235,"code":520,"language":237,"meta":238,"style":238},"      - name: Validate metadata\n        run: |\n          python3 - \u003C\u003C'PY'\n          import configparser, sys\n          cfg = configparser.ConfigParser()\n          cfg.read(\"my_plugin\u002Fmetadata.txt\")\n          required = [\"name\", \"qgisMinimumVersion\", \"description\", \"version\", \"author\", \"email\"]\n          missing = [k for k in required if not cfg.get(\"general\", k, fallback=\"\").strip()]\n          if missing:\n              sys.exit(f\"metadata.txt is missing: {missing}\")\n          print(\"metadata ok:\", cfg.get(\"general\", \"version\"))\n          PY\n",[18,522,523,534,542,547,552,557,562,567,572,577,582,587],{"__ignoreMap":238},[242,524,525,527,529,531],{"class":106,"line":244},[242,526,337],{"class":251},[242,528,248],{"class":247},[242,530,252],{"class":251},[242,532,533],{"class":255},"Validate metadata\n",[242,535,536,538,540],{"class":106,"line":259},[242,537,379],{"class":247},[242,539,252],{"class":251},[242,541,385],{"class":384},[242,543,544],{"class":106,"line":269},[242,545,546],{"class":255},"          python3 - \u003C\u003C'PY'\n",[242,548,549],{"class":106,"line":277},[242,550,551],{"class":255},"          import configparser, sys\n",[242,553,554],{"class":106,"line":292},[242,555,556],{"class":255},"          cfg = configparser.ConfigParser()\n",[242,558,559],{"class":106,"line":299},[242,560,561],{"class":255},"          cfg.read(\"my_plugin\u002Fmetadata.txt\")\n",[242,563,564],{"class":106,"line":307},[242,565,566],{"class":255},"          required = [\"name\", \"qgisMinimumVersion\", \"description\", \"version\", \"author\", \"email\"]\n",[242,568,569],{"class":106,"line":315},[242,570,571],{"class":255},"          missing = [k for k in required if not cfg.get(\"general\", k, fallback=\"\").strip()]\n",[242,573,574],{"class":106,"line":326},[242,575,576],{"class":255},"          if missing:\n",[242,578,579],{"class":106,"line":334},[242,580,581],{"class":255},"              sys.exit(f\"metadata.txt is missing: {missing}\")\n",[242,583,584],{"class":106,"line":348},[242,585,586],{"class":255},"          print(\"metadata ok:\", cfg.get(\"general\", \"version\"))\n",[242,588,589],{"class":106,"line":353},[242,590,591],{"class":255},"          PY\n",[14,593,594,416,596,598,599,602,603,606],{},[413,595,415],{},[18,597,20],{}," is an INI file, so ",[18,600,601],{},"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. ",[18,604,605],{},"qgisMinimumVersion"," is the field most often wrong — it must be the oldest version actually tested, not the oldest that plausibly works.",[14,608,609],{},[38,610,613,616,619,622,625,631,636,644,650,659,665,668,671,674,678,681,685,689,692,696,699,702,705,708,712,716],{"viewBox":611,"role":41,"ariaLabel":612,"xmlns":43},"0 0 760 288","A correctly structured plugin zip with one top-level directory compared with an incorrect one whose contents sit at the archive root",[45,614,615],{},"The zip structure the repository requires",[49,617,618],{},"A 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.",[53,620],{"x":55,"y":55,"width":56,"height":621,"fill":58},"288",[77,623,624],{"x":79,"y":80,"style":81,"fill":82,"textAnchor":83},"Zip the folder, not its contents",[53,626],{"x":627,"y":628,"width":629,"height":630,"rx":175,"fill":157,"stroke":158,"style":93},"26","52","330","204",[77,632,635],{"x":633,"y":174,"style":634,"fill":158,"textAnchor":83},"191","text-anchor:middle;font-size:12px;font-weight:bold;font-family:sans-serif","accepted",[53,637],{"x":638,"y":639,"width":640,"height":627,"rx":641,"fill":642,"stroke":158,"style":643},"50","96","282","4","#e8efe6","stroke-width:1.4",[77,645,649],{"x":646,"y":647,"style":648,"fill":75},"66","114","font-size:10px;font-family:monospace","my_plugin\u002F",[53,651],{"x":652,"y":653,"width":654,"height":655,"rx":656,"fill":176,"stroke":657,"style":658},"74","126","258","24","3","#59645f","stroke-width:1.2",[77,660,664],{"x":661,"y":662,"style":663,"fill":75},"90","143","font-size:9.5px;font-family:monospace","__init__.py",[53,666],{"x":652,"y":667,"width":654,"height":655,"rx":656,"fill":176,"stroke":657,"style":658},"154",[77,669,20],{"x":661,"y":670,"style":663,"fill":75},"171",[53,672],{"x":652,"y":673,"width":654,"height":655,"rx":656,"fill":176,"stroke":657,"style":658},"182",[77,675,677],{"x":661,"y":676,"style":663,"fill":75},"199","my_plugin.py",[77,679,146],{"x":633,"y":680,"style":186,"fill":158,"textAnchor":83},"234",[53,682],{"x":683,"y":628,"width":629,"height":630,"rx":175,"fill":684,"stroke":177,"style":93},"404","#fdf2e2",[77,686,688],{"x":687,"y":174,"style":634,"fill":177,"textAnchor":83},"569","rejected",[53,690],{"x":691,"y":639,"width":640,"height":655,"rx":656,"fill":176,"stroke":657,"style":658},"428",[77,693,664],{"x":694,"y":695,"style":663,"fill":75},"444","113",[53,697],{"x":691,"y":698,"width":640,"height":655,"rx":656,"fill":176,"stroke":657,"style":658},"124",[77,700,20],{"x":694,"y":701,"style":663,"fill":75},"141",[53,703],{"x":691,"y":704,"width":640,"height":655,"rx":656,"fill":176,"stroke":657,"style":658},"152",[77,706,677],{"x":694,"y":707,"style":663,"fill":75},"169",[53,709],{"x":691,"y":710,"width":640,"height":655,"rx":656,"fill":711,"stroke":177,"style":658},"180","#efeadd",[77,713,715],{"x":694,"y":714,"style":663,"fill":177},"197","__pycache__\u002F",[77,717,718],{"x":687,"y":680,"style":186,"fill":177,"textAnchor":83},"no folder to install into",[193,720,722],{"id":721},"attach-it-to-a-release","Attach it to a release",[14,724,725],{},"The GitHub release is the durable artefact, and it is also what a custom plugin repository can point at.",[233,727,729],{"className":235,"code":728,"language":237,"meta":238,"style":238},"      - name: Create release\n        uses: softprops\u002Faction-gh-release@v2\n        with:\n          files: dist\u002F*.zip\n          generate_release_notes: true\n        env:\n          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n",[18,730,731,742,752,759,769,779,786],{"__ignoreMap":238},[242,732,733,735,737,739],{"class":106,"line":244},[242,734,337],{"class":251},[242,736,248],{"class":247},[242,738,252],{"class":251},[242,740,741],{"class":255},"Create release\n",[242,743,744,747,749],{"class":106,"line":259},[242,745,746],{"class":247},"        uses",[242,748,252],{"class":251},[242,750,751],{"class":255},"softprops\u002Faction-gh-release@v2\n",[242,753,754,757],{"class":106,"line":269},[242,755,756],{"class":247},"        with",[242,758,266],{"class":251},[242,760,761,764,766],{"class":106,"line":277},[242,762,763],{"class":247},"          files",[242,765,252],{"class":251},[242,767,768],{"class":255},"dist\u002F*.zip\n",[242,770,771,774,776],{"class":106,"line":292},[242,772,773],{"class":247},"          generate_release_notes",[242,775,252],{"class":251},[242,777,778],{"class":262},"true\n",[242,780,781,784],{"class":106,"line":299},[242,782,783],{"class":247},"        env",[242,785,266],{"class":251},[242,787,788,791,793],{"class":106,"line":307},[242,789,790],{"class":247},"          GITHUB_TOKEN",[242,792,252],{"class":251},[242,794,795],{"class":255},"${{ secrets.GITHUB_TOKEN }}\n",[14,797,798,416,800,803,804,807],{},[413,799,415],{},[18,801,802],{},"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 ",[18,805,806],{},"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.",[193,809,811],{"id":810},"upload-to-pluginsqgisorg","Upload to plugins.qgis.org",[14,813,814,815,818],{},"The repository accepts an authenticated form post, which is one ",[18,816,817],{},"curl"," invocation.",[233,820,822],{"className":235,"code":821,"language":237,"meta":238,"style":238},"      - name: Upload to the QGIS plugin repository\n        run: |\n          curl --fail-with-body -u \"$OSGEO_USER:$OSGEO_PASSWORD\" \\\n            -F \"package=@dist\u002Fmy_plugin-${{ steps.version.outputs.version }}.zip\" \\\n            https:\u002F\u002Fplugins.qgis.org\u002Fplugins\u002Fadd\u002F\n        env:\n          OSGEO_USER: ${{ secrets.OSGEO_USER }}\n          OSGEO_PASSWORD: ${{ secrets.OSGEO_PASSWORD }}\n",[18,823,824,835,843,848,853,858,864,874],{"__ignoreMap":238},[242,825,826,828,830,832],{"class":106,"line":244},[242,827,337],{"class":251},[242,829,248],{"class":247},[242,831,252],{"class":251},[242,833,834],{"class":255},"Upload to the QGIS plugin repository\n",[242,836,837,839,841],{"class":106,"line":259},[242,838,379],{"class":247},[242,840,252],{"class":251},[242,842,385],{"class":384},[242,844,845],{"class":106,"line":269},[242,846,847],{"class":255},"          curl --fail-with-body -u \"$OSGEO_USER:$OSGEO_PASSWORD\" \\\n",[242,849,850],{"class":106,"line":277},[242,851,852],{"class":255},"            -F \"package=@dist\u002Fmy_plugin-${{ steps.version.outputs.version }}.zip\" \\\n",[242,854,855],{"class":106,"line":292},[242,856,857],{"class":255},"            https:\u002F\u002Fplugins.qgis.org\u002Fplugins\u002Fadd\u002F\n",[242,859,860,862],{"class":106,"line":299},[242,861,783],{"class":247},[242,863,266],{"class":251},[242,865,866,869,871],{"class":106,"line":307},[242,867,868],{"class":247},"          OSGEO_USER",[242,870,252],{"class":251},[242,872,873],{"class":255},"${{ secrets.OSGEO_USER }}\n",[242,875,876,879,881],{"class":106,"line":315},[242,877,878],{"class":247},"          OSGEO_PASSWORD",[242,880,252],{"class":251},[242,882,883],{"class":255},"${{ secrets.OSGEO_PASSWORD }}\n",[14,885,886,416,888,891,892,894],{},[413,887,415],{},[18,889,890],{},"--fail-with-body"," is the flag that matters: plain ",[18,893,817],{}," 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.",[14,896,897,898,901,902,905],{},"The upload endpoint changes occasionally and some plugins use the ",[18,899,900],{},"qgis-plugin-ci"," tool, which wraps this along with translation building and changelog extraction. For a plugin with translations or a ",[18,903,904],{},"CHANGELOG.md"," the repository should display, that tool saves more than it costs.",[193,907,909],{"id":908},"releasing-to-a-private-repository","Releasing to a private repository",[14,911,912],{},"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.",[233,914,916],{"className":235,"code":915,"language":237,"meta":238,"style":238},"      - name: Update the internal repository index\n        run: |\n          python3 - \u003C\u003C'PY'\n          import configparser, os, xml.etree.ElementTree as ET\n          version = os.environ[\"VERSION\"]\n          cfg = configparser.ConfigParser()\n          cfg.read(\"my_plugin\u002Fmetadata.txt\")\n          general = cfg[\"general\"]\n\n          root = ET.Element(\"plugins\")\n          plugin = ET.SubElement(root, \"pyqgis_plugin\", name=general[\"name\"], version=version)\n          for tag in (\"description\", \"about\", \"qgis_minimum_version\", \"author_name\"):\n              key = {\"qgis_minimum_version\": \"qgisMinimumVersion\", \"author_name\": \"author\"}.get(tag, tag)\n              ET.SubElement(plugin, tag).text = general.get(key, \"\")\n          ET.SubElement(plugin, \"download_url\").text = (\n              f\"https:\u002F\u002Fplugins.internal.example.org\u002Fmy_plugin-{version}.zip\"\n          )\n          ET.SubElement(plugin, \"file_name\").text = f\"my_plugin-{version}.zip\"\n          ET.ElementTree(root).write(\"dist\u002Fplugins.xml\", encoding=\"utf-8\", xml_declaration=True)\n          PY\n        env:\n          VERSION: ${{ steps.version.outputs.version }}\n",[18,917,918,929,937,941,946,951,955,959,964,968,973,978,983,988,993,998,1003,1008,1013,1019,1024,1031],{"__ignoreMap":238},[242,919,920,922,924,926],{"class":106,"line":244},[242,921,337],{"class":251},[242,923,248],{"class":247},[242,925,252],{"class":251},[242,927,928],{"class":255},"Update the internal repository index\n",[242,930,931,933,935],{"class":106,"line":259},[242,932,379],{"class":247},[242,934,252],{"class":251},[242,936,385],{"class":384},[242,938,939],{"class":106,"line":269},[242,940,546],{"class":255},[242,942,943],{"class":106,"line":277},[242,944,945],{"class":255},"          import configparser, os, xml.etree.ElementTree as ET\n",[242,947,948],{"class":106,"line":292},[242,949,950],{"class":255},"          version = os.environ[\"VERSION\"]\n",[242,952,953],{"class":106,"line":299},[242,954,556],{"class":255},[242,956,957],{"class":106,"line":307},[242,958,561],{"class":255},[242,960,961],{"class":106,"line":315},[242,962,963],{"class":255},"          general = cfg[\"general\"]\n",[242,965,966],{"class":106,"line":326},[242,967,296],{"emptyLinePlaceholder":295},[242,969,970],{"class":106,"line":334},[242,971,972],{"class":255},"          root = ET.Element(\"plugins\")\n",[242,974,975],{"class":106,"line":348},[242,976,977],{"class":255},"          plugin = ET.SubElement(root, \"pyqgis_plugin\", name=general[\"name\"], version=version)\n",[242,979,980],{"class":106,"line":353},[242,981,982],{"class":255},"          for tag in (\"description\", \"about\", \"qgis_minimum_version\", \"author_name\"):\n",[242,984,985],{"class":106,"line":365},[242,986,987],{"class":255},"              key = {\"qgis_minimum_version\": \"qgisMinimumVersion\", \"author_name\": \"author\"}.get(tag, tag)\n",[242,989,990],{"class":106,"line":376},[242,991,992],{"class":255},"              ET.SubElement(plugin, tag).text = general.get(key, \"\")\n",[242,994,995],{"class":106,"line":388},[242,996,997],{"class":255},"          ET.SubElement(plugin, \"download_url\").text = (\n",[242,999,1000],{"class":106,"line":394},[242,1001,1002],{"class":255},"              f\"https:\u002F\u002Fplugins.internal.example.org\u002Fmy_plugin-{version}.zip\"\n",[242,1004,1005],{"class":106,"line":400},[242,1006,1007],{"class":255},"          )\n",[242,1009,1010],{"class":106,"line":406},[242,1011,1012],{"class":255},"          ET.SubElement(plugin, \"file_name\").text = f\"my_plugin-{version}.zip\"\n",[242,1014,1016],{"class":106,"line":1015},19,[242,1017,1018],{"class":255},"          ET.ElementTree(root).write(\"dist\u002Fplugins.xml\", encoding=\"utf-8\", xml_declaration=True)\n",[242,1020,1022],{"class":106,"line":1021},20,[242,1023,591],{"class":255},[242,1025,1027,1029],{"class":106,"line":1026},21,[242,1028,783],{"class":247},[242,1030,266],{"class":251},[242,1032,1034,1037,1039],{"class":106,"line":1033},22,[242,1035,1036],{"class":247},"          VERSION",[242,1038,252],{"class":251},[242,1040,1041],{"class":255},"${{ steps.version.outputs.version }}\n",[14,1043,1044,1046,1047,1049,1050,1053,1054,1056,1057,1053,1060,1063,1064,1067],{},[413,1045,415],{}," The element names in the XML differ from the ",[18,1048,20],{}," keys — ",[18,1051,1052],{},"qgis_minimum_version"," against ",[18,1055,605],{},", ",[18,1058,1059],{},"author_name",[18,1061,1062],{},"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 ",[503,1065,1066],{},"Plugins → Settings → Plugin repositories"," and the plugin appears alongside the official ones, updates included.",[193,1069,1071],{"id":1070},"guard-against-releasing-a-broken-build","Guard against releasing a broken build",[14,1073,1074],{},"A release workflow that does not run the tests is a way to publish a broken plugin faster.",[233,1076,1078],{"className":235,"code":1077,"language":237,"meta":238,"style":238},"    steps:\n      - uses: actions\u002Fcheckout@v4\n      - name: Test\n        uses: docker:\u002F\u002Fqgis\u002Fqgis:release-3_34\n        with:\n          args: bash -c \"pip install pytest pytest-qgis && xvfb-run -a pytest -q tests\u002F\"\n",[18,1079,1080,1086,1096,1107,1116,1122],{"__ignoreMap":238},[242,1081,1082,1084],{"class":106,"line":244},[242,1083,329],{"class":247},[242,1085,266],{"class":251},[242,1087,1088,1090,1092,1094],{"class":106,"line":259},[242,1089,337],{"class":251},[242,1091,340],{"class":247},[242,1093,252],{"class":251},[242,1095,345],{"class":255},[242,1097,1098,1100,1102,1104],{"class":106,"line":269},[242,1099,337],{"class":251},[242,1101,248],{"class":247},[242,1103,252],{"class":251},[242,1105,1106],{"class":255},"Test\n",[242,1108,1109,1111,1113],{"class":106,"line":277},[242,1110,746],{"class":247},[242,1112,252],{"class":251},[242,1114,1115],{"class":255},"docker:\u002F\u002Fqgis\u002Fqgis:release-3_34\n",[242,1117,1118,1120],{"class":106,"line":292},[242,1119,756],{"class":247},[242,1121,266],{"class":251},[242,1123,1124,1127,1129],{"class":106,"line":299},[242,1125,1126],{"class":247},"          args",[242,1128,252],{"class":251},[242,1130,1131],{"class":255},"bash -c \"pip install pytest pytest-qgis && xvfb-run -a pytest -q tests\u002F\"\n",[14,1133,1134,1136,1137,1141],{},[413,1135,415],{}," 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 ",[30,1138,1140],{"href":1139},"\u002Fqgis-plugin-development\u002Ftesting-and-ci-for-plugins\u002Frun-qgis-plugin-tests-in-github-actions\u002F","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.",[193,1143,1145],{"id":1144},"qgis-version-compatibility","QGIS version compatibility",[14,1147,1148,1149,1152],{},"The examples target ",[413,1150,1151],{},"QGIS 3.34 LTR"," (Python 3.12).",[1154,1155,1156,1172],"table",{},[1157,1158,1159],"thead",{},[1160,1161,1162,1166,1169],"tr",{},[1163,1164,1165],"th",{},"QGIS version",[1163,1167,1168],{},"Python",[1163,1170,1171],{},"Notes",[1173,1174,1175,1189,1203,1220,1231],"tbody",{},[1160,1176,1177,1181,1184],{},[1178,1179,1180],"td",{},"3.16 LTR",[1178,1182,1183],{},"3.7",[1178,1185,1186,1188],{},[18,1187,20],{}," fields as described; repository upload endpoint unchanged.",[1160,1190,1191,1194,1197],{},[1178,1192,1193],{},"3.22 LTR",[1178,1195,1196],{},"3.9",[1178,1198,1199,1202],{},[18,1200,1201],{},"plugin_dependencies"," field recognised but not enforced by QGIS.",[1160,1204,1205,1208,1210],{},[1178,1206,1207],{},"3.28 LTR",[1178,1209,1196],{},[1178,1211,1212,1213,1215,1216,1219],{},"Repository requires ",[18,1214,605],{},"; ",[18,1217,1218],{},"qgisMaximumVersion"," optional.",[1160,1221,1222,1225,1228],{},[1178,1223,1224],{},"3.34 LTR",[1178,1226,1227],{},"3.12",[1178,1229,1230],{},"Baseline for this page.",[1160,1232,1233,1236,1238],{},[1178,1234,1235],{},"3.40+",[1178,1237,1227],{},[1178,1239,1240,1241,1244],{},"Compiled resources are increasingly unnecessary; ship ",[18,1242,1243],{},".ui"," and images directly.",[193,1246,1248],{"id":1247},"troubleshooting","Troubleshooting",[198,1250,1251,1257,1268,1278,1286,1295],{},[201,1252,1253,1256],{},[413,1254,1255],{},"The repository rejects the package."," The zip has no single top-level directory, or its name does not match the package name.",[201,1258,1259,416,1262,1264,1265,1267],{},[413,1260,1261],{},"The uploaded version is wrong.",[18,1263,20],{}," was not rewritten from the tag, or the leading ",[18,1266,423],{}," was not stripped.",[201,1269,1270,416,1273,1275,1276,208],{},[413,1271,1272],{},"The upload step passes but nothing appears.",[18,1274,817],{}," returned an HTTP error and exited zero. Add ",[18,1277,890],{},[201,1279,1280,416,1283,1285],{},[413,1281,1282],{},"The plugin fails to load for users.",[18,1284,509],{}," from a different Python version was included. Exclude it.",[201,1287,1288,416,1291,1294],{},[413,1289,1290],{},"The release has no notes.",[18,1292,1293],{},"generate_release_notes"," needs at least one merged pull request since the previous tag.",[201,1296,1297,1300],{},[413,1298,1299],{},"A tag was pushed to an untested commit."," Run the tests inside the release job rather than trusting the branch.",[193,1302,1304],{"id":1303},"conclusion","Conclusion",[14,1306,1307,1308,1310,1311,1313],{},"Trigger on a version tag, derive the version from it and write it into ",[18,1309,20],{}," at build time, zip the plugin folder with caches excluded, validate the metadata before uploading, and use ",[18,1312,890],{}," so a rejection fails the build. Run the tests in the same job, and the only manual step left is deciding to release.",[193,1315,1317],{"id":1316},"frequently-asked-questions","Frequently Asked Questions",[14,1319,1320,1323,1324,1326],{},[413,1321,1322],{},"Should the version live in metadata.txt or the tag?","\nThe tag, written into ",[18,1325,20],{}," during the build. Two committed copies drift; a generated one cannot.",[14,1328,1329,1332,1333,1336,1337,1339],{},[413,1330,1331],{},"Can I publish an experimental version?","\nYes — set ",[18,1334,1335],{},"experimental=True"," in ",[18,1338,20],{}," and the repository lists it separately. Deriving that flag from whether the tag contains a pre-release suffix keeps it consistent.",[14,1341,1342,1345,1346,1349,1350,1352,1353,208],{},[413,1343,1344],{},"Do I need to compile resources?","\nOnly if the plugin uses a ",[18,1347,1348],{},".qrc",". Recent practice is to load images and ",[18,1351,1243],{}," files from disk instead, which removes a build step and a Python-version-sensitive artefact. See ",[30,1354,1356],{"href":1355},"\u002Fqgis-plugin-development\u002Fplugin-boilerplate-structure\u002Fadd-plugin-icon-and-resources-pyqgis\u002F","adding a plugin icon and resources",[14,1358,1359,1362,1363,1365,1366,1369,1370,208],{},[413,1360,1361],{},"How do I handle the changelog?","\nKeep it in ",[18,1364,20],{},"'s ",[18,1367,1368],{},"changelog"," field for the plugin manager, and generate it from the tag range in CI so the two cannot disagree. See ",[30,1371,1373],{"href":1372},"\u002Fqgis-plugin-development\u002Fpublishing-to-the-qgis-plugin-repository\u002Fversion-and-changelog-qgis-plugin\u002F","versioning and changelogs for a QGIS plugin",[193,1375,1377],{"id":1376},"related","Related",[198,1379,1380,1385,1391,1396,1401],{},[201,1381,1382,1384],{},[30,1383,33],{"href":32}," — the guide this recipe belongs to",[201,1386,1387],{},[30,1388,1390],{"href":1389},"\u002Fqgis-plugin-development\u002Fpublishing-to-the-qgis-plugin-repository\u002Fpackage-qgis-plugin-as-zip\u002F","Package a QGIS Plugin as a Zip",[201,1392,1393],{},[30,1394,1395],{"href":217},"Write metadata.txt for a QGIS Plugin",[201,1397,1398],{},[30,1399,1400],{"href":1372},"Version and Changelog a QGIS Plugin",[201,1402,1403],{},[30,1404,1405],{"href":1139},"Run QGIS Plugin Tests in GitHub Actions",[1407,1408,1409],"style",{},"html pre.shiki code .s4JwU, html code.shiki .s4JwU{--shiki-default:#85E89D}html pre.shiki code .s95oV, html code.shiki .s95oV{--shiki-default:#E1E4E8}html pre.shiki code .sU2Wk, html code.shiki .sU2Wk{--shiki-default:#9ECBFF}html pre.shiki code .sDLfK, html code.shiki .sDLfK{--shiki-default:#79B8FF}html pre.shiki code .snl16, html code.shiki .snl16{--shiki-default:#F97583}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}",{"title":238,"searchDepth":259,"depth":259,"links":1411},[1412,1413,1414,1415,1416,1417,1418,1419,1420,1421,1422,1423],{"id":195,"depth":259,"text":196},{"id":224,"depth":259,"text":225},{"id":448,"depth":259,"text":449},{"id":721,"depth":259,"text":722},{"id":810,"depth":259,"text":811},{"id":908,"depth":259,"text":909},{"id":1070,"depth":259,"text":1071},{"id":1144,"depth":259,"text":1145},{"id":1247,"depth":259,"text":1248},{"id":1303,"depth":259,"text":1304},{"id":1316,"depth":259,"text":1317},{"id":1376,"depth":259,"text":1377},"Build, tag and publish a QGIS plugin from CI — deriving the version from the tag, packaging a clean zip, attaching it to a release, and uploading to the plugin repository.","md",{"slug":1427,"type":1428,"breadcrumb":1429,"datePublished":1430,"dateModified":1430},"automate-plugin-release-with-github-actions","article","Automated Releases","2026-08-27","\u002Fqgis-plugin-development\u002Fpublishing-to-the-qgis-plugin-repository\u002Fautomate-plugin-release-with-github-actions",{"title":5,"description":1424},"qgis-plugin-development\u002Fpublishing-to-the-qgis-plugin-repository\u002Fautomate-plugin-release-with-github-actions\u002Findex","-3iLbaSuF8X759kK9KkoIugapnCr58VAaUL2nmnpFY4",1787823363090]