Set up in 5 minutes
Install the App, add a policy file, install the CLI. Keep your existing Actions running next to it until the two agree.
1. Create your workspace and GitHub App
Sign in, create a workspace, and click Create GitHub App. GitHub creates the app under your account or organization, so you own it. Install it on the repositories you want. It asks for checks (write), pull requests and issues (write, for the failure comment), actions (write, to start the fallback workflow) and contents (read, for the policy file and lockfiles). Each repo's page in the dashboard has a prompt for your coding agent that does steps 2–3 in one PR.
2. Add .attested.json to your default branch
The dashboard gives your coding agent a prompt that writes this from your existing workflows. A monorepo example:
{
"version": 2,
"caches": ["backend/.venv", "webapp/node_modules"],
"env": { "COMPOSE_FILE": "docker-compose.yml:docker-compose.ci.yml" },
"inputs": {
"backend": ["backend/**", ".tool-versions", "docker-compose.yml", "webapp/.env.example"]
},
"checks": {
"backend-test": {
"shadows": ["test"],
"inputs": ["@backend"],
"toolchain": ["backend/uv.lock"],
"requires": ["uv", "docker", "aws"],
"setup": ["cp -n .env.example .env; true", "scripts/ci/ecr-login.sh", "cd backend && uv sync --frozen"],
"shards": { "api": "cd backend && make test TEST_ARGS='app/tests/api'",
"rest": "cd backend && make test TEST_ARGS='--ignore=app/tests/api'" },
"cleanup": "docker compose down -v --remove-orphans",
"runner": "blacksmith-16vcpu-ubuntu-2404"
},
"migrations-in-sync": { "inputs": ["backend/app/**"], "diff_base": true, "run": "scripts/ci/check-migrations.sh" },
"webapp-test": { "inputs": ["webapp/**"], "setup": "cd webapp && bun install --frozen-lockfile", "run": "cd webapp && bun run test:run" }
},
"sample_rate": 0.1,
"merge_queue": { "run_missing": true }
}
| Field | Meaning |
|---|---|
run / shards | A command or a list of commands, run in order, that stops at the first failure. Or named shards that must all pass, for matrix and sharded jobs. |
inputs | The files the check depends on, using paths-filter globs, ! negations and named groups ("@backend"). The result is keyed on these files only. If none of them changed since the merge base, the check passes as unaffected, the same as a skipped paths-filter job. Without inputs, the check is keyed on the whole tree. |
diff_base | The check compares against the base, for example "the migration history wasn't rewritten". Its key also covers the merge base, and commands get ATTESTED_MERGE_BASE. |
setup, cleanup | Commands to run before the check, and commands that always run after it. A setup failure is an infrastructure error, so nothing is attested. |
requires | Tools the machine needs (docker, uv, bun, aws…). acli doctor checks them. |
caches | Untracked directories kept between local runs. Everything else untracked is wiped, and tracked files always match the commit. |
context | The check name GitHub shows. The default is attested / <id>. At cut-over, set it to the required check it replaces. |
shadows | The Actions check(s) this replaces. Attested compares results per commit and shows the agreement rate in the dashboard. |
runner | The Actions runner label, used by the fallback job and for cost estimates. |
toolchain | Lockfiles and version pins that are part of the key. |
platforms, min_tier, local | Which OSes count. Use "local": false for checks where only a GitHub Actions result counts. |
merge_queue.run_missing | Merge-queue groups whose inputs have no result start the check on your Actions automatically (default true). |
Definitions always come from the base branch. A PR can't change a check's command line and vouch for itself. When a PR edits a check's definition, that check needs a run on GitHub Actions. Scripts the command calls are part of the PR's content, just as they are under GitHub Actions, so keep .attested.json, scripts/ci/ and .github/ under CODEOWNERS.
Merge queue
The App answers merge_group events. A merge group's tree is new, but each check is keyed on its inputs. So a check whose inputs are the same as a PR head that was already attested reuses that result. If none of a check's inputs changed versus the group's base, it passes as unaffected. Only checks whose inputs really changed with the other PRs in the group run, on your Actions runners, and the queue waits for them.
3. Install the CLI and run
curl -fsSL https://attested.apps.tryplinth.dev/install.sh | sh
acli init # writes .attested.json from package.json / go.mod / pyproject
acli hook # pre-push: runs whatever has no result yet for this tree
acli run # or by hand; acli run unit --force to re-run
acli status # verdict per check for HEAD
acli run --cloud # run on your Actions via attested.yml (commit must be pushed)
acli doctor # checks the repo's setup
Identity comes from your GitHub login (gh auth token or GITHUB_TOKEN). The token is only used to check that you have write access. It isn't stored. You get a 10-minute signing certificate in exchange.
4. Shadow mode, then cut over
Keep your Actions jobs running at first. Each check's shadows list names the Actions check it replaces, and the dashboard shows how often the two agreed and how many Actions minutes those jobs used. Once a check agrees on 99% or more of at least 50 commits, the dashboard's cut-over prompt removes the duplicate job and re-points that required check to your App.
Required checks, pinned to your App
In Settings → Rules → Rulesets, add "Require status checks to pass" with attested / lint, attested / unit and so on, and set the source to the Attested CI app. A status posted by a person, a token or an Action with the same name then doesn't count. Also turn on "Require branches to be up to date", so the merged tree is the tree that was attested.
What the PR shows
| Ledger | GitHub check run |
|---|---|
| pass | success, with who, tier, ledger entry and log link |
| fail | failure plus one PR comment (updated in place) with the last 40 log lines |
| missing | action required, with a Run in cloud button |
| revoked | failure. Open PRs that relied on it are re-evaluated. |
If several results exist for one key, the highest tier wins, then the latest. So a red Tier 2 re-run overrides a green Tier 1 claim.
Verify the ledger yourself
acli verify # replays the chain, checks every signature and every Rekor anchor
acli verify --offline # skip the Rekor lookups
Rows are row_hash = sha256(prev_hash ‖ sha256(canonical(entry))). Every 5 minutes the service signs (seq, head_hash) and records it in Sigstore's public Rekor transparency log. If anyone rewrites history, including us with database superuser access, the replay stops matching the anchors. Rows from private repos are published as hashes only, and the chain still verifies.
Revoke
acli revoke --seq 18231 --reason "faked result, confirmed in incident 52"
acli revoke --identity github:mallory --reason "credentials stolen"
Revocations are new ledger rows (repository admins only). Every commit that relied on the revoked result is re-evaluated.
API
POST /api/v1/certs GitHub token + public key → 10-minute signing cert
POST /api/v1/attestations DSSE envelope (+ gzip log) → {seq, row_hash, verdict}
GET /api/v1/verdicts?repo_id&keys=… pass | fail | missing | revoked (+ tier, seq)
POST /api/v1/runs queue Tier 3 "run in cloud"
POST /api/v1/revocations revoke an attestation or identity (new row)
GET /api/v1/ledger?from&limit public chain export
GET /api/v1/checkpoints signed checkpoints with Rekor receipts
GET /api/v1/keys CA and checkpoint public keys
Attestations are in-toto Statement v1 with predicate type https://attested-ci.dev/check-result/v1, so SLSA tooling can read them.