Actions, secrets and variables
This page covers what a job’s steps can reach besides their tools: actions, secrets, the job token and environment variables. For the tools themselves see The job environment.
Actions
Section titled “Actions”uses: steps run the real upstream GitHub Actions — both top-level
owner/repo@ref actions and sub-path actions like actions/cache/restore@v4.
Real upstream actions, cached by the platform. Hosted and self-hosted
runners execute the upstream action.yml for actions/checkout,
actions/cache (including save and restore), actions/setup-node,
setup-python, setup-go, setup-java, setup-dotnet, ruby/setup-ruby,
dtolnay/rust-toolchain, docker/login-action, docker/metadata-action,
docker/build-push-action, and any third-party action:
- Action mirror: When a run is queued, the server resolves each action’s
ref to a commit SHA (cached with a short TTL and served stale if GitHub is
unreachable) and stores the archive in Garage under
owner/repo@sha. On hosted VM runners, those action trees are mounted into the VM on a read-only EROFS disk before the first step runs — jobs do not clone fromgithub.comon every run. - Tool-download mirror: When a
setup-*action requests a version not already baked into the VM root image, the platform pre-hydrates$RUNNER_TOOL_CACHEfrom a SHA-256-verified mirror in Garage so the action finds a local tool-cache hit instead of fetching from upstream release hosts on every job. - Action runtimes: Composite (
using: composite), Docker (using: docker), and Node.js (using: node24;node20,node16andnode12actions run automatically on thenode24runtime, as on GitHub) are all supported. - Caching (
actions/cacheand the VM cache disk):actions/cacheworks against the platform’s GitHub Actions cache service ($ACTIONS_CACHE_URL). On hosted runners, each job also gets an automatic copy-on-write NVMe cache disk that persists Cargo’s build output ($CARGO_TARGET_DIR), Cargo’s download caches (~/.cargo/registryand~/.cargo/git),~/.cache, the Go/npm/bun/pnpm/Yarn download caches, and Docker’s images and build cache across runs without needing an explicit cache step. Only trusted runs on a protected branch write it back; see what is cached.~/.cargo/binis not cached (it holds the preinstalledrustup), so whatcargo installputs there does not survive into the next run; neither does a project’snode_modules, nor the Nix store. - Docker and
neocr.io: Because each hosted VM runs a rootfuldockerdwithbuildxandcompose,docker/build-push-actionanddockerCLI commands work directly. Every job is already logged in toneocr.iowith its job token, so publishing the repository’s own image needs no login step; see the container registry guide.
Artifact actions (upload-artifact and download-artifact). Upstream
@actions/artifact v4 hard-codes a check for a GitHub-hosted runtime URL, so
actions/upload-artifact and actions/download-artifact are implemented
natively inside the runner against neosource’s artifact store. They implement
v4 semantics (@v4 or a v4.* tag/SHA); @v1, @v2, and @v3 fail
immediately with GitHub’s deprecation error. In neo run, actions/checkout
is also handled natively as a no-op so local runs test your uncommitted
working tree in place rather than overwriting it.
Every uses: value is parsed once, at workflow validation: ./path, docker://image, or owner/repo[/subpath]@ref. A malformed reference (no @ref, an empty ref or owner, owner//repo@v1, a second @, ${{ }} expressions, path traversal, whitespace or control characters) is rejected when the workflow is validated rather than failing mid-job. As on GitHub, the native actions above need an explicit @ref: a bare actions/upload-artifact, actions/download-artifact or (under neo run) actions/checkout is a validation error.
Local actions. A uses: ./path reference (for example
./.github/actions/setup) runs the action stored at that path in your own
repository, resolved against the job workspace after actions/checkout. A
uses: ./… inside a composite action resolves against the same workspace
root. The path and any symlink it follows must stay inside the repository.
A step-level uses: docker://… image reference or a ../path outside the
repository fails at parse time, taking the whole file with it. Reusable
workflows (jobs.<id>.uses:) are not supported — such a job is skipped with
a warning while its siblings still run (unless another job needs: it, which
is a parse error).
Secrets
Section titled “Secrets”Reference secrets with the usual expression syntax:
steps: - run: ./deploy.sh env: API_TOKEN: ${{ secrets.API_TOKEN }}Secrets are managed per workspace through the API:
GET /api/workspaces/{workspace_id}/ci/secretsPUT /api/workspaces/{workspace_id}/ci/secrets/{name}DELETE /api/workspaces/{workspace_id}/ci/secrets/{name}The listing returns names and metadata only, never values. On hosted runners, secrets are fetched per job rather than held on the runner.
The job token
Section titled “The job token”Every job gets a token of its own without creating any secret, under two names
for one value: secrets.GITHUB_TOKEN and secrets.NEOSOURCE_TOKEN, and the
$GITHUB_TOKEN and $NEOSOURCE_TOKEN environment variables. It fetches the
repository, reads and writes the run’s cache and artifacts, and pushes and
pulls the repository’s own image on neocr.io
(what it reaches). It
is not an API token. neo reads $NEOSOURCE_TOKEN, so a step that runs neo
against the API sets that variable to a workspace token in its own env:,
which wins over the job’s.
Secret names starting with GITHUB_ or NEOSOURCE_ are reserved for names the
platform provides, and cannot be created.
Environment variables
Section titled “Environment variables”env: can be set at workflow, job and step level. Precedence is
step > job > workflow — a workflow-level env reaches every job, and a job
or step redefining the same key wins.
Expression contexts available to ${{ … }} include env, steps, needs,
matrix, github, runner, inputs, secrets, vars, job and strategy.
neosource is another name for github: ${{ neosource.sha }} is
${{ github.sha }}, field for field. Likewise every GITHUB_* variable the
runner sets is also set as NEOSOURCE_* with the same value, and the file
variables ($NEOSOURCE_OUTPUT, $NEOSOURCE_ENV, $NEOSOURCE_PATH,
$NEOSOURCE_STEP_SUMMARY, $NEOSOURCE_EVENT_PATH) name the same files as
their GITHUB_* twins. The NEOSOURCE_* twin always carries the runner’s
value: if a workflow overrides a GITHUB_* variable through env: or
$GITHUB_ENV, its twin is not changed (GitHub documents these variables as
not to be overwritten). A docker:// action gets only the variables GitHub
passes to it, plus their twins. A workflow that uses these spellings will not
run on GitHub.
Changed files
Section titled “Changed files”The run’s changed paths, the set
pathsChanged()
reads, are available to steps and scripts. They exist only under
neosource, never as github.changes; github.event.pull_request.changed_files
keeps GitHub’s meaning, a count.
| Name | Value |
|---|---|
neosource.changes.complete |
true when the list below is the exact set, false when it is unknown |
neosource.changes.files |
The changed paths, when complete; absent otherwise |
neosource.changes.base |
The commit the run compares from, or '' when there is none |
neosource.changes.head |
The commit it compares to, or '' when there is none |
$NEOSOURCE_CHANGED_FILES |
Path of a file listing the changed paths, each followed by a NUL byte |
$NEOSOURCE_CHANGED_FILES is set, and the file written, only when the set is
complete. An empty file means nothing changed; an unset variable means the
set is unknown (over 1000 paths, a fork pull request, a manual or scheduled
run, no base). Read it NUL-safely:
- run: | if [ -z "${NEOSOURCE_CHANGED_FILES:-}" ]; then echo "changed files unknown: checking everything" exec ./check-all.sh fi xargs -0 -r ./check.sh < "$NEOSOURCE_CHANGED_FILES"The set is computed only for a workflow whose file names it: pathsChanged(,
neosource.changes or NEOSOURCE_CHANGED_FILES in a value, not a comment. A
script or local action in another file that reads it sees the set as unknown
unless the workflow names it, for example by passing it along:
run: ./ci/check-changed.sh "$NEOSOURCE_CHANGED_FILES".
A script or step if: that reads neosource.changes.files must check
neosource.changes.complete first: an incomplete set is not “no files”.
pathsChanged() already does this and is the better way to gate a step.
In a job if:, reading files from an incomplete set is unknown and runs
the job. A workflow that reads neosource.* will not run on GitHub.
Rollout. A job-level if: pathsChanged(...) is decided by the server and
works with any runner. Using pathsChanged() in a step if:, or in env or
run, needs an up-to-date self-hosted ns-runner: an older runner does not
know the function and fails the step with “unknown expression function”.