Skip to content
Closed
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
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,8 @@ Publishing uses the Python-based `czdev` wrapper (no Rust toolchain needed):
./czdev unpublish my_app --version 1.0.1
```

Requirements: Python 3, `git`, `git-lfs`, `dpkg-deb`.
Requirements: Python 3, `git`, `dpkg-deb`. No SSH key needed — pushes use the
GitHub token obtained by `./czdev login`.

#### Publish Workflow

Expand Down Expand Up @@ -207,7 +208,8 @@ Requirements: Python 3, `git`, `git-lfs`, `dpkg-deb`.
│── czdev publish ───────────▶│ │
│ │── validate .deb ──────────▶│ (check ver)
│ │── fork packages repo ─────▶│
│ │── git push (LFS) ─────────▶│
│ │── upload .deb (Release) ──▶│
│ │── git push (metadata) ────▶│
│ │── POST /pulls ────────────▶│
│◀── PR URL ─────────────────│ │
│ │ │
Expand Down
14 changes: 14 additions & 0 deletions czdev
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
#!/usr/bin/env bash
# Launcher for the Python czdev CLI (login / logout / bump / publish / unpublish).
# Requires only Python 3 — no Rust toolchain needed.
set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"

PYTHON="${PYTHON:-python3}"
if ! command -v "$PYTHON" >/dev/null 2>&1; then
echo "error: python3 not found. Install Python 3 to use czdev." >&2
exit 1
fi

PYTHONPATH="$SCRIPT_DIR/scripts${PYTHONPATH:+:$PYTHONPATH}" exec "$PYTHON" -m czdev "$@"
355 changes: 355 additions & 0 deletions docs/ONLINE_SUBMISSION.md

Large diffs are not rendered by default.

14 changes: 2 additions & 12 deletions scripts/czdev/bump.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
"""Show next version (patch bump) for a package — mirrors the Rust bump module."""

import re
import subprocess
import sys
import urllib.request
from pathlib import Path
from typing import Optional

from .debver import compare_versions

PACKAGES_INDEX_URL = "https://cardputerzero.github.io/packages/dists/stable/main/binary-arm64/Packages"


Expand Down Expand Up @@ -101,14 +102,3 @@ def bump_patch(version: str) -> str:
except ValueError:
patch = 0
return f"{parts[0]}.{parts[1]}.{patch + 1}"


def compare_versions(a: str, b: str) -> int:
def parse(v):
return [int(x) for x in re.split(r'[.\-~]', v) if x.isdigit()]
pa, pb = parse(a), parse(b)
if pa > pb:
return 1
elif pa < pb:
return -1
return 0
78 changes: 78 additions & 0 deletions scripts/czdev/debver.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
"""Debian version comparison implementing the dpkg algorithm.

See deb-version(7): [epoch:]upstream-version[-debian-revision].
This mirrors dpkg's verrevcmp() so czdev agrees with what apt/dpkg (and the
packages-repo CI, which uses `dpkg --compare-versions`) will decide.
"""

_DIGITS = "0123456789"


def _order(c: str) -> int:
"""Sort weight of a single character ('~' sorts before empty/anything)."""
if c == "~":
return -1
if c in _DIGITS:
return 0
if "a" <= c <= "z" or "A" <= c <= "Z":
return ord(c)
return ord(c) + 256


def _verrevcmp(a: str, b: str) -> int:
ia = ib = 0
while ia < len(a) or ib < len(b):
first_diff = 0

# Compare the non-digit prefix ('~' < end-of-string < letters < others).
while (ia < len(a) and a[ia] not in _DIGITS) or (ib < len(b) and b[ib] not in _DIGITS):
ac = _order(a[ia]) if ia < len(a) else 0
bc = _order(b[ib]) if ib < len(b) else 0
if ac != bc:
return ac - bc
ia += 1
ib += 1

# Compare the numeric run: skip leading zeros, then the first differing
# digit decides unless one number is longer.
while ia < len(a) and a[ia] == "0":
ia += 1
while ib < len(b) and b[ib] == "0":
ib += 1
while ia < len(a) and a[ia] in _DIGITS and ib < len(b) and b[ib] in _DIGITS:
if not first_diff:
first_diff = ord(a[ia]) - ord(b[ib])
ia += 1
ib += 1
if ia < len(a) and a[ia] in _DIGITS:
return 1
if ib < len(b) and b[ib] in _DIGITS:
return -1
if first_diff:
return first_diff
return 0


def _split(version: str):
version = version.strip()
epoch = 0
head, sep, rest = version.partition(":")
if sep and head.isdigit():
epoch = int(head)
version = rest
upstream, sep, revision = version.rpartition("-")
if not sep:
upstream, revision = version, ""
return epoch, upstream, revision


def compare_versions(a: str, b: str) -> int:
"""Return -1 / 0 / 1 like dpkg --compare-versions (a lt/eq/gt b)."""
ea, ua, ra = _split(a)
eb, ub, rb = _split(b)
if ea != eb:
return 1 if ea > eb else -1
rc = _verrevcmp(ua, ub)
if rc == 0:
rc = _verrevcmp(ra, rb)
return (rc > 0) - (rc < 0)
28 changes: 28 additions & 0 deletions scripts/czdev/github_client.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""GitHub API client — mirrors the Rust GitHubClient."""

import time
import urllib.request
import urllib.error
import urllib.parse
Expand Down Expand Up @@ -79,6 +80,33 @@ def fork_repo(self, owner: str, repo: str) -> str:
data = self._post(f"/repos/{owner}/{repo}/forks", body={})
return data["full_name"]

def wait_for_branch(self, owner: str, repo: str, branch: str,
timeout_secs: int = 90) -> bool:
"""Poll until `branch` exists (forking is asynchronous on GitHub)."""
deadline = time.time() + timeout_secs
while True:
try:
self.get_ref_sha(owner, repo, f"heads/{branch}")
return True
except urllib.error.HTTPError as e:
if e.code not in (404, 409): # 409: repository is empty (still forking)
raise
if time.time() >= deadline:
return False
time.sleep(3)

def sync_fork(self, owner: str, repo: str, branch: str = "main") -> bool:
"""Fast-forward a fork's branch from upstream (best effort).

Returns False when GitHub cannot fast-forward (e.g. diverged fork);
callers may still proceed — the PR diff is computed against upstream.
"""
try:
self._post(f"/repos/{owner}/{repo}/merge-upstream", body={"branch": branch})
return True
except urllib.error.HTTPError:
return False

def get_ref_sha(self, owner: str, repo: str, ref_name: str) -> str:
data = self._get(f"/repos/{owner}/{repo}/git/ref/{ref_name}")
return data["object"]["sha"]
Expand Down
57 changes: 33 additions & 24 deletions scripts/czdev/publish.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
import hashlib
import json
import os
import re
import shutil
import subprocess
import sys
Expand All @@ -14,6 +13,7 @@
from typing import Optional

from . import auth
from .debver import compare_versions
from .github_client import GitHubClient, Permission

TARGET_OWNER = "CardputerZero"
Expand Down Expand Up @@ -88,10 +88,18 @@ def run(deb: Optional[str] = None):
print(f"You don't have write access to {TARGET_OWNER}/{TARGET_REPO}.")
print(" → Forking to your account... ", end="", flush=True)
fork_name = gh.fork_repo(TARGET_OWNER, TARGET_REPO)
print(f"done ({fork_name})")
parts = fork_name.split("/")
push_owner = parts[0]
push_repo = parts[1]
# Forking is asynchronous; wait until the fork is actually usable,
# then fast-forward its main so we branch off a fresh base.
if not gh.wait_for_branch(push_owner, push_repo, "main"):
print("failed", flush=True)
print(f"ERROR: fork {fork_name} did not become ready in time. Retry in a minute.",
file=sys.stderr)
sys.exit(1)
gh.sync_fork(push_owner, push_repo, "main")
print(f"done ({fork_name})")
branch = branch_name(meta)
pr_head = f"{user.login}:{branch}"

Expand All @@ -106,7 +114,9 @@ def run(deb: Optional[str] = None):
manifest_name = f"{asset_name}.release.json"
manifest_path_in_repo = f"pool/main/{meta['package']}/{manifest_name}"
branch = branch_name(meta)
remote_url = f"git@github.com:{push_owner}/{push_repo}.git"
# Push over HTTPS with the OAuth token so publishing works with just
# `czdev login` — no SSH key setup required.
remote_url = f"https://x-access-token:{token}@github.com/{push_owner}/{push_repo}.git"

# 1) Upload the .deb to a buffer Release on the push target. For third-party
# contributors this is their own fork — it uses THEIR free Release storage
Expand Down Expand Up @@ -134,15 +144,20 @@ def run(deb: Optional[str] = None):

tmp_dir = Path(tempfile.mkdtemp(prefix="czdev-publish-"))
try:
run_cmd_in(tmp_dir, ["git", "init"])
run_cmd_in(tmp_dir, ["git", "remote", "add", "origin", remote_url])
run_cmd_in(tmp_dir, ["git", "init"], secret=token)
# A local identity so `git commit` works even without global git config;
# the noreply address also matches the maintainer-email review checks.
run_cmd_in(tmp_dir, ["git", "config", "user.name", user.login], secret=token)
run_cmd_in(tmp_dir, ["git", "config", "user.email", noreply], secret=token)
run_cmd_in(tmp_dir, ["git", "remote", "add", "origin", remote_url], secret=token)

print(" → git fetch (minimal)... ", end="", flush=True)
run_cmd_in(tmp_dir, ["git", "fetch", "--depth=1", "--filter=blob:none", "origin", "main"])
run_cmd_in(tmp_dir, ["git", "fetch", "--depth=1", "--filter=blob:none", "origin", "main"],
secret=token)
print("done")

print(f" → Creating branch {branch}... ", end="", flush=True)
run_cmd_in(tmp_dir, ["git", "checkout", "-b", branch, "origin/main"])
run_cmd_in(tmp_dir, ["git", "checkout", "-b", branch, "origin/main"], secret=token)
print("done")

dest_dir = tmp_dir / "pool" / "main" / meta["package"]
Expand Down Expand Up @@ -170,13 +185,14 @@ def run(deb: Optional[str] = None):
(dest_dir / manifest_name).write_text(json.dumps(manifest, indent=2) + "\n")

print(" → Creating commit... ", end="", flush=True)
run_cmd_in(tmp_dir, ["git", "add", f"pool/main/{meta['package']}"])
run_cmd_in(tmp_dir, ["git", "add", f"pool/main/{meta['package']}"], secret=token)
run_cmd_in(tmp_dir, ["git", "commit", "-m",
f"publish: {meta['package']} {meta['version']} ({meta['architecture']})"])
f"publish: {meta['package']} {meta['version']} ({meta['architecture']})"],
secret=token)
print("done")

print(" → Pushing branch... ", end="", flush=True)
run_cmd_in(tmp_dir, ["git", "push", "origin", branch])
run_cmd_in(tmp_dir, ["git", "push", "origin", branch], secret=token)
print("done")

finally:
Expand Down Expand Up @@ -368,18 +384,6 @@ def check_version_newer(meta: dict):
print(" ✓ New package (no existing version found)")


def compare_versions(a: str, b: str) -> int:
def parse(v):
return [int(x) for x in re.split(r'[.\-~]', v) if x.isdigit()]

pa, pb = parse(a), parse(b)
if pa > pb:
return 1
elif pa < pb:
return -1
return 0


def check_git_installed():
try:
subprocess.run(["git", "--version"], capture_output=True, check=True)
Expand All @@ -388,9 +392,14 @@ def check_git_installed():
sys.exit(1)


def run_cmd_in(cwd: Path, cmd: list):
def run_cmd_in(cwd: Path, cmd: list, secret: Optional[str] = None):
result = subprocess.run(cmd, cwd=str(cwd), capture_output=True, text=True)
if result.returncode != 0:
cmd_str = " ".join(cmd)
print(f"ERROR: {cmd_str} failed:\n{result.stderr}", file=sys.stderr)
stderr = result.stderr
# Never echo the OAuth token (it may appear in remote URLs).
if secret:
cmd_str = cmd_str.replace(secret, "***")
stderr = stderr.replace(secret, "***")
print(f"ERROR: {cmd_str} failed:\n{stderr}", file=sys.stderr)
sys.exit(1)
7 changes: 7 additions & 0 deletions scripts/czdev/unpublish.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,13 @@ def run(package: str, version: str, arch: str = "arm64"):
parts = fork_name.split("/")
push_owner = parts[0]
push_repo = parts[1]
# Forking is asynchronous; wait for the fork and sync it so the
# removal commit is created on a current base.
if not gh.wait_for_branch(push_owner, push_repo, "main"):
print(f"ERROR: fork {fork_name} did not become ready in time. Retry in a minute.",
file=sys.stderr)
sys.exit(1)
gh.sync_fork(push_owner, push_repo, "main")
branch = branch_name(package, version)
pr_head = f"{user.login}:{branch}"

Expand Down
Loading