Shared CI and Required zizmor Workflows in giskard-actions
Status: accepted; amended 9 November 2026 (the scan moves to a shared script, new pins are checked online, the ruleset targets every kind=code repository at once, admins bypass in pull request mode); amended 12 November 2026 (the Gate fails pull requests whose text holds a Skip-CI Marker; the Shared CI Workflows drop their cache opt-in).
The reusable lint, format and test workflows (Shared CI Workflows python-ci.yml and ts-ci.yml) and the organization-required zizmor workflow (Required Scan Workflow zizmor-scan.yml) ship from giskard-actions under its Umbrella Release, next to the release notes action and the Reusable Review Workflow.
Summary for managers
- Cost: about one runner minute per pull request for the zizmor scan, on top of the lint and test jobs a repository already runs, and one monthly admin step to upgrade zizmor.
- Friction: a pull request that adds a risky workflow pattern is blocked until it is fixed or carries a reasoned inline ignore; every code repository is fixed before the gate is turned on, and a zizmor upgrade reaches every code repository at once, so it is dry-run on the dashboard first.
- Value: one maintained, pinned CI for Python and TypeScript repositories, and a GitHub Actions security gate that pull requests cannot switch off.
Context
The org-wide repository baseline asks every code repository for a CI that lints, checks formatting and runs tests, and for a zizmor scan of its GitHub Actions files on every pull request. Most repositories are uv Python or npm TypeScript projects, so the same jobs would otherwise be copied, pinned and kept up to date in each of them.
An organization ruleset can require a workflow to pass (“Require workflows to pass before merging”) without a caller file in the target repository. The ruleset references the workflow by repository, path and ref, and a public repository can only require a workflow stored in a public repository. A required workflow checks out the pull request’s merge commit, so anything it reads or runs from the workspace is controlled by the pull request. Rulesets have no Evaluate mode outside GitHub Enterprise. Renovate only proposes SHA pin updates (helpers:pinGitHubActionDigests) for repositories that publish SemVer tags.
zizmorcore/zizmor-action cannot be the gate. Its default collection honours .gitignore, so a pull request can force-add a workflow and gitignore it: zizmor skips it, GitHub runs it. It runs python3 -m pip with the pull request’s checkout as working directory, and -m puts that directory first on sys.path, so a pull request that adds pip/__main__.py can fake a passing scan. It does not expose --strict-collection.
Decision
- The shared workflows live in
giskard-actions. It is public, so the required workflow can run on public repositories, and it publishes SemVer tags, so Renovate bumps the callers’ pins. A separate repository would need its own release pipeline to get the same. - The baseline requires a CI, not these workflows. A repository satisfies it with any CI that lints, checks formatting and tests; the Shared CI Workflows are the default way to do it. Every command is an input, passed through an environment variable to
bash -cso no caller value is interpolated into a script. - Tool versions default to the project.
uv-versiondefaults to empty, so setup-uv installs the project’srequired-versionor the latest uv;python-versiondefaults to empty, so uv reads.python-versionorrequires-python.node-versiondefaults to26, the Active LTS line, overridable. - Dependency caching is opt-in (superseded: no cache at all, see the amendment below).
enable-cache(Python) defaults tofalseandcache(TypeScript) to empty: callers may run the workflows on release, tag orworkflow_runevents, where a poisoned cache restored from the default branch would run with release credentials, and prebuilt wheels and tarballs re-download quickly. - Amended: no dependency cache. The opt-in inputs
enable-cache(Python),cacheandcache-dependency-path(TypeScript) are removed, and each step sets its cache input off as a literal:enable-cache: falseonsetup-uv,package-manager-cache: falseonsetup-node. The organization’s policy is no dependency cache in CI, and the repository baseline fails a repository when a workflow step restores or saves a cache, counting an expression in a cache input as on, so the opt-in made this repository fail its own rule. No repository calls these workflows, so the removal ships in a minor release; a caller that still passes a removed input would get GitHub’s unknown-input error when it bumps its pin. - Composite actions are deferred. The Shared CI Workflows cover single-job uv and npm or Yarn 1 projects. pnpm, service containers such as Postgres, and matrices are not supported; repositories that need them keep their own CI for now. Composite steps that such CIs could reuse will be designed once two repositories need the same thing.
- The scan runs zizmor itself, from the digest-pinned image.
zizmor-scan.ymlrunsghcr.io/zizmorcore/zizmor:<version>@sha256:<digest>withdocker run, the pull request checked out intosrc/and mounted read-only, the config checked out into a sibling directory, and no network. The digest fixes the scanner bytes, and nothing the pull request contains is executed or on the scanner’s path. A script rather than adocker://step, so exit code 3 (no inputs) becomes a pass. A hash-lockedpip installinto an isolated interpreter was rejected: the hashes would live in the workflow, where Renovate cannot update them (at the time, the workflow also could not tell whichgiskard-actionscommit it ran from; see the amendment below). - Amended: the scan is a script shared with the skill and the dashboard.
job.workflow_repositoryandjob.workflow_shanow give the commit of the running workflow, including in a ruleset run, so the workflow checks outscripts/zizmor/from this repository at that commit and runsscan.sh. The script holds the image digest and every flag; thegha-hardeningskill and the organization dashboard run the same script, so a local pass means a gate pass. A composite action was rejected: the workflow would have to pin it by SHA in its own repository, one release behind. - The scan audits everything, offline, with the pedantic persona (superseded: the auditor persona since ADR 0005).
--collect=allignores.gitignore;--strict-collectionfails on unparsable files;--offlinedisables online audits, whose external advisories could turn every pull request red without a code change. Online audits belong to the dashboard’s weekly scan, except for new pins: when a pull request adds or changes auses:line, azizmor-pinsjob runs zizmor online with the read-only job token and fails only on an impostor commit on an added line, or when zizmor cannot finish; other online findings are warnings. Pedantic is the standard every repository’s own zizmor check already used, and it only added informational findings across the 10 repositories that ran zizmor. It runs onubuntu-latest, which has Docker. - Exceptions are inline ignores, flagged for review. An exception is a
# zizmor: ignore[<rule>] -- <reason>comment on the flagged line, reviewed in the pull request that adds it. The scan compares ignore comments with the base commit and annotates each new one, without failing. The real control against a malicious pull request is review; the scan’s job is to make risky lines visible.--no-ignoreswas rejected: it also drops the config’signore:entries, which leaves only rule-widedisable, so one justified line switched a rule off for the whole repository. Repository-wide policy stays in the config, read from the base commit on pull requests and merge groups, so a pull request cannot loosen it for itself. - Only the ruleset rule is the gate. “Require workflows to pass before merging” pins the repository, path and SHA of the workflow that must pass. A required status check matched by the name
zizmoris not used: a pull request can satisfy it with a job of the same name in a workflow it adds or edits. - Amended: the Gate also blocks Skip-CI Markers in pull request text. The organization sets
squash_merge_commit_title=PR_TITLEandsquash_merge_commit_message=PR_BODYon its code repositories, and the organization dashboard audits it, so a body that merely quotes a bot’s commit message with a skip instruction stops everypushworkflow, CI and releases included, on the default branch. The Required Scan Workflow is the one workflow every code repository must pass, so askip-ci-markersjob there fails such a pull request. It is a separate job, without permissions, token or checkout, so it adds seconds and its check name says what failed; a step in thezizmorjob would have reported it as a zizmor failure. It reads the title and body from the event file (GITHUB_EVENT_PATH) rather thanenv:: Linux caps one environment string at 128 KiB, and a 65,536-character body of multibyte characters exceeds it, which would fail the job before it starts, without an annotation. Onmerge_group, whose payload has no pull request, it checks the group’s head commit message, which is the commit the queue pushes, and fails when that message is missing. Fetching the live pull request through the API was rejected: “Get a pull request” needs a token withcontents: readorpull-requests: read, which this job would then hold, and the merge queue check already covers edits made before queuing.pull_request: types: [edited]was not added: a ruleset runs a required workflow only onopened,synchronizeandreopened, whatevertypes:it declares. It covers squash merges with the pull request title and body and merge commits, whose message holds the title; squash merges that use the branch’s commit messages and rebase merges are not covered, by design, since the organization’s setting is the pull request body. Bot pull requests are not exempt: Dependabot quotes upstream commit messages, which often hold a marker, and a merged bot pull request would still put it on the default branch. - Fix first, then gate every code repository. The organization dashboard first reports which repositories would fail; each is fixed (or archived). Then the ruleset targets all
kind=coderepositories at once, on the default branch, with “Do not enforce on create”. A per-repositoryzizmor-gateproperty was dropped: one more setting to keep in sync, and a way for a repository to stay out indefinitely. The ruleset is defined in the dashboard’s repository and applied by admins. - Versions move forward only. Callers pin a release SHA and Renovate proposes the next one. A bad release is fixed forward with a patch release, never by moving or deleting a tag; a caller can stay on, or go back to, the previous SHA meanwhile. The ruleset pins
zizmor-scan.ymlby SHA; its JSON lives as code in the dashboard’s repository, so bumping or rolling it back is a reviewed pull request there, which Renovate can propose, applied by an admin.
Consequences
- A
feat:change to any shared workflow cuts a minor release that Renovate proposes to every caller; changes tozizmor-scan.ymlalso need a reviewed pull request in the dashboard’s repository that moves the ruleset SHA. - zizmor upgrades are one monthly admin step: approve the Renovate update, release, dry-run the new version in the organization dashboard, then bump the ruleset SHA.
- In an emergency (the gate itself is broken), an organization admin merges through the ruleset’s only bypass, in pull request mode; nobody can push around it. Disabling the ruleset is the last resort. Gated repositories drop any direct
workflow_callof the scan, which would otherwise run twice. - The ruleset does not allow
cancel-in-progressin a required workflow, so runs of one pull request queue. - The job fails on symlinked YAML files and on symlinks in
.github: the runner follows them, but zizmor audits the target under its own name or skips it when it lies outside the checkout. In Actions, it also fails when a YAML file’s checkout differs from its committed bytes (.gitattributesconversions), since GitHub runs the committed bytes. - Before checking out its scripts, each job checks that
job.workflow_repositoryisGiskard-AI/giskard-actionsandjob.workflow_shaa full SHA: with empty values, checkout would fall back to the pull request and run its scripts. zizmor-pinscannot check references to other private repositories of the organization (the job token reads only its own repository); it warns and skips them. Its online calls spend the repository’s hourlyGITHUB_TOKENquota.- Known gap: an existing ignore comment keeps covering its line when a pull request changes the code it covers (a
run:block under an ignored line, for example); only new ignore comments are flagged. Reviewers see the change in the diff. - Files zizmor cannot parse (test fixtures, Jinja or copier templates) fail strict collection and cannot be excepted; the dashboard dry run surfaces them before a repository is gated.
- The checks show as
zizmor,zizmor-pinsandskip-ci-markersin the ruleset run and as<caller job> / <job>when called as a reusable workflow. Keep these three job names stable so callers’ checks keep their names. - Every pull request in a gated repository pulls the zizmor image from GHCR. The only action the scan uses is
actions/checkout, so it is the only entry the organization actions allowlist needs;docker runin a script is not covered by that allowlist. - GHCR publishes no release timestamps, so Renovate’s 7-day cooldown cannot apply to the image: zizmor updates (version and digest, through a regex manager) wait for Dependency Dashboard approval, given once the same version has been on PyPI for 7 days.
- A pull request can add its own exception. It shows as a warning on the line and in the job summary, so reviewers must check its reason; the organization dashboard lists every ignore comment per repository.
- A caller that runs the scan from
pull_request_targetscans the base branch and gets false assurance; the docs forbid it. - Known gaps without a merge queue: a title or body edited after the last
skip-ci-markersrun is not checked; whoever merges can edit the squash message in the merge box; and GitHub may appendCo-authored-by: <name> <email>trailers for the branch’s other commit authors (this repository’s squash commits show them; GitHub does not document it), so a commit author’s name could carry a marker, which thepull_requestrun does not read. The merge queue catches all three, since it reads the final commit message. After a fix the author must push a commit, click Update branch (when offered), or close and reopen the pull request, since a re-run reads the old event payload. - The organization ruleset gates this repository (
kind=code) too, so its pull requests run the Gate at the SHA the ruleset pins, not the pull request’s copy ofzizmor-scan.yml: a change to that workflow or toscripts/zizmor/is only exercised bytests/until a release carrying it is pinned. Next to it runs this repository’s onlinezizmor.yml(auditor persona since ADR 0005), whose job is namedzizmor-onlineso only the shared scan emitszizmor, and which uses the same image with--no-config; it only adds the online audits.