【免费下载链接】freehire
freehire — the open-source search engine for job seekers
freehire 是开源的职位搜索引擎,而站点上原本只有一个指向 GitHub 的贡献者计数。add-contributors-page这份设计文档记录了如何把"谁在和我们一起构建它"变成站点自己的一等页面:/contributors展示墙、/contributors/<login>个人页、每人一张 1200×630 社交预览卡,以及一条服务请求路径上零 GitHub API 调用的数据管线。本文以该设计文档为骨架,结合仓库中已经落地的路由、规则模块、采集脚本与工作流,逐条拆解每个设计决策背后的约束、被否决的方案,以及与实现代码对应的证据。
问题定义:数据的形状本身就是设计约束
设计文档开篇没有先谈页面,而是先谈数据。仓库当时有 2410 个已合并 PR 和 104 个 issue,背后只有十七个账号:一个 bot、一个持有 2236 个已合并 PR 的维护者,以及十五个外部贡献者中最大的历史也只有八个。八个人从未合并过代码,全部凭借自己开的 issue 出现在这里——这正是"必须把 issue 也算进去"的实测论据:只统计代码会让页面直接腰斩。
任何"朴素实现"的页面——一个按数量排序的平面网格——都会渲染成一根巨大的柱子和十五条发丝,而 bot 排在第二。
这条观察决定了后续几乎所有决策:分组、排序、字段裁剪、甚至快照文件的序列化方式。项目在文档中把这一点称为"The shape of the data is the design constraint"。
必须说明这些数字是设计时的测量值:仓库提交的快照里,维护者一人的mergedPullRequests已经增长到 2783,快照共 26 个条目(包含dependabot这样的自动化账号,由规则层统一过滤)。也就是说,这个"一根柱子 + 十五条发丝"的比例只会更极端,而不会随时间变得温和。
复用的既有基础设施
设计文档特意指出,站点已经具备了实现该页面所需的一切:
web/src/lib/server/og/通过 satori 渲染 1200×630 社交卡,card.ts、company.ts、blog.ts负责内容,shared.ts提供品牌基础构件,render.ts输出 PNG,再以…/og.png/+server.ts形式对外暴露。web/src/lib/server/github.ts已经在用 GitHub API 做 star 徽章和/open页面,并且它自己声明了不能把新需求挂进去的原因:未认证的 GitHub REST 每 IP 每小时只有 60 次请求,而这份预算已经被占满。web/scripts/*.mjs是生成web/资产的既有位置(gen-og.mjs、og-smoke.mjs、gen-api-docs.mjs),它已经是 knip 的入口,而且与根目录scripts/不同——它旁边就有测试运行器:web/跑 vitest。
目标与非目标
目标(文档原文的四个判据):
/contributors的第一印象必须是"有人在这里贡献,而且是最近",而不是"某一个人写了这个项目";/contributors/<login>是贡献者愿意写进简历并分享的页面——列出他实际的 PR 标题,而不只是一个数字;- 每位贡献者有自己的社交预览卡,分享出去的链接显示的是他本人的脸;
- 任意请求路径上零 GitHub API 调用,零新增后端、数据库或 worker 面积。
非目标同样重要,因为它们解释了页面为什么长得"克制":
- 不做奖牌与等级。Novu 的 bronze/silver/gold 在五十个贡献者时是好主意,在八个时则为时过早;个人页已经展示了等级将要依赖的计数。设计文档把这称为"seam":等级以后可以从快照的单人数据算出来,而不需要重新采集。
- 不做人工维护的贡献类型分类。
all-contributors那种文档/设计/triage 的信用体系需要一份手工清单,而手工清单恰恰是最容易悄悄漏掉人的东西。已合并 PR 与已开 issue 都是机器可读的,而且都算数。 - 不做实时新鲜度。对一个关于人的页面来说,一天前的快照足够正确。
- 不做排行榜、排名、积分。
决策一:快照是一个提交进仓库的 JSON,由定时 Action 刷新
选中方案
.github/workflows/contributors.yml每天运行,执行node web/scripts/build-contributors.mjs,并且仅当文件内容发生变化时,推一个分支并开一个启用了自动合并的 Pull Request。
为什么不直推 main:一次真实运行给出的答案
文档明确记录这不是最初的设计。第一版直接向 main 推送,第一次真实运行就否定了它:main 要求三个绿色检查,并且对管理员同样强制(enforce_admins),因此直推对任何人都被拒绝,无论工作流还是人类。代码里看不出这一点,只有跑一次才知道。
为什么必须是CONTRIBUTORS_TOKEN而不是GITHUB_TOKEN
这直接决定了凭证设计:用工作流自带的GITHUB_TOKEN推的分支不会触发任何工作流(GitHub 阻止工作流自我循环),于是 PR 的三个必需检查会永远停在 "expected",任何人都无法合并。因此推送必须携带CONTRIBUTORS_TOKEN——一个仅限本仓库的细粒度个人访问令牌(contents 与 pull-requests 的写权限)。工作流注释把这一段的推理写得很直白:
# Which is also why this cannot use the workflow's own GITHUB_TOKEN. A branch pushed # with it triggers no workflows — GitHub blocks that to stop a workflow looping on # itself — so the pull request would sit with its three required checks forever # "expected", unmergeable by anyone.文档诚实地说:这是一份常驻凭证,是"页面保持最新而无需人记得刷新"的代价。
没有 secret 时是干净的空操作
工作流的第一步只检查 secret 是否存在,并把结果写进GITHUB_OUTPUT:
- name: Check the token is configured id: token env: CONTRIBUTORS_TOKEN: ${{ secrets.CONTRIBUTORS_TOKEN }} run: '[ -n "$CONTRIBUTORS_TOKEN" ] && echo "present=true" >> "$GITHUB_OUTPUT" || echo "present=false" >> "$GITHUB_OUTPUT"'后续每一步都带if: steps.token.outputs.present == 'true'。于是 fork、或尚未配置令牌的仓库,得到的是一个干净的空操作,而不是每晚一次的失败。这正对应 .github/workflows/contributors.yml 顶部那句注释:"WITHOUT THE SECRET THIS IS A CLEAN NO-OP."
一个分支被复用,而不是每次运行一个
文档特别指出:复用同一个分支,这样第二天的变更是在更新那个已打开的 PR,而不是留下一串过期的 PR。实现上,.github/scripts/commit-contributors.sh 每次从检出的 main 重建分支并 force-push:
git checkout -B "$BRANCH" git add "$SNAPSHOT" git commit -m 'Refresh the contributor snapshot' git push --force origin "$BRANCH"脚本注释解释了 force-push 为什么既安全又必要:除了这个任务没有任何东西写这个分支,它覆盖掉的是自己那份已被取代的同一文件版本。
被否决的替代方案
- 请求时实时拉取,像
githubStats那样做记忆化。被否决:每人的 PR 列表无法在每小时 60 次请求内组装完成,记忆化在每次重启时失效,而蓝绿部署意味着两个进程各自消耗一份预算。 cmd/sync-contributorsworker 写入 Postgres。对这个仓库来说很地道,也确实更强大,但它用一次 migration、sqlc 重新生成、一个 Go endpoint,以及一个release.sh并不部署、只能手工拷到宿主机的 systemd unit,换来了没人需要的新鲜度。为了十一行每月才变一次的数据,不值。
决策二:采集用 GraphQL,而不是 Search API
Search API 单个查询上限 1000 条结果,而 2408 个已合并 PR 超过这个上限——用 search 分页拉合并 PR 会静默截断。GraphQL 的repository.pullRequests(states: MERGED)以每次 100 条分页直到完成,大约 25 次调用,并且一次返回 author、number、title、mergedAt。104 个 issue 两次调用即可。维护者身份来自 REST 的 collaborators 接口,过滤permissions.admin;Action 的令牌有读取它的权限。
采集脚本在 web/scripts/build-contributors.mjs 中落实了这套查询,并用pageAll故意不设页数上限:
/** Every node of a connection, paged to completion. * * Deliberately unbounded rather than capped at some number of pages: the point of * using GraphQL here is that it does not truncate the way the search API does at a * thousand results, and a page cap would quietly reintroduce exactly that. */ async function pageAll(token, query, read) { ... }两个容易踩的坑也在源码里被处理掉了:
- GraphQL 的错误是 HTTP 200 +
errors数组,只看状态码会让半采集的一页冒充完整一页,所以graphql()在body.errors存在时抛错。 - author 的
databaseId需要按具体类型写 fragment,因为Actor接口本身不携带它,脚本因此对User与Bot各写了一次... on X { databaseId }。
另外,requireToken()拒绝在无认证状态下运行:未认证 REST 每小时 60 次而这里需要约 30 次,回退方案会"有时成功、有时截断、并且永远不说清是哪种"。
决策三:为所有人保留计数,只为最近二十条保留明细
把全部 2408 个 PR 存进仓库,意味着让一个约 300 KB 的文件每天经过一次提交,其中 99% 是一个人的历史。因此每个人的条目携带精确的总数加上最近二十条已合并 PR。
二十是"个人页还能不变成一条长滚动"的数量,而且规则统一——不为维护者开特例,所以当第二个维护者出现时,文件形状不会变化。实现中这个常量被显式导出并命名:
/** How many of a person's merged pull requests the snapshot carries in full. */ export const RECENT_PULL_REQUEST_LIMIT = 20;assembleEntries()先把每个人的 PR 按mergedAt倒序排序再slice(0, RECENT_PULL_REQUEST_LIMIT),而mergedPullRequests计数在遍历时逐条累加,从未被裁剪。web/scripts/build-contributors.test.mjs 用RECENT_PULL_REQUEST_LIMIT + 5条 PR 断言"总数准确、明细被截断、最新的一条在最前"。
决策四:快照文件是数据的函数,且仅此而已
这是整篇设计里最精妙的一处,也是最容易被忽视的一处。
整个"只变更才提交"的设计建立在git diff --quiet之上:文件里任何在相同数据的不同运行之间会变化的东西,都会让每次运行都产生一次提交——而由于宿主机部署绿色的 main,这就变成每天一次毫无内容的生产部署。
这带来两个后果:
- 人员按 login 排序,而不是保留 GitHub 分页返回的顺序;
- 文件不带任何采集时间戳。初稿有,它会在第一次定时运行时就击穿整个设计,而且在所有测试里看起来都正确:时间戳正是那种没人会回头重读的字段。它本想记录的东西——数据最后一次变化的时间——已经由改变它的那次提交精确且不可伪造地记录了。
实现层面还多做了一步比较级的小心:排序比较的是小写 login 的 code-unit,而不是localeCompare:
const people = [...entries].sort((a, b) => { const [x, y] = [a.login.toLowerCase(), b.login.toLowerCase()]; if (x === y) return 0; return x < y ? -1 : 1; });原因写在源码注释里:localeCompare的答案依赖运行时的 locale 和 ICU 数据,笔记本和 CI runner 可能对同样的两个 login 给出不同结果,而每一次分歧都是一次"数据没变却提交"的提交。先转小写则是因为按原始字符串比较会把所有大写开头的 login 排在小写之前,等于莫名其妙地按大小写给页面分组。测试 web/src/lib/contributors.test.ts 与 web/scripts/build-contributors.test.mjs 各有一条用例锁住了这两点,其中后者的 fixture 特意选了['cy', 'Bea', 'ada'],原始 code-unit 顺序下Bea会排在ada前面。
决策五:规则住在web/src/lib/contributors.ts,采集住在脚本里
职责切分得非常干净:
- 脚本只做 I/O:分页、映射字段、截断到二十、写文件。它找到的每个人都会被写进去,包括 bot。
- 所有被 spec 称为"规则"的东西——排除 bot、拆分维护者与贡献者、按最近贡献排序——都在 web/src/lib/contributors.ts 里,是对快照的纯函数,用 vitest 对着 fixture 单测。
这正是让需求可测试的原因:一个测试可以断言"bot 永远过不了过滤器",以及"一周前的新人排在一年八个提交的老手前面",而全程没有网络。
被否决的替代方案是把规则写进脚本:那样它们只会被一个没人看的定时任务执行,而且位于一个根工作区没有测试运行器的文件里。为了让脚本的组装逻辑也被覆盖,web/vitest.config.ts的 include 增加了一个 glob(原本只有src/**/*.test.ts):
include: ['src/**/*.test.ts', 'scripts/**/*.test.mjs'],数据形状
规则模块导出的 TypeScript 类型就是快照的契约:
export interface ContributorEntry { login: string; id: number; avatarUrl: string; /** GitHub's own account type. `Bot` is how an automated account identifies itself. */ accountType: string; role: 'maintainer' | 'contributor'; /** ISO-8601 instant of their earliest contribution, pull request or issue. */ firstContributionAt: string; /** ISO-8601 instant of their latest contribution, pull request or issue. */ lastContributionAt: string; mergedPullRequests: number; openedIssues: number; recentPullRequests: ContributorPullRequest[]; }ContributorsSnapshot只有一个people字段。注意类型注释里再次强调了"故意不带 collected-at 时间戳"。
bot 过滤:两个信号,而不是一个
function isBot(entry: ContributorEntry): boolean { return entry.accountType === 'Bot' || entry.login.endsWith('[bot]'); }accountType是 GitHub 自己报告的,是权威信号;[bot]后缀则捕获经由未携带该类型的路径拿到的账号。留在这里的话,dependabot[bot]就是这个仓库的第二大贡献者——在一个关于人的页面上,那是 bug。
web/src/lib/contributors.test.ts 里有一段很有价值的注释,说明了为什么这条 tested-against-real-file 的用例非有不可:GraphQL API 把 dependabot 的 login 拼作dependabot(没有[bot]后缀),而 REST 接口把同一个账号拼作dependabot[bot]。如果只按那个"显而易见的信号"写过滤,GitHub 的这个第二大贡献者就会出现在页面上,而这个文件里所有 fixture 都还是绿的。同一文件还有一组针对已提交快照的断言:其中至少存在一个 bot 供规则排除,且两个分组里都不得出现accountType !== 'User'或 login 含bot的条目。
分组与排序
export function contributorGroups(snapshot: ContributorsSnapshot): ContributorGroups { const people = snapshot.people.filter((entry) => !isBot(entry) && hasContributed(entry)); return { maintainers: byRecency(people.filter((entry) => entry.role === 'maintainer')), contributors: byRecency(people.filter((entry) => entry.role !== 'maintainer')), }; }hasContributed()要求mergedPullRequests > 0 || openedIssues > 0:采集器会经由多个查询触达账号,因此可能留下一个什么都没挂上的条目(评论作者、从未合并的已关闭 PR)。没有任何东西可以被称为贡献,就不展示。
byRecency()只比较lastContributionAt,不读任何计数:
function byRecency(entries: ContributorEntry[]): ContributorEntry[] { return [...entries].sort((a, b) => b.lastContributionAt.localeCompare(a.lastContributionAt)); }决策六:按最近贡献排序,而不是按体量
Sourcerer 的 widget 之所以把 "new" 和 "trending" 从 "top" 里分出来,原因正是如此:在按体量排序下,新人一出现就被埋掉,这与本页面的目的完全相反。一个"最近优先"的排序达到同样效果,而且没有类别需要解释。维护者单独成组,因此维护者的"最近"不会占掉新人需要的第一格。
测试把这条规则写成了两个可执行的事实:一周前一条 PR 的新人排在前;仅有 issue 的报送者也能排在一年前的八个 PR 之前。
决策七:opt-out 是脚本里一份显式的排除名单
有人可能不希望自己的名字出现在一个带营销性质的页面上。web/scripts/build-contributors.mjs 里有一个EXCLUDED_LOGINS常量,初始为空,按请求执行:
/** Logins to leave out of the snapshot entirely. */ const EXCLUDED_LOGINS = [];文档承认这是手写清单,而本仓库通常不信任手写清单——但手写清单的危险在于它会藏起本该出现的人,而排除名单的失败方向相反:忘记加某人只会让他显示出来,也就是他本来就在的状态。assembleEntries()会把这份名单与 admin 名单一起转成小写集合,在entryFor()里直接返回null,因此被排除者的 PR 与 issue 都不会进入任何计数。
从设计到落地:页面、卡片与可发现性
展示墙/contributors
web/src/routes/contributors/+page.server.ts 只有一行逻辑——直接导入提交的 JSON 并交给规则模块:
export const load: PageServerLoad = () => contributorGroups(snapshot as ContributorsSnapshot);注释明确写着:分组与排序不在这里做,#lib/contributors拥有它们并已被测试。
页面本身(+page.svelte)有一个值得注意的细节:人数是算出来的,不是写下来的——"a hand-typed figure beside a generated list is a second answer to the same question, and it is the one that goes stale"。维护者组用SectionLabel text="maintained by",贡献者组标题直接写明 "Most recent first",并附带一句解释:"Ordered by when someone last contributed, not by how much — so the newest arrival is at the top, where they should be."
个人页/contributors/<login>
web/src/routes/contributors/[login]/+page.server.ts 的加载函数做了三件事,正好对应三条需求:
- 通过
findContributor()(而不是直接查原始快照)解析 login,找不到就error(404, 'Contributor not found')。这样 bot 或"什么都没贡献"的条目会和展示墙一样 404,而不会得到一张没人链接的卡片。 findContributor()内部大小写不敏感匹配(GitHub login 就是大小写不敏感的)。- 若命中但大小写不同,
redirect(308, ...)到规范 URL——否则每种大小写都是同一个人的一个独立地址,同时切碎搜索索引与分享链接积累。
页面按需求列出头像、GitHub 主页链接、首次贡献时间、已合并 PR 数、已开 issue 数,以及每条 PR 的标题、编号、合并日期与 GitHub 链接。统计条刻意用 flex 而不是两列网格:
<!-- A contributor who has opened no issues has one tile, so a fixed two-column grid would leave half the strip as an empty cell — a wrapping flex grows the last row's tiles to fill the width instead. -->同时也是"issue-only 贡献者"的优雅降级:stats通过.filter((s) => s.value > 0)过滤掉计数为零的项,PR 列表整段包在{#if who.recentPullRequests.length > 0}里,因此不会渲染出一个读起来像坏掉的空小节。列表标题还会在"全部 PR"与"最近 N 条 PR"之间自动切换。
分享按钮按需求只带本页 URL:X 走https://x.com/intent/tweet?text=…&url=…,LinkedIn 走share-offsite/?url=…,两者都对canonical做encodeURIComponent。分享文案是第一人称——"I contribute to freehire, an open-source job search engine."——因为它要读起来像贡献者自己的句子,而不是站点的口吻。
每人一张 OG 卡/contributors/<login>/og.png
卡片外观定义在 web/src/lib/server/og/contributor.ts,是一个纯同步函数:输入一个贡献者条目和一个已解析的头像(data-URI 或null),输出给 satori 的 HTML 字符串,因此可以被单测和渲染冒烟测试直接调用。
export function buildContributorCard( entry: ContributorEntry, opts: { avatar: string | null }, ): string { const role = entry.role === 'maintainer' ? 'Maintains freehire' : 'Contributes to freehire'; ... }设计文档强调的 satori 约束在代码里被严格遵守:布局只能是 flexbox,任何有多个子元素的元素都要声明display: flex;头像用shared.ts的logoBox(..., 'circle'),因为"一个关于人的卡片把人切成方形会读作公司 logo"。所有插值都过esc(),brandFooter()提供统一的品牌页脚。
路由层 web/src/routes/contributors/[login]/og.png/+server.ts 与公司卡的端点同构:
const [fonts, avatar] = await Promise.all([ loadOgFonts(), resolveAvatar(contributor.avatarUrl), ]); const png = await renderMarkupPng(buildContributorCard(contributor, { avatar }), fonts);缓存头为public, max-age=3600, stale-while-revalidate=86400——背后的快照一天最多变一次。头像解析 web/src/lib/server/og/avatar.ts 的设计同样值得引用:satori 不能自己抓远程图片,所以服务端抓取后转成>export function contributorPaths(): string[] { const { maintainers, contributors } = contributorGroups(contributorsSnapshot as ContributorsSnapshot); return [...maintainers, ...contributors].map((entry) => `/contributors/${entry.login}`); }
这里的要点与findContributor()一致:路径由展示墙所用的同一套规则派生,而不是来自原始快照——bot 的个人页会 404,向爬虫提供一条指向已知不存在页面的路径毫无意义。该函数在 web/src/lib/sitemap.test.ts 中被断言"不含任何含bot的路径"。
风险与权衡
设计文档的这部分不是免责声明,而是可操作的失败模式清单:
- 每日合并会触发宿主的自动部署轮询器→ 工作流只在采集数据不同时开 PR。以十六个贡献者计,一个月也就几次合并,每次都是值得部署的真实变化。让"不同"这句话可信的,正是上一节说的"文件是数据的纯函数"。
CONTRIBUTORS_TOKEN是带写权限的常驻凭证→ 它是细粒度、限定本仓库与两项权限的,因此不是账号级令牌;它唯一用途是一个没有别的东西会写的分支。它仍是这个设计最大的成本,换来的是"页面不会因为没人记得刷新而过期"。撤销它只会停止刷新,其余一切不受影响——任务回到空操作。- 不完整的采集会静默缩小已发布名单→ 脚本在任何不完整的分页或 API 错误上让运行失败,而不是写入已有的部分;工作流的提交步骤因此永不执行,上一版快照继续服务。
assertUsable()把这条规则推到极致:一个连人类都没找到的采集是失败的测量,不是空仓库——
export function assertUsable(entries) { const humans = entries.filter((e) => e.accountType !== 'Bot' && !e.login.endsWith('[bot]')); if (humans.length === 0) { throw new Error( `collection found no human contributors (${entries.length} entries in total) — refusing to write a snapshot that would empty the page`, ); } }它防的每一种失败都是静默的:GraphQL 字段被改名、连接开始返回空、令牌什么也读不到。没有它,运行会写出空文件、工作流会提交它、页面会在一次绿色构建中变空。
- 贡献者注销 GitHub 账号→ 头像 404。OG 卡已退化到共享 monogram,个人页显示同样的回退;该条目在下次采集时消失。
- satori 对布局很严格→ 贡献者卡复用
shared.ts的原语,并被既有卡片的渲染冒烟测试覆盖(web/src/lib/server/og/contributor.test.ts)。 [login]路由可被任意输入请求→ 查找是对快照 login 的精确匹配(大小写不敏感),不在快照里的一律 404(页面与卡片都是),且每个插值在进入卡片标记前都被转义。- knip 会拦截
web/中未被使用的文件→ 快照 JSON 与规则模块都被页面导入,scripts/*.mjs已是入口,因此不需要配置豁免;PR 前用pnpm check:dead复核。 actionlint会对每个run:块跑 shellcheck→ 工作流把run:保持为单条命令,逻辑移入.mjs或.github/scripts/*.sh(本仓库已对后者跑 shellcheck)。
工作流的失败可读性:三个被拆出去的 shell 脚本
设计文档里没有展开的一个实现细节,恰好是"只有真正跑过才知道"的延续:.github/workflows/contributors.yml 把逻辑放进了三个脚本,其中第一个是preflight。
.github/scripts/check-contributors-token.sh 存在的原因是它取代的失败信息不可读:一个缺了某项权限的令牌会在三十次 API 采集之后、走到git push时才回来一句
remote: Permission to <owner>/<repo>.git denied to <user>. fatal: ... The requested URL returned error: 403它点名的是用户而不是缺失的权限,读起来像账号错了,而账号其实没问题。所以 preflight真的去写一次:创建一个 probe ref 再删掉。
脚本注释还解释了一个陷阱:看起来最自然的检查——读repos/{owner}/{repo}的.permissions.push——是错的,因为那个字段报告的是令牌所属用户的角色,而不是令牌被授予了什么。一个由管理员持有的只读细粒度令牌会报告push: true,然后依然推不上去。假绿灯比没有检查更糟。
另外两个脚本把"是否提交"和"是否开 PR"的错误分支都吃掉:
- .github/scripts/commit-contributors.sh:只在
git diff --quiet报告变化后被调用; - .github/scripts/open-contributors-pr.sh:先问这个分支上有没有已开的 PR 再决定是否
gh pr create(第二次变更先于第一次合并到达是常规情况而非异常),然后无论哪种情况都(重新)请求gh pr merge --auto --squash——一个曾排队、又被红色运行作废的合并需要重新请求,而请求两次没有成本。
还有一个调度上的细节:04:20 UTC是刻意的,避开了 03:00 开始的每晚pg_dump窗口,这样由它触发的部署不会排在 dump 后面。目前 schedule 是注释掉的,因为 secret 里的令牌当前只有读权限;先跑一次手动workflow_dispatch并读第一步的输出,是判断它能否恢复的方式。这一状态在 openspec/changes/add-contributors-page/tasks.md 中以唯一一条未完成的7.9记录下来:给CONTRIBUTORS_TOKENContents: Read and write、取消 schedule 注释、然后触发两次——第一次开 PR,第二次无事可做。
迁移与回滚
变更整体是增量且可逆的。第一份快照由人工在本地用个人令牌跑脚本后提交,这样引入这些页面的那次部署就有数据;定时工作流从下一次运行开始接管。回滚就是删掉路由与工作流——没有别的东西读这份快照,而web/src/lib/server/github.ts完全未动,因此/open与 star 徽章在两种状态下行为完全一致。
任务清单也展示了落地时的验证门槛:pnpm --dir web lint、test、build全绿;pnpm check:dead对新文件无新增发现;pnpm check:links通过;最后在真实浏览器里打开/contributors、一个个人页及其og.png,确认卡片预览正确。
仍未定论的两个问题
文档明确写着"没有阻塞性问题",但有两个要在页面真正上线并被分享几次之后再回看:
- 二十条 PR 的截断深度对个人页是否合适;
- 等级制度是否在贡献者数量增长到"分级真正有意义"的规模后才值得引入。
这两个问题之所以能留到最后再答,正是因为快照的每人数据已经包含了它们所需的全部原料——这也回到了文档最初的那句话:数据的形状就是设计的约束。先把形状定对(分组、按最近排序、计数精确而明细有界、文件是数据的纯函数),页面上的所有取舍就都变成了在这个形状上做的小决定,而不是需要重新采集一轮的架构变更。
【免费下载链接】freehire
freehire — the open-source search engine for job seekers
相关推荐
Scenario红队测试指南:全面提升AI代理安全性的终极方法
Scenario红队测试指南:全面提升AI代理安全性的终极方法 在当今AI驱动的应用中,确保AI代理的安全性和可靠性至关重要。Scenario作为一款强大的AI
MediaMTX 入门:一个单文件搞定多协议视频流转发的流媒体服务器
MediaMTX 入门:一个单文件搞定多协议视频流转发的流媒体服务器 MediaMTX 是一个零依赖的实时流媒体服务器和媒体代理,负责把音视频流从一种协议转到另
音视频后端直播IINA社区贡献统计:揭秘这款macOS播放器背后的开发者力量
IINA社区贡献统计:揭秘这款macOS播放器背后的开发者力量 IINA作为macOS平台上最受欢迎的现代媒体播放器,其成功离不开一个活跃的开源社区。这款基于m
音视频桌面应用
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考