diff --git a/.github/workflows/release-czdev.yml b/.github/workflows/release-czdev.yml deleted file mode 100644 index 6ba581e..0000000 --- a/.github/workflows/release-czdev.yml +++ /dev/null @@ -1,83 +0,0 @@ -name: Release czdev - -on: - push: - tags: ['czdev-v*'] - workflow_dispatch: - -permissions: - contents: write - -jobs: - build: - strategy: - matrix: - include: - - target: x86_64-unknown-linux-gnu - os: ubuntu-latest - artifact: czdev-linux-x86_64 - - target: aarch64-unknown-linux-gnu - os: ubuntu-latest - artifact: czdev-linux-aarch64 - - target: x86_64-apple-darwin - os: macos-latest - artifact: czdev-macos-x86_64 - - target: aarch64-apple-darwin - os: macos-latest - artifact: czdev-macos-aarch64 - - target: x86_64-pc-windows-msvc - os: windows-latest - artifact: czdev-windows-x86_64.exe - - runs-on: ${{ matrix.os }} - - steps: - - uses: actions/checkout@v4 - - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable - with: - targets: ${{ matrix.target }} - - - name: Install cross-compilation tools (Linux aarch64) - if: matrix.target == 'aarch64-unknown-linux-gnu' - run: | - sudo apt-get update - sudo apt-get install -y gcc-aarch64-linux-gnu - echo '[target.aarch64-unknown-linux-gnu]' >> ~/.cargo/config.toml - echo 'linker = "aarch64-linux-gnu-gcc"' >> ~/.cargo/config.toml - - - name: Build - run: cargo build --release --target ${{ matrix.target }} -p czdev - - - name: Rename artifact (Unix) - if: runner.os != 'Windows' - run: cp target/${{ matrix.target }}/release/czdev ${{ matrix.artifact }} - - - name: Rename artifact (Windows) - if: runner.os == 'Windows' - run: cp target/${{ matrix.target }}/release/czdev.exe ${{ matrix.artifact }} - - - name: Upload artifact - uses: actions/upload-artifact@v4 - with: - name: ${{ matrix.artifact }} - path: ${{ matrix.artifact }} - - release: - needs: build - runs-on: ubuntu-latest - if: startsWith(github.ref, 'refs/tags/') - - steps: - - name: Download all artifacts - uses: actions/download-artifact@v4 - with: - path: dist - merge-multiple: true - - - name: Create Release - uses: softprops/action-gh-release@v2 - with: - generate_release_notes: true - files: dist/* diff --git a/scripts/czdev/github_client.py b/scripts/czdev/github_client.py index bc22f04..72a8a2d 100644 --- a/scripts/czdev/github_client.py +++ b/scripts/czdev/github_client.py @@ -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 diff --git a/scripts/czdev/publish.py b/scripts/czdev/publish.py index 50bd187..48e8c91 100644 --- a/scripts/czdev/publish.py +++ b/scripts/czdev/publish.py @@ -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:") @@ -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"]): @@ -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 @@ -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 diff --git a/scripts/czdev/unpublish.py b/scripts/czdev/unpublish.py index 1678173..44c01b7 100644 --- a/scripts/czdev/unpublish.py +++ b/scripts/czdev/unpublish.py @@ -21,11 +21,6 @@ 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("~", ".") @@ -33,53 +28,46 @@ def run(package: str, version: str, arch: str = "arm64"): 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 @@ -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." @@ -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)