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 test

Triggers

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) and reopened. With no types: all three fire. Any other type in the list (labeled, closed, …) never fires here.
  • Filters. branches / branches-ignore are globs matched against the pull request's base branch, case-sensitively; paths / paths-ignore against 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_SHA is the preview; GLUECRON_HEAD_SHA, GLUECRON_BASE_SHA and GLUECRON_MERGE_SHA name 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 failing gluecron/merge-preview status: merge conflict, update the branch. You do not need a hand-made merge step.
  • The merge gate. A pull_request workflow whose filters match the base (and that runs on synchronize) 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_target is 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=verbose

uses — 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
      fi

Exported 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 override

Secrets

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_dispatch and repository_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 test

Artifacts

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 yet

The 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/zip

Cache 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-lockfile

Expressions

# 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=value lines, or a name<<EOF … EOF heredoc for multiline values. They become ${{ steps.<id>.outputs.<name> }} for later steps and, through the job's outputs: 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 own env: 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 to PATH for 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 }}