Skip to content

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.

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 from github.com on 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_CACHE from 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, node16 and node12 actions run automatically on the node24 runtime, as on GitHub) are all supported.
  • Caching (actions/cache and the VM cache disk): actions/cache works 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/registry and ~/.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/bin is not cached (it holds the preinstalled rustup), so what cargo install puts there does not survive into the next run; neither does a project’s node_modules, nor the Nix store.
  • Docker and neocr.io: Because each hosted VM runs a rootful dockerd with buildx and compose, docker/build-push-action and docker CLI commands work directly. Every job is already logged in to neocr.io with 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).

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/secrets
PUT /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.

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.

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.

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”.