Latest Learning

Stuff I learned recently.

想忽略某个缓存目录,又怕误伤同名前缀的文件(例如 .terraform/ 与必须入库的 .terraform.lock.hcl)。

pattern 以 / 结尾时只匹配目录(及其下路径),不匹配同名的普通文件或符号链接:

.terraform/

不带斜杠的 .terraform 会连「名叫 .terraform 的普通文件」一起忽略。两种写法都不会误伤 .terraform.lock.hcl —— pattern 按整段路径组件匹配,不做前缀匹配。

用 git check-ignore -v <path> 确认到底哪条规则命中(它打出规则文件、行号与 pattern)。

ULID 用的是 Crockford Base32 的 32 字符表:10 个数字加 22 个字母,I、L、O、U 不在里面。所以校验它不能写 [0-9A-Z]{26}:

[0-9A-HJKMNP-TV-Z]{26}      # 宽松形态
[0-7][0-9A-HJKMNP-TV-Z]{25} # 严格形态,首字符只用 3 bit
import re
ULID = re.compile(r"^[0-7][0-9A-HJKMNP-TV-Z]{25}$")

那个看着像乱码的字符类展开正好 32 个字符:

0123456789ABCDEFGHJKMNPQRSTVWXYZ

I/L/O 排除是为了不和 1/0 混淆,U 是 Crockford 为避免拼出脏话额外排除的。注意 P-T 是范围不是单个字符——[0-9A-HJKMNP-TV-Z] 读作 0-9、A-H、字面量 J K M N、范围 P-T(P、Q、R、S、T)、范围 V-Z,跳过 U。

严格形态首字符限 0-7:ULID 是 48 位毫秒时间戳加 80 位随机数,编成 26 个字符后时间戳占前 10 个(50 bit 里只用 48 bit),所以第一个字符只承载 3 bit。

WSL2 默认是 NAT 模式,127.0.0.1 在 WSL 里指 WSL 自己,所以 Windows 上 Clash 监听的端口在 WSL 里连不到——代理地址要写 Windows 宿主 IP(default route 的 gateway):

HOST_IP=$(ip route show default | awk '{print $3; exit}')
export http_proxy=http://$HOST_IP:7897 https_proxy=http://$HOST_IP:7897 all_proxy=socks5://$HOST_IP:7897
export no_proxy=localhost,127.0.0.1,::1,.local,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16

Clash 侧两个前置:局域网连接要开(否则只监听 127.0.0.1,WSL 连不到)、端口用 mixed 端口(同一个口同时吃 HTTP 和 SOCKS5,所以 all_proxy 可以指它)。

换端口或换客户端时先探端点:

timeout 1 bash -c "exec 3<>/dev/tcp/$HOST_IP/7897" && echo ok

别用 zsh 内建的 ztcp 做这个判断,它对已关闭的端口同样返回 0。

开了 DNS 覆写就别关 TUN

DNS 覆写开着时,WSL 的 DNS 查询也走 Windows 宿主,域名被解析成 fake-ip(198.18.0.0/16),这些地址只有 Windows 侧的 TUN 能路由回去。所以 TUN 是 WSL 网络的地基:关掉它,WSL 里所有不走 proxy 环境变量的直连都会因为 198.18.x.x 不可路由而挂。

同理 198.18.0.0/16 不要加进 no_proxy——它是 fake-ip 不是真实地址,加了只会让分流失效。

三国里,托孤是刘备把阿斗交给诸葛亮;Linux 里,托孤是内核把孤儿进程交给 PID 1。 区别在于:诸葛亮会管,很多容器里的 PID 1 不会。

什么是「托孤」

Linux 里每个进程都有父进程。父进程先退出,子进程就成了孤儿进程;内核不会让孤儿无家可归,会把它过继给当前 PID namespace 里的 PID 1。

普通系统里 PID 1 是 init / systemd,它的职责之一就是不断调用 wait(),回收退出的子进程。这就是「托孤」:父进程走了,孩子交给 PID 1。

为什么容器里会出问题

容器里 PID 1 往往不是 init,而是应用本身(litestream、node、nginx、python…)。这些应用只 wait() 自己 fork 出来的子进程,托孤来的不认账,于是每个孤儿退出后都留在进程表里——ps 里显示为 <defunct>。

容器里这类孤儿的常见来源是 healthcheck:每 30s 一次,跑得越久堆得越多。docker exec(healthcheck 走同一条路)创建的进程,其父进程在容器 PID namespace 之外、对容器不可见,因此被托孤给 namespace 里的 PID 1。

僵尸不占 CPU、也不占内存(只剩进程表里一个槽位),但会一直占用 PID 表项;攒够了,容器连新进程都创建不了,排查别的问题时也是噪音。

解法:给容器一个真正的 PID 1

把 tini 放到 PID 1——它只做两件事:reap 托孤来的进程、把信号转发给真正的应用。

RUN apt-get update \
    && apt-get install -y --no-install-recommends tini \
    && rm -rf /var/lib/apt/lists/*

# 应用变成 tini 的子进程,托孤来的孤儿由 tini 回收
ENTRYPOINT ["/usr/bin/tini", "--", "/entrypoint.sh"]

Alpine 用 apk add --no-cache tini,路径是 /sbin/tini。

不改镜像时,docker run --init(或 compose init: true)等效,但镜像内建更省事——调用方不会忘。Kubernetes 没有等价的开关,做法同上:把 tini 作为镜像的 ENTRYPOINT,让容器里的 PID 1 真正承担「家长」的责任。应用若自己实现了 SIGCHLD 处理与 wait() 循环,也可以不用 tini——但大多数业务应用并不想管这些事。

数僵尸

容器里常常没装 ps/procps,直接读 /proc:

for p in /proc/[0-9]*; do
  [ "$(awk '{print $3}' "$p/stat" 2>/dev/null)" = Z ] && \
    awk '{print "pid="$1" ppid="$4" comm="$2}' "$p/stat"
done

ppid 就是那个没 reap 的 PID 1。

同类工具:tini 之外的选择

tini 只做「reap + 转发信号」,单进程容器够了。但容器里一旦要跑多个进程、要按顺序初始化、要进程挂了自动重启,就需要一套完整的 init 了。

  • tini / docker --init:只 reap + 转发信号,单进程容器(最常见)
  • dumb-init:同一类,C 写的更小,能当 entrypoint 处理 shell 形式的 CMD
  • catatonit:同一类,Podman 默认
  • s6-overlay:完整 init + 进程监督(oneshot 初始化、依赖顺序、自动重启、优雅停机)

以 s6-overlay v3 为例(ARG S6_OVERLAY_VERSION=3.2.3.2),装法就是解两个 tar 包,然后 ENTRYPOINT ["/init"]:

RUN apt-get update && apt-get install -y nginx xz-utils
ARG S6_OVERLAY_VERSION=3.2.3.2
ADD https://github.com/just-containers/s6-overlay/releases/download/v${S6_OVERLAY_VERSION}/s6-overlay-noarch.tar.xz /tmp
RUN tar -C / -Jxpf /tmp/s6-overlay-noarch.tar.xz
ADD https://github.com/just-containers/s6-overlay/releases/download/v${S6_OVERLAY_VERSION}/s6-overlay-x86_64.tar.xz /tmp
RUN tar -C / -Jxpf /tmp/s6-overlay-x86_64.tar.xz
ENTRYPOINT ["/init"]

(x86_64 换成目标架构的包名。)启动后 s6-svscan 当 PID 1,分三阶段:阶段 1 跑 /etc/cont-init.d/*(一次性初始化)→ 阶段 2 起 s6-rc 服务 → 阶段 3 监督 /etc/services.d/*。所以老的 cont-init.d / services.d 写法在 v3 里照旧可用,老镜像迁移不用改。

v3 新写法是声明式服务:

/etc/s6-overlay/s6-rc.d/myapp/type        → longrun(常驻,受监督)或 oneshot(跑一次)
/etc/s6-overlay/s6-rc.d/myapp/run         → 启动脚本,结尾 exec 你的进程
/etc/s6-overlay/s6-rc.d/myapp/dependencies.d/base      → 空文件 = 声明依赖
/etc/s6-overlay/user-bundles.d/user/contents.d/myapp   → 空文件 = 加入默认启动集

它本来就是给容器当 PID 1 用的,所以「托孤」这件事它天然接着:孤儿由 s6 回收、信号由 s6 转发、服务挂了 s6-supervise 自动拉起。选型上:单进程用 tini(--init 一行搞定),多进程 / 要顺序初始化 / 要自动重启再上 s6-overlay。

总结

「托孤」听起来很人文,在 Linux 里却是一次很具体的责任转移:内核把孤儿进程交给 PID 1。

PID 1 不只是一个编号,它意味着收养孤儿、回收僵尸、转发信号。容器里让普通应用当 PID 1,却不给它配一个 tini 这样的「诸葛亮」,结果往往就是一群没人收尸的僵尸,慢慢把容器拖死。

所以,别让 PID 1 只挂名,不干活。

在外工作时走代理回家里的内网,在家时自动切回直连——也就是策略组要跟着所在网络自动切。

[SSID Setting] 做不到(它只管 suspend / cellular-mode),得用 Subnet Group:

[Proxy Group]
PROXY-HOME = subnet, default = PROXY-HOME-SMART, SSID:LearningCenter = DIRECT
# 中间层必需:smart 不能直接放进 subnet
PROXY-HOME-SMART = smart, DEVICE:XIAOKEN, 🇨🇳http-home-mac

DEVICE:XIAOKEN 是 Ponte 的动态策略,指向家里那台 Mac(xiaoken),不用在 [Proxy] 里声明;🇨🇳http-home-mac 是普通代理节点。

条件按声明顺序求值,首个匹配生效,网络变化时自动重新求值——这就是「在家直连、出门走代理」。条件支持 SSID: / BSSID: / ROUTER: / TYPE:WIFI|WIRED|CELLULAR,其中 SSID: 大小写敏感。

app 自带 agent skill

Surge.app 里带了一份完整的 agent skill,只是不在 agent 的搜索路径里,软链过去即可(app 升级自动跟随):

find /Applications/<App>.app -iname "SKILL.md" 2>/dev/null
ln -s /Applications/Surge.app/Contents/Resources/Skills/surge ~/.agents/skills/surge
SC=/Applications/Surge.app/Contents/Applications/surge-cli
$SC rule match github.com      # 这个域名会走哪条规则
$SC http probe <url> "<policy>"                        # 端到端验证策略
$SC rule temp add "DOMAIN-SUFFIX,example.com,Proxy"    # 调试用临时规则,别改 profile

自建 Miniflux 订阅 jdon.com,开「抓取原始内容」后全文永远是「点击验证你是真人」,点开原文也弹验证。根因是缺少 Referer 触发人机验证,恰好命中 Miniflux 的隐私设计。解法是加一层反代补上 Referer。

jdon.com 套着腾讯云 EdgeOne,判据就是 Referer:不带 Referer 的请求一律回验证页(HTTP 200,body 是验证页,跳 /captcha/refer.shtml,路径里的 refer 就是提示)。用 curl 做单变量矩阵可确认——UA 换成什么都是验证页,只要带任意 Referer 就正常。

而 Miniflux 发不出 Referer:

  • 出于隐私设计,外链强制 referrerpolicy="no-referrer",抓原文也不发
  • 不支持按 feed 注入请求头(只有 UA / Cookie / 代理)
  • HTTPS forward proxy 是隧道,加不了 TLS 内的请求头

所以 Miniflux 侧没有可改的地方,只能在链路上加一层反代:

server {
    listen 8080;
    resolver 127.0.0.11 valid=600s ipv6=off;  # Docker 内嵌 DNS
    set $upstream https://www.jdon.com;

    # 只对 /rss 改写链接,让「抓原文」和「点开原文」都汇聚到本代理
    location = /rss {
        proxy_pass $upstream;
        proxy_set_header Host www.jdon.com;
        proxy_ssl_server_name on;
        proxy_set_header Referer https://www.jdon.com/;   # 核心
        proxy_set_header Accept-Encoding "";              # gzip 内容 sub_filter 改不了
        sub_filter 'https://www.jdon.com' 'https://rss-proxy.example.com';
        sub_filter_types application/rss+xml application/xml text/xml;
        sub_filter_once off;
    }

    # 其余路径纯透传(只补 Referer),HTML 不改写
    location / {
        proxy_pass $upstream;
        proxy_set_header Host www.jdon.com;
        proxy_ssl_server_name on;
        proxy_set_header Referer https://www.jdon.com/;
    }
}

Miniflux 把 feed URL 改指代理即可。

用 tar 的 -(stdin/stdout)配合 | 管道,把目录内容从一端流式送到另一端,全程不落临时文件。比逐文件拷贝更快、更稳,还保留目录结构与权限。

基本形态

# 左侧:tar 打包写到 stdout;右侧:从 stdin 解包到目标目录
tar cf - -C /src dir \
  | tar xf - -C /dst
  • c 创建归档、f - 输出到 -(stdout);x 解包、f - 从 -(stdin)读取归档
  • -C dir 先切换目录再操作,避免把绝对/相对路径的前缀带进归档

跨主机/跨进程传送

管道两侧不必是同一进程,常见于 kubectl exec、ssh、curl 等场景:

# 从远端 Pod 拉目录到本地,剥离前两级路径
kubectl exec -n platform "$POD" -- tar cf - /app/config \
  | tar xf - --strip-components=2 -C /home/laborant/config

# 等价写法:左侧把 /app 设为根,归档内只剩 config/ 一级前缀,右侧只需 strip 1
kubectl exec -n platform "$POD" -- tar cf - -C /app config \
  | tar xf - --strip-components=1 -C /home/laborant/config
  • --strip-components=N:去掉每个成员路径最前面的 N 个分量(本例去掉 app/config 两级)
  • exec 不加 -t(TTY),避免换行转换污染二进制流
  • -C 目标目录须先存在,否则报 Cannot open,先 mkdir -p

变体

  • 压缩(网络慢):左侧 tar czf -,右侧 tar xzf -(gzip)
  • 本地备份:tar cf - dir | tar xf - 等价 cp -a,但保留更完整属性

注意

  • 两端都需安装 tar(busybox/distroless 容器可能没有)
  • --strip-components 由 GNU tar / bsdtar 提供,macOS / Linux 均有
  • 非 root 解包时文件 owner 变成当前用户(root 下才默认保留)

当 Kubernetes 将 ConfigMap 挂载为 volume 时,pod 内的文件其实是 symlink,直接 kubectl cp 会跳过所有文件导致拷不出内容。用 kubectl exec + tar 管道即可复制真实文件。

问题

# 这样拷贝会失败:目录内全是 symlink,无 .conf 文件落地
kubectl cp platform/${POD}:/app/config/ /home/laborant/config/

warning: skipping symlink: "/home/laborant/config/server.conf" -> "..data/server.conf"
# ...
# kubectl cp 退出码为 0 却什么都没拷出来

原因:kubectl cp 内部用 tar 流式传输,但没传 --dereference。挂载路径下的条目全是 symlink,tar 视其为符号链接而跳过。kubectl cp 即使失败也返回 0,必须 ls 验证结果。

解决方案

在 pod 内部 tar 打包(内核 VFS 透明解析 symlink),再管道给本地 tar 解包:

# 左侧在 pod 内打包并写 stdout,右侧本地解包并剥离前两级路径
kubectl exec -n platform "$POD" -- tar cf - /app/config \
  | tar xf - --strip-components=2 -C /home/laborant/config
  • --strip-components=2:去掉 app/config 两级前缀,只留裸文件名
  • tar xf -:从 stdin 读取归档,-C 指定解包目录
ls /home/laborant/config/
# database.conf  feature-flags.conf  logging.conf  server.conf

通用注意点与变体

  • exec 不要加 -t(TTY):TTY 会对输出做换行转换,污染二进制归档流;无 TTY 的 exec 才是干净的 stdin/stdout 回传
  • -C 目标目录必须先存在:tar 不会自动创建目录,会报 Cannot open 之类错误,先 mkdir -p /home/laborant/config
  • 等价写法:左侧 tar cf - -C /app config 时归档成员只有 config/... 一级前缀,右侧只需 --strip-components=1;与”绝对路径 + strip 2”语义等价,任选一种保持一致
  • 压缩变体:网络较慢时左侧 tar czf -(gzip 压缩),右侧 tar xzf - 解压
  • 依赖:Pod 内需装有 tar(busybox/distroless 镜像可能没有,报 exec: tar: not found);本地的 --strip-components 由 GNU tar / bsdtar 提供,macOS / Linux 均有
  • 权限:非 root 解包时文件 owner 会变成当前用户(tar 仅在 root 下默认保留 owner),通常无碍

获取运行中的 pod 名

POD=$(kubectl get pod -n platform \
  -l app=config-service \
  -o jsonpath='{.items[0].metadata.name}')
原子更新机制:两层级联导致 kubectl cp 失效

ConfigMap 以 volume 挂载时,目录结构是两层间接:

/app/config/
├── ..2026_08_19_10_19_29.2041564214/   ← 真实目录,存真实字节
├── ..data -> ..2026_08_19_10_19_29.2041564214   ← 指向真实目录
└── server.conf -> ..data/server.conf    ← 每个文件都是 symlink
  • 权限串首字符 l 表示 symlink(lrwxrwxrwx)
  • kubelet 更新 ConfigMap 时写入新时间戳目录,再通过一次原子 rename() 重指 ..data,保证运行中的 pod 立即看到新内容且不出现新旧混合
  • tar 默认不跟随 symlink,kubectl cp 因此全部跳过

kubectl create ingress 的 --rule 语法里,tls 是挂在某条 rule 上的,它的 host 会直接复用该 rule 的 host,命令行层面没有单独指定 tls hosts 的选项。

kubectl create ingress snake-ingress -o snake \
  --rule="a.com/=snake:80,tls=snake" \
  --dry-run=client -o yaml

生成结果一定是:

spec:
  rules:
  - host: a.com
    http:
      paths:
      - path: /
        pathType: Exact
        backend:
          service:
            name: snake
            port:
              number: 80
  tls:
  - hosts:
    - a.com
    secretName: snake

rules[].host 和 tls[].hosts 必然一致,无法让两者不同。

可行的两种办法

  1. 手动改生成的 YAML(最直接):用 --dry-run=client -o yaml 生成后直接编辑 spec.tls[0].hosts,改成你想要的域名(如通配符证书 *.a.com 或额外域名),与 rules[].host 脱钩即可。

  2. 用多条 --rule 聚合多个 host:

    kubectl create ingress snake-ingress -o snake \
      --rule="a.com/=snake:80,tls=snake" \
      --rule="b.com/=snake:80,tls=snake" \
      --dry-run=client -o yaml

    这样 tls[0].hosts 会变成 [a.com, b.com],但本质上只是各 rule host 的并集——并不能做到”某条 rule 的 host 不出现在 tls.hosts 里”这种脱钩效果。

如果你的目标是”http 规则匹配 a.com,但证书 SAN 是别的域名(如通配符证书)“,最干净的做法就是方案 1:生成模板后手工改 tls.hosts 再 apply。

pi 的 fetch_content / web_search 报 Blocked internal address ... 198.18.0.0/15:TUN/fake-IP 代理(Surge、Clash、Mihomo)把公网域名解析到保留段,被 SSRF 防护误拦。

在 ~/.pi/web-search.json 加 ssrf.allowRanges 豁免该网段:

{
  "ssrf": { "allowRanges": ["198.18.0.0/15"] }
}

注意路径是 ~/.pi/web-search.json,不是 ~/.pi/agent/。查找顺序:$PI_CODING_AGENT_DIR → $XDG_CONFIG_HOME/pi → ~/.pi。

该文件同时是 credential store(放 provider、API key),追加字段时保留现有内容。

免费版私有仓库不支持 GitHub 原生 auto-merge(API 对 allow_auto_merge=true 返回 200 但静默不生效,和 branch protection 一样提示 “Upgrade to GitHub Pro”)。此时 Renovate 托管版 App 默认的 platformAutomerge: true 会失效:PR 上写着 “Automerge: Enabled” 却永远不会合并。

解法:让 Renovate 自己合并 PR,并去掉周期限制(依赖 group:allNonMajor 已把所有 minor/patch 合并成一个 PR,提高频率不会增加噪音):

{
  "$schema": "https://docs.renovatebot.com/renovate-schema.json",
  "extends": [
    "config:recommended",
    "group:allNonMajor"
  ],
  "rangeStrategy": "bump",
  "dependencyDashboard": false,
  "labels": ["dependencies", "renovate"],
  "automerge": true,
  // 免费版私有仓库用不了 GitHub 原生 auto-merge,由 Renovate 自己合并
  "platformAutomerge": false,
  "packageRules": [
    {
      matchDepTypes: ["peerDependencies"],
      enabled: false,
    },
  ],
  "ignoreDeps": ["node"],
}

排查要点:PR API 返回 autoMergeRequest: null 且 CI 全绿、无 branch protection,却仍不合并,基本就是这个原因。

在 GitHub 页面一键打开对应仓库的 DeepWiki 文档,以及通过 url scheme 唤起本地 Logseq、VSCode 等应用,减少手动拼 URL 的操作。

插件地址:https://chromewebstore.google.com/detail/open-with-for-github/iaicdcencbmacjlfdbgaggfmikfpicig?authuser=0&hl=en

原理

利用 GitHub 仓库页当前的 owner/repo 拼出目标地址,并用 chrome.tabs 或 URL scheme 打开。

实际配置(ghq 管理,本地路径 ~/ghq/github.com/{owner}/{repo})

  • DeepWiki:https://deepwiki.com/{owner}/{repo}
  • Logseq:logseq://graph/MyLifeRecorded?page=github.com/{owner}/{repo}
  • Zed:zed://file/Users/zhaochunqi/ghq/github.com/{owner}/{repo}
  • VSCode:vscode://file/Users/zhaochunqi/ghq/github.com/{owner}/{repo}

示例代码

在 content script 中读取仓库路径,把 github.com/a/b 转为 {owner}/{repo}:

// location.href 形如 https://github.com/tauri-apps/tauri
const match = location.href.match(/github\.com\/([^/]+)\/([^/]+)/);
const owner = match[1];
const repo = match[2];

// 打开 DeepWiki
chrome.tabs.create({ url: `https://deepwiki.com/${owner}/${repo}` });

// 打开本地应用(ghq 路径,注意需要 encode 路径中的空格等字符)
location.href =
  `vscode://file/Users/zhaochunqi/ghq/github.com/${owner}/${repo}`;

注意

  • 本地 url scheme 需要系统已注册对应 handler(VSCode 默认注册 vscode://,Zed 注册 zed://),否则会被浏览器忽略。

linux.do 为注册用户提供免费的 DeepLx API 密钥,可以直接填入陪读蛙翻译插件,实现免费高质量的网页翻译。

获取密钥

  1. 注册并登录 linux.do
  2. 访问 https://connect.linux.do/dash/deeplx 复制 DeepLx Api Key

配置陪读蛙

  1. 安装陪读蛙扩展
  2. 点击陪读蛙扩展图标进入选项页面,导航到「API 提供商」
  3. 选择 DeepLX(默认 Base URL 即为 linux.do 的 api.deeplx.org,无需修改)
  4. 在 API Key 处填入从 linux.do 复制的密钥
  5. 回到页面,在功能提供商里选择翻译服务为 DeepLX
  • 填好后点击「测试连接」,成功即完成配置
  • 遵守合理使用规范,避免高频无效请求

一直以为在阿里云买的域名必须完成 ICP 备案才能正常使用,所以有个域名闲置了很久。最近因为需要苹果开发者认证购买了一个域名,发现将 DNS 解析托管到 Cloudflare 即可正常使用,无需备案。

关键认知:备案是针对”在国内使用国内服务器提供服务”的要求。如果业务不面向国内用户、不接入境内服务器,域名本身可以自由使用,不备案也没问题。

操作方式

  1. 在阿里云域名控制台将 DNS 服务器改为 Cloudflare 分配的 NS 地址
  2. 在 Cloudflare 添加域名并配置 DNS 记录
  3. Cloudflare 的代理(橙色云朵)功能也可正常使用

域名解析走 Cloudflare,源站用海外服务器,完全绕开备案流程。

在 iOS Safari 中,顶部“下载/打开 App”大概率来自 Smart App Banner,而“点链接直接跳转 App”通常是 Universal Links。

<meta name="apple-itunes-app" content="app-id=544007664">

YouTube 能显示“下载 YouTube”是因为网页声明了与 App Store App 绑定的 apple-itunes-app。

如果带上 app-argument,用户点击“打开”可跳转到指定内容页:

<meta
  name="apple-itunes-app"
  content="app-id=544007664, app-argument=https://www.youtube.com/watch?v=dQw4w9WgXcQ"
>

如果 App 未上架 App Store,这一步无法生效,因为 app-id 无法对应有效应用。

Universal Links(网页链接直接唤起 App)

你要额外配置苹果的关联域:网站提供 apple-app-site-association,App 开启 Associated Domains。

https://www.youtube.com/.well-known/apple-app-site-association
{
  "applinks": {
    "details": [
      {
        "appIDs": ["TEAMID.com.google.ios.youtube"],
        "components": [
          {
            "/": "/*"
          }
        ]
      }
    ]
  }
}

快速判断

  • Safari 顶部提示“下载/打开 App” → Smart App Banner
  • 点击 YouTube 链接直接切到 App → Universal Links
  • 想让“打开”跳具体内容页:app-argument + App 内处理深链

在 GHCR 里看到很多 untagged,不一定代表清理任务漏删了垃圾镜像。

如果一个 Docker 镜像是 multi-arch 构建,比如同时构建:

platforms: linux/amd64,linux/arm64

那么一个 tag 通常不是直接贴在某个具体平台镜像上,而是贴在一个“目录”上。这个目录的正式名字叫 OCI image index 或 manifest list。

可以把它理解成:

ghcr.io/org/app:sha-xxxxxxx
  -> 一个总入口 / 目录
      -> amd64 机器用的镜像
      -> arm64 机器用的镜像

用户拉镜像时还是用同一个 tag:

docker pull ghcr.io/org/app:sha-xxxxxxx

Docker 会根据本机架构自动挑正确的子镜像。amd64 机器拿 amd64,arm64 机器拿 arm64。

为什么会显示成 1 tagged + 4 untagged

以一个同时构建 linux/amd64 和 linux/arm64,并且没有关掉 buildx 默认 provenance 的镜像为例,一次构建大致会产生:

1 个 tagged image index
2 个 untagged platform image manifest
2 个 untagged provenance attestation manifest

所以 GHCR 页面上看起来就是:

1 tagged + 4 untagged

这里的 2 个 platform manifest 是真正给不同 CPU 架构运行用的镜像。它们没有自己的 tag,因为 tag 贴在上层 image index 上。

另外 2 个 provenance attestation manifest 不是运行镜像,而是构建证明。它们记录“这个镜像是从哪个 repo、哪个 commit、哪个 Dockerfile、哪次 GitHub Actions run 构建出来的,以及用了哪些基础镜像/构建依赖”。常见格式是 in-toto statement,predicate 是 SLSA provenance。

这些 untagged 可以删吗

不要只看 GHCR UI 上写着 untagged 就手动删。

如果这些 untagged 是被某个保留 tag 引用的子 manifest,删掉以后可能会破坏 multi-arch 镜像,或者丢掉 provenance 证明。正确的清理逻辑应该理解 image index 和它下面的子 manifest,而不是把所有 UI 上的 untagged 都当成孤立垃圾。

像 dataaxiom/ghcr-cleanup-action 这类专门处理 GHCR 清理的工具,会把 multi-arch image、attestation、referrer 这些关系一起考虑。删除旧 tagged image 时,它会连同下面的子 manifest 一起处理;保留新 tagged image 时,被引用的 untagged 子 manifest 也应该留下。

GitHub 能避免这种显示吗

GHCR 目前没有一个开关可以说“不要把被 image index 引用的子 manifest 显示为 untagged”。它是按 registry 里的 manifest/version 展示,所以 UI 会显得比较吵。

能减少数量,但都有代价:

  • 关掉 provenance:每批少两个 attestation,代价是没有构建来源证明。
  • 只构建单平台:少一个平台镜像,代价是不再同时支持 amd64/arm64。
  • 单平台并关掉 provenance:最干净,但功能和供应链信息都缩水。

更实用的判断是:不要追求 GHCR 页面上完全没有 untagged,而是区分“孤立 untagged”和“被保留 tag 引用的 untagged 子 manifest”。前者可以清理,后者是镜像结构的一部分。

市售的食用鸡蛋几乎都是未受精的,无法孵化出小鸡。

原理

  • 食用鸡蛋: 养殖场只养母鸡或严格隔离公鸡,母鸡无需交配就能正常产蛋,这些蛋只是卵细胞,没有胚胎
  • 受精蛋: 需要母鸡和公鸡交配后产下,在 37.5°C 和适当湿度下孵化约 21 天才能出壳

如何区分

  • 照蛋: 用强光照射,受精蛋能看到血丝或胚胎痕迹
  • 打开看: 受精蛋的蛋黄上有小白点 (胚盘),但生鲜时很难分辨

超市买的鸡蛋放多久都孵不出小鸡,只会变质。

GitHub token 校验不要依赖固定长度,尤其不要写类似 ghs_[A-Za-z0-9]{36} 的正则。

GitHub 官方文档列出的 token 前缀包括:

  • ghp_: classic personal access token
  • github_pat_: fine-grained personal access token
  • gho_: OAuth access token
  • ghu_: GitHub App user access token
  • ghs_: GitHub App installation access token
  • ghr_: GitHub App refresh token

2026-04-27 起,GitHub App installation token 会逐步切到 stateless 格式,ghs_ token 会变成类似 ghs_APPID_JWT 的结构,长度约 520 字符且会变化。JWT 部分也不应该由客户端解析或校验语义。

如果业务只是想拦住明显错误的输入,可以只做轻量格式检查:

const githubTokenRe = /^(?:(?:ghp|gho|ghu|ghs|ghr)_|github_pat_)[A-Za-z0-9_.-]+$/;

这个校验只确认是 GitHub 已知 token 前缀,并允许 JWT 常见的 . 和 -。真正有效性仍然应该交给 GitHub API 返回 401 Bad credentials 之类的结果判断。

更稳的原则:

  • token 当作 opaque string,不解析内部内容
  • 不限制固定长度
  • 存储字段至少能放下 520 字符以上
  • Actions 里优先使用内置 GITHUB_TOKEN,跨 repo 或特殊权限才使用 PAT 或 GitHub App

参考:

  1. GitHub Docs: GitHub’s token formats
  2. GitHub Changelog: Notice about upcoming new format for GitHub App installation tokens

Workflowy 早期的 inline editing 不是直接用 contenteditable,而是把一个透明的 textarea 精确覆盖在当前 hover 的文本上,点击后再切换可见层。

实现思路:

  1. 页面上正常渲染文本内容。
  2. 鼠标 hover 某一项时,把一个 opacity: 0 的 textarea 移动到这段文本上方。
  3. 点击时 focus textarea,把 textarea 设为 opacity: 1,同时把底层文本设为 opacity: 0。
  4. 输入时同步 textarea.value 到底层渲染节点。
  5. blur 后恢复底层文本显示,隐藏编辑框。

这样做的好处是输入、选区、键盘行为都交给原生 textarea 处理,同时显示层仍可以自己控制高亮、链接、tag 等富文本效果。

<!doctype html>
<meta charset="utf-8" />
<style>
  .item {
    position: relative;
    padding: 4px 8px;
    font: 16px/1.5 system-ui, sans-serif;
    white-space: pre-wrap;
  }

  #editor {
    position: absolute;
    z-index: 10;
    opacity: 0;
    resize: none;
    overflow: hidden;
    border: 0;
    padding: 4px 8px;
    margin: 0;
    font: 16px/1.5 system-ui, sans-serif;
    background: transparent;
    outline: none;
  }
</style>

<div class="item">Buy milk #todo</div>
<div class="item">Read https://example.com</div>
<textarea id="editor"></textarea>

<script>
  const editor = document.querySelector('#editor');
  let currentItem = null;

  document.querySelectorAll('.item').forEach((item) => {
    item.addEventListener('mouseenter', () => {
      if (document.activeElement === editor) return;
      moveEditorOver(item);
    });
  });

  function moveEditorOver(item) {
    currentItem = item;
    const rect = item.getBoundingClientRect();

    editor.value = item.textContent;
    editor.style.left = `${rect.left + window.scrollX}px`;
    editor.style.top = `${rect.top + window.scrollY}px`;
    editor.style.width = `${rect.width}px`;
    editor.style.height = `${rect.height}px`;
    editor.style.opacity = 0;
  }

  editor.addEventListener('mousedown', () => {
    if (!currentItem) return;
    currentItem.style.opacity = 0;
    editor.style.opacity = 1;
  });

  editor.addEventListener('input', () => {
    if (!currentItem) return;
    currentItem.textContent = editor.value;
  });

  editor.addEventListener('blur', () => {
    if (!currentItem) return;
    currentItem.style.opacity = 1;
    editor.style.opacity = 0;
  });
</script>

来源:How does workflowy implemented inline editing?