Workflows
Run your tests on BTS, against the preview environment they belong to. A workflow is a YAML file in your repo under .bts/workflows/. It runs in a fresh container on the same node and Docker network as the environment, so a test can reach that preview’s real database, its real addons, and its real running app — no fixtures, no tunnels.
The format is deliberately GitHub Actions–shaped. If your test job is already a list of run: steps, it moves over close to verbatim. It is not Actions, though, and the differences are strict — see Porting from GitHub Actions.
Your first workflow
Create .bts/workflows/tests.yml, commit it, and push. The next deploy of any branch runs it.
# .bts/workflows/tests.yml
name: tests
on:
deploy: {} # after this environment's deploy goes live
jobs:
unit:
image: node:22-alpine
steps:
- name: Install
run: npm ci
- name: Test
run: npm test
Results appear in the environment’s Workflows tab in the dashboard, and as a check on the commit.
Triggers
Declare one or more under on:. Every trigger accepts a branches: list of globs (* stops at a /, ** spans them, max 50 entries). With no list every branch matches — except schedule, which defaults to the default branch alone.
| Trigger | When it fires | Use it for |
|---|---|---|
deploy | After the environment’s deploy is live | Anything touching the running app or its database — the preview URL and the data only reflect the new commit at this point. |
push | At webhook time, concurrently with the build | Fast feedback for tests that only need the source tree. The app still serves the previous commit while these run. |
pull_request | PR opened / reopened / ready-for-review, and synchronize if you ask for it | Long suites you only want on PRs. |
dispatch | The Run workflow button, or the API | Ad-hoc runs, with inputs. |
schedule | A cron expression, in UTC | Nightly suites, periodic smoke tests. |
on:
deploy:
branches: ["main", "release/**"]
push: {}
pull_request:
types: [opened, ready_for_review, synchronize]
dispatch:
inputs:
grep:
description: Only run tests matching this
default: ""
required: false
schedule:
- cron: "0 3 * * *"
Draft PRs are skipped, matching how previews already behave. Pull requests from forks never run workflows, on any trigger — deploy, dispatch and schedules included — and whatever the project's fork preview setting. A workflow runs shell commands with project secrets and the preview database on the network, so running one for a fork would hand a stranger your credentials for the price of opening a PR.
A push-triggered run on a brand-new environment waits for that environment’s first deploy — there is no node to run on until then — and starts automatically once it lands.
Path filters
The push and pull_request triggers also take paths: or paths-ignore: — the “don’t run the full suite for a README commit” knob:
on:
pull_request:
paths-ignore: ["**/*.md", "*.md", "docs/**"]
Patterns match the repo-relative file path with the same glob rules as branches: — * stops at a /, ** spans them. That has a consequence worth spelling out: **/*.md does not match a root-level README.md, because **/ demands at least a directory — list *.md too, as above. Up to 50 patterns per trigger, 512 characters each.
paths runs the workflow only when at least one changed file matches; paths-ignore skips it only when every changed file is ignored. Combining the two on one trigger is a parse error — it has two defensible meanings, and silently picking one is worse than making you choose a single form.
What counts as the diff. A push run evaluates the files that push changed — from the webhook payload’s per-commit file lists when they can be trusted (a force push rewrites history the lists don’t describe, and GitHub truncates the list at 20 commits), otherwise from one GitHub compare call against the push’s previous head. A pull_request run on opened, reopened or ready_for_review compares the PR’s base to its head — the PR’s whole diff, exactly like Actions. The one divergence: a synchronize run is synthesized from the push event, so it evaluates the pushed diff rather than re-diffing the whole PR.
When the diff cannot be determined at all, the workflow runs. A path filter is an optimization, and the failure mode of guessing wrong must be a wasted run, never a silently skipped one.
deploy deliberately has no path filter: a deploy-triggered workflow talks to the live preview, and whether the preview changed is not a function of which files did.
Schedules
on:
schedule:
- cron: "0 3 * * *" # 03:00 UTC daily
- cron: "*/30 * * * *" # every half hour
branches: ["staging"]
Standard five-field cron, always UTC. Local time would mean a schedule silently shifting by an hour twice a year and, on the day it shifts backwards, either running twice or not at all — a bug nobody can debug. *, a-b, a,b,c, */n and three-letter month/weekday names all work. @daily and friends do not; write the fields out.
Which environment does a cron run against? With no branches: filter, the default branch’s environment and only that one. This mirrors Actions, where a cron runs on the default branch, and it is what stops a nightly job silently multiplying by the number of open pull requests. Add branches: to opt others in — a long-lived staging preview, say. The filter replaces the default-branch rule rather than adding to it.
A schedule is registered when its environment deploys, by re-reading that commit’s workflow files. So a newly added schedule: block starts firing after the next deploy of the branch it applies to, not the moment you push it — and archiving an environment takes its schedules with it. The run itself uses the commit the environment is currently serving, since the point of running on the environment is to test the preview as it exists.
- Minimum interval 5 minutes; anything tighter is a parse error. At most 4 schedules per workflow.
- A paused environment is skipped, not woken. Pausing says nobody is using this preview, and a cron does not outrank that. The skip and its reason show on the Workflows page.
- After 5 consecutive failures a schedule switches itself off, with the reason next to a Resume button — a cron nobody is watching is exactly what fails quietly for a month.
- Pausing a schedule from the dashboard survives redeploys; reconciling an unchanged file never switches one back on.
Jobs
Job keys must be alphanumeric with dashes or underscores, starting with a letter or digit (^[a-zA-Z0-9][a-zA-Z0-9_-]*$), and at most 100 characters. The key — not the optional name: — is what shows up in the check name, so pick it with that in mind.
env: # workflow-wide, lowest of the three env layers
LOG_LEVEL: info
jobs:
unit: # the job KEY — this is what appears in the check name
name: Unit tests # optional display name, dashboard only
image: node:22-alpine # required
cpu: 1 # cores, 0.1–16, default 1
memory: 2048 # MB, 64–65536, default 1024
timeout-minutes: 20 # 1–60, default 20
working-directory: apps/api
shell: sh # sh | bash | sh-no-errexit | bash-no-errexit
secrets: none # none | all | [NAMED, SECRETS]; default all
runs-on: environment # environment | workflow-nodes — see Where jobs run
env:
LOG_LEVEL: debug # overrides the workflow-level value above
cache: # or the shared 'key:' form — see Caching
- /root/.npm
- /workspace/node_modules
steps:
- name: Install
run: npm ci
- name: Test
run: npm test
e2e:
needs: unit # a bare string is fine; a list also works
image: mcr.microsoft.com/playwright:v1.48.0-noble
steps:
- run: npx playwright test
Jobs with no needs run concurrently. A job whose dependency fails is skipped, not failed, and the run concludes rather than hanging. needs takes either a single job name as a string or a list of up to 10.
working-directory and cache paths are relative to the checkout root and may not contain ..— a path that tries to escape the checkout is a parse error, not a silently clamped path.
Steps
Steps run in order and stop at the first failure unless you say otherwise.
steps:
- name: Lint
run: npm run lint
continue-on-error: true # record the failure, keep going
- name: Test
run: npm test
timeout-minutes: 10
working-directory: packages/core
env:
DEBUG: "1" # step env — the highest-precedence layer
Each step runs through sh -l -e -c, so pipes, &&, redirection and $VAR behave as they look. A single run: command is capped at 8 KB.
Memory
memory: is a hard limit on the job’s container, and one limit for everything in it: every step, and every child of a parallel: block at once. When their processes together reach it, the kernel’s OOM killer stops the largest of them — exit 137, or the test runner’s own exit code when the victim was one of its workers.
BTS reads the container’s memory counters around each step, so a step that lost a process this way says it ran out of memory in its check summary and its log, and says whether it was the job’s own limit or the node running out. Every job also ends its log with its peak against its limit (Memory: peak 12988 MB of the job’s 16384 MB limit (79%)), passing jobs included — the number to size memory: by. Node caps each process’s heap at about 2 GB at typical CI sizes whatever memory: says, so a type-checker that outgrows it fails with JavaScript heap out of memory (exit 134) and needs its own NODE_OPTIONS=--max-old-space-size=….
Timeouts
timeout-minutes bounds the job’s wall clock, and it is enforced in two places because one is not enough. The runner stops the job at its deadline and reports a red check — its clock starts when the job gets a build slot on the node, not when it is dispatched, so queueing is never billed as compute. The control plane settles the job itself if the node has not reported within the timeout plus a fifteen-minute grace, which is the backstop for a runner that stops answering entirely. A check that never resolves blocks a merge exactly as effectively as a red one, minus the explanation.
A job waiting for a free build slot says so in its own log, and gives up after ten minutes rather than queueing behind a wedged build indefinitely. Every step is skipped once the job’s deadline passes; continue-on-error does not apply to a timeout.
The shell, and why a step fails
That is the -e. Without it a shell reports only the last command's status, so a step whose database setup failed on line 1 could still tick green because a redis-cli ping on line 4 succeeded — and the job would fail two steps later with an unexplained auth error, pointing at nothing. A wrong green is worse than a false red.
Override it with shell:, at step, job, or file level — the most specific one wins:
shell: | Runs as | Stops on error |
|---|---|---|
sh (default) | sh -l -e -c | yes |
bash | bash -l -e -o pipefail -c | yes, plus pipefail |
sh-no-errexit | sh -l -c | no |
bash-no-errexit | bash -l -c | no |
jobs:
unit:
image: node:22-alpine
shell: bash # every step in this job, unless it says otherwise
steps:
- run: make test
- run: ./flaky-probe.sh || true
shell: sh-no-errexit # just this step
pipefail is bash-only on purpose: POSIX sh has no such option, and dash — which is /bin/sh on Debian and Ubuntu images — exits non-zero when asked for it. And bash has to actually be in your runner image; the Alpine-based ones ship busybox sh only.
It covers a step that fails, not one that times out. Killing a step kills the docker exec, not the process inside the container, so continuing past a timeout would run the next step alongside an abandoned one — a half-finished npm install still writing node_modules, a dev server still holding a port. A timed-out step ends the job either way.
Where jobs run
Where a project’s jobs run is a project setting (Settings → Resources → Workflows):
- Beside the preview (the default). Every job runs on the node holding its environment, on the preview’s Docker network — everything under What your steps can see applies.
- Workflow nodes. Jobs run on your own private nodes instead — all of them, or the ones you pick — so CI scales separately from the previews: adding a node adds CI capacity. A run’s jobs spread across the nodes, and each job returns to the node that ran it last while that node has a free slot, because its cache lives there. A node takes a job only while its RAM holds the job’s
memory:on top of what the jobs there already claim; otherwise the job waits as Queued, “Waiting for memory”, until one of them finishes.
A job on a workflow node is not on the preview’s network — there is no network between nodes. It gets the source, your variables and secrets, and the preview’s public URLs (BTS_PREVIEW_URL, <SERVICE>_URL), but no addon variables and no <SERVICE>_INTERNAL_URL. A job that needs the preview’s database or internal services asks to stay beside it:
runs-on: environment # file-wide default for every job below
jobs:
unit:
image: node:22-alpine
runs-on: workflow-nodes # this one only needs the source
steps:
- run: npm ci && npm test
integration:
image: node:22-alpine # inherits `environment` — talks to the DB
steps:
- run: npm run test:integration
runs-on:goes on a job or at the top of the file; the job’s own value wins. With noruns-on:anywhere, jobs go to the workflow nodes.- A job with
addons:always runs beside the preview, since its per-run database lives on the preview’s addon. - Without workflow nodes configured,
runs-on:changes nothing — every job runs beside the preview. BTS_RUNS_ONtells a step where it was placed (environmentorworkflow-nodes).- A job on a workflow node doesn’t wait for the environment’s first deploy; a
runs-on: environmentjob in the same run starts once the deploy lands.
What your steps can see
The repo is checked out at the triggering commit and mounted at /workspace, which is the working directory. For a job beside the preview (the default), the environment is on the other end of the network:
| Variables | Where they come from |
|---|---|
DATABASE_URL, REDIS_URL, addon vars | The environment’s own provisioned addons. This is the preview’s real database. |
<SERVICE>_URL, <SERVICE>_INTERNAL_URL | Each running service container of the environment. |
| Your project’s env vars and secrets | The same set the app gets. |
BTS_PREVIEW_URL | The live preview, for end-to-end tests. |
BTS_BRANCH, BTS_COMMIT_SHA, BTS_PR_NUMBER, BTS_ENV_SLUG, BTS_PROJECT_SLUG | Run context. |
BTS_EVENT, BTS_WORKFLOW, BTS_JOB, BTS_RUN_ID | Which trigger and which job. |
BTS_INPUT_<NAME> | Manual dispatch inputs, uppercased. |
CI=true, BTS=true | So tooling behaves like CI. |
Precedence, lowest first: addon vars → service URLs → project env vars → project secrets → BTS_* → the workflow’s env: → the job’s env: → the step’s env:. The file wins over everything because it is the thing the person editing the workflow is looking at, and BTS_* sits below it so a workflow can deliberately override e.g. BTS_PREVIEW_URL to point somewhere else.
That is the point — it means no fixture drift — but a destructive test will destroy preview data. Preview data is disposable by design; a suite that needs a clean slate should ask for a per-run database instead.
A fresh database per job
A hermetic suite — one that runs migrate and seed before testing — should not do that to the database people are reviewing the pull request against. addons.postgres.database: per-run gives the job its own:
jobs:
integration:
image: node:22
addons:
postgres:
database: per-run # a fresh database, dropped when the job settles
steps:
- run: npm run migrate && npm run seed
- run: npm run test:integration
BTS creates a fresh database on the environment’s existing Postgres addon just before the job starts and drops it when the job settles — any terminal state, cancellation included. Same server, new database, so there is no image to pull and no apt-get install postgresql sidecar dance; isolation is database-level, which is exactly the level the preview’s own data needs defending at.
The job’s injected variables are repointed at the per-run database: DATABASE_URL and DB_URL get their path swapped, and PGDATABASE, POSTGRES_DB and DB_NAME are replaced. Host, port and credentials stay the addon’s — so psql, migrations and the app’s own config all land on the fresh database with no step changes.
Those values override a DATABASE_URL stored on the project, and the job’s log says so when they do. Ordinarily a project variable wins over an addon-injected one, but a project that sets its own DATABASE_URL — most do — would otherwise point a job that explicitly asked for isolation straight back at the preview’s database. An env: in the workflow file still wins, since that is the author of the same file speaking.
The database is created owned by the role BTS injects — the environment’s own login where per-environment logins are on (the default), which no other environment’s login can open — which is what makes it writable on PostgreSQL 15 and later: PG15 removed the default CREATE grant on the public schema, so a database owned by someone else answers the first CREATE TABLE with permission denied for schema public — while information_schema reads keep working, which is what makes it look like a connection problem.
It requires the Postgres addon on the environment. A job that asks for per-run and can’t have it fails — loudly, by design, because silently downgrading to the shared database would wipe the preview data the option exists to protect. Postgres only, and per-run is the only value today.
What that inheritance actually means
Read that table as a security statement, not only a convenience one. Every project secret is injected into a container that runs whatever your workflow says — and npm ci alone executes whatever the lockfile resolves to. The default matches the deploy path, since anyone who can push can already read every secret through application code. But a test job is usually the least trusted thing in the environment, so you can narrow it with secrets:, at job or file level:
jobs:
unit:
image: node:22-alpine
secrets: none # no project secrets at all
steps: [{ run: npm test }]
integration:
image: node:22-alpine
secrets: [STRIPE_TEST_KEY] # only this one
steps: [{ run: npm run test:int }]
none withholds every secret, a list withholds all but the named ones (matched case-sensitively), and omitting the key injects them all. A withheld secret is never decrypted on this path at all. It covers both project secrets and this workflow’s own secret variables — none means none, so a job that needs one names it.
secrets: does not cover addon connection variablesDATABASE_URL, PGHOST, PGUSER, PGPASSWORD, REDIS_URL and friends come from the addon layer, and they are the reason a workflow joins the environment’s network at all. This bites in one specific way: psql, pg_dump, redis-cli and the AWS CLI all read connection details from the environment, so a step that sets up a container-local Postgres will find libpq quietly pointing at the preview’s addon instead. Unset them yourself first:
- name: Start a container-local postgres
run: |
unset PGHOST PGPORT PGUSER PGPASSWORD PGDATABASE
su postgres -c "createdb testdb"
Note too that env: can only set a variable, never unset one, and setting one to "" is not the same as it being absent — plenty of config code does process.env.X ?? default, where unset falls through to the default and "" does not. If you need something truly gone, unset it in a step as above, or mark the variable excluded from workflows.
Running steps in parallel
Steps in a job run one at a time, so eight independent checks take the sum of their durations however many cores the job asked for. A parallel: block runs its children concurrently:
steps:
- name: Install
run: npm ci
- parallel:
name: Checks # labels the group in the UI
fail-fast: false # default: run every child to completion
max-concurrent: 4 # default: the job's cpu, min 1, max 16
steps:
- name: Client eslint
run: npm exec -w @acme/client -- eslint "**/*.{ts,tsx}"
- name: Server tsc
run: npm exec -w @acme/server -- tsc --noEmit
- name: Client prettier
run: npm exec -w @acme/client -- prettier --check "**/*.{ts,tsx}"
continue-on-error: true
- name: Package
run: npm run build
A shell loop backgrounds eight commands perfectly well. What it cannot do is give each one a row in the step table, its own log stream, and its own name in the check summary. A group that fails says step "Checks / Client eslint" failed with exit code 1 rather than step "Checks" failed, and the failing child’s output is intact instead of evicted by a chattier sibling.
- The group succeeds only if every child does. A child with
continue-on-error: truenever fails the group — it still shows as failed in its own row, tolerated rather than hidden. fail-fast: false(the default) runs every child to completion.fail-fast: truestops waiting on the siblings at the first failure and marks them cancelled, which is deliberately distinct from failed — eight reds when one check broke sends people to debug the wrong thing.- Env precedence becomes workflow → job → group → step.
- Steps after the group wait for every child. Children count toward the 50-steps-per-job cap. A nested
parallel:is a parse error. - Each child gets its own slice of the transcript budget, so no child can spend another’s; one that outgrows it keeps its head and its tail, where a test runner prints its verdict. Because several write at once, a child’s output is buffered and flushed as one block when it finishes — blocks appear in completion order, the step table in declaration order.
- Children share the job’s
memory:, so four heavy checks at once need four heaps’ worth of it.
Matrix
Four shard jobs differing in one integer are not a dependency graph — they are one job stamped out four times, and writing them out by hand drifts. matrix: stamps them out for you: GitHub’s strategy.matrix, minus the expression language.
jobs:
server-tests:
image: node:22
matrix:
shard: [1, 2, 3, 4]
steps:
- run: npm run test -- --shard=$BTS_MATRIX_SHARD/4
Each axis value is exported to the steps as BTS_MATRIX_<KEY> (key uppercased) — so $BTS_MATRIX_SHARD is the port of ${{ matrix.shard }}.
A matrix job expands at run creation into ordinary jobs named server-tests (shard=1) and so on — each with its own check row, its own log stream, and its own re-run. With more than one axis it is the cartesian product, axes in declaration order: (shard=1, pg=16). Where a parallel: group buys observability within one job, a matrix buys separate jobs — separate checks, separate resource allocations, independently re-runnable.
needs:still names the job key. Depending on a matrix job means depending on all of its combinations, and a matrix job’s ownneedsapplies to every combination.- There is no fail-fast across combinations — combinations are independent jobs, so one shard failing never cancels the others. (Within a job,
parallel:keeps its ownfail-fast.) - Each combination gets its own job-private cache volume. A
cache:with akey:is still shared across the whole group — that is what a key means. - Values are strings (up to 40 characters of
[A-Za-z0-9._/-]— they end up in check names and env vars), numbers, or booleans. Duplicate values within an axis are a parse error. - Caps: at most 3 axes and 10 combinations per job, and at most 20 jobs per workflow after expansion (the plain 10-job cap still applies before it).
Every combination gets its own check — BTS / tests / server-tests (shard=1) (push) — plus one aggregate check, BTS / tests / server-tests (matrix) (push), that appears when the first combination dispatches and concludes when the last settles: failure if any combination failed, cancelled if any was cancelled, skipped only if all were skipped. Mark the aggregate as the required check. GitHub Actions needs a hand-written aggregate job for this; BTS designs it in — so changing the shard count never touches branch protection.
Caching
cache: mounts container paths onto named volumes that survive between runs, so only the first run of a branch pays for the install. The bare-list form gives the job its own volume:
cache:
- /root/.npm
- /workspace/node_modulesTwo jobs that both need node_modules therefore install twice and store twice — which is why workflows get collapsed into one fat job, and a fat job is one check row, one log stream, and one unhelpful red. Give the cache a key: and every job naming it mounts the same volume:
jobs:
install:
image: node:22-alpine
cache:
key: node-modules
hash-files: [package-lock.json]
paths:
- /root/.npm
- /workspace/node_modules
- packages/*/node_modules
steps:
- run: npm ci
lint:
needs: install # one writer, THEN the readers
image: node:22-alpine
cache:
key: node-modules
mode: ro # cannot corrupt what install populated
# no hash-files: the key's identity is inherited from the group
paths: [/workspace/node_modules, packages/*/node_modules]
steps:
- run: npm run lint
needs: install is not decoration. Six jobs starting at once would all npm ci into the same volume and corrupt it; needs: makes it one writer and then N readers, and mode: ro enforces that rather than trusting it.
hash-files is how a cache invalidates. The agent hashes those files from its own checkout — it is the only party that has them — and folds the digest into the volume name, so a lockfile change lands on a fresh volume instead of a stale one. There is no ${{ hashFiles() }} expression to write: name the files and BTS does it. Globs work in both hash-files (up to 20 files) and paths (up to 25).
A key’s identity belongs to the group, not to one job. hash-files and paths are unioned across every job naming the key, so the readers above resolve the same volumes as install without repeating its hash-files: — declaring them once, on the writer, is enough.
Changing paths invalidates the cache, the same way changing a lockfile does. The path list is part of what the cache is, so it is part of its identity: edit it and the next run starts cold, deliberately, rather than mounting the new paths empty alongside the old warm ones.
Globs are expanded before the leaf exists. packages/*/node_modules matches nothing in a fresh checkout — the leaf is the thing being cached — so BTS expands the pattern up to its last wildcard segment (packages/*, which does exist) and re-attaches the tail, giving one volume per package on the first run. A reader whose checkout cannot show the pattern falls back to the paths a previous run recorded on the volumes themselves. A pattern that resolves to nothing either way is skipped, with a line in the log naming it.
The mount is read-write, so a volume shared across branches would let anyone who can open a pull request plant content that the default branch’s workflows later execute. A key shares across jobs, which are all at the same trust level on the same branch. It does not share across branches, which are not.
Was the cache warm?
Every job with a cache: gets BTS_CACHE_HIT set to true or false, so skipping a warm install is an if rather than a hand-rolled stamp file:
- name: Install
run: |
if [ "$BTS_CACHE_HIT" = "true" ] && [ -d node_modules ]; then
echo "cache warm — reconciling only"
fi
npm ci
true means every volume the job mounted was already populated, not that one of them was. A partial cache reports false, and the log names which paths were cold.
Keep using npm ci — which reconciles against the lockfile — rather than npm install, so even a stale cache converges instead of drifting. Prefer a small, specific image (node:22-alpine over node:22): the runner image is pulled once per node, and every run after that starts a container in well under a second.
Concurrency
concurrency: cancel-in-progress # the default
# concurrency: queue # serialize instead
Scoped per (workflow, environment). The default cancels an in-flight run when a newer one starts, on the same reasoning superseded deploys are cancelled: once a newer commit exists, the older answer is not worth waiting for. Use queue when a workflow’s steps can’t safely overlap.
Results and branch protection
One check per job. The exact name matters, because it is the string you put in a branch protection rule:
| Surface | Name |
|---|---|
| Check run (GitHub App installed) | BTS / <workflow> / <job> (<trigger>) |
| Commit status (no App) | bts/wf/<workflow>/<job>/<trigger> |
<workflow> is the file’s name:, or the filename without its extension when there is none. <job> is the job key. <trigger> is deploy, push, pull_request or dispatch.
A check is keyed by (name, commit), and one commit can produce several runs of the same workflow — declaring both push: and deploy: is the recommended split. Without the trigger in the name those two runs would resolve to the same check row and fight over it. So a workflow tests with a job unit on deploy produces BTS / tests / unit (deploy) — require that, not BTS / tests / unit, or you will pin a required check that never appears.
A matrix job produces one check per combination — BTS / tests / server-tests (shard=1) (push) — plus a single aggregate check, BTS / tests / server-tests (matrix) (push), that settles when the last combination does. Require the aggregate, not the combinations, and the branch protection rule survives a change to the shard count.
When a job finishes, its log is on the check: one collapsible section per step, failed steps open, each with the end of its output, so a reviewer without a BTS account can see why a check is red. Your variables’ values and addon passwords are replaced with *** first, as GitHub Actions masks its secrets, and that includes variables not marked secret (a value that is plainly a setting, like production or a port, stays readable). Like Actions, a value a step transformed (base64, say) isn’t caught. Anyone who can read the repository can read these logs, including members you denied workflow logs in BTS, so a project can switch them off in its settings under Resources → Workflows on GitHub; logs already on a check stay there.
Re-run on a job’s check in GitHub re-runs that job; a failed check also offers Re-run failed jobs, and Re-run all checks re-runs everything. The jobs that depend on what you re-run run again too, and the rest are reused as they ended. A re-run keeps the run’s trigger, so its checks replace the red ones and branch protection sees the new result. It always starts from the newest attempt, and is refused while one is still in progress rather than cancelling it. Re-runs are per job, not per step: steps share one container, and a later step runs against what earlier ones installed. If you want finer re-runs, split the steps into jobs. The same buttons are on the Workflows tab, which shows run history, per-step timings, and live logs, with a failing job expanded by default. Failure is reported, not enforced — the deploy already happened; the red check is the signal, and branch protection is what blocks the merge.
Manual dispatch
The Run workflow button on the Workflows page, or:
curl -X POST \
-H "Authorization: Bearer $BTS_API_KEY" \
-H "Content-Type: application/json" \
-d '{"inputs":{"grep":"checkout"}}' \
https://test.buildingswell.com/api/environments/$ENV_ID/workflows/tests/dispatch
Needs workflows:write and deployments:exec — a run executes shell steps with every project secret injected, which is the same capability a container shell gives. A project-scoped API key works, so external tooling can drive workflows for one project without broader access.
Inputs are not secret: they are recorded on the run and exported to every step as BTS_INPUT_*. Pass a credential as a workflow variable instead.
Secrets and variables
Every project variable and secret reaches every workflow by default, which is what makes an integration test against the real preview possible. Two knobs adjust that, both on the project’s Workflows page.
Per-workflow variables. Under Workflows → (pick one) → Variables, add variables scoped to that one workflow. Encrypted at rest like project secrets, injected only into that workflow’s jobs, and layered above the project’s own — so a workflow can carry a credential nothing else needs. They are keyed by the workflow’s name, so renaming a file’s name: strands its variables under the old name; the page lists stranded sets rather than hiding them.
Withholding a project variable. Each row in Settings → Variables has a toggle that keeps it out of workflow runs. This is hygiene, not a security boundary — anyone who can push to the repo can still read the value through application code. What it buys is keeping a production credential out of test transcripts.
Switching a workflow off
The Workflows page has a Disable button per workflow. Off means off for every trigger — push, schedule and the Run workflow button alike; a workflow that stays runnable one way is worse than either extreme, because the path left open is the one nobody remembers. The alternative is deleting the file, which is a commit, a review and a deploy to stop something misbehaving right now — and a scheduled workflow keeps firing throughout.
Disabling does not cancel a run already in flight: that run is part-way through side effects, and killing it is a separate, louder decision with its own Cancel button.
Limits
| Setting | Limit |
|---|---|
| Workflow files per repo | 20 (extras ignored with a warning) |
| Schedules per workflow | 4 |
| Minimum schedule interval | 5 minutes |
| Per-workflow variables | 50 |
| Run history kept | 50 per workflow per environment, 90 days |
| Jobs per workflow | 10 (before matrix expansion) |
matrix axes per job | 3 |
matrix combinations per job | 10 |
| Jobs per workflow after matrix expansion | 20 |
| Steps per job | 50 |
needs entries per job | 10 |
cache paths per job | 25 |
branches globs per trigger | 50 |
paths / paths-ignore globs per trigger | 50 |
cpu | 0.1–16 cores (default 1) |
memory | 64–65536 MB (default 1024) |
| Job timeout | 1–60 min (default 20) |
| Command length | 8 KB per run: |
| Workflow name | 100 characters (file path: 255) |
| Log kept per job | 2 MB (middle-truncated) |
| Dispatch inputs | 10 |
Workflow minutes have their own monthly quota, separate from build minutes, so a chatty test suite can’t exhaust the allowance your deploys depend on. Time spent queued is never counted — metering starts when the job actually acquires its slot on the node.
Porting from GitHub Actions
The schema is strict at every level — workflow, job, and step. A file containing uses:, services:, a runner label like runs-on: ubuntu-latest, or any other key BTS does not define fails to parse, and the whole workflow is reported broken in the Workflows page rather than partially run. Delete those keys; don’t leave them in expecting them to be skipped.
Those are the keys that fail loudly. The differences that change behaviour instead — where a ported job parses fine and then does something you did not expect — are worth reading before you port anything real:
- Secrets are injected by default. Actions makes you write
${{ secrets.FOO }}; here every project secret is already in the environment. Narrow it withsecrets:. - Your steps talk to the preview’s real database, not a fresh service container — so a suite that runs
migrateandseedwill overwrite the data people are reviewing the pull request against, unless the job asks for a per-run database. env:cannot unset a variable, and""is not the same as absent.- The image is yours. There is no pre-provisioned runner, so nothing exists that your image does not install — including
bash,git, and any build toolchain. - There is no step-level
needs:, but aparallel:block runs steps concurrently within a job. - No
${{ }}expressions,hashFiles()included. A cache invalidates by naming files inhash-files:instead.
Two things that used to be on the unsupported list now port directly. strategy.matrix becomes matrix: on the job, and ${{ matrix.shard }} becomes $BTS_MATRIX_SHARD:
# GitHub Actions
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3]
steps:
- run: npm test -- --shard=${{ matrix.shard }}/3
# BTS
matrix:
shard: [1, 2, 3]
steps:
- run: npm test -- --shard=$BTS_MATRIX_SHARD/3
fail-fast: false has no key to port to because it is the only behaviour: combinations are independent jobs, so one failing never cancels the rest. And the aggregate job Actions makes you hand-write for branch protection is built in — see Results and branch protection. Likewise, paths: and paths-ignore: port verbatim on push and pull_request, with one documented divergence: a synchronize run evaluates the pushed diff, not the PR’s whole diff — see Path filters.
The unsupported ones, and what to do instead:
uses:— no action marketplace. Steps are shell commands; install what you need in the step itself.services:— your workflow already has the environment’s real addons on the network. A job that wants an empty database maps toaddons.postgres.database: per-run.runs-on:runner labels — you pick a containerimage:instead. BTS’s ownruns-on:only chooses between the preview’s node and your workflow nodes.- Artifact upload/download and reusable workflows have no equivalent.
- Timezones on a schedule —
cron:works, but it is always UTC. Offset the expression.
Troubleshooting
- “No workflow runs yet” but I have a file. The file is read from the commit that triggered the run, so it has to be committed and pushed. Check the Workflows page for a parse error — an invalid file is reported there by name rather than silently skipped.
- A job fails immediately with a missing command. The step runs in your declared image, not a GitHub runner.
node:22-alpinehas nogit, nobashand no build toolchain; add what you need (apk add --no-cache git) or pick a fuller image. - Tests can’t reach the database.
DATABASE_URLis only populated when the project has the Postgres addon enabled and the environment has deployed at least once — and only for a job beside the preview. With workflow nodes, addruns-on: environmentto the job. - A run says it’s waiting for a node. The environment’s node is offline, or none of the project’s workflow nodes is. The run resumes on its own when a node reconnects.
- A run says “Never started”. Its environment did not become available within an hour — usually the first deploy failed. Fix the deploy and push again.
- A required check never appears. Almost always the trigger suffix — see Results and branch protection.
See also
- nexos.yaml — configuring the environment your workflow runs against.
- Environment Variables — what gets auto-injected and how secrets are encrypted.
- Deployments — how a push becomes a live URL, and what
deploywaits for. - Resource Limits — quotas, including workflow minutes.