效率工具

用 1Password 服务账号给 GitHub Actions 提供部署凭据

用 1Password 服务账号给 GitHub Actions 提供部署凭据:服务器地址、用户名、SSH 私钥和 known_hosts 都放在 1Password 的 CI 专用保险库,GitHub 只保存一个服务账号令牌;推送到 main 后自动 rsync 到 VPS 并重启服务。附带一个 workflow 检查脚本,防止常见的密钥泄露写法。

BINBHO · 图文教程

用 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 步:准备保险库和条目

  1. 新建保险库 shop-ci。
  2. 条目 vps-prod:字段 host、username、known_hosts(在本机运行 ssh-keyscan 服务器地址 得到)。
  3. 条目 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 的访问。