跳到正文
DEV Community· Maya Patel·· 3 小時前AI 評分56

固定版本並非多疑:加固發佈 ego-lite 的 CI

Pinning Isn't Paranoia: Hardening the CI That Ships ego-lite

AI 導讀

Maya Patel 介紹了加固 ego-lite 發佈 CI 的做法,將 PR 驗證與主分支發佈分成不同信任區。PR 工作採用唯讀權限、以 commit SHA 固定第三方 Actions,並按事件設定取消策略;主分支發佈則不會中途取消。

正文

The failures that taught me

Two incidents broke the same quarter and cost more than the postmortems admitted.

The first was an unpinned action. We had uses: some-org/setup-toolchain@v1—a tag, not a SHA. On a Tuesday the tag moved. The new version ran a post-install script that read $GITHUB_TOKEN and pushed a commit to a branch we weren't watching. We caught it in ninety minutes because a reviewer noticed a build step named differently in the log. Most teams don't get ninety minutes.

The second was a concurrency fork bomb. Every push to a PR branch triggered a full matrix, nothing cancelled, and a contributor's force-push loop queued 1,100 jobs overnight. Linux runner minutes aren't free at scale—we burned three weeks of quota in six hours. The build wasn't broken. It was just loud.

Both failure modes are invisible in the toy workflows everyone copies. Integrating citrolabs/ego-lite—a toolkit that hands logged-in browser sessions to agent processes—forced me to fix them.

The architecture: two trust zones

The solution isn't more YAML flags. It's splitting the pipeline into zones with different rules.

The pull request gate is untrusted input: read-only permissions, no secrets, aggressive cancellation, and every third-party action pinned to a commit SHA with the version trailing in a comment so humans can still read it.

The publish stage is trusted: runs only on main, inherits secrets, never cancels mid-flight because a half-published artifact is worse than a slow one.

That single split killed both problems. Cancellation becomes a policy decision per event rather than a global flag. The trust boundary is the branch, not the job.

The workflow

name: ci
on:
  pull_request:
  push:
    branches: [main]

concurrency:
  group: ci-${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: ${{ github.event_name == 'pull_request' }}

permissions:
  contents: read

jobs:
  verify:
    runs-on: ubuntu-24.04
    timeout-minutes: 20
    strategy:
      fail-fast: false
      matrix:
        node: [20, 22]
    steps:
      - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
        with:
          persist-credentials: false

      - uses: pnpm/action-setup@fe02b34f77f8bc703788d5817da081398fad5dd2 # v4.0.0
        with:
          version: 9.12.3

      - uses: actions/setup-node@39370e3970a6d050c480ff2309985a1f4e88d4af # v4.1.0
        with:
          node-version: ${{ matrix.node }}
          cache: pnpm

      - uses: actions/cache@6849a6489940f00c2f30c0fb92c6274307d949b9 # v4.1.2
        with:
          path: ~/.cache/ego/browsers
          key: browsers-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
          restore-keys: browsers-${{ runner.os }}-

      - run: pnpm install --frozen-lockfile
      - run: ./scripts/verify.sh
        env:
          COVERAGE_MIN: "85"

      - name: agent smoke test
        if: matrix.node == 22
        run: pnpm exec ego-lite smoke --headless --timeout 45s
        env:
          AGENT_BASE_URL: https://b-lost.com/v1
          AGENT_MODEL: sonnet-latest
          AGENT_API_KEY: ${{ secrets.AGENT_API_KEY }}

Two details worth defending. persist-credentials: false stops checkout from leaving the token in .git/config, where a compromised dependency can lift it in the next step. The cache key is hashFiles('pnpm-lock.yaml') rather than a version string—the browser revision ego-lite resolves is pinned by the lockfile, so any dependency bump invalidates the cache correctly instead of silently serving a stale binary.

The smoke test matters more than people expect. ego-lite's job is handing a logged-in browser session to an agent process. Most breakage shows up as "browser launches, agent gets a blank page"—which no unit test catches. The baseURL points at a staging relay because the point is to catch config regressions in the agent transport, not to test the model.

The gate script

Coverage thresholds in YAML are decoration—people raise them to make a PR green. The script is where the rule lives, and it needs to fail loudly with a number.

#!/usr/bin/env bash
set -Eeuo pipefail

MIN="${COVERAGE_MIN:-85}"

pnpm exec eslint . --max-warnings=0
pnpm exec tsc --noEmit
pnpm exec vitest run --coverage --reporter=dot

COVER=$(node -p "
  const report = require('./coverage/coverage-summary.json');
  Math.floor(report.total.lines.pct)
")

if (( COVER < MIN )); then
  echo "Coverage ${COVER}% below minimum ${MIN}%" >&2
  exit 1
fi

echo "Coverage: ${COVER}% (min: ${MIN}%)"

The number comes from the JSON artifact vitest writes, not from parsing terminal colors. If you want to change the threshold, you change the environment variable in the workflow, not the script. The script stays dumb.

What this actually prevents

The SHA pinning stops supply chain attacks where a tag moves and the new code exfiltrates tokens or injects backdoors. The comment syntax lets reviewers see what version they're pinning without opening a browser. Dependabot can still send PRs to bump the SHA when a new release lands.

The concurrency group with conditional cancellation stops the queue explosion. PR pushes cancel each other. Main branch builds never cancel because interrupting a deploy halfway is worse than letting it finish slow.

The cache keying off the lockfile stops the ghost bug where a dependency bumps the browser version but CI keeps using the cached old binary and tests pass until production blows up.

The smoke test with a real agent relay catches the silent failure where the browser launches, the session initializes, but the transport config is wrong and the agent gets nothing. Unit tests don't see this. Integration tests with mocked endpoints don't see this. You need the real handshake.

The dilemma no one talks about

The hardest part isn't the YAML. It's deciding whether to auto-merge Dependabot PRs for pinned actions or require human review. Auto-merge is faster and keeps you current. Human review is safer but becomes a bottleneck when you're pinning twenty actions and Dependabot sends three PRs a week.

I still don't have a clean answer. We review manually for now, but the toil is real, and I'm not convinced the security theater is worth it when the actions are from actions/* or other GitHub-official namespaces.

What's your policy? Do you trust Dependabot to bump SHAs automatically, or do you make someone look at the changelog every time?

來源:DEV Community · dev.to