Skip to content

Latest commit

 

History

History
542 lines (430 loc) · 46.4 KB

ci-platforms.md

File metadata and controls

542 lines (430 loc) · 46.4 KB
layout default
title CI/CD Platform Integration Guide
permalink /guides/ci-platforms/

CI/CD Platform Integration Guide

This how-to guide walks through integrating discipline into major continuous integration, GitOps, and local developer workflows. Discipline runs as a single static binary with no runtime dependencies and communicates with CI runners via standard exit codes, SARIF reports, JUnit XML, and GitLab Code Quality JSON. A pull-request run with a resolvable merge base makes no network requests. Three cases do: the opt-in forge features (issue state, bench citation freshness, require_approval, replay, doctor, comment); a push run with no pull-request body, where the default merged-pr-body directive source asks the forge API for the merged pull request; and a CI run (CI, GITHUB_ACTIONS, GITLAB_CI, GITEA_ACTIONS or FORGEJO_ACTIONS set) whose merge base does not resolve, where the binary runs git fetch for the base and unshallows the clone. DISCIPLINE_NO_NETWORK=1 keeps the forge requests off the network; it does not stop that git fetch. See Forge access. A gate only protects a branch when the platform refuses merges that fail it: see Repository Protection.


1. GitHub Actions

Discipline provides an official composite GitHub Action at orieg/discipline@v0. Because it is implemented as a pure composite action wrapping static native binaries, it requires no Node.js or Docker setup on the runner. The action writes workflow annotations, a step summary and step outputs; a pull-request comment needs comment: true, and a SARIF file needs DISCIPLINE_REPORT_SARIF plus an upload step (below).

A repository that runs the default ci-integrity gate must pin third-party actions by commit SHA, so adding uses: orieg/discipline@v0 is itself an Unpinned Third-Party Action error. Pin the action to a commit; a SHA ref runs the binary of the release that commit's Cargo.toml names (version: under with: picks another):

      - uses: orieg/discipline@<commit-sha> # v0.29.0

The examples below use @v0 for readability.

Basic Pull Request Sentinel

Create .github/workflows/discipline.yml:

name: Discipline Sentinel

on:
  pull_request:
    types: [opened, synchronize, reopened, edited]

jobs:
  discipline:
    name: Verify Invariants
    runs-on: ubuntu-latest
    permissions:
      contents: read
      security-events: write # Required to upload SARIF

    steps:
      - name: Checkout Code
        uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          fetch-depth: 0 # Full history required to reach merge base

      - name: Run Discipline Gatekeeper
        uses: orieg/discipline@v0
        env:
          DISCIPLINE_REPORT_SARIF: results.sarif

      - name: Upload SARIF Security Findings
        if: always()
        uses: github/codeql-action/upload-sarif@v3
        with:
          sarif_file: results.sarif

Advisory Mode for Brownfield Adoption

When evaluating Discipline on high-velocity repositories with legacy technical debt, run in advisory mode to generate reports without breaking builds:

      - name: Run Discipline (Advisory Mode)
        uses: orieg/discipline@v0
        with:
          advisory: true

Adding advisory: true is itself a ci-integrity finding (ci-integrity/discipline-action-advisory, "Discipline Action Weakened (advisory: true)"), since the step then exits 0 whatever the gates report. Record the decision in the pull-request body with allow-gate-weakening: ci-integrity <reason>.


Rollup skip-set wiring (ci-skip-set)

A rollup job that accepts skipped as passing cannot tell "skipped because the change did not touch it" from "skipped because change detection emitted all-false". ci-skip-set checks the skip set against the data the rollup saw. The rollup supplies it; nothing is fetched.

jobs:
  detect-changes:
    runs-on: ubuntu-latest
    outputs:
      rust-src: ${{ steps.filter.outputs.rust-src }}
      docs: ${{ steps.filter.outputs.docs }}
    steps:
      - uses: actions/checkout@<sha> # v4
      - id: filter
        uses: dorny/paths-filter@<sha> # v3
        with:
          filters: |
            rust-src: ['src/**', 'Cargo.*']
            docs: ['docs/**']

  test:
    needs: detect-changes
    if: needs.detect-changes.outputs.rust-src == 'true'
    runs-on: ubuntu-latest
    steps: [ ... ]

  docs-lint:
    needs: detect-changes
    if: needs.detect-changes.outputs.docs == 'true'
    runs-on: ubuntu-latest
    steps: [ ... ]

  ci-gate:
    needs: [detect-changes, test, docs-lint]
    if: always()
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@<sha> # v4
        with:
          fetch-depth: 0
      - uses: orieg/discipline@v0
        with:
          suite: integrity
          # The rollup's own view of its dependencies: results and outputs.
          ci_context: ${{ toJson(needs) }}
[gates.ci-skip-set]
workflow = ".github/workflows/ci.yml"   # default: the running workflow
change_job = "detect-changes"           # must have concluded `success`
unconditional_jobs = ["ci-gate"]        # may never be `skipped`

What the gate asserts from that context: the change-detection job succeeded; every job in unconditional_jobs ran; and for every job in the rollup's needs, skipped holds exactly when its if: evaluates false under the observed outputs and results. The evaluator models needs.<job>.result, needs.<job>.outputs.<key>, the status functions always(), success(), failure() and cancelled(), comparisons (==, !=), &&, ||, ! and parentheses, with or without the ${{ }} wrapper. An if: term outside that set is a finding, never a guess; a job whose if: mixes literal text and ${{ }} is one too. toJson(needs) carries only the jobs the rollup names in needs, so a conditional job the rollup does not depend on is outside the check: add it to needs.

Benchmark job wiring (bench-regression)

Measure the merge base and the head in the same job on the same runner, hand both files to the gate, and require a regression override to cite the run it rests on.

  bench:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@<sha> # v4
        with:
          fetch-depth: 0
      - uses: dtolnay/rust-toolchain@<sha> # stable
      - run: cargo install iai-callgrind-runner --locked
      - name: Measure the merge base
        run: |
          git worktree add ../base "$(git merge-base origin/main HEAD)"
          (cd ../base && cargo bench --bench compare -- --output-format=json > "$RUNNER_TEMP/base.json")
      - name: Measure the head
        run: cargo bench --bench compare -- --output-format=json > "$RUNNER_TEMP/head.json"
      - uses: orieg/discipline@v0
        env:
          DISCIPLINE_BENCH_BASE_FILE: ${{ runner.temp }}/base.json
          DISCIPLINE_BENCH_HEAD_FILE: ${{ runner.temp }}/head.json
          DISCIPLINE_BENCH_PROVENANCE: ${{ runner.os }}-${{ runner.arch }}
        with:
          suite: bench
[gates.bench-regression]
severity = "error"
tolerance_pct = 2.0
require_sourced_override = true
citation_source_paths = ["src/**", "benches/**"]
citation_measurement_jobs = [{ job = "bench", guard = "Measure the head" }]

The two files are compared arm by arm; iai-callgrind's instruction counts are deterministic, so the comparison is exact, and a wall-clock harness (Criterion, pytest-benchmark, Google Benchmark) is compared by interval instead. A regression needs allow-regression: <arm> <reason> naming every regressed arm. With require_sourced_override, the reason must also cite a CI run URL or a committed artifact path, and every citation in the reason is checked for freshness, not the first: a cited run must have a conclusion that carries a measurement (cancelled, timed_out, action_required, startup_failure, stale and skipped do not); a run that concluded failure counts only when every job in citation_measurement_jobs reached its guard step with every earlier step green (a regression trips the guard on numbers it measured; a crashed benchmark leaves none); the cited run's head must be reachable from the head under review; a cited data artifact must be tracked and must not have been committed before the branch's newest change under citation_source_paths. A citation the run cannot decide (no token, rate limited, a non-GitHub URL) is named and leaves the gate armed.

2. GitLab CI/CD

Discipline integrates with GitLab CI/CD via a shared component template or a standalone container job. It produces native GitLab Code Quality reports that render directly inside Merge Request diff views.

Remote Pipeline Include

Include the official component in .gitlab-ci.yml:

include:
  - remote: 'https://raw.githubusercontent.com/orieg/discipline/v0.29.0/templates/discipline.gitlab-ci.yml'

Custom Container Job

To customize runner parameters, define a standalone job using the official minimal container image:

discipline:gate:
  stage: test
  image:
    name: ghcr.io/orieg/discipline:v0
    entrypoint: [""]
  variables:
    GIT_STRATEGY: clone
    GIT_DEPTH: 0
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
  before_script:
    - git fetch origin $CI_MERGE_REQUEST_TARGET_BRANCH_NAME || true
  script:
    - discipline check --report-gitlab gl-codequality.json --report-junit junit.xml
  artifacts:
    reports:
      codequality: gl-codequality.json
    paths:
      - gl-codequality.json
    when: always

3. Forgejo & Gitea Actions

Forgejo and Gitea Actions use runner engines compatible with GitHub Actions workflows. The composite action runs natively under forgejo-runner and act_runner. It downloads the release binary on every run; on a runner without internet access, pass binary_path pointing at a discipline binary already on the runner.

Workflow Configuration

Create .forgejo/workflows/discipline.yaml (or .gitea/workflows/discipline.yaml):

name: Invariant Verification

on:
  pull_request:
    types: [opened, synchronize, reopened]

jobs:
  gate:
    runs-on: docker
    steps:
      - name: Checkout Repository
        uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          fetch-depth: 0

      - name: Run Gatekeeper
        uses: https://github.com/orieg/discipline@v0

4. Argo Workflows (GitOps)

In Kubernetes GitOps environments, Discipline can run as a pre-sync or pre-promotion validation step. Use the official Argo template from templates/argo-workflow-template.yaml:

apiVersion: argoproj.io/v1alpha1
kind: Workflow
metadata:
  generateName: discipline-gitops-check-
spec:
  entrypoint: validate-diff
  templates:
    - name: validate-diff
      steps:
        - - name: run-discipline-gate
            templateRef:
              name: discipline-sentinel
              template: discipline-gate
            arguments:
              parameters:
                - name: repo-url
                  value: "https://github.com/my-org/my-repo.git"
                - name: target-branch
                  value: "main"
                - name: source-branch
                  value: "feat/my-feature"
                - name: pr-body
                  value: ""
                - name: discipline-image
                  value: "ghcr.io/orieg/discipline:v0"

What the gate step does:

  • Merge base. It fetches both branches at depth 50 and deepens (then unshallows) until git merge-base resolves, so a branch more than 50 commits from the target is still judged. When no merge base exists, or a fetch fails, the step exits 2 (could not check).
  • Policy from the base. DISCIPLINE_POLICY_FROM=base judges the change by the target branch's discipline.toml; a pull request cannot loosen the policy that judges it, and config-integrity still reports the edit.
  • Exit codes. 0 clean, 1 findings, 2 could not check. The step writes success, failure or could-not-check to its discipline-status output parameter, so a commit-status step can report infrastructure trouble as its own state instead of as a violation.
  • Report budget. DISCIPLINE_MAX_REPORT_LINES (check --max-report-lines) caps the text report on stdout for log services with a per-step budget; the JSON, JUnit and SARIF reports stay complete.
  • Forge token. The merged-pr-body directive source on a push and the review-threads gate read the forge's API. A commented DISCIPLINE_FORGE_TOKEN entry shows where a least-privilege token (contents: read) goes.

5. Docker & Container Environments

Official multi-architecture (linux/amd64, linux/arm64) OCI images are published to GitHub Container Registry:

  • ghcr.io/orieg/discipline:latest
  • ghcr.io/orieg/discipline:v0
  • ghcr.io/orieg/discipline:v0.29.0

Running Locally via Docker

Mount your local repository and execute against the merge base:

docker run --rm \
  -v "$(pwd)":/work:ro \
  -w /work \
  ghcr.io/orieg/discipline:latest \
  discipline check --base origin/main

Security & Non-Root Execution

The container executes as unprivileged user 10001:10001 to meet strict Kubernetes PodSecurityStandards and CIS benchmarks. The image automatically sets safe.directory = "*" in the container global git configuration so volume checkouts owned by different host UIDs function without manual permission adjustments.


6. Pre-Commit & Git Hooks

pre-commit Framework

Add Discipline to .pre-commit-config.yaml:

repos:
  - repo: https://github.com/orieg/discipline
    rev: v0.29.0
    hooks:
      - id: discipline          # builds via cargo on first run
      # Or: - id: discipline-system # invokes pre-installed binary on PATH

Native Git Hooks

Install Discipline's native hooks directly into .git/hooks/:

# Writes .git/hooks/pre-commit, which runs `discipline check --staged`
# and blocks the commit when a gate fails
discipline install-hooks

To run manually during interactive git staging:

# Check staged changes against HEAD
discipline check --staged

# Staged and unstaged changes against HEAD (untracked files are not included)
discipline diff

7. Other CI Platforms (Templates)

Copy-paste starting points live in templates/. Each runs the official container image and fetches the base branch. The Azure, Bitbucket, CircleCI and Jenkins templates write discipline-report.json; the GitLab component writes gl-codequality.json and junit.xml; the Argo template writes junit.xml, sarif.sarif and discipline.json under /workspace/reports/:

Platform Template Base ref
GitLab CI/CD (component) discipline.gitlab-ci.yml base_ref input, else CI_MERGE_REQUEST_TARGET_BRANCH_NAME
Argo Workflows argo-workflow-template.yaml target-branch parameter
Azure Pipelines azure-pipelines.yml System.PullRequest.TargetBranch, else main
Bitbucket Pipelines bitbucket-pipelines.yml BITBUCKET_PR_DESTINATION_BRANCH, else main
CircleCI circleci-config.yml CIRCLE_BASE_REVISION, else origin/main
Jenkins Jenkinsfile CHANGE_TARGET, else main

The templates reference the v0 image tag, which tracks the latest v0.x.y release. Pin an exact version (v0.29.0) or an image digest when a gate verdict must be reproducible from the pipeline file alone. If the merge base cannot be fetched, discipline check exits 2 rather than checking against the wrong base.


8. Repository Protection

A failing gate blocks nothing unless the platform refuses to merge the change. Set these once when adopting discipline, then verify them with discipline doctor:

discipline doctor                # workflows, CODEOWNERS, and the default branch's protection
discipline doctor --local-only   # repository files only, no platform API
discipline doctor --strict       # warnings fail too (exit 1)

doctor reads which workflow jobs run discipline and which rollup jobs depend on them (Actions workflows under .github/, .gitea/ and .forgejo/, or .gitlab-ci.yml including the discipline component), then asks the forge whether the default branch requires one of them. On GitHub, Gitea and Forgejo that is a required status check (Gitea and Forgejo name Actions checks <workflow> / <job> (<event>), and their patterns are matched as globs); on GitLab it is "Pipelines must succeed" with a discipline job that is not allow_failure. It also reports the up-to-date policy, force-push and deletion blocking, the pull-request requirement and its review rules (review: the approving-review count; code-owner-review: whether a CODEOWNERS owner must approve; last-push-approval: whether an approval survives a later push, satisfied by last-push approval or by dismissing stale reviews; on Gitea and Forgejo these are required_approvals, block_on_official_review_requests — the two forges request reviews from CODEOWNERS on their own, and that setting makes the requests blocking — and dismiss_stale_approvals, readable with an admin token, otherwise reported as not visible), bypass (administrators, allowlists, ruleset bypass actors), workflow triggers and token permissions (including push-trigger: a discipline job that runs on push to the default branch while the repository allows squash or rebase merges, which drop a pull request's body from the merge commit — a warning when merged-pr-body is not in directives.sources, information when it is: give the push run a token that can read pull requests, restrict the step to pull_request, or put directives in commit messages), CODEOWNERS coverage of the gate configuration, thread-resolution (GitHub and GitLab: whether merges need every review conversation resolved, from the ruleset's required_review_thread_resolution, classic required_conversation_resolution or GitLab's only_allow_merge_if_all_discussions_are_resolved; information, since the setting is optional; Gitea and Forgejo have no such rule), workflow-protection (Gitea and Forgejo: whether every workflow that runs discipline is covered by the branch rule's protected file patterns, matched as the forge matches them: case folded, with * and ? stopping at . as well as /, so .gitea/workflows/* does not cover ci.yml and .gitea/workflows/** does (#428); a pull-request workflow runs from the pull request's head, so an unprotected one can be rewritten by the change it judges; a warning, admin token needed), ref-guard (a warning when the repository has several worktrees and no discipline lease install-guard; information with one), leases (a failure when a lease file does not parse, which makes the guard refuse every branch update; stale leases as information), pretool-hook (information when a generated agent hook file has no pre-tool entry), and, when ratified-paths is enabled with agent_logins, agent-permission (each agent login's repository role and site-administrator flag: an administrator can act as, or edit the comments of, the owner whose ratification the gate reads; a failure), and, when the configuration claims an agent-aware policy (ratified-paths agent_logins set, or directives.require_approval on), agent-policy (whether a required rule covers the referee surfaces, discipline.toml and the workflows that run discipline: an approving review of every pull request, a code-owner review with CODEOWNERS owning each of them, a signed-commit rule, or on Gitea and Forgejo protected file patterns covering each of them; a pass when one is observed, a warning that --strict fails on when none is or when the token cannot see the rules, unknown when the forge cannot be read, information under --local-only; #942). Exit 0 means nothing failed, 1 a check failed, and 2 a check could not be decided. Access: requests go over HTTPS with an optional token (GH_TOKEN/GITHUB_TOKEN, GITLAB_TOKEN, GITEA_TOKEN, FORGEJO_TOKEN); GitHub rulesets need read access, classic protection an admin token. Without an admin token, Gitea and Forgejo still show the required checks and report the admin-only settings as not visible. On Gitea below 1.26, which ignores a workflow's permissions: (the instance scopes the token), the token finding is reported as information with the upgrade that makes the fix effective; GitLab hides "Pipelines must succeed" from anonymous readers, which leaves that check undecided. See Forge access for tokens and forge detection.

Repository settings. doctor also reads the settings that decide whether a mutable action ref, a moved release tag or a replaced release asset can run. On GitHub: actions-sha-pinning (sha_pinning_required: actions must be pinned to a full-length commit SHA), allowed-actions (a warning on allowed_actions: all; selected and local_only pass), default-token (a warning on default_workflow_permissions: write), actions-approve-prs (a warning when can_approve_pull_request_reviews lets a workflow's token approve pull requests), immutable-releases (a warning when a published release's assets can be replaced and its tag moved), tag-protection (an active tag ruleset with the update and deletion rules whose target covers the latest release's tag, or the first tag when there is no release; a ruleset in evaluate mode does not count) and secret-scoping (a warning, by name only, for a repository-level Actions secret that the local workflows read only from jobs bound to an environment:; a job outside an environment with secrets: inherit counts as reading every secret). On Gitea and Forgejo tag-protection reads the protected tags (name_pattern, glob or /regex/), and secret-scoping reads the repository's Actions secrets and, when the owner is an organisation, the organisation's (names only). Neither forge has an environment to scope a secret to, so a workflow on any branch of the repository can read every one of them: doctor states that, names the secrets that workflows running on pull requests read, and warns on a secret no local workflow reads, which adds that exposure for nothing (a job with secrets: inherit counts as reading every secret). An organisation list a token may not read (not an organisation owner) is said, never taken for an empty one. The other five are information there (not available on this forge). On GitLab: tag-protection reads the protected tags (* wildcards); default-token warns when the CI job token allowlist is off (job_token_scope.inbound_enabled: false: a job token from any project can call this one) or a job token may push to the repository (ci_push_repository_for_job_token_allowed); secret-scoping names each masked (or hidden) CI/CD variable that is neither protected nor scoped to one environment, so every pipeline of the project reads it, and says so when fork merge-request pipelines run in the project (ci_allow_fork_pipelines_to_run_in_parent_project); unmasked variables are configuration and are counted, not reported; the Actions-only settings are information. When a fine-grained GitLab token lacks the read permission a setting needs, GitLab names it (insufficient_granular_scope: Variable: Read, Job Token Scope: Read, ...), and the warning's fix names that permission.

The token doctor runs with (forge-token). On GitLab doctor reads the token's own description (personal_access_tokens/self) and warns when it can do more than read (any scope that is not read_*, such as api, write_repository or create_runner: doctor and the gates need read_api only), when it never expires, or when it expires within 14 days. A CI job token, a deploy token or an OAuth token does not describe itself, which is information. On GitHub, Gitea and Forgejo the audit is not available yet. The Actions policy, the workflow token setting, immutable releases and the secret list are shown to an admin token only (fine-grained: Administration read, Secrets read); without one each is a warning that says could not check and names the access it needs, never a pass. GitHub answers 404 for immutable releases both when the setting is off and when the token may not read it; doctor reads the repository's permissions.admin to tell the two apart.

What each forge's API exposes for these settings (docs/ROADMAP.md, Phase 14 Step 0; recorded against running instances, tests/fixtures/forge_settings/, pinned by tests/test_forge_settings_fixtures.rs). A setting a forge has no API for stays information in doctor, never a pass. On gitlab.com a fine-grained token needs a read permission per setting, which the API names when it is missing (Variable: Read, Job Token Scope: Read, Job Token Scope Allowlist: Read, Environment: Read, Protected Environment: Read):

Setting GitHub Gitea 1.24.7 (RUN 2026-09-29) Forgejo 12.0.4 (RUN 2026-09-29) gitlab.com (RUN 2026-09-29)
Actions secrets by name, repository and environment scope by name, repository and organisation; no environment or other scope, so every workflow run of the repository can read every secret as Gitea CI/CD variables, by key, each with protected (protected branches and tags only), masked, hidden and environment_scope (* is every environment)
Actions variables read read with their value read, the value returned empty the same CI/CD variables (a value is never recorded)
Default token permissions default_workflow_permissions no API no API the job token's inbound scope (job_token_scope: inbound_enabled, and its allowlist) and whether a job token may push (ci_push_repository_for_job_token_allowed)
Allowed actions / SHA pinning policy allowed_actions, sha_pinning_required no API (the instance's actions source is server configuration) as Gitea no uses:
Release immutability immutable releases no API no API no API
Tag protection tag rulesets tag_protections (name_pattern; creating one through the API needs a user or team allowed to push) as Gitea protected tags
Fork and user pipelines not in scope no API no API ci_allow_fork_pipelines_to_run_in_parent_project, restrict_user_defined_variables / ci_pipeline_variables_minimum_override_role, protect_merge_request_pipelines, protected environments (deploy_access_levels)

SHA pins. A full-length SHA pin is not enough on its own, and doctor checks two things the diff does not show (GitHub; information on the other forges). imposter-commit: GitHub resolves owner/repo@<sha> across the repository's fork network, so a commit that exists only in a fork runs under the parent's name. Each pinned commit in the workflows, the repository's action.yml and .github/actions/*/action.yml must be reachable from a branch or tag of owner/repo itself: on its default branch (one compare), the head of a branch or tag, or an ancestor of up to 25 other branches and tags (compare API). It is a failure when every branch and tag was checked and none contains the commit, and a warning when there were more refs than that (the warning also covers a commit that a rewritten branch no longer contains). nested-action-pins: the pinned action's action.yml (or action.yaml, or the pinned reusable workflow) is read at that commit, and a uses: there that is not SHA-pinned, or a docker:// image without a digest, is a warning. This is one level deep: the actions the pinned action calls are not read in turn. A summary line for each counts the pins resolved and the references that are not SHA-pinned, which are not checked here (ci-integrity reports those in a diff). An action repository the token cannot read is a warning, never a pass; an unreachable forge is unknown.

Repository settings the OWASP CI/CD Security Cheat Sheet names. Read from the repository object doctor already fetches (#366); each is information or a warning, never a failure, and one the token cannot see is could not check with the access it needs:

  • auto-merge: on GitHub, a warning when allow_auto_merge is on and the protected branch requires no approving review, since a pull request then merges on its required checks alone; information when reviews are required; a pass when auto-merge is off. GitLab, Gitea and Forgejo have no repository setting (merging once checks succeed is always offered), so the required reviews decide: information.
  • forking: a warning when a private or internal repository allows forks (GitHub allow_forking, GitLab forking_access_level: enabled); information for a public one. Not available on Gitea and Forgejo.
  • secret-scanning: on GitHub, secret scanning and push protection (security_and_analysis), a pass when both are on and information otherwise; on GitLab, secret push protection (secret_push_protection_enabled, an Ultimate feature). The shell-secrets gate reads the diff either way.
  • dependency-alerts: on GitHub, Dependabot alerts (vulnerability-alerts) and, when visible, automated security updates; information when off. dependency-delta reads the diff either way.

Access and identity (#366), counted and never named:

  • deploy-keys: a warning when a deploy key can push (GitHub, Gitea, Forgejo read_only: false; GitLab can_push), since a push with one is attributed to no person.

  • outside-collaborators: information with a count. GitHub counts outside collaborators who can push; Gitea and Forgejo count the repository's collaborators, whose list carries no permission; not available on GitLab.

  • On GitHub, for a repository an organisation owns, read from orgs/{org}:

    • org-base-permission warns when every member has write or admin by default;
    • two-factor warns when two-factor authentication is not required;
    • visibility-change warns when members may change a repository's visibility.

    GitHub shows the base permission and the two-factor requirement to an organisation owner's token only, so other tokens get could not check (RUN, 2026-09-30, owner and member tokens). A repository owned by a personal account has no organisation settings (information). Gitea and Forgejo expose no such organisation settings (RUN, swagger.v1.json). On GitLab, two-factor reads the project's top-level group, where GitLab enforces the requirement for every subgroup and project (require_two_factor_authentication): a warning when it is off, could not check when the token cannot read the group, information for a personal namespace; GitLab has no base permission or visibility-change setting to read (READ, Groups API).

Webhooks and environments (#366):

  • webhooks, reported by host only (never the path, which often carries a token, or credentials in the URL):
    • GitHub: a warning for a webhook with no secret or with TLS verification off (insecure_ssl);
    • GitLab: a warning when TLS verification is off (enable_ssl_verification). GitLab returns no secret token, so its presence is not judged;
    • Gitea and Forgejo: information with a count, since they return neither (RUN, 1.24 and 12, with a webhook created with a secret).
  • environment-reviewers: on GitHub, information naming the deployment environments that hold secrets but need no required reviewer, with their secret counts (never the secrets' names). On GitLab, the environments that masked or hidden CI/CD variables are scoped to (not *, which secret-scoping reports) and that need no deployment approval (a protected environment with required_approval_count or approval_rules; READ, Protected Environments API) are named the same way. Gitea and Forgejo have no environments.

Security policy (#366): security-policy is a local check: a pass when SECURITY.md is at the root, in .github/ or in docs/, and information otherwise, since a reporter is then not told how to report a vulnerability privately.

GitHub shows allow_auto_merge, security_and_analysis and the alert state to an admin token only (RUN, 2026-09-30, against a public repository with an admin and a read token). Gitea 1.24 and Forgejo 12 expose none of these four settings in their API description (RUN, swagger.v1.json).

Coverage against the OWASP CI/CD Security Cheat Sheet and OpenSSF Scorecard. Every recommendation of the cheat sheet and every Scorecard check, mapped to a doctor finding, a gate, "not checked" with the reason, or out of scope. Scorecard measures a repository's history and practices; doctor reads the settings that exist now.

Item Where
Cheat sheet, SCM: avoid auto-merge auto-merge
Cheat sheet, SCM: require reviews that cannot be bypassed pull-request, review, code-owner-review, last-push-approval, bypass; agent-policy when the configuration claims an agent-aware policy
Cheat sheet, SCM: protected branches required-check, up-to-date, force-push, deletion
Cheat sheet, SCM: signed commits signed-commits (information)
Cheat sheet, SCM: limit external contributors outside-collaborators, deploy-keys
Cheat sheet, SCM: MFA two-factor (GitHub organisations, GitLab top-level groups)
Cheat sheet, SCM: no default permissions org-base-permission (GitHub organisations)
Cheat sheet, SCM: restrict forking of private or internal repositories forking
Cheat sheet, SCM: limit changing visibility to public visibility-change (GitHub organisations)
Cheat sheet, pipeline: isolated build nodes out of scope (runner infrastructure); the sandbox-config gate reads job and service containers
Cheat sheet, pipeline: TLS between SCM and CI out of scope
Cheat sheet, pipeline: restrict CI access by IP out of scope
Cheat sheet, pipeline: protect the CI configuration workflow-protection; the ci-integrity gate and policy_from: base
Cheat sheet, pipeline: logging out of scope; discipline audit reads the record back
Cheat sheet, pipeline: SAST, DAST, IaC scanning out of scope; the command gate can run a scanner
Cheat sheet, pipeline: manual approval before production deploys environment-reviewers (GitHub, GitLab)
Cheat sheet, pipeline: no --privileged containers the sandbox-config gate (workflow job and service containers)
Cheat sheet, pipeline: version-controlled pipeline configuration the ci-integrity gate
Cheat sheet, IAM: secrets management secret-scoping, forge-token, secret-scanning; the shell-secrets gate
Cheat sheet, IAM: least privilege default-token, actions-approve-prs, agent-permission, org-base-permission, deploy-keys
Cheat sheet, IAM: identity lifecycle out of scope (identity provider)
Cheat sheet, third-party code: dependency management dependency-alerts, actions-sha-pinning; the dependency-delta gate
Cheat sheet, third-party code: plug-ins and integrations allowed-actions, imposter-commit, nested-action-pins; MCP server lists in the sandbox-config gate; webhooks
Cheat sheet: integrity assurance immutable-releases, tag-protection; the action checks the release attestation
Cheat sheet: visibility and monitoring out of scope; discipline audit reads the record back
Scorecard Binary-Artifacts partly the archive-contents gate (archives in the diff); not a setting
Scorecard Branch-Protection required-check, up-to-date, force-push, deletion, pull-request, review, code-owner-review, last-push-approval, bypass. Scorecard scores "last push approval" and "stale review dismissal" separately; last-push-approval passes on either, so the two can disagree on a branch that dismisses stale reviews only
Scorecard CI-Tests out of scope (project history)
Scorecard CII-Best-Practices out of scope
Scorecard Code-Review pull-request, review, bypass (the rule, not past merges)
Scorecard Contributors out of scope
Scorecard Dangerous-Workflow workflow-protection, trigger; the ci-integrity gate
Scorecard Dependency-Update-Tool dependency-alerts (automated security updates); an update tool's own configuration is not read
Scorecard Fuzzing out of scope; the test-budget gate keeps existing fuzz targets. This repository's own cargo-fuzz targets: docs/ARCHITECTURE.md §8.6
Scorecard License out of scope
Scorecard Maintained out of scope
Scorecard Packaging out of scope
Scorecard Pinned-Dependencies actions-sha-pinning; the ci-integrity and dependency-delta gates; container image digests are not checked
Scorecard SAST out of scope
Scorecard SBOM out of scope
Scorecard Security-Policy security-policy (local: SECURITY.md at the root, in .github/ or docs/; information when absent)
Scorecard Signed-Releases immutable-releases, tag-protection; the action checks the release attestation. Scorecard reads release asset names, not the attestation store, so an attestation counts only when its bundle is also a release asset (*.intoto.jsonl)
Scorecard Token-Permissions default-token; the ci-integrity gate (workflow token permissions)
Scorecard Vulnerabilities dependency-alerts (information)
Scorecard Webhooks webhooks

Protection checklist

Setting Why
Require the check before merge on the default branch Without it a red run is advisory. Require the rollup job (for example ci-gate) that needs: every verification job, including discipline, rather than each job by name; ci-integrity keeps that rollup complete.
Require the branch to be up to date before merge Discipline evaluates the diff against the merge base. A stale green run says nothing about the combined result.
Block force pushes and branch deletion A rewritten default branch removes the base that ratchets (test-floor, test-budget, unsafe-budget) and config-integrity compare against.
No bypass for administrators An admin merge skips every gate. Leave the bypass list empty and fix a stuck check instead of overriding it.
Require pull requests (and review, with more than one maintainer) Directives are read from the PR description and commits. A direct push has no PR body, so it cannot carry a reviewed override.
Gate on pull_request, not on push to the default branch A push run reads only the pushed commits' messages. A squash or rebase merge drops the PR body (a merge commit's default message does not carry it either), so a waiver that passed review fails the push run that follows the merge. Run the gate on pull_request (with edited) and merge_group; if a push run is wanted, put directives in commit messages too, or keep the merged-pr-body source (on by default) with a token that can read pull requests. ci-integrity reports a verification step that gains an if: (Verification Step Narrowed, warning), so restricting the discipline step to pull_request is recorded rather than silent.
Protect gate configuration with CODEOWNERS Put discipline.toml, discipline-baseline.toml, .github/workflows/, and any registry the gates read (for example a superseded-figure registry) under a code owner, so weakening them needs a named reviewer. config-integrity and ci-integrity catch the common forms; review catches the rest.
Require signed commits (optional) Ties commits to verified identities. Rebase locally (git rebase origin/main) when a branch falls behind; a server-side "update branch" rebase rewrites commits unsigned and the merge is then blocked.
Least-privilege workflow token permissions: contents: read is enough for the action. Run discipline on pull_request, not pull_request_target, so untrusted code never runs with a write token.
Today's control for a change that edits its own policy (#932) The base branch's discipline.toml sets a floor whichever side's configuration judges the change (src/policy_floor.rs): the override budgets and require_approval hold from the base; switching off, demoting or exempting a protected path from a protective gate (config-integrity, ci-integrity, ratified-paths, sandbox-config, instruction-smuggling) is a refusal no directive lifts; deleting the configuration file is a refusal. policy_from: base adds the rest of the policy: every other gate's settings and every executed command come from the base, so a loosening the change writes into its own copy has no effect on its own run, where under head it is a config-integrity finding a directive can lift. With directives read from the PR body only and fail_on_overrides or require_approval, this is the control CONFIGURATION.md names. The action's policy_from input defaults to head; the base default is #932's companion change for the next major release.
Ordering and token hygiene The discipline job runs before any job that executes the change (a build, the tests, a script the change edits), in a job with permissions: {} (contents: read where checking out a private repository needs it), no secrets, and persist-credentials: false on actions/checkout. A build log is an exfiltration channel that an egress allowlist does not close: in the reviewdog and tj-actions supply-chain incidents (external reporting, 2025), a compromised action printed encoded runner memory, secrets included, into build logs. A long-lived secret that reaches a runner executing untrusted code is readable by that code. ci-integrity reads workflow token permissions in a diff; doctor reports default-token and secret-scoping.
Secret scanning, a separately owned layer shell-secrets and pii match fixed-format literals (token prefixes, private-key headers, literal bearer headers) in the text they read; they do not validate a credential with its issuer, and a high-entropy secret with no fixed format, or one outside the shell scripts, Dockerfiles and CI files shell-secrets reads, is not theirs to find. Validation needs the network, which no gate uses (AGENTS.md §3.3). Where the forge has secret scanning and push protection, that layer is the forge's (doctor reports secret-scanning); elsewhere it is a dedicated scanner's, owned and configured outside discipline.
Per-agent host identity An agent-aware policy (require_approval, ratified-paths agent_logins) presumes the agent and its approver are different logins. GitHub's coding agent cannot sign commits with verified signatures (external, GitHub documentation); the forge's compensating control is host-side: a pull request the agent opens needs a human approval, and the person who asked for it cannot approve it. Where every agent posts as the owner's login, an approval shows that login agreed, not that a second party did: ARCHITECTURE.md §1.4, assumption 3, records that this does not hold on this repository. Identity stays host-side (#776). doctor reports agent-policy: an agent-aware claim with no required review or signature rule over discipline.toml and the discipline workflows is a warning, which --strict fails on.

Add edited to the pull_request event types so that changing a PR description (adding or removing a directive) re-runs the gate. With a merge queue, also trigger on merge_group and require the same check there. Do not add push: branches: [main] for the same job unless every directive it relies on is also in a commit message: the push run cannot see the PR body (see the event / source table in CONFIGURATION.md).

GitHub: ruleset example

A ruleset on the default branch with a required rollup check, up-to-date policy, no force pushes or deletions, and no bypass:

gh api --method POST repos/OWNER/REPO/rulesets --input - <<'JSON'
{
  "name": "default branch protection",
  "target": "branch",
  "enforcement": "active",
  "bypass_actors": [],
  "conditions": { "ref_name": { "include": ["~DEFAULT_BRANCH"], "exclude": [] } },
  "rules": [
    { "type": "required_status_checks",
      "parameters": {
        "strict_required_status_checks_policy": true,
        "required_status_checks": [ { "context": "ci-gate" } ] } },
    { "type": "pull_request",
      "parameters": {
        "required_approving_review_count": 1,
        "dismiss_stale_reviews_on_push": true,
        "require_code_owner_review": true,
        "require_last_push_approval": false,
        "required_review_thread_resolution": false } },
    { "type": "non_fast_forward" },
    { "type": "deletion" }
  ]
}
JSON

A single-maintainer repository cannot approve its own pull requests: drop the pull_request rule's review count to 0 (keep the rule so changes still arrive as pull requests), or leave it out. Add { "type": "required_signatures" } to require signed commits. Replace ci-gate with the name of your rollup job, or with discipline if the gate is its own workflow.

Minimal CODEOWNERS (.github/CODEOWNERS):

/discipline.toml             @OWNER
/discipline-baseline.toml    @OWNER
/.github/workflows/          @OWNER

GitLab

Under Settings → Repository → Protected branches, allow no one to push or force push to the default branch. Under Settings → Merge requests, enable Pipelines must succeed. Add approval rules and CODEOWNERS with Code owner approval on the protected branch. Use the component in the merge request pipeline so the check runs against the target branch.

Gitea & Forgejo

Under Settings → Branches → Branch protection for the default branch, enable Status check with the discipline job (or its rollup) as a required context, disable force push, require approvals as the team allows, and list discipline.toml and the workflow directory under Protected file patterns, separated by ;: discipline.toml;.gitea/workflows/** (or .forgejo/workflows/**). The forge stops a single * at a ., so .gitea/workflows/* does not protect ci.yml: a pull request that edits it merges (RUN on Gitea 1.24.7 and Forgejo 12, #428). discipline doctor reports a workflow the patterns leave open.