act workflow files
Nostr CI workflows use GitHub Actions syntax and run through act. Most files copied from .github/workflows/ work unchanged. This document lists exactly what the coordinator accepts, what it refuses, and what it adds. Behaviour that only the operator controls is in configuration.md; the design reasoning is in architecture.md. For the model behind the events, read Understanding Nostr CI.
Where files live
text
.ngit/act/workflows/<name>.yml # or .yamlFiles in .github/workflows/, .forgejo/workflows/, .gitea/workflows/, and .gitlab-ci.yml are detected but never executed. The separate directory lets one repository target Nostr CI without changing what its GitHub or Forgejo mirrors run, and lets you keep the two sets different when you want to.
A coordinator reads the files at the exact commit that triggered the run, and every result names the file by path and by the SHA-256 of its content at that commit.
Nothing runs until service is requested
A coordinator advertises whether ordinary workflows are automatic or request-required. Under the default, request-required policy, push and pull request workflows wait until a maintainer publishes a standing Service Request naming that coordinator and the repository. The request is not tied to a commit; a Service Stop ends it. Manual triggers are one-shot and need no standing request. Maintainers request service from gitworkshop.dev or with ngit ci request, as described in Request service; the event shapes are in NIP.md.
Triggers
on: key | Supported | Notes |
|---|---|---|
push | Yes | Fires when the signed repository state adds or moves a branch or tag |
pull_request | Yes | Fires on a Nostr PR or PR update; the PR head is the checkout |
workflow_dispatch | Replay only | Not a trigger, but a manual run uses this event if the file declares it |
schedule | No | Nothing fires on a timer |
| Anything else | No | Ignored |
yaml
on:
push:
branches: [main, "release/**"]
tags: ["v*"]
# Or use branches-ignore / tags-ignore, not both forms for one ref type.
pull_request:A manual run, from ngit ci trigger or GitWorkshop's retry button, replays any file regardless of its on: clause. It runs under workflow_dispatch if declared, otherwise under push or pull_request, and its results say manual.
Ref filters
branches, branches-ignore, tags, and tags-ignore accept exact names, *, prefix*, and prefix/**. Combining an include and an ignore filter for the same ref type is invalid on GitHub and the coordinator treats it the same way: the workflow does not run. A push with no filters runs for every branch and tag; declaring only branches excludes tag pushes, and only tags excludes branch pushes.
Path filters
paths and paths-ignore use GitHub glob syntax (*, **, ?) on both triggers. Inside paths, ! patterns exclude files; positive and negative sets are matched independently, so order-sensitive re-inclusion is not supported. A paths list must contain at least one valid positive pattern; invalid globs and exclusion-only lists fail closed and never match.
Path filters are not evaluated for tag pushes. For a push, the changed set is the diff from the previous commit at that ref. For a PR it is the diff from the merge base with the target repository's default branch. That default comes from the latest accepted repository state's signed HEAD; when the state does not carry one, the coordinator resolves the target clone server's HEAD, falling back to master and then main. Contributor-provided PR clone URLs supply the PR history but never choose the base. When the changed set cannot be computed (first push of a branch, a force push that discarded the old commit, an incomplete repository state, or a PR more than 256 commits from its base) the workflow runs as if it had no path filter.
runs-on decides who runs the file
A coordinator claims a workflow as a whole file, and only when it supports the runs-on labels of every job. A file nobody can serve is skipped silently: no run, no result, no error. That is deliberate, so a coordinator on the right platform can pick it up instead.
- Labels must resolve statically: a string, a list,
group.labels, or amatrix.<key>expression over a static matrix, includingincludeentries. Any other expression makes coordinators skip the file. - A list is satisfied by any one label. Matching is case-insensitive.
- ngit-ci's default labels are
ubuntu-latest,ubuntu-24.04, andubuntu-22.04. Operators may add others, such asubuntu-24.04-arm. - macOS and Windows labels cannot be served. Jobs run in Linux containers.
Check what your coordinator advertises before choosing labels: Choose a coordinator.
More than one architecture
Use one file per architecture: ci.yml with runs-on: ubuntu-latest and ci-arm.yml with runs-on: ubuntu-24.04-arm. Each file is claimed by the matching coordinator, and clients combine the independently signed results per commit.
A single file whose jobs span architectures, or whose matrix.os axis does, runs only where one coordinator supports every label at once. Avoid cross-architecture needs, such as a release job that needs both build-amd64 and build-arm64: job chaining is resolved inside one act invocation, so that fan-in can never be split across machines. Splitting a file across coordinators by needs-connected job groups is on the roadmap.
Jobs
- Independent jobs run in parallel, up to the executing host's CPU count.
needs:ordering andneeds.<job>.outputs.*work as on GitHub.- A static
strategy.matrixworks; matrix values built from expressions do not. - There is no chaining across files or across runs.
- The whole run is subject to the coordinator's timeout, 30 minutes by default. On expiry the workflow concludes
timed_outand a diagnostic Job Result namedngit-ci timeout diagnostic(job id__ngit_ci_timeout__) carries the partial output: setup, VM boot, checkout, Docker readiness, and workflow steps. It is not a job you declared.
Refused by default
The operator owns the job container, so under the default policy these are refused, and the refusal is published as a startup_failure Workflow Result rather than a silent skip:
| Declaration | Default outcome |
|---|---|
container: or services: with options or volumes | Refused |
Any container: or services: when the operator sets container options | Refused |
Job-level uses: (reusable workflows) | Refused |
| Composite actions used from a step | Allowed |
An operator who trusts workflow authors can switch to the workflow policy, which restores act's native behaviour. See job container policy.
The runtime environment
- Jobs run in act's
catthehacker/ubuntu:act-*images, not GitHub's runner VMs. A GitHub-compatible full runner image is over 18 GB and bundles third-party and proprietary software; act's lighter images deliberately do not reproduce it. Tools you expect may be missing or differ in version, path, or service configuration. Install what you need with a setup action or a package step. - Put
actions/checkoutbefore any step that reads the repository. It maps to the coordinator's checkout of the trigger commit, with Git metadata. The step is required when the coordinator copies workspaces through the Docker API, as the supplied Compose deployments do. Checking out a different private repository does not work. - Outbound network access is available. There is no container daemon, so
dockercommands fail unless the operator opted in. - These variables are set on every job:
| Variable | Value |
|---|---|
GITHUB_SHA | The commit that ran |
GITHUB_REF | The Git ref for a push, or refs/pull/ngit for a PR unless the trigger supplied one |
NGIT_CI_REPOSITORY | The repository coordinate, 30617:<pubkey>:<identifier> |
NGIT_CI_TRIGGER_EVENT | The id of the Nostr event that triggered the run |
For a tag-triggered run, the exact refs/tags/<name> ref is present in the checkout. Annotated tags retain their tag object, message, tagger, and date. The checkout remains shallow, so unrelated history and refs may be absent.
There is no GITHUB_TOKEN and github.token is empty. Actions that require a token need an explicit value; see the cache note below.
To use ngit or a nostr:// remote inside a job, add the setup action, which installs ngit and git-remote-nostr from a checksum-pinned manifest and also works on GitHub-hosted runners:
yaml
- uses: danconwaydev/setup-ngit@v3
with:
version: 3.0.0 # optionalSecrets
The secrets context is populated only with names provisioned for this repository, and only when the run's trigger was authored by a confirmed maintainer: a push, or a PR or PR update authored by a maintainer. A third-party PR always runs with empty secrets, so a workflow that must work for contributors cannot depend on one. Anything not provisioned is empty, so everything else the job needs must be fetchable anonymously. Maintainers provision values from gitworkshop.dev, encrypted to the coordinator as a kind-29846 Repository Secret Update, or the operator supplies them out of band; see Configure secrets and per-repo secrets.
Names match [A-Z_][A-Z0-9_]*. Names that collide with runtime variables are rejected, including PATH, HOME, CI, DOCKER_HOST, NIX_CONFIG, and anything starting GITHUB_, NGIT_CI_, RUNNER_, or ACTIONS_.
One name is special. If a maintainer provisions GH_READ_TOKEN, the coordinator also writes it into NIX_CONFIG as a GitHub access token for authorised runs, so locked flake inputs of type github fetch without a workflow change. It authenticates nothing else, and it remains available as ${{ secrets.GH_READ_TOKEN }} for steps that configure other tools. See GH_READ_TOKEN and Nix GitHub inputs.
If the repository's secrets are bunker-sealed and the decryption bunker does not respond, a run that was authorised to receive them fails with startup_failure rather than running without secrets. The published diagnostic Job Result carries the reason.
Artifacts
actions/upload-artifact@v4 and download-artifact work within one run. When the operator has enabled Blossom uploads, every file inside an uploaded artifact is published as its own artifact entry on the Job Result, with a Blossom URL that embeds the file's SHA-256. GitWorkshop lists them with a copyable hash, and a later release step can fetch and verify them.
The official upload action may print a GitHub-shaped artifact-url. Under ngit-ci this is compatibility output from act, not a public download URL: act first stages the artifact on its per-run local server, then ngit-ci uploads the files to Blossom after the workflow finishes. Use the URLs in the signed Job Result for public downloads.
The coordinator matches each staged archive to the runtime outputs of the concrete job that uploaded it, including individual matrix jobs, so a Job Result advertises only its own files. Uploads hidden inside a composite action or reusable workflow need the upload step's outputs to stay visible, or the files are omitted rather than misattributed. Third-party Loom adapters must implement the optional artifact-streaming extension in execution-adapter-protocol.md to carry uploads. Without Blossom enabled, pass data between jobs with outputs or combine steps into one job.
Public outputs and PR previews
Ordinary job outputs become public output entries on the Job Result:
yaml
jobs:
preview:
runs-on: ubuntu-latest
outputs:
nsite_preview: ${{ steps.publish.outputs.url }}
steps:
- id: publish
run: |
# Publish the preview, then expose its public URL only after success.
preview_url=https://preview.example
printf 'url=%s\n' "$preview_url" >> "$GITHUB_OUTPUT"The coordinator publishes that value on the job's kind-9841 event as ["output", "nsite_preview", "https://preview.example"]. Only literal values and a direct steps.<id>.outputs.<name> expression resolve. Each value is limited to 8 KiB and a job to 64 KiB of outputs in total; files belong in artifacts. An output that was declared but never set is published as omitted with a reason (missing, oversized, or unresolved) instead of an empty string, and an empty value is a real output. Output values are public Nostr tags: never put a credential in one.
Nsite preview convention. An output named nsite or starting nsite_ whose value is the URL of a successfully published nsite is recognised by GitWorkshop as a pull request preview and shown with an "Open nsite preview" button and an untrusted-content warning, because the PR's code controls the value. Create a fresh Nostr identity inside the job rather than injecting a maintainer key; the signed CI result is the durable link between the PR, the commit, and that disposable identity. ngit.dev's own documentation workflow does exactly that with ngit account create --local followed by ngit nsite publish. Choose Blossom servers that accept ephemeral upload identities and treat limited retention as acceptable; if the upload or the manifest publication fails, fail the job without setting the output. Long-lived production sites still need a maintainer-authorised signer and belong in trusted runs.
Caching
The coordinator runs an actions/cache-compatible server by default, scoped by repository and by trigger author's trust, so a third-party PR never reads or writes the maintainer cache. Use actions/cache@v4 or nix-community/cache-nix-action as usual, and mark cache steps continue-on-error: true so a coordinator with caching disabled still runs the job:
yaml
- uses: cachix/install-nix-action@v31
with:
nix_path: nixpkgs=channel:nixos-unstable
- name: Restore Nix store cache
continue-on-error: true
uses: nix-community/cache-nix-action@v7
with:
primary-key: nix-${{ runner.os }}-${{ hashFiles('**/*.nix', 'flake.lock') }}
restore-prefixes-first-match: nix-${{ runner.os }}-
token: unused
- name: Restore Rust build cache
continue-on-error: true
uses: actions/cache@v4
with:
path: |
~/.cargo/git
~/.cargo/registry
target
key: rust-${{ runner.os }}-${{ hashFiles('**/Cargo.toml', 'Cargo.lock', 'flake.lock') }}
restore-keys: |
rust-${{ runner.os }}-The token: unused placeholder is required: cache-nix-action reads its token input unconditionally, its default is the empty github.token, and without a value both phases abort. Any non-empty string works because the token only reaches GitHub through the action's purge feature, which is off by default; do not enable purge. Treat cache contents as an optimisation, never as an input to correctness or secrets. The partitions are described in Cross-run build cache.
Running the same workflow on GitHub
A coordinator runs only the workflow paths it advertises, under .ngit/<runner-format>/workflows/, so it never touches .github/workflows/ and the two systems run side by side. A check that must run on both exists in both places. Differences to expect when you copy a file across: no GITHUB_TOKEN, lighter images, refused container: blocks, and whole-file claiming by runs-on.
Reading the results
For each run, a coordinator publishes a Workflow Progress marker (kind 39842) while the run is queued or in progress, a Job Result (kind 9841) per job, and a Workflow Result (kind 9842) with the conclusion. All of them name the commit, the workflow path with its SHA-256, and the normalised trigger, and clients such as gitworkshop.dev show them against commits and PRs. When several coordinators cover different platforms, each publishes its own signed result. The event shapes are in NIP.md.
If nothing ran, the usual causes, in order: no watching coordinator supports the file's runs-on labels, the trigger or its filters did not match, or runs-on could not be resolved statically. A file the coordinator refused produces a startup_failure result, not silence. See When no run appears.