用 1Password 服务账号给 GitHub Actions 提供部署凭据:服务器地址、用户名、SSH 私钥和 known_hosts 都放在 1Password 的 CI 专用保险库,GitHub 只保存一个服务账号令牌;推送到 main 后自动 rsync 到 VPS 并重启服务。附带一个 workflow 检查脚本,防止常见的密钥泄露写法。
先看清成品
deploy.yml通过 actionlint 1.7.12 和 GitHub workflow schema 校验;检查脚本 3 个测试通过,并对本文 workflow 输出“检查通过”。没有在 GitHub 上实际运行,也没有创建服务账号或连接 VPS。
注意:1Password 是付费服务,计划和价格以官方价格页为准。不要共享个人账号或把主密码、Secret Key、服务账号令牌发给别人;团队共享请用共享保险库和权限设置。只管理你有权管理的凭据。
做完之后你会得到什么?
| 成果 | 说明 |
|---|---|
| CI 专用保险库 | shop-ci,只放部署需要的条目 |
| 服务账号 | 只能读 shop-ci |
| 部署 workflow | 读取凭据 → 写 SSH 配置 → rsync → 重启 → 清理 |
| 检查脚本 | 权限、令牌来源、引用位置、输出展开方式 |
需要准备哪些环境?
| 项目 | 要求 | 说明 |
|---|---|---|
| 1Password 账号 | 能创建服务账号的计划和权限 | 以官方服务账号文档为准 |
| GitHub 仓库 | 能设置 Secrets 和 Environments | |
| VPS | 部署用户可免密 sudo systemctl restart shop |
用 sudoers 只放行这一条命令 |
| 本地工具 | Python + PyYAML、actionlint | 检查 workflow |
文件结构
23-1password-B-src/
├── .github/workflows/deploy.yml
├── deploy/README.txt # 换成你的应用文件
├── lint_secrets_usage.py
└── test_lint.py
第 1 步:准备保险库和条目
- 新建保险库
shop-ci。 - 条目
vps-prod:字段 host、username、known_hosts(在本机运行ssh-keyscan 服务器地址得到)。 - 条目
vps-prod-ssh:SSH 密钥类型,在 1Password 里生成新的 ed25519 密钥,把公钥加到服务器部署用户的~/.ssh/authorized_keys。
第 2 步:创建服务账号
在 1Password 网页版 开发者 → 服务账号 新建,只授予 shop-ci 的读取权限。令牌只显示一次,保存到 GitHub 仓库 Settings → Secrets and variables → Actions 的 OP_SERVICE_ACCOUNT_TOKEN(建议放在 production 环境的 Secrets 里,并给环境设置审批人)。
第 3 步:workflow
.github/workflows/deploy.yml:
name: Deploy to VPS
on:
push:
branches: [main]
workflow_dispatch:
permissions:
contents: read
concurrency:
group: deploy-prod
cancel-in-progress: false
jobs:
deploy:
runs-on: ubuntu-latest
environment: production
steps:
- uses: actions/checkout@v7
- name: 从 1Password 读取部署凭据
id: op
uses: 1password/load-secrets-action@v5
env:
OP_SERVICE_ACCOUNT_TOKEN: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }}
DEPLOY_HOST: op://shop-ci/vps-prod/host
DEPLOY_USER: op://shop-ci/vps-prod/username
DEPLOY_KEY: op://shop-ci/vps-prod-ssh/private key?ssh-format=openssh
KNOWN_HOSTS: op://shop-ci/vps-prod/known_hosts
- name: 写入 SSH 配置
env:
DEPLOY_KEY: ${{ steps.op.outputs.DEPLOY_KEY }}
KNOWN_HOSTS: ${{ steps.op.outputs.KNOWN_HOSTS }}
run: |
install -m 700 -d ~/.ssh
printf '%s\n' "$DEPLOY_KEY" > ~/.ssh/id_deploy && chmod 600 ~/.ssh/id_deploy
printf '%s\n' "$KNOWN_HOSTS" > ~/.ssh/known_hosts
- name: 同步并重启
env:
DEPLOY_HOST: ${{ steps.op.outputs.DEPLOY_HOST }}
DEPLOY_USER: ${{ steps.op.outputs.DEPLOY_USER }}
run: |
rsync -az --delete -e "ssh -i ~/.ssh/id_deploy" deploy/ "$DEPLOY_USER@$DEPLOY_HOST:/srv/shop/"
ssh -i ~/.ssh/id_deploy "$DEPLOY_USER@$DEPLOY_HOST" 'sudo systemctl restart shop'
- name: 清理密钥
if: always()
run: rm -f ~/.ssh/id_deploy
要点:
- 默认模式下密钥作为步骤输出返回并自动打码;后续步骤通过
env:传入,脚本里用"$DEPLOY_KEY"读取。 ?ssh-format=openssh让私钥以 OpenSSH 格式返回。- 写入 known_hosts 而不是关闭主机密钥检查,防止中间人攻击。
if: always()保证失败时也删除私钥文件。
第 4 步:本地检查
lint_secrets_usage.py:
"""检查 workflow 里 1Password 用法的几条规则:
1. 顶层 permissions 只读
2. OP_SERVICE_ACCOUNT_TOKEN 只能来自 secrets
3. 1Password 引用只能写在 load-secrets 步骤里
4. run 脚本里不能直接写 ${{ steps.*.outputs.* }}(应先放进 env,避免脚本注入和明文展开)
"""
import re, sys
import yaml
def lint(doc):
errs = []
if doc.get("permissions") not in ({"contents": "read"}, "read-all", {}):
errs.append("顶层 permissions 应为 contents: read")
for jname, job in doc.get("jobs", {}).items():
for i, st in enumerate(job.get("steps", []), 1):
where = f"{jname} 第{i}步"
env = st.get("env", {}) or {}
uses = st.get("uses", "")
tok = env.get("OP_SERVICE_ACCOUNT_TOKEN")
if tok is not None and not re.fullmatch(r"\$\{\{\s*secrets\.\w+\s*\}\}", str(tok)):
errs.append(f"{where}: OP_SERVICE_ACCOUNT_TOKEN 必须引用 secrets")
if not uses.startswith("1password/load-secrets-action") and any(str(v).startswith("op://") for v in env.values()):
errs.append(f"{where}: op:// 引用只应出现在 load-secrets 步骤")
if re.search(r"\$\{\{\s*steps\.[^}]*outputs", st.get("run", "")):
errs.append(f"{where}: run 中直接展开了步骤输出,请改用 env")
return errs
if __name__ == "__main__":
errs = lint(yaml.safe_load(open(sys.argv[1], encoding="utf-8")))
print("\n".join("✗ " + e for e in errs) or "检查通过")
sys.exit(1 if errs else 0)
test_lint.py:
import yaml
from lint_secrets_usage import lint
GOOD = yaml.safe_load(open(".github/workflows/deploy.yml", encoding="utf-8"))
def test_good(): assert lint(GOOD) == []
def test_inline_output():
d = {"permissions": {"contents": "read"}, "jobs": {"j": {"steps": [{"run": "echo ${{ steps.op.outputs.DEPLOY_KEY }}"}]}}}
assert "直接展开" in lint(d)[0]
def test_plain_token():
d = {"permissions": {"contents": "read"}, "jobs": {"j": {"steps": [{"uses": "1password/load-secrets-action@v5", "env": {"OP_SERVICE_ACCOUNT_TOKEN": "ops_xxx"}}]}}}
assert "secrets" in lint(d)[0]
pip install pyyaml pytest
python lint_secrets_usage.py .github/workflows/deploy.yml
python -m pytest -q
actionlint .github/workflows/deploy.yml
检查结果(本机实际输出):检查通过、3 passed,actionlint 无输出。
第 5 步:推送并观察(本文未执行)
推送到 main 后,在 Actions 日志里“从 1Password 读取部署凭据”步骤应显示成功,后续步骤中所有密钥都显示为 ***。
调试示例
如果有人把 run 写成:
run: echo "${{ steps.op.outputs.DEPLOY_KEY }}" > ~/.ssh/id_deploy
检查脚本输出:
✗ deploy 第3步: run 中直接展开了步骤输出,请改用 env
${{ }} 表达式会在脚本运行前被替换进脚本文本,含特殊字符时可能破坏命令甚至被注入;先放进 env: 再用 shell 变量读取更安全。测试 test_inline_output 覆盖了这个情况。
出错了怎么办?
| 现象 | 原因 | 解决 |
|---|---|---|
authorization prompt dismissed / 认证失败 |
令牌错误或已撤销 | 重新生成并更新 Secret |
isn't a vault / 找不到条目 |
服务账号没有该保险库权限 | 在服务账号设置里授权 shop-ci |
Load key ... invalid format |
私钥格式不对 | 引用加 ?ssh-format=openssh |
Host key verification failed |
known_hosts 缺失或过期 | 重新 ssh-keyscan 并更新条目 |
| fork 的 PR 拿不到密钥 | GitHub 默认不给 fork PR 传 Secrets | 这是预期的安全行为,部署只在 main 上跑 |
模板规则
- 一个用途一个保险库、一个服务账号,权限只读。
- 密钥通过
env:传给脚本,不在run中展开表达式。 - 部署结束清理私钥文件。
项目资源
| 用途 | 地址 |
|---|---|
| 1Password 服务账号 | https://www.1password.dev/service-accounts |
| 在 GitHub Actions 中加载密钥 | https://www.1password.dev/ci-cd/github-actions |
| load-secrets-action | https://github.com/1Password/load-secrets-action |
| 密钥引用语法(含 ssh-format) | https://www.1password.dev/cli/secret-reference-syntax |
| GitHub Actions 安全使用指南 | https://docs.github.com/en/actions/reference/security/secure-use |
GitHub 里只剩一个令牌,真正的凭据都在 1Password 里集中管理:换服务器密码、轮换 SSH 密钥都不用改仓库,撤销服务账号就能立刻切断 CI 的访问。