Skip to content
BETAGuardrails are in beta. The library, the configuration format and the CLI command can still change.

Guardrails / Collectors

GitHub

What GitHub itself reads out of the repository: the Actions workflows in .github/workflows, read as they are written, and the CODEOWNERS file, read as rules. For a workflow, which events start it, what token permissions it hands a job, which runner each job asks for, and every action a step reaches for and how tightly it is pinned. For CODEOWNERS, every pattern in file order with the owners it names, and the owners that apply to the paths a guardrail asks about, resolved the way GitHub resolves them, where the last rule to match a path is the one that owns it. Names and shapes only, never a secret, an input value or an environment value.

Facts keygithub
Versionv1
Scriptgithub.py
Timeout60 seconds

Inputs

InputDescriptionDefaultEnvironment
workflowsComma separated globs matched against every file in the working directory and against bare file names, naming the workflows to read. Narrows what discovery finds; a file it matches that is not under .github/workflows is still left out..github/workflows/*.yml,.github/workflows/*.yamlGUARDRAIL_INPUT_WORKFLOWS
pathsComma separated paths to resolve owners for, relative to the repository root. A guardrail that cares about the files a pull request changed names them here.README.md,LICENSEGUARDRAIL_INPUT_PATHS

A guardrail that declares an input of the same name passes it through, so the guardrail's configuration in buildnote.json is what decides these values.

A guardrail asks for these facts by name, and reads them back the same way:

json
{
  "collect": ["github"]
}
python
github = guardrail.facts("github")

Facts

The document github collects. A path carrying [] is an entry of the list before it, and one carrying [path] is a key of the object before it.

FactMeaning
sourcereport when a workflow or a CODEOWNERS file was read, none when neither was.
reasonWhy nothing was read, present only when source is none.
reportsEvery file that was read, workflows in path order and the CODEOWNERS file after them.
reports[].pathPath of the workflow, relative to the directory the CLI runs in.
reports[].modifiedWhen it was last written, ISO 8601.
reports[].formatworkflow for an Actions workflow, codeowners for the CODEOWNERS file.
workflowsThe workflows themselves, one entry per file that was read. Absent when the repository carries none, so a check reads its absence rather than an empty list.
workflows[].pathPath of the file, relative to the directory the CLI runs in.
workflows[].nameName the workflow gives itself, or null when it names none.
workflows[].triggersThe on keys: the events that start the workflow.
workflows[].permissionsThe workflow level permissions value, exactly as written. null when the file declares none, which is the token being whatever the repository defaults to rather than an empty set.
workflows[].defaultsThe workflow level defaults value, exactly as written. null when there is none.
workflows[].concurrencyWhether the file declares a workflow level concurrency group, so an in flight run is cancelled or queued rather than racing.
workflows[].jobsThe jobs the workflow declares.
workflows[].jobs[].idKey the job is declared under, which is what needs names.
workflows[].jobs[].nameDisplay name the job gives itself, or null.
workflows[].jobs[].runsOnThe runs-on value, exactly as written: a label, a list of labels or a runner group.
workflows[].jobs[].selfHostedfalse when the runner is one of GitHub's hosted images, true when it is explicitly self-hosted or names no hosted image, and null when the label is an expression.
workflows[].jobs[].permissionsThe job level permissions value, exactly as written. null when the job declares none and inherits whatever the workflow or the repository gives it.
workflows[].jobs[].timeoutMinutesThe declared timeout-minutes, exactly as written, or null when the job declares none and takes GitHub's six hour default.
workflows[].jobs[].environmentThe deployment environment the job runs in, exactly as written, or null. An environment is what carries a protection rule and its own secrets.
workflows[].jobs[].needsJobs this one waits for.
workflows[].jobs[].ifThe raw condition guarding the job, or null.
workflows[].jobs[].usesThe reusable workflow this job calls instead of declaring steps, exactly as written, or null.
workflows[].jobs[].secretsUsedNames of the secrets referenced anywhere in the job, and the keys of a secrets block it passes on. Names only, never values: the facts are attached to the run event.
workflows[].jobs[].stepsThe steps the job declares, in order.
workflows[].jobs[].steps[].nameDisplay name of the step, or null.
workflows[].jobs[].steps[].usesThe action the step runs, exactly as written, or null for a step that only runs a script.
workflows[].jobs[].steps[].actionThe part of uses before the @: owner/repo with any subdirectory for a published action, the path for a ./ local action, the image for a docker:// action.
workflows[].jobs[].steps[].refWhat follows the @ in uses, whatever it names, or null when it names nothing. A tag, a branch and a sha are indistinguishable here beyond pinned.
workflows[].jobs[].steps[].pinnedWhether ref is a 40 character hex commit sha, which is the only ref a tag cannot be moved out from under.
workflows[].jobs[].steps[].localWhether the action is a ./ path in this repository rather than a published one.
workflows[].jobs[].steps[].dockerWhether the action is a docker:// image rather than a published one.
workflows[].jobs[].steps[].runThe shell body the step runs, truncated to 2000 characters, or null.
workflows[].jobs[].steps[].shellThe shell the step declares for itself, or null when it takes the default the workflow or GitHub sets.
workflows[].jobs[].steps[].withKeysNames of the inputs the step passes to the action. Names only, never values.
workflows[].jobs[].steps[].interpolationsEvery ${{ }} expression appearing in the run body, read from the whole body before it was truncated. An expression interpolated into a shell body is the script injection surface.
triggersEvery event declared across the workflows that were read.
untrustedTriggersOne entry per workflow and trigger that runs with the repository's own token on a change somebody outside it wrote: pull_request_target, issue_comment and workflow_run.
untrustedTriggers[].pathPath of the workflow declaring it.
untrustedTriggers[].triggerThe trigger itself.
counts.workflowsWorkflows that were read.
counts.jobsJobs across every workflow.
counts.stepsSteps across every job.
unparsedFiles that matched but were not read, because the YAML they are written in is outside the subset the collector can represent. A file here is not a file with nothing in it: nothing was read from it and no verdict should be drawn about it.
unparsed[].pathPath of the file that was not read.
unparsed[].reasonThe construct that could not be represented, and the line it is on.
codeownersThe CODEOWNERS file the repository carries. Absent when it carries none at any of the three locations GitHub reads one from, so a check reads its absence rather than an empty rule list.
codeowners.pathPath of the CODEOWNERS file that was read: the first of .github/CODEOWNERS, CODEOWNERS and docs/CODEOWNERS that exists, which is the order GitHub searches them in. A repository carrying more than one of them is owned by the first, and the rest are text GitHub never reads.
codeowners.rulesEvery rule the file declares, in file order, which is the order GitHub resolves them in.
codeowners.rules[].patternPattern exactly as it is written, so an anchored /docs/ reads differently from a docs/ matched at any depth. A bracketed line such as [Docs] is a pattern too, not a heading: GitHub CODEOWNERS has no section headers, so the line is resolved like any other rule and owns whatever it matches.
codeowners.rules[].ownersOwners the rule names, as GitHub users (@dana), GitHub teams (@acme/platform) or email addresses. Empty when the rule names none, which removes ownership from everything it matches.
codeowners.rules[].lineLine the rule is declared on, so a verdict points at the file.
codeowners.ownersEvery owner the file names, by owner, with how many rules name them.
codeowners.matchedOwners GitHub resolves for each path the paths input named, by the path exactly as it was given. A path nobody asked about is absent, so read the ones the guardrail configured rather than iterating.
codeowners.matched[path].ownersOwners the last rule to match names, because the last match is what GitHub gives the path. Empty when no rule matched, and empty when the last one to match names no owners.
codeowners.matched[path].rulePattern of the last rule that matched, or null when none did.
codeowners.matched[path].lineLine that rule is declared on, or null when none matched.
codeowners.unparsedEvery line that is not a rule, in file order. Comments and blank lines are not among them, because GitHub ignores them rather than misreading them.
codeowners.unparsed[].lineLine that could not be read as a rule.
codeowners.unparsed[].reasonWhy it could not be read as one.

A collector that cannot finish prints what it had along with an incomplete key saying why, so the facts below the point it stopped are absent. A check that cares reads incomplete before the rest.

Example facts

The example facts collected by github collector:

json
{
  "source": "report",
  "reports": [
    {
      "path": ".github/workflows/build.yml",
      "modified": "2026-03-04T10:15:00Z",
      "format": "workflow"
    },
    {
      "path": "CODEOWNERS",
      "modified": "2026-03-04T10:15:00Z",
      "format": "codeowners"
    }
  ],
  "workflows": [
    {
      "path": ".github/workflows/build.yml",
      "name": "Build",
      "triggers": [
        "push",
        "pull_request"
      ],
      "permissions": {
        "contents": "read"
      },
      "defaults": null,
      "concurrency": false,
      "jobs": [
        {
          "id": "build",
          "name": "Build and test",
          "runsOn": "ubuntu-latest",
          "selfHosted": false,
          "permissions": {
            "contents": "read"
          },
          "timeoutMinutes": 15,
          "environment": null,
          "needs": [],
          "if": null,
          "uses": null,
          "secretsUsed": [],
          "steps": [
            {
              "name": "Check out the repository",
              "uses": "actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8",
              "action": "actions/checkout",
              "ref": "08c6903cd8c0fde910a37f88322edcfb5dd907a8",
              "pinned": true,
              "local": false,
              "docker": false,
              "run": null,
              "shell": null,
              "withKeys": [],
              "interpolations": []
            },
            {
              "name": "Set up the JDK",
              "uses": "actions/setup-java@v4",
              "action": "actions/setup-java",
              "ref": "v4",
              "pinned": false,
              "local": false,
              "docker": false,
              "run": null,
              "shell": null,
              "withKeys": [
                "distribution",
                "java-version"
              ],
              "interpolations": []
            },
            {
              "name": "Build",
              "uses": null,
              "action": null,
              "ref": null,
              "pinned": false,
              "local": false,
              "docker": false,
              "run": "./gradlew check",
              "shell": null,
              "withKeys": [],
              "interpolations": []
            }
          ]
        }
      ]
    }
  ],
  "triggers": [
    "push",
    "pull_request"
  ],
  "untrustedTriggers": [],
  "counts": {
    "workflows": 1,
    "jobs": 1,
    "steps": 3
  },
  "codeowners": {
    "path": "CODEOWNERS",
    "rules": [
      {
        "pattern": "*",
        "owners": [
          "@acme/platform"
        ],
        "line": 1
      },
      {
        "pattern": "*.md",
        "owners": [
          "@acme/docs",
          "docs@acme.example"
        ],
        "line": 3
      },
      {
        "pattern": "/service/",
        "owners": [
          "@acme/service-team"
        ],
        "line": 5
      },
      {
        "pattern": "/service/build/",
        "owners": [],
        "line": 7
      }
    ],
    "owners": {
      "@acme/platform": 1,
      "@acme/docs": 1,
      "docs@acme.example": 1,
      "@acme/service-team": 1
    },
    "matched": {
      "README.md": {
        "owners": [
          "@acme/docs",
          "docs@acme.example"
        ],
        "rule": "*.md",
        "line": 3
      },
      "LICENSE": {
        "owners": [
          "@acme/platform"
        ],
        "rule": "*",
        "line": 1
      }
    },
    "unparsed": []
  },
  "unparsed": []
}

Collected for

GuardrailCategoryInputs
github/actions-pinned-by-digestgithubworkflows
github/codeowners-catch-allgithubworkflows
github/codeowners-no-unowned-rulesgithubworkflows
github/codeowners-owners-per-rulegithubworkflows
github/codeowners-parsesgithubworkflows
github/codeowners-presentgithubworkflows
github/codeowners-team-ownedgithubworkflows
github/job-timeout-setgithubworkflows
github/least-privilege-tokengithubworkflows
github/no-script-injectiongithubworkflows
github/no-untrusted-checkoutgithubworkflows

All collectors

Buildnote Limited
Registered in England and Wales, Reg: 16140412