diff --git a/README.md b/README.md index 64b86c4..cb68796 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ Cloudflare Worker(本仓库 worker/,静态页面同域托管) │ 因此包名被占或版本倒退在传字节之前就被挡住 │ ⑤ .deb 流式转发进 packages 仓库 staging Release(BOT_TOKEN),全程不落 │ 内存,上限由 Cloudflare 请求体限制而非 Worker 的 128 MB 内存决定 - │ ⑥ repository_dispatch: web-submission + │ ⑥ repository_dispatch: web-submission(只改商店信息时为 web-listing-update) ▼ CardputerZero/packages Actions(`packages-workflows/` 是这些 workflow 的源) │ ⑦ dpkg-deb 权威校验:完整性 / control / .desktop / setuid / 设备文件 / @@ -62,14 +62,56 @@ fork 用自己改写的规则给自己打分。 - 管理员:`wrangler.toml` 的 `ADMIN_LOGINS`(逗号分隔的 GitHub 登录名) 可管理任意包。 - 相同版本再次提交 → 前端与服务端都会提示"版本已存在,请提升版本号"。 +- 只差 `~` / `.` / `:` 的两个版本(如 `1.0~rc1` 与 `1.0.rc1`)在 Release 里是 + 同一个文件名,后一个会被拒绝;已发布的发布清单(`*.deb.release.json`)不可修改。 + +## 只更新商店信息 + +已发布的包想改名称、介绍、分类、截图或图标,不必发新版本: + +- 网页:「我的软件包」里点「编辑商店信息」,表单带入线上内容,改完提交 + (`POST /api/listing` → `repository_dispatch: web-listing-update`)。 +- 命令行:`czdev publish --meta-only`,读取 `app-builder.json` 的 `store` 段。 + +两条通道都只改 `pool/main/<包名>/` 下的 `meta.json`、截图和图标,不动发布清单; +归属与元数据规则照常检查,通过后和版本更新一样自动合并。 + +## staging Release 清理 + +`staging` 是缓冲区:网页上传的 `.deb` 与商店图片、`czdev` PR 镜像的 `.deb` +都先放在这里。`prune-staging.yml` 每天(以及每次索引构建之后)清理: +已提升进 `apt-pool` 的 `.deb`、超过 14 天且没有任何 open PR 引用的 `.deb`、 +超过 2 天的商店图片。open PR 还在用的 `.deb` 一律保留。可手动运行并勾选 +`dry_run` 先看会删什么。 + +## 违规下架(管理员) + +开发者自己的「下架」只移除某一个版本的清单。因违规需要整体下架时,由 +admin/maintain 权限的维护者在 Actions 里运行 **Take Down Package** +(`takedown.yml`),填写包名与原因: + +1. 删除 `pool/main/<包名>/` 整个目录(所有版本、商店信息、截图),自动合并并 + 重建索引,APT 与商店同时下线; +2. 合并后删除 `apt-pool` 与 `staging` 里该包的二进制,旧下载链接一并失效; +3. 默认把包名记入 `policy/takedowns.json`(原因、时间、执行人、原归属者), + 两条提交通道都会拒绝这个包名,同一应用不能换个上传再回来;**不封禁账号**, + 该开发者的其他应用照常; +4. 开 issue @原归属者说明原因;申诉在该 issue 里处理,解除保留就是删掉 + `takedowns.json` 里对应的条目。 + +已经装到设备上的应用不受影响(设备端没有远程停用机制)。 ## 初步安全报告(浏览器 + CI 双层) - setuid/setgid、全局可写、设备文件、路径穿越 - 安装路径策略:对齐 Debian 的标准位置(`usr/bin/`、`usr/share/`、`usr/lib/`、 `opt/`、`etc/`、`var/lib/`、`usr/share/APPLaunch/`、`usr/share/doc/`、 - `(usr/)lib/systemd/system/`),但共享目录里的文件名/目录名必须与包名相关 + `(usr/)lib/systemd/system/`),但共享目录里的文件名/目录名必须以包名开头 (比对时忽略 `-` `_` `.` 的差异),因此装不进 `usr/bin/ls` 或 `sshd.service`; + `etc/default/`、`etc/logrotate.d/`、`usr/share/man/`、`usr/share/lintian/overrides/`、 + `usr/share/bash-completion/completions/`、`usr/lib/tmpfiles.d/`、`usr/lib/udev/rules.d/` + 这些 Debian 共享目录改为比对文件名(如 `70-<包名>.rules`、`<包名>.1.gz`), + udev 规则另外不得含 `RUN`、`PROGRAM`、`IMPORT{program}`; `usr/local/` 与 `/bin` `/sbin` `/lib` 等 usr-merge 别名路径按 Debian Policy 9.1.2 / 10.1 一律拒绝。被拒时网页与失败 issue 都会给出规则说明和一段可直接 交给 AI 的提示词(前端 `site/debparse.js` 与 CI 的实现由测试保证逐字一致)。 @@ -99,8 +141,8 @@ Cloudflare Workers(不使用 Cloudflare 的 git 集成 / 自动拉取部署) 也可以本地 `wrangler secret put` 一次性设置) 3. push `main`(或手动 workflow_dispatch)→ 自动部署。 4. `packages-workflows/` 下的文件复制到 `CardputerZero/packages`:`*.yml` - 进 `.github/workflows/`,`install_path_policy.py` 进 `.github/scripts/` - (一次性)。想恢复人工审核,网页通道把 `AUTO_MERGE` 改为 `"false"`, + 进 `.github/workflows/`,`*.py` 与 `*.sh` 进 `.github/scripts/` + (每个文件第一行注明了目标路径)。想恢复人工审核,网页通道把 `AUTO_MERGE` 改为 `"false"`, 命令行通道删掉 `validate-pr-comment.yml` 里的 Merge 步骤;PR 仍会自动 创建,由管理员合并。 5. 仓库 Settings → Actions 的 fork PR 审批策略需放宽到 diff --git a/packages-workflows/deb_summary.py b/packages-workflows/deb_summary.py new file mode 100644 index 0000000..f8b914c --- /dev/null +++ b/packages-workflows/deb_summary.py @@ -0,0 +1,90 @@ +#!/usr/bin/env python3 +# Copy to CardputerZero/packages: .github/scripts/deb_summary.py +# +# The package facts a reviewer wants at a glance, as a markdown table, shared by +# both submission channels: process-web-submission.yml puts it in the publish +# PR body, validate-pr.yml in the result comment on a `czdev publish` PR. The +# portal's pre-check shows the same facts before upload. +# +# Usage: deb_summary.py [] +# +# Prints the table on stdout. Never fails the caller: an unreadable field is +# shown as "?". + +import io +import os +import subprocess +import sys +import tarfile + +EM_AARCH64 = 183 + + +def field(deb, name): + try: + return subprocess.run(["dpkg-deb", "-f", deb, name], + capture_output=True, text=True, check=True).stdout.strip() + except Exception: + return "" + + +def mib(n): + return f"{n / 1048576:.1f} MB" + + +def contents(deb): + """(file count, ELF count, set of ELF machine names) from the data tarball.""" + files = elf = 0 + machines = set() + tb = subprocess.run(["dpkg-deb", "--fsys-tarfile", deb], capture_output=True, check=True).stdout + with tarfile.open(fileobj=io.BytesIO(tb)) as tf: + for m in tf.getmembers(): + if not m.isfile(): + continue + files += 1 + head = tf.extractfile(m).read(20) + if len(head) >= 20 and head[:4] == b"\x7fELF": + elf += 1 + machine = int.from_bytes(head[18:20], "little") + machines.add("arm64" if machine == EM_AARCH64 else f"e_machine={machine}") + return files, elf, machines + + +def cell(s): + # Field values come from the uploaded .deb: keep them from breaking the table. + return str(s).replace("|", "\\|").replace("\n", " ").strip() or "?" + + +def main(argv): + deb = argv[1] + published = argv[2] if len(argv) > 2 else "" + installed_kb = field(deb, "Installed-Size") + rows = [ + ("Package", f"`{field(deb, 'Package')}`"), + ("Version", field(deb, "Version")), + ("Previous version / 上一版", published or "— (new package / 新应用)"), + ("Architecture", field(deb, "Architecture")), + ("Maintainer", field(deb, "Maintainer")), + ("Download size / 安装包大小", mib(os.path.getsize(deb))), + ("Installed size / 安装后大小", mib(int(installed_kb) * 1024) if installed_kb.isdigit() else "?"), + ] + try: + files, elf, machines = contents(deb) + rows.append(("Files / 文件数", files)) + rows.append(("ELF binaries / ELF 二进制", + f"{elf} ({', '.join(sorted(machines))})" if elf else "0")) + except Exception: + rows.append(("Files / 文件数", "?")) + depends = field(deb, "Depends") + if depends: + rows.append(("Depends", depends)) + + print("| Field | Value |") + print("|---|---|") + for k, v in rows: + print(f"| {k} | {cell(v)} |") + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) diff --git a/packages-workflows/install_path_policy.py b/packages-workflows/install_path_policy.py index a64d8af..56814a5 100644 --- a/packages-workflows/install_path_policy.py +++ b/packages-workflows/install_path_policy.py @@ -27,8 +27,26 @@ ALIASED_ROOTS = ("bin/", "sbin/", "lib/", "lib32/", "lib64/", "libx32/") SYSTEMD_DIRS = ("lib/systemd/system/", "usr/lib/systemd/system/") UNIT_SUFFIX = re.compile(r"\.(service|socket|timer|target|path|mount)$") +# Shared Debian directories whose *files* carry the package name, rather than a +# subdirectory: /etc/default/, NN-.rules, .1.gz and so on. Each +# entry is (directory pattern, what to strip from the file name before matching). +FILE_NAMED_DIRS = ( + (re.compile(r"etc/default/"), None), + (re.compile(r"etc/logrotate\.d/"), None), + (re.compile(r"usr/share/lintian/overrides/"), None), + (re.compile(r"usr/share/bash-completion/completions/"), None), + (re.compile(r"usr/lib/tmpfiles\.d/"), re.compile(r"\.conf$")), + (re.compile(r"usr/lib/udev/rules\.d/"), re.compile(r"^\d+-|\.rules$")), + (re.compile(r"usr/share/man/(?:[^/]+/)?man[1-9][^/]*/"), re.compile(r"\.[1-9][^.]*(?:\.gz)?$")), +) +UDEV_RULES_DIR = "usr/lib/udev/rules.d/" +# udev runs these keys' commands as root, which apps must not do. +UDEV_EXEC_KEY = re.compile(r"(?:^|[\s,])(?:RUN|PROGRAM|IMPORT\{program\})(?!\w)") +UDEV_EXEC_REASON = ("udev rules must not use RUN, PROGRAM or IMPORT{program} (they run commands as root) / " + "udev 规则不能使用 RUN、PROGRAM、IMPORT{program}(这些会以 root 执行命令)") ALLOWED_SUMMARY = ("允许 usr/bin/<与包名相关的可执行文件>、" "usr/share|usr/lib|opt|var/lib 下与包名相关的子目录、etc/ 下与包名相关的配置、" + "etc/default、man、lintian、bash-completion、tmpfiles.d、udev、logrotate.d 下以包名命名的文件、" "usr/share/APPLaunch/、usr/share/doc/、systemd 服务目录") @@ -37,12 +55,23 @@ def normalize_name(s): def named_after_pkg(name, pkg): - """Is `name` recognizably the package's own? Separators are ignored and - either may prefix the other, so package fontpreview accepts a font_preview - directory and myapp accepts myapp-cli, but neither accepts ls.""" + """Is `name` recognizably the package's own? Separators are ignored and the + name must start with the package name, so package fontpreview accepts a + font_preview directory and a fontpreview-cli binary but not ls. Only this + direction counts: package sudoers-tool must not claim sudoers.d.""" a = normalize_name(UNIT_SUFFIX.sub("", name)) b = normalize_name(pkg or "") - return bool(a and b) and (a.startswith(b) or b.startswith(a)) + return bool(a and b) and a.startswith(b) + + +def udev_rule_runs_programs(text): + """Does this udev rule make udev execute something? RUN, PROGRAM and + IMPORT{program} all run as root, outside any service's User=.""" + for raw in text.splitlines(): + line = raw.strip() + if line and not line.startswith("#") and UDEV_EXEC_KEY.search(line): + return True + return False def install_path_issue(path, pkg): @@ -70,6 +99,15 @@ def install_path_issue(path, pkg): return "Debian Policy 10.1 forbids /bin /sbin /lib etc. (usr-merge alias paths); use the /usr equivalent / Debian Policy 10.1 禁止软件包写入 /bin /sbin /lib 等 usr-merge 别名路径,请改用 /usr 下的对应位置" if path.startswith("usr/share/APPLaunch/") or path.startswith("usr/share/doc/"): return None + for pattern, strip in FILE_NAMED_DIRS: + m = pattern.match(path) + if m: + d, name = m.group(0), path[m.end():] + if "/" in name: + return f"no subdirectories under {d} / {d} 下不应再有子目录" + if named_after_pkg(strip.sub("", name) if strip else name, pkg): + return None + return f"the file name under {d} must relate to the package name {pkg} / {d} 下的文件名必须与包名 {pkg} 相关" # /etc/.conf is as much a Debian convention as /etc// (compare # rsyslog.conf, logrotate.conf), so a lone conffile is fine here. if path.startswith("etc/"): @@ -106,7 +144,7 @@ def install_path_issue(path, pkg): 请帮我修改打包脚本(CMakeLists.txt / debian/rules / install 脚本 / Makefile,看项目里用的是哪种), 把文件装到合规位置,并同步更新 .desktop 里的 Exec 和 Icon、以及代码里读取资源文件的路径。 -商店的规则(包名记作 PKG,比对时忽略 - _ . 的差异,所以 PKG 是 fontpreview 时目录叫 font_preview 也算合规): +商店的规则(包名记作 PKG。“与包名相关”指名字以 PKG 开头,比对时忽略 - _ . 的差异,所以 PKG 是 fontpreview 时目录叫 font_preview、fontpreview-data 都算合规,叫 preview 不算): 允许安装到: - usr/bin/<名字>:可执行文件,名字必须与包名相关(这是 Debian Policy 10.1 的标准位置) @@ -115,6 +153,9 @@ def install_path_issue(path, pkg): - opt/<与包名相关的目录>/**:整体打包自带的目录 - etc/<与包名相关的目录>/** 或 etc/<与包名相关>.conf:配置文件(Debian conffile 惯例) - var/lib/<与包名相关的目录>/**:运行时数据 +- etc/default/、etc/logrotate.d/、usr/share/lintian/overrides/、usr/share/bash-completion/completions/、 + usr/lib/tmpfiles.d/.conf、usr/share/man/manN/.N.gz:Debian 标准的共享目录,目录名照旧,文件名以包名开头 +- usr/lib/udev/rules.d/NN-.rules:udev 规则,不能使用 RUN、PROGRAM 和 program 类型的 IMPORT(这些会以 root 执行命令) - usr/share/APPLaunch/**:CardputerZero 启动器资源,且必须有 usr/share/APPLaunch/applications/<名字>.desktop - usr/share/doc/**:文档 - lib/systemd/system/<与包名相关>.service 或 usr/lib/systemd/system/<同样>:服务名必须与包名相关, @@ -139,14 +180,14 @@ def install_path_issue(path, pkg): 📁 Install path rules + a ready-to-paste AI prompt / 安装路径规则 + 可直接交给 AI 的提示词(点开) We allow Debian's standard locations and only require that **anything landing -in a shared directory carries your package name**, so an app cannot quietly +in a shared directory starts with your package name**, so an app cannot quietly shadow system commands or other packages' files. `-`, `_` and `.` are ignored in the comparison, so package `fontpreview` may use a `font_preview` directory. Copy the block below into an AI assistant (Cursor / Claude / ChatGPT); it carries the full policy plus your rejected paths and can fix the packaging scripts directly. -我们放行 Debian 的标准位置,只要求**落在共享目录里的文件名/目录名带上你的包名**, +我们放行 Debian 的标准位置,只要求**落在共享目录里的文件名/目录名以你的包名开头**, 这样一个应用不会悄悄覆盖系统命令或别人的文件。比对时忽略 `-`、`_`、`.` 的差异, 所以包名 `fontpreview` 用 `font_preview` 目录是可以的。 把下面整段复制给 AI(Cursor / Claude / ChatGPT),它带着完整规则和你这次被拒的路径, @@ -176,6 +217,10 @@ def rejected_paths(deb, pkg): if not path: continue why = install_path_issue(path, pkg) + if not why and path.startswith(UDEV_RULES_DIR) and member.isfile(): + text = tf.extractfile(member).read().decode("utf-8", "replace") + if udev_rule_runs_programs(text): + why = UDEV_EXEC_REASON if why: # Backticks would break out of the code fence in the help text, # and control characters would garble it. diff --git a/packages-workflows/package_owner.sh b/packages-workflows/package_owner.sh new file mode 100644 index 0000000..1e73079 --- /dev/null +++ b/packages-workflows/package_owner.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +# Copy to CardputerZero/packages: .github/scripts/package_owner.sh +# +# Who owns a published package, by the manifests in the current checkout: +# package names are first-come-first-served, keyed on the uploader's GitHub +# login recorded as `uploaded_by` in the release manifest. Legacy manifests +# have no `uploaded_by`, so the login is derived from the published binary's +# Maintainer noreply address. +# +# Usage: package_owner.sh +# Prints "|"; either part is +# empty when unknown (an unpublished package prints "|"). + +set -uo pipefail +PKG=$1 + +EXISTING="" ; LATEST_MANIFEST="" +for m in pool/main/"$PKG"/*.deb.release.json; do + [ -f "$m" ] || continue + v=$(jq -r '.version // empty' "$m") + if [ -n "$v" ] && { [ -z "$EXISTING" ] || dpkg --compare-versions "$v" gt "$EXISTING"; }; then + EXISTING="$v"; LATEST_MANIFEST="$m" + fi +done + +OWNER_LOGIN="" +if [ -n "$LATEST_MANIFEST" ]; then + OWNER_LOGIN=$(jq -r '.uploaded_by // empty' "$LATEST_MANIFEST" | tr '[:upper:]' '[:lower:]') + if [ -z "$OWNER_LOGIN" ]; then + u=$(jq -r '.url // empty' "$LATEST_MANIFEST") + if [ -n "$u" ] && curl -fsL --max-time 300 -o /tmp/existing.deb "$u" 2>/dev/null; then + OM=$(dpkg-deb -f /tmp/existing.deb Maintainer 2>/dev/null | grep -oP '<\K[^>]+' | tr '[:upper:]' '[:lower:]' || true) + OWNER_LOGIN=$(printf '%s' "$OM" | sed -nE 's/^([0-9]+\+)?([^@]+)@users\.noreply\.github\.com$/\2/p') + fi + fi +fi + +printf '%s|%s\n' "$EXISTING" "$OWNER_LOGIN" diff --git a/packages-workflows/process-web-submission.yml b/packages-workflows/process-web-submission.yml index dc6129a..5288b4d 100644 --- a/packages-workflows/process-web-submission.yml +++ b/packages-workflows/process-web-submission.yml @@ -1,18 +1,26 @@ # Copy to CardputerZero/packages: .github/workflows/process-web-submission.yml # -# Handles `repository_dispatch` (type: web-submission) sent by the developer -# portal Worker. Authoritative validation with real dpkg-deb: integrity, -# control fields, .desktop, structure & safety scan, first-come-first-served -# ownership (the package name belongs to the GitHub login that first uploaded -# it, recorded as `uploaded_by`; admins bypass), and version monotonicity. On -# success it opens a publish PR (and can auto-merge it when AUTO_MERGE is true); -# on failure it opens an issue mentioning the submitter. +# Handles `repository_dispatch` sent by the developer portal Worker: +# +# web-submission a new .deb. Authoritative validation with real +# dpkg-deb: integrity, control fields, .desktop, +# structure & safety scan, first-come-first-served +# ownership (the package name belongs to the GitHub login +# that first uploaded it, recorded as `uploaded_by`; +# admins bypass), version monotonicity, and names that +# were taken down (policy/takedowns.json). +# web-listing-update only the store listing (meta.json, screenshots, icon) +# of a published package; the version and binary stay. +# Ownership and the metadata policy are checked the same. +# +# On success it opens a PR (and can auto-merge it when AUTO_MERGE is true); on +# failure it opens an issue mentioning the submitter. name: Process Web Submission on: repository_dispatch: - types: [web-submission] + types: [web-submission, web-listing-update] permissions: contents: write @@ -35,6 +43,7 @@ jobs: - name: Download and validate .deb id: validate + if: github.event.action == 'web-submission' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} DEB_URL: ${{ github.event.client_payload.binary.url }} @@ -131,26 +140,11 @@ jobs: # the uploader's GitHub login (recorded as `uploaded_by` in the # release manifest). Existing packages are only updatable by the # recorded owner or an admin. No email-vs-account matching. --- - EXISTING="" ; LATEST_MANIFEST="" - for m in pool/main/"$PKG"/*.deb.release.json; do - [ -f "$m" ] || continue - v=$(jq -r '.version // empty' "$m") - if [ -n "$v" ] && { [ -z "$EXISTING" ] || dpkg --compare-versions "$v" gt "$EXISTING"; }; then - EXISTING="$v"; LATEST_MANIFEST="$m" - fi - done - OWNER_LOGIN="" - if [ -n "$LATEST_MANIFEST" ]; then - OWNER_LOGIN=$(jq -r '.uploaded_by // empty' "$LATEST_MANIFEST" | tr '[:upper:]' '[:lower:]') - # Legacy manifests have no `uploaded_by`: derive the owner login - # from the published binary's Maintainer noreply address. - if [ -z "$OWNER_LOGIN" ]; then - u=$(jq -r '.url // empty' "$LATEST_MANIFEST") - if [ -n "$u" ] && curl -fsL --max-time 300 -o /tmp/existing.deb "$u" 2>/dev/null; then - OM=$(dpkg-deb -f /tmp/existing.deb Maintainer 2>/dev/null | grep -oP '<\K[^>]+' | tr '[:upper:]' '[:lower:]' || true) - OWNER_LOGIN=$(printf '%s' "$OM" | sed -nE 's/^([0-9]+\+)?([^@]+)@users\.noreply\.github\.com$/\2/p') - fi - fi + IFS='|' read -r EXISTING OWNER_LOGIN < <(bash .github/scripts/package_owner.sh "$PKG") + # A name that was taken down stays reserved: the same app cannot + # come back under it by being uploaded again. + if [ -f policy/takedowns.json ] && jq -e --arg p "$PKG" 'has($p)' policy/takedowns.json >/dev/null; then + ERR="${ERR}- ❌ package name '$PKG' was taken down and is reserved; contact a maintainer\n" fi SUBMITTER_LC=$(printf '%s' "$SUBMITTER" | tr '[:upper:]' '[:lower:]') if [ -n "$OWNER_LOGIN" ] && [ "$IS_ADMIN" != "true" ] && [ "$OWNER_LOGIN" != "$SUBMITTER_LC" ]; then @@ -159,7 +153,16 @@ jobs: if [ -n "$EXISTING" ] && dpkg --compare-versions "$VER" le "$EXISTING"; then ERR="${ERR}- ❌ version $VER is not newer than published $EXISTING\n" fi - { echo "pkg=$PKG"; echo "ver=$VER"; echo "arch=$ARCH"; } >> "$GITHUB_OUTPUT" + # Release asset names turn '~' and ':' into '.', so 1.0~rc1 and + # 1.0.rc1 share one asset and one manifest. Publishing the second + # would overwrite the first's manifest while the pool kept serving + # the first's binary. + ASSET_NAME="${PKG}_${VER//[\~:]/.}_${ARCH}.deb" + if [ -f "pool/main/$PKG/$ASSET_NAME.release.json" ]; then + TAKEN=$(jq -r '.version // empty' "pool/main/$PKG/$ASSET_NAME.release.json") + ERR="${ERR}- ❌ version $VER maps to the same file name ($ASSET_NAME) as published version $TAKEN; pick a version that differs in more than '~' / '.' / ':'\n" + fi + { echo "pkg=$PKG"; echo "ver=$VER"; echo "arch=$ARCH"; echo "prev=$EXISTING"; } >> "$GITHUB_OUTPUT" fi # Random delimiters: these values embed file names taken from the # uploaded .deb, and a crafted name must not be able to close the @@ -171,12 +174,35 @@ jobs: printf 'path_help<<%s\n%s\n%s\n' "$D" "$(cat /tmp/path-help.md)" "$D" >> "$GITHUB_OUTPUT" fi + - name: Validate listing update + id: listing + if: github.event.action == 'web-listing-update' + env: + PKG: ${{ github.event.client_payload.package }} + SUBMITTER: ${{ github.event.client_payload.login }} + IS_ADMIN: ${{ github.event.client_payload.is_admin }} + run: | + set -uo pipefail + ERR="" + echo "$PKG" | grep -qP '^[a-z0-9][a-z0-9.+\-]+$' || { echo "::error::bad package name"; exit 1; } + IFS='|' read -r EXISTING OWNER_LOGIN < <(bash .github/scripts/package_owner.sh "$PKG") + SUBMITTER_LC=$(printf '%s' "$SUBMITTER" | tr '[:upper:]' '[:lower:]') + if [ -z "$EXISTING" ]; then + ERR="- ❌ '$PKG' has no published version; publish a .deb first\n" + elif [ "$IS_ADMIN" != "true" ] && [ -z "$OWNER_LOGIN" ]; then + ERR="- ❌ could not verify ownership of '$PKG' (no recorded uploader); ask an admin\n" + elif [ "$IS_ADMIN" != "true" ] && [ "$OWNER_LOGIN" != "$SUBMITTER_LC" ]; then + ERR="- ❌ package '$PKG' is owned by @$OWNER_LOGIN; only the original uploader or an admin can update it\n" + fi + echo "pkg=$PKG" >> "$GITHUB_OUTPUT" + printf 'errors<> "$GITHUB_OUTPUT" + - name: Collect store metadata id: meta - if: steps.validate.outputs.errors == '' + if: steps.validate.outputs.errors == '' && steps.listing.outputs.errors == '' env: SOURCE_REPO: ${{ github.event.client_payload.source_repo }} - PKG: ${{ steps.validate.outputs.pkg }} + PKG: ${{ steps.validate.outputs.pkg || steps.listing.outputs.pkg }} SUBMITTER: ${{ github.event.client_payload.login }} STORE_JSON: ${{ toJSON(github.event.client_payload.store) }} run: | @@ -363,9 +389,9 @@ jobs: # submission is judged identically whichever way it arrives. - name: Validate store metadata id: metacheck - if: steps.validate.outputs.errors == '' && steps.meta.outputs.errors == '' + if: steps.validate.outputs.errors == '' && steps.listing.outputs.errors == '' && steps.meta.outputs.errors == '' env: - PKG: ${{ steps.validate.outputs.pkg }} + PKG: ${{ steps.validate.outputs.pkg || steps.listing.outputs.pkg }} run: | set -uo pipefail ERR="" @@ -391,7 +417,7 @@ jobs: - name: Create publish PR id: pr - if: steps.validate.outputs.errors == '' && steps.meta.outputs.errors == '' && steps.metacheck.outputs.errors == '' + if: github.event.action == 'web-submission' && steps.validate.outputs.errors == '' && steps.meta.outputs.errors == '' && steps.metacheck.outputs.errors == '' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} DEB_URL: ${{ github.event.client_payload.binary.url }} @@ -402,10 +428,12 @@ jobs: PKG: ${{ steps.validate.outputs.pkg }} VER: ${{ steps.validate.outputs.ver }} ARCH: ${{ steps.validate.outputs.arch }} + PREV: ${{ steps.validate.outputs.prev }} + SOURCE_REPO: ${{ github.event.client_payload.source_repo }} run: | set -euo pipefail NAME="${PKG}_${VER}_${ARCH}.deb" - ASSET_NAME="${NAME//\~/.}" + ASSET_NAME="${NAME//[\~:]/.}" # First release of a new package name is gated on a human maintainer # watching a demo video; updates to published packages are unattended. # Judged by the manifests tracked on main (the checkout's index) — @@ -421,17 +449,28 @@ jobs: --arg by "$SUBMITTER" --arg em "$UPLOADER_EMAIL" \ '{filename:$f,url:$u,sha256:$s,size:$z,package:$p,version:$v,architecture:$a,uploaded_by:$by,uploader_email:$em}' \ > "pool/main/$PKG/$ASSET_NAME.release.json" - BRANCH="publish/${PKG}-${VER}-web-${{ github.run_id }}" + # '~' and ':' are legal in a Debian version but not in a git ref. + BRANCH="publish/${PKG}-${VER//[\~:]/_}-web-${{ github.run_id }}" git config user.name "cardputerzero-bot" git config user.email "cardputerzero-bot@users.noreply.github.com" git checkout -b "$BRANCH" git add "pool/main/$PKG" git commit -m "publish: $PKG $VER ($ARCH) via developer portal by @$SUBMITTER" git push origin "$BRANCH" + # Same table as the czdev channel's validation comment, so a reviewer + # sees size, installed size and binaries whichever way it arrived. + { + printf 'Submitted by @%s via the developer portal.\n\n' "$SUBMITTER" + python3 .github/scripts/deb_summary.py /tmp/pkg.deb "$PREV" + printf '| SHA-256 | `%s` |\n' "$WANT_SHA" + printf '| Binary | [%s](%s) |\n' "$ASSET_NAME" "$DEB_URL" + [ -n "$SOURCE_REPO" ] && printf '| Source | %s |\n' "$SOURCE_REPO" + printf '\nAll automated checks passed in [this run](%s/%s/actions/runs/%s).\n' \ + "${{ github.server_url }}" "${{ github.repository }}" "${{ github.run_id }}" + } > /tmp/pr-body.md PR_URL=$(gh pr create --base main --head "$BRANCH" \ --title "publish: $PKG $VER (portal submission by @$SUBMITTER)" \ - --body "$(printf 'Submitted by @%s via the developer portal.\n\n| Field | Value |\n|---|---|\n| Version | %s |\n| Architecture | %s |\n| SHA-256 | `%s` |\n| Binary | [%s](%s) |\n\nAll automated checks passed in [this run](%s/%s/actions/runs/%s).' \ - "$SUBMITTER" "$VER" "$ARCH" "$WANT_SHA" "$ASSET_NAME" "$DEB_URL" "${{ github.server_url }}" "${{ github.repository }}" "${{ github.run_id }}")") + --body-file /tmp/pr-body.md) echo "url=$PR_URL" >> "$GITHUB_OUTPUT" if [ "$NEW_PKG" = "true" ]; then gh pr comment "$PR_URL" --body "$(printf '@%s ✅ All automated checks passed.\n\n🆕 This is the **first release** of a new app (\`%s\`), so per store policy it will not merge automatically: please post a short video of the app running on a real device in this PR (a phone recording is fine). A maintainer will review and merge it. Later version updates merge automatically.\n\n---\n\n✅ 自动校验全部通过。\n\n🆕 这是新应用 \`%s\` 的**首次发布**,按商店政策不会自动合并:\n请在本 PR 评论区发一段应用在真机上运行的演示视频(手机拍摄即可),\n维护者查看后会手动合并上架。后续版本更新会自动合并。' "$SUBMITTER" "$PKG" "$PKG")" @@ -448,12 +487,42 @@ jobs: fi fi + - name: Create listing update PR + if: github.event.action == 'web-listing-update' && steps.listing.outputs.errors == '' && steps.meta.outputs.errors == '' && steps.metacheck.outputs.errors == '' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + SUBMITTER: ${{ github.event.client_payload.login }} + PKG: ${{ steps.listing.outputs.pkg }} + run: | + set -euo pipefail + BRANCH="metadata/${PKG}-web-${{ github.run_id }}" + git config user.name "cardputerzero-bot" + git config user.email "cardputerzero-bot@users.noreply.github.com" + git checkout -b "$BRANCH" + git add -A "pool/main/$PKG" + git commit -m "metadata: $PKG store listing via developer portal by @$SUBMITTER" + git push origin "$BRANCH" + PR_URL=$(gh pr create --base main --head "$BRANCH" \ + --title "metadata: $PKG store listing (portal update by @$SUBMITTER)" \ + --body "$(printf 'Store listing update for `%s` by @%s via the developer portal.\n\nOnly `meta.json`, screenshots and the icon change; the published version and binary stay as they are. Ownership and the metadata policy passed in [this run](%s/%s/actions/runs/%s).' \ + "$PKG" "$SUBMITTER" "${{ github.server_url }}" "${{ github.repository }}" "${{ github.run_id }}")") + # Same rule as a version update of a published package: it merges + # unattended once the checks above pass. + if [ "$AUTO_MERGE" = "true" ]; then + if gh pr merge "$PR_URL" --squash --delete-branch; then + gh workflow run update-index.yml --ref main || echo "::warning::could not trigger update-index; run it manually" + else + echo "::warning::auto-merge failed; awaiting manual review" + fi + fi + - name: Report failure to the submitter - if: always() && (steps.validate.outputs.errors != '' || steps.meta.outputs.errors != '' || steps.metacheck.outputs.errors != '' || failure()) + if: always() && (steps.validate.outputs.errors != '' || steps.listing.outputs.errors != '' || steps.meta.outputs.errors != '' || steps.metacheck.outputs.errors != '' || failure()) env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} SUBMITTER: ${{ github.event.client_payload.login }} - FILENAME: ${{ github.event.client_payload.filename }} + FILENAME: ${{ github.event.client_payload.filename || format('{0} store listing', github.event.client_payload.package) }} + ERR0: ${{ steps.listing.outputs.errors }} ERR1: ${{ steps.validate.outputs.errors }} ERR2: ${{ steps.meta.outputs.errors }} ERR3: ${{ steps.metacheck.outputs.errors }} @@ -463,7 +532,7 @@ jobs: { echo "@$SUBMITTER your portal submission of \`$FILENAME\` failed validation:" echo - printf '%b%b%b' "$ERR1" "$ERR2" "$ERR3" + printf '%b%b%b%b' "$ERR0" "$ERR1" "$ERR2" "$ERR3" echo # Set only when a policy check rejected something: states the rule # and hands over a prompt that fixes the packaging or metadata. diff --git a/packages-workflows/process-web-unpublish.yml b/packages-workflows/process-web-unpublish.yml index 734e099..b24beaf 100644 --- a/packages-workflows/process-web-unpublish.yml +++ b/packages-workflows/process-web-unpublish.yml @@ -46,7 +46,7 @@ jobs: set -uo pipefail ERR="" echo "$PKG" | grep -qP '^[a-z0-9][a-z0-9.+\-]+$' || { echo "::error::bad package name"; exit 1; } - ASSET_NAME=$(echo "${PKG}_${VER}_${ARCH}.deb" | tr '~' '.') + ASSET_NAME=$(echo "${PKG}_${VER}_${ARCH}.deb" | tr '~:' '..') MANIFEST="pool/main/$PKG/$ASSET_NAME.release.json" if [ ! -f "$MANIFEST" ]; then ERR="- ❌ manifest not found: \`$MANIFEST\`\n" @@ -72,7 +72,8 @@ jobs: fi fi if [ -z "$ERR" ]; then - BRANCH="unpublish/${PKG}-${VER}-web-${{ github.run_id }}" + # '~' and ':' are legal in a Debian version but not in a git ref. + BRANCH="unpublish/${PKG}-${VER//[\~:]/_}-web-${{ github.run_id }}" git config user.name "cardputerzero-bot" git config user.email "cardputerzero-bot@users.noreply.github.com" git checkout -b "$BRANCH" diff --git a/packages-workflows/prune-staging.yml b/packages-workflows/prune-staging.yml new file mode 100644 index 0000000..ff22dbd --- /dev/null +++ b/packages-workflows/prune-staging.yml @@ -0,0 +1,101 @@ +# Copy to CardputerZero/packages: .github/workflows/prune-staging.yml +# +# Keeps the `staging` release from growing forever. It is a buffer, not an +# archive: the portal uploads each submission's .deb and store PNGs there +# (worker BUFFER_TAG), and capture-to-staging.yml mirrors every czdev PR's .deb +# into it. Nothing used to delete them afterwards. +# +# What goes, and why it is safe: +# - a .deb already in apt-pool: promotion (update-index.yml) skips names the +# pool holds, so the staging copy is never read again; +# - a .deb older than STALE_DAYS that no open PR's manifest names: its +# submission failed, was abandoned or was closed unmerged; +# - a store PNG older than PNG_DAYS: process-web-submission.yml commits the +# images into git within the same run, so they are dead after it. +# A .deb an open PR still points at is always kept, however old. + +name: Prune Staging Release + +on: + schedule: + - cron: '17 3 * * *' + workflow_run: + workflows: ["Deploy APT Repository to Pages"] + types: [completed] + workflow_dispatch: + inputs: + dry_run: + description: 'Only list what would be deleted' + type: boolean + default: false + +permissions: + contents: write + pull-requests: read + +concurrency: + group: prune-staging + cancel-in-progress: false + +env: + STALE_DAYS: "14" + PNG_DAYS: "2" + +jobs: + prune: + runs-on: ubuntu-latest + steps: + - name: Delete promoted and stale staging assets + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + DRY_RUN: ${{ inputs.dry_run || 'false' }} + run: | + set -euo pipefail + R="$GITHUB_REPOSITORY" + gh release view staging -R "$R" >/dev/null 2>&1 || { echo "no staging release"; exit 0; } + + gh release view apt-pool -R "$R" --json assets --jq '.assets[].name' | sort -u > /tmp/pool.txt + + # File names that open PRs' manifests still need. Read through the API + # as data, like capture-to-staging.yml: no PR code is checked out. + : > /tmp/wanted.txt + for pr in $(gh pr list -R "$R" --state open --limit 500 --json number --jq '.[].number'); do + head=$(gh pr view "$pr" -R "$R" --json headRepositoryOwner,headRepository,headRefOid \ + --jq '"\(.headRepositoryOwner.login)/\(.headRepository.name) \(.headRefOid)"') + repo=${head% *}; sha=${head#* } + gh api "repos/$R/pulls/$pr/files" --paginate \ + --jq '.[] | select(.status != "removed") | .filename' \ + | grep -E '^pool/main/[^/]+/[^/]+\.deb\.release\.json$' \ + | while read -r path; do + gh api "repos/$repo/contents/$path?ref=$sha" --jq '.content' 2>/dev/null \ + | base64 -d | jq -r '.filename // empty' || true + done >> /tmp/wanted.txt || true + done + sort -u -o /tmp/wanted.txt /tmp/wanted.txt + + now=$(date -u +%s) + gh api "repos/$R/releases/tags/staging" --jq '.assets[] | "\(.id)\t\(.name)\t\(.created_at)"' > /tmp/assets.tsv + deleted=0 + while IFS=$'\t' read -r id name created; do + age_days=$(( (now - $(date -u -d "$created" +%s)) / 86400 )) + why="" + case "$name" in + *.deb) + if grep -qxF "$name" /tmp/wanted.txt; then + continue + elif grep -qxF "$name" /tmp/pool.txt; then + why="promoted to apt-pool" + elif [ "$age_days" -ge "$STALE_DAYS" ]; then + why="no open PR uses it, ${age_days} days old" + fi ;; + *.png) + [ "$age_days" -ge "$PNG_DAYS" ] && why="store image, ${age_days} days old" ;; + esac + [ -n "$why" ] || continue + echo "delete $name ($why)" + if [ "$DRY_RUN" != "true" ]; then + gh api -X DELETE "repos/$R/releases/assets/$id" + fi + deleted=$((deleted + 1)) + done < /tmp/assets.tsv + echo "::notice::staging: $deleted asset(s) $([ "$DRY_RUN" = "true" ] && echo "would be deleted" || echo "deleted")" diff --git a/packages-workflows/takedown.yml b/packages-workflows/takedown.yml new file mode 100644 index 0000000..d8577a2 --- /dev/null +++ b/packages-workflows/takedown.yml @@ -0,0 +1,178 @@ +# Copy to CardputerZero/packages: .github/workflows/takedown.yml +# +# Takes a package off the store for a policy violation (malware, infringement, +# content that breaks the rules). Run by a maintainer from the Actions tab; +# this is not the developer's own unpublish (process-web-unpublish.yml), which +# removes one version and leaves the binary downloadable. +# +# A takedown removes every trace the store serves: +# - pool/main// entirely (all manifests, meta.json, screenshots, icon), +# so the next index build drops it from APT and the store registry; +# - the package's binaries in the apt-pool and staging releases, so old +# download links stop working too (only after the removal is merged: the +# index build fails on a manifest whose binary is gone); +# - and, unless told otherwise, records the name in policy/takedowns.json, +# which both submission channels check, so the same app cannot come back +# by being uploaded again. The owner's account is not blocked. +# The owner is notified in an issue that states the reason; an appeal is +# discussed there, and lifting it means deleting the takedowns.json entry. + +name: Take Down Package + +on: + workflow_dispatch: + inputs: + package: + description: 'Package name to take down' + required: true + reason: + description: 'Reason, shown to the owner in the notification issue' + required: true + reserve_name: + description: 'Reserve the package name so it cannot be uploaded again' + type: boolean + default: true + +permissions: + contents: write + issues: write + pull-requests: write + actions: write # to trigger update-index via workflow_dispatch after the merge + +concurrency: + group: takedown-${{ inputs.package }} + cancel-in-progress: false + +jobs: + takedown: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install tools + run: sudo apt-get update && sudo apt-get install -y dpkg-dev jq + + - name: Remove the package and open the takedown PR + id: remove + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PKG: ${{ inputs.package }} + REASON: ${{ inputs.reason }} + RESERVE: ${{ inputs.reserve_name }} + ACTOR: ${{ github.actor }} + run: | + set -euo pipefail + # Anyone with write access can dispatch a workflow; a takedown is for + # the people who run the store. + PERM=$(gh api "repos/$GITHUB_REPOSITORY/collaborators/$ACTOR/permission" --jq .permission) + case "$PERM" in admin|maintain) ;; *) echo "::error::@$ACTOR has '$PERM' access; a takedown needs admin or maintain"; exit 1;; esac + + echo "$PKG" | grep -qP '^[a-z0-9][a-z0-9.+\-]+$' || { echo "::error::bad package name '$PKG'"; exit 1; } + [ -d "pool/main/$PKG" ] || { echo "::error::pool/main/$PKG does not exist"; exit 1; } + + IFS='|' read -r LATEST OWNER < <(bash .github/scripts/package_owner.sh "$PKG") + VERSIONS=$(jq -r '.version // empty' pool/main/"$PKG"/*.deb.release.json 2>/dev/null | sort -V | paste -sd, - || true) + jq -r '.filename // empty' pool/main/"$PKG"/*.deb.release.json 2>/dev/null > /tmp/assets.txt || true + + BRANCH="takedown/${PKG}-${{ github.run_id }}" + git config user.name "cardputerzero-bot" + git config user.email "cardputerzero-bot@users.noreply.github.com" + git checkout -b "$BRANCH" + git rm -rq "pool/main/$PKG" + if [ "$RESERVE" = "true" ]; then + mkdir -p policy + [ -f policy/takedowns.json ] || echo '{}' > policy/takedowns.json + jq --arg p "$PKG" --arg r "$REASON" --arg by "$ACTOR" --arg o "$OWNER" --arg v "$VERSIONS" \ + --arg at "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ + '.[$p] = {reason: $r, taken_down_at: $at, by: $by, owner: $o, versions: ($v | split(",") | map(select(. != "")))}' \ + policy/takedowns.json > /tmp/takedowns.json + mv /tmp/takedowns.json policy/takedowns.json + git add policy/takedowns.json + fi + git commit -qm "takedown: $PKG by @$ACTOR" + git push -q origin "$BRANCH" + + { + echo "Takedown of \`$PKG\` by @$ACTOR." + echo + echo "| Field | Value |" + echo "|---|---|" + echo "| Owner | ${OWNER:+@$OWNER} |" + echo "| Versions | ${VERSIONS:-—} |" + echo "| Name reserved | $RESERVE |" + echo + echo "**Reason:**" + echo + printf '%s\n' "$REASON" | sed 's/^/> /' + } > /tmp/pr.md + PR_URL=$(gh pr create --base main --head "$BRANCH" --title "takedown: $PKG" --body-file /tmp/pr.md) + MERGED=false + if gh pr merge "$PR_URL" --squash --delete-branch; then + MERGED=true + gh workflow run update-index.yml --ref main || echo "::warning::could not trigger update-index; run it manually" + else + echo "::warning::could not merge $PR_URL; merge it by hand, then delete the binaries listed in this run" + fi + { echo "owner=$OWNER"; echo "versions=$VERSIONS"; echo "pr=$PR_URL"; echo "merged=$MERGED"; } >> "$GITHUB_OUTPUT" + + - name: Delete the binaries from apt-pool and staging + if: steps.remove.outputs.merged == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -uo pipefail + for tag in apt-pool staging; do + gh api "repos/$GITHUB_REPOSITORY/releases/tags/$tag" --jq '.assets[] | "\(.id)\t\(.name)"' > /tmp/rel.tsv 2>/dev/null || continue + while IFS=$'\t' read -r id name; do + if grep -qxF "$name" /tmp/assets.txt; then + gh api -X DELETE "repos/$GITHUB_REPOSITORY/releases/assets/$id" && echo "deleted $tag/$name" + fi + done < /tmp/rel.tsv + done + + - name: Notify the owner + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PKG: ${{ inputs.package }} + REASON: ${{ inputs.reason }} + RESERVE: ${{ inputs.reserve_name }} + OWNER: ${{ steps.remove.outputs.owner }} + VERSIONS: ${{ steps.remove.outputs.versions }} + PR_URL: ${{ steps.remove.outputs.pr }} + run: | + { + [ -n "$OWNER" ] && echo "@$OWNER" + echo + echo "Your app \`$PKG\` (versions: ${VERSIONS:-—}) has been taken down from the CardputerZero AppStore." + echo + echo "**Reason:**" + echo + printf '%s\n' "$REASON" | sed 's/^/> /' + echo + if [ "$RESERVE" = "true" ]; then + echo "The package name \`$PKG\` is reserved and cannot be uploaded again. Your account is not affected: your other apps stay published and you can keep submitting." + else + echo "Your account is not affected: your other apps stay published and you can keep submitting." + fi + echo + echo "If you believe this is a mistake, or have fixed the problem, reply in this issue and a maintainer will review it." + echo + echo "Change: $PR_URL" + echo + echo "---" + echo + echo "你的应用 \`$PKG\`(版本:${VERSIONS:-—})已从 CardputerZero AppStore 下架。" + echo + echo "**原因:**" + echo + printf '%s\n' "$REASON" | sed 's/^/> /' + echo + if [ "$RESERVE" = "true" ]; then + echo "包名 \`$PKG\` 已被保留,不能再次上传。你的账号不受影响:其他应用照常上架,也可以继续提交。" + else + echo "你的账号不受影响:其他应用照常上架,也可以继续提交。" + fi + echo + echo "如果你认为这是误判,或者已经修正了问题,请直接在这个 issue 里回复,维护者会重新审核。" + } > /tmp/issue.md + gh issue create --title "takedown: $PKG" --body-file /tmp/issue.md diff --git a/packages-workflows/validate-pr.yml b/packages-workflows/validate-pr.yml index 38e1c55..804bf2f 100644 --- a/packages-workflows/validate-pr.yml +++ b/packages-workflows/validate-pr.yml @@ -100,6 +100,16 @@ jobs: ERRORS="${ERRORS}- ❌ Raw \`.deb\` files must not be committed to git. Upload the binary to a Release in your fork and submit a \`*.deb.release.json\` manifest (use \`czdev publish\`).\n" fi + # A published manifest is never edited: the pool keeps serving the + # binary already promoted under its name, so a changed url/sha256 + # would describe a file nobody downloads. This is also how a version + # whose asset name collides with a published one (1.0~rc1 vs + # 1.0.rc1) shows up. + for m in $(git diff --name-only --diff-filter=MR "$BASE_SHA".."$HEAD_SHA" | grep '\.deb\.release\.json$' || true); do + ERRORS="${ERRORS}- ❌ \`$m\` is already published and cannot be changed; publish a new version instead (versions that differ only in '~' / '.' / ':' share one file name)\n" + done + + SUMMARIES="" ADDED_MANIFESTS=$(git diff --name-only --diff-filter=A "$BASE_SHA".."$HEAD_SHA" | grep '\.deb\.release\.json$' || true) for manifest in $ADDED_MANIFESTS; do @@ -135,6 +145,10 @@ jobs: if [[ "$dir_pkg" != "$ctrl_pkg" ]]; then ERRORS="${ERRORS}- ❌ Directory '$dir_pkg' != Package field '$ctrl_pkg'\n" fi + # A name that was taken down stays reserved (takedown.yml). + if git show "$BASE_SHA:policy/takedowns.json" 2>/dev/null | jq -e --arg p "$dir_pkg" 'has($p)' >/dev/null 2>&1; then + ERRORS="${ERRORS}- ❌ package name '$dir_pkg' was taken down and is reserved; contact a maintainer\n" + fi case "$(dpkg-deb -f "$deb" Architecture 2>/dev/null)" in arm64|all) ;; *) ERRORS="${ERRORS}- ❌ \`$fname\` architecture must be arm64/all, got '$(dpkg-deb -f "$deb" Architecture 2>/dev/null)'\n" ;; @@ -215,8 +229,8 @@ jobs: ERRORS="${ERRORS}- ❌ Invalid package name: '$ctrl_pkg'\n" fi expected_name="${ctrl_pkg}_${new_ver}_$(dpkg-deb -f "$deb" Architecture).deb" - # GitHub release assets sanitize '~' to '.'; allow either form. - if [[ "$fname" != "$expected_name" && "$fname" != "${expected_name//\~/.}" ]]; then + # GitHub release assets sanitize '~' and ':' to '.'; allow either form. + if [[ "$fname" != "$expected_name" && "$fname" != "${expected_name//[\~:]/.}" ]]; then ERRORS="${ERRORS}- ❌ filename '$fname' should be '$expected_name'\n" fi @@ -257,6 +271,13 @@ jobs: ERRORS="${ERRORS}- ❌ Version $new_ver is not newer than existing $existing_ver\n" fi + # The facts a reviewer wants (sizes, binaries), in the same table + # the portal channel puts in its PR body. Taken from the base branch + # like the policies; optional, so its absence never blocks a PR. + if git show "$BASE_SHA:.github/scripts/deb_summary.py" > /tmp/deb_summary.py 2>/dev/null; then + SUMMARIES="${SUMMARIES}### \`$fname\`\n\n$(python3 /tmp/deb_summary.py "$deb" "$existing_ver")\n| SHA-256 | \`$sha\` |\n\n" + fi + if [[ -z "$ERRORS" ]]; then echo "✓ $fname passed"; fi done @@ -315,6 +336,7 @@ jobs: [ -s /tmp/path-help-final.md ] && { echo; cat /tmp/path-help-final.md; } [ -s /tmp/meta-help-final.md ] && { echo; cat /tmp/meta-help-final.md; } [[ -n "$WARNINGS" ]] && { echo; echo "Suggestions (non-blocking) / 建议(不阻断):"; echo -e "$WARNINGS"; } + [[ -n "$SUMMARIES" ]] && { echo; echo "## Package details / 安装包信息"; echo; printf '%b' "$SUMMARIES"; } echo; echo "---" echo "*If you believe this is an error, contact a maintainer.*" echo; echo "*如果你认为这是误判,请联系维护者。*"; } > /tmp/out/result.md @@ -337,7 +359,8 @@ jobs: echo echo "自动校验全部通过,此版本更新将自动合并上架。" fi - [[ -n "$WARNINGS" ]] && { echo; echo "Suggestions (non-blocking) / 建议(不阻断):"; echo -e "$WARNINGS"; }; } > /tmp/out/result.md + [[ -n "$WARNINGS" ]] && { echo; echo "Suggestions (non-blocking) / 建议(不阻断):"; echo -e "$WARNINGS"; } + [[ -n "$SUMMARIES" ]] && { echo; echo "## Package details / 安装包信息"; echo; printf '%b' "$SUMMARIES"; }; } > /tmp/out/result.md echo "status=success" >> "$GITHUB_OUTPUT" fi cat /tmp/out/result.md diff --git a/site/app.js b/site/app.js index a5dee11..8d2c8dd 100644 --- a/site/app.js +++ b/site/app.js @@ -345,6 +345,7 @@ $("file").addEventListener("change", (e) => pick(e.target.files[0])); async function pick(f) { if (!f) return; if (!f.name.endsWith(".deb")) return say("err", t("upload.pickDeb")); + leaveListing(); if (maxSizeMb && f.size > maxSizeMb * 1048576) { return say("err", t("upload.tooLarge", { size: (f.size / 1048576).toFixed(1), max: maxSizeMb })); } @@ -768,6 +769,7 @@ function missingStoreFields() { } $("submit-btn").addEventListener("click", async () => { + if (listingPkg) return submitListing(); if (!file || !parsed) return; const missing = missingStoreFields(); @@ -802,16 +804,7 @@ $("submit-btn").addEventListener("click", async () => { // Privacy: when checked (default) the Worker records a noreply address; // when unchecked it publishes the developer's verified GitHub email. body.append("hide_email", $("hide-email").checked ? "true" : "false"); - body.append("title", $("s-title").value.trim()); - body.append("summary", $("s-summary").value.trim()); - body.append("description", $("s-desc").value.trim()); - body.append("categories", selectedCategories().join(",")); - body.append("display_name", $("s-author").value.trim()); - body.append("license", $("s-license").value.trim()); - body.append("share_code", $("s-code").value.trim().toUpperCase()); - body.append("permissions", JSON.stringify(selectedPermissions())); - if (storeIcon) body.append("icon", storeIcon, "icon.png"); - storeShots.forEach((sh, i) => body.append("screenshots", sh.blob, `shot${i}.png`)); + appendStoreFields(body); const r = await fetch("/api/submit/finalize", { method: "POST", @@ -835,6 +828,87 @@ $("submit-btn").addEventListener("click", async () => { } }); +/** The store form's fields and images, as both submit paths send them. */ +function appendStoreFields(body) { + body.append("title", $("s-title").value.trim()); + body.append("summary", $("s-summary").value.trim()); + body.append("description", $("s-desc").value.trim()); + body.append("categories", selectedCategories().join(",")); + body.append("display_name", $("s-author").value.trim()); + body.append("license", $("s-license").value.trim()); + body.append("share_code", $("s-code").value.trim().toUpperCase()); + body.append("permissions", JSON.stringify(selectedPermissions())); + if (storeIcon) body.append("icon", storeIcon, "icon.png"); + storeShots.forEach((sh, i) => body.append("screenshots", sh.blob, `shot${i}.png`)); +} + +/* --------------------------- store listing only -------------------------- */ + +// Set while the form edits a published package's listing without a .deb. +let listingPkg = null; + +/** + * Open the store form for a published package with nothing to upload: the + * developer only wants to change its name, description, screenshots or icon, + * and the version and binary stay as published. + */ +async function editListing(pkg) { + file = null; + parsed = null; + listingPkg = pkg; + resetStoreForm(); + say("", ""); + $("drop-text").innerHTML = t("upload.dropHtml"); + $("deb-only").classList.add("hidden"); + $("hide-email-row").classList.add("hidden"); + $("listing-title").textContent = t("listing.title", { pkg }); + $("listing-banner").classList.remove("hidden"); + $("submit-btn").disabled = false; + $("submit-btn").textContent = t("listing.submit"); + $("preview-box").classList.remove("hidden"); + switchTab("upload"); + await prefillStore(pkg).catch(() => { /* registry unreachable: start blank */ }); +} + +/** Back to the ordinary .deb upload form. */ +function leaveListing() { + if (!listingPkg) return; + listingPkg = null; + $("deb-only").classList.remove("hidden"); + $("hide-email-row").classList.remove("hidden"); + $("listing-banner").classList.add("hidden"); + $("preview-box").classList.add("hidden"); + $("submit-btn").textContent = t("preview.submit"); + resetStoreForm(); +} + +$("listing-cancel").addEventListener("click", () => { leaveListing(); say("", ""); }); + +async function submitListing() { + const missing = missingStoreFields(); + if (missing.length) { + return say("err", t("form.missingRequired", { fields: missing.join("、") })); + } + $("submit-btn").disabled = true; + try { + say("", t("listing.submitting")); + const body = new FormData(); + body.append("package", listingPkg); + appendStoreFields(body); + const r = await fetch("/api/listing", { method: "POST", body }); + const data = await r.json().catch(() => ({})); + if (!r.ok) throw new Error(data.detail || data.error || `HTTP ${r.status}`); + say("ok", t("upload.submitOk", { + message: data.message, + actions: data.actions_url, + track: data.track_url, + })); + } catch (err) { + say("err", t("upload.submitFailed", { msg: err.message })); + $("submit-btn").disabled = false; + } +} + async function postJson(path, obj) { const r = await fetch(path, { method: "POST", @@ -931,7 +1005,9 @@ async function renderMine() { actions.appendChild(a); rows.appendChild(tr); } - for (const p of mine.sort((a, b) => a.name.localeCompare(b.name) || compareDebVersions(a.Version, b.Version))) { + mine.sort((a, b) => a.name.localeCompare(b.name) || compareDebVersions(a.Version, b.Version)); + mine.forEach((p, i) => { p.latest = i === mine.length - 1 || mine[i + 1].name !== p.name; }); + for (const p of mine) { const tr = document.createElement("tr"); tr.innerHTML = `${p.name}${p.Version}${p.owner ? "@" + p.owner : p.email}`; const actions = tr.lastElementChild; @@ -947,6 +1023,13 @@ async function renderMine() { a.title = `${p.name} ${p.Version}(${p.Size ? (p.Size / 1048576).toFixed(1) + " MB" : t("mine.sizeUnknown")})`; actions.appendChild(a); } + // The listing belongs to the package, not a version: offer it once. + if (p.latest) { + const edit = document.createElement("button"); + edit.textContent = t("mine.editListing"); + edit.addEventListener("click", () => editListing(p.name)); + actions.appendChild(edit); + } const btn = document.createElement("button"); btn.textContent = t("mine.unpublish"); btn.addEventListener("click", () => unpublish(p, btn)); diff --git a/site/debparse.js b/site/debparse.js index 7709ae9..1863c92 100644 --- a/site/debparse.js +++ b/site/debparse.js @@ -207,23 +207,53 @@ const PKG_DIR_ROOTS = ["usr/share/", "usr/lib/", "opt/", "var/lib/"]; const ALIASED_ROOTS = ["bin/", "sbin/", "lib/", "lib32/", "lib64/", "libx32/"]; const SYSTEMD_DIRS = ["lib/systemd/system/", "usr/lib/systemd/system/"]; const UNIT_SUFFIX = /\.(service|socket|timer|target|path|mount)$/; +// Shared Debian directories whose *files* carry the package name, rather than +// a subdirectory: /etc/default/, NN-.rules, .1.gz and so on. +// Each entry is [directory pattern, what to strip from the file name]. +const FILE_NAMED_DIRS = [ + [/^etc\/default\//, null], + [/^etc\/logrotate\.d\//, null], + [/^usr\/share\/lintian\/overrides\//, null], + [/^usr\/share\/bash-completion\/completions\//, null], + [/^usr\/lib\/tmpfiles\.d\//, /\.conf$/], + [/^usr\/lib\/udev\/rules\.d\//, /^\d+-|\.rules$/g], + [/^usr\/share\/man\/(?:[^/]+\/)?man[1-9][^/]*\//, /\.[1-9][^.]*(?:\.gz)?$/], +]; +const UDEV_RULES_DIR = "usr/lib/udev/rules.d/"; +// udev runs these keys' commands as root, which apps must not do. +const UDEV_EXEC_KEY = /(?:^|[\s,])(?:RUN|PROGRAM|IMPORT\{program\})(?!\w)/; +const UDEV_EXEC_REASON = "udev rules must not use RUN, PROGRAM or IMPORT{program} (they run commands as root) / " + + "udev 规则不能使用 RUN、PROGRAM、IMPORT{program}(这些会以 root 执行命令)"; const ALLOWED_SUMMARY = "允许 usr/bin/<与包名相关的可执行文件>、" + "usr/share|usr/lib|opt|var/lib 下与包名相关的子目录、etc/ 下与包名相关的配置、" + + "etc/default、man、lintian、bash-completion、tmpfiles.d、udev、logrotate.d 下以包名命名的文件、" + "usr/share/APPLaunch/、usr/share/doc/、systemd 服务目录"; const normalizeName = (s) => s.toLowerCase().replace(/[-_.]/g, ""); /** - * Is `name` recognizably the package's own? Compared with separators ignored - * and in both directions, so package `fontpreview` accepts a `font_preview` - * directory and package `myapp` accepts a `myapp-cli` binary, while neither - * accepts `ls`. + * Is `name` recognizably the package's own? Compared with separators ignored, + * and the name must start with the package name, so package `fontpreview` + * accepts a `font_preview` directory and a `fontpreview-cli` binary but not + * `ls`. Only this direction counts: package `sudoers-tool` must not claim + * `sudoers.d`. */ function namedAfterPackage(name, pkgName) { const a = normalizeName(name.replace(UNIT_SUFFIX, "")); const b = normalizeName(pkgName || ""); - return Boolean(a && b) && (a.startsWith(b) || b.startsWith(a)); + return Boolean(a && b) && a.startsWith(b); +} + +/** + * Does this udev rule make udev execute something? RUN, PROGRAM and + * IMPORT{program} all run as root, outside any service's User=. + */ +export function udevRuleRunsPrograms(text) { + return text.split("\n").some((raw) => { + const line = raw.trim(); + return line !== "" && !line.startsWith("#") && UDEV_EXEC_KEY.test(line); + }); } /** @@ -256,6 +286,15 @@ export function installPathIssue(path, pkgName) { return "Debian Policy 10.1 forbids /bin /sbin /lib etc. (usr-merge alias paths); use the /usr equivalent / Debian Policy 10.1 禁止软件包写入 /bin /sbin /lib 等 usr-merge 别名路径,请改用 /usr 下的对应位置"; } if (path.startsWith("usr/share/APPLaunch/") || path.startsWith("usr/share/doc/")) return null; + for (const [pattern, strip] of FILE_NAMED_DIRS) { + const m = path.match(pattern); + if (!m) continue; + const dir = m[0], name = path.slice(dir.length); + if (name.includes("/")) return `no subdirectories under ${dir} / ${dir} 下不应再有子目录`; + return namedAfterPackage(strip ? name.replace(strip, "") : name, pkgName) + ? null + : `the file name under ${dir} must relate to the package name ${pkgName} / ${dir} 下的文件名必须与包名 ${pkgName} 相关`; + } // /etc/.conf is as much a Debian convention as /etc// (compare // rsyslog.conf, logrotate.conf), so a lone conffile is fine here. if (path.startsWith("etc/")) { @@ -297,7 +336,7 @@ const INSTALL_PATH_PROMPT = `我在给 CardputerZero AppStore 打一个 Debian 请帮我修改打包脚本(CMakeLists.txt / debian/rules / install 脚本 / Makefile,看项目里用的是哪种), 把文件装到合规位置,并同步更新 .desktop 里的 Exec 和 Icon、以及代码里读取资源文件的路径。 -商店的规则(包名记作 PKG,比对时忽略 - _ . 的差异,所以 PKG 是 fontpreview 时目录叫 font_preview 也算合规): +商店的规则(包名记作 PKG。“与包名相关”指名字以 PKG 开头,比对时忽略 - _ . 的差异,所以 PKG 是 fontpreview 时目录叫 font_preview、fontpreview-data 都算合规,叫 preview 不算): 允许安装到: - usr/bin/<名字>:可执行文件,名字必须与包名相关(这是 Debian Policy 10.1 的标准位置) @@ -306,6 +345,9 @@ const INSTALL_PATH_PROMPT = `我在给 CardputerZero AppStore 打一个 Debian - opt/<与包名相关的目录>/**:整体打包自带的目录 - etc/<与包名相关的目录>/** 或 etc/<与包名相关>.conf:配置文件(Debian conffile 惯例) - var/lib/<与包名相关的目录>/**:运行时数据 +- etc/default/、etc/logrotate.d/、usr/share/lintian/overrides/、usr/share/bash-completion/completions/、 + usr/lib/tmpfiles.d/.conf、usr/share/man/manN/.N.gz:Debian 标准的共享目录,目录名照旧,文件名以包名开头 +- usr/lib/udev/rules.d/NN-.rules:udev 规则,不能使用 RUN、PROGRAM 和 program 类型的 IMPORT(这些会以 root 执行命令) - usr/share/APPLaunch/**:CardputerZero 启动器资源,且必须有 usr/share/APPLaunch/applications/<名字>.desktop - usr/share/doc/**:文档 - lib/systemd/system/<与包名相关>.service 或 usr/lib/systemd/system/<同样>:服务名必须与包名相关, @@ -413,7 +455,11 @@ function analyzeFiles(dataFiles, report, pkgName) { // is already doomed. Skipped without a package name, since nothing can then // be judged as the package's own and the missing field is reported already. if (f.type !== "dir" && pkgName) { - const issue = installPathIssue(f.path, pkgName); + let issue = installPathIssue(f.path, pkgName); + if (!issue && f.type === "file" && f.path.startsWith(UDEV_RULES_DIR) + && udevRuleRunsPrograms(new TextDecoder().decode(f.data))) { + issue = UDEV_EXEC_REASON; + } if (issue) { report.push(["danger", `安装路径不合规: ${f.path} —— ${issue}`]); badPaths.push(f.path); diff --git a/site/i18n/en.json b/site/i18n/en.json index 0307ab5..324d2ce 100644 --- a/site/i18n/en.json +++ b/site/i18n/en.json @@ -59,7 +59,7 @@ }, "pathHelp": { "title": "Install paths rejected — how to fix them", - "rule": "Debian's standard locations are all allowed; the one requirement is that whatever lands in a shared directory carries your package's name, so an app cannot quietly take over a system command or another package's files. Separators are ignored when matching, so package fontpreview may use a font_preview directory.", + "rule": "Debian's standard locations are all allowed; the one requirement is that whatever lands in a shared directory starts with your package's name, so an app cannot quietly take over a system command or another package's files. Separators are ignored when matching, so package fontpreview may use a font_preview directory.", "hint": "Paste the whole block below into an assistant (Cursor / Claude / ChatGPT). It carries the full policy plus the paths that were rejected, so it can fix your packaging scripts directly:", "copy": "Copy prompt", "show": "Show the full prompt", @@ -122,9 +122,17 @@ "unpublishFailed": "Unpublish failed: {msg}", "statusReview": "Under review", "statusPublishing": "Merged, index rebuilding…", - "viewPr": "View PR" + "viewPr": "View PR", + "editListing": "Edit store listing" }, "decompress": { "loadFailed": "Could not load the {what} decompression module (used only for local preview). Please refresh and retry; you can still submit — the server completes validation." + }, + "listing": { + "title": "Edit the store listing of {pkg}", + "note": "Updates only the name, description, categories, screenshots and icon shown in the store. No .deb is needed and the published version stays as it is. Merged automatically once the checks pass.", + "cancel": "Cancel", + "submit": "Submit listing update", + "submitting": "Submitting the listing update…" } } diff --git a/site/i18n/ja.json b/site/i18n/ja.json index 66f375f..3ba6cde 100644 --- a/site/i18n/ja.json +++ b/site/i18n/ja.json @@ -59,7 +59,7 @@ }, "pathHelp": { "title": "インストール先が規約に合いません。修正方法", - "rule": "Debian 標準の場所はすべて許可しています。条件は、共有ディレクトリに置くファイル名・ディレクトリ名にパッケージ名を含めることだけです。これによりアプリがシステムコマンドや他パッケージのファイルを黙って上書きすることを防ぎます。照合時に - _ . の違いは無視するため、パッケージ名 fontpreview で font_preview ディレクトリを使っても構いません。", + "rule": "Debian 標準の場所はすべて許可しています。条件は、共有ディレクトリに置くファイル名・ディレクトリ名にパッケージ名で始めることだけです。これによりアプリがシステムコマンドや他パッケージのファイルを黙って上書きすることを防ぎます。照合時に - _ . の違いは無視するため、パッケージ名 fontpreview で font_preview ディレクトリを使っても構いません。", "hint": "以下のブロックをそのまま AI(Cursor / Claude / ChatGPT)に貼り付けてください。規約全文と今回拒否されたパスを含んでいるため、パッケージングスクリプトをそのまま修正できます:", "copy": "プロンプトをコピー", "show": "プロンプト全文を表示", @@ -122,9 +122,17 @@ "unpublishFailed": "公開停止に失敗しました:{msg}", "statusReview": "審査中", "statusPublishing": "マージ済み、インデックス更新中…", - "viewPr": "PR を見る" + "viewPr": "PR を見る", + "editListing": "ストア情報を編集" }, "decompress": { "loadFailed": "{what} の解凍モジュールを読み込めませんでした(ローカルプレビュー用)。更新して再試行してください。そのまま送信もでき、サーバー側で検証します。" + }, + "listing": { + "title": "{pkg} のストア情報を編集", + "note": "ストアに表示される名前・説明・カテゴリ・スクリーンショット・アイコンだけを更新します。.deb のアップロードは不要で、公開中のバージョンはそのままです。チェックに通ると自動でマージされます。", + "cancel": "キャンセル", + "submit": "ストア情報の更新を送信", + "submitting": "ストア情報を送信中…" } } diff --git a/site/i18n/zh-CN.json b/site/i18n/zh-CN.json index 94eb162..a67985e 100644 --- a/site/i18n/zh-CN.json +++ b/site/i18n/zh-CN.json @@ -59,7 +59,7 @@ }, "pathHelp": { "title": "安装路径不合规,怎么改?", - "rule": "我们放行 Debian 的标准位置,只要求落在共享目录里的文件名/目录名带上你的包名,这样一个应用不会悄悄覆盖系统命令或别人的文件。比对时忽略 - _ . 的差异,所以包名 fontpreview 用 font_preview 目录是可以的。", + "rule": "我们放行 Debian 的标准位置,只要求落在共享目录里的文件名/目录名以你的包名开头,这样一个应用不会悄悄覆盖系统命令或别人的文件。比对时忽略 - _ . 的差异,所以包名 fontpreview 用 font_preview 目录是可以的。", "hint": "把下面整段复制给 AI(Cursor / Claude / ChatGPT),它带着完整规则和你这次被拒的路径,可以直接帮你改打包脚本:", "copy": "复制提示词", "show": "查看提示词全文", @@ -122,9 +122,17 @@ "unpublishFailed": "下架失败:{msg}", "statusReview": "审核中", "statusPublishing": "已合并,索引更新中…", - "viewPr": "查看 PR" + "viewPr": "查看 PR", + "editListing": "editListing" }, "decompress": { "loadFailed": "无法加载 {what} 解压组件(仅用于本地预览),请刷新重试;仍可直接提交,服务器会完成校验" + }, + "listing": { + "title": "编辑 {pkg} 的商店信息", + "note": "只更新商店里的名称、介绍、分类、截图和图标,不需要上传 .deb,已发布的版本保持不变。通过校验后会自动合并。", + "cancel": "取消", + "submit": "提交商店信息更新", + "submitting": "正在提交商店信息…" } } diff --git a/site/index.html b/site/index.html index 4ec3322..f5fa5bf 100644 --- a/site/index.html +++ b/site/index.html @@ -177,6 +177,15 @@

CardputerZero 开发者中心