Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
83 changes: 0 additions & 83 deletions .github/workflows/release-czdev.yml

This file was deleted.

22 changes: 15 additions & 7 deletions scripts/czdev/github_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,19 +59,27 @@ def get_user(self) -> User:
data = self._get("/user")
return User(login=data["login"], email=data.get("email"))

def check_permission(self, owner: str, repo: str, username: str) -> int:
def check_permission(self, owner: str, repo: str) -> int:
"""Return the authenticated user's permission level on a repo.

Uses `GET /repos/{owner}/{repo}`, whose `permissions` object reflects the
token owner's access. Unlike the collaborators/{user}/permission endpoint,
this does NOT require the caller to already have push access, so external
contributors (who should fall through to the fork+PR path) get a clean
answer instead of a 403 Forbidden.
"""
try:
data = self._get(f"/repos/{owner}/{repo}/collaborators/{username}/permission")
data = self._get(f"/repos/{owner}/{repo}")
except urllib.error.HTTPError as e:
if e.code == 404:
if e.code in (403, 404):
return Permission.NONE
raise
perm = data.get("permission", "")
if perm == "admin":
perms = data.get("permissions", {})
if perms.get("admin"):
return Permission.ADMIN
if perm in ("maintain", "write"):
if perms.get("maintain") or perms.get("push"):
return Permission.WRITE
if perm in ("read", "triage"):
if perms.get("pull") or perms.get("triage"):
return Permission.READ
return Permission.NONE

Expand Down
23 changes: 12 additions & 11 deletions scripts/czdev/publish.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,7 @@ def run(deb: Optional[str] = None):
user = gh.get_user()

noreply = f"{user.login}@users.noreply.github.com"
all_emails = [noreply]
if user.email:
all_emails.append(user.email)
uploader_email = user.email or noreply

print("Preflight checks:")

Expand All @@ -50,14 +48,13 @@ def run(deb: Optional[str] = None):
sys.exit(1)
print(" ✓ .desktop file found")

# 2. Extract metadata and check email
# 2. Extract metadata. Ownership is first-come-first-served by package name,
# keyed on the uploader's GitHub login (recorded in the release manifest
# below and enforced server-side). We no longer require the deb's
# Maintainer email to match the uploader's GitHub emails — we just record
# who uploaded it.
meta = extract_metadata(deb_path)
if not any(e.lower() == meta["maintainer_email"].lower() for e in all_emails):
print(f"ERROR: Maintainer email does not match your GitHub verified emails.", file=sys.stderr)
print(f" Maintainer: {meta['maintainer_email']}", file=sys.stderr)
print(f" Your emails: {all_emails}", file=sys.stderr)
sys.exit(1)
print(" ✓ Maintainer email matches GitHub account")
print(f" ✓ Maintainer: {meta['maintainer'] or meta['maintainer_email'] or '(none)'}")

# 3. Package name validation
if not is_valid_package_name(meta["package"]):
Expand All @@ -79,7 +76,7 @@ def run(deb: Optional[str] = None):
check_version_newer(meta)

# Determine target: direct push or fork
perm = gh.check_permission(TARGET_OWNER, TARGET_REPO, user.login)
perm = gh.check_permission(TARGET_OWNER, TARGET_REPO)
if perm >= Permission.WRITE:
push_owner = TARGET_OWNER
push_repo = TARGET_REPO
Expand Down Expand Up @@ -126,6 +123,10 @@ def run(deb: Optional[str] = None):
"package": meta["package"],
"version": meta["version"],
"architecture": meta["architecture"],
# Ownership record: first uploader of a package name owns it. The login
# is the authoritative owner key; the email is kept for contact/audit.
"uploaded_by": user.login,
"uploader_email": uploader_email,
}

# 2) Commit only metadata (meta.json, screenshots, icon, manifest) to a PR
Expand Down
117 changes: 71 additions & 46 deletions scripts/czdev/unpublish.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,65 +21,53 @@ def run(package: str, version: str, arch: str = "arm64"):
gh = GitHubClient(token)
user = gh.get_user()

noreply = f"{user.login}@users.noreply.github.com"
all_emails = [noreply]
if user.email:
all_emails.append(user.email)

# Packages are referenced by a manifest in git; the .deb itself lives in a
# Release. GitHub sanitizes '~' to '.' in release asset names.
asset_name = f"{package}_{version}_{arch}.deb".replace("~", ".")
file_path = f"pool/main/{package}/{asset_name}.release.json"

print(f"Checking ownership of {package} {version}...")

# Read the manifest from the repo, download the .deb it points at, and
# verify the Maintainer matches this user.
tmp_dir = Path(tempfile.mkdtemp(prefix="czdev-unpublish-"))
perm = gh.check_permission(TARGET_OWNER, TARGET_REPO)
is_maintainer = perm >= Permission.WRITE

# Ownership is first-come-first-served by package name: the recorded uploader
# (GitHub login) owns it. Only that login or a repo maintainer can remove it.
# We no longer match the deb's Maintainer email against the caller's emails.
try:
try:
manifest_raw = gh.get_file_content(TARGET_OWNER, TARGET_REPO, file_path)
except FileNotFoundError:
print("ERROR: package manifest not found in repository", file=sys.stderr)
sys.exit(1)
try:
manifest = json.loads(manifest_raw)
deb_url = manifest["url"]
except (json.JSONDecodeError, KeyError):
print("ERROR: invalid manifest (missing url)", file=sys.stderr)
sys.exit(1)
manifest_raw = gh.get_file_content(TARGET_OWNER, TARGET_REPO, file_path)
except FileNotFoundError:
print("ERROR: package manifest not found in repository", file=sys.stderr)
sys.exit(1)
try:
manifest = json.loads(manifest_raw)
except json.JSONDecodeError:
print("ERROR: invalid manifest", file=sys.stderr)
sys.exit(1)

deb_local = tmp_dir / asset_name
try:
req = urllib.request.Request(deb_url, headers={"User-Agent": "czdev/0.1"})
with urllib.request.urlopen(req, timeout=600) as resp, open(deb_local, "wb") as out:
shutil.copyfileobj(resp, out)
except Exception as exc:
print(f"ERROR: could not download package binary: {exc}", file=sys.stderr)
sys.exit(1)
owner_login = str(manifest.get("uploaded_by") or "").strip()

try:
result = subprocess.run(
["dpkg-deb", "-f", str(deb_local), "Maintainer"],
capture_output=True, text=True, check=True,
)
maintainer = result.stdout.strip()
except (subprocess.CalledProcessError, FileNotFoundError):
print("ERROR: dpkg-deb not available", file=sys.stderr)
sys.exit(1)
finally:
shutil.rmtree(tmp_dir, ignore_errors=True)
# Legacy manifests (created before ownership was recorded) carry no
# `uploaded_by`. Fall back to deriving the owner login from the published
# binary's Maintainer noreply address.
if not owner_login and manifest.get("url"):
owner_login = derive_owner_login_from_deb(manifest["url"], asset_name)

maint_email = extract_email(maintainer)
if not any(e.lower() == maint_email.lower() for e in all_emails):
print(f"Cannot unpublish: package maintainer '{maintainer}' does not match your account.", file=sys.stderr)
print(" You can only remove packages you own.", file=sys.stderr)
if owner_login:
if owner_login.lower() != user.login.lower() and not is_maintainer:
print(f"Cannot unpublish: '{package}' is owned by @{owner_login}.", file=sys.stderr)
print(" Only the original uploader or a repo maintainer can remove it.", file=sys.stderr)
sys.exit(1)
print(f" ✓ Ownership verified (owner: @{owner_login})")
elif is_maintainer:
print(" ✓ No recorded owner; proceeding as repo maintainer")
else:
print(f"Cannot unpublish: the owner of '{package}' could not be determined.", file=sys.stderr)
print(" Ask a repo maintainer to remove it.", file=sys.stderr)
sys.exit(1)
print(f" ✓ Ownership verified ({maint_email})")

# Determine push target
perm = gh.check_permission(TARGET_OWNER, TARGET_REPO, user.login)
if perm >= Permission.WRITE:
if is_maintainer:
push_owner = TARGET_OWNER
push_repo = TARGET_REPO
pr_head = None
Expand Down Expand Up @@ -112,7 +100,7 @@ def run(package: str, version: str, arch: str = "arm64"):
head = pr_head if pr_head else branch
pr_body = (
f"## Remove package: `{package}` v{version}\n\n"
f"Requested by @{user.login} (maintainer email: {maint_email}).\n\n"
f"Requested by @{user.login} (owner: @{owner_login or user.login}).\n\n"
f"Manifest: `{file_path}`\n\n"
f"Submitted via `czdev unpublish`. Removing the manifest drops the package "
f"from the index on the next build; the apt-pool asset can be pruned separately."
Expand All @@ -139,3 +127,40 @@ def extract_email(maintainer: str) -> str:
if start != -1 and end != -1:
return maintainer[start + 1:end]
return maintainer


def login_from_noreply(email: str) -> str:
"""Extract a GitHub login from a noreply email, else "".

Handles both `login@users.noreply.github.com` and the newer
`12345+login@users.noreply.github.com` form.
"""
email = (email or "").strip().lower()
suffix = "@users.noreply.github.com"
if not email.endswith(suffix):
return ""
local = email[: -len(suffix)]
if "+" in local:
local = local.split("+", 1)[1]
return local


def derive_owner_login_from_deb(deb_url: str, asset_name: str) -> str:
"""Download the published binary and derive the owner login from its
Maintainer noreply address. Best-effort: returns "" on any failure."""
tmp_dir = Path(tempfile.mkdtemp(prefix="czdev-unpublish-"))
try:
deb_local = tmp_dir / asset_name
try:
req = urllib.request.Request(deb_url, headers={"User-Agent": "czdev/0.1"})
with urllib.request.urlopen(req, timeout=600) as resp, open(deb_local, "wb") as out:
shutil.copyfileobj(resp, out)
result = subprocess.run(
["dpkg-deb", "-f", str(deb_local), "Maintainer"],
capture_output=True, text=True, check=True,
)
except (Exception, subprocess.CalledProcessError):
return ""
return login_from_noreply(extract_email(result.stdout.strip()))
finally:
shutil.rmtree(tmp_dir, ignore_errors=True)
Loading