Shared CI workflows
Three reusable workflows for the CI every Giskard code repository needs: lint, format check and tests for uv Python projects (python-ci.yml) and Node/TypeScript projects (ts-ci.yml), and a zizmor scan of GitHub Actions files (zizmor-scan.yml) that an organization ruleset can require on every pull request, which also blocks pull request text that would stop CI on the default branch after a squash merge.
The repository baseline requires a CI that lints, checks formatting and runs tests, not these workflows. Use them when their defaults fit; keep your own CI when they do not.
- Prerequisites
- Pinning
- Python CI
- TypeScript CI
- Caching
- Command inputs
- Caller permissions and secrets
- zizmor scan
Prerequisites
- Python CI: a uv project with a committed
uv.lock,ruffandpytestin a dependency group, and at least one test. Otherwise overridelint-commandandtest-command. - TypeScript CI: a
package.jsonwith alintscript,prettierindevDependencies, atestscript, and a committedpackage-lock.json(npm) oryarn.lock(Yarn 1). pnpm is not supported yet. - Not supported: pnpm, service containers (for example Postgres for integration tests) and build matrices. Repositories that need them keep their own CI for now.
- zizmor scan: nothing in the repository; it runs on GitHub-hosted
ubuntu-latest, which has Docker. - Every caller grants
contents: readand passes no secrets.
Pinning
Pin the full commit SHA of a release with its version as a comment, and use the same SHA for every reference to this repository. Get the SHA from the release page or with:
gh api repos/Giskard-AI/giskard-actions/git/ref/tags/v1.1.0 --jq .object.sha
uses: Giskard-AI/giskard-actions/.github/workflows/python-ci.yml@<full-commit-sha> # v1.1.0
Renovate with helpers:pinGitHubActionDigests then proposes updates. See Pinning and versioning.
Python CI
Runs uv sync then the lint command in a lint job, and uv sync then the test command in a parallel test job. Both jobs set UV_NO_SYNC=1, so uv run uses the environment uv sync built and does not add back groups that sync-args left out.
name: CI
on:
pull_request:
merge_group:
push:
branches: [main]
permissions: {}
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
python:
permissions:
contents: read
uses: Giskard-AI/giskard-actions/.github/workflows/python-ci.yml@<full-commit-sha> # v1.1.0
with:
working-directory: backend # optional, defaults to the repository root
The checks appear as python / lint and python / test (calling job name, then the called job name).
| Input | Type | Default | Description |
|---|---|---|---|
python-version | string | '' | Python version for uv; empty lets uv choose from .python-version or requires-python |
uv-version | string | '' | uv version; empty uses [tool.uv] required-version from uv.toml or pyproject.toml in working-directory, else the latest uv |
working-directory | string | . | Directory holding pyproject.toml and uv.lock; every command runs there |
runner | string | ubuntu-latest | Runner label for both jobs |
sync-args | string | --locked --all-groups | Arguments of uv sync, split on whitespace |
lint-command | string | uv run ruff format --check . && uv run ruff check . | Command of the lint job |
test-command | string | uv run pytest | Command of the test job |
timeout-minutes | number | 30 | Timeout of each job |
--locked fails when uv.lock is out of date with pyproject.toml; --all-groups installs every dependency group, so the default commands need ruff and pytest in one of them.
TypeScript CI
Runs the install, lint and format commands in a lint job, and the install and test commands in a parallel test job.
name: CI
on:
pull_request:
merge_group:
push:
branches: [main]
permissions: {}
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
frontend:
permissions:
contents: read
uses: Giskard-AI/giskard-actions/.github/workflows/ts-ci.yml@<full-commit-sha> # v1.1.0
with:
working-directory: frontend # optional, defaults to the repository root
| Input | Type | Default | Description |
|---|---|---|---|
node-version | string | 26 | Node.js version spec; 26 is the Active LTS line since 2026-10-28 |
working-directory | string | . | Directory holding package.json; every command runs there |
runner | string | ubuntu-latest | Runner label for both jobs |
install-command | string | npm ci | Install command, run in both jobs |
lint-command | string | npm run lint | Lint step of the lint job |
format-command | string | npx --no prettier --check . | Format check step of the lint job; --no fails when prettier is not installed instead of downloading it |
test-command | string | npm test | Command of the test job |
timeout-minutes | number | 30 | Timeout of each job |
Package managers:
- Yarn 1: set the
yarn ...commands. The runner image’s Yarn 1.22 is used. - pnpm is not supported yet. The pnpm binary would have to be installed inside the called job (a caller cannot add
pnpm/action-setupto it), and Node.js 25 and later no longer ship Corepack.
Caching
Neither CI workflow restores or saves a dependency cache, and no input turns one on: every setup-uv step sets enable-cache: false and every setup-node step package-manager-cache: false. GitHub Actions caches written on the default branch are restored by every other ref, including tag pushes and release runs, so anything that can write that cache (a compromised dependency or build step, a workflow that runs untrusted code) can poison what a later release or secret-holding run restores. Callers may run these workflows on release, tag or workflow_run events. Prebuilt wheels and npm tarballs re-download quickly, so installing from the lock file in every job costs little.
Releases up to v2 had opt-in inputs (enable-cache in Python CI, cache and cache-dependency-path in TypeScript CI). GitHub rejects a call that passes an input the workflow does not declare, so remove them from your with: when you bump past v2.
Command inputs
Command inputs run as bash -eo pipefail -c "<command>" from an environment variable, never as an expression inside the script, so zizmor reports no template injection. They are still code: set them to fixed strings in your workflow and never build them from pull request data such as branch names or titles.
The defaults fail on purpose when the project has no tests (pytest exits with code 5), no lint script, or no ruff, pytest or prettier installed; set lint-command, test-command or format-command to what the project actually runs.
Caller permissions and secrets
Both CI workflows and the zizmor scan only need contents: read. Grant it on the calling job, which caps every job of the called workflow. Put concurrency on the caller as well; the CI workflows do not declare it. None of them takes a secret: never add secrets: inherit, which would hand every repository secret to the commands.
zizmor scan
zizmor failed on my PR?
- Fix the finding. The annotation names the rule and links its documentation.
- For a justified exception, add
# zizmor: ignore[<rule>] -- <reason>on the flagged line (see Exceptions). The scan flags it as a new ignore so your reviewers check the reason. - Editing
.github/zizmor.ymlin your pull request has no effect. The config is read from the base commit. A repository-wide change goes in its own pull request first; then push a commit or click Update branch (re-running the job reuses the old base commit). - A symlinked action, workflow, YAML or
.githubfile fails the scan. Commit the file itself. - A YAML path with a control character, a comma, a colon or
%fails the scan. zizmor prints paths unescaped in its annotations; rename the file or directory. - “zizmor did not run”: zizmor stopped before reporting findings. Its message is just above in the log: a file it cannot parse (move templates and fixtures out of
.github/, or rename them so they no longer end in.yml), or a Docker or network failure (re-run the job). - “differs from its committed bytes”: a
.gitattributesentry (filter,eol,working-tree-encoding) changes the file on checkout. Remove that attribute for the file: GitHub runs the committed bytes. zizmor-pinsfailed withimpostor-commit: a reference your pull request adds or changes points to a commit outside the action’s own branches and tags (often a commit from a fork). Pin a release with its SHA. This finding cannot be ignored.
skip-ci-markers failed on my PR?
The pull request title or body holds a Skip-CI Marker: [skip ci], [ci skip], [no ci], [skip actions] or [actions skip] anywhere, in any case, backticks included, or a line that reads skip-checks: true or skip-checks:true. The organization squash-merges with the pull request title and body as the commit message, so the marker lands in the commit on the default branch, and GitHub runs no push workflow for a commit whose message holds one (Skipping workflow runs), so CI and release jobs would silently not run after the merge. A body that only quotes a bot’s commit message is enough.
- Reword it. Write “the skip-ci marker”, or drop the brackets. The annotation names the marker and its line in the body.
- Then push a commit, click Update branch (when offered), or close and reopen the pull request. The ruleset runs the Gate only when a pull request is opened, reopened or gets new commits, so editing the title or body does not re-run it, and re-running the job reads the text its run started with.
- In a merge queue, the job checks the commit message the queue will push. Remove the pull request from the queue, reword it, and queue it again.
skip-ci-markers failed on a Dependabot or Renovate PR?
Bot pull requests are not exempt: merged, their body still becomes the squash commit on the default branch. Rewording may not stick either: Renovate regenerates the body when it updates the pull request, and @dependabot recreate overwrites edits.
- Dependabot lists the upstream commits in a “Commits” section, and release tooling often writes a marker in its version-bump commits. Dependabot has no option to leave that section out: dependabot-core#4764 was closed as a duplicate of the still-open requests for configurable pull request text (#2211, #1460), and a community pull request that would have replaced the markers (#7311) was closed unmerged. Edit the body to remove the marker lines, then close and reopen the pull request right away so the Gate reads the new body, and merge. Do not click Update branch: Dependabot stops rebasing a pull request once someone else pushes a commit to it (Dependabot docs). Do not comment
@dependabot recreate, which overwrites the edit. If Dependabot pushes to the pull request later, the Gate runs again on its new body. -
Renovate quotes release notes, not commit messages, so markers are rare there. When a package’s release notes hold one, stop fetching them for that package in the repository’s Renovate config (
fetchChangeLogs); Renovate drops the release notes from the body on its next run:{ "packageRules": [ { "matchPackageNames": ["<package>"], "fetchChangeLogs": "off" } ] }
How it runs
zizmor-scan.yml runs zizmor from its container image, pinned by digest (ghcr.io/zizmorcore/zizmor:<version>@sha256:...), with the auditor persona. Findings appear as annotations on the pull request and fail the zizmor job, which times out after 10 minutes.
The scan itself is scripts/zizmor/scan.sh, the only place that sets the zizmor version and flags. The workflow checks this repository out at the commit that runs (job.workflow_sha: the ruleset’s pinned SHA, or the ref a caller pinned) and runs the script from there, so the workflow and its script always match. The gha-hardening skill and the organization dashboard run the same script, so a repository that passes locally passes the Gate:
git clone --depth 1 --branch <release> https://github.com/Giskard-AI/giskard-actions /tmp/giskard-actions
bash /tmp/giskard-actions/scripts/zizmor/scan.sh . # ZIZMOR_RUNNER=uvx without Docker
- PR content cannot touch the scanner. The pull request is checked out into
src/and mounted read-only into the container, which runs without network. The base commit’s YAML files are checked out into a siblingbase/directory, never insidesrc/. - Auditor persona. The scan reports every finding, including code smells and
secrets-outside-env(a job that reads a secret without declaring an Environment; reusable workflows are not checked); see ADR 0005. A repository cannot choose another persona. - No symlinked Actions files. The runner follows a symlink when it loads a local action, but zizmor audits the target under its own name, or skips it when the target is outside the checkout. The job fails before scanning if a
.ymlor.yamlfile, a.githubdirectory or anything under one is a symlink. - Committed bytes only. GitHub runs the committed file, but checkout applies
.gitattributesconversions (filter,eol,working-tree-encoding), which could show zizmor different bytes. In Actions, the scan fails if a tracked YAML file differs from its committed bytes. - zizmor’s errors cannot write annotations. They quote values from the scanned files, so the scan prints them with workflow commands stopped.
- Config from the base commit. On
pull_requestandmerge_groupruns, the zizmor config is read from the base commit (github.event.pull_request.base.shaorgithub.event.merge_group.base_sha), in zizmor’s discovery order:.github/zizmor.yml,.github/zizmor.yaml,zizmor.yml,zizmor.yaml. With none, an empty config (rules: {}) is used. On other events, such as a reusable call from apushworkflow, the config of the scanned commit is used. - Inline ignores are honoured and flagged.
# zizmor: ignore[...]comments and the config’signore:entries apply. Each ignore comment a pull request adds, compared with the base commit, becomes a warning on its line and is listed in the job summary. The warning never fails the job. See Exceptions. - Every file counts.
--collect=allaudits workflows, composite actions, Dependabot and pre-commit files even when.gitignorelists them, since GitHub runs a force-added workflow regardless.--strict-collectionfails on a file zizmor cannot parse. - Offline. Online audits (known vulnerable actions, impostor commits, ref confusion) depend on external advisories and could turn every pull request in the organization red without a code change. The
zizmorjob runs offline; thezizmor-pinsjob below runs them on what the pull request changes only. - New pins are checked online. When the pull request adds lines to a workflow, an
action.ymlor a pre-commit config (compared with its base; onmerge_group, with the previous queue entry), thezizmor-pinsjob runs zizmor online, with the job’s read-only token and the base commit’s config, on the committed bytes of those files only. It fails when an impostor commit (a SHA that no branch or tag of the referenced repository contains, such as a commit pushed to a fork) touches an added line, even behind an ignore comment, or when zizmor cannot finish. Other online findings are warnings. Without such a line, the job passes at once.- The token reads only the repository itself. A reference to another private repository of the same organization is not checked online, and gets a warning; a reference the token cannot read elsewhere fails, since the runner could not read it either.
- Called from a
pushworkflow, the job compares the last commit with its parent. - Each online check costs GitHub API calls from the repository’s hourly
GITHUB_TOKENquota; a pin to an unknown SHA in a repository with many tags costs the most.
- No files is a pass. A repository with nothing to audit passes with a notice.
- Skip-CI Markers in the pull request text fail the
skip-ci-markersjob. It reads the title and body from the event file (GITHUB_EVENT_PATH), never from the checkout or an environment variable (Linux caps one at 128 KiB, which a long body can exceed), with no permissions, no token and no checkout, so it finishes in seconds. Onmerge_groupthe payload has no pull request body, so the job checks the group’s head commit message instead: the commit the queue pushes to the default branch, which also catches a title or body edited after the pull request run. Without that message, the job fails rather than pass unchecked. Called from apushworkflow, it has nothing to check and passes. See skip-ci-markers failed on my PR?- It covers the organization’s merge settings: squash merges with the pull request title and body (
squash_merge_commit_title=PR_TITLE,squash_merge_commit_message=PR_BODY, set on its code repositories and audited by the organization dashboard), and merge commits, whose message holds the title. A squash merge that uses the branch’s commit messages, or a rebase merge, keeps commit messages the job does not read, by design. In a merge queue it reads the queued commit itself, whatever the merge method. - Known gaps without a merge queue, all caught by the merge queue since it reads the final commit message:
- A title or body edited after the last run is not checked. A ruleset runs a required workflow only on the
opened,synchronizeandreopenedactivity types, whatevertypes:the workflow declares, so addingeditedwould change nothing in gated repositories. - Whoever merges can edit the squash message in the merge box (Merge message for a squash merge).
- GitHub may append
Co-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. Thepull_requestrun does not read commit authors.
- A title or body edited after the last run is not checked. A ruleset runs a required workflow only on the
- It covers the organization’s merge settings: squash merges with the pull request title and body (
- Never call it from
pull_request_target. That event checks out the base branch, so the scan would pass on code the pull request does not contain.
Exceptions
An exception is an inline ignore on the flagged line, with a one-line reason, reviewed in the pull request that adds it:
on: # zizmor: ignore[dangerous-triggers] -- /review gated to OWNER/MEMBER/COLLABORATOR
issue_comment:
The scan flags every ignore comment the pull request adds, so reviewers see each one. Keep the reason specific enough for a reviewer, or a later auditor, to judge it.
Repository-wide policy (disabling a rule everywhere, or tuning options such as the unpinned-uses policies with rules.<id>.config) goes in .github/zizmor.yml, read from the base commit, so a pull request cannot loosen it for itself. Require code owner review on .github/** (a CODEOWNERS entry plus “Require review from Code Owners”).
Strict collection failures cannot be excepted: a file zizmor cannot parse (test fixtures, Jinja or copier templates, syntax zizmor does not support) fails the scan. The organization dashboard’s dry run surfaces them before the Gate is turned on or upgraded.
Rollout with an organization ruleset
The workflow triggers on pull_request and merge_group, so an organization ruleset can run it on code repositories without a caller file. Rulesets have no Evaluate mode outside GitHub Enterprise, so:
- The organization dashboard reports which repositories would fail the scan, and lists their ignore comments. Every failing code repository is fixed first, with the
gha-hardeningskill. - Once none fails, an organization ruleset with Require workflows to pass before merging requires
.github/workflows/zizmor-scan.ymlfromGiskard-AI/giskard-actionsat the full commit SHA of a release, on the default branch of every repository withkind=code, with Do not enforce on create so new repositories can be created.
The ruleset JSON lives as code in the dashboard’s repository (rulesets/zizmor-gate.json) and changes through a reviewed pull request there; admins apply it.
Only this rule is a trustworthy gate: it pins the repository, path and SHA of the workflow that must pass. A required status check that matches the name zizmor can be satisfied by a job of the same name in a workflow the pull request itself adds or edits. The check shows as zizmor in the ruleset run and as <caller job> / zizmor when the workflow is called as a reusable workflow; zizmor-pins and skip-ci-markers show the same way.
Operations
- Emergency. Organization admins are the ruleset’s only bypass actors, in pull request mode: if the Gate itself breaks (GHCR or Actions outage), an admin merges the pull request anyway. Nobody can push to the default branch around it. A finding merged that way keeps failing every later pull request of the repository until it is fixed.
- Gated repositories drop their direct call. Once the ruleset gates a repository, remove any
workflow_callofzizmor-scan.ymland any zizmor job of its own, or the scan runs twice. - zizmor upgrades are one admin step. A ruleset bump reaches every code repository at once, so: approve the update on the Renovate Dependency Dashboard, cut the release, read the dashboard’s weekly bump report (it scans every code repository with the candidate release and opens a
baseline/zizmor-bumppull request), then merge that pull request and apply the new ruleset SHA. - No
cancel-in-progress. GitHub does not allow it in a workflow required by a ruleset. A scan in progress on a pull request finishes; the next push waits as the one pending run of the group, and a later push cancels that pending run and takes its place, so only the latest commit waiting is scanned.
Dependencies
- Actions: only
actions/checkout. If the organization restricts which actions may run, the allowlist must include it. The zizmor image is started withdocker runin a script, which the actions allowlist does not cover. - This repository is public: the scan checks out
scripts/zizmor/from it with the target repository’s own token, which can read any public repository. - GHCR: every run pulls the zizmor image from
ghcr.io, so every pull request in the organization depends on it being reachable. - Updates: Renovate proposes new zizmor versions with their digest. GHCR publishes no release timestamps, so the 7-day cooldown cannot apply automatically; the update waits for approval on the Dependency Dashboard, given once the same version has been on PyPI for 7 days.
Call it as a reusable workflow
Repositories that want the scan before the ruleset covers them, or on other events, can call it:
name: zizmor
on:
pull_request:
merge_group:
push:
branches: [main]
permissions: {}
jobs:
zizmor:
permissions:
contents: read
uses: Giskard-AI/giskard-actions/.github/workflows/zizmor-scan.yml@<full-commit-sha> # v1.1.0
Its pull_request and merge_group triggers are ignored when called; the caller’s triggers apply, and the base commit is still read from the caller’s event. Its concurrency group appends zizmor-scan to the caller’s workflow name, so it does not collide with a workflow-level group on the caller.
This repository’s own zizmor.yml workflow (job zizmor-online, so the zizmor check name stays unique) runs the same image with the auditor persona and online audits. The online audits are the only thing it adds to the shared scan.
The organization ruleset gates this repository too, so its pull requests run the Gate at the SHA the ruleset pins, not their own copy of zizmor-scan.yml. A change to that workflow or to scripts/zizmor/ is only exercised by tests/ until a release carrying it is pinned.