Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
55be802
Add a test harness that blocks SVN writes to real repositories
dpanta94 Oct 5, 2026
64c0b7d
Add shared helpers for the wporg-svn actions
dpanta94 Oct 5, 2026
a76f4ab
Add the tag action
dpanta94 Oct 5, 2026
c657fce
Add the set-stable action
dpanta94 Oct 5, 2026
dc4fdfd
Add the update-trunk action
dpanta94 Oct 5, 2026
b1557ad
Test failures, races and secret handling across the actions
dpanta94 Oct 5, 2026
3dd323a
Document the wporg-svn actions and add an example release workflow
dpanta94 Oct 5, 2026
c6b9e88
Run the wporg-svn tests and an end-to-end release in CI
dpanta94 Oct 5, 2026
e52ba37
Add opt-in read-only checks against real plugins
dpanta94 Oct 5, 2026
6fedcfd
Create the new tag in a single commit
dpanta94 Oct 6, 2026
e6340a3
Refuse to update trunk before set-stable
dpanta94 Oct 6, 2026
37e2e58
Let releases require the tag revision that was QA'd
dpanta94 Oct 6, 2026
4b76e0f
Report what a failed write left behind, not what the run did
dpanta94 Oct 6, 2026
6cf0528
Escape masked values and say how to keep the ZIP URL private
dpanta94 Oct 6, 2026
c829f7a
Document that the release credentials are repository secrets
dpanta94 Oct 6, 2026
88235bb
Read ZIP names the way Info-ZIP writes them
dpanta94 Oct 6, 2026
c86c0ca
Limit ZIP paths by depth, length and total count
dpanta94 Oct 6, 2026
2c93237
Stop oversized downloads on curl older than 8.4
dpanta94 Oct 6, 2026
cb256ea
Use bulk updates for SVN checkouts and exports
dpanta94 Oct 6, 2026
7ce1d10
Add and delete paths with one svn call each
dpanta94 Oct 6, 2026
cbcdb38
Reject symlinks from ZIPs made on OS X
dpanta94 Oct 6, 2026
36cc64f
Stop assuming Info-ZIP leaves the UTF-8 flag unset
dpanta94 Oct 6, 2026
d78cfb4
Test that expected-revision catches edits inside the tag
dpanta94 Oct 6, 2026
81b52e5
Merge remote-tracking branch 'origin/main' into feat/wporg-svn-release
dpanta94 Oct 14, 2026
4809e80
Let actionlint accept the documented concurrency queue key
dpanta94 Oct 15, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/actionlint.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# actionlint 1.7.12 doesn't know concurrency.queue, which GitHub documents and this workflow
# relies on. Drop this once it does: https://github.com/rhysd/actionlint/issues/657
paths:
.github/workflows/release-tags.yml:
ignore:
- 'unexpected key "queue" for "concurrency" section'
240 changes: 240 additions & 0 deletions .github/actions/wporg-svn/README.md

Large diffs are not rendered by default.

63 changes: 63 additions & 0 deletions .github/actions/wporg-svn/examples/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# A plugin repository's release workflow. Replace my-plugin, and replace
# <full-commit-sha> with a full commit SHA of stellarwp/plugin-toolbox.
name: Release to WordPress.org

on:
workflow_dispatch:
inputs:
plugin-slug:
description: The plugin's wordpress.org slug
required: true
zip-url:
description: HTTPS URL of the built plugin ZIP (prefer a short-lived signed URL)
required: true

# One release at a time per plugin, held for the whole sequence. This lock only
# covers workflows in this repository: release each plugin from one place.
concurrency:
group: wporg-${{ inputs.plugin-slug }}
cancel-in-progress: false

permissions:
contents: read

jobs:
tag:
runs-on: ubuntu-24.04
outputs:
version: ${{ steps.tag.outputs.version }}
revision: ${{ steps.tag.outputs.revision }}
steps:
- name: Install Subversion
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends subversion
- id: tag
uses: stellarwp/plugin-toolbox/.github/actions/wporg-svn/tag@<full-commit-sha>
with:
plugin-slug: ${{ inputs.plugin-slug }}
zip-url: ${{ inputs.zip-url }}
wporg-username: ${{ secrets.WPORG_USERNAME }}
wporg-password: ${{ secrets.WPORG_PASSWORD }}

release:
needs: tag
runs-on: ubuntu-24.04
# A protected environment: its reviewers QA tags/<version> at the tag job's
# revision output before approving this job.
environment: wordpress-org
steps:
- name: Install Subversion
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends subversion
- uses: stellarwp/plugin-toolbox/.github/actions/wporg-svn/set-stable@<full-commit-sha>
with:
plugin-slug: ${{ inputs.plugin-slug }}
version: ${{ needs.tag.outputs.version }}
expected-revision: ${{ needs.tag.outputs.revision }}
wporg-username: ${{ secrets.WPORG_USERNAME }}
wporg-password: ${{ secrets.WPORG_PASSWORD }}
- uses: stellarwp/plugin-toolbox/.github/actions/wporg-svn/update-trunk@<full-commit-sha>
with:
plugin-slug: ${{ inputs.plugin-slug }}
version: ${{ needs.tag.outputs.version }}
expected-revision: ${{ needs.tag.outputs.revision }}
wporg-username: ${{ secrets.WPORG_USERNAME }}
wporg-password: ${{ secrets.WPORG_PASSWORD }}
231 changes: 231 additions & 0 deletions .github/actions/wporg-svn/lib/archive.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,231 @@
#!/usr/bin/env python3
"""Inspect a plugin ZIP before extracting anything, then extract it safely.

Usage:
archive.py extract ZIP DEST extract into the empty directory DEST and print
the plugin root (DEST or its one wrapper directory)

Every entry is checked before the first byte is written. Errors go to stderr
without echoing entry names, and exit 1.
"""

from __future__ import annotations

import os
import re
import stat
import struct
import sys
import unicodedata
import zipfile
import zlib

MAX_ARCHIVE_BYTES = 256 * 1024 * 1024
MAX_ENTRIES = 50_000
MAX_EXPANDED_BYTES = 1024 * 1024 * 1024
MAX_RATIO = 100
MAX_DEPTH = 64
MAX_PATH_BYTES = 1024

# Packaging noise skipped (never extracted): macOS resource forks and Finder files.
NOISE_TOP = "__MACOSX"
NOISE_NAME = ".DS_Store"
VCS = {".svn", ".git"}


class ArchiveError(Exception):
pass


def _check_control(name: str) -> None:
if any(ord(c) < 0x20 or 0x7F <= ord(c) <= 0x9F for c in name):
raise ArchiveError("an entry name contains a control character")


def _unicode_paths(info: zipfile.ZipInfo, header: bytes):
"""Yield the name in each of the entry's Info-ZIP Unicode Path fields (0x7075).

Only version 1 fields whose CRC matches the header name count; any other is
stale, as unzip and zipfile treat it.
"""
extra, header_crc = info.extra, zlib.crc32(header)
while len(extra) >= 4:
kind, size = struct.unpack("<HH", extra[:4])
data, extra = extra[4:4 + size], extra[4 + size:]
if kind != 0x7075:
continue
if len(data) < 5:
raise ArchiveError("the archive is malformed")
version, crc = struct.unpack("<BI", data[:5])
if version == 1 and crc == header_crc:
try:
yield data[5:].decode("utf-8")
except UnicodeDecodeError:
raise ArchiveError("an entry name is not UTF-8") from None


def _name(info: zipfile.ZipInfo) -> str:
"""The entry's name as its author wrote it.

zipfile reads a name without the UTF-8 flag as CP437, but Info-ZIP's zip
writes UTF-8 there without setting the flag: on macOS, and on Ubuntu without
en_US.UTF-8. Such names are taken as UTF-8; one that isn't valid UTF-8 is
refused rather than guessed.
"""
raw = info.orig_filename
_check_control(raw)
flagged = info.flag_bits & 0x800
name = raw
if not flagged and not raw.isascii():
try:
name = raw.encode("cp437").decode("utf-8")
except UnicodeError:
raise ArchiveError("an entry name is not UTF-8") from None
_check_control(name)
# unzip and zipfile 3.12+ take the name from the last valid Unicode Path
# field; older zipfile ignores them. Every one must agree, on every Python.
if any(other != name for other in _unicode_paths(info, raw.encode("utf-8" if flagged else "cp437"))):
raise ArchiveError("an entry has two different names")
# Otherwise filename differs from both only where zipfile changed the name.
if info.filename not in (raw, name):
raise ArchiveError("an entry has an unsafe path")
return name


def _check_name(info: zipfile.ZipInfo) -> list:
"""Return the path components of a safe entry name, or raise."""
name = _name(info)
if "\\" in name or name.startswith("/") or re.match(r"[A-Za-z]:", name):
raise ArchiveError("an entry has an unsafe path")
if len(name.encode()) > MAX_PATH_BYTES:
raise ArchiveError(f"an entry path is longer than {MAX_PATH_BYTES} bytes")
parts = (name[:-1] if name.endswith("/") else name).split("/")
if len(parts) > MAX_DEPTH:
raise ArchiveError(f"an entry path is deeper than {MAX_DEPTH} levels")
if any(part in ("", ".", "..") for part in parts):
raise ArchiveError("an entry has an unsafe path")
return parts


def _check_type(info: zipfile.ZipInfo) -> None:
if info.flag_bits & 0x1:
raise ArchiveError("the archive has encrypted entries")
kind = stat.S_IFMT(info.external_attr >> 16)
# Hosts that store Unix modes: 3 is Unix, 19 is OS X.
if info.create_system in (3, 19) and kind not in (0, stat.S_IFREG, stat.S_IFDIR):
raise ArchiveError("an entry is not a regular file or directory")
if kind == stat.S_IFDIR and not info.is_dir():
raise ArchiveError("an entry is not a regular file or directory")


def _fold(parts) -> tuple:
return tuple(unicodedata.normalize("NFC", part).casefold() for part in parts)


def inspect(zf: zipfile.ZipFile, archive_bytes: int) -> list:
"""Validate every entry; return (info, parts) for the entries to extract."""
infos = zf.infolist()
if len(infos) > MAX_ENTRIES:
raise ArchiveError(f"the archive has more than {MAX_ENTRIES} entries")

kept, raw_names, kinds, spellings, total = [], set(), {}, {}, 0
for info in infos:
parts = _check_name(info)
_check_type(info)
if info.orig_filename in raw_names:
raise ArchiveError("the archive has a duplicate or ambiguous path")
raw_names.add(info.orig_filename)
if parts[0] == NOISE_TOP or parts[-1] == NOISE_NAME:
continue
if any(part.lower() in VCS for part in parts):
raise ArchiveError("the archive contains version control metadata")

# Every prefix is a directory; the last component is the entry itself.
# Each distinct one becomes a file or directory on disk, listed or not.
folded = _fold(parts)
for depth in range(1, len(parts) + 1):
kind = "file" if depth == len(parts) and not info.is_dir() else "dir"
key, spelling = folded[:depth], "/".join(parts[:depth])
if key not in kinds:
kinds[key], spellings[key] = kind, spelling
if len(kinds) > MAX_ENTRIES:
raise ArchiveError(f"the archive has more than {MAX_ENTRIES} files and directories")
elif spellings[key] != spelling:
raise ArchiveError("the archive has a duplicate or ambiguous path")
elif kind == "file" or kinds[key] == "file":
raise ArchiveError("the archive has a file/directory conflict")

total += info.file_size
kept.append((info, parts))

if total > MAX_EXPANDED_BYTES:
raise ArchiveError(f"the archive expands past {MAX_EXPANDED_BYTES} bytes")
if total > MAX_RATIO * max(archive_bytes, 1):
raise ArchiveError(f"the archive's expansion ratio exceeds {MAX_RATIO}")
return kept


def plugin_root(kept: list) -> tuple:
"""Path components of the plugin root: () when flat, (wrapper,) when wrapped."""
top = {parts[0] for _, parts in kept}
top_files = {parts[0] for info, parts in kept if len(parts) == 1 and not info.is_dir()}
if "readme.txt" in top_files:
return ()
if len(top) == 1 and not top_files:
wrapper = next(iter(top))
if any(parts == [wrapper, "readme.txt"] and not info.is_dir() for info, parts in kept):
return (wrapper,)
raise ArchiveError("the plugin root has no readme.txt")
raise ArchiveError(
"cannot find one plugin root: readme.txt must be at the top level or inside one wrapper directory")


def extract(zip_path: str, dest: str) -> str:
"""Extract zip_path into the empty directory dest; return the plugin root."""
size = os.path.getsize(zip_path)
if size > MAX_ARCHIVE_BYTES:
raise ArchiveError(f"the archive is larger than {MAX_ARCHIVE_BYTES} bytes")
dest = os.path.realpath(dest)
try:
with zipfile.ZipFile(zip_path) as zf:
kept = inspect(zf, size)
root = plugin_root(kept)
written = 0
for info, parts in kept:
target = os.path.join(dest, *parts)
if not os.path.realpath(target).startswith(dest + os.sep):
raise ArchiveError("an entry has an unsafe path")
if info.is_dir():
os.makedirs(target, exist_ok=True)
continue
os.makedirs(os.path.dirname(target), exist_ok=True)
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0)
with zf.open(info) as source, os.fdopen(os.open(target, flags, 0o644), "wb") as out:
while True:
chunk = source.read(1024 * 1024)
if not chunk:
break
written += len(chunk)
if written > MAX_EXPANDED_BYTES:
raise ArchiveError(f"the archive expands past {MAX_EXPANDED_BYTES} bytes")
out.write(chunk)
except (zipfile.BadZipFile, zipfile.LargeZipFile, NotImplementedError, RuntimeError, EOFError,
zlib.error, ValueError) as error:
raise ArchiveError("the archive is malformed") from error
return os.path.join(dest, *root)


def main(argv) -> int:
if len(argv) != 3 or argv[0] != "extract":
print(__doc__, file=sys.stderr)
return 2
try:
print(extract(argv[1], argv[2]))
except (ArchiveError, OSError) as error:
print(f"error: {error}", file=sys.stderr)
return 1
return 0


if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))
Loading
Loading