Evaluate before CI loads credentials
A fork pull request shared a job with configure-cloud-credentials. Nobody had stolen anything — the PR was a typo fix. GitHub already withholds secrets from pull_request on forks. That was not the hole. The workflow mixed untrusted checkout with a credential step (pull_request_target / same job as tests). If the next PR had been worse, production credentials could have been minted where untrusted code ran. The gap was evaluate-shaped, not a breach.
Separate untrusted work from credentials
Teams mix untrusted work and credential work in one workflow, enable pull_request_target because tests "need secrets," or load production credentials before classifying the trigger. GitHub's primitives are repo- and environment-level. They are not an evaluate of ci_privilege vs deploy.
Coding agents make the typo-PR shape ordinary. Without evaluate before secrets, a confused agent can hammer a deploy path that should have been deny or approval_required.
Evaluate before secrets, production credentials, or prod credentials
Carry owner/repo@sha, the GitHub event name, and the action type (ci_privilege for tests/lint, deploy for production publish). Policy returns allow, deny, or approval_required. On approval_required, fail closed until an operator approves.
- uses: limetry/limetry/packages/ci@main
with:
limetry_api_key: ${{ secrets.LIMETRY_API_KEY }}
limetry_base_url: ${{ vars.LIMETRY_BASE_URL }}
policy_id: ${{ vars.LIMETRY_CI_POLICY_ID }}
action_type: deploy
require_trusted: "true"
If the event is pull_request (untrusted) and require_trusted is set, the step fails immediately — before configure-cloud-credentials or docker login. The failing step produces a structured error: "action_type deploy is denied; event pull_request is not trusted."
Split ci-privilege and deploy policies
- ci-privilege.json — allows
ci_privilege(tests, typecheck, lint). Deniesdeploy. Used in the quality-gate workflow every PR triggers. - deploy.json — allows
ci_privilegeanddeploy. Only reached by workflows that require a trusted event (pushto main,release, orworkflow_dispatch).
PRs evaluate one intent and pass. Production deploy requires both a trusted trigger and an explicit policy allow.
Classify GitHub events
@limetry/ci classifies events against an explicit trusted allowlist:
- Trusted:
push,release,workflow_dispatch - Untrusted (named):
pull_request,pull_request_target,pull_request_review,pull_request_review_comment,issues,issue_comment,workflow_run - Everything else, including
scheduleand future events: untrusted by default. Arefofrefs/pull/*is also untrusted regardless of event name.
require_trusted: true on a deploy step fails the job before configure-cloud-credentials.
Use the composite action or the library
@limetry/ci (open source) ships as a GitHub composite action and a TypeScript library. The composite action can boot a local Limetry node in the job, apply policy JSON, and evaluate. The library exposes evaluateCiPrivilege() for custom scripts or a remote self-run evaluate endpoint.
import { evaluateCiPrivilege } from "@limetry/ci"
const result = await evaluateCiPrivilege({
apiKey: process.env.LIMETRY_API_KEY!,
policyId: process.env.LIMETRY_POLICY_ID!,
agentId: "github_actions",
actionType: "deploy",
repository: "acme/api",
sha: process.env.GITHUB_SHA!,
eventName: "push",
})
if (result.evaluation.decision === "deny" || result.trust === "untrusted") {
process.exit(1)
}
Lessons from dogfooding
We run @limetry/ci on the Limetry monorepo. Quality-gate evaluates ci_privilege (must allow) and deploy (must deny). Deploy evaluates deploy with require_trusted: true before cloud deploy credentials load.
Keep the gate fast. Starting a Limetry node inside the job adds seconds. Build the server before the gate step, or point limetry_base_url at a standing self-run evaluate endpoint to avoid local node startup.
Write clear failure messages. Contributors should see: "Limetry denied action_type=deploy because event=pull_request is untrusted."
Stage before required checks. Run as a non-required job, read decisions from audit, then make the check required at the branch-protection layer. The action still fails the step on deny.
Branch protection and environment rules remain. Limetry is the evaluate layer on top: allow ci_privilege on any event, but deploy needs a trusted event and policy allow, with privacy-safe audit across repos.
Related: The Copilot PR burst that nearly shipped to prod and Staging the CI gate.
