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 }
}
FieldMeaning
run / shardsA 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.
inputsThe 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_baseThe 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, cleanupCommands to run before the check, and commands that always run after it. A setup failure is an infrastructure error, so nothing is attested.
requiresTools the machine needs (docker, uv, bun, aws…). acli doctor checks them.
cachesUntracked directories kept between local runs. Everything else untracked is wiped, and tracked files always match the commit.
contextThe check name GitHub shows. The default is attested / <id>. At cut-over, set it to the required check it replaces.
shadowsThe Actions check(s) this replaces. Attested compares results per commit and shows the agreement rate in the dashboard.
runnerThe Actions runner label, used by the fallback job and for cost estimates.
toolchainLockfiles and version pins that are part of the key.
platforms, min_tier, localWhich OSes count. Use "local": false for checks where only a GitHub Actions result counts.
merge_queue.run_missingMerge-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

LedgerGitHub check run
passsuccess, with who, tier, ledger entry and log link
failfailure plus one PR comment (updated in place) with the last 40 log lines
missingaction required, with a Run in cloud button
revokedfailure. 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.