Reference
Compatibility matrix
Every feature of GitHub Actions workflow syntax, what Apex Actions does with it, and the conformance fixture that proves it.
Compatibility is not a promise; it is a test suite. Every row below comes from the conformance corpus, and a row may only name a fixture that exists — so this table is the scope of what is covered rather than a summary of it. If a behaviour is not listed, it is not claimed.
Of 44 features, 35 are supported, 7 are partial and 2 are not supported. The Proved by column names the fixtures. A row with no fixture is claimed and not yet demonstrated, and says so — "supported" and "proved supported" are different sentences.
You can check any of this yourself against your own repositories, before moving anything:
apex migrate scan your-org # or: apex migrate scan --path .The apex CLI is on npm as @apex-actions/cli, and needs Node 24 or later.
Triggers
| Feature | Status | Proved by | Notes |
|---|---|---|---|
on: push — branches, branches-ignore, tags, tags-ignore | Supported | branch-glob-release, trigger-refused-branch-filter | A trigger that refuses says so in a form a consumer can act on: the plan carries not_triggered.{code, subject, filter} — the first filter to refuse, in GitHub's evaluation order, and the list it was tested against as written. GitHub reports nothing at all here. |
on: push / on: pull_request — paths, paths-ignore | Supported | paths-filter-skip, paths-zero-depth-glob | A push payload's commit lists supply the changed files when the caller does not; a pull request's files come from the API. With no file information the workflow runs, which is GitHub's behaviour. |
on: pull_request / pull_request_target — activity types, base-branch filters | Supported | paths-filter-skip, pull-request-target | Defaults to opened, synchronize, reopened when types is absent. pull_request_target runs the default branch's copy of the workflow at its last commit, never the pull request's head, with the repository's secrets and no approval for a fork, as on GitHub; every pull request activity type can trigger it, closed and labeled included. |
on: workflow_dispatch — typed inputs (string, boolean, number, choice, environment) | Supported | workflow-dispatch-inputs | — |
on: workflow_call — inputs, secrets, outputs | Supported | reusable-local, reusable-remote | — |
on: workflow_run — workflows, activity types, branch filters | Supported | workflow-run-downstream | Emitted by the control plane when a run is created and when it finishes. Always planned from the default branch, as on GitHub. |
on: repository_dispatch — types, client_payload | Supported | repository-dispatch | POST /api/v1/repos/:owner/:repo/dispatches. Always planned from the default branch. |
on: schedule — five-field cron, lists, ranges, steps, month and day names | Supported | schedule-nightly | UTC, as on GitHub. The non-standard @daily, ?, L, W and # extensions are rejected, because GitHub rejects them. |
Other webhook events (issues, release, create, …) | Partial | — no fixture yet | The engine matches any event name and its activity types generically, so planning is correct. The control plane only ingests push, pull_request, workflow_dispatch, workflow_run and repository_dispatch today; the App does not subscribe to the rest, so they never arrive. |
Workflow-level keys
| Feature | Status | Proved by | Notes |
|---|---|---|---|
name, run-name | Supported | needs-dag | An unnamed workflow is named after its file, as on GitHub. |
env, defaults.run.shell, defaults.run.working-directory | Supported | node-ci-matrix | — |
concurrency — group, cancel-in-progress | Supported | concurrency-groups | — |
permissions — read-all, write-all, per-scope mapping | Partial | permissions-oidc | Resolved as on GitHub: a mapping is total, and a scope it does not name is none. It narrows GITHUB_TOKEN, which is minted per job for its own repository, and gates the OIDC token. The token can carry only what the Apex App holds: issues, statuses and deployments are not among them. |
Jobs
| Feature | Status | Proved by | Notes |
|---|---|---|---|
needs — DAG, cycle detection, skipped-dependency propagation | Supported | needs-dag | — |
if at job level, including status functions with needs | Supported | needs-dag, workflow-run-downstream | Decided at plan time where it depends only on github, inputs and vars; deferred to the scheduler otherwise. |
runs-on — label, label list, group + labels | Supported | node-ci-matrix | — |
strategy.matrix — axes, include, exclude, include-only matrices, whole-matrix expressions, and expressions in individual values | Supported | node-ci-matrix, matrix-include-only, matrix-named-job, matrix-value-expression | A matrix with only include is exactly its entries — one job each. It planned as a single job carrying the last entry until 2026-09-05, which is what matrix-include-only pins. A single value may also be an expression, and GitHub evaluates it before the leg is named or scheduled; until 2026-09-08 only a whole-axis expression was evaluated, so a list of expressions passed through as literal text and the job carried ${{ … }} as its runs-on — unschedulable, and silent. matrix-value-expression pins it. |
strategy.fail-fast, strategy.max-parallel, strategy.job-index, strategy.job-total | Supported | node-ci-matrix | — |
outputs — job outputs and needs.<job>.outputs | Supported | needs-dag, reusable-remote | — |
environment — name and url | Supported | repository-dispatch, permissions-oidc | Protection rules — reviewers, wait timers, branch policies — are configuration outside the workflow file and are enforced by the control plane. |
concurrency at job level | Supported | concurrency-groups | — |
permissions at job level | Partial | permissions-oidc | Replaces the workflow's set outright; the two are never merged. Enforced as the workflow-level row says. |
container, services | Supported | node-ci-matrix | Passed to the runner verbatim; the executor owns the semantics. A service answers by its id and on the job's own localhost:<host port> — GitHub's container-job and VM-job forms both. |
timeout-minutes, continue-on-error | Supported | node-ci-matrix | — |
uses — reusable workflows, local (./…) and remote (owner/repo/…@ref) | Supported | reusable-local, reusable-nesting-depth, reusable-remote | Nesting runs to the caller plus ten reusable levels and refuses the next, as github.com did when measured on 2026-09-26. |
with, secrets, secrets: inherit on a reusable-workflow job | Supported | reusable-local, reusable-remote | — |
Steps
| Feature | Status | Proved by | Notes |
|---|---|---|---|
run, shell, working-directory | Supported | node-ci-matrix | — |
uses — remote actions (owner/repo@ref), local actions (./path), Docker actions | Supported | node-ci-matrix | Fetched by the runner over HTTPS as source tarballs; JavaScript, composite and Docker actions all execute. |
with, env, id, name, if, continue-on-error, timeout-minutes | Supported | node-ci-matrix | — |
Workflow commands — set-output, add-mask, add-path, set-env, group, error, warning, notice, step summary | Supported | — no fixture yet | Runner-side; proved by the runner's own conformance suite rather than by a plan golden. As on github.com: set-output and save-state still work, with a deprecation warning; set-env and add-path fail their step unless ACTIONS_ALLOW_UNSECURE_COMMANDS is true. |
Expressions
| Feature | Status | Proved by | Notes |
|---|---|---|---|
Operators, literals, indexing, * filters | Supported | core.jsonl | — |
contains, startsWith, endsWith, format, join, toJSON, fromJSON, hashFiles | Supported | functions.jsonl | — |
success(), always(), cancelled(), failure() | Supported | functions.jsonl | — |
Contexts — github, env, vars, job, steps, runner, secrets, strategy, matrix, needs, inputs | Supported | contexts.jsonl | — |
| Error reporting for malformed expressions | Supported | errors.jsonl | GitHub's arithmetic-free grammar: ${{ 2 * 3 }} is a syntax error there and here. |
Services around a workflow
| Feature | Status | Proved by | Notes |
|---|---|---|---|
actions/cache and every setup-* action's cache: option | Supported | — no fixture yet | The control plane serves GitHub's v1 _apis/artifactcache protocol; proved by a contract test against the real @actions/cache package. |
actions/upload-artifact, actions/download-artifact | Supported | — no fixture yet | Twirp ArtifactService plus Azure block-blob upload; proved against the real @actions/artifact at two client generations, including upload-artifact@v7's archive: false, which uploads one file as it is and downloads it back as that file. |
OIDC — id-token: write, getIDToken(), cloud federation | Supported | permissions-oidc | The control plane is the issuer: a JWKS document, a discovery document and a per-job token endpoint the runner advertises as ACTIONS_ID_TOKEN_REQUEST_URL. |
Cross-run download-artifact inside a workflow | Not supported | — no fixture yet | It does not use the artifact protocol at all, but the public GitHub REST API through octokit. Serving it needs a GitHub-API-shaped surface; apex artifact download covers the scriptable case. Tracked in the backlog. |
GitHub Packages (npm.pkg.github.com, ghcr.io) with the job's own GITHUB_TOKEN | Not supported | — no fixture yet | GitHub Packages accepts a personal access token (classic) or GitHub Actions' own GITHUB_TOKEN, and nothing else. On Apex a job's GITHUB_TOKEN is a GitHub App installation token, and the registry refuses it for publishing and for installing, whatever permissions: packages asks for. Create a personal access token (classic) with write:packages to publish or read:packages to install, store it as a secret, and pass that instead, e.g. NODE_AUTH_TOKEN: ${{ secrets.PACKAGES_TOKEN }}. |
concurrency: and schedule: in local mode (apex run) | Partial | — no fixture yet | A local run is one run with nothing to wait for: concurrency: groups are accepted and have no effect, and a schedule: trigger never fires on its own — apex run runs the workflow once, as a schedule would. |
actions/cache and artifacts in local mode (apex run) | Partial | — no fixture yet | A local run serves both protocols itself: upload-artifact and download-artifact within the run, written to .apex/artifacts/, and actions/cache kept across runs in .apex/cache/ (10 GiB, least recently used first). Proved with the stock actions on Docker Desktop and on a native Linux Docker engine; rootless Docker is not yet proven. One cache scope per checkout, whatever the branch. |
| Environments — required reviewers, wait timers, branch and tag policies, deployment records | Partial | — no fixture yet | Enforced by the control plane and exercised by its own tests rather than a plan golden, but set only through its API, which customer tokens cannot reach. GitHub's own environment rules are not read. A required reviewer is a GitHub user or an Apex Account role, never a GitHub team, and must also hold the role's deployments:write. |
github.actor, github.triggering_actor — who started or re-ran a run | Partial | — no fixture yet | The person's own GitHub login, as on GitHub. What it no longer implies is write access to the repository: on GitHub whoever dispatches or re-runs holds write, and on Apex they hold the Account role that grants actions:write, which an Owner may give to someone with only read on GitHub. A workflow that treats the actor as "someone who can push here" — to skip a check, or to decide whether to publish — should check the permission itself. Deliberate (Apex decides access, not GitHub). |
Runners
Not part of the matrix above, because it is a decision about what we run rather than a question about workflow syntax: Apex Actions offers Linux runners on x86-64 and arm64. Windows and macOS are not offered and are not on the roadmap.