| layout | default |
|---|---|
| title | CI/CD Platform Integration Guide |
| permalink | /guides/ci-platforms/ |
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.
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.0The examples below use @v0 for readability.
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.sarifWhen 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: trueAdding 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>.
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.
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.
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.
Include the official component in .gitlab-ci.yml:
include:
- remote: 'https://raw.githubusercontent.com/orieg/discipline/v0.29.0/templates/discipline.gitlab-ci.yml'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: alwaysForgejo 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.
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@v0In 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-baseresolves, so a branch more than 50 commits from the target is still judged. When no merge base exists, or a fetch fails, the step exits2(could not check). - Policy from the base.
DISCIPLINE_POLICY_FROM=basejudges the change by the target branch'sdiscipline.toml; a pull request cannot loosen the policy that judges it, andconfig-integritystill reports the edit. - Exit codes.
0clean,1findings,2could not check. The step writessuccess,failureorcould-not-checkto itsdiscipline-statusoutput 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-bodydirective source on a push and thereview-threadsgate read the forge's API. A commentedDISCIPLINE_FORGE_TOKENentry shows where a least-privilege token (contents: read) goes.
Official multi-architecture (linux/amd64, linux/arm64) OCI images are published to GitHub Container Registry:
ghcr.io/orieg/discipline:latestghcr.io/orieg/discipline:v0ghcr.io/orieg/discipline:v0.29.0
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/mainThe 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.
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 PATHInstall 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-hooksTo 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 diffCopy-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.
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 whenallow_auto_mergeis 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 (GitHuballow_forking, GitLabforking_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). Theshell-secretsgate reads the diff either way.dependency-alerts: on GitHub, Dependabot alerts (vulnerability-alerts) and, when visible, automated security updates; information when off.dependency-deltareads the diff either way.
Access and identity (#366), counted and never named:
-
deploy-keys: a warning when a deploy key can push (GitHub, Gitea, Forgejoread_only: false; GitLabcan_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-permissionwarns when every member haswriteoradminby default;two-factorwarns when two-factor authentication is not required;visibility-changewarns 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-factorreads 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 checkwhen 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).
- GitHub: a warning for a webhook with no secret or with TLS verification off (
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*, whichsecret-scopingreports) and that need no deployment approval (a protected environment withrequired_approval_countorapproval_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 |
| 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).
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" }
]
}
JSONA 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
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.
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.