Skip to content

How CI works here

neosource runs CI from workflow files stored in your repository. The shape will be familiar if you have used GitHub Actions, but two things differ enough to cover before you write anything: where the files live, and which triggers actually fire.

.neosource/workflows/
├── ci.yml
└── nightly.yaml

Three rules, and each of them has bitten someone:

  • The directory is .neosource/workflows/, not .github/workflows/.
  • Files must end in .yml or .yaml.
  • Only the top level of that directory is read. Files in subdirectories are not picked up.

.github/workflows/ is discovered, but never runs

Section titled “.github/workflows/ is discovered, but never runs”

A .github/workflows/ directory is read and parsed — with the same parser that handles .neosource/workflows/ — but nothing in it is ever registered or dispatched. Its files are recorded as an inert report, not as workflows.

You see that report on the repository’s Actions tab while the repo has no runs of its own: each discovered file is listed, along with whether it would run as-is and any warnings the parser raised against it. The same data is on GET /api/repos/{owner}/{repo}/workflows as discovered[].

So a repository imported from GitHub does not silently have “no CI” with no explanation — but it does have no running CI until you move the files into .neosource/workflows/. See Coming from GitHub Actions for what to change once you move them.

Workflows are indexed from the default branch only. When you push to it, neosource reads .neosource/workflows/ out of the tree that push just created and replaces the repository’s registered workflow set with what it finds.

Two consequences follow from “replaces”:

  • A workflow file added in a push is registered before that same push’s triggers are evaluated — so a workflow you add can fire on the very push that adds it.
  • A workflow file deleted from the tree is deregistered. There is no stale leftover definition.

Pushes to other branches do not change the registered set. A workflow that only exists on a feature branch is not registered, though it can still run against that branch once it is on the default branch — the definition comes from the default branch, the code under test does not.

The parser accepts more trigger events than the server dispatches. Three events start a run today:

Trigger Fires when
push You push to the repository.
pull_request A PR is opened, or updated with new commits (synchronize).
schedule A cron entry comes due.

workflow_dispatch, repository_dispatch and change parse but do not dispatch. The full status table and the filter rules are in Writing a workflow — including the glob semantics, which are stricter than most people expect (release/* does not match release/v1/rc1).

To start a run by hand, call POST /api/workflow-runs with a workflow_id and a cause. It needs Write permission on the repository.

A concurrency: block works, at both workflow and job level, following GitHub’s rules:

  • At most one run per group is active.
  • At most one run per group is pending. A newer arrival cancels the older pending run and takes its place.
  • cancel-in-progress: true additionally cancels the active run. The default is false.
  • Group names are compared case-insensitively, scoped per repository.

A run held by the gate carries the status concurrency_blocked, so the run list says why nothing is happening. A held job simply stays ready, which already reads as queued.

The gate runs inside the enqueue transaction, so it holds at every entry point — push, pull request, schedule, manual POST /api/workflow-runs, and rerun alike.

Within a run, needs: is enforced server-side. A job waits until everything it depends on has finished, and if a dependency fails, the jobs downstream of it are skipped transitively rather than run against a broken state.

if: conditions are evaluated at both step and job level. A job-level if: follows GitHub’s rules, including the two that surprise people: a skipped dependency does not satisfy success(), so it skips its downstream jobs too; and a condition naming no status function is implicitly &&-ed with success(). Name always(), failure() or !cancelled() to opt out of that. A skipped job shows a green check, not a red one.

Two divergences: cancelling a run cancels always() jobs too, and cancelled() never evaluates true. See Job-level if:.

When a workflow file is indexed, its parse diagnostics are stored alongside it: unknown keys and unsupported triggers as warning, a whole-file parse failure as error. A file that fails to parse is still indexed — as an empty definition that can never run — so it shows up as broken rather than vanishing from the list.

Read them from the workflows API:

GET /api/repos/{owner}/{repo}/workflows

Each workflow in the response carries a warnings array with a severity and a message.

The web UI renders these diagnostics only for discovered .github/workflows/ files, not for your registered workflows. The Actions page lists registered workflows by path and name alone, so one that parsed with warnings — a dropped trigger, a misspelled key — looks completely normal there. Until that gap closes, the API is the only place to see warnings on a workflow that actually runs, and it is worth checking after any migration.

Purpose Endpoint
List a repo’s runs GET /api/repos/{owner}/{repo}/workflow-runs
Runs for one commit GET /api/repos/{owner}/{repo}/commits/{sha}/workflow-runs
One run GET /api/workflow-runs/{run_id}
Jobs in a run GET /api/workflow-runs/{run_id}/jobs
Job logs GET /api/jobs/{job_id}/logs
Follow logs live GET /api/jobs/{job_id}/logs/stream
Test/lint annotations GET /api/runs/{run_id}/annotations
Cancel a run POST /api/workflow-runs/{run_id}/cancel
Re-run POST /api/workflow-runs/{run_id}/rerun
Cancel one job POST /api/jobs/{job_id}/cancel

Full schemas are in the API reference. There is also an Actions view in the web UI covering the run list, the job graph, steps and logs.