Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Running it in CI

The short form is the published GitHub convenience Action. It carries the engine inside the selected action tree, derives both commits from the triggering event, and turns findings into file feedback on the pull request. It is not the provider-authenticated controller lane:

name: docs
on:
  pull_request:
  merge_group:
  push:
    branches: [main]
permissions:
  contents: read
jobs:
  amiss:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          fetch-depth: ${{ github.event_name == 'pull_request' && 2 || 0 }}
      - id: amiss
        uses: HardMax71/amiss@v0
        with:
          profile: observe
      - if: always()
        uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
        with:
          name: amiss-report
          path: ${{ steps.amiss.outputs.report }}
          if-no-files-found: ignore

The published first run uses observe: introduced problems appear as Fixes without blocking, changed targets appear as Checks, and pre-existing problems stay as pre-existing inventory. An incomplete or untrusted run still fails. Triage the initial report, adopt any repository policy it needs, then drop the input to take the default, enforce-introduced, which blocks what a pull request introduces while the backlog stays warnings. On an unchanged head 52 of the 61 repositories in the corpus fail enforce, which blocks every failing finding, so that profile suits a repository whose backlog is already clear. When the site is built somewhere else the tree carries no generator configuration, so every destination is read against the source files until .amiss/router.yml names the router; What a documentation router serves has the names.

To make the check a merge gate, require the job’s status in the branch’s protection rule or ruleset; the job above reports as amiss. A repository that merges through a merge queue keeps the merge_group trigger, since a required check that never runs on the queue’s group leaves every entry waiting, and the Action derives the group’s base and head on its own.

What the Action does

Before running anything it verifies the selected binary against the release manifest shipped in the same tree. A wall-clock watchdog backstops the engine’s resource ceilings, and a scan that outlives the window is ended so the job fails with no result, never a verdict. The window defaults to 120 seconds, far above a real scan: this repository takes 0.26 seconds and a 1,620-document Docusaurus site 4.2 seconds. The job fails on exit classes 1 and 2, and under the default enforce-introduced profile only a finding the change introduced makes class 1. The outputs exit-class and report expose the verdict class and the JSON report path for anything downstream; the file sits in the runner’s temp directory, which is why the workflow above uploads it as the amiss-report artifact. A run that ends before the engine could scan, on an unsupported runner or a checkout missing a commit, reports exit class 2, and the path then names a file that was never written, which the upload step’s if-no-files-found: ignore tolerates.

InputDefaultRole
profileenforce-introducedobserve reports without blocking, enforce-introduced blocks what the change introduces and warns on the backlog, enforce blocks every failing finding
basederivedfull commit ID, overrides the event derivation
candidatederivedfull commit ID, overrides the event derivation
repo.repository root inside the workspace
object-formatsha1or sha256
annotationstruedisplayed Fixes and Checks, and scan errors, become file annotations
watchdog-seconds120wall-clock window before the scan is ended

When base and candidate stay empty, the event supplies them:

EventBaseCandidate
pull_requestthe candidate’s own first parentthe merge result
pull_request_targetthe payload’s base tipthe pull request’s head
merge_groupthe group’s base committhe group’s head
pushthe event’s beforethe pushed head

The first parent is deliberate: the payload’s base tip races the merge ref GitHub rebuilds lazily after a base branch moves, while the first parent is exactly the base the test merge was built from. Both commits must exist in the checkout. At the checkout default of depth 1 the Action deepens the checkout by one commit from its own head before it gives up, which reaches the merge commit’s parents and a single-commit push, so fetch-depth: 2 only saves a round trip; a push of several commits needs fetch-depth: 0, which the workflow above gives pushes. A pull_request_target checkout is the base branch, so the head is absent by design and the Action never fetches it: give actions/checkout ref: ${{ github.event.pull_request.head.sha }} with fetch-depth: 0 for that event.

The identity host comes from the event’s server URL, so on GitHub Enterprise Server the report claims the instance’s own host and recognizes that host’s blob and tree links, with the github dialect declared explicitly. Release assembly supplies the host the same way, to a manifest builder that stores an open build-source identity instead of assuming github.com; the release workflow is a checkable example of that input. The report and request formats are forge-neutral.

Pinning the Action

The moving major ref follows the engine’s semver major, v0 for the 0.x series and v1 from 1.0.0 on, so one series can never rewrite another’s ref. A vX.Y.Z source tag is an immutable exact pin whose dispatcher delegates to the equally immutable action/vX.Y.Z runtime tag; a source commit pins the dispatcher but still makes that second hop. Pin action/vX.Y.Z directly, or its generated Action commit, when policy requires the complete runtime tree in one ref.

pinsmajorv0moving major refsourcevX.Y.Zimmutable source tagmajor->sourcefollows thelatest releaseruntimeaction/vX.Y.Zimmutable runtime treesource->runtimedispatcherdelegates

Invoking the engine directly

The long form is useful outside GitHub Actions or when a workflow constructs the exact evaluation itself. Amiss’s own self-scan workflow builds the pull request’s engine, assembles a local action tree with its manifest, and runs that composite under --profile enforce. A minimal adjacent-commit direct invocation is:

- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
  with:
    fetch-depth: 2
    persist-credentials: false
- run: cargo install --locked --registry crates-io --version '=<reviewed-version>' amiss
- env:
    REPOSITORY: ${{ github.repository }}
    BRANCH: ${{ github.base_ref || github.ref_name }}
    DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
  run: |
    amiss check --repo . --object-format sha1 \
      --base "$(git rev-parse HEAD~1)" \
      --candidate "$(git rev-parse HEAD)" \
      --repository "github.com/${REPOSITORY,,}" \
      --ref "refs/heads/${BRANCH}" \
      --default-branch-ref "refs/heads/${DEFAULT_BRANCH}" \
      --profile observe --format json > amiss-report.json

Replace <reviewed-version> with the exact release you reviewed. The leading = makes the Cargo requirement exact, Cargo checks the crate archive against the crates.io index checksum, and --locked refuses to recompute the packaged lockfile, so the command pins both the released crate and its dependency graph. The placeholder is deliberately release-independent. In a pull request --ref names the branch it merges into: the candidate is what that branch will hold after the merge, so a link pinned to it resolves against the candidate, and a link that breaks fails the pull request rather than the merge. Repository and branch names travel through environment variables because a branch can be named anything and text pasted into a shell script becomes code; the owner is lowercased in shell because GitHub hands it over with its registered capitals and Amiss refuses anything but lowercase. A scan is a pure function of the two snapshots and the invocation, so there is no baseline cache to warm between runs. As with the Action, graduate to --profile enforce once the first report is triaged.

The external rail extends any direct invocation into web evidence, advisory: derive the plan from the written report, probe its introduced destinations, and judge through the assessment. Amiss runs exactly this chain on its own pull requests, in the external-advisory job of the same workflow linked above, with every defect degrading into one summary line rather than a failed check:

- env:
    GH_TOKEN: ${{ github.token }}
  run: |
    gh release download v<reviewed-version> --repo HardMax71/amiss --pattern amiss-probe-linux-x86_64
    gh attestation verify amiss-probe-linux-x86_64 --repo HardMax71/amiss \
      --signer-workflow HardMax71/amiss/.github/workflows/release.yml
    chmod +x amiss-probe-linux-x86_64
- run: |
    amiss external-plan --report amiss-report.json --format json > amiss-plan.json
    ./amiss-probe-linux-x86_64 --plan amiss-plan.json > amiss-evidence.json
    amiss external-assess --plan amiss-plan.json --evidence amiss-evidence.json

The assessment refutes only what a probe or forge API positively disproved, so its rows are telemetry to read, not a gate to wire, until the rates have earned that. Its human summary also windows permanent-redirect retarget suggestions; temporary redirects remain evidence and never become edit suggestions. The external assessment has the recipe that probes every destination rather than the introduced ones. The prober ships beside the engine in every release, in the same SHA256SUMS and sigstore bundle, so the download above is the same Verified consumption recipe with the pattern changed. A release cut before the prober has no such asset; there the source build cargo build --locked --release -p amiss-probe stands in, which is also what the dogfood job runs so it probes with the pull request’s own prober.

That dogfood job uses GitHub’s cache only to replay successful, nonempty evidence when the same workflow run is retried. The exact generation key carries the cache schema and probe options, runner platform, immutable run and commit identities, and the attempt number. Restore fallback is confined to that prefix, so only an older generation from the same run and commit can match. The first attempt restores nothing, and empty evidence saves nothing. A restored file is still untrusted input: external-assess must accept its exact plan binding before the probe is skipped. A miss, cache outage, or invalid body runs the probe again; nonempty corrected evidence is saved as the current attempt’s generation for later retries. Every attempt derives the assessment locally. A new workflow run therefore never inherits an observation from the old one, and the cache never becomes a baseline or changes the advisory policy.

The SARIF projection turns the same run into GitHub code-scanning alerts on the lines the findings name. Two steps after any direct invocation, in a job whose permissions block adds security-events: write beside contents: read:

- run: amiss check <the check flags above> --format sarif > amiss.sarif
- uses: github/codeql-action/upload-sarif@24c7eb380a2dc368f2d129e4c65e51d172983a1e # v4
  with:
    sarif_file: amiss.sarif
    category: amiss

The category keeps Amiss’s alerts distinct from any other SARIF producer in the repository. The uploaded rows are ordinary code-scanning alerts, so GitHub’s remediation surfaces operate on them directly, agentic autofix included. What each result carries is stated in The report.

Code scanning reads less of the file than the file holds, in three ways. It renders no SARIF fixes, so the replacement a finding carries reaches amiss fix and other SARIF viewers but never the alert. It tracks an alert by the line hash upload-sarif computes and ignores every other fingerprint, so the finding key does not follow an alert across runs, and two findings of one kind on one line are one alert. And a pull request shows an alert only when its line is in the diff, while a drift finding usually sits in a document the change never touched. The Action’s annotations and job summary list those, so keep the Action or the human output beside the upload rather than in place of it.

On GitLab the whole job ships as a pinned template. GitLab’s CI/CD Catalog only serves components hosted on a GitLab instance, so a GitHub-hosted project publishes the honest equivalent: a template consumed as a remote include from a tagged URL.

include:
  - remote: https://raw.githubusercontent.com/HardMax71/amiss/v<reviewed-version>/integrations/gitlab/amiss.gitlab-ci.yml

variables:
  AMISS_VERSION: v<reviewed-version>

Both pins name the release you reviewed and move together, and the job’s image is pinned by digest. The template runs on merge-request pipelines and on the default branch, refuses to run with exit 2 until AMISS_VERSION is set, and verifies the downloaded binary against the release’s SHA256SUMS before executing it. It scans under AMISS_PROFILE (observe until the first report is triaged, the same ramp as everywhere else). A merge request compares its diff base with its head, a merged-results or merge-train pipeline compares its merge commit with that commit’s first parent, so drift already on the target is not charged to the merge request, and the default branch compares each push with the commit before it; a project’s first commit has none, so its checkout is read whole through --index. It declares the project’s identity from GitLab’s own variables, the server host and the lowercased project path with the gitlab dialect, the branch a merge request targets as --ref, and the default branch, so a URL into the project’s own files is checked like a relative link rather than left external. It renders Code Quality and JUnit from that same validated report without a second scan, and uploads three artifacts: the exact JSON report, a Code Quality report rendered in the merge-request widget and inline on the diff, and a JUnit report for the test widget. The fingerprint is the finding key, so the widget’s new-versus-resolved diff, taken against the default branch’s own report, follows the same identity the report uses. An exit-2 run writes no Code Quality rows, since the widget would read an empty file as a clean run; the JUnit report carries the error instead. This is rendering, not the trust lane: a blocking run still fails the job by exit class, and the provider-verified gate is the GitLab policy lane.

On Gitea and Forgejo the published Action runs unchanged, and it reads the Gitea URL dialect there, since both runners say which they are. Gitea Actions resolves uses: references through github.com by default, so the same two steps shown at the top of this page work in a .gitea/workflows/ file verbatim: verified on Gitea 1.24.7 with act_runner 0.6.1, where a broken reference failed the job with the engine’s exit class and its file annotation, and the repaired push went green. This is the convenience surface, not the Gitea and Forgejo provider lane, whose own floor is stated there.

Reading a run

When a run blocks, use the grouped feedback to orient, then read the exact JSON findings for repair evidence. The Action and human views show at most ten Fix and Check items combined, in engine order, then at most ten pre-existing items in a window of their own, each window with its own overflow line, so a run with many new Fixes still names the backlog item that blocks it. A displayed Fix or Check with a candidate text location becomes a file annotation at the citing line, a Fix as an error or warning by its disposition and a Check as a notice, so a changed target is seen in the diff without failing anything. Pre-existing inventory stays in the summary and report, and every control byte a path carries reaches the annotation escaped. If the scan failed, feedback is unavailable and at most ten retained errors are annotated instead. The blocking rows remain the report’s errors and findings whose effective_disposition is fail, and the complete grouped and raw sets always remain in the report. The Action’s report output names that JSON file, so a later step reads it in place without rerunning anything. One step lists every grouped PR item with its target and affected-place count:

- if: always()
  env:
    REPORT: ${{ steps.amiss.outputs.report }}
  run: |
    [ -s "$REPORT" ] || exit 0
    jq -r '.payload.feedback
      | select(.status == "available")
      | .items[]
      | [.action, .effective_disposition,
         ((.target | strings) // "-"), .location_count]
      | @tsv' "$REPORT"

What this surface is not

The Action invokes the public command: its branch is the candidate ref used for URL resolution, its report target ref is null, and it does not acquire provider-authenticated external controls, invoke the sealed bootstrap path, or publish through an independently authenticated integration. Caller-supplied identity fields never become provider authority. The authenticated lanes are separately operated source-built services: a GitHub App publishing an App-owned Check Run on the authoritative test merge, a GitLab pipeline execution policy job authenticated through OIDC, and a dedicated Gitea or Forgejo reviewer required by the effective branch rule. Provider-verified controls compares those lanes and links their setup, and Controller delivery documents the shared retry record; the GitHub lane’s own page is GitHub provider lane.

Before a commit exists

The same check runs on the staged index. The repository publishes a pre-commit hook that scans the staged state against HEAD with the amiss binary on the path (cargo install --locked amiss, at the version CI reviews). The hook runs on every commit, since a staged rename breaks links in files the commit never touched, and it runs under enforce-introduced unless told otherwise: what the commit introduces blocks and an older backlog only warns. It reads the index Git hands the hook, so git commit -a and git commit <path> are checked as they will commit. args picks the profile:

repos:
  - repo: https://github.com/HardMax71/amiss
    rev: v<reviewed-version>
    hooks:
      - id: amiss
        args: [--profile, enforce]

Replace v<reviewed-version> with the exact release you reviewed, the same convention as every version pin on this page. When the staged check reports fixes, amiss fix applies them to the working tree in place; restage and the same hook judges the repaired state.

Last change: , commit: 0987dade