Diffinery

codician/diffinerypublic

Open-source, agent-first GitHub and GitHub Actions alternative that runs on your own Cloudflare account: git hosting, pull requests, code review and drop-in GitHub Actions YAML on Workers, Durable Objects, R2 and Containers.

398 lines · 23.6 KBRaw History

title: Expressions and contexts description: The ${{ }} expression language in Diffinery workflows: operators, functions, every context property Diffinery sets, and the default environment variables.

Expressions let a workflow compute values and conditions at run time: ${{ github.ref_name }}, if: failure(), ${{ fromJSON(needs.plan.outputs.matrix) }}. Diffinery implements the GitHub Actions expression language, with the same syntax, operators, functions and type rules, and fills in the same contexts. This page is the reference for all of it, including the default environment variables every step gets and every difference from GitHub.

Syntax

An expression is written between ${{ and }}:

env:
  IMAGE: diffinery.com/acme/website:${{ github.sha }}
steps:
  - run: echo "Building ${{ github.ref_name }} for ${{ github.actor }}"
  • In most values, each ${{ }} is replaced by its result converted to a string, and the text around it is kept.
  • When a value is exactly one expression (for example matrix: ${{ fromJSON(needs.plan.outputs.matrix) }}), the result keeps its type: a list stays a list, a number stays a number.
  • In if: conditions of jobs and steps, ${{ }} is optional: if: github.event_name == 'push' is the same as if: ${{ github.event_name == 'push' }}.
  • }} inside a single-quoted string doesn't end the expression. To write a literal ${{, use ${{ '${{' }}.
  • Expressions in on: are not evaluated.

Every expression in a file is checked for syntax when the file is parsed. A mistake fails the file with Invalid expression ${{ <expr> }}: <detail> (or Invalid expression: <detail> in an if: without ${{ }}), and an unclosed one with Unclosed expression: '${{' at offset <n> has no matching '}}'. Run diffinery workflow validate to catch these before you push.

Note: In an if: that mixes text and expressions, such as if: ${{ a }} && ${{ b }}, the result is a string, which is true unless it's empty. Write the whole condition as one expression instead: if: a && b. GitHub behaves the same way.

Literals

TypeExamplesNotes
nullnull
Booleantrue, false
Number42, -1, 3.14, 2e3, 0xff, 0o17, NaN, InfinityAny JSON number, plus hexadecimal (0x) and octal (0o). A leading - or + is part of the number: there is no arithmetic and no unary minus.
String'hello', 'it''s'Single quotes only; write '' for a quote. Double quotes fail with Unexpected double quote; string literals use single quotes.

Operators

OperatorMeaning
( )Grouping
[ ]Index: github.event['pull_request'], matrix.include[0]
.Property: github.event.pull_request.title
.*, [*]Object filter (see below)
!Not
<, <=, >, >=Comparison
==, !=Equality
&&And

The or operator is ||. Precedence, from highest to lowest: property access and indexing, !, comparisons, equality, &&, ||. There are no arithmetic operators, as on GitHub.

&& and || return one of their operands, not a boolean: a && b is a when a is falsy and b otherwise; a || b is a when a is truthy and b otherwise. That gives you defaults: ${{ inputs.tag || 'latest' }}.

Property access

  • Property names are case-insensitive: github.EVENT_NAME reads github.event_name.
  • Names may contain -: steps.build-app.outputs.path, matrix.node-version.
  • Reading a property of something that doesn't have it gives null instead of an error, so github.event.pull_request.title is null on a push. An unknown context name is null too.
  • Array indexes start at 0. An index out of range gives null.

Object filters

.* collects a property from every item of an array or object and gives a list:

- run: echo "${{ toJSON(github.event.pull_request.labels.*.name) }}"
- if: contains(github.event.pull_request.labels.*.name, 'deploy')
  run: ./deploy.sh

Types and conversion

Comparisons convert values the way GitHub does:

RuleExample
Values of different types are compared as numbers. null is 0, true is 1, false is 0, '' is 0, numeric strings are their number, and anything else is NaN.'1' == 1 is true, null == 0 is true
Strings are compared case-insensitively.'Main' == 'main' is true
A comparison with NaN is false.'abc' < 1 is false
Arrays and objects are only equal to themselves.fromJSON('[1]') == fromJSON('[1]') is false

Truthiness: false, 0, -0, NaN, '' and null are falsy; everything else, including every array and object, is truthy.

When a result is inserted into text: null becomes '', booleans become true or false, whole numbers have no decimals, and arrays and objects become JSON (indented by 2 spaces).

Functions

Function names are case-insensitive. Calling an unknown function fails with Unrecognized function '<name>', and a wrong number of arguments with Function '<name>' expects <n> argument(s) but got <m>.

FunctionReturns
contains(search, item)If search is an array: true when an element equals item (with the equality rules above). Otherwise true when the string search contains item, ignoring case.
startsWith(string, prefix)true when string starts with prefix, ignoring case.
endsWith(string, suffix)true when string ends with suffix, ignoring case.
format(string, value0, value1, ...)string with {0}, {1}, ... replaced by the values. Write {{ and }} for literal braces.
join(array, separator)The elements joined with separator (default ,). A non-array is converted to a string.
toJSON(value)value as JSON, indented by 2 spaces.
fromJSON(string)The parsed value. Use it to turn strings into booleans, numbers, lists or objects.
hashFiles(pattern, ...)A SHA-256 hash of the matching files, or '' when none match (see below).
success(), failure(), cancelled(), always()Status checks (see below).

Examples:

- run: echo "${{ format('{0}/{1}@{2}', github.repository_owner, github.event.repository.name, github.sha) }}"
- if: startsWith(github.ref, 'refs/tags/v')
  run: ./release.sh
- uses: actions/cache@v4
  with:
    path: ~/.npm
    key: npm-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}

Runtime errors from functions fail the step or job that evaluates them:

MessageCause
fromJSON: invalid JSON "<text>": <detail>The argument isn't valid JSON.
format: invalid format string '<string>'A { isn't followed by a number and }.
format: invalid format string '<string>' (unescaped '}')A lone }. Write }}.
format: placeholder {<n>} has no matching argument in '<string>'Not enough values.

hashFiles

hashFiles takes one or more glob patterns relative to GITHUB_WORKSPACE (*, **, ?, [...], and !pattern to exclude). It hashes each matching file with SHA-256, then hashes those hashes together, and returns the hex digest. Files outside the workspace and .git directories are ignored. It returns '' when no file matches.

hashFiles only works in places evaluated on the runner, that is in steps, job env, container, services and outputs. Elsewhere (job if, runs-on, strategy, name) it returns ''.

Status check functions

In a job's if::

FunctionTrue when
success()The run hasn't been cancelled and every job in needs succeeded or was skipped.
failure()A job in needs failed or timed out.
cancelled()The run has been cancelled.
always()Always.

In a step's if::

FunctionTrue when
success()No earlier step of the job failed (steps with continue-on-error: true don't count) and the job isn't being cancelled or timing out.
failure()An earlier step failed.
cancelled()The job is being cancelled or has hit its timeout.
always()Always.

An if: that doesn't call any of these is treated as success() && (<condition>). So if: github.ref == 'refs/heads/main' is skipped after a failure, while if: always() && github.ref == 'refs/heads/main' still runs. Inside a composite action, the functions look at the action's own steps.

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - run: npm test
      - if: failure()
        run: echo "::error::Tests failed"
  notify:
    needs: test
    if: always()
    runs-on: ubuntu-latest
    steps:
      - run: echo "test finished with ${{ needs.test.result }}"

Important: Two differences from GitHub at the job level. On GitHub, when a job is skipped, every job that needs it is skipped too; on Diffinery a skipped job counts as a success, so the jobs that need it run. And failure() only looks at the jobs listed in the job's own needs, not at the jobs those depend on. If a job must not run after an earlier failure further up the chain, list that job in needs or check its result, for example if: needs.build.result == 'success'.

Contexts

Where each context is available

Some values are evaluated by Diffinery before a job starts, and the rest by the runner while the job runs, so not every context is available everywhere:

WhereAvailable contexts
run-name, top-level concurrencygithub, inputs, vars
jobs.<id>.ifgithub, needs, inputs, vars, env (workflow-level values, unevaluated), status functions
jobs.<id>.runs-on, jobs.<id>.strategygithub, needs, inputs, vars, env
jobs.<id>.name, jobs.<id>.timeout-minutesgithub, needs, inputs, vars, env, matrix, strategy
jobs.<id>.environment (name and url)github, inputs, vars, matrix. A url that refers to steps. is ignored.
jobs.<id>.with (reusable workflow call)github, needs, inputs, vars
jobs.<id>.secrets (reusable workflow call)github, needs, inputs, vars, secrets
jobs.<id>.env, container, servicesgithub, needs, inputs, vars, secrets, matrix, strategy, runner, job
Steps: if, name, uses, run, with, env, shell, working-directory, continue-on-error, timeout-minutesgithub, env, vars, secrets, matrix, needs, steps, runner, inputs, job, strategy
jobs.<id>.outputsSame as steps
on.workflow_call.outputsgithub, jobs, inputs, vars

In places evaluated before the job starts, github.token is empty and github.workspace isn't set, and vars and secrets hold only repository (and organization) values: a job's environment variables and secrets are added when the job starts, so they're available in steps, jobs.<id>.env, container, services and outputs, but not in jobs.<id>.if, runs-on, name or environment itself.

github

Information about the run and the event. Most properties are also environment variables (see Default environment variables).

PropertyValue
github.event_nameThe event that started the run, for example push, pull_request, release or repository_dispatch
github.eventThe event payload. See Events that trigger workflows for each event's fields, for example github.event.release.tag_name for release and github.event.client_payload for repository_dispatch.
github.shaThe commit the run checks out. See the table in Events that trigger workflows.
github.refThe full ref, for example refs/heads/main, refs/tags/v1.2.0 (also for release runs) or refs/pull/7/merge
github.ref_nameThe short ref: main, v1.2.0, or 7/merge for pull requests
github.ref_typetag for tags, branch otherwise
github.ref_protectedAlways false
github.head_refThe pull request's head branch for pull_request and pull_request_target, otherwise ''
github.base_refThe pull request's base branch for pull_request and pull_request_target, otherwise ''
github.repositoryacme/website
github.repository_idThe repository's ID
github.repository_owneracme
github.repositoryUrlhttps://diffinery.com/acme/website.git
github.actorThe login of the account that triggered the run (for a re-run, who re-ran it): for release, whoever published, edited or deleted the release; for repository_dispatch, the account whose token sent the event. Scheduled runs use diffinery-scheduler; merge queue groups use the login of whoever queued the pull request.
github.triggering_actorSame as github.actor
github.run_idThe run's ID, a 32-character hexadecimal string
github.run_numberThe run's number, as a string
github.run_attempt1, or higher for re-runs, as a string
github.workflowThe workflow's name, or its path when it has none
github.workflow_refacme/website/.github/workflows/ci.yml@refs/heads/main
github.jobThe job's ID (inside a reusable workflow, its ID in that workflow)
github.server_urlhttps://diffinery.com
github.api_urlhttps://diffinery.com/api/v3
github.graphql_urlhttps://diffinery.com/api/graphql. Set for compatibility only: Diffinery has no GraphQL API yet
github.tokenThe job's token (same as secrets.GITHUB_TOKEN)
github.workspaceThe workspace directory, GITHUB_WORKSPACE
github.retention_days90
github.secret_sourceActions
github.action''
github.action_pathInside a composite action: the action's directory. Otherwise ''.
github.action_repository, github.action_refInside a composite action: the action's repository and ref.

GitHub properties Diffinery doesn't set include github.actor_id, github.repository_owner_id, github.workflow_sha, github.event_path (use the GITHUB_EVENT_PATH variable), github.env, github.path, github.output, github.state and github.step_summary (use the environment variables of the same names).

Note: github.run_number counts runs of all workflows in the repository, not each workflow separately as on GitHub, and github.run_id is a hexadecimal string, not a number.

env

Environment variables set in the workflow: the workflow's env, the job's env, the step's env and anything earlier steps wrote to $GITHUB_ENV. It doesn't include the default variables such as GITHUB_SHA; read those with github.* or in the shell.

env:
  STAGE: production
jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - if: env.STAGE == 'production'
        run: echo "Deploying to $STAGE"

vars

The repository's variables, set under Settings > Secrets and variables or with PUT /repos/{owner}/{repo}/actions/variables/{name}. In a job with environment:, the environment's variables are added once the job starts and win over repository variables with the same name. Names are stored in upper case, and lookups ignore case. An unset variable is ''. See Secrets and variables and Environments.

secrets

PropertyValue
secrets.<NAME>A repository secret, or an organization secret of the repository's owner. A repository secret wins over an organization secret with the same name. In a job with environment:, the environment's secrets win over both (pull request runs, and some runs started by agents, don't get environment secrets; see environment).
secrets.GITHUB_TOKENThe job's token. It has read access to the repository; see Workflow syntax.
secrets.DIFFINERY_TOKENThe same job token, under a Diffinery name.

Secret names are stored in upper case and lookups ignore case; an unset secret is ''. Inside a reusable workflow, secrets holds only what the caller passed (or everything with secrets: inherit), plus the two tokens. Secret values are masked in logs.

matrix

The values of the current matrix combination, for example matrix.os and matrix.node. Empty for jobs without a matrix.

strategy

PropertyValue
strategy.fail-fastWhether fail-fast is on
strategy.job-indexThis job's position in the matrix, from 0
strategy.job-totalHow many jobs the matrix produced
strategy.max-parallelThe effective max-parallel

needs

For each job in the job's needs:

PropertyValue
needs.<job_id>.resultsuccess, failure, cancelled or skipped. For a matrix job: failure if any of its jobs failed or timed out, otherwise cancelled if any was cancelled, skipped if all were skipped, otherwise success.
needs.<job_id>.outputs.<name>The job's outputs (strings). For a matrix job, the merged outputs of all its jobs. For a reusable workflow call, the called workflow's on.workflow_call.outputs.

steps

For each earlier step with an id in the same job (or the same composite action):

PropertyValue
steps.<id>.outputs.<name>Outputs the step set with $GITHUB_OUTPUT or ::set-output
steps.<id>.outcomesuccess, failure, cancelled or skipped, before continue-on-error
steps.<id>.conclusionThe same, after continue-on-error (a failed step with continue-on-error: true is success)

runner

PropertyValue
runner.osLinux
runner.archX64 on hosted runners (ARM64, ARM or X86 on self-hosted machines with those processors)
runner.namediffinery
runner.environmentdiffinery-hosted
runner.tempA temporary directory for the job; /workspace/_temp on hosted runners, where every job starts in a fresh container
runner.tool_cacheWhere setup actions install tools; /opt/hostedtoolcache on hosted runners
runner.workspaceThe parent of GITHUB_WORKSPACE; /workspace on hosted runners
runner.debug1 when debug logging is on (secret or variable ACTIONS_STEP_DEBUG set to true), otherwise ''

job

PropertyValue
job.statussuccess, failure or cancelled: the job's state so far
job.container.id, job.container.networkThe job container's ID and network, when the job has a container
job.services.<name>.id, .network, .portsEach service container's ID, network and port mappings (ports['5432'] is the host port)

inputs

For workflow_dispatch runs: the dispatch inputs, typed (booleans are booleans, numbers are numbers). Inside a reusable workflow: the inputs the caller passed with with, plus declared defaults. For other events: empty.

jobs

Only in on.workflow_call.outputs of a reusable workflow: jobs.<job_id>.result and jobs.<job_id>.outputs.<name> for the called workflow's jobs.

Default environment variables

Every step gets these variables. Workflows can't override the GITHUB_* and RUNNER_* ones; setting them in env has no effect. Paths are those of hosted runners, where the repository acme/website is checked out at /workspace/website.

VariableValue
CItrue
GITHUB_ACTIONStrue
DIFFINERYtrue. Use it to detect Diffinery in scripts.
GITHUB_REPOSITORYacme/website
GITHUB_REPOSITORY_OWNERacme
GITHUB_SHAThe commit the run is about (see Events that trigger workflows)
GITHUB_REFThe full ref
GITHUB_REF_NAMEThe short ref (main, v1.2.0, 7/merge)
GITHUB_REF_TYPEbranch or tag
GITHUB_HEAD_REFThe head branch for pull request events, otherwise empty
GITHUB_BASE_REFThe base branch for pull request events, otherwise empty
GITHUB_RUN_IDThe run's ID
GITHUB_RUN_NUMBERThe run's number
GITHUB_RUN_ATTEMPTThe attempt number
GITHUB_JOBThe job's ID
GITHUB_ACTORWho triggered the run
GITHUB_TRIGGERING_ACTORSame as GITHUB_ACTOR
GITHUB_EVENT_NAMEThe event name
GITHUB_EVENT_PATHA JSON file holding the event payload, /workspace/_temp/_github_workflow/event.json
GITHUB_SERVER_URLhttps://diffinery.com
GITHUB_API_URLhttps://diffinery.com/api/v3
GITHUB_GRAPHQL_URLhttps://diffinery.com/api/graphql. Set for compatibility only: Diffinery has no GraphQL API yet
GITHUB_WORKFLOWThe workflow's name, or its path
GITHUB_WORKFLOW_REFacme/website/.github/workflows/ci.yml@refs/heads/main
GITHUB_WORKSPACE/workspace/website
GITHUB_RETENTION_DAYS90
GITHUB_ACTIONThe step's id, or __run for a run step without one (for actions, __ plus the action's repository with other characters replaced by _)
GITHUB_ENV, GITHUB_PATH, GITHUB_OUTPUT, GITHUB_STATE, GITHUB_STEP_SUMMARYPaths of the step's environment files
RUNNER_OSLinux
RUNNER_ARCHX64 on hosted runners
RUNNER_NAMEdiffinery
RUNNER_ENVIRONMENTdiffinery-hosted
RUNNER_TEMP/workspace/_temp
RUNNER_TOOL_CACHE/opt/hostedtoolcache
RUNNER_WORKSPACE/workspace
AGENT_TOOLSDIRECTORYSame as RUNNER_TOOL_CACHE

Set only in some cases:

VariableWhen
RUNNER_DEBUG1 when the secret or variable ACTIONS_STEP_DEBUG is true
ACTIONS_ID_TOKEN_REQUEST_URL, ACTIONS_ID_TOKEN_REQUEST_TOKENWhen the job has permissions: id-token: write. Used by OIDC clients; see OIDC.
GITHUB_ACTION_PATH, GITHUB_ACTION_REPOSITORY, GITHUB_ACTION_REFIn JavaScript and Docker actions, and in the steps of composite actions
INPUT_<NAME>In JavaScript and Docker actions: each input, with the name upper-cased and spaces replaced by _
STATE_<NAME>In an action's main and post scripts: values saved with $GITHUB_STATE or ::save-state

Hosted runners also set LANG=C.UTF-8, LC_ALL=C.UTF-8, PIP_BREAK_SYSTEM_PACKAGES=1 (so pip install works with Ubuntu's Python) and COREPACK_ENABLE_DOWNLOAD_PROMPT=0.

GitHub variables that Diffinery doesn't set include GITHUB_ACTOR_ID, GITHUB_REPOSITORY_ID, GITHUB_REPOSITORY_OWNER_ID, GITHUB_WORKFLOW_SHA, GITHUB_REF_PROTECTED, ImageOS and ImageVersion. GITHUB_TOKEN isn't an environment variable unless you set it, as on GitHub:

- run: gh api repos/${{ github.repository }}
  env:
    GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

Differences from GitHub

  • A skipped job counts as a success for the jobs that need it, so they run; on GitHub they're skipped. failure() in a job's if: only looks at its direct needs.
  • runs-on can't use the matrix context.
  • jobs.<id>.environment can't use the needs or strategy contexts, and its url can't use step outputs.
  • Job-level if: can also read the workflow-level env.
  • hashFiles() returns '' outside steps and other runner-evaluated values.
  • github.event.inputs keeps the types of dispatch inputs; on GitHub its values are strings.
  • github.run_id is a hexadecimal string, and github.run_number counts runs across all workflows of the repository.
  • On a re-run, github.actor is whoever started the re-run (and github.triggering_actor matches it).
  • github.ref_protected is always false, and secrets.DIFFINERY_TOKEN is an extra name for the job token.
  • GITHUB_* and RUNNER_* defaults can't be overridden, but CI and DIFFINERY can.