Workflow YAML syntax
Workflows live in .gluecron/workflows/*.yml at the repo root. Gluecron runs them on the same node that handles your pushes — no external scheduler required.
Minimal example
name: CI
on:
push:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: bun install --frozen-lockfile
- run: bun testTriggers
The on: key controls when a workflow fires. Multiple triggers can be combined in a single file.
push
on:
push:
branches: [main, "release/**"]
# Optional — only run when these paths change:
paths:
- "src/**"
- "package.json"pull_request
on:
pull_request:
types: [opened, synchronize, reopened]
branches: [main]What Gluecron honours today:
- When it fires.
opened(a pull request is created),synchronize(every push to the head branch of an open pull request) andreopened. With notypes:all three fire. Any other type in the list (labeled,closed, …) never fires here. - Filters.
branches/branches-ignoreare globs matched against the pull request's base branch, case-sensitively;paths/paths-ignoreagainst the files the pull request changes (a pull request that touches none records a passing "not needed" run). - What it builds. A merge preview: the head merged onto the current base tip, made by the same merge "Update branch" uses and kept at
refs/gluecron-pr/<n>/merge.GITHUB_SHAis the preview;GLUECRON_HEAD_SHA,GLUECRON_BASE_SHAandGLUECRON_MERGE_SHAname the head, the base it was built on and the preview. If the head does not merge cleanly nothing runs and the head gets a failinggluecron/merge-previewstatus: merge conflict, update the branch. You do not need a hand-made merge step. - The merge gate. A
pull_requestworkflow whose filters match the base (and that runs onsynchronize) is required at the pull request's head: missing or queued is pending, never a pass, and a new push needs a new green run. A push run of the same workflow does not stand in for it. - Rapid pushes coalesce. A newer head takes over the pull request's queued run and keeps its place in line;
concurrency:groups apply. pull_request_targetis not supported and never fires.
schedule (cron)
Drop a cron expression in the schedule array. The Gluecron autopilot ticker fires it from the same node — no external scheduler needed.
on:
schedule:
# Every day at 06:00 UTC
- cron: "0 6 * * *"
# Every Monday at 09:00 UTC
- cron: "0 9 * * 1"workflow_dispatch (manual)
on:
workflow_dispatch:
inputs:
environment:
description: "Target environment"
required: true
default: staging
type: choice
options: [staging, production]Jobs
Each job runs in a fresh container. Jobs within the same workflow run in parallel by default; use needs: to sequence them.
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: bun run build
deploy:
runs-on: ubuntu-latest
needs: build # waits for build to succeed
if: github.ref == 'refs/heads/main'
steps:
- run: echo "Deploying…"Steps
run — shell commands
steps:
- name: Install deps
run: bun install --frozen-lockfile
- name: Multi-line script
run: |
bun run lint
bun run typecheck
bun test --reporter=verboseuses — reusable actions
steps:
- uses: actions/checkout@v4 # check out the repo
- uses: oven-sh/setup-bun@v2 # install Bun
with:
bun-version: latest
- uses: actions/cache@v4 # cache restore/save
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lockb') }}Other actions (actions/github-script, softprops/action-gh-release, …) run from Gluecron only: the exact owner/repo hosted here, or a copy a site admin imported into gluecron-actions. Runs never download from github.com; an action that is not hosted or imported is skipped and the log says how to import it. Composite and node actions are supported, container actions are not.
gluecron/affected — run only the tests a change can reach
For JavaScript and TypeScript repositories. The step compares the run's commit with its base (a pull request: the merge base with the target branch; a push: the last commit on the branch whose run of this workflow passed in full), follows the import graph from every changed file to the test files that load it, and exports the list to the job's later steps. It answers "run everything" for a lockfile, a dependency manifest, a toolchain pin, a Dockerfile, an .env file, an env / config module (env-manifest, config), any *.config.* file, tsconfig, bunfig.toml, a workflow file or a file the test command runs, a test setup file (bunfig, jest / vitest setupFiles, --preload / --require) or anything it imports, anything under a fixtures or snapshots directory, any file that is not a module (SQL migrations, templates, shell scripts), a dependency graph it cannot fully read (a module or config it cannot read, a tsconfig that does not parse), any push to the default branch, and the merge queue / merge train. Type-only imports, lazy require() and literal import() are followed; a test that reaches a load with a computed name always runs. A run that selected is marked partial: neither its success nor its failure stands for the tree on another commit, the merge train asks for a full run before merging on it, and the default branch's own run is always the full suite.
steps:
- uses: gluecron/affected@v1
with:
events: pull_request # default: pull_request, push
ignore: "**/*.md, docs/**" # changes that never affect a test
all-on: "fixtures/**" # changes that always run everything
always-run: "src/__tests__/schema-*.test.ts" # run on every change
- run: |
if [ "$GLUECRON_AFFECTED_ALL" = "false" ]; then
[ -z "$GLUECRON_AFFECTED_TESTS" ] || bun test $GLUECRON_AFFECTED_TESTS
else
bun test
fiExported to later steps: GLUECRON_AFFECTED_ALL (true / false), GLUECRON_AFFECTED_TESTS and GLUECRON_AFFECTED_FILES (one path per line), GLUECRON_AFFECTED_COUNT, GLUECRON_AFFECTED_BASE and GLUECRON_AFFECTED_REASON. A step that ignores them runs every test, as before. A test file can also opt in to every run with the line // gluecron-affected: always-run (or // @gluecron-affected: always).
Every green partial run is later checked against the full run of the same tree. The share that missed a failure the full run found — the escaped-failure rate — is printed in each selecting run's log, shown on every partial run's page, and published at /<owner>/<repo>/actions/affected-stats. The rules and their limits: docs/CI-AFFECTED-TESTS.md.
Environment variables
Set env vars at the workflow, job, or step level. Step-level values override job-level which override workflow-level.
env:
NODE_ENV: test
LOG_LEVEL: debug
jobs:
test:
runs-on: ubuntu-latest
env:
DATABASE_URL: postgresql://localhost/testdb
steps:
- run: bun test
env:
VERBOSE: "1" # step-level overrideSecrets
Secrets are stored encrypted at the repo or org level and injected at runtime. They never appear in logs — values are masked automatically.
steps:
- name: Deploy
run: curl -X POST $DEPLOY_WEBHOOK
env:
DEPLOY_WEBHOOK: ${{ secrets.DEPLOY_WEBHOOK_URL }}Manage repo secrets at /:owner/:repo/settings/secrets. Org-level secrets live at /orgs/:slug/settings/secrets and are shared across all repos in the org.
GITHUB_TOKEN — the job's own token
Every job gets a fresh token for its own repository, as on GitHub Actions. Use it as ${{ secrets.GITHUB_TOKEN }}, ${{ secrets.GLUECRON_TOKEN }} or ${{ github.token }}. It is not in a step's environment unless you map it there, and it is masked in logs. GITHUB_API_URL, GITHUB_SERVER_URL and GITHUB_GRAPHQL_URL point at this instance, so gh and Octokit work unchanged.
By default it can only read: contents: read and metadata: read. A permissions: block on the workflow or the job widens it (the job's block replaces the workflow's). Scopes: contents, issues, pull-requests, statuses, checks, actions, deployments, packages, each read, write or none; read-all and write-all work too.
permissions:
contents: write # push, commit files
issues: write # open, edit, comment on, close issues
pull-requests: write # open PRs, comment on them
statuses: write # commit statuses
jobs:
report:
runs-on: self
steps:
- run: |
curl -sf -X POST -H "Authorization: token $GH_TOKEN" \
"$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/issues" \
-d '{"title":"Nightly report"}'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}The rules it lives by:
- It works only on this repository: git over HTTPS and the repository's REST API (
/api/v2, the GitHub-compatible/api/v3, commit statuses). Any other repository is refused. - It never has admin or settings rights. It cannot change branch protection, secrets or webhooks, and cannot create tokens.
- It acts as the person who triggered the run. Branch protection, rulesets and the push-time checks apply exactly as they do to that person.
- A pull request run from someone without write access always gets a read-only token, whatever
permissions:asks for. - Pushes, PRs and other changes made with it start no new workflow run, except
workflow_dispatchandrepository_dispatch. - It stops working when the job finishes or is cancelled, and in any case after the job's timeout plus five minutes.
- Every write made with it is recorded in the audit log against the workflow run.
A repository secret you stored yourself under the name GITHUB_TOKEN or GLUECRON_TOKEN still takes precedence for secrets.*, so workflows that used one keep working.
Matrix builds
Run the same job across multiple configurations in parallel.
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
node: [18, 20, 22]
os: [ubuntu-latest, macos-latest]
fail-fast: false # don't cancel others on first failure
steps:
- uses: actions/checkout@v4
- run: node --version # uses matrix.node implicitly via setup-node
- run: bun testArtifacts
actions/upload-artifact stores a file or directory with the run; actions/download-artifact restores it. To read an artifact from an EARLIER run of this repository, give run-id: a run id, a run number of the same workflow, or previous — the newest earlier run of this workflow that has an artifact of that name. github-token is accepted and not needed; another repository's artifacts are refused.
- uses: actions/download-artifact@v4
with:
name: crawl-state
run-id: previous # or a run id / run number
path: state
optional: true # first run: nothing to read yetThe REST API speaks GitHub's shapes, under /api/v2 and the GitHub-compatible /api/v3, with the same access as the repository:
GET /api/v3/repos/:owner/:repo/actions/runs/:run_id/artifacts
GET /api/v3/repos/:owner/:repo/actions/artifacts?name=crawl-state
GET /api/v3/repos/:owner/:repo/actions/artifacts/:artifact_id
GET /api/v3/repos/:owner/:repo/actions/artifacts/:artifact_id/zipCache action
# Bun package cache — speeds up installs by ~80% on warm hits
- uses: actions/cache@v4
id: bun-cache
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lockb') }}
restore-keys: |
${{ runner.os }}-bun-
- run: bun install --frozen-lockfileExpressions
# Context access
${{ github.ref_name }} # main
${{ github.ref }} # refs/heads/main
${{ github.sha }} # full commit SHA
${{ runner.os }} # Linux
${{ job.status }} # success | failure | cancelled
# Functions
${{ hashFiles('**/bun.lockb') }}
${{ contains(github.ref, 'release') }}
${{ startsWith(github.ref, 'refs/tags/') }}
${{ endsWith(github.ref, '/main') }}
${{ format('{0}-{1}', runner.os, github.sha) }}
${{ join(fromJSON(needs.plan.outputs.list), ' ') }}
${{ toJSON(matrix) }}
${{ inputs.level || 'info' }} # || gives back the first truthy value
${{ success() }} ${{ failure() }} ${{ always() }} ${{ cancelled() }}GitHub Actions compatibility: outputs, env, summaries
Every run: step gets fresh, empty files for GitHub's file commands, and they work as they do on Actions:
$GITHUB_OUTPUT:name=valuelines, or aname<<EOF…EOFheredoc for multiline values. They become${{ steps.<id>.outputs.<name> }}for later steps and, through the job'soutputs:mapping,${{ needs.<job>.outputs.<name> }}for the jobs that need it.$GITHUB_ENV: the same syntax; the variables reach every later step of the job. A step's ownenv:still wins. Names that would change how later steps start (NODE_OPTIONS,LD_*,BASH_ENV,PATH,GIT_*,GITHUB_*,RUNNER_*) are refused, and the log says so.$GITHUB_PATH: one directory per line, prepended toPATHfor later steps (the last one written comes first).$GITHUB_STEP_SUMMARY: markdown, stored with the step and shown under it on the run page. Raw HTML is sanitised and secrets are masked.
Each step reads back at most 32 KB of outputs, 16 KB of env, 4 KB of path and 16 KB of summary. Outputs or env over the limit fail the step with the reason; a longer summary is cut, with a note.
jobs:
version:
runs-on: self
outputs:
tag: ${{ steps.meta.outputs.tag }}
steps:
- id: meta
run: |
echo "tag=v$(date +%Y%m%d)" >> "$GITHUB_OUTPUT"
echo "BUILD_MODE=release" >> "$GITHUB_ENV"
echo "### Built ${{ github.sha }}" >> "$GITHUB_STEP_SUMMARY"
- run: echo "mode=$BUILD_MODE tag=${{ steps.meta.outputs.tag }}"
publish:
needs: version
runs-on: self
steps:
- run: echo "publishing ${{ needs.version.outputs.tag }}"Steps also see GITHUB_ACTIONS=true, RUNNER_OS=Linux, RUNNER_ARCH, RUNNER_TEMP (a per-run scratch directory), GITHUB_WORKSPACE, GITHUB_SHA, GITHUB_REF, GITHUB_REF_NAME, GITHUB_REPOSITORY, GITHUB_EVENT_NAME, GITHUB_RUN_ID, GITHUB_RUN_ATTEMPT and GITHUB_SERVER_URL, plus the same values as GLUECRON_*.
continue-on-error
On a step: a failure is recorded with outcome failure and conclusion success(${{ steps.<id>.outcome }} / .conclusion), the job goes on, and failure() stays false. On a job: the job is shown as failed but does not fail the run, and jobs that need it run. Both accept an expression, such as ${{ matrix.experimental }}.
working-directory, shell and defaults.run
working-directory: on a step (relative to the workspace), else the job's defaults.run.working-directory, else the workflow's. shell: works the same way: bash runs bash --noprofile --norc -eo pipefail,sh runs sh -e, and python, pwsh and a custom cmd {0} template are supported. With no shell: a step runs under bash -e, as it always has here.
Expressions in run:, env: and with:
${{ … }} works in run:, env:, with:, working-directory: and a job's outputs:, with the github, env, steps, needs, matrix, inputs, secrets, runner and job contexts. In a run: script the value is never pasted into the script text: each expression becomes a variable reference (${GLUECRON_EXPR_1}), so a value with quotes or $(…) in it cannot run as a command. One consequence: inside single quotes the reference stays literal, so write "${{ … }}", not '${{ … }}'. The event payload (github.event.*) is not available beyond workflow_dispatch inputs.
Reusable workflows and local composite actions
A job can call another workflow file instead of listing steps. The called file declares on: workflow_call with itsinputs, secrets and outputs; its jobs run in the caller's run (shown as caller / job), with the caller's checkout and github.*.
# .gluecron/workflows/deploy.yml (the caller)
jobs:
build:
uses: ./.gluecron/workflows/build.yml # or ./.github/workflows/build.yml
with:
target: prod
secrets: inherit # or { TOKEN: ${{ secrets.DEPLOY_TOKEN }} }
report:
needs: build
runs-on: self
steps:
- run: echo "built ${{ needs.build.outputs.artifact }}"
# .gluecron/workflows/build.yml (the called workflow)
on:
workflow_call:
inputs:
target: { type: string, required: true }
secrets:
TOKEN: { required: false }
outputs:
artifact:
value: ${{ jobs.compile.outputs.artifact }}
jobs:
compile:
runs-on: self
outputs:
artifact: ${{ steps.c.outputs.artifact }}
steps:
- id: c
run: echo "artifact=app-${{ inputs.target }}.tgz" >> "$GITHUB_OUTPUT"- Another repository:
uses: owner/repo/.gluecron/workflows/x.yml@ref. It must be hosted on this Gluecron and readable by the calling repository (public, the same account or organisation, or readable by its owner). Nothing is ever fetched from outside Gluecron. - Secrets are passed only when named, or with
secrets: inherit. An input the called workflow does not declare, or a missing required input or secret, stops the run before any step starts. - GitHub's limits apply: at most 10 levels of workflows (the caller included) and 50 unique reusable workflows in one run; a workflow may not call itself.
A step can run a composite action from the same repository:uses: ./path/to/action, where that directory holds an action.yml withruns.using: composite. Its steps run inline, with the same privileges as run: steps; with: feeds its inputs, and itsoutputs become steps.<id>.outputs. JavaScript and Docker actions are refused, and so is a path that leaves the repository.
Complete CI + deploy example
# .gluecron/workflows/ci.yml
name: CI / Deploy
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
ci:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/cache@v4
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lockb') }}
- uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- run: bun install --frozen-lockfile
- run: bun run typecheck
- run: bun test
deploy:
runs-on: ubuntu-latest
needs: ci
if: github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v4
- run: bun run build
- name: Deploy to Fly.io
run: flyctl deploy --remote-only
env:
FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}