很多人已经习惯让 AI 写功能、修 bug、补测试。

但代码写完以后,真正准备开源时,仍然有一串容易出问题的工作:

  • 哪些文件应该上传,哪些缓存、日志和本地环境不应该上传?
  • .env、私钥、Token 或带账号密码的 URL 有没有混进项目?
  • README、LICENSE、运行命令和测试说明是否齐全?
  • 目标仓库到底是不是用户刚创建的那个空仓库?
  • 本地已经有 origin 时,能不能直接改 URL?
  • 远端不是空的,应该合并、更新,还是立即停止?
  • git push 返回以后,怎样确认远端分支真的指向本地这次提交?

如果把这些问题全部压缩成一句"帮我上传到 GitHub",AI 当然可以很快执行 git add . && git push。问题是,它也可能把不该上传的东西一起推上去,或者把本地项目推到错误的远端。

所以我没有做一个"更快执行 push"的脚本,而是做了一个会先审计、会拒绝、会留下证据的 Codex Skill:

github-safe-publish

开源地址:https://github.com/wangzifan396-wzf/skills

首个公开提交:https://github.com/wangzifan396-wzf/skills/commit/71c6a9c501b406c31d42413f501afb091ba1baff

它解决的是一个很窄、但非常实际的问题:

用户先在 GitHub 创建一个空仓库,然后把本地项目和仓库地址交给 AI;AI 负责检查发布边界、准备开源材料,并在明确授权后使用普通 Git 操作提交、推送和校验。

为什么做成 Skill,而不是一段超长脚本

项目发布同时包含两类工作。

第一类需要判断:

  • 这是源码、文档、演示素材,还是本地生成物?
  • dist 是应该排除的构建缓存,还是 GitHub Pages 真正需要的发布产物?
  • 一个 60 MiB 视频应该保留在 Git、转成 Release 附件,还是使用 Git LFS?
  • 缺少 LICENSE 时应该选择哪一种许可证?
  • 非空远端是不是这个项目之前已经发布过的同一段历史?

这些问题不能靠固定扩展名完全决定。比如"所有 build/ 都不上传"看起来省事,却可能直接删掉某些项目真正要交付的静态产物。

第二类工作适合确定性脚本:

  • 枚举 Git 已跟踪、未跟踪和已忽略路径;
  • 检测常见密钥形态和 GitHub 单文件上限;
  • 解析 owner/repository;
  • 读取远端 HEAD 和分支;
  • 执行 git diff --cached --check;
  • 普通提交、普通推送;
  • 再次读取远端 ref,并与本地 HEAD 比较。

因此这个项目采用两层结构:Skill 负责判断、授权和编排,Python 脚本负责重复且容易验证的动作。脚本全部只使用 Python 标准库和系统 Git,没有再引入一个新的依赖栈。

我把发布拆成六步

图 1:外部写入发生在扫描、准备、远端检查和 dry-run 之后。结束条件不是"push 命令没有报错",而是远端目标分支 SHA 与本地 HEAD 完全一致。

整个工作流分成六步。

第一步:确认范围与授权

Skill 先确定两个对象:本地项目根目录,以及目标 GitHub 仓库。

"帮我看看能不能发布"只代表只读检查;"把这个项目上传到这个仓库"才代表可以在这个范围内提交和推送。创建 GitHub 仓库、创建 Release、开启 Pages、发 Issue 和发 CSDN 都不自动包含在这份授权里。

第二步:扫描上传边界

扫描器会输出两份材料:

audit.json    # 完整机器可读清单
audit.md     # 方便人工复核的报告

JSON 中会列出:

  • 每个候选文件的相对路径、字节数、是否已跟踪、是否为符号链接;
  • Git 返回的已忽略路径;
  • 项目类型、常见入口和 package.json scripts;
  • README、LICENSE、.gitignore、运行与测试入口是否存在;
  • blocker、warning 和建议补充的忽略规则;
  • 当前分支、工作树状态和远端信息。

直接运行扫描器的命令是:

python github-safe-publish/scripts/inspect_project.py D:\path\to\project `
  --json-out audit.json `
  --report-out audit.md

报告默认不会把审计文件自己塞回待发布项目。它们是发布证据,不一定是项目源码的一部分。

第三步:补齐忽略规则和开源文档

Skill 不会看到 node_modules 就直接删除它,而是先生成保守的 .gitignore 建议:

python github-safe-publish/scripts/prepare_gitignore.py D:\path\to\project

上面只预览。确认以后才增加 --write。

默认关注的内容包括:

  • node_modules/、.venv/、__pycache__/;
  • pytest、mypy、Ruff 等工具缓存;
  • .log、.tmp、编辑器 swap 文件;
  • .env 和环境专用配置,同时保留 .env.example、.env.sample。

它故意不默认排除 dist/、build/、图片、视频和生成文档,因为这些路径在不同项目中可能具有完全不同的交付语义。

README 和 LICENSE 也不应该机械生成。Skill 会检查它们是否存在,但 README 里的功能、运行命令和测试结果必须来自真实项目;没有许可证意图时,也不能默认替维护者选择 MIT、Apache-2.0 或 GPL。

第四步:只读检查 GitHub 远端

远端检查器只接受标准的 GitHub HTTPS 或 SSH 仓库地址:

https://github.com/OWNER/REPOSITORY
https://github.com/OWNER/REPOSITORY.git
git@github.com:OWNER/REPOSITORY.git

它拒绝带查询参数、/tree/main 等非仓库路径,以及把账号或 Token 嵌进 HTTPS URL 的写法。

执行:

python github-safe-publish/scripts/inspect_remote.py `
  https://github.com/OWNER/REPOSITORY `
  --json-out remote.json

一个真正的空仓库会得到类似结果:

{
  "accessible": true,
  "empty": true,
  "defaultBranch": null,
  "heads": []
}

第五步:默认只做 dry-run

发布器即使拿到了完整参数,默认仍然不会修改任何状态:

python github-safe-publish/scripts/publish_project.py `
  --project D:\path\to\project `
  --remote https://github.com/OWNER/REPOSITORY `
  --confirm-repository OWNER/REPOSITORY `
  --branch main `
  --commit-message "feat: publish project" `
  --receipt publish-plan.json

这里专门要求再写一次 --confirm-repository OWNER/REPOSITORY。它不是为了增加仪式感,而是防止 URL 指向一个名字相近、但不是用户真正目标的仓库。

dry-run 会复述:

  • 项目根目录;
  • 解析后的仓库所有者和名称;
  • 目标分支与远端名;
  • 候选文件数和体积;
  • 阻断与警告;
  • 将要执行的 Git 操作。

但它不会初始化 Git、暂存文件、创建提交、增加远端或推送。

第六步:普通推送,并校验远端 SHA

只有用户明确要求发布,而且 dry-run 已经复核,才在同一条命令后增加:

--execute

发布器会:

  1. 必要时初始化 Git;
  2. 暂存已跟踪变更、删除项和扫描器确认的候选文件;
  3. 执行 git diff --cached --check;
  4. 有变化时创建普通提交;
  5. 在不冲突时增加目标远端;
  6. 使用普通 git push --set-upstream,不带任何 force 参数;
  7. 重新读取 refs/heads/main;
  8. 将远端 SHA 与本地 HEAD 逐字比较。

最后还可以独立验证:

python github-safe-publish/scripts/verify_publish.py `
  --project D:\path\to\project `
  --remote https://github.com/OWNER/REPOSITORY `
  --branch main

自动上传最重要的是知道什么时候停

图 2:安全门覆盖本地内容、远端历史、Git 补丁和发布后结果。任何一项阻断都比"先推上去再说"更便宜。

这个 Skill 把下面六类情况设成了明确的停止点。

1. 疑似密钥、私钥和凭据文件

扫描器会关注:

  • .env、私钥和 keystore 类文件;
  • GitHub github_pat_、ghp_ 等令牌形态;
  • AWS Access Key、Slack Token、OpenAI Key;
  • password、client_secret、api_key、access_token 等疑似非占位赋值。

报告只给出类别、路径和行号,不会把匹配值复制到 JSON、终端或文章里。

这里还有一个容易忽视的区别:如果秘密文件已经被 Git 跟踪,后来只把它加进 .gitignore 并不能解决问题。它仍然需要从索引或历史中移除,已经可能暴露的凭据还应该旋转。V1 扫描当前发布边界,不替代 GitHub Secret Scanning,也不自动清洗历史。

2. GitHub 单文件上限

文件达到 50 MiB 会警告,超过 GitHub 普通 Git 的 100 MiB 单文件限制会阻断。

警告的意义不是"一律删除大文件",而是让维护者选择:

  • 这是不是不该提交的本地生成物?
  • 是否更适合作为 Release 附件?
  • 是否真的要使用 Git LFS?

3. 非空或无关远端

首次发布默认只面向空仓库。

远端只要已经有分支,就会阻断。对已经由同一本地历史发布过的项目,可以显式增加 --allow-existing,但仍要满足:

  • 目标远端已经绑定并经过检查;
  • 远端目标分支存在;
  • 远端分支是本地 HEAD 的祖先;
  • 最终只能执行 fast-forward 普通推送。

无关历史不会自动使用 --allow-unrelated-histories 合并,更不会 force push 覆盖。

4. 远端名已经指向别处

如果本地已有 origin,但它不是用户给出的仓库,发布器不会静默执行 git remote set-url。

这时应该保留原远端,确认用户是不是选错项目,或者在明确情况下选择一个新的远端名。自动改 URL 虽然方便,却也是把正确代码推到错误仓库的高风险来源。

5. 暂存补丁本身不合格

这条安全门在第一次真实发布时就发挥了作用。

最初执行发布,脚本在提交前返回:

{
  "status": "failed",
  "error": ".gitignore:13: new blank line at EOF. ..."
}

原因是部分新文件末尾多了一行空白。此时文件已经在本地暂存,但没有创建提交,也没有推送远端。

修正 EOF 空行、重新运行 10 个测试和官方 Skill 校验后,第二次发布才继续。这不是一个"差点失败"的插曲,而是我希望这个工具具备的行为:格式问题应该在本地提交前暴露,而不是为了自动化完成率被忽略。

6. 本地与远端 SHA 不一致

git push 进程退出码为 0,只说明 Git 命令认为推送完成。Skill 仍然会查询远端的精确分支 ref。

这次真实回执是:

{
  "status": "published",
  "branch": "main",
  "commitCreated": true,
  "localHead": "71c6a9c501b406c31d42413f501afb091ba1baff",
  "remoteHead": "71c6a9c501b406c31d42413f501afb091ba1baff",
  "verified": true,
  "candidateFiles": 16,
  "ignoredPathEntries": 2
}

随后独立验证器再次得到 headMatches: true、worktreeClean: true。

Skill 的目录里有什么

仓库当前结构如下:

skills/
├── README.md
├── LICENSE
├── tests/
│   └── test_scripts.py
└── github-safe-publish/
    ├── SKILL.md
    ├── agents/
    │   └── openai.yaml
    ├── scripts/
    │   ├── inspect_project.py
    │   ├── inspect_remote.py
    │   ├── prepare_gitignore.py
    │   ├── publish_lib.py
    │   ├── publish_project.py
    │   └── verify_publish.py
    └── references/
        ├── safety-policy.md
        ├── readiness-checklist.md
        └── troubleshooting.md

SKILL.md 保留核心工作流和资源导航。更细的授权、凭据、远端历史规则放在 safety-policy.md;README、LICENSE、运行和测试说明放在 readiness checklist;认证失败、远端非空、大文件和推送拒绝的处理则按需读取 troubleshooting。

这种分层可以避免每次调用 Skill 时把所有细节一次性塞进上下文,同时让真正执行外部写入前必须读取安全策略。

怎样安装和使用

Windows PowerShell

git clone https://github.com/wangzifan396-wzf/skills.git
Copy-Item -Recurse .\skills\github-safe-publish `
  "$HOME\.codex\skills\github-safe-publish"

macOS / Linux

git clone https://github.com/wangzifan396-wzf/skills.git
cp -R skills/github-safe-publish ~/.codex/skills/github-safe-publish

重启 Codex,让它重新发现 Skill。

然后先在 GitHub 创建一个真正的空仓库。首次使用建议不要勾选自动创建 README、LICENSE 或 .gitignore,因为这些选项会让远端已经拥有首个提交,V1 会按非空远端处理。

在本地项目目录中可以直接说:

使用 $github-safe-publish,检查当前项目,并发布到
https://github.com/OWNER/REPOSITORY 。

如果只想先看结果,不发布:

使用 $github-safe-publish,只审计当前项目和这个 GitHub 仓库,
生成上传清单、排除清单与修复建议,不要提交或推送。

除了 Python 3.10+ 和 Git,脚本没有第三方运行依赖。GitHub 身份认证使用系统已有的 Git Credential Manager、SSH Agent 或其他正常 Git 认证环境,不需要把 Token 粘贴到对话中。

用它审计两个真实项目

图 3:Skill 先扫描自己,再扫描 mini-browser-games。候选表示进入复核边界,不代表所有内容都会无条件上传;warning 也不等于失败。

为了避免这个 Skill 只对一个临时样例有效,我先让它扫描自身,再扫描了我的 100 款浏览器游戏开源仓库。

结果如下:

指标 github-safe-publish 仓库 mini-browser-games
候选文件 16 185
候选体积 77.2 KiB 73.4 MiB
忽略路径项 2 24
阻断 0 0
警告 0 2

游戏仓库的两个警告分别是:

  1. 根目录有 100 个独立 HTML 游戏,没有单一的传统 index.html 入口;
  2. 带字幕的宣传视频约 63.2 MiB,需要人工确认是否继续放在普通 Git 仓库中。

这两个警告都没有被"智能修复"。第一个是仓库本身的产品形态,不是代码错误;第二个涉及演示材料的发布策略,也不应该由一个通用脚本替维护者决定。

这正是清单比"自动排除"更重要的地方:工具负责让差异可见,人负责决定项目真正的边界。

我为脚本写了哪些测试

当前仓库有 10 个标准库 unittest 用例,覆盖:

  • HTTPS、.git 后缀、scp 风格 SSH 和 ssh:// URL 归一化;
  • 拒绝嵌入 HTTPS 凭据的 URL;
  • 拒绝 /tree/main 这类非仓库路径;
  • --confirm-repository 必须与目标一致;
  • 静态网页项目入口识别;
  • node_modules 和 Git ignore 行为;
  • 真实 .env 阻断、.env.example 保留;
  • 疑似密钥被发现,但原值不会出现在 manifest;
  • 超过大小阈值时阻断;
  • .gitignore 写入顺序正确且重复执行不重复追加。

执行:

python -m unittest discover -s tests -v

结果是 10/10 通过。Skill 本体还使用官方 quick_validate.py 检查 YAML frontmatter、名称与目录结构,结果为 Skill is valid!。

真实远端测试也覆盖了三种状态:

  • 首次空仓库:允许发布并完成 SHA 校验;
  • 已发布的非空仓库:默认返回 blocker,dry-run 退出码为 2;
  • 同一历史后续更新:只有显式 --allow-existing 时 dry-run 才进入 ready,并仍要求 fast-forward。

V1 故意没有包办一切

图 4:复杂场景不会被包装成"自动化失败",而是转入人工审查或专用工作流。能明确保证安全的部分才进入 V1 自动执行路径。

github-safe-publish 当前没有尝试成为完整的 GitHub 运维平台。

它不会自动:

  • 创建 GitHub 仓库;
  • 修改仓库 description、topics 或可见性;
  • 创建 Release、Tag 或 GitHub Pages;
  • 发 Issue、Pull Request、消息或 CSDN;
  • 重写 Git 历史、清理已经提交过的秘密;
  • 配置 Git LFS、子模块或组织 SSO;
  • 把 monorepo 的一个子目录强行拆成独立仓库;
  • 覆盖带有无关历史的远端。

这些能力以后可以继续增加,但每一种都有不同的权限和失败语义。把它们全部塞进第一版,只会让一句"上传项目"获得过大的外部操作范围。

V1 的边界更像一句承诺:

对用户已经创建的空仓库,完成可审计的首次发布;对同一历史完成显式、可验证的 fast-forward 更新;其他场景先停下来解释。

这次做 Skill 给我的最大启发

以前我会把"自动化"理解为尽量减少步骤。做完这个 Skill 后,我更愿意把它理解成:

把重复动作交给脚本,把关键判断暴露出来,把外部写入推迟到证据充分以后。

一个真正安全的上传工具,不应该以"成功执行了多少次 push"为唯一指标。它还应该回答:

  • 它有没有把秘密值输出到日志?
  • 它能不能解释为什么某个文件被排除?
  • 它遇到非空远端会不会自作主张?
  • 它能不能在提交前停下来?
  • 它最后能不能证明远端就是本地这个提交?

这次首发时,安全门真的因为 EOF 空白行停止,反而让我更确认这套设计是有意义的。自动化不是永远向前执行;可靠的自动化必须拥有明确、可解释的刹车。

本文的 5 张图片也不是手工拼接的不可复现成品。图中数字来自 docs/images/github-safe-publish/article-data.json,Pillow 脚本读取这份 JSON 后生成 1920×1080 封面和 4 张 1600×900 正文图:

cd promo-video
npm.cmd run article:github-safe-publish:images

以后 Skill 的文件数、测试数或真实审计结果变化时,只需要先更新结构化数据,再重新生成图片,就不必在多张图里手工修改同一个数字。

项目已经使用 MIT License 开源:GitHub - wangzifan396-wzf/skills · GitHub

如果你也经常让 Coding Agent 完成项目,可以直接安装后拿一个新建的空仓库试用。也欢迎检查脚本、补充 Secret 规则、提交 Issue,或者用它审计自己的开源发布边界。

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐