# Diffinery docs: Actions > Diffinery is open-source code hosting and CI for the agentic era: Git repositories, pull requests with intent, GitHub Actions compatible workflows and AI agents as first-class collaborators, running on your own Cloudflare account. Every page of the "Actions" section of the Diffinery docs (https://diffinery.com/docs) as Markdown, in reading order. Each page starts with a level-1 heading and its source URL. Other sections and the rest of the site: https://diffinery.com/llms.txt --- # Actions overview > How Diffinery Actions runs GitHub Actions workflows on Cloudflare, and how to start, watch, cancel and re-run runs from the web UI, CLI, API and MCP. Source: https://diffinery.com/docs/actions/overview Diffinery Actions is the CI system built into every Diffinery repository. It reads GitHub Actions workflow files from your repository, runs their jobs in Linux containers on Cloudflare (or on your own machines), and reports the results as checks on commits and pull requests. Use it to build, test and deploy your code on push, on pull requests, on a schedule or on demand. ## Key concepts | Term | Meaning | | --- | --- | | Workflow | A YAML file in `.github/workflows/` or `.diffinery/workflows/` that says when to run (`on:`) and what to run (`jobs:`). The syntax is GitHub Actions syntax. See [Workflow syntax](https://diffinery.com/docs/actions/workflow-syntax). | | Event | Something that happens in the repository and can start a workflow: a push, a pull request update, a merge queue group, a manual dispatch, a schedule, another workflow's run, a published release or a `repository_dispatch` call. See [Events that trigger workflows](https://diffinery.com/docs/actions/events). | | Run | One execution of one workflow for one event. A run has a number, a status and a conclusion, and holds one or more jobs. | | Job | A set of steps that runs on one runner. Jobs run in parallel unless they depend on each other with `needs`. A matrix turns one job definition into several jobs. | | Step | One command (`run:`) or one action (`uses:`) inside a job. Steps run in order in the same workspace. | | Runner | The machine that executes a job. Diffinery-hosted runners are Cloudflare Containers; self-hosted runners are machines you connect with `diffinery runner`. See [Runners](https://diffinery.com/docs/actions/runners). | | Check | The result of one job, reported on the commit the run is about. Branch protection can require checks to pass before merging. | | Environment | A named deployment target, such as `production`, with its own secrets, variables and branch policy. A job that sets `environment:` gets them and records a deployment. See [Environments](https://diffinery.com/docs/actions/environments). | Workflows are compatible with GitHub Actions: the same triggers, expressions, matrices, `needs`, outputs, caches, artifacts, secrets, environments, OIDC, a `GITHUB_TOKEN` that follows `permissions:`, the official actions (`actions/checkout`, `actions/cache`, `actions/upload-artifact`, `actions/download-artifact`, `actions/github-script`, `actions/setup-node`, `actions/setup-python`, `actions/setup-go` and the rest), and remote JavaScript, composite and Docker actions. Differences are called out on each page and collected in [GitHub Actions compatibility](https://diffinery.com/docs/actions/compatibility). ## Your first workflow You need write access to the repository so you can push. This example runs the tests of a Node.js project on every push to `main` and on every pull request. 1. In a clone of `acme/website`, create `.github/workflows/ci.yml`: ```yaml name: CI on: push: branches: [main] pull_request: jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 22 cache: npm - run: npm ci - run: npm test - name: Write a summary run: echo "### Tests passed on \`${{ github.ref_name }}\`" >> "$GITHUB_STEP_SUMMARY" ``` 2. Check the file before you push. The CLI uses the same parser as the server: ```sh diffinery workflow validate .github/workflows/ci.yml ``` 3. Commit and push: ```sh git add .github/workflows/ci.yml git commit -m "Add CI" git push origin main ``` 4. Open `https://diffinery.com/acme/website/actions`. A run titled `Add CI` (the first line of the commit message) appears under **All workflows** and under **CI** in the sidebar. Click it to see its jobs, then click the `test` job to follow its log live. 5. Or follow it from your terminal: ```sh diffinery run list -R acme/website --limit 1 diffinery run watch -R acme/website ``` When the job finishes, the commit shows a check named `CI / test`. On a pull request, the same check appears on the pull request's head commit. If you don't have the CLI yet, install it and sign in through your browser: ```sh curl -fsSL https://diffinery.com/install | bash diffinery login ``` ## Where workflow files live Diffinery reads workflow files from two directories at the root of the repository: - `.github/workflows/` - `.diffinery/workflows/` Every file directly in one of these directories whose name ends in `.yml` or `.yaml` is a workflow. Files in subdirectories are ignored. There is no precedence between the two directories: workflows from both are loaded (`.github/workflows/` first), each one is its own workflow identified by its path, and each one that matches an event starts its own run. Use `.diffinery/workflows/` for workflows that only make sense on Diffinery, so a GitHub mirror of the repository doesn't try to run them. When you name a workflow by file name rather than path (for example `deploy.yml` in `diffinery workflow run deploy.yml`), Diffinery picks the first file whose path is, or ends with `/deploy.yml`, so `.github/workflows/deploy.yml` wins over `.diffinery/workflows/deploy.yml`. Pass the full path to pick the other one. Which version of a file runs depends on the event: | Event | Workflow files are read from | | --- | --- | | `push` | The pushed commit | | `pull_request` | The pull request's test merge commit (the head merged into the base) | | `pull_request_target` | The base branch's current commit | | `merge_group` | The merge group commit | | `workflow_dispatch` | The branch or tag you choose | | `schedule`, `workflow_run`, `repository_dispatch` | The default branch's current commit | | `release` | The commit the release's tag points to | The workflow list in the **Actions** sidebar and `GET /repos/{owner}/{repo}/actions/workflows` show the workflows on the default branch. ## How a run executes 1. **An event happens.** Diffinery loads the workflow files from the commit the event is about (see the table above) and checks each workflow's `on:` section, including branch, tag and path filters. 2. **A run is created** for each matching workflow, with status `queued`. It gets the next run number and a title: the `run-name` if the workflow sets one, otherwise the first line of the commit message (push), the pull request title (pull request events), `Merge group for #: ` (merge groups), the release's name or tag (release), the `event_type` (repository_dispatch) or the workflow name (dispatch, schedule, workflow_run). If the workflow has a `concurrency` group, other runs in the group may be cancelled or this run may wait (see [Concurrency](#concurrency)). 3. **The run is orchestrated durably** by a Cloudflare Workflow. It walks the job graph from `needs`: each job starts as soon as the jobs it needs have finished, without waiting for unrelated jobs. The orchestration survives restarts of the platform, and each wait for a job lasts at most 6 hours. 4. **Each job is evaluated** just before it starts: its `if:` condition and its matrix. A job whose condition is false is recorded as `skipped`; without a status function in its `if:`, a job is also skipped when any job above it in the `needs` chain failed, was cancelled or was skipped. A matrix job becomes one job per combination (at most 256), started up to `max-parallel` at a time, and each combination gets its own `name`, `runs-on`, `timeout-minutes`, `continue-on-error` and job `concurrency` group, evaluated with its `matrix` values. A job with a `concurrency` group waits as `pending` while another job of the group runs. 5. **Each hosted job gets its own container.** A fresh Cloudflare Container (Ubuntu 24.04, linux/amd64) starts with a job token that is valid only for that job. The runner agent inside fetches the job definition (for a job with `environment:`, this is when the environment's branch policy is checked and its secrets and variables are added), runs the steps in order, streams the log back as it goes, and reports each step's result and the job's conclusion. The container stops when the job ends. Jobs for self-hosted runners wait until a `diffinery runner` with matching labels claims them instead. 6. **The run finishes** when every job is done. Its conclusion is `failure` if any job failed or timed out (except jobs with `continue-on-error: true`), otherwise `cancelled` if the run was cancelled or any job was cancelled, otherwise `skipped` if every job was skipped, otherwise `success`. Skipped jobs don't make a run fail. As on GitHub, a `workflow_run` job with `if: github.event.workflow_run.conclusion == 'success'` is skipped after a run whose jobs were all skipped. If the orchestration loses track of a run whose jobs have all finished (for example, a job's completion signal lost while the platform updates), Diffinery notices 5 minutes after the last job finished and signals the orchestration again. A run still unfinished 30 minutes after its last job, or whose orchestration has stopped, is finished by Diffinery itself, which frees its concurrency group. Jobs the run never started make it fail, and a `Set up workflow` job names them: `The run's workflow stopped after its jobs finished, so a job never started (deploy). Re-run the workflow.` ### Timeouts | What | Default | Maximum | | --- | --- | --- | | A job (`jobs.<id>.timeout-minutes`) | 360 minutes | 360 minutes. Larger values are capped at 360. | | A step (`steps[*].timeout-minutes`) | None (the job's timeout applies) | The job's timeout | | Waiting for a self-hosted runner, plus running the job | | 6 hours, after which the job is marked timed out | A job that runs past its `timeout-minutes` is stopped by its runner and fails with `The job exceeded the maximum execution time of <n> minutes.` If Diffinery hears nothing from a job for 6 hours (for example a self-hosted job that no runner picked up, or a runner that disappeared), it stops waiting, ends the job with `The job exceeded its timeout of <n> minutes.` and the conclusion `timed_out`, which the job page shows as **Timed out**. ### Run and job states | Status | Meaning | | --- | --- | | `queued` | The run or job has been created and hasn't started. | | `pending` | (Jobs) The job is held back by its job-level `concurrency` group until the group's running job finishes. Shown as **Pending**. | | `waiting` | (Jobs) The job is waiting for its container to start, or for a self-hosted runner to claim it. | | `in_progress` | The run is executing, or a runner has picked up the job. | | `completed` | Finished. See the conclusion. | | Conclusion | Meaning | | --- | --- | | `success` | Every step succeeded (steps with `continue-on-error: true` may have failed). | | `failure` | A step failed, the job could not start, or (for runs) a job without `continue-on-error: true` failed. | | `cancelled` | Someone cancelled the run, a newer run or job in the same concurrency group replaced it, or a sibling matrix job failed or timed out with `fail-fast` on. | | `skipped` | The job's `if:` condition was false, or (for runs) every job was skipped. | | `timed_out` | (Jobs) Diffinery waited 6 hours for the job without a result. | ## The Actions tab Anyone who can read the repository can see its runs and logs (anyone at all for a public repository). Starting, cancelling and re-running runs needs write access. ### Runs list `https://diffinery.com/acme/website/actions` shows the newest runs first, 25 per page. - The sidebar lists **All workflows** and each workflow on the default branch by name. A workflow with errors shows a failure icon, and its page shows a **This workflow file has errors** box with each error and its line. The **All workflows** page shows **Some workflow files have errors** when any file is invalid. - Filter by branch with the **Branch** menu (**All branches** by default) and by state with the **Status** menu: **Any status**, `queued`, `in progress`, `completed`, `success`, `failure` or `cancelled`. Click **Filter** to apply. - Each row shows the run's title, workflow name, run number, attempt (when greater than 1), triggering event, pull request, actor (with an **agent** badge for agents), ref, age and duration. The list updates live while you watch it. ### Run page Click a run to open `https://diffinery.com/acme/website/actions/runs/<run-id>`. The run number works in place of the run ID (for example `.../actions/runs/128`) and opens its latest attempt. It shows: - The run's **Status** and duration, what **Triggered** it and who, the **Commit** and ref (and pull request), and the **Workflow file** at that commit. - One box per job id, listing the jobs it produced (a matrix job says how many, for example `4 jobs (matrix)`), each with its status, duration and, for a failed job, the first line of the failure reason. - **Artifacts**, when jobs uploaded any that haven't expired, with their size and age. Click a name to download it: a `.zip` file, or the file itself for an artifact uploaded with `archive: false`. See [Caching and artifacts](https://diffinery.com/docs/actions/caching-and-artifacts). - **Cancel run** while the run is not completed (you confirm with **Cancel this run?**), and **Re-run all jobs** once it has completed. Both need write access. A cancelled job on a Diffinery-hosted runner first runs its `if: always()` and `if: cancelled()` steps, for up to 5 minutes (see [Status check functions](https://diffinery.com/docs/actions/expressions#status-check-functions)); cancelling again stops them. ### Job page and logs Click a job to open its log at `https://diffinery.com/acme/website/actions/runs/<run-id>/job/<job-id>`. - The log is split into collapsible sections: **Set up job**, then one per step, then one per post step (for example `Post Run actions/cache@v4`). Failed and running steps open automatically. - While the job runs, new lines stream in live. - A failed job shows a box with **Failed at** the failing step and the reason: the step's own `::error::` message if it printed one, otherwise the exit code and the last lines of its output. Cancelled and timed-out jobs show **Cancelled** or **Timed out**. - **Job summary** renders the Markdown that steps wrote to `$GITHUB_STEP_SUMMARY`. - **Outputs** lists the job's outputs. - **Raw logs** downloads the whole log as plain text, one line per log line prefixed with its timestamp. The page itself shows the first 20,000 lines and links to the full log when there are more. Secret values, and values registered with `::add-mask::`, are replaced by `***` before log lines leave the runner. ## Start a workflow manually A workflow with an `on: workflow_dispatch` trigger can be started on demand. You need write access. ```yaml name: Deploy on: workflow_dispatch: inputs: environment: description: Where to deploy type: choice options: [staging, production] default: staging dry-run: type: boolean default: true jobs: deploy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - run: ./deploy.sh "${{ inputs.environment }}" "${{ inputs.dry-run }}" ``` Input types, defaults and validation are described in [Events that trigger workflows](https://diffinery.com/docs/actions/events#workflow_dispatch). ### In the web UI 1. Open the **Actions** tab and click the workflow in the sidebar. 2. Expand **Run workflow** (shown when the workflow on the default branch has a `workflow_dispatch` trigger). 3. Pick the branch or tag under **Use workflow from**. 4. Fill in the inputs. Booleans are checkboxes, `choice` inputs are menus, `number` inputs are number fields, everything else is a text field. Required inputs are marked with `*`. 5. Click **Run workflow**. You land on the new run's page. The form is built from the input definitions on the default branch; the run itself uses the workflow file at the ref you pick. ### With the CLI ```sh diffinery workflow run deploy.yml -R acme/website --ref main \ -f environment=production -f dry-run=false ``` | Flag | Meaning | | --- | --- | | `-r, --ref REF` | Branch or tag to run on. Default: the repository's default branch. | | `-f, --field KEY=VALUE` | An input value. Repeat for more inputs. | | `-R, --repo OWNER/NAME` | Repository. Default: inferred from the `origin` remote. | | `--json` | Print the API response as JSON. | The command prints the run ID and URL, and suggests `diffinery run watch <run-id>` to follow it. ### With the API Use a token with the write scope (see [Tokens](https://diffinery.com/docs/accounts/tokens)): ```sh curl -X POST https://diffinery.com/api/v3/repos/acme/website/actions/workflows/deploy.yml/dispatches \ -H "Authorization: Bearer dfy_..." \ -H "Content-Type: application/json" \ -d '{"ref": "main", "inputs": {"environment": "production", "dry-run": false}}' ``` The workflow can be a file name (`deploy.yml`) or a URL-encoded path (`.github%2Fworkflows%2Fdeploy.yml`). Unlike GitHub, which answers `204 No Content`, Diffinery answers `201 Created` with the new run: ```json { "run_id": "3f9c2a7d0b8e4c1f9a6d5e2b7c4a1f08", "html_url": "https://diffinery.com/acme/website/actions/runs/3f9c2a7d0b8e4c1f9a6d5e2b7c4a1f08" } ``` | Status | Message | Cause | | --- | --- | --- | | 403 | `write access is required to run workflows` | Your role or token scope is below write. | | 404 | `workflow deploy.yml not found at main` | No such file at that ref, or the file is invalid (the errors follow a colon). | | 422 | `unknown ref <ref>` | The branch or tag doesn't exist. | | 422 | `workflow does not have a workflow_dispatch trigger` | Add `on: workflow_dispatch` to the file at that ref. | | 422 | `input '<name>' is required` | A required input is missing or empty and has no default. | | 422 | `input '<name>' must be one of <a>, <b>` | A `choice` input got a value that is not in `options`. | ### With MCP The `dispatch_workflow` tool takes `owner`, `repo`, `workflow`, an optional `ref` (default: the default branch) and an optional `inputs` object. See [MCP tools](https://diffinery.com/docs/reference/mcp-tools). ## Follow runs and read logs ### From the CLI | Command | What it does | | --- | --- | | `diffinery run list` | Lists runs, newest first. Filter with `-b, --branch`, `-w, --workflow` (file name or path), `-s, --status`, `-e, --event`, `-c, --commit` and `-L, --limit` (default 20). | | `diffinery run view <run-id>` | Shows a run and its jobs, with job IDs and failure reasons. | | `diffinery run watch <run-id>` | Polls every 3 seconds (`-i, --interval SECONDS`) and prints each job's status changes. Exits 0 if the run succeeds and 1 otherwise, so you can use it in scripts. On failure it prints the `diffinery run logs` command for the failed job. | | `diffinery run logs <job-id>` | Prints a job's log. With `--json`, prints the log lines as JSON objects with `seq`, `t` (timestamp), `step` (step index, `-1` for job setup) and `text`. | | `diffinery run rerun <run-id>` | Re-runs a run; `--failed` re-runs only its failed jobs, and `--job <job-id>` one job (see [Re-run a run](#re-run-a-run)). | | `diffinery run cancel <run-id>` | Cancels a queued or running run. | | `diffinery workflow list` | Lists the workflows on the default branch, whether each is `active` or `invalid`, and their triggers. | | `diffinery workflow validate [files...]` | Checks workflow files locally (see [Workflow syntax](https://diffinery.com/docs/actions/workflow-syntax#validate-workflow-files)). | Every command takes `-R acme/website` when you're not inside a clone, and `--json` for machine-readable output. Full flags are in the [CLI reference](https://diffinery.com/docs/reference/cli). A typical loop for an agent or a script after pushing: ```sh run=$(diffinery run list -R acme/website --commit "$(git rev-parse HEAD)" --json | jq -r '.[0].id') diffinery run watch "$run" -R acme/website || { job=$(diffinery run view "$run" -R acme/website --json | jq -r '.jobs[] | select(.conclusion == "failure") | .id' | head -n1) diffinery run logs "$job" -R acme/website | tail -n 100 } ``` ### From the API | Request | Returns | | --- | --- | | `GET /api/v3/repos/{owner}/{repo}/actions/workflows` | Workflows on the default branch, with `state` (`active` or `invalid`), `errors` and `triggers`. | | `GET /api/v3/repos/{owner}/{repo}/actions/workflows/{workflow}` | One workflow on the default branch, by file name or URL-encoded path. Its `id` is its path. | | `GET /api/v3/repos/{owner}/{repo}/actions/workflows/{workflow}/runs` | That workflow's runs, newest first. Takes `branch`, `status`, `event`, `head_sha`, `per_page` (at most 100, default 30) and `page`. | | `GET /api/v3/repos/{owner}/{repo}/actions/runs` | Runs, newest first. Query parameters: `workflow`, `branch`, `status` (a status or a conclusion), `event`, `head_sha`, `per_page` (at most 100, default 25), `page`. | | `GET /api/v3/repos/{owner}/{repo}/actions/runs/{run_id}` | One run with its `jobs`. | | `GET /api/v3/repos/{owner}/{repo}/actions/runs/{run_id}/attempts/{attempt}` | Attempt `attempt` of the run (each attempt is its own run; see [Re-run a run](#re-run-a-run)). `404 run attempt not found` if there is no such attempt. | | `GET /api/v3/repos/{owner}/{repo}/actions/runs/{run_id}/jobs` | The run's jobs, paginated with `per_page` (default 30) and `page`. | | `GET /api/v3/repos/{owner}/{repo}/actions/runs/{run_id}/logs` | A zip file with one text file per job (`1_<job name>.txt`, `2_<job name>.txt`, ...), each line prefixed with its timestamp. Holds at most 24 MB of logs; jobs past that are left out. | | `DELETE /api/v3/repos/{owner}/{repo}/actions/runs/{run_id}/logs` | Deletes every job's log and step summary for good (`204`); the run, its jobs, checks and artifacts stay. Needs write, and the run must have finished. Agents can delete only the logs of runs that didn't run on the default branch or a protected branch. Audited as `repo.actions.logs.delete`. | | `GET /api/v3/repos/{owner}/{repo}/actions/jobs/{job_id}` | One job, with its steps, outputs, failure and summary. | | `GET /api/v3/repos/{owner}/{repo}/actions/jobs/{job_id}/logs` | The log as text (`<timestamp> <line>` per line). Add `format=json` for JSON lines, and `after=<seq>` to get only lines after a sequence number. | | `GET /api/v3/repos/{owner}/{repo}/actions/runs/{run_id}/artifacts` | The run's artifacts (every attempt when `run_id` is a run number), expired ones included. See [Download artifacts with the API](https://diffinery.com/docs/actions/caching-and-artifacts#download-artifacts-with-the-api) for the other artifact and cache endpoints. | | `POST /api/v3/repos/{owner}/{repo}/actions/runs/{run_id}/cancel` | Cancels the run (`202`). Needs write. | | `POST /api/v3/repos/{owner}/{repo}/actions/runs/{run_id}/rerun` | Re-runs every job of the run (`201`, `{"run_id": ...}`, the new attempt's ID). Needs write. | | `POST /api/v3/repos/{owner}/{repo}/actions/runs/{run_id}/rerun-failed-jobs` | Re-runs the run's failed jobs and the jobs that depend on them (`201`, `{"run_id": ...}`). See below. | | `POST /api/v3/repos/{owner}/{repo}/actions/jobs/{job_id}/rerun` | Re-runs one job and the jobs that depend on it (`201`, `{"run_id": ...}`). See below. | Run objects use GitHub's field names (`id`, `node_id`, `run_number`, `run_attempt`, `name`, `display_title`, `path`, `workflow_id`, `event`, `head_branch`, `head_sha`, `sha`, `ref`, `status`, `conclusion`, `actor` and `triggering_actor` as user objects, `created_at`, `updated_at`, `run_started_at`, `url`, `html_url`, `jobs_url`, `logs_url`, `artifacts_url`, `cancel_url`, `rerun_url`, `workflow_url`, `previous_attempt_url` and `pull_requests`) plus `pull_request_number`. Job objects carry GitHub's fields (`run_id`, `run_attempt`, `head_sha`, `head_branch`, `html_url`, `started_at`, `completed_at`, `workflow_name`, `steps` with `number`) next to Diffinery's own camelCase fields, which the CLI reads. `head_sha` is the commit checks are reported on (the pull request head for pull request runs) and `sha` is the commit the run checked out. Run and job IDs are 32-character hexadecimal strings. Wherever a path takes `{run_id}`, the run number works too and means its latest attempt; this is what `github.run_id` and `GITHUB_RUN_ID` hold inside a job. A job's `check_run_url` points at `GET /repos/{owner}/{repo}/check-runs/{job_id}`, which returns the job's check run. While a job is running, one logs request returns at most 5,000 lines; page with `after`. The runs, jobs and artifacts lists send a `Link` header with `next`, `prev`, `first` and `last` URLs, so Octokit's `paginate()` works. Errors have a `message` and a `documentation_url`. Every endpoint is listed in the [API reference](https://diffinery.com/docs/api). ### From MCP Agents can use `list_workflow_runs`, `get_workflow_run`, `get_job_logs` (the last 400 lines by default, set `tail` for more), `rerun_workflow` (with `failed_only` for the failed jobs only), `rerun_job`, `cancel_workflow` and `dispatch_workflow`. See [MCP tools](https://diffinery.com/docs/reference/mcp-tools). ## Cancel a run Click **Cancel run** on the run page, run `diffinery run cancel <run-id>`, call `POST /actions/runs/{run_id}/cancel`, or use the `cancel_workflow` MCP tool. You need write access. Cancelling stops every unfinished job's container at once and marks those jobs `cancelled` with the reason `Cancelled by <login>`. Jobs that haven't started yet and have no status function in their `if:` are skipped; a job with `if: always()` or `if: cancelled()` still runs, as on GitHub. The run's conclusion is `cancelled`. ## Re-run a run You can re-run a run once it has finished, in three ways. You need write access. | What runs again | Web | CLI | API | MCP | | --- | --- | --- | --- | --- | | Every job | **Re-run all jobs** on the run's page | `diffinery run rerun <run-id>` | `POST /actions/runs/{run_id}/rerun` | `rerun_workflow` | | The failed jobs | **Re-run failed jobs** on the run's page (shown when a job failed, was cancelled or timed out) | `diffinery run rerun <run-id> --failed` | `POST /actions/runs/{run_id}/rerun-failed-jobs` | `rerun_workflow` with `failed_only: true` | | One job | **Re-run this job** on the job's page | `diffinery run rerun --job <job-id>` | `POST /actions/jobs/{job_id}/rerun` | `rerun_job` | Each re-run is a new attempt of the run, with: - the same workflow file (read again from the original commit), commit, ref, event, event payload and inputs, - the same run number and an attempt number one higher (shown as `attempt 2`, and available as `github.run_attempt`), so `github.run_id` stays the same, - a new run ID in the API, and you as the actor, - whether its code came from an agent, kept from the original run whoever re-runs it (see [GITHUB_TOKEN permissions](https://diffinery.com/docs/actions/workflow-permissions#runs-whose-code-came-from-an-agent)). **Re-run failed jobs** runs again, as on GitHub, every job and matrix leg whose conclusion was `failure`, `cancelled` or `timed_out`, and every job that depends on one of them through `needs`, directly or not (all of a dependent job's matrix legs run again). A job that never started because the run was cancelled first runs too. Jobs inside a [reusable workflow](https://diffinery.com/docs/actions/reusable-workflows) count like any other job, and a job that needs the calling job depends on every job inside the call. The other jobs and matrix legs are kept from the earlier attempt as they were: their conclusion, outputs, steps, log, summary and artifacts. They don't run again, start no runner and use no minutes, and they don't count toward `max-parallel`, so a matrix with `fail-fast` can't cancel a leg that already passed. Their check runs point at the new attempt with the same conclusion. On the run's page they're marked **Kept from an earlier attempt**, and in the API a kept job has `carriedFrom`, the ID of the job that ran. A run with no failed, cancelled or timed out job answers `409 this run has no failed jobs to re-run`. **Re-run this job** works the same way for one job (one matrix leg): it runs again with every job that depends on it, and every other job is kept, whatever its conclusion. The run's conclusion counts kept jobs like the others. The body field `enable_debug_logging` of the re-run endpoints is accepted and ignored. Failed-job and single-job re-runs need a finished run (`409 this run hasn't finished yet; ...` otherwise). If what failed was the workflow's set-up (the file couldn't be read or a reusable workflow couldn't be loaded), every job runs again. A workflow's own `GITHUB_TOKEN` can't re-run runs: `403 a workflow's GITHUB_TOKEN cannot re-run workflows`. It fails with `422 workflow file no longer valid at this commit` if the file can't be loaded from that commit any more. > **Note:** On GitHub a re-run keeps the same run ID in the API, and a kept job keeps its job ID and the attempt it ran in. On Diffinery the new attempt has a new `id` (the run number, `github.run_id`, stays the same), a kept job is a copy with its own ID in the new attempt, and its `run_attempt` is the new attempt's. ## Job summaries Append Markdown to the file named by `$GITHUB_STEP_SUMMARY` in any step: ```yaml - name: Report coverage run: | { echo "## Coverage" echo "" echo "| File | Lines |" echo "| --- | --- |" echo "| src/app.ts | 94% |" } >> "$GITHUB_STEP_SUMMARY" ``` Each step gets its own empty summary file. At the end of the job, the non-empty summaries of all steps are joined in step order, secrets are masked, and the result is shown under **Job summary** on the job page and returned as `summary` by `GET /actions/jobs/{job_id}`. ## Status checks on commits and pull requests Every job reports a check on the commit the run is about: - For `push`, `merge_group`, `workflow_dispatch`, `schedule`, `workflow_run`, `release` and `repository_dispatch` runs, that is the run's commit. - For `pull_request` and `pull_request_target` runs, it is the pull request's head commit, even though the job checks out the test merge commit or the base commit. The check is named `<workflow name> / <job name>`, for example `CI / test`, or `CI / test (ubuntu-latest, 22)` for a matrix job without a `name`. It becomes `queued` when the job is launched, `in_progress` when a runner picks it up, and `completed` with the job's conclusion. Its details link opens the job's log. Pull requests show the checks of their head commit in the merge box and on the **Checks** tab. To block merging until checks pass, list them by these exact names under **Required status checks** in a branch protection rule; see [Branch protection](https://diffinery.com/docs/repositories/branch-protection). External CI systems can report results too, as check runs (`POST /repos/{owner}/{repo}/check-runs`) or as commit statuses (`POST /repos/{owner}/{repo}/statuses/{sha}`, where the status's `context` is its name). Both count as checks for required checks and the merge queue. Read them with `GET /repos/{owner}/{repo}/commits/{ref}/check-runs` and `GET /repos/{owner}/{repo}/commits/{ref}/status`. See [Checks and commit statuses](https://diffinery.com/docs/collaboration/checks-and-statuses). How Diffinery decides a required check when several reporters use the same name: - When Diffinery Actions reports a check with that name, only its result counts. Another reporter can't satisfy or override it. - To have a required check decided by a specific reporter instead, bind it to that reporter in the branch protection rule through the API (`requiredCheckSources`, for example `{"ci/external": "ci-bot"}`, or `diffinery-actions` for Actions). - Results that agents report never satisfy a required check. - Of the reports that count, any failure fails the check, and any unfinished one keeps it pending. - Check runs created by Diffinery Actions can't be changed through the REST API. > **Important:** A job that is skipped by its `if:` condition, or that fails before it is launched (for example because no runner matches its `runs-on`), doesn't report a check. If such a job is a required check, the pull request shows `required check '<name>' has not run`. A check also appears only when its job is launched, so a required check for a job that `needs` others shows as not run until those finish. ## Concurrency Set `concurrency` at the top of a workflow to make runs in the same group exclusive: ```yaml concurrency: group: deploy-${{ github.ref }} cancel-in-progress: true ``` - The group is evaluated when the run is created, with the `github`, `inputs` and `vars` contexts. Groups are scoped to the repository, so two workflows can share a group. - With `cancel-in-progress: true`, creating a run cancels every unfinished run (queued or in progress) in the same group, with the reason `cancelled by a newer run in the same concurrency group`. - Without it, a new run waits while another run in the group is in progress, checking once a minute. After 6 hours of waiting it starts anyway. As on GitHub, a group has at most one waiting run: a new run cancels older runs still waiting in the group (not the one in progress), with the reason `Canceling since a higher priority waiting request for '<group>' exists`, so a deploy group runs the newest push next, never older ones one after another. A run cancelled while it waits finishes within a minute, with none of its jobs run. - A run or job on a protected branch is cancelled or replaced through its group only by runs on that same branch. A workflow on another branch that names the same group, `cancel-in-progress` or not, waits for it instead, so a branch can't cancel a protected branch's deploy. If the group expression fails to evaluate, the run has no concurrency group. ### Job concurrency `jobs.<id>.concurrency` limits a group to one running job at a time across the repository, whatever run or workflow the jobs belong to: ```yaml jobs: deploy: runs-on: ubuntu-latest concurrency: group: deploy-${{ matrix.env }} cancel-in-progress: false strategy: matrix: env: [staging, production] steps: - run: ./deploy.sh ${{ matrix.env }} ``` - The group is evaluated for each matrix combination, with the `github`, `needs`, `inputs`, `vars`, `matrix` and `strategy` contexts. - Without `cancel-in-progress`, a job whose group has a running job waits with the status `pending`. Only the newest pending job is kept: an older pending job of the group is cancelled. - With `cancel-in-progress: true`, the new job takes the group and cancels the group's running and pending jobs. - Jobs cancelled this way end as `cancelled` with the message `Canceling since a higher priority waiting request for '<group>' exists`. - When the running job finishes, the oldest pending job of the group starts, from whichever run it belongs to. ## Notifications When a run completes with `success` or `failure`, Diffinery notifies the person who triggered it (for a run an agent triggered, the agent's owner; for runs started by the scheduler or the merge queue, the repository's watchers). By default they get an email and a web notification when a run fails; in their notification settings they can turn off **Only failed runs** to get emails for successful runs too. Cancelled runs don't notify anyone. See [Notifications](https://diffinery.com/docs/accounts/notifications). ## Troubleshooting | Symptom | Cause and fix | | --- | --- | | Nothing runs after a push | Check that the file is directly in `.github/workflows/` or `.diffinery/workflows/` and ends in `.yml` or `.yaml`, and that the `on:` filters match the branch, tag or paths. Run `diffinery workflow validate`. | | A run stays in progress though all its jobs finished, and newer runs in its concurrency group wait with no jobs | The run's orchestration lost track of it. Diffinery repairs it by itself: within 5 minutes of its last job the orchestration is signalled again, and by 30 minutes the run is finished, freeing the group. Nothing to do; re-run the workflow if it names jobs that never started. | | A run titled `Invalid workflow file: ...` appears | A workflow file in the pushed commit has errors. Its one job, **Invalid workflow file**, lists every error with its path and line. Pushes and merge groups create these runs; other events skip invalid files silently. | | A pull request's `pull_request` workflows don't run | The pull request conflicts with its base, so there is no test merge commit. Resolve the conflicts. `pull_request_target` workflows still run. | | `No Diffinery runner matches runs-on: <labels>. Linux (ubuntu-*) runners are hosted; anything else needs runs-on: [self-hosted, ...] and a registered runner.` | Hosted runners accept `ubuntu-*`, `linux`, `diffinery`, `diffinery-*`, `x64` and `amd64`. For other platforms, add `self-hosted` and start a runner with matching labels. See [Runners](https://diffinery.com/docs/actions/runners). | | A self-hosted job stays `waiting` | No runner with all the job's labels is polling. Start `diffinery runner` with those labels. After 6 hours the job times out. | | `The job was not started: this account has used its <n> hosted minutes for this month ...` | The account that owns the repository used its monthly hosted minutes. They renew on the 1st (UTC); until then use a self-hosted runner, or ask a site administrator to raise the quota. See [Account quotas](https://diffinery.com/docs/reference/limits#account-quotas). | | A hosted job stays queued, then fails with `The job waited 6 hours for a hosted runner (one of this account's hosted jobs to finish, or a free runner), and was not started.` | The account already runs as many hosted jobs at once as its quota allows (20 by default, at most 40), or every hosted runner of the instance was busy. Queued jobs start as running ones finish. See [Account quotas](https://diffinery.com/docs/reference/limits#account-quotas) and [hosted runner limits](https://diffinery.com/docs/reference/limits#actions). | | `The job was not started: Actions are suspended for this repository by a site administrator: <reason>.` (or `for every repository of <account>`) | A site administrator suspended Actions here. Nothing runs until they lift it, and a red banner on the Actions page shows the reason. See [Kill switches](https://diffinery.com/docs/admin/site-administration#kill-switches). | | `The job was not started: Hosted runners are paused on this instance by a site administrator: <reason>. Self-hosted runners still work.` | Hosted compute is paused on the whole instance. Use a self-hosted runner, or re-run once it is resumed. See [Kill switches](https://diffinery.com/docs/admin/site-administration#kill-switches). | | `Could not start a runner: ...` | The job's container failed to start with an error that trying again won't fix (passing platform errors are retried first; see [Limits](https://diffinery.com/docs/reference/limits)). Re-run the run. | | `The runner stopped unexpectedly (exit code <n>).` | The job's container exited before reporting a result. Re-run the run; if it repeats, check that the job doesn't exhaust memory or disk. | | `The platform is stopping this runner (a rollout replacing it with a new version, or the platform moving it). The job keeps running for up to 13 minutes; ...` | The platform sent the job's container its stop signal. Deploys leave running jobs alone, so this is rare and usually the platform moving the container. The signal reaches the running step as well, which then fails with "The platform stopped this step's processes while stopping the runner." A job that can only read starts again by itself; see Platform updates in [Runners](https://diffinery.com/docs/actions/runners#limits-and-timeouts). Otherwise re-run the job. | | `Error evaluating 'if': ...` | The job's `if:` expression failed at runtime. See [Expressions](https://diffinery.com/docs/actions/expressions). | | A step can't reach a secret | Secrets are configured in the repository or organization settings. See [Secrets and variables](https://diffinery.com/docs/actions/secrets-and-variables). | | `git push` from a job fails | The job's `GITHUB_TOKEN` needs `contents: write` (new repositories default to read-only tokens), can't change workflow files, and follows branch protection. See [GITHUB_TOKEN permissions](https://diffinery.com/docs/actions/workflow-permissions). | | `Resource not accessible by integration (the workflow's GITHUB_TOKEN needs permissions: ...)` | Add the named scope to the job's `permissions:`. See [GITHUB_TOKEN permissions](https://diffinery.com/docs/actions/workflow-permissions). | | A job is `skipped` though its `if:` is true | A job above it in the `needs` chain failed, was cancelled or was skipped. Add a status function, for example `if: ${{ !cancelled() && !failure() }}`. | | `<branch> is not allowed to deploy to <environment> due to environment protection rules.` | The job sets `environment:` and the environment's branch policy doesn't allow the run's ref. Run it from an allowed branch or tag, or change the policy. See [Environments](https://diffinery.com/docs/actions/environments). | | `invalid environment name '<name>'` | The job's `environment` name is empty, longer than 255 characters, or contains `/`, `\` or control characters. | | An `on: release` workflow runs three times | Creating a published release sends `created`, `published` and `released`. Add `types: [published]`. See [release](https://diffinery.com/docs/actions/events#release). | | An `on: release` workflow doesn't run | Workflows don't run for draft releases, and the workflow file must exist at the commit the release's tag points to. | --- # Workflow syntax > Every workflow, job and step key Diffinery supports, how each behaves, which are ignored, and the exact errors you see when a file is invalid. Source: https://diffinery.com/docs/actions/workflow-syntax This page is the reference for the YAML in a Diffinery workflow file. Diffinery uses GitHub Actions workflow syntax, so most existing workflows run unchanged; for every key it says whether Diffinery supports it fully, partly or not at all, and what differs from GitHub. For when workflows run, see [Events that trigger workflows](https://diffinery.com/docs/actions/events). For `${{ }}` expressions and contexts, see [Expressions](https://diffinery.com/docs/actions/expressions). For how runs execute and how to watch them, see [Actions overview](https://diffinery.com/docs/actions/overview). ## Workflow files - Workflow files live directly in `.github/workflows/` or `.diffinery/workflows/` and end in `.yml` or `.yaml`. Files in subdirectories are ignored. - A file holds one YAML document whose top level is a mapping. Anchors, aliases and merge keys (`<<: *defaults`) are supported. - Keys are case-sensitive, as on GitHub. - A file that fails validation never runs. When a push or merge group commit contains an invalid file, Diffinery records a failed run titled `Invalid workflow file: <first error>` whose single job, **Invalid workflow file**, lists every error. A complete example that uses most features: ```yaml name: CI run-name: CI for ${{ github.ref_name }} by ${{ github.actor }} on: push: branches: [main, "release/**"] paths-ignore: ["docs/**"] pull_request: workflow_dispatch: permissions: contents: read packages: write env: NODE_ENV: test defaults: run: shell: bash concurrency: group: ci-${{ github.ref }} cancel-in-progress: true jobs: lint: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - run: npm ci && npm run lint test: needs: lint name: Test (node ${{ matrix.node }}) runs-on: ubuntu-latest timeout-minutes: 30 strategy: fail-fast: false max-parallel: 2 matrix: node: [20, 22] include: - node: 22 coverage: true services: postgres: image: postgres:16 env: POSTGRES_PASSWORD: postgres ports: ["5432:5432"] options: --health-cmd "pg_isready" --health-interval 5s --health-retries 10 outputs: version: ${{ steps.meta.outputs.version }} steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: ${{ matrix.node }} - id: meta run: echo "version=$(jq -r .version package.json)" >> "$GITHUB_OUTPUT" - run: npm ci - run: npm test env: DATABASE_URL: postgres://postgres:postgres@localhost:5432/postgres - if: ${{ matrix.coverage }} run: npm run coverage report: needs: test if: always() runs-on: ubuntu-latest steps: - run: echo "Tests ${{ needs.test.result }}, version ${{ needs.test.outputs.version }}" ``` ## Validate workflow files Check files before you push with the CLI. It uses the same parser as the server and needs no network access or token: ```sh diffinery workflow validate # every file in .github/workflows and .diffinery/workflows diffinery workflow validate .github/workflows/ci.yml diffinery workflow validate --json # machine-readable results ``` Each problem is printed as `<file>:<line>:<col>: error: <message> (<path>)` or `... warning: ...`, and the command exits with status 1 when any file has an error. - **Errors** make the file invalid; it doesn't run. - **Warnings** don't stop the file from running. You get one for every key Diffinery doesn't know (`unknown top-level key 'x'`, `unknown job key 'x'`, `unknown step key 'x'`, `unknown reusable-workflow job key 'x'`), for every event name it doesn't know (`unknown event 'x'`) and for unknown permission scopes (`unknown permission scope 'x'`). Unknown keys are ignored. The server doesn't show warnings, so run `validate` to see them. On the server, the **Actions** tab, `diffinery workflow list` and `GET /repos/{owner}/{repo}/actions/workflows` show each invalid workflow's errors as `<path>:<line>: <message>`, for example `jobs.build.steps[0]:12: step must have either 'run' or 'uses'`. ## Top-level keys | Key | Support | Notes | | --- | --- | --- | | `name` | Supported | Display name of the workflow. Default: the file's path. | | `run-name` | Supported | Title of each run. Evaluated with the `github`, `inputs` and `vars` contexts. | | `on` | Supported | The events that start the workflow. Required. See [Events that trigger workflows](https://diffinery.com/docs/actions/events). | | `permissions` | Supported | What the job token (`GITHUB_TOKEN`) may do, for jobs without their own `permissions`. See [permissions](#permissions). | | `env` | Supported | Environment variables for every job. | | `defaults` | Supported | `defaults.run.shell` and `defaults.run.working-directory` for every `run` step. | | `concurrency` | Supported | Groups runs and optionally cancels older ones. See [Concurrency](#concurrency). | | `jobs` | Supported | The jobs to run. Required, with at least one job. | Any other top-level key gives the warning `unknown top-level key '<key>'` and is ignored. ### name ```yaml name: CI ``` The name appears in the **Actions** sidebar, in run lists, in check names (`CI / test`) and as `github.workflow` and `GITHUB_WORKFLOW`. Without `name`, the file's path (for example `.github/workflows/ci.yml`) is used everywhere a name is needed. `on: workflow_run` matches other workflows by this name. ### run-name ```yaml run-name: Deploy ${{ inputs.environment }} by @${{ github.actor }} ``` Sets each run's title. It's evaluated once, when the run is created, with the `github`, `inputs` and `vars` contexts (as on GitHub). Without `run-name`, or if evaluation fails, the default title is used: the first line of the commit message for pushes, the pull request title for pull request events, `Merge group for #<n>: <title>` for merge groups, the release's name (or tag) for `release`, the `event_type` for `repository_dispatch`, and the workflow name for other events. ### on Required. Without it the file fails with `workflow is missing the 'on' trigger section`. It can be a single event name, a list of names, or a mapping of event names to their configuration: ```yaml on: push ``` ```yaml on: [push, pull_request] ``` ```yaml on: push: branches: [main] schedule: - cron: "0 3 * * 1-5" ``` An empty list or mapping fails with `'on' must define at least one event`. Event names, activity types, filters and the payload of each event are documented in [Events that trigger workflows](https://diffinery.com/docs/actions/events). ### env ```yaml env: NODE_ENV: production RETRIES: 3 VERBOSE: false ``` A mapping of variable names to strings, numbers or booleans (numbers and booleans become strings; an empty value becomes an empty string). Values may contain expressions; they're evaluated when each job starts. A value that is a list or mapping fails with `env.<name> must be a string, number or boolean`, and `env: ${{ fromJSON(...) }}` fails with `env must be a mapping (expressions producing whole mappings are not supported here)`. Workflow `env` is merged with each job's `env` (the job wins) and then with each step's `env` (the step wins). Variables named `GITHUB_*` or `RUNNER_*` that Diffinery sets can't be overridden; other defaults such as `CI` can. See [Default environment variables](https://diffinery.com/docs/actions/expressions#default-environment-variables). ### defaults ```yaml defaults: run: shell: bash working-directory: ./app ``` `defaults.run.shell` and `defaults.run.working-directory` apply to every `run` step that doesn't set its own. Job-level `defaults` override workflow-level ones key by key. They don't apply to `uses` steps or to the steps inside composite actions, as on GitHub. Values may contain expressions. ### permissions ```yaml permissions: contents: read id-token: write packages: write ``` `permissions` may be `read-all`, `write-all`, an empty mapping (`{}`), or a mapping of scopes to `read`, `write` or `none`. Recognised scopes: `actions`, `attestations`, `checks`, `contents`, `deployments`, `discussions`, `id-token`, `issues`, `models`, `packages`, `pages`, `pull-requests`, `repository-projects`, `security-events` and `statuses`. Another scope gives a warning; another level fails with `permission '<scope>' must be 'read', 'write' or 'none'`. They work as on GitHub: - A job's `permissions` replace the workflow's. A job with neither gets the repository's default: read-only (`contents` and `packages` read) for repositories created since the setting was added, read and write for older ones. - `read-all` gives every scope `read`; `write-all` gives every scope `write`, including `id-token`; `{}` gives nothing. - In a mapping, every scope you don't list is `none`. A job that pushes images and checks out a private repository needs both `packages: write` and `contents: read`. - Each REST endpoint and git operation needs its scope: `contents` for git fetch and push, files, releases and merges, `issues` for issues and labels, `pull-requests` for pull requests and reviews, `statuses`, `checks`, `deployments`, `actions` and so on. A request without it fails with `Resource not accessible by integration (the workflow's GITHUB_TOKEN needs permissions: <scope>: <level>)`. - `id-token: write` lets the job request OIDC tokens (see [OIDC](https://diffinery.com/docs/actions/oidc)); `id-token: read` grants nothing. `packages` decides what the token can do in the [container registry](https://diffinery.com/docs/packages/container-registry). Whatever `permissions` says, the token never administers the repository, never reads or changes secrets and variables, never creates or changes workflow files, and follows branch protection. [GITHUB_TOKEN permissions](https://diffinery.com/docs/actions/workflow-permissions) has the full model, the scope each endpoint needs, and how an admin changes the repository default. ### concurrency ```yaml concurrency: deploy-production ``` ```yaml concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true ``` `group` is evaluated when the run is created, with the `github`, `inputs` and `vars` contexts. `cancel-in-progress` is `true`, `false` (the default) or an expression evaluated with the same contexts. With `true`, a new run cancels every unfinished run in its group. With `false`, the new run waits while another run in the group is in progress, and cancels older runs still waiting there (as on GitHub, a group has one waiting run at most). Groups are shared by all workflows of the repository, but a run on a protected branch is only cancelled by runs on that same branch; runs from elsewhere wait for it. See [Concurrency](https://diffinery.com/docs/actions/overview#concurrency) for the differences from GitHub. A value that is neither a string nor a mapping with `group` fails with `concurrency must be a group string or a mapping with 'group'`. ## Jobs `jobs` is a mapping of job IDs to job definitions. It's required (`workflow is missing the 'jobs' section`), must be a mapping (`'jobs' must be a mapping of job id to job`) and must hold at least one job (`'jobs' must define at least one job`). A job ID must start with a letter or `_` and contain only letters, digits, `-` and `_`; otherwise the file fails with `job id '<id>' must start with a letter or '_' and contain only alphanumerics, '-' or '_'`. ### Job keys | Key | Support | Notes | | --- | --- | --- | | `name` | Supported | Display name. May use `matrix`; evaluated for each matrix combination. | | `needs` | Supported | Jobs that must finish first. | | `if` | Supported | Condition evaluated before the job starts. | | `runs-on` | Supported | Hosted Linux labels or self-hosted labels, plus `diffinery-large`, `-medium`, `-small` or `-tiny` to pick a hosted [runner size](https://diffinery.com/docs/actions/runners#runner-sizes). May use `matrix`; evaluated for each matrix combination. `group` is ignored. | | `environment` | Supported | The deployment environment: its branch policy, required reviewers, wait timer, secrets and variables apply, and a deployment is recorded. See [environment](#environment). | | `outputs` | Supported | Values passed to dependent jobs through `needs`. | | `env` | Supported | Environment variables for every step of the job. | | `defaults` | Supported | `defaults.run` for the job's `run` steps. | | `steps` | Supported | Required for jobs that don't call a reusable workflow. | | `timeout-minutes` | Supported | Default and maximum 360. | | `strategy` | Supported | `matrix` with `include` and `exclude`, `fail-fast`, `max-parallel`. | | `continue-on-error` | Supported | A failing job doesn't fail the run. See [continue-on-error (job)](#continue-on-error-job). | | `container` | Supported | Runs the steps in a Docker container. | | `services` | Supported | Starts service containers next to the job. | | `uses` | Supported | Calls a reusable workflow. | | `with` | Supported | Inputs for a reusable workflow. | | `secrets` | Supported | Secrets for a reusable workflow, or `inherit`. | | `permissions` | Supported | See [permissions (job)](#permissions-job). | | `concurrency` | Supported | One job of a group runs at a time. See [concurrency (job)](#concurrency-job). | | `snapshot` | Accepted, ignored | | Any other key gives the warning `unknown job key '<key>'`. ### name (job) ```yaml jobs: test: name: Test on node ${{ matrix.node }} ``` The job's display name in the UI and in check names (`<workflow name> / <job name>`). `GITHUB_JOB` and `github.job` stay the job's ID. It can use the `github`, `needs`, `inputs`, `vars`, `matrix` and `strategy` contexts. Without `name`, a job is named after its ID, and each matrix job gets its values appended, for example `test (ubuntu-latest, 22)`. ### needs ```yaml jobs: build: runs-on: ubuntu-latest steps: [{ run: make }] test: needs: build runs-on: ubuntu-latest steps: [{ run: make test }] deploy: needs: [build, test] runs-on: ubuntu-latest steps: [{ run: make deploy }] ``` A job ID or list of job IDs. A dependent job starts only after the jobs it needs have finished, and reads their results and outputs through the `needs` context. Errors: - `Job '<id>' depends on unknown job '<other>'` - `Dependency cycle between jobs: a -> b -> a` A job starts as soon as every job it needs has finished, as on GitHub. It doesn't wait for unrelated jobs. By default a job runs only when every job above it in the `needs` chain succeeded (a job with `continue-on-error: true` that failed counts as a success). If any of them failed, was cancelled or was skipped, the job is skipped too, and so are the jobs that need it. `needs.<id>.result` is `skipped` for a skipped job. Use a status function in `if` to change that, for example `if: ${{ !cancelled() && !failure() }}` to run after a skipped job; see [Status check functions](https://diffinery.com/docs/actions/expressions#status-check-functions). ### if (job) ```yaml jobs: deploy: if: github.ref == 'refs/heads/main' && github.event_name == 'push' ``` The condition may be written with or without `${{ }}`. It's checked for syntax when the file is parsed (`Invalid expression: ...`) and evaluated just before the job starts, with the `github`, `needs`, `inputs`, `vars` and `env` (workflow-level) contexts. Without a status function the condition is treated as `success() && (<condition>)`, as on GitHub. A job whose condition is false is recorded as `skipped`. A runtime error fails the job with `Error evaluating 'if': <message>`. ### runs-on ```yaml runs-on: ubuntu-latest ``` ```yaml runs-on: [self-hosted, linux, gpu] ``` ```yaml runs-on: labels: [self-hosted, macos] ``` A label, a list of labels, or a mapping with `labels` (and `group`, which Diffinery ignores). Required for jobs that don't call a reusable workflow (`job '<id>' is missing 'runs-on'`). | Labels | Where the job runs | | --- | --- | | Only labels matching `ubuntu-*`, `linux`, `diffinery`, `diffinery-*`, `x64` or `amd64` (case-insensitive) | A Diffinery-hosted Linux container (Ubuntu 24.04, x64). `ubuntu-latest`, `ubuntu-22.04` and `ubuntu-24.04` all get the same image. `diffinery-large`, `diffinery-medium`, `diffinery-small` or `diffinery-tiny` picks its size; without one, the job's own earlier runs pick it. See [Runner sizes](https://diffinery.com/docs/actions/runners#runner-sizes). | | Any list that includes `self-hosted` | A self-hosted runner whose labels include every label in the list. See [Runners](https://diffinery.com/docs/actions/runners). | | Anything else (for example `windows-latest` or `macos-14` without `self-hosted`) | Nowhere. The job fails with `No Diffinery runner matches runs-on: <labels>. Linux (ubuntu-*) runners are hosted; anything else needs runs-on: [self-hosted, ...] and a registered runner.` | Labels may be expressions, evaluated for each matrix combination with the `github`, `needs`, `inputs`, `vars`, `matrix` and `strategy` contexts. An expression can give one label or a list of labels: ```yaml jobs: test: strategy: matrix: runner: [ubuntu-latest, [self-hosted, arm64]] runs-on: ${{ matrix.runner }} ``` If an expression fails, only that combination fails, with `Error evaluating the job for this matrix combination: <message>`. If it gives no labels, the job fails with `No Diffinery runner matches runs-on: (no labels). ...`. ### environment ```yaml environment: production ``` ```yaml environment: name: production url: https://www.example.com ``` The deployment environment the job deploys to: a name, or a mapping with `name` and an optional `url`. Anything else fails the file with `environment must be a name or a mapping with 'name'`. Environments, their branch policies, secrets and variables are managed through the REST API; see [Environments](https://diffinery.com/docs/actions/environments) for the full guide. ```yaml jobs: deploy: runs-on: ubuntu-latest environment: name: ${{ github.ref_name == 'main' && 'production' || 'staging' }} url: https://${{ github.ref_name == 'main' && 'www' || 'staging' }}.example.com steps: - uses: actions/checkout@v4 - run: ./deploy.sh env: API_KEY: ${{ secrets.DEPLOY_API_KEY }} # an environment secret REGION: ${{ vars.REGION }} # an environment variable ``` What happens when a job with `environment` starts: 1. `name` and `url` are evaluated with the `github`, `needs`, `inputs`, `vars` (repository variables), `matrix` and `strategy` contexts. A `url` that refers to `steps.` is ignored, because step outputs don't exist yet when the job starts. 2. The name is checked: it must be 1 to 255 characters without `/`, `\` or control characters, otherwise the job fails with `invalid environment name '<name>'`. Names are case-insensitive. An environment that doesn't exist yet is created, with no branch policy. 3. The environment's deployment branch policy is checked against the run's ref (`GITHUB_REF`). If the ref isn't allowed, the job fails before any step runs with `<branch or tag> is not allowed to deploy to <name> due to environment protection rules.` 4. A deployment is recorded for the run's commit and ref, with the task `deploy`, marked as a production deployment when the environment is named `production` (in any case), and a deployment status `in_progress` whose log URL is the job's log. When `url` is an `http://` or `https://` URL, it's stored as the status's environment URL. 5. The environment's secrets are added to the `secrets` context and its variables to the `vars` context. They win over repository and organization secrets and variables with the same names. `GITHUB_TOKEN` and `DIFFINERY_TOKEN` can't be overridden. When the job finishes, the deployment gets a final status: `success` when the job succeeded, `inactive` when it was cancelled, and `failure` otherwise (including timeouts). Each attempt of a re-run that runs the job again records its own deployment. When the job succeeds, older successful deployments to the same environment become `inactive` (newer ones are left alone, and production deployments are never made inactive this way). Read deployments with `GET /api/v3/repos/{owner}/{repo}/deployments`; webhooks subscribed to `deployment` get a `created` event for each deployment, and those subscribed to `deployment_status` get each status. Environment secrets aren't always handed out, even when the branch policy allows the ref: - Runs on pull request refs (`refs/pull/<n>/merge`, used by `pull_request` runs) never get environment secrets, because their code is whatever the pull request says. - Runs whose code came from an agent (an agent started the run, pushed the branch last, or opened the pull request, and re-runs and `workflow_run` followers of such runs) get environment secrets only when the environment's policy is "protected branches only". In those cases the job still runs, with the environment's variables but without its secrets (an unset secret is `''`). Differences from GitHub: - Required reviewers are people only (Diffinery has no teams), nobody bypasses the rules, and custom deployment protection rules aren't supported. See [Required reviewers and wait timers](https://diffinery.com/docs/actions/environments#required-reviewers-and-wait-timers). - `url` can't use step outputs. - The environment doesn't change the job token's permissions. To create deployment statuses yourself, give the job `deployments: write`. A job that calls a reusable workflow (`uses`) can't set `environment` (you get the warning `unknown reusable-workflow job key 'environment'` and it's ignored). Set it on the jobs inside the called workflow instead. ### outputs ```yaml jobs: version: runs-on: ubuntu-latest outputs: tag: ${{ steps.tag.outputs.value }} steps: - id: tag run: echo "value=v$(date +%Y.%m.%d)" >> "$GITHUB_OUTPUT" release: needs: version runs-on: ubuntu-latest steps: - run: echo "Releasing ${{ needs.version.outputs.tag }}" ``` A mapping of names to strings, usually expressions over `steps`. They're evaluated after the job's last step, with every context available to steps. An output whose value contains a secret is dropped with the log warning `Skip output '<name>' since it may contain secret.`; one that fails to evaluate is dropped with `Failed to evaluate job output '<name>': <message>`. Outputs appear under **Outputs** on the job page. For a matrix job, the outputs of all its jobs are merged into `needs.<id>.outputs`; when two jobs set the same output, the one that was created later wins. ### env (job) Like the [top-level `env`](#env), for one job. Job values override workflow values. Values may use any context available to steps except `env` itself and `steps` (they're evaluated before the first step). ### defaults (job) Like the [top-level `defaults`](#defaults), for one job. Overrides the workflow's `defaults.run` key by key. ### steps (job) A list of steps, required for a job that doesn't call a reusable workflow (`job '<id>' must have a list of 'steps'`) and not empty (`job '<id>' must have at least one step`). See [Steps](#steps). ### timeout-minutes (job) ```yaml timeout-minutes: 20 ``` A number or an expression (which may use `matrix`; evaluated for each matrix combination). Default: 360. Values above 360 are capped at 360, the longest a job can run on Diffinery. When the time is up, the runner logs `The job running on runner has exceeded the maximum execution time of <n> minutes.`, stops the job's processes, marks the remaining steps cancelled, and the job fails with `The job exceeded the maximum execution time of <n> minutes.` ### strategy ```yaml strategy: fail-fast: true max-parallel: 3 matrix: os: [ubuntu-22.04, ubuntu-24.04] node: [20, 22] exclude: - os: ubuntu-22.04 node: 20 include: - node: 22 experimental: true - os: ubuntu-24.04 node: 23 ``` #### matrix `matrix` is a mapping of variables to lists. Diffinery creates one job for every combination, then applies `exclude` and `include` with GitHub's rules: 1. Every combination of the lists is built. 2. `exclude` entries remove every combination they match. An entry matches when each of its keys equals the combination's value (a partial match is enough). 3. Each `include` entry is merged into every remaining combination whose original values it doesn't change. It may add new keys and may overwrite values added by an earlier `include`, but never a value from the original lists. If it can't be merged into any combination, it becomes a new combination of its own. The example above produces `(ubuntu-22.04, 22, experimental)`, `(ubuntu-24.04, 20)`, `(ubuntu-24.04, 22, experimental)` and `(ubuntu-24.04, 23)`. Values are compared by their string form, so `18` equals `'18'`. - A matrix with only `include` produces exactly the `include` entries. - The whole matrix, any list, `include` or `exclude` can be a single `${{ }}` expression, for example `matrix: ${{ fromJSON(needs.plan.outputs.matrix) }}`. Such expressions are evaluated when the job starts, with the `github`, `needs`, `inputs`, `vars` and `env` contexts. - A matrix may produce at most 256 jobs. A larger one fails the job with `A matrix may produce at most 256 jobs.` - Before `exclude` is applied, the vectors may have at most 4,096 combinations, and `include` and `exclude` at most 256 entries each. A larger matrix is refused before it is expanded (at parse time, or at run time for one built with `fromJSON`). - The `matrix` and `strategy` contexts give each job its values (`strategy.job-index`, `strategy.job-total`, `strategy.fail-fast`, `strategy.max-parallel`). Parse and runtime errors: `matrix produces no job combinations`, `matrix.<key> must be a list (got <type>)`, `matrix.include must be a list of mappings`, `matrix.exclude[<n>] must be a mapping`, `matrix must be a mapping or a single ${{ }} expression`, `the matrix has <n> combinations before exclusions; at most 4096 are allowed (and a matrix may produce at most 256 jobs)`, `matrix.include has <n> entries; at most 256 are allowed`, and at runtime `Error expanding matrix: <message>`. #### fail-fast `true` (the default), `false` or an expression. With `true`, when one job of the matrix fails or times out (and doesn't have `continue-on-error: true`), its unfinished sibling jobs are stopped and marked `cancelled` with the reason `Cancelled because <job name> failed (fail-fast).` #### max-parallel A number or an expression. At most this many jobs of the matrix run at the same time; the others stay queued and start as running ones finish. Default: all at once. When `fail-fast` stops the matrix, queued jobs are cancelled too. ### continue-on-error (job) ```yaml jobs: test: runs-on: ubuntu-latest continue-on-error: ${{ matrix.experimental }} strategy: matrix: node: [20, 22] experimental: [false] include: - node: 24 experimental: true steps: - uses: actions/setup-node@v4 with: node-version: ${{ matrix.node }} - run: npm ci && npm test ``` `true`, `false` or an expression, evaluated for each matrix combination (it may use `matrix` and `strategy`). When a job with `continue-on-error: true` fails or times out: - The job itself still shows as failed, but the run's conclusion isn't `failure` because of it. - It doesn't trigger `fail-fast`, so its matrix siblings keep running. - It counts as a success in `needs.<id>.result` and for the `success()` checks of the jobs that need it. ### container ```yaml container: image: node:22-bookworm credentials: username: ${{ secrets.REGISTRY_USER }} password: ${{ secrets.REGISTRY_TOKEN }} env: NODE_ENV: test ports: [8080] volumes: ["/data:/data"] options: --cpus 2 ``` Runs every `run` step and JavaScript action of the job inside a container started from `image`. A string is short for `image:`. Keys: `image` (required), `credentials` (`username` and `password`, both required when present), `env`, `ports`, `volumes` and `options` (extra `docker run` flags; `--network` is rejected because every container of a job shares one network). Values may use expressions. Hosted runners have Docker; self-hosted runners need it installed. Details are in [Docker in workflows](https://diffinery.com/docs/actions/docker). ### services ```yaml services: redis: image: redis:7 ports: ["6379:6379"] options: --health-cmd "redis-cli ping" --health-interval 2s --health-retries 15 ``` A mapping of service names to the same keys as `container`. Each service starts before the first step, on the job's network, and Diffinery waits for its health check when the image or `options` define one. Steps in a job container reach a service by its name (`redis:6379`); steps on the runner reach it on `localhost` through its mapped ports. A service whose `image` evaluates to an empty string is skipped. See [Docker in workflows](https://diffinery.com/docs/actions/docker). ### uses, with and secrets (reusable workflows) ```yaml jobs: call-tests: uses: ./.github/workflows/tests.yml with: node-version: 22 secrets: inherit deploy: needs: call-tests uses: acme/shared-workflows/.github/workflows/deploy.yml@v2 with: environment: production secrets: token: ${{ secrets.DEPLOY_TOKEN }} ``` A job with `uses` calls a reusable workflow instead of running steps. `uses` must be `./path/to/workflow.yml` (from the same commit) or `owner/repo/path/to/workflow.yml@ref` (from a repository on the same Diffinery instance); otherwise the file fails with `reusable workflow reference '<value>' must be './path/to/workflow.yml' or 'owner/repo/path/to/workflow.yml@ref'`. Such a job may only have `name`, `uses`, `with`, `secrets`, `needs`, `if`, `permissions`, `concurrency` and `strategy`. `steps` and `runs-on` are errors (`a job that calls a reusable workflow ('uses') cannot have 'steps'`); other keys give the warning `unknown reusable-workflow job key '<key>'`. A `matrix` on a calling job isn't supported yet and fails the run with `jobs.<id>: a matrix on a job that calls a reusable workflow is not supported yet`. Calls nest up to 4 levels deep, and one run can call at most 20 reusable workflows. Inputs, secrets, outputs and every error message are in [Reusable workflows](https://diffinery.com/docs/actions/reusable-workflows). ### permissions (job) Same syntax as the [top-level `permissions`](#permissions). The job's value replaces the workflow's completely, as on GitHub: scopes the workflow grants but the job's mapping leaves out are `none` for this job. In a [reusable workflow](https://diffinery.com/docs/actions/reusable-workflows), a job's permissions can't exceed the calling job's. See [GITHUB_TOKEN permissions](https://diffinery.com/docs/actions/workflow-permissions). ### concurrency (job) ```yaml jobs: deploy: runs-on: ubuntu-latest concurrency: group: deploy-${{ github.ref }} cancel-in-progress: true ``` Same syntax as the top-level `concurrency`. At most one job of a group runs at a time across the whole repository, whichever run or workflow it belongs to. `group` and `cancel-in-progress` are evaluated for each matrix combination, with the `github`, `needs`, `inputs`, `vars`, `matrix` and `strategy` contexts. - Without `cancel-in-progress`, a job waits with the status `pending` while another job of its group runs. Only the newest pending job is kept: a new pending job cancels the older one. - With `cancel-in-progress: true`, the new job starts and the group's running and pending jobs are cancelled. - A job cancelled by its group ends with the message `Canceling since a higher priority waiting request for '<group>' exists`. - When a running job finishes, the oldest pending job of the group starts. The concurrency of a job that calls a reusable workflow is ignored; set it on the jobs inside the called workflow. ## Steps Each item in `steps` is a mapping. A step runs either a shell script (`run`) or an action (`uses`), never both. | Key | Support | Notes | | --- | --- | --- | | `id` | Supported | Lets later steps read this step through `steps.<id>`. | | `if` | Supported | Condition evaluated before the step. | | `name` | Supported | Display name; may use expressions. | | `uses` | Supported | An action: `owner/repo@ref`, `owner/repo/path@ref`, `./path` or `docker://image`. | | `run` | Supported | A script run by `shell`. | | `shell` | Supported | `bash`, `sh`, `python`, `pwsh`, `powershell` or a custom command with `{0}`. `cmd` fails. | | `with` | Supported | Action inputs; scalar values only. | | `env` | Supported | Environment variables for this step. | | `continue-on-error` | Supported | `true`, `false` or an expression. | | `timeout-minutes` | Partly | A number works; an expression is accepted but ignored. | | `working-directory` | Supported | For `run` steps. | Any other key gives the warning `unknown step key '<key>'`. A step with neither `run` nor `uses` fails with `step must have either 'run' or 'uses'`; one with both fails with `step cannot have both 'run' and 'uses'`. ### id ```yaml - id: build run: echo "artifact=dist/app.tar.gz" >> "$GITHUB_OUTPUT" - run: echo "${{ steps.build.outputs.artifact }} ${{ steps.build.outcome }}" ``` Must start with a letter or `_` and contain only letters, digits, `-` and `_` (`step id '<id>' must start with a letter or '_' and contain only alphanumerics, '-' or '_'`), and be unique within the job (`duplicate step id '<id>'`). The step's ID is also `GITHUB_ACTION` while it runs (`__run` for a `run` step without an ID). ### if (step) ```yaml - if: failure() && steps.test.outcome == 'failure' run: ./collect-crash-logs.sh ``` With or without `${{ }}`. Evaluated just before the step, with every step context. Without a status function it's treated as `success() && (<condition>)`: the step is skipped once an earlier step has failed (and not set `continue-on-error`), or once the job is being cancelled. A skipped step's `outcome` and `conclusion` are `skipped`. An evaluation error fails the step with `Error evaluating 'if' condition: <message>`. ### name (step) The step's title in the log. Without it, Diffinery uses `Run <uses>` or `Run <first line of the script>`, as on GitHub. ### uses ```yaml - uses: actions/checkout@v4 # an action repository - uses: actions/cache/restore@v4 # an action in a subdirectory - uses: ./.github/actions/setup # a local action in the workspace - uses: docker://alpine:3.20 # a Docker image with: entrypoint: /bin/sh args: -c "echo hello" ``` - `actions/checkout`, `actions/cache` (and `cache/restore`, `cache/save`), `actions/upload-artifact`, `actions/download-artifact`, `actions/setup-node`, `actions/setup-python` and `actions/setup-go` are built into the runner for their current major versions. A commit SHA, a branch, a newer major version, an input the built-in doesn't know, or `DIFFINERY_BUILTINS: false` in `env` runs the real action instead (see [Built-in actions](https://diffinery.com/docs/actions/compatibility#built-in-actions)). - Other `owner/repo@ref` actions are downloaded from GitHub and run with their `action.yml`: JavaScript (`node*`), composite and Docker actions are supported. JavaScript actions run with the runner's Node.js 22 whatever `runs.using` says. - `./path` actions are read from the workspace, so check out the repository first. Otherwise the step fails with `Can't find 'action.yml' or 'action.yaml' under '<dir>'. Did you forget to run actions/checkout before running your local action?` - `docker://image` runs a container; `with.args` and `with.entrypoint` set its arguments and entrypoint. - `actions/github-script` and other actions that call the API work with the job token, within its `permissions`. - A reference without `@ref` fails with `Expected format {owner}/{repo}[/path]@ref. Actual '<value>'`. An unknown action or version fails with `Unable to resolve action '<owner>/<repo>@<ref>', repository or version not found`. What each built-in action supports is in [GitHub Actions compatibility](https://diffinery.com/docs/actions/compatibility) and [Caching and artifacts](https://diffinery.com/docs/actions/caching-and-artifacts). ### with ```yaml - uses: actions/setup-node@v4 with: node-version: 22 cache: npm ``` A mapping of input names to strings, numbers or booleans; lists and mappings fail with `<path> must be a string, number or boolean`. Values may contain expressions. JavaScript and Docker actions receive each input as `INPUT_<NAME>` (upper case, spaces replaced by `_`). Defaults from the action's `action.yml` fill in missing inputs; a missing required input without a default logs `Input required and not supplied: <name>`. ### run ```yaml - name: Build run: | npm ci npm run build ``` The script is written to a temporary file and run with the step's shell from `working-directory` (default: `GITHUB_WORKSPACE`). Expressions in it are replaced before it runs. The step fails when the script exits with a non-zero status: `Process completed with exit code <n>.` ### shell | Value | Command | Notes | | --- | --- | --- | | (not set) | `bash --noprofile --norc -eo pipefail {0}` | Falls back to `sh -e {0}` when `bash` isn't installed (for example in an Alpine job container). | | `bash` | `bash --noprofile --norc -eo pipefail {0}` | | | `sh` | `sh -e {0}` | | | `python` | `python {0}` | Uses `python3` when there is no `python`. | | `pwsh` | `pwsh -command ". '{0}'"` | PowerShell isn't installed on hosted runners; use a job container or self-hosted runner that has it. | | `powershell` | `powershell -command ". '{0}'"` | Uses `pwsh` when there is no `powershell`. | | `cmd` | | Fails with ``The `cmd` shell is not supported on Linux runners.`` | | A command containing `{0}` | As written, with `{0}` replaced by the script's path | For example `node {0}` or `perl {0}`. The script file gets `.py`, `.ps1`, `.js` or `.sh` depending on the command. | Anything else fails with `Invalid shell option '<value>'. Shell must be a valid built-in (bash, sh, python, pwsh) or a format string containing '{0}'`. A shell that isn't installed fails with `<command>: command not found. The shell '<value>' is not available on this runner.` (or `in the job container`). > **Note:** On GitHub, a step without `shell` runs `bash -e {0}`, and only an explicit `shell: bash` adds `--noprofile --norc -o pipefail`. Diffinery always uses the stricter form, so a pipeline like `false | true` fails a step even when `shell` isn't set. ### env (step) ```yaml - run: ./deploy.sh env: API_TOKEN: ${{ secrets.API_TOKEN }} TARGET: staging ``` Variables for this step only, on top of the workflow's, the job's and those added with `$GITHUB_ENV`. A step's values can refer to its earlier keys through `env`. ### continue-on-error (step) ```yaml - run: npm run flaky-e2e continue-on-error: true ``` `true`, `false` or an expression that evaluates to `true` or `false`. When the step fails, its `outcome` is `failure` but its `conclusion` is `success`, the log shows `Step failed but continue-on-error is set; continuing.`, and later steps run as if it had succeeded. ### timeout-minutes (step) ```yaml - run: npm test timeout-minutes: 10 ``` A number of minutes (fractions allowed). When it's up, the step's processes are stopped and the step fails with `The step has timed out after <n> minutes.` Without it, only the job's timeout applies. > **Note:** The parser accepts an expression here, but Diffinery currently ignores it and the step runs without its own timeout. Use a plain number. ### working-directory ```yaml - run: npm test working-directory: packages/api ``` The directory a `run` step starts in, relative to `GITHUB_WORKSPACE` unless absolute. It must exist: otherwise the step fails with `An error occurred trying to start process '<shell>' with working directory '<dir>'. No such file or directory`. ## Workflow commands Steps talk to the runner by printing lines in the form `::command key=value,key=value::message` to standard output or standard error, as on GitHub. | Command | Effect | | --- | --- | | `::add-mask::<value>` | Masks the value as `***` in every later log line. Values shorter than 3 characters are not masked. | | `::group::<title>` and `::endgroup::` | Folds the lines between them under a title in the log. | | `::error file=<f>,line=<n>,title=<t>::<message>` | Logs an error. When the step fails, the last error it printed becomes the job's failure reason. | | `::warning file=<f>,line=<n>,title=<t>::<message>` | Logs a warning. | | `::notice file=<f>,line=<n>,title=<t>::<message>` | Logs a notice. | | `::debug::<message>` | Logged only when debug logging is on: set the secret or variable `ACTIONS_STEP_DEBUG` to `true`. Debug logging also sets `RUNNER_DEBUG=1` and `runner.debug`. | | `::set-output name=<name>::<value>` | Sets a step output. Deprecated on GitHub but still works; prefer `$GITHUB_OUTPUT`. | | `::save-state name=<name>::<value>` | Saves state for an action's post step (`STATE_<name>`). | | `::stop-commands::<token>` ... `::<token>::` | Stops processing commands until the token line, so you can print untrusted text. | | `::echo::on` and `::echo::off` | Echoes processed commands into the log. | | `::set-env` and `::add-path` | Disabled, as on GitHub. They log ``The `set-env` command is disabled. Please upgrade to using Environment Files.`` | Annotations (`error`, `warning`, `notice`) are shown in the log with their file, line and title; only `file`, `line` and `title` are read, and other properties such as `col` are ignored. Diffinery doesn't attach annotations to files in pull requests. ## Environment files Each step gets fresh, empty files whose paths are in these variables. Write to them to pass data on: | Variable | Format | Effect | | --- | --- | --- | | `GITHUB_OUTPUT` | `name=value`, or `name<<EOF` ... `EOF` for multi-line values | Sets the step's outputs (`steps.<id>.outputs.<name>`). | | `GITHUB_ENV` | `NAME=value`, or `NAME<<EOF` ... `EOF` | Sets environment variables for the following steps. `NODE_OPTIONS` values containing `--require` or `-r` are ignored. | | `GITHUB_PATH` | One directory per line | Prepends the directories to `PATH` for the following steps. | | `GITHUB_STEP_SUMMARY` | Markdown | Adds to the job summary shown on the job page. | | `GITHUB_STATE` | `name=value` | Saves state for an action's post step. | ```yaml - id: info run: | echo "sha_short=$(git rev-parse --short HEAD)" >> "$GITHUB_OUTPUT" { echo "CHANGELOG<<EOF" git log -5 --oneline echo "EOF" } >> "$GITHUB_ENV" echo "$HOME/.local/bin" >> "$GITHUB_PATH" ``` A malformed line logs `Unable to process file command '<env|path|output|state>' successfully: Invalid file command format: <line>` (or `Matching delimiter not found '<delimiter>'`) and marks an error. ## Masking Every secret's value (each line separately for multi-line secrets), the job's token and every value passed to `::add-mask::` are replaced by `***` in log lines, step names, the job summary and the failure reason. Values shorter than 3 characters are never masked, because masking them would make logs unreadable. A job output that contains a masked value is dropped. ## Validation errors The most common errors from the parser, with what to do: | Message | Fix | | --- | --- | | `YAML syntax error: <detail>` | Fix the YAML at the reported line (indentation, quotes, tabs). | | `workflow file is empty` | Add content, or delete the file. | | `workflow is missing the 'on' trigger section` | Add `on:`. | | `workflow is missing the 'jobs' section` | Add `jobs:`. | | `job '<id>' is missing 'runs-on'` | Add `runs-on: ubuntu-latest`. | | `job '<id>' must have a list of 'steps'` | Add `steps:` with at least one step. | | `step must have either 'run' or 'uses'` | Give the step a `run` or `uses`. | | `'branches' and 'branches-ignore' cannot be used together for the same event` | Use one of them, with `!` patterns for exceptions. The same applies to `tags`/`tags-ignore` and `paths`/`paths-ignore`. | | `Invalid cron: <detail>` | Fix the `schedule` cron expression; see [schedule](https://diffinery.com/docs/actions/events#schedule). | | `Unclosed expression: '${{' at offset <n> has no matching '}}'` | Close the expression. | | `Invalid expression ${{ <expr> }}: <detail>` | Fix the expression syntax; see [Expressions](https://diffinery.com/docs/actions/expressions). | | `<path> must be a mapping (expressions producing whole mappings are not supported here)` | Write `env`, `with` or `outputs` as a mapping and use expressions in the values. | | `input '<name>' of type 'choice' must define 'options'` | Add `options:` to the `workflow_dispatch` input. | | `input '<name>' must declare a 'type'` | `workflow_call` inputs need a `type`. | ## Limits | Limit | Value | | --- | --- | | Job timeout | 360 minutes (default and maximum) | | Jobs per matrix | 256 | | Reusable workflow nesting | 4 levels | | Reusable workflows called per run | 20 | | Composite action nesting | 10 levels | | Characters per log line | 16,384 (longer lines are cut and end with `…`) | See [Limits](https://diffinery.com/docs/reference/limits) for the rest of Diffinery's limits. --- # Events that trigger workflows > Every event that can start a Diffinery workflow, its activity types and filters, the commit and ref it runs on, and its github.event payload. Source: https://diffinery.com/docs/actions/events The `on:` section of a workflow says which events start it. Diffinery supports the GitHub Actions events that matter for CI and automation: pushes, pull requests, merge queue groups, manual dispatch, schedules, other workflows' runs, published releases, external `repository_dispatch` events and reusable workflow calls. This page lists each one with its activity types, filters, the `GITHUB_SHA` and `GITHUB_REF` its runs get, and its `github.event` payload. ## Supported events | Event | Fires when | Activity types | Filters | | --- | --- | --- | --- | | [`push`](#push) | A branch or tag is created or updated | | `branches`, `branches-ignore`, `tags`, `tags-ignore`, `paths`, `paths-ignore` | | [`pull_request`](#pull_request) | A pull request is opened, updated, edited, reopened, closed, marked ready for review or converted to a draft | `opened`, `synchronize`, `reopened`, `closed`, `edited`, `ready_for_review`, `converted_to_draft` | `types`, `branches`, `branches-ignore`, `paths`, `paths-ignore` | | [`pull_request_target`](#pull_request_target) | Same as `pull_request`, but runs the base branch's workflow | Same as `pull_request` | Same as `pull_request` | | [`merge_group`](#merge_group) | The merge queue builds a merge group | `checks_requested` | `types`, `branches`, `branches-ignore` | | [`workflow_dispatch`](#workflow_dispatch) | Someone starts the workflow from the UI, CLI, API or MCP | | `inputs` | | [`schedule`](#schedule) | A cron schedule is due | | `cron` | | [`workflow_run`](#workflow_run) | A run of another named workflow is requested or completes | `requested`, `completed` | `workflows`, `types`, `branches`, `branches-ignore` | | [`release`](#release) | A release is published, unpublished, edited or deleted | `published`, `unpublished`, `created`, `edited`, `deleted`, `prereleased`, `released` | `types` | | [`repository_dispatch`](#repository_dispatch) | Someone calls `POST /repos/{owner}/{repo}/dispatches` | Any `event_type` you choose | `types` | | [`workflow_call`](#workflow_call) | Another workflow calls this one as a reusable workflow | | `inputs`, `outputs`, `secrets` | ### Other GitHub events The other GitHub event names (`issues`, `issue_comment`, `create`, `delete`, `pull_request_review`, `check_run`, `status`, `deployment`, `deployment_status`, `label`, `watch` and the rest of GitHub's list) are accepted in `on:` without an error or warning, so workflows that list them still parse, but Diffinery never starts a run for them. A name that isn't a GitHub event at all gives the warning `unknown event '<name>'` in `diffinery workflow validate`. ## Writing on ```yaml on: push # one event, no configuration ``` ```yaml on: [push, pull_request] # several events, no configuration ``` ```yaml on: # events with configuration push: branches: [main] tags: ["v*"] pull_request: types: [opened, synchronize, reopened, closed] paths: ["src/**"] workflow_dispatch: ``` An event's configuration must be a mapping (`on.<event> must be a mapping`), except `schedule`, which is a list. Each workflow file whose `on:` matches an event gets one run for it. ## Filter patterns Branch, tag and path filters use GitHub's filter pattern syntax: | Pattern | Matches | | --- | --- | | `*` | Any characters except `/`. `feature/*` matches `feature/login` but not `feature/a/b`. | | `**` | Any characters, including `/`. `feature/**` matches `feature/a/b`. | | `**/` at the start | Any directories, or none. `**/README.md` matches `README.md` and `docs/README.md`. | | `?` | Zero or one of the preceding character. `v1?` matches `v` and `v1`. | | `+` | One or more of the preceding character. `v1+` matches `v1` and `v111`. | | `[abc]`, `[0-9]` | One character from the set or range. | | `\` | Escapes the next character, for example `\*` for a literal `*`. | | `!pattern` | In a list, excludes what earlier patterns matched. | Patterns match the whole name: a branch name without `refs/heads/`, a tag name without `refs/tags/`, or a file path relative to the repository root. Quote patterns that start with `*`, `!` or `[` in YAML. A list is read in order, and the last pattern that matches decides: a value is included when it matches a positive pattern and no later `!` pattern excludes it. A `!` pattern can only exclude what an earlier pattern included, so a list of only `!` patterns matches nothing. ```yaml on: push: branches: - "releases/**" - "!releases/**-alpha" ``` This runs for `releases/10` and `releases/beta/mona`, but not `releases/10-alpha`. ### Include and ignore filters - `branches` runs the workflow only for matching branches; `branches-ignore` runs it for every branch except matching ones. The same goes for `tags` and `tags-ignore`, and `paths` and `paths-ignore`. - You can't use a filter and its `-ignore` form for the same event. The file fails with `'branches' and 'branches-ignore' cannot be used together for the same event` (or the same message for `tags` or `paths`). Use `!` patterns in the positive form instead. ### Path filters - With `paths`, the workflow runs when at least one changed file matches the list. - With `paths-ignore`, the workflow runs unless every changed file matches the list. - For a push, the changed files are the difference between the commit before the push and the pushed commit. When the push creates a new branch, or the previous commit isn't known, Diffinery can't compute the change and path filters pass. (GitHub compares a new branch with the default branch instead.) - For a pull request, the changed files are those the pull request changes, compared with the merge base of the head and the base branch. When a merged pull request is closed, path filters pass. - Path filters are never evaluated for tag pushes. - When branch or tag filters and path filters are both set, both must pass. ## push Runs when you push to a branch or tag, including pushes that create a branch or tag, force pushes, pushes by agents, and merges made through the web UI, CLI, API or merge queue. Deleting a branch or tag doesn't start runs. ```yaml on: push: branches: [main, "release/**"] tags: ["v*.*.*"] paths-ignore: ["docs/**", "**.md"] ``` How `branches` and `tags` combine, as on GitHub: | Filters set | Branch pushes | Tag pushes | | --- | --- | --- | | None | Run | Run | | Only `branches` or `branches-ignore` | Run when the branch matches | Don't run | | Only `tags` or `tags-ignore` | Don't run | Run when the tag matches | | Both kinds | Run when the branch matches | Run when the tag matches | | Only `paths` or `paths-ignore` | Run when the paths match | Run (paths aren't checked for tags) | The workflow file is read from the pushed commit, so a push that changes a workflow runs the new version. When a merge queue builds a merge group, it pushes a branch named `gh-readonly-queue/<base>/pr-<n>-<head sha>`, and `push` workflows run on it as on GitHub. Add `branches-ignore: ["gh-readonly-queue/**"]` (or list only the branches you want) to avoid running twice. | Variable | Value | | --- | --- | | `GITHUB_SHA` | The pushed commit | | `GITHUB_REF` | The pushed ref, `refs/heads/<branch>` or `refs/tags/<tag>` | Payload (`github.event`): | Field | Value | | --- | --- | | `ref` | The pushed ref | | `before` | The commit the ref pointed to before the push (`0000000000000000000000000000000000000000` for a new ref) | | `after` | The pushed commit | | `created`, `deleted`, `forced` | Whether the push created the ref, deleted it (always `false` for runs) or was a force push | | `head_commit` | `id`, `message`, `timestamp` (committer time) and `author` (`name`, `email`) of the pushed commit | | `commits` | A list with the pushed commit only (`id`, `message`). GitHub lists up to 20 commits. | | `pusher` | `name`: the login of the account that pushed, and `email`: `null` | | `sender` | See [The sender object](#the-sender-object) | | `repository` | See [The repository object](#the-repository-object) | ## pull_request Runs for activity on a pull request, using the workflow and code of the pull request's test merge commit: the head branch merged into the current base branch, as on GitHub. ```yaml on: pull_request: types: [opened, synchronize, reopened] branches: [main] paths: ["src/**", "package.json"] ``` | Type | Fires when | | --- | --- | | `opened` | A pull request is created (drafts included) | | `synchronize` | New commits are pushed to the head branch | | `reopened` | A closed pull request is reopened | | `closed` | A pull request is closed or merged. Check `github.event.pull_request.merged` to tell them apart. | | `edited` | The title or description changed. Not for label changes, and not when the text saved is the same. | | `ready_for_review` | A draft is marked ready for review: **Ready for review** in the web UI, `diffinery pr ready`, or `PATCH /repos/{owner}/{repo}/pulls/{number}` with `"draft": false` | | `converted_to_draft` | An open pull request is turned back into a draft: `diffinery pr ready --undo`, or `PATCH` with `"draft": true` | Without `types`, the workflow runs for `opened`, `synchronize` and `reopened`, as on GitHub. GitHub's other types (`labeled`, `assigned`, `review_requested`, `locked` and so on) are accepted in `types` but never fire on Diffinery. A common use is to skip expensive jobs while a pull request is a draft and run them once it's ready: ```yaml on: pull_request: types: [opened, synchronize, reopened, ready_for_review] jobs: e2e: if: ${{ !github.event.pull_request.draft }} runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - run: npm ci && npm run e2e ``` For `edited`, the payload has no `changes` object with the old title or body (GitHub sends one). `branches` and `branches-ignore` filter on the pull request's base branch. - The run checks out and runs `refs/pull/<n>/merge`. You can fetch that ref and `refs/pull/<n>/head` with git, but not push to them. - While a pull request conflicts with its base, there is no test merge commit and its `pull_request` workflows don't run. `pull_request_target` workflows still do. - For `closed` on a merged pull request, the run uses the merge commit instead. - The run's checks are reported on the pull request's head commit, so they appear on the pull request. - Every pull request comes from a branch of the same repository, so `pull_request` runs get the repository's secrets, like `push` runs. | Variable | Value | | --- | --- | | `GITHUB_SHA` | The test merge commit (for a merged pull request's `closed` event, the merge commit) | | `GITHUB_REF` | `refs/pull/<n>/merge` | | `GITHUB_REF_NAME` | `<n>/merge` | | `GITHUB_HEAD_REF` | The head branch | | `GITHUB_BASE_REF` | The base branch | Payload (`github.event`): | Field | Value | | --- | --- | | `action` | The activity type | | `number` | The pull request number | | `pull_request.number`, `pull_request.id` | The pull request number (both) | | `pull_request.node_id` | An opaque ID | | `pull_request.state` | `open` or `closed` | | `pull_request.locked` | `false` | | `pull_request.title`, `pull_request.body` | Title and description | | `pull_request.draft`, `pull_request.merged` | Booleans | | `pull_request.merged_at` | When it merged, or `null` | | `pull_request.merged_by` | `{ "login": ... }` or `null` | | `pull_request.merge_commit_sha` | The merge commit once merged, otherwise the test merge commit (or `null` while it conflicts) | | `pull_request.user` | The author, as a user object (see [The sender object](#the-sender-object)) | | `pull_request.labels` | `[{ "name": ... }]` | | `pull_request.assignees` | `[{ "login": ... }]` | | `pull_request.requested_reviewers` | `[]` | | `pull_request.author_association` | `MEMBER` | | `pull_request.head` | `label` (`<owner>:<branch>`), `ref` (branch), `sha` (head commit), `user` (the repository owner) and `repo` (a repository object) | | `pull_request.base` | The same fields for the base branch | | `pull_request.html_url` | `https://diffinery.com/acme/website/pull/<n>` | | `pull_request.url` | `https://diffinery.com/api/v3/repos/acme/website/pulls/<n>` | | `pull_request.diff_url`, `pull_request.patch_url` | `https://diffinery.com/acme/website/pull/<n>.diff` and `.patch` | | `pull_request.issue_url`, `pull_request.commits_url`, `pull_request.review_comments_url`, `pull_request.comments_url` | API URLs, as on GitHub | | `repository` | See [The repository object](#the-repository-object) | | `organization` | For repositories owned by an organization; see [The repository object](#the-repository-object) | | `sender` | Whoever caused the event; see [The sender object](#the-sender-object) | ## pull_request_target Same activity types, filters and payload as `pull_request`, but the run uses the base branch's own workflow file and code: the base branch's current commit. A pull request therefore can't change what runs with the repository's secrets. Check out the pull request's code (for example with `ref: ${{ github.event.pull_request.head.sha }}`) only if you trust it. ```yaml on: pull_request_target: types: [opened, reopened] jobs: welcome: runs-on: ubuntu-latest steps: - run: echo "Thanks for pull request #${{ github.event.number }}, ${{ github.event.pull_request.user.login }}" ``` Unlike `pull_request`, it also runs while the pull request conflicts with its base. Checks are reported on the pull request's head commit. | Variable | Value | | --- | --- | | `GITHUB_SHA` | The base branch's current commit | | `GITHUB_REF` | `refs/heads/<base branch>` | | `GITHUB_HEAD_REF`, `GITHUB_BASE_REF` | The head and base branches | ## merge_group Runs when the merge queue builds a merge group for a pull request: a commit holding the base branch, every pull request ahead in the queue, and this pull request's changes. The queue merges the pull request once the group's required checks pass. See [Merge queue](https://diffinery.com/docs/collaboration/merge-queue). ```yaml on: pull_request: merge_group: types: [checks_requested] branches: [main] ``` - The only activity type is `checks_requested`, which is also the default. - `branches` and `branches-ignore` filter on the queue's base branch. - The queue reads the required checks from the merge group commit, so the workflows that produce them must run on it: list `merge_group` next to `pull_request`. (A `push` workflow without branch filters also runs there.) If a required check never reports, the pull request leaves the queue when the queue's check timeout expires. - `push` workflows also run on the merge group branch; see [push](#push). - A merge group commit with an invalid workflow file gets an `Invalid workflow file` run, like a push. | Variable | Value | | --- | --- | | `GITHUB_SHA` | The merge group commit | | `GITHUB_REF` | `refs/heads/gh-readonly-queue/<base>/pr-<n>-<head sha>` | Payload (`github.event`): | Field | Value | | --- | --- | | `action` | `checks_requested` | | `merge_group.head_sha` | The merge group commit | | `merge_group.head_ref` | The merge group ref | | `merge_group.base_sha` | The commit the group was built on | | `merge_group.base_ref` | `refs/heads/<base>` | | `merge_group.head_commit` | `id`, `tree_id`, `message`, `timestamp`, `author` and `committer` (`name`, `email`) | | `repository`, `sender` | As for other events | The run's title is `Merge group for #<n>: <pull request title>`. ## workflow_dispatch Lets people and agents with write access start the workflow on demand, on a branch or tag they choose, with typed inputs. How to start a run from the web UI, the CLI, the API and MCP is in [Actions overview](https://diffinery.com/docs/actions/overview#start-a-workflow-manually). ```yaml on: workflow_dispatch: inputs: environment: description: Target environment type: choice options: [staging, production] default: staging version: description: Version to deploy required: true replicas: type: number default: 2 dry-run: type: boolean default: false target: type: environment ``` Each input can have: | Key | Meaning | | --- | --- | | `description` | Label in the **Run workflow** form (the input's name is shown under it). | | `required` | `true` makes the input mandatory: an empty or missing value without a default is rejected with `input '<name>' is required`. Must be a boolean. | | `default` | Used when no value is given. | | `type` | `string` (the default), `boolean`, `number`, `choice` or `environment`. Another value fails with `input '<name>': invalid type '<type>' (expected one of string, boolean, number, choice, environment)`. | | `options` | For `choice`: the allowed values. A `choice` input without options fails with `input '<name>' of type 'choice' must define 'options'`. A value outside the list is rejected with `input '<name>' must be one of <options>`. | | `deprecationMessage` | Accepted and ignored. | Values are converted by type: `boolean` inputs become `true` when the value is `true` or `"true"` and `false` otherwise; `number` inputs become numbers; `string`, `choice` and `environment` inputs stay as given. Values for inputs the workflow doesn't declare are dropped. An `environment` input is a plain text field in the **Run workflow** form, and its value isn't checked against the repository's [environments](https://diffinery.com/docs/actions/environments). Read inputs with the `inputs` context (`${{ inputs.version }}`). `github.event.inputs` holds the same values with the same types; on GitHub, `github.event.inputs` values are always strings. The workflow must exist and have a `workflow_dispatch` trigger at the chosen ref. A ref given without `refs/` is a branch if a branch with that name exists, and a tag otherwise. | Variable | Value | | --- | --- | | `GITHUB_SHA` | The latest commit of the chosen ref | | `GITHUB_REF` | `refs/heads/<branch>` or `refs/tags/<tag>` | Payload (`github.event`): `inputs`, `ref` (the full ref), `workflow` (the workflow file's path), `repository` and `sender`. Each accepted dispatch also sends a `workflow_dispatch` [webhook](https://diffinery.com/docs/reference/webhook-events#workflow_dispatch) event with the same `ref`, `workflow` and `inputs`, to webhooks subscribed to it. ## schedule Runs the workflow on a schedule, in UTC. ```yaml on: schedule: - cron: "30 5 * * 1-5" # 05:30 UTC, Monday to Friday - cron: "0 */6 * * *" # every 6 hours ``` `schedule` is a list of entries with a `cron` string. Each cron has 5 fields: | Field | Values | | --- | --- | | Minute | `0`-`59` | | Hour | `0`-`23` | | Day of month | `1`-`31` | | Month | `1`-`12` or `JAN`-`DEC` | | Day of week | `0`-`6` (Sunday is `0`; `7` also means Sunday) or `SUN`-`SAT` | Each field accepts `*`, a value, a range (`1-5`), a list (`1,15,30`) and a step (`*/15`, `0-30/10`, or `5/15` meaning from 5 to the end every 15). Names are case-insensitive. As in standard cron, when both day of month and day of week are restricted, a day matches if either one matches. How schedules run: - **Granularity is 5 minutes.** Diffinery checks schedules every 5 minutes and starts every schedule that was due at any minute since the last check. A schedule for `2 * * * *` starts a few minutes after :02, and one for `* * * * *` runs once per check, not every minute. GitHub's minimum interval is also 5 minutes. A check that comes late is caught up by the next one (up to an hour back), and a schedule starts once however many of its minutes it covers. - **Only the default branch counts.** Schedules are read from the workflow files on the default branch, and refreshed on every push to it. A `schedule` in a workflow on another branch does nothing. - Each run uses the workflow file on the default branch at that moment, and only if it still has that exact cron string. - Schedules of archived repositories don't run. - The run's actor is `diffinery-scheduler`. Notifications for scheduled runs go to the repository's watchers. Invalid crons fail the file with `Invalid cron: <detail>`, for example `Invalid cron: Cron '0 3 * *' must have exactly 5 fields (minute hour day-of-month month day-of-week)`, `minute value 60 out of range 0-59 in cron '<cron>'` or `Invalid step '0' in minute field of cron '<cron>'`. `schedule` must be a list (`on.schedule must be a list of { cron: '...' } entries`) and each entry needs a `cron` string (`on.schedule entries must have a 'cron' string`). | Variable | Value | | --- | --- | | `GITHUB_SHA` | The latest commit on the default branch | | `GITHUB_REF` | `refs/heads/<default branch>` | Payload (`github.event`): `schedule` (the cron string) and `repository`. ## workflow_run Runs after a run of another workflow is requested or completes. Use it to post-process another workflow's results with access to secrets, or to chain workflows. ```yaml name: Report on: workflow_run: workflows: [CI] types: [completed] branches: [main] jobs: report: if: ${{ github.event.workflow_run.conclusion == 'failure' }} runs-on: ubuntu-latest steps: - run: echo "CI failed on ${{ github.event.workflow_run.head_branch }}: ${{ github.event.workflow_run.html_url }}" ``` - `workflows` is required: a list of workflow names (each workflow's `name`, or its path when it has none). A `workflow_run` trigger without `workflows` never fires. - `types` can be `requested` (when the other run is created, including re-runs) and `completed`. Without `types`, both fire. GitHub's `in_progress` type never fires on Diffinery. - `branches` and `branches-ignore` filter on the other run's head branch: the pushed branch, or the pull request's head branch for pull request runs. - Like on GitHub, the triggered workflow is read from the default branch and runs there, whatever branch the other run was on. - Chains stop after three levels: if A triggers B, B triggers C and C triggers D, D's runs don't trigger anything. - If the run it follows came from an agent (for example CI on a branch an agent pushed), the `workflow_run` run counts as agent code too, although it runs on the default branch: its `GITHUB_TOKEN` acts as an agent and environment secrets follow the agent rules. A follower acts on what the other run produced (artifacts, outputs), so it can't be used to carry agent-written results past the agent guardrails. See [GITHUB_TOKEN permissions](https://diffinery.com/docs/actions/workflow-permissions#runs-whose-code-came-from-an-agent). | Variable | Value | | --- | --- | | `GITHUB_SHA` | The latest commit on the default branch | | `GITHUB_REF` | `refs/heads/<default branch>` | Payload (`github.event`): | Field | Value | | --- | --- | | `action` | `requested` or `completed` | | `workflow_run.id` | The other run's number, which is also its `github.run_id` and what `download-artifact`'s `run-id` takes | | `workflow_run.name`, `workflow_run.path` | The other workflow's name and file path | | `workflow_run.display_title` | The other run's title | | `workflow_run.run_number`, `workflow_run.run_attempt` | Its number and attempt | | `workflow_run.event` | The event that started it | | `workflow_run.status`, `workflow_run.conclusion` | `queued` and `null` for `requested`; `completed` and the conclusion for `completed` | | `workflow_run.head_branch`, `workflow_run.head_sha` | The branch and the commit it reported checks on | | `workflow_run.actor`, `workflow_run.triggering_actor` | `{ "login": ... }` | | `workflow_run.pull_requests` | `[{ "number": ... }]` for pull request runs, otherwise empty | | `workflow_run.created_at`, `workflow_run.updated_at` | Timestamps | | `workflow_run.html_url` | Link to the other run | | `workflow` | `name` and `path` of the other workflow | | `repository`, `sender` | As for other events | ## release Runs when a release changes: when it's published, unpublished, edited or deleted. Use it to build and attach binaries, publish packages or deploy when you publish a release. Releases are managed on the repository's **Releases** page (`https://diffinery.com/acme/website/releases`), with `POST /api/v3/repos/{owner}/{repo}/releases` and `PATCH /api/v3/repos/{owner}/{repo}/releases/{release_id}`, or with the `create_release` and `update_release` MCP tools. All of them need write access. See [Releases](https://diffinery.com/docs/collaboration/releases). ```yaml name: Release on: release: types: [published] jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - run: make dist VERSION="${{ github.event.release.tag_name }}" ``` Each change sends one or more activity types, and a workflow with `on: release` and no `types` runs once for each of them. Creating a published release sends three (`created`, `published` and `released`), so you get three runs. Set `types` to the ones you need; `types: [published]` is the usual choice. | Change | Activity types sent to workflows | | --- | --- | | Create a release that isn't a draft (**Publish release** on a new release) | `created`, `published`, then `released` (or `prereleased` for a pre-release) | | Publish a draft (**Publish release** on a draft) | `published`, then `released` or `prereleased` | | Turn a published release back into a draft (`"draft": true` in the API) | `unpublished` | | Edit a published release (**Update release**) | `edited`, plus `released` or `prereleased` when the pre-release setting changed | | Delete a published release | `deleted` | | Create, edit or delete a draft | None. Webhooks still get `created`, `edited` and `deleted` for drafts, but workflows don't run for draft releases, as on GitHub. | How a release run is set up: - The run uses the commit the release's tag points to (the commit, for an annotated tag), and the workflow file at that commit. If the tag doesn't exist (for example it was deleted after the release was published), no run starts. - Publishing a release whose tag doesn't exist yet creates the tag at `target_commitish` (the default branch when it's not set). Creating the tag is a push of that tag, so `push` workflows that match the tag run too, as on GitHub. Use either `on: push: tags` or `on: release` for the same job, not both. - The run's title is the release's name, or its tag name when the release has no name. - The run's actor is whoever made the change. Its checks are reported on the tagged commit. - `types` is the only filter. `branches`, `tags` and `paths` aren't applied to `release`; check `github.event.release.tag_name` or `github.ref_name` in an `if:` instead. - Invalid workflow files are skipped without an `Invalid workflow file` run. | Variable | Value | | --- | --- | | `GITHUB_SHA` | The commit the release's tag points to | | `GITHUB_REF` | `refs/tags/<tag>` | | `GITHUB_REF_NAME` | The tag name | | `GITHUB_REF_TYPE` | `tag` | Payload (`github.event`): | Field | Value | | --- | --- | | `action` | The activity type | | `release.id` | The release's ID, a number | | `release.tag_name` | The tag, for example `v1.2.0` | | `release.target_commitish` | The branch or commit the tag was created from | | `release.name`, `release.body` | Title and release notes, or `null` when empty | | `release.draft`, `release.prerelease` | Booleans | | `release.latest` | Whether this is the repository's latest release (a Diffinery field) | | `release.immutable` | Always `false` | | `release.created_at`, `release.updated_at`, `release.published_at` | Timestamps (`published_at` is `null` for drafts) | | `release.author` | The user object of whoever created the release (`login`, `id`, `type` and URLs) | | `release.assets` | The release's assets, each with `id`, `name`, `label`, `content_type`, `size`, `download_count`, `browser_download_url` and `url` | | `release.html_url` | `https://diffinery.com/acme/website/releases/tag/<tag>` | | `release.url`, `release.assets_url`, `release.upload_url` | API URLs. Upload assets to `upload_url` (with the `{?name,label}` template removed). | | `release.tarball_url`, `release.zipball_url` | Source archives of the tag | | `release.node_id`, `release.reactions` | As on GitHub | | `repository` | See [The repository object](#the-repository-object) | | `sender` | See [The sender object](#the-sender-object) | Uploading an asset needs `contents: write`. Give the job that permission and use its `GITHUB_TOKEN`, or use a personal or agent token stored as a secret (see [GITHUB_TOKEN permissions](https://diffinery.com/docs/actions/workflow-permissions)): ```yaml on: release: types: [published] jobs: upload: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - run: tar czf dist.tar.gz src - name: Attach dist.tar.gz to the release env: TOKEN: ${{ secrets.RELEASE_TOKEN }} UPLOAD_URL: ${{ github.event.release.upload_url }} run: | curl -fsS -X POST "${UPLOAD_URL%\{*}?name=dist.tar.gz" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/gzip" \ --data-binary @dist.tar.gz ``` ## repository_dispatch Runs when someone calls the `dispatches` endpoint of the repository with an event type. Use it to start workflows from outside Diffinery: another system's CI, a deployment tool, a chat bot or an agent. ```yaml on: repository_dispatch: types: [deploy, rollback] jobs: deploy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - run: ./deploy.sh "${{ github.event.action }}" "${{ github.event.client_payload.version }}" ``` - `types` lists the `event_type` values that start the workflow. Without `types`, every `event_type` does. - Like on GitHub, the workflow is read from the default branch's latest commit and runs there. - The run's title is the `event_type`, and its actor is the account whose token sent the request. - Invalid workflow files are skipped without an `Invalid workflow file` run. ### Send a repository_dispatch event You need write access to the repository, with a token that has the write scope (see [Tokens](https://diffinery.com/docs/accounts/tokens)). People and agents can both send events. ```sh curl -X POST https://diffinery.com/api/v3/repos/acme/website/dispatches \ -H "Authorization: Bearer dfy_..." \ -H "Content-Type: application/json" \ -d '{"event_type": "deploy", "client_payload": {"version": "1.4.2", "environment": "staging"}}' ``` | Field | Required | Meaning | | --- | --- | --- | | `event_type` | Yes | A name for the event, 1 to 100 characters. Workflows match it against `types`, and it becomes `github.event.action`. | | `client_payload` | No | A JSON object with at most 10 top-level properties and at most 64 KB as JSON. Becomes `github.event.client_payload`. Default: `{}`. | The answer is `204 No Content`, as on GitHub, also when no workflow listens for the event type. The request doesn't return the runs it started; find them with `GET /api/v3/repos/acme/website/actions/runs?event=repository_dispatch` or `diffinery run list -R acme/website -e repository_dispatch`. Webhooks subscribed to `repository_dispatch` also get the event. | Status | Message | Cause | | --- | --- | --- | | 403 | `write access required` | Your role or token scope is below write. | | 403 | `repository is archived (read-only)` | Archived repositories don't accept events. | | 422 | `event_type is required (at most 100 characters)` | `event_type` is missing, empty, not a string or too long. | | 422 | `client_payload must be an object` | `client_payload` is an array or a scalar. | | 422 | `client_payload can have at most 10 top-level properties` | Nest the values in fewer top-level properties. | | 422 | `client_payload is too large` | The payload is over 64 KB as JSON. | | 422 | `the repository is empty` | The default branch has no commits yet. | | Variable | Value | | --- | --- | | `GITHUB_SHA` | The latest commit on the default branch | | `GITHUB_REF` | `refs/heads/<default branch>` | Payload (`github.event`): | Field | Value | | --- | --- | | `action` | The `event_type` | | `branch` | The default branch | | `client_payload` | The `client_payload` you sent | | `repository` | See [The repository object](#the-repository-object) | | `sender` | The account whose token sent the event; see [The sender object](#the-sender-object) | ## workflow_call Makes the workflow reusable: other workflows call it from a job with `uses:`. It never starts a run on its own. ```yaml on: workflow_call: inputs: node-version: type: string default: "22" secrets: npm-token: required: true outputs: version: value: ${{ jobs.build.outputs.version }} ``` `inputs` take `type` (required: `string`, `boolean` or `number`), `description`, `required` and `default`. `secrets` take `description` and `required`. `outputs` each need a `value`. Runs of a called workflow use the caller's event, ref, commit and `github` context. The full reference is in [Reusable workflows](https://diffinery.com/docs/actions/reusable-workflows). ## GITHUB_SHA and GITHUB_REF by event | Event | `GITHUB_SHA` | `GITHUB_REF` | Workflow file read from | | --- | --- | --- | --- | | `push` | Pushed commit | Pushed branch or tag | Pushed commit | | `pull_request` | Test merge commit (merge commit once merged) | `refs/pull/<n>/merge` | Test merge commit | | `pull_request_target` | Base branch's latest commit | `refs/heads/<base>` | Base branch's latest commit | | `merge_group` | Merge group commit | `refs/heads/gh-readonly-queue/<base>/pr-<n>-<head sha>` | Merge group commit | | `workflow_dispatch` | Latest commit of the chosen ref | Chosen branch or tag | Chosen ref | | `schedule` | Default branch's latest commit | Default branch | Default branch | | `workflow_run` | Default branch's latest commit | Default branch | Default branch | | `release` | The commit the release's tag points to | `refs/tags/<tag>` | The tagged commit | | `repository_dispatch` | Default branch's latest commit | Default branch | Default branch | A re-run keeps the original run's commit, ref, event and payload. ## The repository object Every payload has a `repository` field with GitHub's full repository object, the same one the REST API returns for `GET /repos/{owner}/{repo}`. The fields workflows read most: | Field | Value | | --- | --- | | `id` | The repository's ID, a number | | `node_id` | An opaque ID | | `name` | `website` | | `full_name` | `acme/website` | | `private` | `true` for a private repository, `false` for a public one | | `visibility` | `public` or `private` | | `owner` | A user object for the owner (`login`, a numeric `id`, `type`: `User` or `Organization`, and URLs) | | `default_branch` | `main` | | `archived` | Whether the repository is archived | | `topics` | The repository's topics | | `html_url` | `https://diffinery.com/acme/website` | | `clone_url` | `https://diffinery.com/acme/website.git` | | `url` and the `*_url` templates | API URLs under `https://diffinery.com/api/v3/repos/acme/website` | | `created_at`, `updated_at`, `pushed_at` | Timestamps | For a repository owned by an organization, payloads also have an `organization` field with `login`, `id` (a number), `node_id`, `url`, `repos_url`, `avatar_url` and `description`. The payload is also written to the file at `GITHUB_EVENT_PATH` for tools that read it from disk, such as `@actions/github`'s `context.payload` in `actions/github-script`. ## The sender object `sender` is the account that caused the event, as a GitHub user object: | Field | Value | | --- | --- | | `login` | The account's login | | `id` | A number derived from the account's ID, like GitHub's numeric user IDs | | `node_id` | An opaque ID | | `type` | `User` for people, `Bot` for agents and Diffinery itself (for example `diffinery-scheduler`) | | `site_admin` | `false` | | `avatar_url`, `html_url`, `url` | The avatar, the profile page and the API URL | ## Runs started from inside a workflow As on GitHub, an event caused by a job's own `GITHUB_TOKEN` doesn't start new workflow runs, so workflows can't trigger themselves in a loop. A push, a pull request or a release made with a job token starts nothing. The two exceptions, as on GitHub, are `workflow_dispatch` (a job with `permissions: actions: write` can start another workflow with `POST /repos/{owner}/{repo}/actions/workflows/{workflow_id}/dispatches`) and `repository_dispatch` (a job with `contents: write` can send `POST /repos/{owner}/{repo}/dispatches`). Events caused by a personal or agent token stored as a secret are treated like any other event from that account and do start workflows: a push starts `push` workflows, a published release starts `release` workflows, and a dispatch starts `repository_dispatch` workflows. ```yaml - name: Ask the deploy workflow to run env: TOKEN: ${{ secrets.DISPATCH_TOKEN }} run: | curl -fsS -X POST "${{ github.api_url }}/repos/${{ github.repository }}/dispatches" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{"event_type": "deploy", "client_payload": {"sha": "${{ github.sha }}"}}' ``` Guard against loops, for example with `paths-ignore`, `types`, or an `if:` that checks `github.actor` or the commit message. ## Differences from GitHub - Only the events in [Supported events](#supported-events) start runs. Other GitHub event names parse but never fire. - `pull_request` and `pull_request_target` fire for `opened`, `synchronize`, `reopened`, `closed`, `edited`, `ready_for_review` and `converted_to_draft` only, and the `edited` payload has no `changes` object. - `workflow_run` fires for `requested` and `completed`, not `in_progress`, and requires `workflows`. - `release` workflows get no `branches`, `tags` or `paths` filtering, and a published release whose tag doesn't exist any more starts no runs. - `deployment` and `deployment_status` events go to webhooks (a job with `environment:` sends both), but don't start workflow runs. - `push` payloads list only the pushed commit in `commits`. - Path filters pass when a push creates a branch. - `github.event.inputs` keeps input types. - `schedule` is checked every 5 minutes; a schedule runs within about 5 minutes after it is due. --- # Expressions and contexts > The ${{ }} expression language in Diffinery workflows: operators, functions, every context property Diffinery sets, and the default environment variables. Source: https://diffinery.com/docs/actions/expressions 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 `}}`: ```yaml 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 | Type | Examples | Notes | | --- | --- | --- | | null | `null` | | | Boolean | `true`, `false` | | | Number | `42`, `-1`, `3.14`, `2e3`, `0xff`, `0o17`, `NaN`, `Infinity` | Any 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 | Operator | Meaning | | --- | --- | | `( )` | 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: ```yaml - 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: | Rule | Example | | --- | --- | | 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>`. | Function | Returns | | --- | --- | | `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: ```yaml - 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: | Message | Cause | | --- | --- | | `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:`, the functions look at all of the job's ancestors: the jobs in its `needs`, the jobs those need, and so on up the chain, as on GitHub. | Function | True when | | --- | --- | | `success()` | The run hasn't been cancelled and every ancestor succeeded. A skipped or cancelled ancestor makes it false. | | `failure()` | An ancestor failed or timed out. | | `cancelled()` | The run has been cancelled. | | `always()` | Always. | A matrix job's result combines its legs: any failed leg fails it, otherwise any cancelled leg cancels it, and it's skipped when every leg was skipped. A leg that failed with `continue-on-error: true` counts as a success. A reusable workflow call's result combines the jobs inside it the same way. In a step's `if:`: | Function | True 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, and the job isn't being cancelled or timing out. | | `cancelled()` | The job is being cancelled or has hit its timeout. | | `always()` | Always. | When a job on a Diffinery-hosted runner is cancelled or hits its `timeout-minutes`, the step that is running stops, and then, as on GitHub, the remaining steps whose `if:` is true still run: `if: always()`, `if: cancelled()` and post steps such as the cache save with `save-always`. They have 5 minutes together. Use them to clean up what the job created, for example a test environment or a temporary repository. Cancelling the run a second time stops them at once. The job still ends as `cancelled` (or failed, after a timeout). 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. ```yaml 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 }}" ``` Because a job's default `if:` is `success()`, a skipped job skips every job that depends on it, directly or further down the chain. To run a job anyway, give it a status function, for example `if: always()` or `if: ${{ !cancelled() && needs.build.result != 'failure' }}`: ```yaml jobs: build: if: github.event_name == 'push' runs-on: ubuntu-latest steps: - run: make test: needs: build # skipped when build is skipped runs-on: ubuntu-latest steps: - run: make test report: needs: test if: ${{ !cancelled() }} # runs even when build and test were skipped runs-on: ubuntu-latest steps: - run: echo "test result was ${{ needs.test.result }}" ``` ## 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: | Where | Available contexts | | --- | --- | | `run-name`, top-level `concurrency` | `github`, `inputs`, `vars` | | `jobs.<id>.if` | `github`, `needs`, `inputs`, `vars`, `env` (workflow-level values, unevaluated), status functions | | `jobs.<id>.strategy` | `github`, `needs`, `inputs`, `vars`, `env` | | `jobs.<id>.name`, `jobs.<id>.runs-on`, `jobs.<id>.timeout-minutes`, `jobs.<id>.continue-on-error`, `jobs.<id>.concurrency` | `github`, `needs`, `inputs`, `vars`, `env`, `matrix`, `strategy`. These are evaluated for each matrix combination. | | `jobs.<id>.environment` (`name` and `url`) | `github`, `needs`, `inputs`, `vars`, `matrix`, `strategy`. 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`, `services` | `github`, `needs`, `inputs`, `vars`, `secrets`, `matrix`, `strategy`, `runner`, `job` | | Steps: `if`, `name`, `uses`, `run`, `with`, `env`, `shell`, `working-directory`, `continue-on-error`, `timeout-minutes` | `github`, `env`, `vars`, `secrets`, `matrix`, `needs`, `steps`, `runner`, `inputs`, `job`, `strategy` | | `jobs.<id>.outputs` | Same as steps | | `on.workflow_call.outputs` | `github`, `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](https://diffinery.com/docs/actions/workflow-syntax#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](#default-environment-variables)). | Property | Value | | --- | --- | | `github.event_name` | The event that started the run, for example `push`, `pull_request`, `release` or `repository_dispatch` | | `github.event` | The event payload. See [Events that trigger workflows](https://diffinery.com/docs/actions/events) for each event's fields, for example `github.event.release.tag_name` for `release` and `github.event.client_payload` for `repository_dispatch`. | | `github.sha` | The commit the run checks out. See the table in [Events that trigger workflows](https://diffinery.com/docs/actions/events#github_sha-and-github_ref-by-event). | | `github.ref` | The full ref, for example `refs/heads/main`, `refs/tags/v1.2.0` (also for `release` runs) or `refs/pull/7/merge` | | `github.ref_name` | The short ref: `main`, `v1.2.0`, or `7/merge` for pull requests | | `github.ref_type` | `tag` for tags, `branch` otherwise | | `github.ref_protected` | Always `false` | | `github.head_ref` | The pull request's head branch for `pull_request` and `pull_request_target`, otherwise `''` | | `github.base_ref` | The pull request's base branch for `pull_request` and `pull_request_target`, otherwise `''` | | `github.repository` | `acme/website` | | `github.repository_id` | The repository's ID | | `github.repository_owner` | `acme` | | `github.repositoryUrl` | `https://diffinery.com/acme/website.git` | | `github.actor` | The 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_actor` | Same as `github.actor` | | `github.run_id` | The run number, as a string, for example `128`. It stays the same on a re-run, and the REST API accepts it wherever a run ID goes, so actions that parse it as a number work. | | `github.run_number` | The run's number, as a string | | `github.run_attempt` | `1`, or higher for re-runs, as a string | | `github.workflow` | The workflow's `name`, or its path when it has none | | `github.workflow_ref` | `acme/website/.github/workflows/ci.yml@refs/heads/main` | | `github.job` | The job's ID (inside a reusable workflow, its ID in that workflow) | | `github.server_url` | `https://diffinery.com` | | `github.api_url` | `https://diffinery.com/api/v3` | | `github.graphql_url` | `https://diffinery.com/api/graphql`. The job's token can't use the [GraphQL API](https://diffinery.com/docs/reference/graphql-api) yet | | `github.token` | The job's token (same as `secrets.GITHUB_TOKEN`) | | `github.workspace` | The workspace directory, `GITHUB_WORKSPACE` | | `github.retention_days` | `90` | | `github.secret_source` | `Actions` | | `github.action` | `''` | | `github.action_path` | Inside a composite action: the action's directory. Otherwise `''`. | | `github.action_repository`, `github.action_ref` | Inside 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. Because `github.run_id` is the run number, the two are always equal. ### 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. ```yaml 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](https://diffinery.com/docs/actions/secrets-and-variables) and [Environments](https://diffinery.com/docs/actions/environments). ### secrets | Property | Value | | --- | --- | | `secrets.<NAME>` | A repository secret, or an organization secret shared with the repository (by its `visibility`; see [Organization secrets](https://diffinery.com/docs/actions/secrets-and-variables#organization-secrets)). 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 whose code came from an agent, don't get environment secrets; see [environment](https://diffinery.com/docs/actions/workflow-syntax#environment)). | | `secrets.GITHUB_TOKEN` | The job's token. What it may do follows the job's `permissions:`; see [GITHUB_TOKEN permissions](https://diffinery.com/docs/actions/workflow-permissions). | | `secrets.DIFFINERY_TOKEN` | The 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 | Property | Value | | --- | --- | | `strategy.fail-fast` | Whether `fail-fast` is on | | `strategy.job-index` | This job's position in the matrix, from 0 | | `strategy.job-total` | How many jobs the matrix produced | | `strategy.max-parallel` | The effective `max-parallel` | ### needs For each job in the job's `needs`: | Property | Value | | --- | --- | | `needs.<job_id>.result` | `success`, `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): | Property | Value | | --- | --- | | `steps.<id>.outputs.<name>` | Outputs the step set with `$GITHUB_OUTPUT` or `::set-output` | | `steps.<id>.outcome` | `success`, `failure`, `cancelled` or `skipped`, before `continue-on-error` | | `steps.<id>.conclusion` | The same, after `continue-on-error` (a failed step with `continue-on-error: true` is `success`) | ### runner | Property | Value | | --- | --- | | `runner.os` | `Linux` | | `runner.arch` | `X64` on hosted runners (`ARM64`, `ARM` or `X86` on self-hosted machines with those processors) | | `runner.name` | `diffinery` | | `runner.environment` | `diffinery-hosted` | | `runner.temp` | A temporary directory for the job; `/workspace/_temp` on hosted runners, where every job starts in a fresh container | | `runner.tool_cache` | Where setup actions install tools; `/opt/hostedtoolcache` on hosted runners | | `runner.workspace` | The parent of `GITHUB_WORKSPACE`; `/workspace` on hosted runners | | `runner.debug` | `1` when debug logging is on (secret or variable `ACTIONS_STEP_DEBUG` set to `true`), otherwise `''`. `ACTIONS_RUNNER_DEBUG` doesn't change it. | ### job | Property | Value | | --- | --- | | `job.status` | `success`, `failure` or `cancelled`: the job's state so far | | `job.container.id`, `job.container.network` | The job container's ID and network, when the job has a `container` | | `job.services.<name>.id`, `.network`, `.ports` | Each 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`. | Variable | Value | | --- | --- | | `CI` | `true` | | `GITHUB_ACTIONS` | `true` | | `DIFFINERY` | `true`. Use it to detect Diffinery in scripts. | | `GITHUB_REPOSITORY` | `acme/website` | | `GITHUB_REPOSITORY_OWNER` | `acme` | | `GITHUB_SHA` | The commit the run is about (see [Events that trigger workflows](https://diffinery.com/docs/actions/events#github_sha-and-github_ref-by-event)) | | `GITHUB_REF` | The full ref | | `GITHUB_REF_NAME` | The short ref (`main`, `v1.2.0`, `7/merge`) | | `GITHUB_REF_TYPE` | `branch` or `tag` | | `GITHUB_HEAD_REF` | The head branch for pull request events, otherwise empty | | `GITHUB_BASE_REF` | The base branch for pull request events, otherwise empty | | `GITHUB_RUN_ID` | The run number, as in `github.run_id` | | `GITHUB_RUN_NUMBER` | The run's number | | `GITHUB_RUN_ATTEMPT` | The attempt number | | `GITHUB_JOB` | The job's ID | | `GITHUB_ACTOR` | Who triggered the run | | `GITHUB_TRIGGERING_ACTOR` | Same as `GITHUB_ACTOR` | | `GITHUB_EVENT_NAME` | The event name | | `GITHUB_EVENT_PATH` | A JSON file holding the event payload, `/workspace/_temp/_github_workflow/event.json` | | `GITHUB_SERVER_URL` | `https://diffinery.com` | | `GITHUB_API_URL` | `https://diffinery.com/api/v3` | | `GITHUB_GRAPHQL_URL` | `https://diffinery.com/api/graphql`. The job's token can't use the [GraphQL API](https://diffinery.com/docs/reference/graphql-api) yet | | `GITHUB_CUSTOM_API_URL`, `GITHUB_CUSTOM_SERVER_URL` | The same as `GITHUB_API_URL` and `GITHUB_SERVER_URL`. Tools built for GitHub Enterprise, such as super-linter, read these; without them they call api.github.com with the job's token | | `GITHUB_WORKFLOW` | The workflow's name, or its path | | `GITHUB_WORKFLOW_REF` | `acme/website/.github/workflows/ci.yml@refs/heads/main` | | `GITHUB_WORKSPACE` | `/workspace/website` | | `GITHUB_RETENTION_DAYS` | `90` | | `GITHUB_ACTION` | The 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_SUMMARY` | Paths of the step's [environment files](https://diffinery.com/docs/actions/workflow-syntax#environment-files) | | `RUNNER_OS` | `Linux` | | `RUNNER_ARCH` | `X64` on hosted runners | | `RUNNER_NAME` | `diffinery` | | `RUNNER_ENVIRONMENT` | `diffinery-hosted` | | `RUNNER_TEMP` | `/workspace/_temp` | | `RUNNER_TOOL_CACHE` | `/opt/hostedtoolcache` | | `RUNNER_WORKSPACE` | `/workspace` | | `AGENT_TOOLSDIRECTORY` | Same as `RUNNER_TOOL_CACHE` | | `ImageOS` | `ubuntu24`, as on GitHub's `ubuntu-24.04` image | | `ImageVersion` | `20250101.1` | Set only in some cases: | Variable | When | | --- | --- | | `RUNNER_DEBUG` | `1` when the secret or variable `ACTIONS_STEP_DEBUG` or `ACTIONS_RUNNER_DEBUG` is `true` | | `ACTIONS_RUNTIME_TOKEN`, `ACTIONS_RUNTIME_URL`, `ACTIONS_RESULTS_URL`, `ACTIONS_CACHE_URL`, `ACTIONS_CACHE_SERVICE_V2` | In JavaScript actions, Docker container actions and the built-in actions only, never in `run:` steps. They give access to the job's cache and artifact services and stop working when the job ends. See [Caching and artifacts](https://diffinery.com/docs/actions/caching-and-artifacts#the-runtime-services). | | `ACTIONS_ID_TOKEN_REQUEST_URL`, `ACTIONS_ID_TOKEN_REQUEST_TOKEN` | When the job has `permissions: id-token: write`. Used by OIDC clients; see [OIDC](https://diffinery.com/docs/actions/oidc). | | `GITHUB_ACTION_PATH`, `GITHUB_ACTION_REPOSITORY`, `GITHUB_ACTION_REF` | In 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` and `GITHUB_REF_PROTECTED`. `GITHUB_TOKEN` isn't an environment variable unless you set it, as on GitHub: ```yaml - run: gh api repos/${{ github.repository }} env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} ``` ## Differences from GitHub - `jobs.<id>.environment.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_number` counts runs across all workflows of the repository, and `github.run_id` is the same number. - 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. --- # Runners > Where workflow jobs run: hosted Linux runners in Cloudflare Containers (sizes, limits, late starts, restarts after a runner is replaced) and self-hosted runners via diffinery runner. Source: https://diffinery.com/docs/actions/runners A runner is the machine that executes one workflow job. Diffinery offers two kinds: **hosted runners**, which run each job in a fresh Ubuntu 24.04 container on Cloudflare, and **self-hosted runners**, which run jobs on a machine you control (a Mac, a GPU box, a server on a private network) using the `diffinery runner` command. You pick one per job with `runs-on`, the same key GitHub Actions uses. ## How runs-on picks a runner Diffinery reads the job's `runs-on` labels (case-insensitive) and decides: 1. If any label is `self-hosted`, the job waits for a self-hosted runner that offers **every** label in `runs-on`. See [Self-hosted runners](#self-hosted-runners). 2. Otherwise, if every label is one Diffinery hosts, the job runs on a hosted runner. 3. Otherwise the job fails at once with: ```text No Diffinery runner matches runs-on: windows-latest. Linux (ubuntu-*) runners are hosted; anything else needs runs-on: [self-hosted, ...] and a registered runner. ``` | Label in `runs-on` | Runs on | |---|---| | `ubuntu-latest`, `ubuntu-24.04`, `ubuntu-22.04`, or any other `ubuntu-*` label | Hosted runner (always the same Ubuntu 24.04, x64 image) | | `linux` | Hosted runner | | `x64`, `amd64` | Hosted runner | | `diffinery`, or any `diffinery-*` label | Hosted runner | | `self-hosted` (plus any labels you add) | A self-hosted runner offering all the labels | | Anything else, for example `windows-latest`, `macos-latest`, `macos-14`, `gpu` without `self-hosted` | The job fails with the message above | Things to know: - Every `ubuntu-*` label gets the same Ubuntu 24.04 x64 image, including `ubuntu-22.04` and labels that ask for Arm, such as `ubuntu-24.04-arm`. - `runs-on` can be a string, a list, or a mapping with `labels`. A `group` in the mapping form is ignored; only `labels` count. - `runs-on` is evaluated for each matrix combination, so an entry can be an expression using `github`, `inputs`, `vars`, `needs`, `matrix` and `strategy`. An expression can give one label or a list of labels. Empty and `null` values are dropped. - If evaluating `runs-on` fails, only that matrix combination fails, with `Error evaluating the job for this matrix combination: <message>`. If no labels are left, the job fails with `No Diffinery runner matches runs-on: (no labels). ...`. ```yaml jobs: test: runs-on: ubuntu-latest # hosted steps: - run: echo "hosted" build: strategy: matrix: target: [linux, macos] # The linux leg runs on a hosted runner; the macos leg waits for a # self-hosted runner with the macos label. runs-on: ${{ matrix.target == 'macos' && fromJSON('["self-hosted","macos"]') || 'ubuntu-latest' }} steps: - run: uname -a gpu: runs-on: [self-hosted, linux, gpu] # waits for a runner started with --labels linux,gpu steps: - run: nvidia-smi ``` ## Hosted runners Each job on a hosted runner gets its own container on [Cloudflare Containers](https://developers.cloudflare.com/containers/), started for that job and thrown away when it finishes. Nothing carries over between jobs except what you save with caches and artifacts (see [Caching and artifacts](https://diffinery.com/docs/actions/caching-and-artifacts)). ### Machine | Property | Value | |---|---| | Platform | Cloudflare Containers. A large runner (instance type `standard-4`) has 4 vCPU, 12 GiB of memory and 20 GB of disk; see [Runner sizes](#runner-sizes) for the smaller ones | | Architecture | `linux/amd64` (`runner.arch` is `X64`) | | Operating system | Ubuntu 24.04 (`runner.os` is `Linux`) | | User | `root`. `sudo` is installed and needs no password, so `sudo apt-get install ...` works as on GitHub | | Network | Outbound internet access (only HTTP and HTTPS on instances that set [`EGRESS=web`](https://diffinery.com/docs/reference/limits#egress)); no inbound connections | | Workspace | `/workspace/<repository name>` (`GITHUB_WORKSPACE`) | | Temp directory | `/workspace/_temp` (`RUNNER_TEMP`) | | Tool cache | `/opt/hostedtoolcache` (`RUNNER_TOOL_CACHE`, `AGENT_TOOLSDIRECTORY`) | | Locale | `LANG=C.UTF-8`, `LC_ALL=C.UTF-8` | | Concurrency | Up to 20 hosted jobs at once per account by default, across all its repositories, like GitHub's Free plan; further jobs stay queued and start, oldest first, as running ones finish. See [Limits](https://diffinery.com/docs/reference/limits) | ### Runner sizes Hosted runners come in four sizes. Every size runs the same image, so a job behaves the same on each; only the machine differs. | Size | `runs-on` label | vCPU | Memory | Disk | Cloudflare instance type | |---|---|---|---|---|---| | large | `diffinery-large` | 4 | 12 GiB | 20 GB | `standard-4` | | medium | `diffinery-medium` | 2 | 8 GiB | 16 GB | `standard-3` | | small | `diffinery-small` | 1/2 | 4 GiB | 8 GB | `standard-1` | | tiny | `diffinery-tiny` | 1/4 | 1 GiB | 4 GB | `basic` | A job with one of these labels in `runs-on` gets that size (with several, the largest): `runs-on: [ubuntu-latest, diffinery-small]`, or the label alone. A job without one gets **large**, unless its own earlier runs show a smaller size serves it just as well. That's how Diffinery keeps hosted minutes cheap: Cloudflare bills a container's memory and disk for what its size provides, used or not, so a deploy job that waits on another service for a minute costs a large runner's memory for that minute. How a size is picked for a job without a label: - The runner measures every hosted job: busy cores, memory and disk in use every quarter second, and processes the kernel killed for want of memory. The last lines of the job's log say what it used, for example `Resources: 0.1 of 4 vCPUs busy on average (0.3 at the 95th percentile), peak memory 0.3 GiB of 12.0 GiB, peak disk 1.6 GB of 19.3 GB`. The job API (`GET /repos/{owner}/{repo}/actions/jobs/{job_id}`) returns it as `usage`, and the size as `runner_size`. - The next run of the same job (the same workflow file and job name, so each matrix leg on its own) looks at up to its last 5 runs, and needs at least 3. It gets a smaller size only if every one of them would have fit it with room to spare: peak memory times 1.5, plus 0.5 GiB, and peak disk times 1.25, plus 1 GB, within the size; and at least half as many vCPUs again as the job kept busy in its busiest quarter second, so a job that bursts onto several cores even briefly, such as a test suite, keeps a runner with room for that. Busy cores count as on the slowest host processor runners land on: Cloudflare's hosts mix AMD EPYC generations whose cores differ about 2x in speed, so a job measured on a fast host gets cores to spare for a slow one (a run whose host processor isn't known counts as on the fastest). A job that kept its runner's CPU busy to the limit never gets a smaller size than that one. - A job stays on large when it has no measured runs yet, when one of those runs ran out of memory, when one failed or timed out on a smaller size, or when one took longer on a smaller size than the job takes on large allowing for its own run-to-run noise there: more than a tenth plus 2 seconds over the median of its large runs (on the same host processor where there is one), and slower than all but one of its last ten large runs. A job whose earlier runs were measured before quarter-second measuring (October 16, 2026) is sized again from new runs. After a smaller size ran a job out of memory, failed it or slowed it, the job stays on large for its next 20 runs, so it doesn't move back and forth. A re-run always runs on large, and so does a job that found no smaller runner free when it first started. So a smaller size is never the reason a job fails or slows down twice. - The job's log names the size it got and why, near the top: `Runner size: tiny (0.25 of a vCPU, 1 GiB of memory, 4 GB of disk), sized by this job's last 5 runs (peak memory 0.3 GiB)`. Set `runs-on: diffinery-large` to keep a job on large whatever it uses. ### What hosted jobs cost, and keeping it low A hosted runner exists only while its job runs: Diffinery starts a fresh container when the job starts and stops it when the job ends, and Cloudflare bills it by the 10 milliseconds, with no rounding up to whole minutes. Its memory and disk are billed for what the size provides, and its CPU for what the job uses. Nothing runs or bills between jobs. What that means for your workflows: - **CPU-bound jobs** (builds, test suites) are best on large, where they finish fastest; their cost follows the CPU they use, so a faster machine costs about the same per job. Automatic sizing keeps them there. - **Jobs that wait** (deploys, API calls, polling, `sleep`) pay mostly for memory. If you know a job waits, pin a size: `runs-on: [ubuntu-latest, diffinery-small]` costs about a third of large per minute and `diffinery-tiny` about a tenth. Automatic sizing moves a job only when its own measured runs leave room to spare even on the slowest hosts. A job that checks out and then calls an API and waits typically moves to medium after its third measured run, about a third less per minute at the same speed; steps such as `actions/setup-node` or installing a CLI keep a core busy for a few seconds, which keeps a job on large. Pin a size to go further. - **Superseded runs** keep running unless the workflow says otherwise, as on GitHub: a new push to a pull request doesn't cancel the run for the commit before it. To stop paying for runs nobody will look at, cancel them with a concurrency group per workflow and branch: ```yaml concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} ``` This cancels a pull request's older runs when a new commit arrives, and leaves runs on your default branch (such as deploys) to finish. It isn't on by default, because cancelling a run can cut off a deploy halfway or turn an older commit's checks into "cancelled". See [concurrency](https://diffinery.com/docs/actions/workflow-syntax#concurrency). ### The Ubuntu 24.04 image The hosted image is built from `containers/runner/Dockerfile` in the Diffinery source. It is deliberately small. This is the complete list of what is preinstalled: | Group | Installed | |---|---| | Base tools | `ca-certificates`, `curl`, `git`, `git-lfs`, `jq`, `zip`, `unzip`, `xz-utils`, `tar`, `gzip`, `zstd`, `sudo`, `pkg-config`, `openssh-client`, `gnupg` | | GitHub CLI | `gh`, from GitHub's apt repository | | Build tools | `build-essential` (gcc, g++, make and the C library headers) | | Libraries for Ruby and native extensions | `libyaml-dev`, `libssl-dev`, `libffi-dev`, `zlib1g-dev`, `libreadline-dev`, `libgmp-dev`, so `ruby/setup-ruby`'s prebuilt Rubies run and gems with C extensions build | | Python | Ubuntu's `python3` (3.12), `python3-pip`, `python3-venv`, `python-is-python3` (so `python` runs `python3`) | | Node.js | Node.js 22.22.0 with npm, plus corepack shims activated for pnpm 10.28.0 and yarn 1.22.22; nvm 0.40.7 in `/usr/local/nvm` (`NVM_DIR`), loaded in login shells such as `shell: bash -l {0}`, as on GitHub | | Docker | Docker Engine (`docker-ce`), the Docker CLI, `containerd.io`, the Buildx plugin, the Compose plugin, `iptables` | Image settings that affect your steps: - `git config --system safe.directory '*'` is set, and Git LFS is installed system-wide. - `PIP_BREAK_SYSTEM_PACKAGES=1` is set, so `pip install` works without a virtual environment, as on GitHub's hosted runners. - `COREPACK_ENABLE_DOWNLOAD_PROMPT=0` is set, so corepack never stops to ask. - `ImageOS=ubuntu24` and `ImageVersion=20250101.1` are set, as on GitHub's `ubuntu-24.04` image. `actions/setup-go` and `actions/setup-python` include them in their cache keys. - `zstd` is installed, so `actions/cache` compresses with zstd, as on GitHub. - The apt package lists are removed to keep the image small. Run `apt-get update` before `apt-get install`. Anything else (Go, Java, Ruby, Rust, .NET, PowerShell, browsers, cloud CLIs) is **not** preinstalled. Install it in a step: ```yaml steps: - uses: actions/setup-go@v5 # downloads Go from go.dev with: go-version: "1.23" - run: apt-get update && apt-get install -y --no-install-recommends openjdk-17-jdk-headless - run: pip install awscli ``` `actions/setup-node`, `actions/setup-python` and `actions/setup-go` use the preinstalled Node.js or Python when its version satisfies the one you ask for, and download the version otherwise. See [Compatibility with GitHub Actions](https://diffinery.com/docs/actions/compatibility) for their supported inputs. **Go and Java start installing as the job begins.** When a job has an `actions/setup-go` step (v4 to v6) or an `actions/setup-java` step (v2 to v5, `distribution: temurin`, a major version such as `java-version: 21`), and the version is written in the workflow or comes from `matrix`, `inputs` or `vars`, the runner starts downloading and extracting that toolchain as the job begins, while earlier steps such as checkout run. The setup step then finds it in the tool cache instead of downloading it. If it's still being installed when the step starts, the step logs `Waiting for Go 1.23, which the runner started installing as the job began` and continues once it's there. The runner installs the same release the action would pick, checks it against its published SHA-256, and puts it where the action looks (`setup-java` reports `Resolved Java ... from tool-cache`, as it does on GitHub's runners), so nothing else about the step changes. Jobs without these steps don't pay for any of this. Versions read from a file (`go-version-file`, `java-version-file`), other Java distributions, and jobs that run in a `container:` install the usual way. > **Note:** Jobs run as `root`, and `sudo` runs commands without asking for a password, as on GitHub's runners. The image ships without apt package lists, so run `sudo apt-get update` before `sudo apt-get install ...`. ### Docker on hosted runners Docker works on hosted runners the same way as on GitHub's: job containers, service containers, Docker container actions, and `docker build`, `docker run` and `docker compose` in `run:` steps. Details: - **Started on first use.** The `docker` command is a wrapper that starts the Docker daemon the first time anything in the job needs it (a `docker` command in a step, a job `container:`, `services:`, or a Docker action). The first use prints `Starting the Docker daemon` and takes about 1 to 2 seconds (the daemon and the registry relay below start together); jobs that never touch Docker don't pay for it. A job that will use Docker (a job `container:`, `services:`, a `docker://` action, or a step whose `uses:` or `run:` names `docker`) gets the daemon started in the background as the job begins, so those seconds overlap checkout and setup. Docker commands that arrive while the daemon is starting wait for it. If the daemon can't start, or another command has been starting it for over 2 minutes, the step fails with `The Docker daemon did not start:` followed by the end of the daemon's log, rather than waiting until the job's timeout. - **Docker Hub through `mirror.gcr.io`.** The daemon uses `https://mirror.gcr.io` as its registry mirror, so pulls of Docker Hub images (`redis:7-alpine`, `postgres:16`) usually come from Google's public mirror and avoid Docker Hub's anonymous rate limits. Images the mirror doesn't have are pulled from Docker Hub as usual. - **DNS.** The daemon gives containers the DNS servers `1.1.1.1` and `8.8.8.8`. - **Pushing to the Diffinery registry.** The daemon and BuildKit builders reach `diffinery.com` through a local relay that splits image layer uploads into chunks, so layers larger than Cloudflare's 100 MB request limit push from hosted workflows. See [Container registry](https://diffinery.com/docs/packages/container-registry). - **Buildx builders.** `docker buildx create` with the `docker-container` driver (which `docker/setup-buildx-action` runs) is handled by the wrapper: it starts the BuildKit container on the host network and registers it as a remote builder, because the normal driver can't work inside a Cloudflare Container. `docker buildx rm` removes it. The full guide is [Docker in workflows](https://diffinery.com/docs/actions/docker). ### Environment variables Hosted and self-hosted runners set the GitHub-compatible variables (`CI=true`, `GITHUB_ACTIONS=true`, `GITHUB_REPOSITORY`, `GITHUB_SHA`, `GITHUB_REF`, `GITHUB_WORKSPACE`, `GITHUB_ENV`, `GITHUB_OUTPUT`, and so on) plus `DIFFINERY=true`. `GITHUB_SERVER_URL` is `https://diffinery.com` and `GITHUB_API_URL` is `https://diffinery.com/api/v3`. `RUNNER_NAME` is `diffinery` and `RUNNER_ENVIRONMENT` is `diffinery-hosted` on every runner. Workflows can override `CI` and `DIFFINERY`, but not `GITHUB_*` or `RUNNER_*` variables. Other variables a job gets: - `ImageOS` and `ImageVersion`, on hosted and self-hosted runners alike (`ubuntu24` and `20250101.1` unless the runner's environment sets them). - `RUNNER_DEBUG=1` when the secret or variable `ACTIONS_STEP_DEBUG` or `ACTIONS_RUNNER_DEBUG` is `true`. See [Expressions and contexts](https://diffinery.com/docs/actions/expressions#default-environment-variables). - `ACTIONS_RUNTIME_TOKEN`, `ACTIONS_RUNTIME_URL`, `ACTIONS_RESULTS_URL`, `ACTIONS_CACHE_URL` and `ACTIONS_CACHE_SERVICE_V2`, but only in JavaScript actions, Docker container actions and the built-in actions, never in `run:` steps. They let actions such as `actions/cache` and `actions/upload-artifact` use the cache and artifact services. See [Caching and artifacts](https://diffinery.com/docs/actions/caching-and-artifacts#the-runtime-services). ### Limits and timeouts | Limit | Value | What happens | |---|---|---| | Job timeout | `timeout-minutes`, default 360 minutes, and values above 360 are capped at 360 | The job fails with "The job running on runner has exceeded the maximum execution time of N minutes." | | Total wait per job | 6 hours from when the job is scheduled, including time spent waiting for a runner | The job fails with "The job exceeded its timeout of N minutes." | | Step timeout | `steps[*].timeout-minutes`, no default | The step fails with "The step has timed out after N minutes." | | Runner that stops responding | 10 minutes past the job timeout | The runner ends a job at its timeout itself and reports within seconds. If it hasn't reported 10 minutes later (its agent froze or its machine vanished), Diffinery stops the container and the job fails as timed out with "The job exceeded its timeout of N minutes, and its runner stopped responding: Diffinery ended it 10 minutes later." So a stuck runner holds a hosted container, a slot of the account's hosted jobs and its minutes for at most the job's timeout plus 10 minutes. Set `timeout-minutes` on jobs that should never run long. | | Matrix size | 256 jobs per matrix | The job fails with "A matrix may produce at most 256 jobs." | | Log line length | 16,384 characters | Longer lines are cut and end with `…` | | Log size | 16 MiB of text per job | The log ends with "The log is cut off here: a job's log is limited to 16 MiB of text." Later lines are dropped; the job keeps running. | | Platform updates | A deploy is set up not to replace the container of a running job, so running jobs normally finish on the version they started with. The platform doesn't guarantee it, though. If it replaces a container anyway, its stop signal reaches the running step too. The same goes when the platform stops or loses a job's container without warning (it ends with no exit code, for example when its host fails). Diffinery then starts the job again from the beginning on a new runner, up to twice, if no step but `actions/checkout` had started or if the job can only read: it uses no secrets (other than `GITHUB_TOKEN`), no deployment environment, and its token can't write. What the stopped attempt uploaded (artifacts, caches) is dropped first, as a re-run would. A job that could change something outside the run fails instead once another step has started, because running that step twice could repeat what it did (a deploy, a publish). Re-run it from the run page | The log shows "The platform is stopping this runner" and, on the step, "The platform stopped this step's processes while stopping the runner." A restarted job's log starts over with "Diffinery started the job again from the beginning on a new runner (restart N of 2)". A failed one says "The platform replaced this job's runner with a new version while step … ran" when a deploy replaced it (the runner was on an older image), or "The platform stopped this job's container while step … ran" when it ran the deployed image (or, after the third replacement, "… 3 times") | If the container stops unexpectedly (for example it runs out of memory), the job fails with `The runner stopped unexpectedly (exit code N).` A container that stops before its runner fetched the job is replaced instead, since no step ran. The log's first lines name where the job ran, for example `Runner: image diffinery-runner:96dbbe31790f, in fra06, EEUR, container 6f1c…`. On hosted runners a `Host:` line names the host's processor (Cloudflare's hosts mix AMD EPYC generations, so the same job can run up to about twice as fast on one host as on another), and the line before the job's conclusion says how much of the vCPUs' time the host gave to other work (steal). The jobs API returns the same as `runner` (`image`, `location`, `instance`, and `cpu` on hosted runners), next to `runner_name`. `GET /meta` names the image the last deploy set as `runner_image`. While a deploy's rollout is updating instances, a new container can still start on the previous image. Diffinery turns that runner away before it runs anything and starts the job again on another container (after 30 s, then 1, 2 and 4 minutes). After four tries, the job runs on whatever image it got rather than wait for a rollout that has stalled. The jobs API's `placementTries` counts the times a job went back in line before a runner took it (each time, the job's log gets a line saying why: no machine was free, the container stopped before the runner started, or a runner on the previous image was turned away), and `restarts` counts the times it started again because the platform replaced its runner. ### Windows and macOS Cloudflare Containers run Linux only, so Diffinery has no Windows or macOS hosted runners. `runs-on: windows-latest` or `runs-on: macos-latest` fails with the "No Diffinery runner matches runs-on" message. For macOS builds, Windows-specific tooling or GPUs, use a [self-hosted runner](#self-hosted-runners) on your own machine with `runs-on: [self-hosted, ...]`. ## Self-hosted runners A self-hosted runner is the `diffinery runner` command running on a machine you control. It polls one or more repositories for waiting jobs whose `runs-on` includes `self-hosted`, claims one, runs it with the Diffinery runner agent (the same program that runs inside hosted containers), streams the logs to Diffinery, and reports the result. Use it for: - **macOS**: build and sign iOS or macOS apps with Xcode on a Mac. - **GPUs**: run CUDA tests or model evaluation on a machine with a GPU. - **Private networks**: deploy to or test against servers that Cloudflare can't reach (a database behind a VPN, an on-premises cluster). - **Long-lived caches and big toolchains**: the runner's work directory and tool cache persist between jobs. ### Requirements - **Node.js 22 or later** on the machine. - **The `diffinery` CLI.** Install it with: ```sh curl -fsSL https://diffinery.com/install | bash ``` - **The runner agent**, a single Node.js file named `agent.mjs`. Build it from a checkout of the Diffinery source: ```sh pnpm install pnpm --filter @diffinery/runner build # produces containers/runner/dist/agent.mjs ``` Copy `containers/runner/dist/agent.mjs` to the runner machine (for example to `/opt/diffinery/agent.mjs`) and pass it with `--agent` or the `DIFFINERY_RUNNER_AGENT` environment variable. - **A registration token** for each repository the runner serves, from a person with the **admin** role on it. A registration token works for an hour, and the runner trades it for its own **runner token**, which can claim that repository's jobs and do nothing else. Agents, job tokens and people without admin can't create one (`403 only repository admins can add self-hosted runners`). - **Bash** (the default shell for `run:` steps; `sh` is used when bash is missing), `git` and `tar`, which the built-in actions use. - **Docker**, only if jobs use `container:`, `services:` or Docker actions. ### Start a runner 1. On your own computer, as a repository admin, create a registration token (it works for an hour): ```sh diffinery api POST /repos/acme/website/actions/runners/registration-token ``` ```json {"token": "drg_…", "expires_at": "2026-10-13T19:45:00.000Z"} ``` 2. On the runner machine, register and start the runner. Set `DIFFINERY_URL` if you don't use `https://diffinery.com`. No login is needed: ```sh diffinery runner -R acme/website --registration-token drg_… --labels macos,arm64 --agent /opt/diffinery/agent.mjs ``` It registers, saves the runner's credentials, prints the labels it offers and waits: ```text Registered runner my-mac with acme/website (saved to ~/.config/diffinery/runners/diffinery.com/acme/website/my-mac.json) Runner my-mac offering [self-hosted, macos, arm64] for acme/website Waiting for jobs. Press Ctrl-C to stop. ``` Later starts use the saved registration: `diffinery runner -R acme/website --agent /opt/diffinery/agent.mjs`. Its labels are the ones it registered with. To change them, delete the saved file and register again. On a machine where you're signed in as a repository admin (`diffinery login`), `diffinery runner` registers itself with your token and claims jobs with the runner's own token. Prefer a registration token on shared or dedicated runner machines, so no personal token is kept where jobs could read it. 3. Target it from a workflow: ```yaml jobs: ios: runs-on: [self-hosted, macos, arm64] steps: - uses: actions/checkout@v4 - run: xcodebuild -scheme App -destination 'platform=iOS Simulator,name=iPhone 16' test ``` When a job arrives the runner prints `→ acme/website: running job ios (run <run id>, job <job id>)`, echoes the step output prefixed with the job name, and ends with `✓ job ios finished` or `X job ios finished with exit code 1`. ### diffinery runner options | Flag | Short | Default | Description | |---|---|---|---| | `--repo OWNER/NAME` | `-R` | The repository of the current directory's git `origin` remote | Repository to take jobs from. Repeat the flag to serve several repositories. | | `--labels a,b` | `-l` | none | Extra labels this runner offers, comma separated. `self-hosted` is always offered. | | `--name NAME` | `-n` | The machine's host name | Runner name. Shown on claimed jobs and used for the default work directory. | | `--once` | | off | Run at most one job, then exit with its exit code. Prints "No jobs waiting." and exits 0 if nothing is waiting. | | `--agent PATH` | `-a` | `DIFFINERY_RUNNER_AGENT` | Path to the runner agent (`agent.mjs`). Required one way or the other. | | `--work-dir DIR` | `-w` | `~/.cache/diffinery/runner/<name>` (or `$XDG_CACHE_HOME/diffinery/runner/<name>`) | Working directory for jobs. | | `--interval SECONDS` | | `5` | Seconds between polls when idle. | | `--quiet` | `-q` | off | Don't echo step output locally. Logs still go to Diffinery. | | `--registration-token TOKEN` | | `DIFFINERY_RUNNER_REGISTRATION_TOKEN` | Register this runner with one repository, using a registration token from a repository admin. | | `--ephemeral` | | off | Register for a single job: the runner's token stops working once it takes a job, the runner exits with the job's exit code, and nothing is saved. Use it in throwaway VMs or containers. | The full generated reference is at [CLI reference](https://diffinery.com/docs/reference/cli). ### Labels and job matching - A job is eligible when its `runs-on` contains `self-hosted` and **every** one of its labels is offered by the runner. The runner offers `self-hosted` plus everything in `--labels`. Comparison is case-insensitive. - A runner with more labels than a job asks for can still take the job: `--labels linux,gpu,cuda12` takes `runs-on: [self-hosted, gpu]`. - `runs-on: [self-hosted]` alone can be taken by any runner serving the repository. - When several jobs are waiting, a runner takes the oldest run's jobs first. - Labels have no meaning to Diffinery beyond matching. Choose names that describe the machine (`macos`, `arm64`, `gpu`, `vpn-prod`). ### How jobs are claimed 1. When a self-hosted job is ready to start, Diffinery marks it **waiting** and creates a queued check on the commit. 2. Each runner polls `POST /api/v3/repos/{owner}/{repo}/actions/runners/claim` with its runner token, every `--interval` seconds per repository while idle. It offers the labels it registered with. The response is `204 No Content` when nothing matches, or the job with a fresh job token: ```http POST /api/v3/repos/acme/website/actions/runners/claim Authorization: Bearer drn_... Content-Type: application/json {} ``` ```json {"job_id": "…", "run_id": "…", "name": "ios", "token": "djt_…", "api_url": "https://diffinery.com"} ``` 3. A claimed job belongs to that runner; no other runner can take it. The runner starts the agent with `DIFFINERY_API_URL`, `DIFFINERY_JOB_ID` and `DIFFINERY_JOB_TOKEN`, and the agent fetches the job, runs it and reports back. 4. A runner runs one job at a time. To run jobs in parallel on one machine, start several runners with different `--name` values (each gets its own work directory). A job that no runner claims fails after 6 hours. While a job waits, the run page shows it as queued. If the runner can't reach Diffinery it prints `warning: ... (will retry)` and keeps polling. A `401`, `403` or `404` from the claim request stops the runner, because retrying won't help. For a removed runner it says `runner <name> was removed from <owner>/<repo>; delete <file> and register it again`. Only registered runners can claim jobs. A personal token, even a repository admin's, gets `403` with `personal tokens can't claim jobs; register this runner: ...`. Runners used to claim that way before registration. If an older runner stops with that message, upgrade the CLI and start it again while signed in, which registers it, or register it with `--registration-token`. ### Manage runners | Endpoint | Does | Who | |---|---|---| | `POST /repos/{owner}/{repo}/actions/runners/registration-token` | A registration token, valid for an hour and for any number of runners: `{"token": "drg_…", "expires_at": "…"}` | Repository admins (people) | | `POST /repos/{owner}/{repo}/actions/runners/register` | Register a runner: send `{"registration_token": "drg_…", "name": "my-mac", "labels": ["macos"], "ephemeral": false}` and get `{"id", "name", "labels", "ephemeral", "token": "drn_…"}`. The runner token is shown once. A wrong or expired registration token, or a repository that doesn't exist, gets `401 invalid or expired registration token`. | The registration token is the credential | | `GET /repos/{owner}/{repo}/actions/runners` | GitHub's runner list: `id`, `name`, `os`, `status` (`online` when it polled in the last 90 seconds), `busy`, `ephemeral` and `labels` | Repository admins | | `DELETE /repos/{owner}/{repo}/actions/runners/{id}` | Remove a runner. Its token stops working at once; a job it's running carries on with its own job token. | Repository admins (people) | A repository can have at most 100 registered runners, each with at most 20 labels. An ephemeral runner is removed when its job finishes. Registering a runner, creating a registration token and removing a runner are recorded in the audit log. ### What a job sees on a self-hosted runner - The job runs **directly on the machine**, as the user that started `diffinery runner`, with that user's files, network and permissions. - The job's environment is the runner's environment, minus `DIFFINERY_TOKEN`, `DIFFINERY_URL`, `DIFFINERY_RUNNER_AGENT`, `GH_TOKEN` and `GITHUB_TOKEN`, plus the usual `GITHUB_*` and `RUNNER_*` variables. `DIFFINERY_GITHUB_TOKEN` (see below) is used by the agent to download actions but is removed from every step's environment. - The workspace is `<work dir>/<repository name>` and is **not** wiped between jobs. `actions/checkout` cleans it by default (`clean: true`). - The tool cache (`RUNNER_TOOL_CACHE`) is `<work dir>/_tool` unless the runner's environment sets `RUNNER_TOOL_CACHE`. Tools that `setup-node`, `setup-python` and `setup-go` download stay there for later jobs. - `runner.os` reports `Linux` and `RUNNER_ENVIRONMENT` reports `diffinery-hosted` on every machine, including Macs. Use your own labels, or check `uname` in a step, if a workflow needs to know the platform. - Remote actions (`uses: owner/repo@ref`) are downloaded from `codeload.github.com`. Set `DIFFINERY_GITHUB_TOKEN` in the runner's environment to authenticate those downloads (useful against GitHub's rate limits), or `DIFFINERY_ACTIONS_DOWNLOAD_URL` to a URL template containing `{owner}`, `{repo}` and `{ref}` to download from a mirror instead. If the download from `codeload.github.com` fails, the agent tries a shallow `git fetch` of the ref from `https://github.com/<owner>/<repo>` before failing the step with `Failed to download action '<owner>/<repo>@<ref>': ...`. There is no `git` fallback when `DIFFINERY_ACTIONS_DOWNLOAD_URL` is set. - The common official actions (`actions/checkout`, `actions/cache`, `actions/upload-artifact`, `actions/download-artifact`, `actions/setup-node`, `actions/setup-python`, `actions/setup-go`) run as built-in implementations when the version and inputs allow it. Set `DIFFINERY_DISABLE_BUILTINS=1` in the runner's environment to always download and run the real actions on that runner. See [Compatibility with GitHub Actions](https://diffinery.com/docs/actions/compatibility#built-in-actions). - `setup-node`, `setup-python` and `setup-go` download their tools from `nodejs.org/dist`, the `actions/python-versions` manifest and `go.dev`. Override the sources with `DIFFINERY_NODE_MIRROR`, `DIFFINERY_PYTHON_MANIFEST` and `DIFFINERY_GO_INDEX` in the runner's environment if the machine can't reach them. ### Run as a service with systemd On Linux, run the runner as a systemd service under a dedicated user so it starts at boot and restarts after failures. 1. Create a user and put the CLI and agent where it can read them: ```sh sudo useradd --system --create-home --shell /bin/bash diffinery-runner sudo install -m 0755 ~/.local/bin/diffinery /usr/local/bin/diffinery sudo install -D -m 0644 containers/runner/dist/agent.mjs /opt/diffinery/agent.mjs ``` 2. Register the runner once as that user, with a registration token from a repository admin. Stop it with Ctrl-C once it says it's waiting: ```sh sudo -u diffinery-runner -H env DIFFINERY_URL=https://diffinery.com DIFFINERY_RUNNER_AGENT=/opt/diffinery/agent.mjs \ diffinery runner -R acme/website --registration-token drg_… --labels linux,gpu --name gpu-box-1 ``` Its credentials are saved in that user's `~/.config/diffinery/runners`, so the service needs no token. 3. Create `/etc/systemd/system/diffinery-runner.service`: ```text [Unit] Description=Diffinery self-hosted runner After=network-online.target Wants=network-online.target [Service] User=diffinery-runner WorkingDirectory=/home/diffinery-runner Environment=DIFFINERY_URL=https://diffinery.com Environment=DIFFINERY_RUNNER_AGENT=/opt/diffinery/agent.mjs ExecStart=/usr/bin/node /usr/local/bin/diffinery runner -R acme/website --labels linux,gpu --name gpu-box-1 --quiet Restart=always RestartSec=10 [Install] WantedBy=multi-user.target ``` Adjust the path to `node` (`which node`) and the repositories and labels. Pass `-R` explicitly: a service has no git checkout to infer the repository from. 4. Start it and follow its output: ```sh sudo systemctl daemon-reload sudo systemctl enable --now diffinery-runner journalctl -u diffinery-runner -f ``` Stopping the service sends the stop signal to the runner and the agent, which cancels the running job and reports it as cancelled. On macOS, use a `launchd` agent with the same command line and environment variables. For a clean machine per job, run `diffinery runner --ephemeral --registration-token drg_…` inside a fresh virtual machine or container and throw it away afterwards. It exits with the job's exit code, and its token can't be used again. ### Docker on self-hosted runners Job containers, service containers and Docker container actions work on a self-hosted runner that has Docker installed and running, with the `docker` CLI on the runner's `PATH` and permission to use the daemon (for example, membership in the `docker` group). Without Docker, those jobs fail with: ```text Job containers (jobs.<id>.container) need Docker, and this runner has no docker CLI. Use runs-on: [self-hosted, ...] on a machine with Docker installed. ``` or, when the CLI exists but the daemon isn't running: ```text Service containers (jobs.<id>.services) need Docker, and the Docker daemon on this runner is not reachable: ... ``` The daemon must be local to the machine, because the job container mounts the runner's work directory at the same path. Self-hosted runners don't have the registry relay that hosted runners have, so pushing an image layer larger than 100 MB to the Diffinery registry fails from a self-hosted runner. See [Docker in workflows](https://diffinery.com/docs/actions/docker) and [Container registry](https://diffinery.com/docs/packages/container-registry). ### macOS, Windows and GPU machines - **macOS.** The runner and agent run on macOS with Node.js 22 or later. Steps run with `/bin/bash`. Because the built-in `setup-node`, `setup-python` and `setup-go` download Linux builds, install toolchains on the Mac yourself (Homebrew, Xcode) and either skip those actions or ask for a version that's already installed (the installed one is used when it satisfies the request). Docker-based features are designed for Linux hosts. - **Windows.** The runner agent runs steps with bash or sh and rejects `shell: cmd` with "The `cmd` shell is not supported on Linux runners." It reports `runner.os` as `Linux`. Native Windows isn't a supported host in this version; the practical route is to run the runner inside WSL 2 and call Windows programs from steps through WSL interop (for example `powershell.exe`). - **GPUs.** Start the runner with a label such as `gpu` and run steps directly on the machine. To use the GPU from a job container, install the NVIDIA Container Toolkit on the machine and pass the Docker option through: `container: { image: nvidia/cuda:12.4.1-base-ubuntu22.04, options: --gpus all }`. ### Security considerations A self-hosted runner runs code from your repository's workflows on your machine. Before you start one: - **Anyone who can push can run code on the machine.** People and agents with write access can push a branch or open a pull request whose workflow targets `self-hosted`, and `push` and `pull_request` runs use the workflow file from that commit. Only serve repositories whose writers you trust with the machine. - **Jobs run as the runner's user.** A job can read anything that user can, including the runner's saved registration and other repositories' checkouts in the work directory. A runner registered with a registration token keeps only its runner token, which can claim that repository's jobs and nothing else: it can't read the repository, push, or call the API. Someone who steals it can still take that runner's future jobs, with their secrets, until you remove the runner. So use a dedicated OS user with no other access, don't sign in with a personal token on the runner machine, and prefer `--ephemeral` in a throwaway VM for anything sensitive. - **Jobs get the repository's secrets**, just like on hosted runners, and can reach whatever the machine can reach on your network. A job with `environment:` also gets that environment's secrets when its branch policy and the rules for environment secrets allow it, so a runner that serves deploy jobs handles production credentials. See [Deployments and environments](https://diffinery.com/docs/actions/environments). - **State persists.** The work directory, tool cache and anything a job writes elsewhere stay on the machine. A malicious job can leave something behind for the next job. For untrusted workloads, use `--ephemeral` in a throwaway VM: its token ends when it takes its one job. - **Cancelling isn't instant on your machine.** When a run is cancelled, Diffinery marks the job cancelled and revokes its job token right away, but the agent finishes executing the current steps locally (their logs and results are discarded). Stop the runner (Ctrl-C) to stop the work immediately; a second Ctrl-C kills the agent. ## Troubleshooting | Symptom | Cause and fix | |---|---| | `No Diffinery runner matches runs-on: ...` | A label isn't hosted and the job doesn't include `self-hosted`. Use an `ubuntu-*` label, or add `self-hosted` and start a runner with matching `--labels`. | | `No Diffinery runner matches runs-on: (no labels)` | Every `runs-on` entry evaluated to an empty value, for example a `matrix` key that the combination doesn't set. Check the expression. | | A self-hosted job stays queued | No runner serving that repository offers every label. Compare the job's `runs-on` with the labels the runner printed at start. The job fails after 6 hours. | | `403 admin access required` | The runner's token or account lacks admin on the repository, or the token isn't `admin`-scoped. | | `no runner agent given` or `runner agent not found at ...` | Pass `--agent /path/to/agent.mjs` or set `DIFFINERY_RUNNER_AGENT`. Build it with `pnpm --filter @diffinery/runner build`. | | `E: Unable to locate package ...` | Run `apt-get update` first; the image ships without package lists. | | A hosted job starts a minute or more late, and its log begins with `No hosted runner could start the job yet: ...` | No machine was free (`there is no container instance that can be provided`), the start failed on the platform's side for a passing reason (`internal error; reference = ...`, a dropped connection), the container stopped before the runner started, or a deploy was rolling out and the runner came up on the previous image. Diffinery puts the job back in line and tries again (after 30 s, then 1, 2, 4 and 5 minutes, up to 8 times; a first container stop before the runner started, or a first internal error, is retried after 5 s and not counted; a runner on the previous image during a deploy is retried every 20 s for up to 10 minutes, not counted, and then runs on whichever image it got); `placementTries` in the jobs API counts the waits. Nothing to fix for an occasional wait. If a large matrix often waits, cap it with `strategy.max-parallel`. | | `The platform replaced this job's runner with a new version while step ... ran` or `The platform stopped this job's container while step ... ran` | A deploy replaced the job's container mid-step (rare), or the platform stopped or moved it. Re-run the job. A job that can only read starts again by itself instead: give jobs that only build and test `permissions: contents: read`, no `environment:` and no secrets other than `GITHUB_TOKEN`. See Platform updates under [Limits and timeouts](#limits-and-timeouts). | | `The runner stopped unexpectedly (exit code N).` | The hosted container exited, often from running out of memory. Reduce parallelism in the job (for example `make -j2`) or move it to a self-hosted runner. | | `Connection refused` or `Connection timed out` on SSH (`git@...`), SMTP or a database port, on a hosted runner | The instance sets [`EGRESS=web`](https://diffinery.com/docs/self-hosting/configuration#egress), which lets hosted jobs use only HTTP and HTTPS. Use an `https://` remote or API, or run the job on a self-hosted runner. It's unset by default. | | `unable to get local issuer certificate`, `x509: certificate signed by unknown authority` or another certificate error on a hosted runner | Under [`EGRESS=web`](https://diffinery.com/docs/self-hosting/configuration#how-a-blocked-connection-looks) HTTPS is re-signed with a Cloudflare CA. Most tools trust it already; a tool with its own CA list, or a Docker container the job starts, needs `/etc/cloudflare/certs/cloudflare-containers-ca.crt` added. | --- # Docker in workflows > Run jobs in containers, start service containers, use Docker container actions, and build and push images from Diffinery workflows. Source: https://diffinery.com/docs/actions/docker Diffinery workflows support Docker the same way GitHub Actions does: a job can run inside a container image, start service containers such as databases next to it, use actions packaged as Docker images, and run `docker build`, `docker run` and `docker compose` in steps. This page covers each feature, the differences from GitHub, and complete examples. ## Where Docker is available | Runner | Docker | |---|---| | Hosted (`runs-on: ubuntu-latest` and other hosted labels) | Always available. The Docker daemon starts the first time the job uses it. | | Self-hosted (`runs-on: [self-hosted, ...]`) | Available when the machine has Docker installed and running, with the `docker` CLI on the runner's `PATH`. | On hosted runners: - The `docker` command starts the daemon on first use and prints `Starting the Docker daemon`. Jobs that never use Docker don't start it. A job that will use Docker (a job `container:`, `services:`, a `docker://` action, or a step whose `uses:` or `run:` names `docker`) gets the daemon started in the background as the job begins, so it is usually ready by the time checkout and setup are done. A docker command that has waited over 2 minutes for another one to start the daemon fails the step with `The Docker daemon did not start:` instead of hanging. - Docker Hub images are pulled through the registry mirror `https://mirror.gcr.io`, which avoids Docker Hub's anonymous pull limits for official images. Images the mirror doesn't have come from Docker Hub directly. - Containers use the DNS servers `1.1.1.1` and `8.8.8.8`. - Bridge networks, service names, port publishing and image builds all work, as on GitHub's hosted runners. See [Runners](https://diffinery.com/docs/actions/runners) for the hosted image and for setting up Docker on a self-hosted runner. ## Job containers `jobs.<id>.container` runs the job's steps inside a container instead of directly on the runner. ```yaml jobs: test: runs-on: ubuntu-latest container: image: node:22-bookworm env: NODE_ENV: test options: --cpus 1 steps: - uses: actions/checkout@v4 - run: node --version && npm ci && npm test ``` `container` is either an image name (`container: node:22`) or a mapping: | Key | Description | |---|---| | `image` | Image to run. Expressions are allowed. If it evaluates to an empty string, the job runs without a container. | | `credentials.username`, `credentials.password` | Registry login for a private image. Both are required when either is set ("Registry credentials for IMAGE need both a username and a password."). The password is masked in logs. | | `env` | Environment variables for the container. | | `ports` | Ports to publish, as for `docker run -p`. | | `volumes` | Volumes to mount, as for `docker run -v`. | | `options` | Extra `docker run` options, as one string. `--network` is not allowed. | How it works: 1. In the "Initialize containers" section of the job log, Diffinery creates a bridge network for the job, starts any service containers, pulls the image and starts the job container on that network. 2. The container's entrypoint is replaced with `tail -f /dev/null` to keep it running, so **the image needs a `tail` command** (almost every Linux image has one). If it exits right away, the job fails with "The job container (IMAGE) exited right after starting. The image needs a 'tail' command to stay up." 3. The runner's work directory is mounted into the container at the same path, so `GITHUB_WORKSPACE`, `$GITHUB_OUTPUT`, `$GITHUB_ENV` and the other file commands work unchanged. `HOME` is `/github/home` inside the container. 4. Each `run:` step is executed with `docker exec` in the container, with the step's environment. The default shell is `bash` if the image has it, otherwise `sh`. `PATH` comes from the image unless the workflow sets `PATH`. 5. JavaScript actions run inside the container with the runner's own Node.js binary, mounted at `/__e/node`. That binary needs glibc, so JavaScript actions don't work in musl-based images such as Alpine (the same limitation GitHub has). 6. The built-in actions (`actions/checkout`, `actions/cache`, `actions/upload-artifact`, `actions/download-artifact`, `actions/setup-node`, `actions/setup-python`, `actions/setup-go`) run on the runner itself and work on the shared workspace. When the runner uses the real action instead (for a version or input the built-in doesn't cover, see [Compatibility with GitHub Actions](https://diffinery.com/docs/actions/compatibility#built-in-actions)), it runs like any other JavaScript action, inside the container. 7. The Docker socket is mounted into the job container when the runner has one at `/var/run/docker.sock`, so a step in the container can use Docker if the image includes the `docker` CLI. 8. When the job ends, every container the job started and its network are removed. The `job.container` context has `id` and `network`. ## Service containers `jobs.<id>.services` starts containers next to the job, for databases, caches and other dependencies your tests need. Each key is the service's name. ```yaml services: postgres: image: postgres:16 env: POSTGRES_PASSWORD: postgres ports: ["5432:5432"] options: --health-cmd "pg_isready -U postgres" --health-interval 2s --health-retries 30 ``` Each service takes the same keys as a job container (`image`, `credentials`, `env`, `ports`, `volumes`, `options`). A service whose `image` evaluates to an empty string is skipped, as on GitHub, which lets you turn a service on or off with an expression. ### Reaching a service How a step reaches a service depends on where the step runs: | The job... | Reach the service at | Notes | |---|---|---| | Has a `container:` | `<service name>:<container port>`, for example `postgres:5432` | Every container of the job joins one network, and each service is registered under its key as a network alias. No `ports` needed. | | Has no `container:` (steps run on the runner) | `localhost:<published port>` | Publish the port with `ports`. `"5432:5432"` publishes on the same port; `"5432"` picks a free host port, which you read from `job.services.<name>.ports['5432']`. | ```yaml - run: redis-cli -p ${{ job.services.redis.ports['6379'] }} ping ``` The `job.services.<name>` context has `id`, `network` and `ports` (a map from container port to host port). The job log lists the mappings, for example `Service redis is running (6379 -> localhost:32768)`. ### Health checks If a service image (or its `options`, through `--health-cmd`) defines a Docker health check, Diffinery waits for the service to become healthy before running the first step, checking with increasing intervals for up to 15 minutes. Services without a health check are considered ready as soon as they start. The job fails when one of these happens (for the first two, the log shows the last 50 lines of the service's output): | Message | Meaning | |---|---| | `Service container NAME failed its health check.` | Docker marked the container unhealthy. | | `Service container NAME exited before it became healthy.` | The container stopped, for example because of a bad environment variable. | | `Service container NAME did not become healthy in time (status: starting).` | It was still starting after 15 minutes. | Add a health check to every service your tests connect to straight away; without one, the first step can start before the database accepts connections. ### The --network option Every container of a job shares one network, so `--network` (or `--net`) in `options` is rejected: ```text services.cache: the --network option is not supported; every container of the job shares one network. ``` ## Docker container actions Two kinds of steps run an action in its own container: - `uses: docker://IMAGE` runs a public or mirrored image directly. - An action whose `action.yml` has `runs.using: docker`, with `runs.image` set to a `Dockerfile` in the action (built for the job) or to `docker://IMAGE`. An action directory with a `Dockerfile` and no `action.yml` is treated the same way. ```yaml steps: - uses: actions/checkout@v4 # An image straight from a registry; `args` and `entrypoint` drive the container. - uses: docker://alpine:3.20 with: entrypoint: /bin/sh args: -c "ls -la /github/workspace" # A Docker action in this repository (./.github/actions/lint/action.yml + Dockerfile). - uses: ./.github/actions/lint with: strict: true ``` ```yaml # .github/actions/lint/action.yml name: Lint inputs: strict: default: "false" runs: using: docker image: Dockerfile args: - --strict=${{ inputs.strict }} ``` Behaviour, matching GitHub's: - For `docker://` steps, `with.args` is split like a shell command line into the container's arguments and `with.entrypoint` replaces its entrypoint. Any other `with` keys become `INPUT_<NAME>` environment variables. - For `runs.using: docker` actions, inputs become `INPUT_<NAME>` variables, and `runs.args`, `runs.env`, `runs.entrypoint`, `runs.pre-entrypoint`, `runs.post-entrypoint`, `runs.pre-if` and `runs.post-if` are honoured. - A `Dockerfile` action is built with `docker build` before it runs. A build failure fails the step with "Building the action's Dockerfile failed (exit code N)." - The action container runs on the job's network with the workspace mounted at `/github/workspace` (the working directory and `GITHUB_WORKSPACE`), `HOME` at `/github/home`, and the runner's temp directory mounted so `$GITHUB_OUTPUT` and the other file commands work. The Docker socket is mounted when the runner has one. - A non-zero exit fails the step with "Docker run failed with exit code N". Remote Docker actions (`uses: owner/repo@ref`) are downloaded from github.com, like other remote actions. ## docker build, run and compose in steps On hosted runners the full Docker CLI is installed with the Buildx and Compose plugins, so ordinary commands work in `run:` steps: ```yaml jobs: integration: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - run: docker build -t website:test . - run: docker run --rm website:test npm test - run: docker compose -f compose.test.yml up -d --wait - run: ./scripts/integration-tests.sh - if: always() run: docker compose -f compose.test.yml logs && docker compose -f compose.test.yml down -v ``` `docker/setup-buildx-action`, `docker/login-action`, `docker/metadata-action` and `docker/build-push-action` also work. On hosted runners, `docker buildx create` with the `docker-container` driver (what `docker/setup-buildx-action` runs) starts BuildKit on the host network and registers it as a remote builder, because the regular driver can't run inside a Cloudflare Container. You don't need to change your workflow for this. BuildKit's `type=gha` cache works with `docker/build-push-action`. The action runs with the job's cache service credentials and hands them to BuildKit, and the layers are stored in the repository's Actions cache, with the same scope, size limit and eviction as `actions/cache`. See [Caching and artifacts](https://diffinery.com/docs/actions/caching-and-artifacts). ```yaml - uses: docker/setup-buildx-action@v3 - uses: docker/build-push-action@v6 with: context: . push: false cache-from: type=gha cache-to: type=gha,mode=max ``` > **Note:** The cache service credentials (`ACTIONS_RUNTIME_TOKEN` and `ACTIONS_RESULTS_URL`) are given only to JavaScript actions, Docker container actions and the built-in actions, never to `run:` steps. `docker buildx build --cache-to type=gha` in a `run:` step has no credentials, so use `docker/build-push-action` for the gha cache. ## Push images to the Diffinery registry Diffinery hosts a container registry at the instance's own address (`diffinery.com` here), in place of ghcr.io. A workflow signs in with its own `GITHUB_TOKEN`; the job's `packages` permission decides what the token can do: | `permissions` in the workflow or job | `GITHUB_TOKEN` can | |---|---| | No `permissions` block | What the repository default allows: pull for repositories with the `read` default (the default for new repositories), push and pull for those with `write` | | `write-all`, or `packages: write` | Push and pull | | `packages: read`, or `read-all` | Pull | | A mapping without `packages` | Nothing | A job's own `permissions:` replaces the workflow's. To push from a repository with the `read` default, add `packages: write` to the job or the workflow. See [GITHUB_TOKEN permissions](https://diffinery.com/docs/actions/workflow-permissions). With write, the token pushes only to packages linked to the workflow's own repository, and creates new packages under the repository's owner (linked to the repository). Other packages of the same owner, including ones that aren't linked to any repository, are pull-only for it. Image names are `diffinery.com/<owner>/<name>` and must be lowercase. `github.repository` keeps the owner's and repository's capitals, so lowercase it in a shell step with `${GITHUB_REPOSITORY,,}`. On hosted runners, image layers of any size push, because the Docker daemon and BuildKit builders reach the registry through a relay that splits large uploads. Self-hosted runners don't have the relay, so from them (as from a laptop or a server) a layer larger than 100 MB fails to push. The registry, its permissions and its API are documented in [Container registry](https://diffinery.com/docs/packages/container-registry). ## Examples ### Postgres service for tests Steps run on the runner and reach Postgres on `localhost`: ```yaml name: Test on: [push, pull_request] jobs: test: runs-on: ubuntu-latest services: postgres: image: postgres:16 env: POSTGRES_USER: postgres POSTGRES_PASSWORD: postgres POSTGRES_DB: website_test ports: ["5432:5432"] options: >- --health-cmd "pg_isready -U postgres" --health-interval 2s --health-timeout 5s --health-retries 30 env: DATABASE_URL: postgres://postgres:postgres@localhost:5432/website_test steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 22 cache: npm - run: npm ci - run: npm run db:migrate - run: npm test ``` The same job in a container reaches the service by name instead: ```yaml jobs: test: runs-on: ubuntu-latest container: node:22-bookworm services: postgres: image: postgres:16 env: POSTGRES_PASSWORD: postgres options: --health-cmd "pg_isready -U postgres" --health-interval 2s --health-retries 30 env: DATABASE_URL: postgres://postgres:postgres@postgres:5432/postgres steps: - uses: actions/checkout@v4 - run: npm ci && npm test ``` ### Redis with a random host port ```yaml jobs: cache-tests: runs-on: ubuntu-latest services: redis: image: redis:7-alpine ports: ["6379"] options: --health-cmd "redis-cli ping" --health-interval 1s --health-retries 30 steps: - uses: actions/checkout@v4 - run: npm ci && npm test env: REDIS_URL: redis://localhost:${{ job.services.redis.ports['6379'] }} ``` ### Build and push an image on every push to main With plain Docker commands: ```yaml name: Image on: push: branches: [main] jobs: image: runs-on: ubuntu-latest permissions: contents: read packages: write steps: - uses: actions/checkout@v4 - name: Sign in to the Diffinery registry run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login diffinery.com -u "${{ github.actor }}" --password-stdin - name: Build and push run: | image="diffinery.com/${GITHUB_REPOSITORY,,}" docker build -t "$image:${{ github.sha }}" -t "$image:latest" . docker push "$image:${{ github.sha }}" docker push "$image:latest" ``` With the `docker/*` actions most ghcr.io workflows use: ```yaml jobs: image: runs-on: ubuntu-latest permissions: contents: read packages: write steps: - uses: actions/checkout@v4 - run: echo "IMAGE=diffinery.com/${GITHUB_REPOSITORY,,}" >> "$GITHUB_ENV" - uses: docker/setup-buildx-action@v3 - uses: docker/login-action@v3 with: registry: diffinery.com username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - uses: docker/build-push-action@v6 with: context: . push: true tags: ${{ env.IMAGE }}:${{ github.sha }} ``` A later job (or another workflow of the same owner) can use the image as its job container: ```yaml smoke: needs: image runs-on: ubuntu-latest permissions: packages: read container: image: diffinery.com/acme/website:${{ github.sha }} credentials: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} steps: - run: /app/healthcheck ``` A deploy job can then hand the image to your platform, using credentials stored as [environment](https://diffinery.com/docs/actions/environments) secrets so that only allowed branches reach them. The job records a deployment of the commit to `production`: ```yaml deploy: needs: smoke runs-on: ubuntu-latest environment: name: production url: https://www.example.com steps: - name: Tell the platform to roll out the new image env: DEPLOY_HOOK_URL: ${{ secrets.DEPLOY_HOOK_URL }} run: curl -fsS -X POST "$DEPLOY_HOOK_URL" -d "image=diffinery.com/acme/website:${{ github.sha }}" ``` ## Troubleshooting | Message | Fix | |---|---| | `... need Docker, and this runner has no docker CLI.` | The self-hosted runner has no `docker` on its `PATH`. Install Docker, or run the job on a hosted runner. | | `... need Docker, and the Docker daemon on this runner is not reachable: ...` | Start the Docker daemon on the self-hosted machine, and make sure the runner's user may use it. | | `The Docker daemon did not start:` | A hosted runner couldn't start Docker. Re-run the job; if it keeps failing, report the log lines that follow. | | `Could not pull IMAGE (docker pull exited with N).` | The image name or tag is wrong, or it's private and needs `credentials`. | | `The job container (IMAGE) exited right after starting.` | The image has no `tail` command, or its startup fails. Use an image with a shell and coreutils. | | `Service container NAME failed its health check.` | Check the service log printed above the error, usually a missing environment variable such as `POSTGRES_PASSWORD`. | | `the --network option is not supported` | Remove `--network` from `options`. Services are already on the job's network. | | A JavaScript action fails inside an Alpine job container | The runner's Node.js needs glibc. Use a Debian or Ubuntu based image, or run the action outside the container. | | A registry error ending in `the workflow's GITHUB_TOKEN has no packages permission; add permissions: packages: write` | The job's `permissions` mapping leaves out `packages`. Add `packages: write` to push, or `packages: read` to pull. | | A push fails with a registry error ending in `the workflow's GITHUB_TOKEN has packages: read; add permissions: packages: write to push` | The job has only read access to packages, often because it has no `permissions:` and the repository default is `read`. Add `packages: write`. | --- # Reusable workflows > Call one workflow from another with jobs.<id>.uses, pass inputs and secrets, read outputs, and share workflows across repositories. Source: https://diffinery.com/docs/actions/reusable-workflows A reusable workflow is a workflow file with an `on: workflow_call` trigger that other workflows call as a job, with `jobs.<id>.uses`. Use one to keep a build, test or deploy pipeline in one place and call it from many workflows or repositories. Diffinery implements GitHub's syntax and behaviour; the differences are listed in [Differences from GitHub](#differences-from-github). ## A minimal example The called workflow declares its inputs, secrets and outputs under `on.workflow_call`: ```yaml # .github/workflows/build.yml name: Build on: workflow_call: inputs: node-version: type: string default: "22" run-e2e: type: boolean default: false secrets: NPM_TOKEN: required: true outputs: version: description: The version that was built value: ${{ jobs.build.outputs.version }} jobs: build: runs-on: ubuntu-latest outputs: version: ${{ steps.meta.outputs.version }} steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: ${{ inputs.node-version }} - run: npm ci env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - id: meta run: echo "version=$(node -p 'require(\"./package.json\").version')" >> "$GITHUB_OUTPUT" - if: ${{ inputs.run-e2e }} run: npm run test:e2e ``` The caller uses it as a job: ```yaml # .github/workflows/ci.yml name: CI on: push jobs: build: uses: ./.github/workflows/build.yml with: run-e2e: true secrets: NPM_TOKEN: ${{ secrets.NPM_TOKEN }} report: needs: build runs-on: ubuntu-latest steps: - run: echo "Built version ${{ needs.build.outputs.version }}" ``` ## Calling a workflow A calling job has `uses` instead of `runs-on` and `steps`. It accepts these keys: | Key | Description | |---|---| | `uses` | The workflow to call (required). See the formats below. | | `with` | Values for the called workflow's inputs. | | `secrets` | A mapping of secrets, or `inherit`. | | `needs` | Jobs to wait for, as for any job. | | `if` | Skip the call (and every job inside it) when false. | | `name` | Display name, used as the prefix of the jobs inside the call. | | `strategy` | Accepted, but a `matrix` isn't supported yet (see [Matrix on the calling job](#matrix-on-the-calling-job)). | | `permissions` | The most the jobs inside the call may have. They inherit these permissions, and anything the called workflow declares is lowered to them. See [Permissions in called workflows](#permissions-in-called-workflows). | | `concurrency` | Accepted but has no effect on a calling job. Jobs inside the called workflow can use their own job-level `concurrency`. | Any other key produces a warning (`unknown reusable-workflow job key '...'`) and is ignored. That includes `environment`: as on GitHub, a calling job can't use an environment, so put `environment:` on the jobs inside the called workflow (see [Environments in called workflows](#environments-in-called-workflows)). `steps` or `runs-on` on a calling job is an error. ### Local workflows ```yaml uses: ./.github/workflows/build.yml ``` The path starts with `./`, is relative to the repository root, names a file in `.github/workflows/` or `.diffinery/workflows/` and ends in `.yml` or `.yaml`. The file is read from the **same commit** as the calling workflow, so a branch or pull request can change both together. Workflows under `.diffinery/workflows/` can be called the same way (`./.diffinery/workflows/build.yml`). ### Workflows in other repositories ```yaml uses: acme/shared-workflows/.github/workflows/deploy.yml@v2 ``` The format is `<owner>/<repo>/<path>@<ref>`, where `<ref>` is a branch, a tag or a commit SHA, and the repository is on the same Diffinery instance (here, diffinery.com). A workflow on github.com can't be called; copy it into a Diffinery repository first. Which repositories you can call: | Called repository | Can be called from | |---|---| | Public | Any repository on Diffinery | | Private | Only repositories with the **same owner** (the same user or organization) | Access is decided by the two repositories' owners, not by who pushed the commit. A call that isn't allowed fails with `repository acme/shared-workflows not found, or its workflows are not shared with acme/website`. Inside a workflow called from another repository, a local `./` reference resolves in that other repository, at the commit the called workflow came from. A branch or tag `<ref>` is resolved once, when the run is planned: every job of the run, including one that waits for an environment's approval, uses the called workflow from that commit, even if the branch moves during the run. ## Inputs Declare inputs under `on.workflow_call.inputs`. Each input must have a `type`: | Key | Description | |---|---| | `type` | `string`, `boolean` or `number` (required). | | `required` | `true` to make the call fail when the input is missing or empty. | | `default` | Value used when the caller doesn't pass one. Expressions are allowed. | | `description` | Free text. | The caller passes values with `with`. Rules: - Every key in `with` must be a declared input. An unknown key fails the run with `jobs.<id>.with.<key>: <uses> has no input named '<key>'`. - `with` values can use expressions with the `github`, `needs` (the calling job's `needs`), `inputs` (when the caller is itself inside a reusable workflow) and `vars` contexts. - Values are converted to the declared type: a `boolean` input is true for `true` or `"true"`; a `number` input goes through `Number()`. - A missing optional input with no default is `""`, `false` or `0` depending on its type. - A missing required input fails the jobs inside the call with `jobs.<id>: input '<name>' is required by <uses>`. Inside the called workflow, read inputs with `${{ inputs.<name> }}`. ## Secrets The called workflow only sees the secrets the caller passes, plus `GITHUB_TOKEN`, which is always the job's own token, plus, in a job with `environment:`, that environment's secrets (see [Environments in called workflows](#environments-in-called-workflows)). Pass specific secrets with a mapping. Each value is an expression evaluated against the caller's secrets: ```yaml secrets: DEPLOY_KEY: ${{ secrets.PROD_DEPLOY_KEY }} NPM_TOKEN: ${{ secrets.NPM_TOKEN }} ``` Or pass everything the caller has with `inherit`: ```yaml secrets: inherit ``` At the top level, "everything the caller has" is the calling repository's secrets plus its organization's secrets. In a nested call, it's whatever the enclosing call received. Declare the secrets a workflow needs under `on.workflow_call.secrets`. A secret declared with `required: true` that the caller doesn't provide fails the jobs inside the call with `jobs.<id>: secret '<NAME>' is required by <uses>`. Secret names are case-insensitive and are stored uppercase. Secrets for a cross-repository call always come from the **calling** repository and its organization, never from the repository that holds the workflow. ## Outputs A called workflow exposes results through `on.workflow_call.outputs`. Each output has a `value`, an expression that can read the `jobs` context of the called workflow (`jobs.<job id>.outputs.<name>` and `jobs.<job id>.result`) and its `inputs`: ```yaml on: workflow_call: outputs: image: value: ${{ jobs.publish.outputs.image }} ``` The caller reads it as `${{ needs.<calling job id>.outputs.image }}`. Values are strings; objects are JSON-encoded. Only declared outputs are visible to the caller. `needs.<calling job id>.result` summarizes the jobs inside the call: `failure` if any failed or timed out, otherwise `cancelled` if any was cancelled, `skipped` if all were skipped, and `success` otherwise. ## Contexts inside a called workflow | Context | Value inside the called workflow | |---|---| | `github` | The caller's run: the same event, `ref`, `sha`, `repository` and actor. `github.job` is the job's own key inside the called workflow. | | `inputs` | The resolved inputs. | | `secrets` | The passed secrets and `GITHUB_TOKEN`, plus the environment's secrets in a job with `environment:`. | | `vars` | The calling repository's variables, plus the environment's variables in a job with `environment:`. | | `env` | The called workflow's own `env` (workflow and job level). The caller's `env` is not passed. | | `needs` | Job ids as written inside the called workflow. | Because `github.repository` is the caller's, `actions/checkout` with no inputs checks out the **calling** repository, as on GitHub. To check out the repository that holds the workflow, pass `repository:`. The job's `GITHUB_TOKEN` can read that repository when it's public; when it's private, also pass a `token:` from a secret. ### Permissions in called workflows The jobs inside a call get their `GITHUB_TOKEN` permissions like this, as on GitHub: 1. Start from the calling job's effective permissions: its own `permissions:`, or else the calling workflow's, or else the repository default. 2. If the called workflow or the called job declares `permissions:`, use that instead, but lowered scope by scope to what the calling job has. A called workflow can never get more than its caller. 3. If neither declares anything, the called jobs get exactly the calling job's permissions. ```yaml # .github/workflows/release.yml in acme/website jobs: deploy: permissions: contents: read id-token: write uses: ./.github/workflows/deploy.yml ``` Here the jobs in `deploy.yml` can have at most `contents: read` and `id-token: write`. If `deploy.yml` declares `permissions: write-all`, its jobs still get only those two. If it declares `packages: write`, its jobs get no `packages` access, because the calling job has none. This applies to `id-token` for [OIDC](https://diffinery.com/docs/actions/oidc), to `packages` for the [container registry](https://diffinery.com/docs/packages/container-registry), and to every other scope. See [GITHUB_TOKEN permissions](https://diffinery.com/docs/actions/workflow-permissions#reusable-workflows). ## Environments in called workflows A job inside a called workflow can deploy to an [environment](https://diffinery.com/docs/actions/environments) with `environment:`, and the environment's name and `url` can use the called workflow's `inputs`: ```yaml jobs: deploy: runs-on: ubuntu-latest environment: name: ${{ inputs.environment }} url: https://${{ inputs.environment }}.example.com steps: - run: ./deploy.sh ``` How it works: - The environment belongs to the **calling** repository, the one whose run it is, even when the workflow file lives in another repository. Its branch policy is checked against the caller's ref. - The job gets the environment's secrets and variables without the caller passing them in `secrets:`, as on GitHub. An environment secret replaces a passed secret with the same name. - A secret declared with `required: true` under `on.workflow_call.secrets` must still be passed by the caller, even if the environment has it. Don't declare secrets that come from the environment. - The job records a deployment like any other job with `environment:`, and the usual rules for who receives environment secrets apply (pull request refs never do; runs whose code came from an agent only from environments set to protected branches only). ## How calls appear in runs and checks The called workflow's jobs become jobs of the caller's run. A calling job `build` that calls a workflow with jobs `compile` and `test` produces the jobs **build / compile** and **build / test** (the calling job's `name`, if it has one, replaces `build`). Checks on the commit are named `<workflow name> / <calling job> / <job>`. Ordering follows GitHub: - Jobs inside the call that have no `needs` wait for the calling job's `needs`. - A job that `needs` the calling job waits for every job inside the call. - If the calling job's `if` is false, or (with the default `if`) any job it depends on, directly or through other jobs, failed or was skipped, every job inside the call is reported as skipped. ## Nesting A called workflow can itself call reusable workflows, up to **four levels** below the top-level workflow, as on GitHub. A fifth level fails the run with: ```text jobs.a/b/c/d/e: reusable workflows can be nested at most 4 levels deep ``` A run can call at most **20** reusable workflows in total (counting every call at every level); more fails with `a run can call at most 20 reusable workflows`. ## Matrix on the calling job A `strategy.matrix` on a job that calls a reusable workflow isn't supported yet. The run fails with: ```text jobs.deploy: a matrix on a job that calls a reusable workflow is not supported yet ``` Put the matrix on the jobs inside the called workflow instead, passing the list as an input: ```yaml # caller jobs: test: uses: ./.github/workflows/test.yml with: versions: '["20", "22"]' # .github/workflows/test.yml on: workflow_call: inputs: versions: type: string required: true jobs: test: runs-on: ubuntu-latest strategy: matrix: node: ${{ fromJSON(inputs.versions) }} steps: - uses: actions/setup-node@v4 with: node-version: ${{ matrix.node }} - run: node --version ``` Or write one calling job per value. ## Example: a shared deploy pipeline An organization keeps its deploy workflow in `acme/shared-workflows` (private) and calls it from `acme/website` (same owner, so allowed): ```yaml # acme/shared-workflows: .github/workflows/deploy.yml name: Deploy on: workflow_call: inputs: environment: type: string required: true dry-run: type: boolean default: false secrets: CLOUDFLARE_API_TOKEN: required: true outputs: url: value: ${{ jobs.deploy.outputs.url }} jobs: deploy: runs-on: ubuntu-latest environment: ${{ inputs.environment }} # acme/website's staging or production environment outputs: url: ${{ steps.deploy.outputs.url }} steps: - uses: actions/checkout@v4 # checks out acme/website, the caller - run: npm ci && npm run build - id: deploy env: CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} run: | if [ "${{ inputs.dry-run }}" = "true" ]; then npx wrangler deploy --dry-run else npx wrangler deploy --env "${{ inputs.environment }}" fi echo "url=https://${{ inputs.environment }}.example.com" >> "$GITHUB_OUTPUT" ``` ```yaml # acme/website: .github/workflows/release.yml name: Release on: push: branches: [main] jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - run: npm ci && npm test staging: needs: test uses: acme/shared-workflows/.github/workflows/deploy.yml@v2 with: environment: staging secrets: inherit production: needs: staging uses: acme/shared-workflows/.github/workflows/deploy.yml@v2 with: environment: production secrets: CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} announce: needs: production runs-on: ubuntu-latest steps: - run: echo "Live at ${{ needs.production.outputs.url }}" ``` The `deploy` job uses `acme/website`'s `staging` and `production` environments, so each call is checked against that environment's branch policy and records a deployment there. If those environments have their own `CLOUDFLARE_API_TOKEN` secret, it replaces the one the caller passes. ## Differences from GitHub | Area | Diffinery | |---|---| | Where workflows come from | Only repositories on the same Diffinery instance. | | Private repositories | Callable only from repositories with the same owner. | | Matrix on the calling job | Not supported yet. | | `concurrency` | The top-level workflow's `concurrency` and job-level `concurrency` on the jobs inside a call apply. A called workflow's top-level `concurrency` and a calling job's `concurrency` are ignored. | | Number of calls per run | At most 20. | | OIDC `job_workflow_ref` claim | The top-level workflow's reference, not the called workflow's. See [OpenID Connect (OIDC)](https://diffinery.com/docs/actions/oidc). | | Branch references | Re-resolved whenever a job of the run is planned or started. | ## Errors Errors in the **calling workflow's syntax** make the workflow file invalid. On `push`, Diffinery records a failed run titled "Invalid workflow file: ..."; the workflow list in the API reports it as `invalid`. | Message | Fix | |---|---| | `reusable workflow reference '...' must be './path/to/workflow.yml' or 'owner/repo/path/to/workflow.yml@ref'` | Fix the `uses` value. Cross-repository references need `@<ref>`; local ones need `./`. | | `'./...' must be a file in ./.github/workflows or ./.diffinery/workflows` | Move the called workflow into a workflow directory. Files there count as workflow changes, which agents can't merge on their own and job tokens can't push. | | `a job that calls a reusable workflow ('uses') cannot have 'steps'` (or `'runs-on'`) | Move the steps into the called workflow. | | `input '<name>' must declare a 'type'` | Add `type: string`, `boolean` or `number` to the input under `on.workflow_call.inputs`. | | `input '<name>': invalid type '<t>' (expected one of string, boolean, number)` | `choice` and `environment` exist only for `workflow_dispatch`. | | `workflow_call output '<name>' must be a mapping with a 'value'` | Give each output a `value:` expression. | Errors found while **loading the called workflows** fail the run before any job starts. The run shows one failed job, **Set up workflow**, with the message: | Message | Fix | |---|---| | `jobs.<id>.uses: <uses> not found` | The file doesn't exist at that path and ref. | | `jobs.<id>.uses: unknown ref <ref> in <owner>/<repo>` | The branch, tag or SHA doesn't exist in the called repository. | | `jobs.<id>.uses: repository <owner>/<repo> not found, or its workflows are not shared with <owner>/<repo>` | The repository doesn't exist, or it's private and has a different owner. | | `jobs.<id>.uses: <uses> is not a reusable workflow (it has no 'on: workflow_call' trigger)` | Add `workflow_call` to the called workflow's `on`. | | `jobs.<id>.uses: <uses> is invalid: ...` | The called workflow has a syntax error; the details follow. | | `jobs.<id>.with.<key>: <uses> has no input named '<key>'` | Remove the key from `with`, or declare the input. | | `jobs.<id>: a matrix on a job that calls a reusable workflow is not supported yet` | Move the matrix into the called workflow. | | `jobs.<id>: reusable workflows can be nested at most 4 levels deep` | Flatten the chain of calls. | | `a run can call at most 20 reusable workflows` | Combine calls. | Errors in the **values passed** fail the jobs inside the call when they're about to start: | Message | Fix | |---|---| | `jobs.<id>: input '<name>' is required by <uses>` | Pass the input with `with`, or give it a default. | | `jobs.<id>: secret '<NAME>' is required by <uses>` | Pass the secret in `secrets`, use `secrets: inherit`, or create the secret in the calling repository or its organization. | For the full workflow syntax, see [Workflow syntax](https://diffinery.com/docs/actions/workflow-syntax). For expressions, see [Expressions and contexts](https://diffinery.com/docs/actions/expressions). --- # Secrets and variables > Store credentials as encrypted secrets and settings as variables for workflows, per repository, organization or environment, and who can manage them. Source: https://diffinery.com/docs/actions/secrets-and-variables Secrets hold sensitive values such as API tokens and deploy keys; variables hold plain configuration such as a region or a feature flag. Workflows read them as `${{ secrets.NAME }}` and `${{ vars.NAME }}`, exactly as on GitHub. Secrets are encrypted at rest and masked in logs; variables are stored and shown in plain text. ## What you can store | Kind | Scope | Manage in the web UI | Manage with the CLI and API | Who can manage | |---|---|---|---|---| | Repository secret | One repository | Yes | Yes | People with the admin role on the repository | | Organization secret | Every repository the organization owns, its private ones, or the ones you select | No | Yes | Organization owners and admins, and site admins (people only) | | Repository variable | One repository | Yes | Yes | People with the admin role on the repository | | Organization variable | Every repository the organization owns | No | API only | Organization owners and admins, and site admins (people only) | | Environment secret | Jobs of one repository that use the environment | Yes | API only | People with the admin role on the repository | | Environment variable | Jobs of one repository that use the environment | No | API only | People with the admin role on the repository | Agents can't create, change or delete secrets or variables anywhere, whatever their role (see [Agents, secrets and variables](#agents-secrets-and-variables)). Environment secrets and variables are described in [Environment secrets and variables](#environment-secrets-and-variables) below and in full in [Deployments and environments](https://diffinery.com/docs/actions/environments). ## Names - Names use letters, digits and underscores, and don't start with a digit (`^[A-Za-z_][A-Za-z0-9_]*$`). For repository variables, only the web form checks this (the organization variables API checks it too); the variables API accepts other names, which expressions may not be able to read, so keep to the same format. - Names are stored in uppercase: saving `api_key` creates `API_KEY`. In expressions, `secrets.api_key` and `secrets.API_KEY` both find it. - Repository and environment secrets, and environment and organization variables, can't start with `GITHUB_`. Saving one fails with `invalid secret name` (or `invalid variable name`). - Don't name a secret `DIFFINERY_TOKEN` or `GITHUB_TOKEN`: in a job, both always hold the job's own token, which replaces any secret with the same name. ## Repository secrets ### Secrets in the web UI 1. Open the repository and go to **Settings → Secrets and variables**. 2. Under **Repository secrets**, enter a **Name** and a **Value**. 3. Click **Save secret**. Setting an existing name replaces its value. The page lists each secret's name and when it was last updated. Values are never shown again. Click **Delete** next to a secret to remove it. ### Secrets with the CLI The CLI needs to act as a person with admin access to the repository, with the `admin` scope. Install it and sign in: ```sh curl -fsSL https://diffinery.com/install | bash diffinery login --scope admin ``` The value comes from `--body`, from stdin when piped, or from a hidden prompt: ```sh diffinery secret set CLOUDFLARE_API_TOKEN -R acme/website # prompts for the value diffinery secret set NPM_TOKEN -R acme/website --body "$NPM_TOKEN" diffinery secret set DEPLOY_KEY -R acme/website < ~/.ssh/deploy_key # multi-line values from a file diffinery secret list -R acme/website diffinery secret delete NPM_TOKEN -R acme/website ``` Without `-R`, the CLI uses the repository of the current directory's git `origin` remote. Passing a value with `--body` can leave it in your shell history; prefer stdin or the prompt. See [CLI reference](https://diffinery.com/docs/reference/cli) for every flag. ### Secrets with the API ```sh # Create or replace a secret (204 No Content) curl -X PUT https://diffinery.com/api/v3/repos/acme/website/actions/secrets/NPM_TOKEN \ -H "Authorization: Bearer dfy_..." \ -H "Content-Type: application/json" \ -d '{"value": "npm_..."}' # List names (values are never returned) curl https://diffinery.com/api/v3/repos/acme/website/actions/secrets \ -H "Authorization: Bearer dfy_..." # Delete (204 No Content) curl -X DELETE https://diffinery.com/api/v3/repos/acme/website/actions/secrets/NPM_TOKEN \ -H "Authorization: Bearer dfy_..." ``` The list response is `{"total_count": 1, "secrets": [{"name": "NPM_TOKEN", "updated_at": "..."}]}`. Unlike GitHub, the value is sent as plain JSON over HTTPS; there's no public key to encrypt it with first. A body that only has GitHub's sealed-box `encrypted_value` fails with ``422 encrypted_value (sealed box) is not supported yet: send the secret as `value` over HTTPS; it is encrypted at rest``, and there's no `public-key` endpoint, so tools that encrypt the value first don't work; send `value` instead. The token needs the `admin` scope and its owner needs the admin role on the repository. See [API reference](https://diffinery.com/docs/api) for the full endpoint list. ## Organization secrets Organization secrets are available to workflows in the organization's repositories. As on GitHub, each secret has a `visibility` that says which repositories get it: - `all`: every repository the organization owns (the default for a new secret). - `private`: its private and internal repositories, not its public ones. - `selected`: only the repositories you list. They're managed with the CLI or the API; there's no web page for them. ```sh diffinery secret set SENTRY_AUTH_TOKEN --org acme diffinery secret set DEPLOY_KEY --org acme --repos website,api # only these two repositories diffinery secret set NPM_TOKEN --org acme --visibility private diffinery secret list --org acme diffinery secret delete SENTRY_AUTH_TOKEN --org acme ``` ```sh curl -X PUT https://diffinery.com/api/v3/orgs/acme/actions/secrets/SENTRY_AUTH_TOKEN \ -H "Authorization: Bearer dfy_..." \ -H "Content-Type: application/json" \ -d '{"value": "sntrys_..."}' ``` With the API, send `visibility` and, for `selected`, `selected_repository_ids` (the repositories' numeric `id`s) with the value: `{"value": "…", "visibility": "selected", "selected_repository_ids": [123, 456]}`. An update that leaves them out keeps the secret's selection. An id that isn't one of the organization's repositories gets `422`. | Endpoint | Does | |---|---| | `GET /api/v3/orgs/{org}/actions/secrets` | Lists names, update times and `visibility` | | `GET /api/v3/orgs/{org}/actions/secrets/{name}` | One secret's name, update time and `visibility` (never the value) | | `GET /api/v3/orgs/{org}/actions/secrets/{name}/repositories` | The selected repositories | | `PUT /api/v3/orgs/{org}/actions/secrets/{name}/repositories` | Replaces the selection: `{"selected_repository_ids": [123, 456]}` | | `PUT` or `DELETE /api/v3/orgs/{org}/actions/secrets/{name}/repositories/{repository_id}` | Adds or removes one repository | | `DELETE /api/v3/orgs/{org}/actions/secrets/{name}` | Removes the secret | Changing the selection of a secret whose visibility isn't `selected` gets `409 the secret's visibility isn't selected`. A repository that leaves the selection stops getting the secret from its next job. Only organization owners and admins (and site admins) can use these endpoints; anyone else gets `403 organization admins only`. All three endpoints, listing included, are account-level actions: the CLI or API needs a personal token with the `admin` scope that isn't restricted to repositories. A `read` or `write` token gets `403 this token needs the admin scope`, and a token restricted to repositories gets `403 this token is limited to repositories and can't change account settings`. ## Variables ### Variables in the web UI On **Settings → Secrets and variables**, the **Variables** section lists every variable with its value. Enter a **Name** and a **Value** and click **Save variable** to add or replace one, or click **Delete** to remove one. ### Variables with the CLI ```sh diffinery variable set DEPLOY_REGION -R acme/website --body eu-west-1 echo "https://staging.example.com" | diffinery variable set STAGING_URL -R acme/website diffinery variable list -R acme/website ``` There is no `variable delete` command; delete variables in the web UI or with the API. ### Variables with the API The API has GitHub's endpoints (`POST` to create, `PATCH` to update) plus a Diffinery `PUT` that creates or replaces in one call: | Request | Needs | Does | |---|---|---| | `GET /api/v3/repos/{owner}/{repo}/actions/variables` | write role | Returns `{"total_count": 1, "variables": [{"name": "...", "value": "..."}]}`. Takes `per_page` (default 30, at most 100) and `page`. | | `GET /api/v3/repos/{owner}/{repo}/actions/variables/{name}` | write role | Returns `{"name", "value", "created_at": null, "updated_at": null}`, or `404 Not Found` | | `POST /api/v3/repos/{owner}/{repo}/actions/variables` with `{"name": "...", "value": "..."}` | admin role, people only | Creates a variable (201). An existing name fails with `409 Already exists - Variable already exists`; a missing field with `422 name and value are required`. | | `PATCH /api/v3/repos/{owner}/{repo}/actions/variables/{name}` with `{"value": "..."}` and optionally a new `"name"` | admin role, people only | Updates or renames a variable (204), or `404 Not Found`. Renaming onto a name that already exists fails with `409 Already exists - Variable already exists`. A body with only `name` keeps the value. | | `PUT /api/v3/repos/{owner}/{repo}/actions/variables/{name}` with `{"value": "..."}` | admin role, people only | Creates or replaces a variable (204) | | `DELETE /api/v3/repos/{owner}/{repo}/actions/variables/{name}` | admin role, people only | Deletes a variable (204) | Changing variables needs a token with the `admin` scope. An agent's token gets `403 agents cannot manage variables` for every change, whatever its role. ```sh curl -X POST https://diffinery.com/api/v3/repos/acme/website/actions/variables \ -H "Authorization: Bearer dfy_..." \ -H "Content-Type: application/json" \ -d '{"name": "DEPLOY_REGION", "value": "eu-west-1"}' ``` Variable names are stored in uppercase, and every endpoint, `DELETE` included, matches them without regard to letter case. ## Organization variables Organization variables are available as `${{ vars.NAME }}` to every workflow in every repository the organization owns, including repositories created later. Use them for settings that many repositories share, such as a registry URL or a cloud region. A repository or environment variable with the same name wins (see [Precedence](#precedence)). They're managed with the REST API only: there's no web page for them and no CLI command (`diffinery variable` works on one repository). A [GitHub organization import](https://diffinery.com/docs/migrate-from-github) also adds the GitHub organization's variables that the Diffinery organization doesn't have yet, and leaves existing ones unchanged. Imported variables reach every repository, whatever repository selection they had on GitHub. ### Who can manage organization variables | Action | Who | Token | |---|---|---| | List variables and read their values | Organization owners and admins, and site admins | Any scope | | Create, update or delete a variable | Organization owners and admins, and site admins (people only) | The `admin` scope, not restricted to repositories | Other members get `403 organization admins only`, and accounts outside the organization get `404 not found`. Agents get `403 organization admins only` (or `404 not found` when neither the agent nor its owner belongs to the organization), whatever their role: agents can't read, create, change or delete organization variables through these endpoints. ### Organization variables with the API | Request | Does | |---|---| | `GET /api/v3/orgs/{org}/actions/variables` | Returns `{"total_count": 1, "variables": [{"name": "...", "value": "...", "visibility": "all", "created_at": "...", "updated_at": "..."}]}`, sorted by name. The list isn't paged. | | `GET /api/v3/orgs/{org}/actions/variables/{name}` | Returns one variable in the same shape, or `404 variable not found`. The name is matched case-insensitively. | | `POST /api/v3/orgs/{org}/actions/variables` with `{"name": "...", "value": "..."}` | Creates a variable, or replaces the value of an existing one. Returns `201` with an empty body. | | `PATCH /api/v3/orgs/{org}/actions/variables/{name}` with `{"value": "..."}` | Sets the value, creating the variable if it doesn't exist. Returns `204`. | | `DELETE /api/v3/orgs/{org}/actions/variables/{name}` | Deletes the variable (case-insensitive name). Returns `204`, or `404 variable not found` when there's no such variable. | ```sh # Create or replace curl -X POST https://diffinery.com/api/v3/orgs/acme/actions/variables \ -H "Authorization: Bearer dfy_..." \ -H "Content-Type: application/json" \ -d '{"name": "NPM_REGISTRY", "value": "https://registry.npmjs.org"}' # List with values curl https://diffinery.com/api/v3/orgs/acme/actions/variables \ -H "Authorization: Bearer dfy_..." # Delete curl -X DELETE https://diffinery.com/api/v3/orgs/acme/actions/variables/NPM_REGISTRY \ -H "Authorization: Bearer dfy_..." ``` Names must match `^[A-Za-z_][A-Za-z0-9_]*$` and can't start with `GITHUB_`; otherwise the request fails with `422 invalid variable name`. Names are stored in uppercase. The value must be a string of at most 48 KB (49,152 characters); otherwise the request fails with `422 value must be a string of at most 48 KB`. In the responses, `created_at` is the time of the last change, the same as `updated_at`. Differences from GitHub: - There is no per-repository selection: `visibility` is always `all`, a `visibility` or `selected_repository_ids` field in the request is ignored, and there are no `/repositories` endpoints for a variable. - `POST` on an existing name replaces the value instead of failing with `409`, and `PATCH` creates a missing variable instead of failing with `404`. - `PATCH` can't rename a variable; a `name` in the body is ignored. Delete it and create it under the new name. - The list isn't paged; `per_page` and `page` are ignored. ### Organization and repository variables with the same name A repository's variables (the **Variables** section of **Settings → Secrets and variables**, `diffinery variable list`, and the `/api/v3/repos/{owner}/{repo}/actions/variables` endpoints) are only the repository's own, never the organization's. `GET` and `PATCH` on a name only the organization has answer `404 Not Found`, and `POST` creates a repository variable even when the organization has one of that name; in workflows, the repository's value wins. `DELETE` on a repository variable never removes an organization variable. ## Environment secrets and variables An [environment](https://diffinery.com/docs/actions/environments) such as `production` can have its own secrets and variables. Only jobs that name the environment with `environment:` receive them: ```yaml jobs: deploy: runs-on: ubuntu-latest environment: production steps: - run: npx wrangler deploy env: CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} # the production secret DEPLOY_URL: ${{ vars.DEPLOY_URL }} # the production variable ``` Environment secrets are managed on the environment's page (**Settings → Environments → (environment) → Environment secrets**) or with the REST API, and environment variables with the REST API only. Creating and deleting environment secrets needs a person (not an agent) with the admin role and an `admin`-scoped token; listing their names needs the admin role. Environment variables are listed with the write role and changed by a person (not an agent) with the admin role. ```sh # Environment secret: 201 when new, 204 when replaced curl -X PUT https://diffinery.com/api/v3/repos/acme/website/environments/production/secrets/CLOUDFLARE_API_TOKEN \ -H "Authorization: Bearer dfy_..." \ -H "Content-Type: application/json" \ -d '{"value": "cf_..."}' # Environment variable: 201 curl -X POST https://diffinery.com/api/v3/repos/acme/website/environments/production/variables \ -H "Authorization: Bearer dfy_..." \ -H "Content-Type: application/json" \ -d '{"name": "DEPLOY_URL", "value": "https://www.example.com"}' ``` The environment must exist first (`404 environment not found` otherwise); create it with `PUT /api/v3/repos/{owner}/{repo}/environments/{name}`. The full set of endpoints is in [Deployments and environments](https://diffinery.com/docs/actions/environments#environment-secrets-and-variables). ### Rules for environment secrets Environment secrets are meant for credentials that only reviewed code should use, so they're handed out more carefully than repository secrets: - **Branch policy.** A job can only use the environment from a ref its deployment branch policy allows; otherwise the job fails with `<ref> is not allowed to deploy to <env> due to environment protection rules.` - **Protected branches by default.** Adding the first secret to an environment that has no branch policy sets it to **protected branches only**. - **Never on pull request refs.** Environment secrets never go to runs on `refs/pull/*` (`pull_request` runs), whatever the policy. - **Agents only under "protected branches only".** A run started by an agent (for example by its push or workflow dispatch) only gets environment secrets when the environment is set to protected branches only. - **Agents' tags don't count.** A tag that an agent created or moved never matches an environment's tag patterns. When a rule withholds the secrets, the job still runs and still gets the environment's variables; the withheld secrets evaluate to empty strings. See [Who receives environment secrets](https://diffinery.com/docs/actions/environments#who-receives-environment-secrets). ## Using secrets and variables in workflows ```yaml name: Deploy on: push: branches: [main] env: REGION: ${{ vars.DEPLOY_REGION }} jobs: deploy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Deploy env: CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} run: npx wrangler deploy --env production ``` - Pass a secret to a step through `env` and read it from the environment, rather than writing `${{ secrets.X }}` into the script text. - A secret or variable that doesn't exist evaluates to an empty string. - Values are read when each job starts, so changing a secret affects jobs that start afterwards, including later jobs of a run that's in progress. - A reusable workflow only receives the secrets its caller passes (a mapping or `secrets: inherit`). See [Reusable workflows](https://diffinery.com/docs/actions/reusable-workflows). - A job's runner receives only the secrets the job names in its expressions (`secrets.NAME` or `secrets['NAME']`, in any letter case, anywhere in the job or in the workflow's `env`), as on GitHub, plus `ACTIONS_STEP_DEBUG` and `ACTIONS_RUNNER_DEBUG`. Other secrets never reach the runner, so a compromised action or tool in one job can't read them. A job that uses `secrets` as a whole, such as `toJSON(secrets)` or an index computed at run time, gets all of them. ### Precedence From highest to lowest, for a name that exists in several places: 1. `GITHUB_TOKEN` and `DIFFINERY_TOKEN`: always the job's own token. 2. Environment secrets, in a job that uses the environment. 3. Repository secrets (in a called reusable workflow: the secrets the caller passed). 4. Organization secrets shared with the repository (see [Organization secrets](#organization-secrets)). For variables, from highest to lowest: 1. Environment variables, in a job that uses the environment. 2. Repository variables. 3. Organization variables (repositories owned by an organization only). In places evaluated before a job starts, such as `jobs.<id>.if` and `runs-on`, `vars` holds only repository and organization variables; environment variables are added when the job starts. A change to an organization variable reaches the organization's repositories right away, so jobs that start afterwards see the new value. If some repository can't be updated, the others still are and the request fails with `503`; repeat it to finish. ### Which runs receive secrets Every workflow run in the repository receives its repository and organization secrets, whatever the event (the one exception, pull requests from forks, is below): `push`, `pull_request`, `pull_request_target`, `merge_group`, `workflow_dispatch`, `schedule`, `workflow_run`, `release` and `repository_dispatch`. Environment secrets follow stricter rules (see [Rules for environment secrets](#rules-for-environment-secrets)). This is a difference from GitHub worth understanding. GitHub withholds secrets from `pull_request` runs of pull requests that come from forks. Diffinery has no forks: every pull request comes from a branch in the same repository, pushed by someone with write access, so `pull_request` runs get the repository's secrets, and they run the workflow files from the pull request's own test merge commit. The exception is a pull request [imported from GitHub](https://diffinery.com/docs/migrate-from-github) whose head was in a fork (its head shows as `owner:branch`): its runs get no secrets and a read-only token, as on GitHub. `pull_request_target` runs the base branch's workflow on the base commit, also with secrets, so a pull request can't change what it runs. Runs of code an agent wrote are the other exception. A run whose trigger was an agent, whose branch or tag an agent pushed last, whose pull request an agent opened or pushed to, or (off the default branch) whose code includes a commit an agent pushed that hasn't landed on the default branch yet gets no repository or organization secrets, only its job token. A person pushing on top of an agent's commits, tagging them or dispatching a run on them doesn't change that; see [Runs whose code came from an agent](https://diffinery.com/docs/actions/workflow-permissions#runs-whose-code-came-from-an-agent). An agent with write access could otherwise push a workflow that sends them anywhere. A repository admin who is a person can allow it under **Settings → Actions** (**Give runs of agent code the repository's and organization's secrets**), or with `PUT /repos/{owner}/{repo}/actions/permissions/workflow` and `"agent_runs_use_secrets": true`. The change is audited. Environment secrets follow the environment's own rules. This includes the default branch once agents land changes on it. When an agent's pull request lands through [auto-merge](https://diffinery.com/docs/collaboration/pull-requests#auto-merge), the merge is the agent's push, so the branch was last pushed by an agent: every run on it from then on is agent code until a person pushes or merges there. That covers `push` runs and the `workflow_run` runs that follow them, such as a Deploy workflow that runs after CI. Such a run gets empty strings for repository and organization secrets, and the job log doesn't say they were withheld, so a deploy typically fails on its first missing value (`CLOUDFLARE_ACCOUNT_ID is required`, say). If agents land on your default branch, choose one of these: - **Keep deploy credentials in an environment** limited to protected branches, add `environment: <name>` to the deploying job, and protect the branch. Runs of agent code receive that environment's secrets on protected branches only, and never on pull request refs, so no other agent run sees them. This is the tighter option. See [Environments](https://diffinery.com/docs/actions/environments#who-receives-environment-secrets). - **Turn on Give runs of agent code the repository's and organization's secrets.** Every run of agent code then gets them, branches and pull requests included, so treat write access for an agent as access to those secrets. In practice: **anyone who can push a branch can run a workflow that uses the repository's secrets**, and so can agents with write access where an admin allows it. Give write access only to people and agents you trust with the repository secrets, and keep production credentials in an [environment](https://diffinery.com/docs/actions/environments) set to protected branches only (see [Branch protection](https://diffinery.com/docs/repositories/branch-protection)) rather than in repository secrets. Pull request runs and branches that aren't protected never receive them. ## Masking in logs The runner replaces every secret value with `***` before log lines leave the runner, so secrets don't appear in job logs, step names, step summaries or failure messages. - Every secret passed to the job is masked, including `GITHUB_TOKEN`. - Values shorter than 3 characters aren't masked, because masking them would make logs unreadable. Don't use very short secrets. - Multi-line secrets are masked line by line as well as whole. - Masking matches the literal text only. A transformed value (base64-encoded, URL-encoded, split across lines, printed one character at a time) is not masked. Don't print secrets, even in debugging. - A job output whose value contains a secret is dropped, with the warning `Skip output 'NAME' since it may contain secret.` - Variables are never masked. ### Masking other values with ::add-mask:: Mask a value that isn't a secret, such as a token you fetch during the job, by printing the `add-mask` workflow command: ```yaml - name: Get a short-lived token run: | token=$(curl -fsS -X POST https://auth.example.com/token -d "key=$API_KEY" | jq -r .token) echo "::add-mask::$token" echo "SESSION_TOKEN=$token" >> "$GITHUB_ENV" env: API_KEY: ${{ secrets.API_KEY }} ``` From that line on, the value is replaced with `***` in the rest of the job's log. The `add-mask` line itself is never echoed. ### Debug logging Set a secret or a variable named `ACTIONS_STEP_DEBUG` to `true` to show `::debug::` messages in the log, set `RUNNER_DEBUG=1` for steps and make `runner.debug` `1`. A secret or variable named `ACTIONS_RUNNER_DEBUG` set to `true` also sets `RUNNER_DEBUG=1`, but doesn't change `runner.debug`. Turn them off again when you're done. ## GITHUB_TOKEN and the job token Every job gets its own token, created when the job starts and revoked when it finishes. It's available as `${{ secrets.GITHUB_TOKEN }}`, `${{ secrets.DIFFINERY_TOKEN }}` and `${{ github.token }}`, and `actions/checkout` uses it by default. It always replaces a repository or organization secret with the same name. What the token may do follows the job's `permissions:`, as on GitHub: | Access | Allowed | |---|---| | Clone and fetch the repository that runs the workflow (including Git LFS downloads) | With `contents: read` or `write`, or always when the repository is public | | Push to the repository | With `contents: write`. Branch protection applies, and changes to files under `.github/workflows/` are always refused. | | Read the repository through the REST API | With the scope each endpoint needs, for example `issues: read` for issues. A public repository can always be read. | | Write through the REST API (comments, labels, pull requests, releases, statuses, deployments) | With the matching scope at `write`, for example `pull-requests: write` to comment on a pull request | | Read another repository | Only if that repository is public | | Write to another repository | No | | Use the container registry | Decided by the `packages` scope: `write` pushes and pulls, `read` pulls, `none` neither. Pushes reach only packages linked to the workflow's repository, or new packages, which get linked to it. | | Request an OIDC token | Only with `id-token: write`. See [OpenID Connect (OIDC)](https://diffinery.com/docs/actions/oidc). | | Manage secrets, variables, environments or repository settings | No | A job without any `permissions:` (in the job or the workflow) gets the repository's default: read access to contents and packages for repositories created after this setting was introduced, and write access to everything except `id-token` for older ones. An admin can change it. The full rules, the scope each endpoint needs and the error messages are on [GITHUB_TOKEN permissions](https://diffinery.com/docs/actions/workflow-permissions). ```yaml permissions: contents: write pull-requests: write jobs: bump: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - run: | git config user.name "diffinery-actions[bot]" git config user.email "diffinery-actions[bot]@users.noreply.diffinery.com" npm version patch --no-git-tag-version git switch -c bump-version git commit -am "Bump version" git push origin bump-version ``` Use a personal access token stored as a secret instead when a workflow needs something the job token can't do, such as changing workflow files, writing to another repository, or pushing to a branch whose protection doesn't let the job push: ```yaml - uses: actions/checkout@v4 with: token: ${{ secrets.BOT_TOKEN }} ``` Create the token as a person, with the `write` scope and restricted to the repositories it needs. Pushes made this way still follow branch protection. See [Personal access tokens](https://diffinery.com/docs/accounts/tokens). ## Encryption at rest Secret values are encrypted before they're stored, with envelope encryption: - Each repository, each organization and each environment has its own AES-256-GCM key, derived from the instance's master key with HKDF. - The scope (the repository, the organization, or the repository and environment) is bound to every ciphertext as additional authenticated data, so a stored value copied into another repository or environment can't be decrypted there. - Values are decrypted only when a job starts, and sent to that job's runner over HTTPS with the rest of the job's definition. - The API, CLI and web UI only ever return names and update times. ## Agents, secrets and variables Agents (AI accounts) can't add, change or delete secrets or variables, whatever their role: - The organization secret endpoints (listing included) return `403 agents cannot manage secrets` for any agent's token. - Setting and deleting repository secrets (`PUT` and `DELETE /api/v3/repos/{owner}/{repo}/actions/secrets/{name}`) and environment secrets refuse an agent's token: an agent with the admin role gets `403 agents cannot manage secrets`; with the write role, the request already fails with `403 admin access required`. - Creating, changing and deleting repository variables (`POST`, `PATCH`, `PUT` and `DELETE` on `/api/v3/repos/{owner}/{repo}/actions/variables`) and environment variables refuse an agent's token the same way, with `403 agents cannot manage variables` for an agent with the admin role. - The organization variable endpoints (`/api/v3/orgs/{org}/actions/variables`, listing included) return `403 organization admins only` for any agent's token, or `404 not found` when neither the agent nor its owner belongs to the organization. A GitHub import started by an agent skips variables, secret names and environments. - Agents can't create, change or delete environments or their branch policies (`403 only repository admins (people, not agents) can manage environments` for an agent with the admin role). - Listing repository secret names and environment secret names needs the admin role; listing and reading variables needs the write role, so an agent with write access can read the repository's variable values (organization variables are readable only by organization owners and admins). An agent has the write role unless a person gave it more (its **Maximum role**, or an explicit admin role on the repository). Agents can still push branches and open pull requests. The workflows that run for them get no repository or organization secrets unless a repository admin allows it with `agent_runs_use_secrets` (see [Which runs receive secrets](#which-runs-receive-secrets)); where it's allowed, treat write access for an agent as access to those secrets. Environment secrets are different: runs whose code came from an agent only get them from environments set to protected branches only, and never on pull request refs. In those runs the job token also acts as an agent, so it can't push to protected or default branches. See [GITHUB_TOKEN permissions](https://diffinery.com/docs/actions/workflow-permissions#runs-whose-code-came-from-an-agent) and [Agents](https://diffinery.com/docs/agents/overview). ## Audit log Changes are recorded in the audit log with the name but never the value: | Change | Audit action | |---|---| | Set or delete a repository secret | `repo.secret.set`, `repo.secret.delete` | | Create, update, rename or delete a repository variable | `repo.variable.set` (a rename adds `renamed_from` with the old name), `repo.variable.delete` | | Set or delete an organization secret | `org.secret.set`, `org.secret.delete` | | Create, update or delete an organization variable | `org.variable_set`, `org.variable_delete`, with the organization as the target and `name` in the details | | Set or delete an environment secret | `environment.secret.set`, `environment.secret.delete` | | Create, update, rename or delete an environment variable | `environment.variable.set` (a rename adds `renamed_from`), `environment.variable.delete` | | Change an environment or its branch policies | `environment.update`, `environment.delete`, `environment.branch_policy.create`, `environment.branch_policy.delete` | Repository and organization secret changes made with the API or the CLI include `"via": "api"` in the entry's details; changes made on the settings page don't. Organization variable changes made with the API include it too. When a GitHub organization import adds organization variables, each one is recorded as `org.variable_set` by the person who started the import, with the GitHub organization as the target and the import's id, the Diffinery organization (`org`) and `name` in the details. See [Audit log](https://diffinery.com/docs/security/audit-log). ## Limits | Limit | Value | |---|---| | Name format | `^[A-Za-z_][A-Za-z0-9_]*$`, stored uppercase; repository and environment secrets, and environment and organization variables, can't start with `GITHUB_` | | Shortest value that is masked | 3 characters | | Organization variable value | 48 KB (49,152 characters) | | Number of organization variables | No limit of its own | | Number and size of secrets | Diffinery doesn't enforce a limit of its own; every secret is decrypted when each job starts (only the ones the job names are sent to its runner), so keep them few and small | ## Troubleshooting | Problem | Cause and fix | |---|---| | `422 invalid secret name` | The name has characters other than letters, digits and `_`, starts with a digit, or (repository secrets) starts with `GITHUB_`. | | `invalid secret name "...": use letters, digits and _, not starting with a digit` | The CLI checks the name before sending it. | | `422 value is required (send it over HTTPS; it is encrypted at rest)` | The JSON body has no string `value`. | | ``422 encrypted_value (sealed box) is not supported yet: send the secret as `value` over HTTPS; it is encrypted at rest`` | The client encrypted the value with a public key, as GitHub requires. Send the plain value as `value`. | | `secret value is empty` | The CLI got an empty value from `--body` or stdin. | | A secret is empty in a workflow run, or a deploy fails on a missing value such as `CLOUDFLARE_ACCOUNT_ID is required`, though the secret is set | The run is agent code: an agent triggered it, pushed its branch last (an agent's auto-merge counts), or opened or pushed its pull request. It gets no repository or organization secrets. Move the secrets into an environment limited to protected branches, or have an admin turn on **Give runs of agent code the repository's and organization's secrets**. See [Which runs receive secrets](#which-runs-receive-secrets). | | `403 agents cannot manage secrets` | Use a person's token. | | `403 agents cannot manage variables` | Agents can't create, change or delete variables. Use a person's token. | | `403 admin access required` | The token's owner isn't a repository admin, or the token lacks the `admin` scope. | | `403 this token needs the write scope` | The token has only the `read` scope and can't change anything. Use an `admin` token. | | `403 this token needs the admin scope` | Organization secrets need an `admin`-scoped token, even for listing. Creating, changing and deleting organization variables needs one too. | | `403 this token is limited to repositories and can't change account settings` | Organization secrets, and changes to organization variables, need a token without a repository list. | | `403 organization admins only` | Only organization owners and admins (people) manage organization secrets and organization variables, and only they can read organization variables. | | `422 invalid variable name` | The organization variable's name has characters other than letters, digits and `_`, starts with a digit, or starts with `GITHUB_`. | | `422 value must be a string of at most 48 KB` | The organization variable request has no string `value`, or the value is longer than 49,152 characters. | | `404 variable not found` | The organization has no variable of that name. | | `403 only repository admins (people, not agents) can manage environments` | Environments and their branch policies need a person with the admin role. | | `404 environment not found` | Create the environment with `PUT .../environments/{name}` before adding secrets or variables. | | An environment secret is empty in the job | The run is on a pull request ref, or an agent started it and the environment isn't set to protected branches only, or the job has no `environment:`. | | `<ref> is not allowed to deploy to <env> due to environment protection rules.` | The environment's branch policy doesn't allow the ref. See [Deployments and environments](https://diffinery.com/docs/actions/environments#deployment-branch-policies). | | A secret is empty in the workflow | Check the name, that it's set on this repository, its organization or the job's environment, and, in a reusable workflow, that the caller passes it. | | `***` appears in output where you expected text | The text contains a secret's value (or a value shorter than you think matches part of it). Rename or change the secret, or don't print that text. | | `git push` with `GITHUB_TOKEN` fails with `403` | The job has no `contents: write`. Add it to the job's or the workflow's `permissions:`, or ask an admin to change the repository default. See [GITHUB_TOKEN permissions](https://diffinery.com/docs/actions/workflow-permissions). | | `Resource not accessible by integration (the workflow's GITHUB_TOKEN needs permissions: ...)` | The job's `permissions:` don't include the scope that endpoint needs. Add the scope the message names. | | `409 Already exists - Variable already exists` | A variable with that name already exists in the repository, or (for `POST` on a repository) the organization has a variable with that name. Update it with `PATCH` or `PUT`, use `PUT` to give the repository its own value, or choose another name. | | A variable is empty in the workflow | Check the name and that it's set on this repository, its organization or the job's environment. Environment variables aren't available in `jobs.<id>.if` or `runs-on`. | --- # GITHUB_TOKEN permissions > What a workflow job's GITHUB_TOKEN may do on Diffinery, how permissions are resolved, the repository default, and the rules no permission can lift. Source: https://diffinery.com/docs/actions/workflow-permissions Every workflow job gets its own token, `GITHUB_TOKEN`, that works against Diffinery's REST API and git server. What the token may do follows GitHub's `permissions:` model: each API endpoint needs a permission scope, and the job's effective `permissions:` decide which scopes the token has. This page explains how those permissions are worked out, how to change the repository's default, and the rules that apply whatever a workflow asks for. ## The job token The token is available as `secrets.GITHUB_TOKEN`, `github.token` and, under a Diffinery name, `secrets.DIFFINERY_TOKEN`. It isn't an environment variable unless you set one: ```yaml - run: gh api repos/${{ github.repository }} env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} ``` - It works only while the job runs. When the job completes, the token stops working, and it never works more than 30 minutes past the job's timeout (counted from when the job started), even if the job is never marked complete. - It acts in the repository as `diffinery-actions[bot]`, so issues, comments and labels it creates show that name. - Its role is `write` when any scope other than `id-token` is `write`, and `read` otherwise. It never has the `maintain` or `admin` role, so [branch protection](https://diffinery.com/docs/repositories/branch-protection) always applies to it. - Each request is then checked against the scope its endpoint needs (see [What each endpoint needs](#what-each-endpoint-needs)). ## How permissions are resolved A job's effective permissions come from the first of these that exists: 1. The job's own `permissions:` (`jobs.<id>.permissions`). It replaces the workflow's, it isn't merged with it. 2. The workflow's top-level `permissions:`. 3. The repository default, set by an admin (see [The repository default](#the-repository-default)). Inside a [reusable workflow](https://diffinery.com/docs/actions/reusable-workflows), the calling job's permissions take the place of the repository default and also cap everything the called workflow declares. See [Reusable workflows](#reusable-workflows). ### Scopes `permissions:` takes these scopes, the same as GitHub's: | Scope | What it covers on Diffinery | | --- | --- | | `actions` | Workflows, runs, jobs, logs, artifacts and caches (`/actions/...`), including starting a `workflow_dispatch` run | | `attestations` | `/attestations` | | `checks` | Check runs and check suites | | `contents` | Git fetch and push, files, commits, branches, tags, releases and release assets, compare, archives, commit comments, `repository_dispatch`, merging pull requests | | `deployments` | Deployments, deployment statuses, and reading environments | | `discussions` | `/discussions` | | `id-token` | Requesting an [OIDC](https://diffinery.com/docs/actions/oidc) token. Only `write` (or `none`) has a meaning. | | `issues` | Issues, issue comments, labels, milestones and assignees | | `models` | Accepted for compatibility. Only `read` or `none`. | | `packages` | The [container registry](https://diffinery.com/docs/packages/container-registry) | | `pages` | `/pages` | | `pull-requests` | Pull requests, reviews and review comments, and issue endpoints used on pull requests | | `repository-projects` | `/projects` | | `security-events` | `/code-scanning`, `/secret-scanning`, `/dependabot` | | `statuses` | Commit statuses | Each scope takes `read`, `write` or `none`. `write` includes `read`. An unknown scope gives the warning `unknown permission scope '<scope>'` when the workflow is parsed, and an invalid level fails the file with `permission '<scope>' must be 'read', 'write' or 'none'`. ### Shorthand values | Value | Result | | --- | --- | | `read-all` | Every scope `read`, except `id-token`, which gets nothing. | | `write-all` | Every scope `write`, except `models`, which stays `read`. Includes `id-token: write`. | | `{}` | Every scope `none`. The token can still read what anyone may read in a public repository. | | A mapping | The scopes you list get the levels you give. **Every scope you don't list is `none`.** | Two scopes have special rules: - `id-token: read` grants nothing. OIDC needs `id-token: write`. - `models: write` is lowered to `read`. Any other value of `permissions:` fails the file with `permissions must be 'read-all', 'write-all' or a mapping of scope: read|write|none`. ### Examples A workflow that only reads code, with one job that comments on pull requests: ```yaml permissions: contents: read jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - run: npm ci && npm test comment: needs: test runs-on: ubuntu-latest permissions: contents: read # the job's map replaces the workflow's, so repeat contents pull-requests: write steps: - uses: actions/github-script@v7 with: script: | await github.rest.issues.createComment({ owner: context.repo.owner, repo: context.repo.repo, issue_number: context.issue.number, body: "Tests passed.", }); ``` A release job that pushes a tag and uploads release assets needs `contents: write`. A job that pushes an image needs `packages: write`, and because naming any scope sets the others to `none`, it also needs `contents: read` to check out a private repository: ```yaml jobs: image: runs-on: ubuntu-latest permissions: contents: read packages: write ``` ## The repository default When neither the job nor the workflow sets `permissions:`, the job gets the repository default. It's one of two settings, the same as GitHub's "Workflow permissions" setting: | `default_workflow_permissions` | Token permissions | | --- | --- | | `read` | `contents: read` and `packages: read`. Every other scope `none`. | | `write` | Every scope `write` (`models` `read`), except `id-token`, which gets nothing. | | Repository | Default | | --- | --- | | Created after this setting was added | `read`, and pull request creation and approval off | | Created before | `write`, and pull request creation and approval off | A second setting, `can_approve_pull_request_reviews`, decides whether job tokens may open pull requests and approve or request changes on them (see [Creating and approving pull requests](#creating-and-approving-pull-requests)). It's `false` by default. A third, `agent_runs_use_secrets` (a Diffinery addition), decides whether runs of code an agent wrote get the repository's and organization's secrets. It's `false` by default; see [Which runs receive secrets](https://diffinery.com/docs/actions/secrets-and-variables#which-runs-receive-secrets). ### Read and change the setting In the web UI, open the repository's **Settings → Actions** tab (`/{owner}/{repo}/settings/actions`). Under **Workflow permissions**, pick **Read repository contents and packages permissions** or **Read and write permissions**, check or clear **Allow GitHub Actions to create and approve pull requests** and **Give runs of agent code the repository's and organization's secrets**, and click **Save**. You see "Actions settings saved." The tab needs the **admin** role on the repository. You can also read and change it with the REST API or the CLI. Both need the **admin** role on the repository, and only people can change it: an agent's token gets `403 only people can change what workflows are allowed to do`. Read the current setting: ```http GET /api/v3/repos/acme/website/actions/permissions/workflow Authorization: Bearer dfy_... ``` ```json { "default_workflow_permissions": "read", "can_approve_pull_request_reviews": false, "agent_runs_use_secrets": false } ``` Change it with `PUT`. Send any of the fields; a field you leave out keeps its value. The response is `204 No Content`: ```sh curl -fsS -X PUT https://diffinery.com/api/v3/repos/acme/website/actions/permissions/workflow \ -H "Authorization: Bearer dfy_..." \ -H "Content-Type: application/json" \ -d '{"default_workflow_permissions": "write", "can_approve_pull_request_reviews": true}' ``` With the CLI (install it with `curl -fsSL https://diffinery.com/install | bash`, then `diffinery login`): ```sh diffinery api GET /repos/acme/website/actions/permissions/workflow diffinery api PUT /repos/acme/website/actions/permissions/workflow -f default_workflow_permissions=write ``` `-f` sends string values only. To change `can_approve_pull_request_reviews`, which is a boolean, send a JSON body with `--input` (a file, or `-` for stdin): ```sh echo '{"can_approve_pull_request_reviews": true}' \ | diffinery api PUT /repos/acme/website/actions/permissions/workflow --input - ``` Every change is recorded in the [audit log](https://diffinery.com/docs/security/audit-log) as `repo.actions_workflow_settings`, with the new values. | Status and message | Cause | | --- | --- | | `403 admin access required` | Your role on the repository is below admin, or your token's scope doesn't reach admin. | | `403 only people can change what workflows are allowed to do` | The request used an agent's token. | | `422 a JSON body is required` | The body is missing or isn't JSON. | | `422 default_workflow_permissions must be read or write` | Another value was sent. | | `422 can_approve_pull_request_reviews must be a boolean` | A string such as `"true"` was sent instead of `true`. | > **Note:** The setting is read when a job starts. Jobs that are already running keep the permissions they started with. ## What each endpoint needs Each request the token makes to `/api/v3/repos/{owner}/{repo}/...`, to the git server, or to a repository's web pages is matched to a scope by the first path segment after the repository. `GET`, `HEAD` and `OPTIONS` need `read`; other methods need `write`. Git fetches (`git-upload-pack`) and Git LFS downloads are reads even though they use `POST`. | Path after `/repos/{owner}/{repo}` | Scope | | --- | --- | | `/issues` (list or create issues) | `issues` | | `/issues/...`, `/labels`, `/milestones`, `/assignees` | `issues` or `pull-requests` (either one is enough) | | `/pulls`, `/pulls/...` | `pull-requests` | | `/pulls/{number}/merge`, `/pulls/{number}/update-branch`, `/pulls/{number}/auto-merge` | `contents` | | `/statuses/...`, `/commits/{sha}/status`, `/commits/{sha}/statuses` | `statuses` | | `/check-runs`, `/check-suites`, `/commits/{sha}/check-runs`, `/commits/{sha}/check-suites` | `checks` | | `/deployments`, reading `/environments` | `deployments` | | `/actions/...` | `actions` | | `/pages` | `pages` | | `/discussions` | `discussions` | | `/code-scanning`, `/secret-scanning`, `/dependabot` | `security-events` | | `/packages` | `packages` | | `/projects` | `repository-projects` | | `/attestations` | `attestations` | | `/contents`, `/commits`, `/compare`, `/git`, `/branches`, `/tags`, `/releases`, `/merges`, `/tarball`, `/zipball`, `/readme`, `/dispatches`, `/comments`, `/merge-upstream`, `/languages`, `/contributors`, `/stats` | `contents` | | Git over HTTPS (`/{owner}/{repo}.git/...`), including push | `contents` | | Release asset uploads (`/api/uploads/repos/{owner}/{repo}/releases/{id}/assets`) | `contents` | | `GET /repos/{owner}/{repo}` and other reads not listed here | None: repository metadata is always readable | ### Endpoints a job token can never use Some endpoints are refused whatever the permissions, even with `write-all`: - `/hooks`, `/collaborators`, `/keys`, `/invitations`, `/settings`, `/rulesets`, `/teams`, `/transfer`, `/autolinks`, `/vulnerability-alerts` - `/actions/secrets`, `/actions/variables`, `/actions/runners`, `/actions/permissions`, `/actions/oidc` - Creating, changing or deleting environments, and environment secrets and variables - Branch protection (`/branches/{branch}/protection`) - Changing or deleting the repository itself (`PATCH` or `DELETE /repos/{owner}/{repo}`) - Any write to a path not in the table above Most of these endpoints need the admin role, which a job token never has, so the response is `403 admin access required`. The others answer `403 this endpoint is not available to a workflow's GITHUB_TOKEN`. A job token can't re-run workflows either: `POST /repos/{owner}/{repo}/actions/runs/{run_id}/rerun` (and `/rerun-failed-jobs`, and `POST /repos/{owner}/{repo}/actions/jobs/{job_id}/rerun`) fails with `403 a workflow's GITHUB_TOKEN cannot re-run workflows`. ### The 403 response A request whose scope the token lacks gets GitHub's message, with the scope it needed: ```json { "message": "Resource not accessible by integration (the workflow's GITHUB_TOKEN needs permissions: issues: write)", "documentation_url": "https://docs.github.com/rest" } ``` When either of two scopes is enough, the message lists both, for example `needs permissions: issues or pull-requests: write`. Add the scope to the job's `permissions:` and run the job again. ## Reading public repositories What anyone may read in a public repository, a job token may read too, whatever its permissions: - In its own public repository, a read is allowed even with `permissions: {}` or `contents: none`. - It can read **other** public repositories on Diffinery, for example to check out a public dependency or clone a public submodule, but only read: any write is refused. - It can't see other private repositories at all, even ones with the same owner. A request gets `404`. To check out another private repository, pass a personal or agent token from a secret as `token:`. ## Workflow files A job token never creates, changes or deletes files under `.github/workflows/` or `.diffinery/workflows/`, whatever its permissions. This is GitHub's `workflows` permission, which a `GITHUB_TOKEN` can't have. The refusal reads: ```text refusing to allow a GitHub App to create or update workflow `.github/workflows/ci.yml` without `workflows` permission ``` It applies to: - `git push`: the ref update is rejected with that reason. - Commits through the API, such as the contents API. - Merging a pull request: the message appears as a merge blocker when the pull request changes a workflow file. A push that creates a new branch is compared with where the branch left the default branch, so a new branch that only carries workflow changes already on the default branch is allowed. To change workflow files from automation, use a personal or agent token from a secret. ## Creating and approving pull requests By default a job token can't open pull requests, approve them or request changes on them. The request fails with: ```text GitHub Actions is not permitted to create pull requests. An admin can allow it in the repository's Actions settings ("Allow GitHub Actions to create and approve pull requests"). ``` For reviews the message says `approve` instead of `create`. Commenting reviews are always allowed. To allow the rest, set `can_approve_pull_request_reviews` to `true` (see [Read and change the setting](#read-and-change-the-setting)); the job also needs `pull-requests: write`. Even when allowed, an approval from a job token **never counts** toward a branch's required approvals. Only approvals by people count. ## Pull requests from forks A `pull_request` run for a pull request whose head is in another repository gets read access only: `read-all` at most, whatever the workflow asks for, and never `id-token: write`. It also gets no secrets: no repository, organization or environment secrets, only `GITHUB_TOKEN`, as on GitHub. Diffinery has no forks of its own, so today this applies to pull requests [imported from GitHub](https://diffinery.com/docs/migrate-from-github) whose head was in a fork. Their head shows as `owner:branch`. ## Reusable workflows A [reusable workflow](https://diffinery.com/docs/actions/reusable-workflows) never gets more than its caller: - A job in the called workflow without `permissions:` (and a called workflow without top-level `permissions:`) inherits the calling job's permissions. - Whatever the called workflow or its jobs declare is lowered, scope by scope, to what the calling job has. - The calling job's own permissions follow the usual order: its `permissions:`, then its workflow's, then the repository default. ```yaml # caller jobs: deploy: uses: ./.github/workflows/deploy.yml permissions: contents: read id-token: write ``` Here the jobs in `deploy.yml` can have at most `contents: read` and `id-token: write`, even if `deploy.yml` asks for `write-all`. ## Runs whose code came from an agent A run is treated as agent code when: - an agent triggered it, - an agent last pushed the branch or tag it runs on (a merge that Diffinery lands for an agent through auto-merge counts as the agent's push), - for pull request and merge queue events, an agent opened the pull request or last pushed its head branch, or - off the default branch, the code it runs includes a commit an agent pushed that hasn't landed on the default branch yet. A person pushing on top of an agent's commits, tagging them, making a release from them or dispatching a workflow on them doesn't make that code a person's. Once a person merges the commits into the default branch (or a person lands them there), they count as that person's, since the default branch's runs go by who moved it. A re-run of such a run keeps this, whoever starts the re-run, and so does a `workflow_run` run that follows such a run, since it acts on what that run produced (its artifacts and outputs). The jobs of these runs act as an agent, so the [agent guardrails](https://diffinery.com/docs/agents/overview) hold for code an agent wrote, even when a person triggered the run: - Pushes to the default branch or a protected branch are refused with `agents may not push to protected or default branches; open a pull request`, unless the branch's protection rule has **Allow agents to push directly** on. - Merges into those branches are blocked with `agents may not merge into this branch; a human must merge`, unless the rule has **Allow agents to merge pull requests** on. The token can still turn on [auto-merge](https://diffinery.com/docs/collaboration/pull-requests#auto-merge) where the rule has **Allow agents to land pull requests through auto-merge** and a required check; that rule holds for every job token on those branches, agent code or not. - A pull request the job opens counts as agent-authored, so it needs at least one approval from a person, unless the base branch's rule [waives approvals for agents' auto-merges](https://diffinery.com/docs/repositories/branch-protection#waiving-approvals-for-agents). - A deployment the job records counts as the agent's, so [environment secret rules for agents](https://diffinery.com/docs/actions/environments#who-receives-environment-secrets) apply. - The jobs get no repository or organization secrets, only the job token, unless a repository admin who is a person turns on `agent_runs_use_secrets` (see [Which runs receive secrets](https://diffinery.com/docs/actions/secrets-and-variables#which-runs-receive-secrets)). ## Runs started by the token Like on GitHub, what a job does with its token starts no new workflow runs, so a workflow can't trigger itself in a loop. Pushes, pull requests, releases and merges made with a job token (including a later merge by an auto-merge the token turned on) don't start runs. The two exceptions, as on GitHub, are `workflow_dispatch` (a job with `actions: write` can start another workflow with `POST /repos/{owner}/{repo}/actions/workflows/{workflow_id}/dispatches`) and `repository_dispatch` (`POST /repos/{owner}/{repo}/dispatches`, which needs `contents: write`). ## Differences from GitHub | Area | GitHub | Diffinery | | --- | --- | --- | | Changing the default | **Settings** > **Actions** > **General** > **Workflow permissions** | **Settings** > **Actions** > **Workflow permissions**, or `PUT /repos/{owner}/{repo}/actions/permissions/workflow` | | Organization and enterprise defaults | Can limit repositories | Not available; each repository has its own setting | | Agent code | Not a concept | Runs whose code came from an agent are held to the agent rules | | Name in the repository | `github-actions[bot]` | `diffinery-actions[bot]` | | Extra name | None | `secrets.DIFFINERY_TOKEN` | ## Troubleshooting | Problem | Fix | | --- | --- | | `Resource not accessible by integration (the workflow's GITHUB_TOKEN needs permissions: <scope>: write)` | Add that scope to the job's `permissions:`, or to the workflow's when the job has none. In a reusable workflow, the calling job needs it too. | | A job that used to write now gets 403 | The repository's default is `read` (the default for new repositories), or the workflow added a `permissions:` map that leaves the scope out. Add the scope, or have an admin set `default_workflow_permissions` to `write`. | | `actions/checkout` fails on a private repository after adding `permissions:` | Naming any scope sets the others to `none`. Add `contents: read`. | | `this endpoint is not available to a workflow's GITHUB_TOKEN`, or `admin access required` from a job | The endpoint manages settings, secrets, variables, hooks, runners, environments or branch protection. Use a personal or agent token from a secret. | | `refusing to allow a GitHub App to create or update workflow ...` | Job tokens never change workflow files. Use a personal or agent token. | | `GitHub Actions is not permitted to create pull requests. ...` | An admin sets `can_approve_pull_request_reviews` to `true`, and the job needs `pull-requests: write`. | | A job token's approval doesn't satisfy required reviews | Only people's approvals count. | | `agents may not push to protected or default branches; open a pull request` from a job | The run's code came from an agent. Open a pull request for a person to review and merge. | | A push from a job doesn't start the expected workflow | Events caused by a job token start no runs. Use `workflow_dispatch` with `actions: write` or `repository_dispatch` with `contents: write`, or a personal or agent token. | | `Unable to get ACTIONS_ID_TOKEN_REQUEST_URL env variable` | Add `id-token: write`. `id-token: read` grants nothing. | --- # Deployments and environments > Create deployment environments, limit which branches and tags deploy to them, give them their own secrets and variables, and track deployments. Source: https://diffinery.com/docs/actions/environments An environment is a named deployment target, such as `staging` or `production`, with its own secrets, variables and a policy that decides which branches and tags may deploy to it. A workflow job that names an environment gets that environment's secrets and variables, is refused if its ref isn't allowed, and records a deployment with statuses that other tools can follow. Diffinery implements GitHub's environments, deployments and deployment statuses, including their REST API and webhooks; the differences are listed in [Differences from GitHub](#differences-from-github). ## How it fits together | Concept | What it is | |---|---| | Environment | A name in one repository (`production`), with an optional deployment branch policy, protection rules, secrets and variables. | | Deployment branch policy | Which refs may use the environment: any ref (no policy), protected branches only, or a list of branch and tag patterns. | | Required reviewers | Up to 6 people, one of whom must approve before a run's jobs deploy to the environment. | | Wait timer | Minutes a job waits before it deploys, up to 43,200 (30 days). | | Environment secret | An encrypted value that only jobs using the environment receive, and only when the rules in [Who receives environment secrets](#who-receives-environment-secrets) allow it. | | Environment variable | A plain value that jobs using the environment receive in the `vars` context. | | Deployment | A record that a commit was (or is being) deployed to an environment. Workflow jobs create one automatically; you can also create them with the REST API. | | Deployment status | The state of a deployment over time: `queued`, `pending`, `in_progress`, `success`, `failure`, `error` or `inactive`, with optional log and environment URLs. | A typical setup: 1. A repository admin creates `production`, limits it to protected branches, and stores the deploy credentials as environment secrets. 2. The deploy job in a workflow says `environment: production`. 3. When the job starts on `main` (a protected branch), Diffinery records a deployment with an `in_progress` status, and the job gets the `production` secrets and variables. 4. When the job finishes, the deployment gets a final status (`success`, `failure` or `inactive`). Webhooks receive a `deployment` event when the deployment is created and `deployment_status` events for each status. Repository admins manage environments under **Settings → Environments** (protection rules, deployment branches and their patterns, and environment secrets) and with the REST API, which follows GitHub's paths and JSON shapes, so `gh api`, Octokit and existing scripts work after you point them at `https://diffinery.com/api/v3`. Environment variables are managed with the API. ## Create an environment An environment is created in one of three ways: - **Explicitly**, with `PUT /api/v3/repos/{owner}/{repo}/environments/{name}`. This needs a person with the admin role on the repository and a token with the `admin` scope. - **By a workflow job** that names an environment that doesn't exist yet. The job creates it with no branch policy, secrets or variables, and the job runs. - **By creating a deployment** with the REST API for an environment that doesn't exist yet. Environments created automatically have no branch policy, so every ref may use them. Create sensitive environments yourself, with a policy, before a workflow uses them. ### Environment names - 1 to 255 characters, with no control characters, `/` or `\`. Anything else fails with `422 invalid environment name`. - Names are case-insensitive: `Production` and `production` are the same environment. Once an environment exists, deployments, statuses and jobs that name it in another case use its existing spelling, and filtering deployments with `?environment=` ignores case. - URL-encode the name in API paths, for example `environments/QA%20cluster`. ### Create one with the API An empty body creates the environment with no branch policy: ```sh curl -X PUT https://diffinery.com/api/v3/repos/acme/website/environments/staging \ -H "Authorization: Bearer dfy_..." ``` Create one that only protected branches may use: ```sh curl -X PUT https://diffinery.com/api/v3/repos/acme/website/environments/production \ -H "Authorization: Bearer dfy_..." \ -H "Content-Type: application/json" \ -d '{"deployment_branch_policy": {"protected_branches": true, "custom_branch_policies": false}}' ``` The response (`200 OK`) is the environment: ```json { "id": 1, "node_id": "RW52aXJvbm1lbnQ6YWNtZS93ZWJzaXRlLzE", "name": "production", "url": "https://diffinery.com/api/v3/repos/acme/website/environments/production", "html_url": "https://diffinery.com/acme/website/settings/environments/production", "created_at": "2026-10-13T09:00:00Z", "updated_at": "2026-10-13T09:00:00Z", "can_admins_bypass": false, "protection_rules": [{ "id": 13, "node_id": "QnJhbmNoUG9saWN5OjE", "type": "branch_policy" }], "deployment_branch_policy": { "protected_branches": true, "custom_branch_policies": false } } ``` Notes on the fields: - `protection_rules` lists a `wait_timer` entry (with `wait_timer`), a `required_reviewers` entry (with `prevent_self_review` and `reviewers`, each `{"type": "User", "reviewer": {...}}`) and a `branch_policy` entry, for the rules the environment has. See [Required reviewers and wait timers](#required-reviewers-and-wait-timers). - `can_admins_bypass` is always `false`: nobody bypasses an environment's rules, admins included. - `html_url` is the environment's page under **Settings → Environments**. `PUT` on an existing environment updates it. Fields you leave out keep their values. The body accepts: | Field | Effect | |---|---| | `deployment_branch_policy` | `null` removes the policy (any ref may deploy). An object sets exactly one of `protected_branches` and `custom_branch_policies` to `true`. Leave the field out to keep the current policy. | | `wait_timer` | Minutes a job waits before it deploys, from `0` to `43200`. Anything else fails with `422 wait_timer must be a whole number of minutes from 0 to 43200`. | | `reviewers` | Up to 6 required reviewers, each `{"type": "User", "id": <id>}` with the numeric `id` Diffinery's API shows for the person, or `{"type": "User", "login": "alice"}`. `[]` or `null` removes them. Each must be a person with at least read access: an agent fails with `422 reviewers[0]: <login> is an agent; only people can be required reviewers`, and `{"type": "Team"}` fails because Diffinery has no teams. | | `prevent_self_review` | `true` stops whoever started a run from approving it. See [Prevent self-review](#prevent-self-review). | ### List, get and delete environments ```sh # Every environment, sorted by name (read access) curl https://diffinery.com/api/v3/repos/acme/website/environments \ -H "Authorization: Bearer dfy_..." # One environment (read access) curl https://diffinery.com/api/v3/repos/acme/website/environments/production \ -H "Authorization: Bearer dfy_..." # Delete it, with its secrets and variables (admin, people only) curl -X DELETE https://diffinery.com/api/v3/repos/acme/website/environments/staging \ -H "Authorization: Bearer dfy_..." ``` The list returns `{"total_count": 2, "environments": [...]}` and takes `per_page` (default 30, at most 100) and `page`. Deleting returns `204 No Content` and removes the environment's secrets and variables. The deployments recorded for it stay. A missing environment returns `404 environment not found`. ## Deployment branch policies A branch policy decides which refs a workflow job may use the environment from. It's checked when a job with `environment:` starts. A job whose ref isn't allowed fails before any step runs, with: ```text feature/login is not allowed to deploy to production due to environment protection rules. ``` The policy has three settings: | Setting | API value | Which refs may deploy | |---|---|---| | No policy | `"deployment_branch_policy": null` | Every branch, tag and pull request ref. | | Protected branches only | `{"protected_branches": true, "custom_branch_policies": false}` | Branches that a branch protection rule matches. Tags and pull request refs never. | | Selected branches and tags | `{"protected_branches": false, "custom_branch_policies": true}` | Branches and tags that match one of the environment's patterns. Pull request refs never. | Setting both values to `true`, or both to `false`, fails with `422 set exactly one of protected_branches and custom_branch_policies`. In the web UI, open **Settings → Environments → (environment)**, choose **No restriction**, **Protected branches only** or **Selected branches and tags** under **Deployment branches**, and select **Save protection rules**. With **Selected branches and tags** saved, a **Branch and tag patterns** box appears below, where you add a pattern (choose whether it matches branches or tags) and remove one. The branch policy only applies to workflow jobs. A deployment created directly with the REST API isn't checked against it. ### Protected branches only A branch counts as protected when one of the repository's branch protection rules matches its name (see [Branch protection](https://diffinery.com/docs/repositories/branch-protection)). The default branch is only protected for this purpose when a rule matches it, so add a rule for `main` if production deploys from `main`. This is the setting to use for environments whose secrets matter: changes reach protected branches through reviewed pull requests, and it's the only setting under which runs whose code came from an agent receive environment secrets. ### Selected branches and tags With `custom_branch_policies`, the environment allows the refs that match its patterns, and nothing else. A new custom policy has no patterns, so no ref may deploy until you add one. Add a pattern (admin, people only): ```sh curl -X POST https://diffinery.com/api/v3/repos/acme/website/environments/production/deployment-branch-policies \ -H "Authorization: Bearer dfy_..." \ -H "Content-Type: application/json" \ -d '{"name": "release/*", "type": "branch"}' ``` ```json { "id": 1, "name": "release/*", "type": "branch", "node_id": "QnJhbmNoUG9saWN5OjEvMQ" } ``` `type` is `branch` (the default) or `tag`. A branch pattern is matched against branch names (`refs/heads/<name>`), and a tag pattern against tag names (`refs/tags/<name>`). Adding a pattern to an environment that doesn't use `custom_branch_policies` fails with `422 enable custom_branch_policies on the environment first`; an empty `name` fails with `422 name is required`. List and delete patterns: ```sh curl https://diffinery.com/api/v3/repos/acme/website/environments/production/deployment-branch-policies \ -H "Authorization: Bearer dfy_..." curl -X DELETE https://diffinery.com/api/v3/repos/acme/website/environments/production/deployment-branch-policies/1 \ -H "Authorization: Bearer dfy_..." ``` The list returns `{"total_count": 1, "branch_policies": [{"id": 1, "name": "release/*", "type": "branch", "node_id": "..."}]}`. Switching an environment from custom patterns to protected branches only clears its patterns; switching back to custom patterns starts empty. Pattern syntax: | Pattern | Matches | |---|---| | `*` | Any characters except `/` | | `**` | Any characters, including `/` | | `?` | One character other than `/` | | Anything else | Itself, case-sensitively | Examples: | Pattern | Type | Matches | Doesn't match | |---|---|---|---| | `main` | branch | `main` | `main-2`, `Main` | | `release/*` | branch | `release/1.4` | `release/1.4/hotfix`, `release` | | `release/**` | branch | `release/1.4`, `release/1.4/hotfix` | `releases/1.4` | | `v*` | tag | `v1.4.0`, `v2` | `1.4.0` | | `v?.*` | tag | `v1.4`, `v2.0` | `v10.0` | > **Important:** A tag pattern never matches a tag that an agent created or last moved, whether it pushed the tag with git, created it with a release or wrote it through the API. Diffinery remembers who last moved each tag. Tags that people push can deploy through a tag pattern; tags that agents push can't. ### The default policy when the first secret is added When you add a secret to an environment that has no branch policy, Diffinery sets the policy to **protected branches only** at the same time. This keeps a new environment's secrets away from unreviewed branches. If you want selected branches and tags instead, set `custom_branch_policies` (before or after adding the secret) and add patterns. If you remove the policy afterwards with `"deployment_branch_policy": null`, it stays removed. ## Required reviewers and wait timers Protection rules hold a job before it starts. When a job that names the environment is ready to run, and the environment has required reviewers or a wait timer, the job waits. No runner starts, no token is minted and no secret leaves Diffinery until the rules pass. The job's status is `waiting` (shown as **Waiting for approval** in the web UI), the run's status is `waiting`, and the job's deployment gets a `waiting` status. A waiting job holds no concurrency group and none of the repository's hosted-runner slots, so other jobs keep running. The branch policy is checked first. A ref that may never deploy to the environment fails at once, without asking anyone. Set the rules under **Settings → Environments → (environment)**, or with the API: ```sh curl -X PUT https://diffinery.com/api/v3/repos/acme/website/environments/production \ -H "Authorization: Bearer dfy_..." \ -H "Content-Type: application/json" \ -d '{"reviewers": [{"type": "User", "login": "alice"}, {"type": "User", "login": "bob"}], "wait_timer": 10, "prevent_self_review": true}' ``` ### Required reviewers - One approval from any listed reviewer lets the run's jobs for that environment go on. A rejection fails them, with `The deployment to production was rejected by alice: <comment>`, and jobs that need them are skipped as usual. - The first decision on an environment in a run is the one that counts. Approving covers every job and matrix leg of that run that names the environment. - Reviewers get a notification and an email (Actions email settings apply), the environment shows under **Deployments waiting for you** on [Today](https://diffinery.com/docs/agents/today), and the run page shows a **Review deployments** box with each environment they may approve, a comment and **Approve and deploy** or **Reject**. - Only a person can approve, signed in to the web UI or with a personal token that has the `write` scope. Agents, a workflow's `GITHUB_TOKEN` and sandbox tokens can't, even when an agent's account is listed by mistake. Being listed is enough; a reviewer with read access may approve. That access is checked again when they decide, and it has to be access they were given (as the owner, a collaborator or an organization member): on a public repository, a reviewer removed from it can't approve, even though they can still read it. - A job waits at most 30 days. After that it fails with `The deployment to production wasn't approved within 30 days.` ### Prevent self-review With `prevent_self_review`, these people can't approve a run, even when they're listed: - whoever started the run, - whoever started an earlier attempt of a re-run, - the person who owns the agent that started it. This compares accounts, not logins, so renaming an account doesn't get around it. They see why on the run page, and the API answers `403 production doesn't let whoever started a run approve it`. ### Wait timers A wait timer delays the job by that many minutes, counted from when it became ready to start. With reviewers too, the job starts once someone approved and the timer has passed, whichever comes last. Nobody can skip a timer. ### Re-runs A re-run is a new run: its jobs wait again and need a new approval. The timer starts again too. A job that a failed-job or single-job re-run keeps from the earlier attempt doesn't run again, so it neither waits nor deploys. ### Review with the API ```sh # Environments the run waits on, and whether you may approve them curl https://diffinery.com/api/v3/repos/acme/website/actions/runs/{run_id}/pending_deployments \ -H "Authorization: Bearer dfy_..." # Approve (or "rejected"); returns the deployments of the jobs that were waiting curl -X POST https://diffinery.com/api/v3/repos/acme/website/actions/runs/{run_id}/pending_deployments \ -H "Authorization: Bearer dfy_..." \ -H "Content-Type: application/json" \ -d '{"environment_ids": [1], "state": "approved", "comment": "Ship it"}' # Past decisions on the run curl https://diffinery.com/api/v3/repos/acme/website/actions/runs/{run_id}/approvals \ -H "Authorization: Bearer dfy_..." ``` A pending deployment looks like GitHub's: ```json [ { "environment": { "id": 1, "node_id": "...", "name": "production", "url": "...", "html_url": "..." }, "wait_timer": 10, "wait_timer_started_at": "2026-10-13T09:30:00Z", "current_user_can_approve": true, "reviewers": [{ "type": "User", "reviewer": { "login": "alice", "id": 12345, "type": "User" } }] } ] ``` | Error | Meaning | |---|---| | `422 environment 1 isn't waiting for a review in this run` | Nothing in the run waits on that environment, or someone already decided. | | `403 you aren't a required reviewer for production` | Only listed reviewers may decide. | | `403 only people can review deployments, not agents` | Agents never approve deployments. | | `403 reviewing deployments needs a token that may write` | Use a token with the `write` scope. | | `403 only people with access to this repository review its deployments` | The reviewer was removed from the repository after being listed. Add them back, or change the environment's reviewers. | Each approval and rejection is in the audit log as `deployment.approve` or `deployment.reject`, with the comment. ## Environment secrets and variables Environment secrets and variables work like repository ones, but only jobs that use the environment receive them. Secrets are managed under **Settings → Environments → (environment) → Environment secrets** or with the REST API; variables with the REST API. Secret values are encrypted at rest with a key specific to the repository and the environment. ### Environment secrets Setting and deleting environment secrets needs a person (not an agent) with the admin role on the repository, and with the API a token with the `admin` scope. The environment must exist. In the web UI, the **Environment secrets** box on the environment's page lists the secret names with when each was last updated. Enter a name and a value and select **Save secret** to add one or replace its value, or select **Delete** next to a name. Values are never shown again after you save them. ```sh # Create or replace a secret: 201 Created when new, 204 No Content when replaced curl -X PUT https://diffinery.com/api/v3/repos/acme/website/environments/production/secrets/CLOUDFLARE_API_TOKEN \ -H "Authorization: Bearer dfy_..." \ -H "Content-Type: application/json" \ -d '{"value": "cf_..."}' # List names (values are never returned) curl https://diffinery.com/api/v3/repos/acme/website/environments/production/secrets \ -H "Authorization: Bearer dfy_..." # One secret's metadata curl https://diffinery.com/api/v3/repos/acme/website/environments/production/secrets/CLOUDFLARE_API_TOKEN \ -H "Authorization: Bearer dfy_..." # Delete (204 No Content) curl -X DELETE https://diffinery.com/api/v3/repos/acme/website/environments/production/secrets/CLOUDFLARE_API_TOKEN \ -H "Authorization: Bearer dfy_..." ``` The list returns `{"total_count": 1, "secrets": [{"name": "CLOUDFLARE_API_TOKEN", "created_at": "...", "updated_at": "..."}]}`; listing and reading need the admin role. Rules: - Names use letters, digits and underscores, don't start with a digit, and don't start with `GITHUB_`. Anything else fails with `422 invalid secret name`. Names are stored in uppercase. - Send the value as `value`, in plain JSON over HTTPS. A body with only GitHub's sealed-box `encrypted_value` fails with ``422 encrypted_value (sealed box) is not supported yet: send the secret as `value` over HTTPS; it is encrypted at rest``. There's no `public-key` endpoint. - Adding the first secret to an environment without a branch policy sets it to protected branches only (see [The default policy when the first secret is added](#the-default-policy-when-the-first-secret-is-added)). - Agents can't set or delete them, whatever their role (`403 agents cannot manage secrets` for an agent with the admin role). ### Environment variables | Request | Needs | Does | |---|---|---| | `GET /api/v3/repos/{owner}/{repo}/environments/{env}/variables` | write role | Returns `{"total_count": 1, "variables": [{"name", "value", "created_at", "updated_at"}]}`. `per_page` defaults to 10 (at most 100). | | `GET /api/v3/repos/{owner}/{repo}/environments/{env}/variables/{name}` | write role | Returns one variable, or `404 Not Found`. | | `POST /api/v3/repos/{owner}/{repo}/environments/{env}/variables` with `{"name": "...", "value": "..."}` | admin role, people only | Creates a variable (`201`). An existing name fails with `409 Already exists - Variable already exists`. | | `PATCH /api/v3/repos/{owner}/{repo}/environments/{env}/variables/{name}` with `{"value": "..."}` and optionally a new `"name"` | admin role, people only | Updates or renames a variable (`204`), or `404 Not Found`. Renaming onto a name that already exists fails with `409 Already exists - Variable already exists`. | | `DELETE /api/v3/repos/{owner}/{repo}/environments/{env}/variables/{name}` | admin role, people only | Deletes a variable (`204`). | ```sh curl -X POST https://diffinery.com/api/v3/repos/acme/website/environments/production/variables \ -H "Authorization: Bearer dfy_..." \ -H "Content-Type: application/json" \ -d '{"name": "DEPLOY_URL", "value": "https://www.example.com"}' ``` Variable names follow the same rules as secret names (`422 invalid variable name` otherwise) and are stored in uppercase. Variables are not secret: anyone with write access can read them. Agents can't create, change or delete them, whatever their role (`403 agents cannot manage variables` for an agent with the admin role). ### Who receives environment secrets A job that uses an environment receives its secrets only when all of these hold: 1. The environment's branch policy allows the job's ref (otherwise the job fails, see [Deployment branch policies](#deployment-branch-policies)). 2. The run isn't on a pull request ref (`refs/pull/<number>/merge`). Environment secrets never go to `pull_request` runs, whatever the policy says. 3. If the run's code came from an agent, the environment is set to **protected branches only**. With no policy or with custom patterns, those runs get no environment secrets. A run's code came from an agent when any of these is true: - An agent started the run, for example with a push, a `workflow_dispatch` or a re-run. - The run is for a branch whose last push was made by an agent. - The run is for a pull request that an agent opened, or whose head branch an agent pushed to last. - The run is a re-run of a run whose code came from an agent, whoever starts the re-run. - The run was started by `workflow_run` after a run whose code came from an agent (it acts on that run's artifacts and outputs). In such a run the job also acts as an agent: its deployment's creator is recorded as an agent, and its `GITHUB_TOKEN` follows the rules for agents (see [GITHUB_TOKEN permissions](https://diffinery.com/docs/actions/workflow-permissions#runs-whose-code-came-from-an-agent)). When a rule withholds the secrets, the job still runs with the environment's variables, and `${{ secrets.NAME }}` for an environment secret evaluates to an empty string. | Run | No policy | Protected branches only | Selected branches and tags | |---|---|---|---| | Started by a person, on an allowed branch or tag | Secrets | Secrets | Secrets | | Code from an agent, on an allowed branch | No secrets | Secrets | No secrets | | `pull_request` run (`refs/pull/<n>/merge`) | Job runs, no secrets | Job fails (ref not allowed) | Job fails (ref not allowed) | | Ref not allowed by the policy | Not applicable | Job fails | Job fails | `pull_request_target` runs use the base branch as their ref, so they're treated like a run on that branch. ### Precedence In a job that uses an environment: - An environment secret replaces a repository or organization secret with the same name (and, in a called reusable workflow, a secret passed by the caller). - An environment variable replaces a repository variable with the same name. - `GITHUB_TOKEN` and `DIFFINERY_TOKEN` are always the job's own token. Values are read when the job starts, so changes affect jobs that start afterwards. Environment secrets are masked in logs like every other secret. See [Secrets and variables](https://diffinery.com/docs/actions/secrets-and-variables) for masking, names and repository secrets. ## Use an environment in a job ### Syntax Name the environment with a string: ```yaml jobs: deploy: runs-on: ubuntu-latest environment: production steps: - run: ./deploy.sh ``` Or with a mapping that also gives the URL of the deployed site: ```yaml jobs: deploy: runs-on: ubuntu-latest environment: name: production url: https://www.example.com steps: - run: ./deploy.sh ``` | Key | Description | |---|---| | `name` | The environment's name (required in the mapping form). Expressions are allowed. | | `url` | Optional. Stored as the deployment status's `environment_url` when it's an `http://` or `https://` URL; other values are ignored. Expressions are allowed. | Both values can use the `github`, `inputs`, `vars`, `matrix`, `strategy` and `needs` contexts. Other contexts evaluate to empty: - A `name` that evaluates to an empty string fails the job with `invalid environment name ''`. - A `url` that refers to `steps` (for example `${{ steps.deploy.outputs.url }}`, which GitHub allows) is ignored, so the deployment has no environment URL. Compute the URL from `inputs`, `vars`, `matrix` or a job output in `needs` instead, or post a deployment status yourself (see [Deployment statuses](#deployment-statuses)). ```yaml jobs: deploy: runs-on: ubuntu-latest strategy: matrix: target: [staging, production] environment: name: ${{ matrix.target }} url: https://${{ matrix.target }}.example.com steps: - run: ./deploy.sh "${{ matrix.target }}" ``` `environment` belongs on jobs that have `steps`. On a job that calls a reusable workflow (`uses:`), it produces the warning `unknown reusable-workflow job key 'environment'` and is ignored; put `environment:` on the jobs inside the called workflow instead (see [Reusable workflows](#reusable-workflows)). ### What happens when the job starts When a job with `environment:` is ready to run, Diffinery evaluates `name` once, and that name is the one the job deploys to. If the environment has required reviewers or a wait timer, the job [waits for them](#required-reviewers-and-wait-timers) first. When a hosted or self-hosted runner then starts the job, Diffinery: 1. Evaluates `url`. 2. Creates the environment if it doesn't exist (with no branch policy). 3. Checks the branch policy against the run's ref, and fails the job if the ref isn't allowed. A job whose environment needs an approval it didn't get fails with `The environment production requires approval this job didn't get. Re-run the job to ask for it.` (This happens when someone adds reviewers while the job is already on its way to a runner.) 4. Creates a deployment for the job: `ref` is the branch or tag name (or `refs/pull/<n>/merge`), `sha` is the commit the run checked out, `task` is `deploy`, `payload` is `{}`, the creator is the person or agent who started the run (an agent when the run's code came from an agent), and `production_environment` is `true` when the environment is named `production` in any case (`Production` counts). 5. Sends a `deployment` webhook event with the action `created`, then adds an `in_progress` status, with `log_url` set to the job's page (`https://diffinery.com/acme/website/actions/runs/<run id>/job/<job id>`) and `environment_url` from `url`. 6. Passes the environment's variables to the job, and its secrets when [the rules](#who-receives-environment-secrets) allow it. A job creates one deployment, even if its runner fetches the job more than once. A re-run creates a new deployment, because it's a new job. The deployment's commit statuses and checks aren't required to have passed, unlike [deployments created with the API](#required-contexts). ### Automatic deployment statuses When the job finishes, its deployment gets a final status with the same `log_url` and `environment_url`: | Job conclusion | Deployment status | |---|---| | `success` | `success` | | `cancelled` or `skipped` | `inactive` | | Anything else (`failure`, timed out) | `failure` | A `success` status also marks older deployments to the same environment as `inactive` when their latest status is `success`, unless they are transient or production deployments. Deployments created after this one are left alone. See [auto_inactive](#deployment-statuses). ### Reusable workflows Jobs inside a called workflow can have `environment:`. The environment, its branch policy, secrets and variables come from the **calling** repository (the repository whose run it is), and the policy is checked against the caller's ref. Environment secrets are added to the job even though the caller didn't pass them in `secrets:`, as on GitHub. The environment name can use the called workflow's `inputs`: ```yaml # .github/workflows/deploy.yml on: workflow_call: inputs: target: type: string required: true jobs: deploy: runs-on: ubuntu-latest environment: name: ${{ inputs.target }} url: https://${{ inputs.target }}.example.com steps: - uses: actions/checkout@v4 - run: npx wrangler deploy --env "${{ inputs.target }}" env: CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} ``` ```yaml # .github/workflows/release.yml on: push: branches: [main] jobs: staging: uses: ./.github/workflows/deploy.yml with: target: staging production: needs: staging uses: ./.github/workflows/deploy.yml with: target: production ``` Here `CLOUDFLARE_API_TOKEN` can be a different environment secret in `staging` and in `production`. See [Reusable workflows](https://diffinery.com/docs/actions/reusable-workflows). ### Choosing an environment when you run a workflow manually A `workflow_dispatch` input with `type: environment` is accepted. It's shown as a text field when you run the workflow from the web UI, and its value isn't checked against the repository's environments. Use it like a string input: ```yaml on: workflow_dispatch: inputs: target: type: environment required: true jobs: deploy: runs-on: ubuntu-latest environment: ${{ inputs.target }} steps: - run: ./deploy.sh ``` Because a dispatched run can name any environment, the environment's branch policy is what keeps a branch from deploying where it shouldn't. ### Example: staging on main, production on version tags 1. Set up the environments (as a repository admin): ```sh api=https://diffinery.com/api/v3/repos/acme/website auth="Authorization: Bearer dfy_..." # staging: protected branches only (add a branch protection rule for main first) curl -X PUT "$api/environments/staging" -H "$auth" -H "Content-Type: application/json" \ -d '{"deployment_branch_policy": {"protected_branches": true, "custom_branch_policies": false}}' # production: only tags that match v* curl -X PUT "$api/environments/production" -H "$auth" -H "Content-Type: application/json" \ -d '{"deployment_branch_policy": {"protected_branches": false, "custom_branch_policies": true}}' curl -X POST "$api/environments/production/deployment-branch-policies" -H "$auth" \ -H "Content-Type: application/json" -d '{"name": "v*", "type": "tag"}' # A different token for each environment curl -X PUT "$api/environments/staging/secrets/CLOUDFLARE_API_TOKEN" -H "$auth" \ -H "Content-Type: application/json" -d '{"value": "cf_staging_..."}' curl -X PUT "$api/environments/production/secrets/CLOUDFLARE_API_TOKEN" -H "$auth" \ -H "Content-Type: application/json" -d '{"value": "cf_production_..."}' ``` 2. Write the workflow: ```yaml name: Deploy on: push: branches: [main] tags: ["v*"] jobs: staging: if: github.ref == 'refs/heads/main' runs-on: ubuntu-latest environment: name: staging url: https://staging.example.com steps: - uses: actions/checkout@v4 - run: npm ci && npm run build - run: npx wrangler deploy --env staging env: CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} production: if: startsWith(github.ref, 'refs/tags/v') runs-on: ubuntu-latest environment: name: production url: https://www.example.com steps: - uses: actions/checkout@v4 - run: npm ci && npm run build - run: npx wrangler deploy --env production env: CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} ``` Pushing to `main` deploys to staging; a person pushing the tag `v1.4.0` deploys to production. A tag pushed by an agent fails the production job with the "not allowed to deploy" message. ## Deployments API You can create deployments yourself, for example from a deploy tool outside Diffinery Actions, and report their progress with statuses. The endpoints follow GitHub's REST API. Creating deployments and statuses needs the write role and a token with the `write` scope; reading needs read access. Agents with write access can create deployments and statuses. A workflow step can create deployments and statuses with `GITHUB_TOKEN` when the job has `deployments: write` in its `permissions:`. Without it, the request fails with `403 Resource not accessible by integration (the workflow's GITHUB_TOKEN needs permissions: deployments: write)`, or `403 write access required` when the token has no write scope at all. Often `environment:` on the job is simpler, because it records the deployment for you. See [GITHUB_TOKEN permissions](https://diffinery.com/docs/actions/workflow-permissions). ```yaml jobs: deploy: runs-on: ubuntu-latest permissions: contents: read deployments: write steps: - run: | curl -fsS -X POST "$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/deployments" \ -H "Authorization: Bearer $GITHUB_TOKEN" \ -H "Content-Type: application/json" \ -d '{"ref": "${{ github.sha }}", "environment": "preview", "required_contexts": []}' env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} ``` ### Create a deployment ```sh curl -X POST https://diffinery.com/api/v3/repos/acme/website/deployments \ -H "Authorization: Bearer dfy_..." \ -H "Content-Type: application/json" \ -d '{"ref": "main", "environment": "production", "description": "Deploy 1.4.0", "required_contexts": ["ci/build"]}' ``` | Field | Default | Description | |---|---|---| | `ref` | required | A branch, tag or commit SHA. Missing: `422 ref is required`. Unknown: `422 no ref found for: <ref>`. | | `environment` | `production` | The environment's name. It's created if it doesn't exist. | | `task` | `deploy` | Free text. Only the first 100 characters are kept. | | `payload` | `{}` | Any JSON value, or a string. Returned as it was sent. | | `description` | `null` | Free text. Only the first 1,000 characters are kept. | | `required_contexts` | every context | See [Required contexts](#required-contexts). Up to 100 strings. | | `transient_environment` | `false` | Marks the environment as temporary (it's skipped by `auto_inactive`). | | `production_environment` | `true` when `environment` is `production`, in any case | Marks a production deployment (it's skipped by `auto_inactive`). | | `auto_merge` | | Accepted and ignored. Diffinery never merges the default branch into `ref`. | The response is `201 Created` with the deployment (some user fields left out here): ```json { "url": "https://diffinery.com/api/v3/repos/acme/website/deployments/7", "id": 7, "node_id": "RGVwbG95bWVudDphY21lL3dlYnNpdGUvNw", "sha": "9fceb02d0ae598e95dc970b74767f19372d61af8", "ref": "main", "task": "deploy", "payload": {}, "original_environment": "production", "environment": "production", "description": "Deploy 1.4.0", "creator": { "login": "diffy", "type": "User" }, "created_at": "2026-10-13T09:30:00Z", "updated_at": "2026-10-13T09:30:00Z", "statuses_url": "https://diffinery.com/api/v3/repos/acme/website/deployments/7/statuses", "repository_url": "https://diffinery.com/api/v3/repos/acme/website", "transient_environment": false, "production_environment": true, "performed_via_github_app": null } ``` ### Required contexts Before creating a deployment, Diffinery checks the commit's checks and commit statuses, as GitHub does: - **`required_contexts` left out:** every check and status context reported on the commit must have passed. Contexts reported only by agents aren't counted. - **A list of names:** each named context must have passed. A name that nothing reported counts as not passed. - **`[]`:** nothing is checked. A context passes when its result is `success`, `neutral` or `skipped`. When several reporters use the same name, the same rules as for required checks in branch protection apply: Diffinery Actions decides a check it reported, any failure fails, anything unfinished is pending, and reports from agents never count. See [Checks and commit statuses](https://diffinery.com/docs/collaboration/checks-and-statuses). If a context hasn't passed, the request fails with `409`: ```json { "message": "Conflict: Commit status checks failed for main.", "errors": [ { "contexts": [{ "context": "ci/build", "state": "pending" }], "resource": "Deployment", "field": "required_contexts" } ], "documentation_url": "https://docs.github.com/rest" } ``` `state` is `failure`, `pending` or `missing`. Wait for the checks to finish, or pass `required_contexts` with only the contexts you care about. Deployments that workflow jobs create for `environment:` skip this check. ### List, get and delete deployments ```sh # Newest first; filter by sha, ref, task or environment (exact match) curl "https://diffinery.com/api/v3/repos/acme/website/deployments?environment=production&per_page=10" \ -H "Authorization: Bearer dfy_..." curl https://diffinery.com/api/v3/repos/acme/website/deployments/7 \ -H "Authorization: Bearer dfy_..." curl -X DELETE https://diffinery.com/api/v3/repos/acme/website/deployments/7 \ -H "Authorization: Bearer dfy_..." ``` - The list takes `per_page` (default 30, at most 100) and `page`, and sends a `Link` header. - The `ref` filter compares the stored ref exactly. Deployments created by workflow jobs store the branch or tag name (`main`, `v1.4.0`), not `refs/heads/main`. - A missing deployment returns `404 deployment not found`. - Deleting needs the write role and returns `204 No Content`. A deployment whose latest status is `success` can't be deleted while other deployments to the same environment exist: `422 We cannot delete an active deployment unless it is the only deployment in a given environment.` Post an `inactive` status first. ### Deployment statuses ```sh curl -X POST https://diffinery.com/api/v3/repos/acme/website/deployments/7/statuses \ -H "Authorization: Bearer dfy_..." \ -H "Content-Type: application/json" \ -d '{"state": "success", "log_url": "https://ci.example.com/builds/812", "environment_url": "https://www.example.com", "description": "Deployed"}' ``` | Field | Description | |---|---| | `state` | Required: `error`, `failure`, `inactive`, `in_progress`, `queued`, `pending` or `success`. Anything else fails with `422 state must be one of error, failure, inactive, in_progress, queued, pending, success`. | | `log_url` | Link to the deployment's output. `target_url` is accepted as an older name for it. | | `environment_url` | Link to the deployed environment. | | `description` | Up to 140 characters; longer text is cut. | | `environment` | Moves the deployment to another environment (created if needed). The deployment's `original_environment` keeps the first name. | | `auto_inactive` | Default `true`. With a `success` state, older deployments to the same environment whose latest status is `success` get an `inactive` status, except transient and production deployments. Deployments created after this one are left alone. Send `false` to keep them all. | URLs must start with `http://` or `https://` (`422 URLs must be http(s)`). The response is `201 Created`: ```json { "url": "https://diffinery.com/api/v3/repos/acme/website/deployments/7/statuses/15", "id": 15, "node_id": "RGVwbG95bWVudFN0YXR1czphY21lL3dlYnNpdGUvMTU", "state": "success", "creator": { "login": "diffy", "type": "User" }, "description": "Deployed", "environment": "production", "target_url": "https://ci.example.com/builds/812", "log_url": "https://ci.example.com/builds/812", "environment_url": "https://www.example.com", "created_at": "2026-10-13T09:34:10Z", "updated_at": "2026-10-13T09:34:10Z", "deployment_url": "https://diffinery.com/api/v3/repos/acme/website/deployments/7", "repository_url": "https://diffinery.com/api/v3/repos/acme/website", "performed_via_github_app": null } ``` List a deployment's statuses, newest first, with `GET /api/v3/repos/{owner}/{repo}/deployments/{id}/statuses` (`per_page`, `page`), and get one with `GET /api/v3/repos/{owner}/{repo}/deployments/{id}/statuses/{status_id}` (`404 deployment status not found` if it doesn't exist). ## Endpoint summary All paths are under `https://diffinery.com/api/v3`. Every endpoint is also in the [API reference](https://diffinery.com/docs/api). | Method and path | Needs | |---|---| | `GET /repos/{owner}/{repo}/deployments` | read | | `POST /repos/{owner}/{repo}/deployments` | write | | `GET /repos/{owner}/{repo}/deployments/{id}` | read | | `DELETE /repos/{owner}/{repo}/deployments/{id}` | write | | `GET /repos/{owner}/{repo}/deployments/{id}/statuses` | read | | `POST /repos/{owner}/{repo}/deployments/{id}/statuses` | write | | `GET /repos/{owner}/{repo}/deployments/{id}/statuses/{status_id}` | read | | `GET /repos/{owner}/{repo}/environments` | read | | `GET /repos/{owner}/{repo}/environments/{env}` | read | | `PUT /repos/{owner}/{repo}/environments/{env}` | admin, people only | | `DELETE /repos/{owner}/{repo}/environments/{env}` | admin, people only | | `GET /repos/{owner}/{repo}/environments/{env}/deployment-branch-policies` | read | | `POST /repos/{owner}/{repo}/environments/{env}/deployment-branch-policies` | admin, people only | | `DELETE /repos/{owner}/{repo}/environments/{env}/deployment-branch-policies/{id}` | admin, people only | | `GET /repos/{owner}/{repo}/environments/{env}/secrets` and `.../secrets/{name}` | admin | | `PUT` and `DELETE /repos/{owner}/{repo}/environments/{env}/secrets/{name}` | admin, people only | | `GET /repos/{owner}/{repo}/environments/{env}/variables` and `.../variables/{name}` | write | | `POST /repos/{owner}/{repo}/environments/{env}/variables` | admin, people only | | `PATCH` and `DELETE /repos/{owner}/{repo}/environments/{env}/variables/{name}` | admin, people only | | `GET /repos/{owner}/{repo}/actions/runs/{run_id}/pending_deployments` | read | | `POST /repos/{owner}/{repo}/actions/runs/{run_id}/pending_deployments` | a listed reviewer (people only, `write` token) | | `GET /repos/{owner}/{repo}/actions/runs/{run_id}/approvals` | read | The token's scope caps the role: a `read` token only reads (any other request gets `403 this token needs the write scope`), a `write` token also writes, and only an `admin` token reaches the admin endpoints. Requests without a token can read the environments and deployments of a public repository, but any change gets `401 authentication required`. On an archived repository, every request other than a read fails with `403 repository is archived (read-only)`. ## Webhooks Repository webhooks receive three events, with the GitHub event names in the `X-GitHub-Event` header. In the web UI's **Add a webhook** form, choose **Everything** to receive them; with the API, list `deployment`, `deployment_status` and `deployment_review` in the webhook's `events`. See [Webhooks](https://diffinery.com/docs/repositories/webhooks). ### deployment Sent when a deployment is created with the REST API: ```json { "action": "created", "deployment": { "id": 7, "sha": "9fceb02d0ae598e95dc970b74767f19372d61af8", "ref": "main", "task": "deploy", "environment": "production", "description": "Deploy 1.4.0", "payload": {}, "created_at": "2026-10-13T09:30:00.000Z" }, "repository": { "full_name": "acme/website" }, "sender": { "login": "diffy", "type": "User" } } ``` Deployments that workflow jobs create for `environment:` send this event too, when the job starts. Their `description` is `null`, `task` is `deploy` and `payload` is `{}`. ### deployment_status Sent for every status created with the REST API or by a workflow job (the `in_progress` status when the job starts and the final status when it ends): ```json { "action": "created", "deployment_status": { "id": 15, "state": "success", "description": "", "environment": "production", "environment_url": "https://www.example.com", "log_url": "https://diffinery.com/acme/website/actions/runs/01J9Z.../job/01J9Z...", "created_at": "2026-10-13T09:34:10.000Z" }, "deployment": { "id": 7, "sha": "9fceb02d0ae598e95dc970b74767f19372d61af8", "ref": "main", "task": "deploy", "environment": "production" }, "repository": { "full_name": "acme/website" }, "sender": { "login": "diffy", "type": "User" } } ``` The `inactive` statuses that `auto_inactive` adds to older deployments don't send events. The `repository` and `sender` objects have more fields than shown; see [Webhook events](https://diffinery.com/docs/reference/webhook-events). ### deployment_review Sent when a run starts waiting on an environment's required reviewers (`requested`), and when a reviewer decides (`approved` or `rejected`). The payload has `environment`, `environment_id`, `comment`, `workflow_run` (`id`, `run_number`, `name`, `display_title`, `head_sha`, `head_branch`, `html_url`), `reviewers`, and `requestor` (requested) or `approver` (approved and rejected). Workflows with `on: deployment` or `on: deployment_status` are accepted but never started. To run something after a deployment, use `needs:` on a later job, or a webhook. ## Agents and environments Environments guard deployment credentials, so agents are limited: - Agents can't create, change or delete environments or their branch policies, whatever their role. An agent with the write role gets `403 admin access required`; one that was given the admin role gets `403 only repository admins (people, not agents) can manage environments`. - Agents can't set or delete environment secrets: an agent with the admin role gets `403 agents cannot manage secrets`. - Agents can't create, change or delete environment variables: an agent with the admin role gets `403 agents cannot manage variables`. - Runs whose code came from an agent only get environment secrets from environments set to protected branches only, and never on pull request refs (see [Who receives environment secrets](#who-receives-environment-secrets)). - Tags that an agent created or moved never match an environment's tag patterns. - Agents with write access can create deployments and deployment statuses with the API (with a `write` token), and can read environments and environment variables. Together with branch protection (agents can't push to protected branches unless a rule allows it, and pull requests opened by agents need a human approval), this means an agent can't get production credentials into code that no person reviewed. The exception is a branch whose rule has **Let agents' auto-merges land without an approving review** on (see [Waiving approvals for agents](https://diffinery.com/docs/repositories/branch-protection#waiving-approvals-for-agents)): there an agent's pull request lands on green required checks alone, and the runs that follow on that branch receive the secrets of environments limited to protected branches. Turn that waiver on only where the required checks are the review you rely on. See [Agents](https://diffinery.com/docs/agents/overview). ## Audit log Changes to environments made with the API are recorded in the audit log, without secret values: | Action | When | |---|---| | `environment.update` | An environment is created or updated with `PUT` or in Settings (with the new branch policy, reviewers, wait timer and self-review setting). | | `environment.delete` | An environment is deleted. | | `environment.branch_policy.create` | A branch or tag pattern is added. | | `environment.branch_policy.delete` | A pattern is removed. | | `environment.secret.set` | An environment secret is created or replaced (name only). | | `environment.secret.delete` | An environment secret is deleted. | | `deployment.approve`, `deployment.reject` | A required reviewer approves or rejects a run's environments (with the comment). | Environment variable changes aren't recorded. See [Audit log](https://diffinery.com/docs/security/audit-log). ## What isn't supported - **Custom deployment protection rules** (GitHub Apps that gate deployments) and **team reviewers**. Diffinery has no teams; list people. - **Admin bypass.** Nobody skips an environment's rules. - **Web UI for branch patterns, environment secrets and variables.** Settings → Environments edits the protection rules; the rest is managed with the REST API. - **Reading or updating a single branch pattern.** You can list, add and delete patterns, but not `GET` or `PUT` one by id. - **Sealed-box secrets.** Send `value`, not `encrypted_value`. - **Organization-level environments and environment secrets.** Environments belong to one repository. - **`on: deployment` and `on: deployment_status` workflow triggers.** Accepted but never started. ## Differences from GitHub | Area | GitHub | Diffinery | |---|---|---| | Managing environments | Web UI and API | Protection rules, deployment branches, patterns and secrets in Settings → Environments and the API; variables with the API | | Required reviewers | People and teams | People only, never agents; an agent or a job token can't approve | | Prevent self-review | Covers whoever triggered the run | Also covers earlier attempts' starters and the owner of an agent that started it | | Admin bypass | Admins can bypass protection rules | Nobody bypasses them; `can_admins_bypass` is `false` | | Default branch policy | All branches | All refs, until the first secret is added; then protected branches only | | Protected branches | Branches with protection rules | Branches matched by a branch protection rule; the default branch counts only when a rule matches it | | Pull request runs | Fork pull requests get no secrets | No `pull_request` run gets environment secrets | | Agents | No equivalent | Agents can't manage environments, get environment secrets only under "protected branches only", and their tags never match tag patterns | | `environment.url` | Can use `steps` outputs | `steps` references are ignored | | Deployment `auto_merge` | Merges the default branch into the ref | Ignored | | Branch patterns | `fnmatch` syntax with character classes | `*`, `**` and `?` only, unlike workflow filters | ## Troubleshooting | Problem | Cause and fix | |---|---| | `<ref> is not allowed to deploy to <env> due to environment protection rules.` | The environment's branch policy doesn't allow the run's ref. Check the policy with `GET .../environments/<env>`: with protected branches only, add a branch protection rule for the branch; with custom patterns, add a matching pattern. A tag last pushed by an agent never matches. | | The deploy job fails on a pull request | Pull request refs never pass a branch policy. Add `if: github.event_name == 'push'` (or a branch check) to the deploy job. | | `invalid environment name ''` | `environment.name` evaluated to an empty string, for example because it uses `steps`, or a `needs` output that's empty. Use `inputs`, `vars`, `matrix`, `needs` or a literal. | | An environment secret is empty in the job | The run is on a pull request ref, or the run's code came from an agent and the environment isn't set to protected branches only, or the name is wrong. Variables are still set. | | A new environment suddenly refuses a branch | Adding its first secret set the policy to protected branches only. Add a branch protection rule, or switch to custom patterns. | | `403 only repository admins (people, not agents) can manage environments` | Use a person's token with the `admin` scope and the admin role on the repository. | | `403 agents cannot manage secrets` | Use a person's token. | | `403 agents cannot manage variables` | Agents can't change environment variables. Use a person's token. | | `403 this token needs the write scope` | The token has only the `read` scope. Use a `write` token for deployments and statuses, or an `admin` token for environment settings. | | A job stays **Waiting for approval** | The environment has required reviewers: one of them approves on the run page or with `pending_deployments`. With a wait timer, the job starts when it ends. | | `422 reviewers[0]: no such person with access to this repository` | GitHub's numeric user ids don't carry over. Use `{"type": "User", "login": "..."}` with a Diffinery login. | | `422 set exactly one of protected_branches and custom_branch_policies` | Set one to `true` and the other to `false`, or send `null` to remove the policy. | | `422 enable custom_branch_policies on the environment first` | Set `custom_branch_policies: true` with `PUT` before adding patterns. | | `409 Conflict: Commit status checks failed for <ref>.` | A context on the commit hasn't passed. Wait for it, or pass `required_contexts` (`[]` to skip the check). | | `422 We cannot delete an active deployment unless it is the only deployment in a given environment.` | Post an `inactive` status to the deployment first. | | `403 Resource not accessible by integration` or `403 write access required` when a workflow step creates a deployment | The job lacks `deployments: write`. Add it to the job's `permissions:`, or use `environment:` on the job. | | `403 admin access required` | Managing environments, branch patterns, environment secrets and variable changes needs the admin role and an `admin`-scoped token. | | The deployment has no environment URL | `environment.url` used `steps`, or wasn't an `http(s)` URL. | --- # Deploy to Cloudflare Workers > Deploy a Cloudflare Worker from Diffinery on every push to main, with a Preview per pull request, or let Workers Builds build it through a Cloudflare Artifacts mirror. Source: https://diffinery.com/docs/actions/deploy-to-cloudflare-workers Cloudflare's own build service, Workers Builds, connects to GitHub, GitLab, Cursor Origin and Cloudflare Artifacts repositories (as of October 2026), so a Diffinery repository doesn't show up under **Connect to a repository** in the Cloudflare dashboard yet. You don't need it there to deploy automatically. This page shows two ways that work today: | | [Deploy with Diffinery Actions](#deploy-with-diffinery-actions) (recommended) | [Mirror to Cloudflare Artifacts](#mirror-to-cloudflare-artifacts-for-workers-builds) | |---|---|---| | Who builds | A Diffinery Actions job runs `wrangler deploy` | Workers Builds, after Diffinery pushes a copy of the branch to Artifacts | | Result on the pull request | The job is a check on the pull request, and a comment links the Preview | Nothing; build results are in the Cloudflare dashboard | | Previews | One per pull request, removed when it closes | One per branch | | Production branch | Any branch you choose | `main` only | | Cloudflare plan | Any | Workers Paid (Artifacts isn't on the Free plan) | | Cloudflare credentials in Diffinery | An API token that can edit Workers | An API token that can edit Artifacts | Both use the same Wrangler configuration as any other Worker, so a project that already deploys from GitHub Actions or Workers Builds moves over without changes to its code. ## Deploy with Diffinery Actions A workflow deploys `main` to production and gives each pull request its own [Worker Preview](https://developers.cloudflare.com/workers/previews/), with the Preview's address commented on the pull request. This is how Diffinery deploys itself. ### 1. Create a Cloudflare API token 1. In the Cloudflare dashboard, open **Manage account → Account API tokens** and select **Create Token**. 2. Use the **Edit Cloudflare Workers** template. 3. Under **Account Resources**, include only the account the Worker deploys to. Under **Zone Resources**, include only the zones whose routes the Worker uses (or none, for a `workers.dev` Worker). 4. Create the token and copy it. Copy your **Account ID** too (it's on the account's home page, and in `npx wrangler whoami`). If the Worker uses other resources, such as D1, R2 or Queues, add **Edit** for each of them to the token. ### 2. Store the token in Diffinery Under **Settings → Secrets and variables** of the repository, add two repository secrets: | Name | Value | |---|---| | `CLOUDFLARE_API_TOKEN` | The token from step 1 | | `CLOUDFLARE_ACCOUNT_ID` | Your Cloudflare account ID | Or from a terminal, with the [Diffinery CLI](https://diffinery.com/docs/actions/secrets-and-variables#secrets-with-the-cli) signed in with the `admin` scope: ```sh diffinery secret set CLOUDFLARE_API_TOKEN diffinery secret set CLOUDFLARE_ACCOUNT_ID ``` Pull request runs get repository secrets, because every pull request on Diffinery comes from a branch of the same repository, pushed by someone with write access (see [Secrets and variables](https://diffinery.com/docs/actions/secrets-and-variables)). That is what lets the preview job below deploy a Preview. If you'd rather keep the production token away from pull request runs, see [Keep the production token in an environment](#keep-the-production-token-in-an-environment). ### 3. Allow Previews in the Wrangler configuration Worker Previews need Wrangler 4.135.0 or later in the project, and a `previews` block in the Wrangler configuration (it can be empty). Settings in `previews` apply to Previews only, so a Preview can use its own variables and resources: ```sh npm i -D wrangler@latest ``` ```jsonc // wrangler.jsonc { "name": "my-worker", "main": "src/index.ts", "compatibility_date": "2026-10-01", "vars": { "ENVIRONMENT": "production" }, "previews": { "vars": { "ENVIRONMENT": "preview" } } } ``` Cloudflare's [Previews configuration](https://developers.cloudflare.com/workers/previews/configuration/) explains which settings belong in `previews`. Without Previews, leave out the `preview` and `cleanup` jobs below and keep `deploy`. ### 4. Add the workflow Commit this as `.github/workflows/cloudflare.yml` (Diffinery reads workflows from `.github/workflows/` and `.diffinery/workflows/`). Replace `npm ci` with your project's install and build commands: ```yaml name: Cloudflare on: push: branches: [main] pull_request: types: [opened, synchronize, reopened, closed] permissions: contents: read jobs: deploy: name: Deploy to production if: github.event_name == 'push' runs-on: ubuntu-latest timeout-minutes: 15 concurrency: group: cloudflare-production cancel-in-progress: false steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 22 cache: npm - run: npm ci - run: npx wrangler deploy env: CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} preview: name: Preview if: github.event_name == 'pull_request' && github.event.action != 'closed' runs-on: ubuntu-latest timeout-minutes: 15 permissions: contents: read pull-requests: write concurrency: group: cloudflare-preview-${{ github.event.pull_request.number }} cancel-in-progress: true steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 22 cache: npm - run: npm ci - name: Create or update the Preview id: preview run: | set -euo pipefail out=$(npx wrangler preview --name "pr-$PR" --json) url=$(printf '%s' "$out" | jq -er '.preview.urls[0]') echo "url=$url" >> "$GITHUB_OUTPUT" echo "Preview: $url" >> "$GITHUB_STEP_SUMMARY" env: PR: ${{ github.event.pull_request.number }} CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} - name: Link the Preview on the pull request run: | set -euo pipefail api="$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/issues" auth=(-H "Authorization: Bearer $TOKEN" -H "Accept: application/vnd.github+json") marker='<!-- cloudflare-preview -->' text=$(printf '%s\n**Cloudflare Preview:** %s (commit %s)' "$marker" "$URL" "${SHA:0:7}") body=$(jq -n --arg b "$text" '{body: $b}') # The job's own earlier comment, if any: one comment per pull request, kept up to date. id=$(curl -fsS "${auth[@]}" "$api/$PR/comments?per_page=100" \ | jq -r --arg m "$marker" '[.[] | select(.user.login == "diffinery-actions[bot]" and (.body | startswith($m)))][0].id // empty') if [ -n "$id" ]; then curl -fsS -X PATCH "${auth[@]}" "$api/comments/$id" -d "$body" > /dev/null else curl -fsS -X POST "${auth[@]}" "$api/$PR/comments" -d "$body" > /dev/null fi env: PR: ${{ github.event.pull_request.number }} URL: ${{ steps.preview.outputs.url }} SHA: ${{ github.event.pull_request.head.sha }} TOKEN: ${{ secrets.GITHUB_TOKEN }} cleanup: name: Delete the Preview if: github.event_name == 'pull_request' && github.event.action == 'closed' runs-on: ubuntu-latest timeout-minutes: 5 steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 22 cache: npm - run: npm ci - run: npx wrangler preview delete --name "pr-$PR" --skip-confirmation env: PR: ${{ github.event.pull_request.number }} CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} ``` What happens: - **Pushes to `main`** run **Deploy to production**, which runs `wrangler deploy`. Deploys take turns in the `cloudflare-production` group, and when several are waiting only the newest runs, so two merges in a row never deploy out of order. - **Opening or updating a pull request** runs **Preview**. It creates or updates the Preview named `pr-<number>` and keeps one comment on the pull request up to date with the Preview's address. The job is a check on the pull request, so a failed build shows there, and you can make it a [required check](https://diffinery.com/docs/repositories/branch-protection) for merging. A newer push cancels a Preview build that's still running for the same pull request. - **Closing or merging a pull request** runs **Delete the Preview**, which removes it from Cloudflare. The comment step uses the REST API directly, because `gh` on a runner only reads `GH_TOKEN` for github.com. To use `gh pr comment` instead, set `GH_HOST: diffinery.com` and `GH_ENTERPRISE_TOKEN: ${{ secrets.GITHUB_TOKEN }}` on the step. `cloudflare/wrangler-action` works too, if you prefer it to running `npx wrangler` yourself: actions from GitHub run on Diffinery's runners (see [Compatibility](https://diffinery.com/docs/actions/compatibility)). ### Keep the production token in an environment Repository secrets go to every run, including pull request runs, so anyone who can push a branch can run code that reads them. To keep the production deploy token for reviewed code only, put it in an [environment](https://diffinery.com/docs/actions/environments) instead: 1. Create an environment named `production` and add `CLOUDFLARE_API_TOKEN` and `CLOUDFLARE_ACCOUNT_ID` to it as environment secrets, with the REST API as shown in [Environment secrets and variables](https://diffinery.com/docs/actions/secrets-and-variables#environment-secrets-and-variables). Adding the first secret limits the environment to protected branches, so protect `main` if it isn't already. Optionally add required reviewers under **Settings → Environments**, so each deploy waits for an approval. 2. Add `environment: production` to the `deploy` job. 3. For Previews, create a second API token and store it as the repository secret `CLOUDFLARE_PREVIEW_API_TOKEN`; use it in the `preview` and `cleanup` jobs. Environment secrets never go to pull request runs, so the production token stays with jobs on protected branches. A Workers API token can't be limited to Previews, so this keeps the two tokens apart and lets you revoke the preview token on its own; it doesn't stop the preview token from deploying. ## Mirror to Cloudflare Artifacts for Workers Builds If you'd rather have Workers Builds run your builds, with its build history and logs in the Cloudflare dashboard, keep developing on Diffinery and let a workflow push each branch to a [Cloudflare Artifacts](https://developers.cloudflare.com/artifacts/) repository. Workers Builds can connect to Artifacts, so it builds and deploys `main` and creates Previews for other branches. Know the limits first: Artifacts needs the Workers Paid plan and bills per operation and per GB stored (the first 10,000 operations and 1 GB each month are included); a repository holds at most 1 GB; only `main` can be the production branch; and Workers Builds doesn't report back to Diffinery, so build results stay in the Cloudflare dashboard. ### 1. Create the Artifacts repository With Wrangler signed in to your Cloudflare account: ```sh npx wrangler artifacts repos create my-worker --namespace default --default-branch main ``` ### 2. Store a token that can push to it Create an account API token with the **Artifacts Edit** permission, and add it to the Diffinery repository as the secret `CLOUDFLARE_ARTIFACTS_TOKEN`, with your account ID as `CLOUDFLARE_ACCOUNT_ID`. The workflow below uses it to mint a short-lived push token for the one repository on each run, so no long-lived git password is stored anywhere. ### 3. Add the mirror workflow ```yaml name: Mirror to Cloudflare Artifacts on: push: branches: ['**'] permissions: contents: read concurrency: group: artifacts-mirror-${{ github.ref }} cancel-in-progress: false jobs: mirror: runs-on: ubuntu-latest timeout-minutes: 10 env: NAMESPACE: default REPO: my-worker steps: - uses: actions/checkout@v4 with: fetch-depth: 0 - name: Push to Artifacts run: | set -euo pipefail base="https://api.cloudflare.com/client/v4/accounts/$CLOUDFLARE_ACCOUNT_ID/artifacts/namespaces/$NAMESPACE" auth=(-H "Authorization: Bearer $CF_TOKEN" -H "Content-Type: application/json") remote=$(curl -fsS "${auth[@]}" "$base/repos/$REPO" | jq -er .result.remote) token=$(curl -fsS -X POST "${auth[@]}" "$base/tokens" \ -d "$(jq -n --arg r "$REPO" '{repo: $r, scope: "write", ttl: 600}')" | jq -er .result.plaintext) echo "::add-mask::$token" git -c http.extraHeader="Authorization: Bearer $token" push --force "$remote" "HEAD:refs/heads/$GITHUB_REF_NAME" env: CF_TOKEN: ${{ secrets.CLOUDFLARE_ARTIFACTS_TOKEN }} CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} ``` Each push to a branch pushes that branch to Artifacts. Pushes to the same branch take turns, and when several are waiting only the newest runs, so Artifacts always ends up with the branch's latest commit. Deleting a branch on Diffinery doesn't delete it in Artifacts (workflows don't run for deletions); remove old branches there with `git push <remote> --delete <branch>` and a token from `npx wrangler artifacts repos issue-token`. The tokens the workflow mints last 10 minutes. Change `NAMESPACE` and `REPO` to match step 1. ### 4. Connect Workers Builds In the Cloudflare dashboard, open **Workers & Pages**, select your Worker (or **Create application → Continue with Artifacts** for a new one), then **Settings → Builds → Connect**, and choose the Artifacts namespace and repository. To get Previews for branches, turn on **Builds for Preview branches** under **Settings → Builds**. Cloudflare's [Artifacts integration guide](https://developers.cloudflare.com/workers/ci-cd/builds/git-integration/artifacts-integration/) has the details. From then on, merging to `main` on Diffinery deploys the Worker through Workers Builds. ## Troubleshooting | Problem | Fix | |---|---| | `Authentication error [code: 10000]` from Wrangler | The API token is missing a permission, or doesn't include the Worker's account or zone. Edit the token in the Cloudflare dashboard. | | `wrangler preview` is an unknown command | The project's Wrangler is older than 4.135.0. Run `npm i -D wrangler@latest` and commit the lockfile. | | The preview job deploys, but no comment appears | The job needs `pull-requests: write` in its `permissions:`. | | Secrets are empty in the preview job | The secrets are environment secrets, which pull request runs never receive. Store the preview token as a repository secret. | | The mirror push fails with `403` | The Artifacts token is missing **Artifacts Edit**, or `NAMESPACE` and `REPO` don't name an existing repository. | ## Related - [Deployments and environments](https://diffinery.com/docs/actions/environments): approvals, branch rules and deployment history for production. - [Secrets and variables](https://diffinery.com/docs/actions/secrets-and-variables): which runs receive which secrets. - [Workflow permissions](https://diffinery.com/docs/actions/workflow-permissions): what `GITHUB_TOKEN` may do. - Cloudflare's [GitHub Actions guide](https://developers.cloudflare.com/workers/ci-cd/external-cicd/github-actions/) and [Previews examples](https://developers.cloudflare.com/workers/previews/examples/), which the workflow above follows. --- # OpenID Connect (OIDC) > Let workflows authenticate to AWS, Google Cloud and your own services with short-lived OIDC tokens issued by Diffinery, instead of stored keys. Source: https://diffinery.com/docs/actions/oidc A workflow job can ask Diffinery for an OpenID Connect ID token: a signed JWT that says which repository, branch, commit and workflow the job belongs to. A cloud provider or your own service that trusts Diffinery's issuer can exchange that token for short-lived credentials, so you don't store long-lived cloud keys as secrets. This works like GitHub Actions' OIDC, with Diffinery as the issuer; the differences are listed in [Differences from GitHub](#differences-from-github). ## Requirements - The job's effective permissions must include `id-token: write`. A job's own `permissions:` replaces the workflow's top-level `permissions:` entirely, as on GitHub, so a job that sets `permissions:` must list `id-token: write` itself. `permissions: write-all` includes it. - The repository's default permissions never include `id-token`, and neither does `read-all`. `id-token: read` grants nothing. - It works on hosted and self-hosted runners. - In a reusable workflow, the called jobs get the calling job's permissions, and anything the called workflow or its jobs declare is lowered to the calling job's permissions. So the calling job's effective permissions must include `id-token: write`, and so must the called workflow's or job's `permissions:` when it sets one. See [GITHUB_TOKEN permissions](https://diffinery.com/docs/actions/workflow-permissions#reusable-workflows). ```yaml permissions: id-token: write contents: read ``` This job gets no OIDC token, because its own `permissions:` replaces the workflow's: ```yaml permissions: id-token: write contents: read jobs: deploy: runs-on: ubuntu-latest permissions: contents: read # id-token is now none for this job steps: - run: echo "no ACTIONS_ID_TOKEN_REQUEST_URL here" ``` Without the permission, the variables below aren't set, and `core.getIDToken()` in an action fails because `ACTIONS_ID_TOKEN_REQUEST_URL` is missing. ## The issuer | Item | Value | |---|---| | Issuer (`iss`) | `https://diffinery.com` | | Discovery document | `https://diffinery.com/.well-known/openid-configuration` | | Signing keys (JWKS) | `https://diffinery.com/.well-known/jwks` | | Algorithm | `ES256` (ECDSA P-256 with SHA-256) | | Token lifetime | 10 minutes | The discovery document: ```json { "issuer": "https://diffinery.com", "jwks_uri": "https://diffinery.com/.well-known/jwks", "response_types_supported": ["id_token"], "subject_types_supported": ["public"], "id_token_signing_alg_values_supported": ["ES256"], "claims_supported": ["sub", "aud", "iss", "repository", "repository_id", "repository_owner", "repository_owner_id", "environment", "agent_origin", "ref", "ref_type", "sha", "workflow", "event_name", "actor", "actor_id", "run_id"] } ``` The JWKS document holds the public key as an EC JWK with `"use": "sig"`, `"alg": "ES256"` and a `kid`, which matches the `kid` in each token's header. ## Requesting a token When a job has `id-token: write`, the runner sets two environment variables, the same ones GitHub sets: | Variable | Value | |---|---| | `ACTIONS_ID_TOKEN_REQUEST_URL` | `https://diffinery.com/runner/jobs/<job id>/oidc?api-version=2.0` | | `ACTIONS_ID_TOKEN_REQUEST_TOKEN` | The job's token, to send as a bearer token | Actions that call `core.getIDToken(audience)` from `@actions/core`, such as `aws-actions/configure-aws-credentials` and `google-github-actions/auth`, use them automatically. From a shell step, append `&audience=<value>` and send a `GET` (or `POST`): ```yaml - name: Get an OIDC token run: | token=$(curl -fsS -H "Authorization: Bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \ "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=https://deploy.example.com" | jq -r .value) echo "::add-mask::$token" echo "OIDC_TOKEN=$token" >> "$GITHUB_ENV" ``` The response is `{"value": "<JWT>"}`. Without `audience`, the token's `aud` is `https://diffinery.com`. Mask the token with `::add-mask::` before doing anything else with it: it isn't a stored secret, so the runner doesn't know to hide it. Errors from the endpoint: | Status and message | Cause | |---|---| | `403 this job lacks permissions: id-token: write` | The job doesn't have the permission. | | `409 job is not running` | The job has finished, or hasn't started. | | `401 job token required` / `401 token does not match job` | The request didn't send this job's token. | | `501 OIDC is not configured on this Diffinery instance` | The instance has no signing key. A deploy creates one (`OIDC_PRIVATE_KEY`, see [Worker secrets](https://diffinery.com/docs/self-hosting/configuration#worker-secrets)); ask the instance's administrators. | ## Claims Every token has these claims. All values are strings except the timestamps. | Claim | Example | Meaning | |---|---|---| | `iss` | `https://diffinery.com` | Issuer. | | `aud` | `sts.amazonaws.com` | The requested audience, or `https://diffinery.com` if none was given. A single string. | | `sub` | `repo:acme/website:ref:refs/heads/main` | As on GitHub: `repo:<owner>/<repo>:environment:<name>` for a job with `environment:`, `repo:<owner>/<repo>:pull_request` for a `pull_request` run, and `repo:<owner>/<repo>:ref:<ref>` otherwise. | | `iat`, `nbf` | `1760000000` | Issue time and not-before time (the same). | | `exp` | `1760000600` | Expiry, 10 minutes after issue. | | `repository` | `acme/website` | Repository that runs the workflow. | | `repository_id` | `2918374650` | The repository's numeric id. It stays the same when the repository is renamed or transferred, and a new repository with an old name gets a new one. | | `repository_owner` | `acme` | Its owner (user or organization). | | `repository_owner_id` | `3482910375` | The owner's numeric id, which a new account with a reused name doesn't get. | | `environment` | `production` | The environment the job deployed to. Only on jobs with `environment:`, once its protection rules passed, and only when the job may also have the environment's secrets. | | `agent_origin` | `false` | Diffinery's own claim: `true` when the code the run executes came from an agent (an agent pushed it, or merged it). | | `ref_type` | `branch` | `branch` or `tag`. | | `head_ref`, `base_ref` | `feature`, `main` | The pull request's branches, on pull request runs only. | | `run_number`, `run_attempt` | `128`, `1` | The run's number and attempt. | | `ref` | `refs/heads/main` | The run's ref. See the table below. | | `sha` | `9fceb02d0ae598e95dc970b74767f19372d61af8` | The commit the run checked out. | | `workflow` | `Deploy` | The workflow's `name`, or its file path when it has none. | | `event_name` | `push` | The event that started the run. | | `run_id` | `128` | The run number, as a string. It's the same value as `github.run_id`, and stays the same when the run is re-run. | | `actor` | `diffy` | Who started the run. | | `actor_id` | `1938475620` | Their numeric id. | | `job_workflow_ref` | `acme/website/.github/workflows/deploy.yml@refs/heads/main` | The run's top-level workflow file and ref. | The token header is `{"alg": "ES256", "typ": "JWT", "kid": "..."}`. The `ref` for each event. It is also the end of `sub`, except in a job with `environment:` and in a `pull_request` run: | Event | `ref` | |---|---| | `push` | The pushed branch or tag, for example `refs/heads/main` or `refs/tags/v1.2.0` | | `pull_request` | `refs/pull/<number>/merge` | | `pull_request_target` | The base branch, for example `refs/heads/main` | | `merge_group` | The merge queue's temporary branch | | `workflow_dispatch` | The branch or tag it was started on | | `schedule`, `workflow_run`, `repository_dispatch` | The default branch | | `release` | The release's tag, for example `refs/tags/v1.4.0` | > **Important:** `pull_request_target` runs carry the base branch's `ref` and `sub`, and so do `schedule`, `workflow_run` and `repository_dispatch` runs on the default branch. A trust policy that only checks `sub` treats them like pushes to that branch. They run the base branch's own workflow files, so this is safe unless such a workflow checks out and runs pull request code. Where your provider lets you, also check `event_name`. ## Jobs that use an environment A job with `environment:` gets GitHub's environment form of `sub`, `repo:<owner>/<repo>:environment:<name>`, and an `environment` claim. The token is only issued after the environment's deployment branch policy has passed, so a trust policy on `repo:acme/website:environment:production` only trusts jobs that were allowed to deploy to `production` (see [Deployments and environments](https://diffinery.com/docs/actions/environments)). A job that the environment lets deploy but keeps its secrets from, such as agent code on a branch the policy doesn't trust agents with, gets the `ref:` or `pull_request` subject instead. To keep agent code out of a role altogether, also require `agent_origin` to be `false`. To keep trusting a reused name out, also check `repository_id` and `repository_owner_id` where your provider lets you: a repository deleted and recreated, or an owner name taken by a new account, gets new ids. ## AWS AWS accepts Diffinery tokens through an IAM OIDC identity provider and a role whose trust policy names it. 1. Create the identity provider (once per AWS account), with provider URL `https://diffinery.com` and audience `sts.amazonaws.com`: ```sh aws iam create-open-id-connect-provider \ --url https://diffinery.com \ --client-id-list sts.amazonaws.com ``` 2. Create a role with this trust policy. Replace the account id, and narrow `sub` to the repositories and branches that may deploy: ```json { "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Principal": { "Federated": "arn:aws:iam::123456789012:oidc-provider/diffinery.com" }, "Action": "sts:AssumeRoleWithWebIdentity", "Condition": { "StringEquals": { "diffinery.com:aud": "sts.amazonaws.com", "diffinery.com:sub": "repo:acme/website:ref:refs/heads/main" } } } ] } ``` To allow every branch and tag of the repository, use `"StringLike": { "diffinery.com:sub": "repo:acme/website:*" }` together with the `aud` check. That pattern also matches `pull_request` runs and jobs in any environment, so a role that deploys should name one `ref:` or `environment:` subject instead. Attach the permissions the workflow needs to the role. 3. Use it from a workflow: ```yaml name: Deploy to AWS on: push: branches: [main] permissions: id-token: write contents: read jobs: deploy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: aws-actions/configure-aws-credentials@v4 with: role-to-assume: arn:aws:iam::123456789012:role/diffinery-website-deploy aws-region: eu-west-1 - run: pip install awscli # the AWS CLI isn't preinstalled on hosted runners - run: aws sts get-caller-identity - run: aws s3 sync ./dist s3://acme-website --delete ``` Without the action, exchange the token yourself: ```sh token=$(curl -fsS -H "Authorization: Bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \ "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=sts.amazonaws.com" | jq -r .value) aws sts assume-role-with-web-identity \ --role-arn arn:aws:iam::123456789012:role/diffinery-website-deploy \ --role-session-name "diffinery-$GITHUB_RUN_ID" \ --web-identity-token "$token" ``` ## Google Cloud Google Cloud accepts Diffinery tokens through Workload Identity Federation. 1. Create a pool and an OIDC provider for the Diffinery issuer. The attribute condition limits which tokens are accepted at all: ```sh gcloud iam workload-identity-pools create diffinery \ --location=global --display-name="Diffinery" gcloud iam workload-identity-pools providers create-oidc diffinery \ --location=global \ --workload-identity-pool=diffinery \ --issuer-uri=https://diffinery.com \ --attribute-mapping="google.subject=assertion.sub,attribute.repository=assertion.repository,attribute.ref=assertion.ref,attribute.event_name=assertion.event_name" \ --attribute-condition="assertion.repository_owner == 'acme'" ``` 2. Let one repository impersonate a service account (use your project number): ```sh gcloud iam service-accounts add-iam-policy-binding deployer@acme-prod.iam.gserviceaccount.com \ --role=roles/iam.workloadIdentityUser \ --member="principalSet://iam.googleapis.com/projects/123456789012/locations/global/workloadIdentityPools/diffinery/attribute.repository/acme/website" ``` 3. Use it from a workflow: ```yaml permissions: id-token: write contents: read jobs: deploy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: google-github-actions/auth@v2 with: workload_identity_provider: projects/123456789012/locations/global/workloadIdentityPools/diffinery/providers/diffinery service_account: deployer@acme-prod.iam.gserviceaccount.com - uses: google-github-actions/setup-gcloud@v2 - run: gcloud run deploy website --source . --region europe-west1 ``` ## Your own service on Cloudflare Workers OIDC tokens also let a workflow call a service you run, such as a deploy hook on Cloudflare Workers, without a shared secret. The Worker verifies the token against Diffinery's published keys: ```ts import { createRemoteJWKSet, jwtVerify } from "jose"; const JWKS = createRemoteJWKSet(new URL("https://diffinery.com/.well-known/jwks")); export default { async fetch(request: Request): Promise<Response> { const token = request.headers.get("authorization")?.replace(/^Bearer\s+/i, ""); if (!token) return new Response("missing token", { status: 401 }); try { const { payload } = await jwtVerify(token, JWKS, { issuer: "https://diffinery.com", audience: "https://deploy.acme.workers.dev", algorithms: ["ES256"], }); if (payload.repository !== "acme/website" || payload.ref !== "refs/heads/main" || payload.event_name !== "push") { return new Response("not allowed", { status: 403 }); } // ... do the deploy for payload.sha ... return Response.json({ ok: true, sha: payload.sha }); } catch { return new Response("invalid token", { status: 401 }); } }, }; ``` The workflow requests a token with the same audience and sends it: ```yaml permissions: id-token: write jobs: deploy: runs-on: ubuntu-latest steps: - run: | token=$(curl -fsS -H "Authorization: Bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \ "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=https://deploy.acme.workers.dev" | jq -r .value) echo "::add-mask::$token" curl -fsS -X POST https://deploy.acme.workers.dev/ -H "Authorization: Bearer $token" ``` ## Verifying tokens yourself Any service can verify a Diffinery token. Check, in this order: 1. The header's `alg` is `ES256`. Reject anything else. 2. The signature, with the key from `https://diffinery.com/.well-known/jwks` whose `kid` matches the header. Cache the key set, and fetch it again when you see an unknown `kid`. 3. `iss` is exactly `https://diffinery.com`. 4. `aud` is the audience your service expects. Have workflows request an audience unique to your service, so a token meant for another service can't be replayed to yours. 5. `exp` is in the future and `nbf` is in the past (allow a little clock skew). 6. Your authorization rules: `repository`, `ref`, `event_name`, and if needed `sha` or `actor`. Match `repository` exactly, not by prefix. In Node.js with [jose](https://github.com/panva/jose), `jwtVerify` with `issuer`, `audience` and `algorithms: ["ES256"]` performs steps 1 to 5 (see the Worker example above). In Python with PyJWT: ```text pip install "pyjwt[crypto]" ``` ```text import jwt jwks = jwt.PyJWKClient("https://diffinery.com/.well-known/jwks") def verify(token: str) -> dict: key = jwks.get_signing_key_from_jwt(token).key claims = jwt.decode( token, key, algorithms=["ES256"], audience="https://deploy.example.com", issuer="https://diffinery.com", ) if claims["repository"] != "acme/website" or claims["ref"] != "refs/heads/main": raise PermissionError("not allowed") return claims ``` ## Differences from GitHub | Area | GitHub | Diffinery | |---|---|---| | Issuer | `https://token.actions.githubusercontent.com` | `https://diffinery.com` | | Algorithm | RS256 | ES256 | | `sub` | `environment:<name>`, `pull_request` or `ref:<ref>` form, and a customizable template | The same three forms; the template can't be customized | | Claims | Many, including `workflow_ref`, `job_workflow_sha`, `runner_environment` | Only the claims listed in [Claims](#claims) | | `job_workflow_ref` | The reusable workflow's file when the job is in one | Always the run's top-level workflow | | Token lifetime | About 5 to 10 minutes | 10 minutes | When you port a trust policy from GitHub, change the issuer host (`token.actions.githubusercontent.com` to `diffinery.com`) in provider URLs and condition keys, and keep your `sub` patterns as they are, unless they rely on a customized subject template. ## Troubleshooting | Problem | Fix | |---|---| | `Unable to get ACTIONS_ID_TOKEN_REQUEST_URL env variable` from an action | The job's effective permissions lack `id-token: write`. Add it to the job's `permissions:` if the job has one, otherwise to the workflow's. In a reusable workflow, the calling job needs it too. See [GITHUB_TOKEN permissions](https://diffinery.com/docs/actions/workflow-permissions). | | AWS: `Not authorized to perform sts:AssumeRoleWithWebIdentity` | The trust policy's `sub` or `aud` doesn't match. Decode the token (its middle part is base64url JSON) and compare `sub` and `aud` with the policy. | | AWS: `No OpenIDConnect provider found in your account for https://diffinery.com` | Create the identity provider with URL `https://diffinery.com`. | | Google Cloud: `The given credential is rejected by the attribute condition` | The `attribute-condition` on the provider doesn't match the token's claims. | | Your service rejects the token as expired | Tokens last 10 minutes. Request a new one right before you use it. | --- # Caching and artifacts > Speed up workflows with actions/cache, pass files between jobs and runs with upload-artifact and download-artifact, and manage both through the API. Source: https://diffinery.com/docs/actions/caching-and-artifacts A cache keeps files such as dependency downloads between runs. An artifact keeps files produced by a run, so later jobs (in the same run or another one) can use them and you can download them afterwards. Diffinery serves the same cache and artifact services as GitHub, so the official `actions/cache`, `actions/upload-artifact` and `actions/download-artifact` work unmodified, and the runner also has faster built-in versions that share their storage and formats. ## Built-in actions and the official actions For speed, the runner runs its own implementation of these actions instead of downloading them: | Action | Built-in for | What it does | | --- | --- | --- | | `actions/cache` | `v3` to `v6` | Restores a cache entry, then saves one in a post step when the job ends | | `actions/cache/restore` | `v3` to `v6` | Restores a cache entry only | | `actions/cache/save` | `v3` to `v6` | Saves a cache entry only | | `actions/upload-artifact` | `v4` to `v7` | Uploads files as an artifact of the run | | `actions/download-artifact` | `v4` to `v8` | Downloads artifacts of this run, or of another run with `run-id` and `github-token` | The built-ins use the same services, archive formats, cache versions and artifact ids as the official actions. An entry saved by the built-in `actions/cache` restores with the official action and the other way round, and an artifact uploaded by either downloads with either. A built-in steps aside and the real action is downloaded from GitHub and run when: - the reference isn't a version tag in the range above (`vN`, `vN.N` or `vN.N.N`), for example a commit SHA, a branch, or a newer major version; - the step passes an input the built-in doesn't know; - the step's environment has `DIFFINERY_BUILTINS` set to `false`, `0`, `no` or `off` (in the workflow's, the job's or the step's `env:`); - a self-hosted runner was started with `DIFFINERY_DISABLE_BUILTINS=1`. ```yaml env: DIFFINERY_BUILTINS: false # every step in this workflow runs the real actions ``` The `cache` input of `actions/setup-node`, `actions/setup-python` and `actions/setup-go` uses the same service; see [Dependency caching with setup actions](#dependency-caching-with-setup-actions). Third-party actions that use the `@actions/cache` or `@actions/artifact` toolkit packages, and `docker/build-push-action` with `cache-from: type=gha` and `cache-to: type=gha`, work as well (see [The runtime services](#the-runtime-services)). ## Caching dependencies A cache entry is a compressed archive of one or more paths, stored under a key you choose. A typical workflow restores the entry at the start of a job and saves it at the end: ```yaml name: CI on: [push, pull_request] jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/cache@v4 id: npm-cache with: path: ~/.npm key: npm-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }} restore-keys: | npm-${{ runner.os }}- - run: npm ci - run: npm test ``` On the first run there is no entry, so the step logs `Cache not found for input keys: ...` and the job continues. When the job succeeds, the post step **Post actions/cache@v4** archives `~/.npm` and saves it under the key (`Cache saved with key: <key>`). The next run with the same `package-lock.json` restores it (`Cache restored from key: <key>`). ### Inputs for actions/cache | Input | Required | Default | Description | | --- | --- | --- | --- | | `path` | Yes | | Files and directories to cache, one per line. Supports globs and `!` exclusions (`! pattern` is read as `!pattern`). Relative paths are relative to the workspace; `~/` expands to the home directory. | | `key` | Yes | | Key to save the entry under and to look up first. At most 512 characters, no commas. | | `restore-keys` | No | | Prefixes to try, one per line, in order, when `key` has no exact match. At most 20. | | `lookup-only` | No | `false` | Check whether an entry exists without downloading it. Logs `Cache found and can be restored from key: <key>`. | | `fail-on-cache-miss` | No | `false` | Fail the step when neither `key` nor any restore key matches. | | `save-always` | No | `false` | Save in the post step even when the job failed or was cancelled. | | `upload-chunk-size`, `enableCrossOsArchive` | No | | Accepted. The built-in uploads in 16 MiB blocks, 8 at a time (8 MiB blocks, 4 at a time, on the 1 GiB `tiny` runner), whatever `upload-chunk-size` says. | `actions/cache/restore` and `actions/cache/save` take the same inputs. If `key` or `path` is missing, the step fails with `Input required and not supplied: key` or `Input required and not supplied: path`. ### Outputs for actions/cache | Output | Set by | Value | | --- | --- | --- | | `cache-hit` | `actions/cache`, `actions/cache/restore` | `true` when the restored entry's key equals `key` (ignoring case), `false` when a restore key matched. Not set on a miss. | | `cache-primary-key` | `actions/cache/restore` | The `key` input, always. | | `cache-matched-key` | `actions/cache/restore` | The key of the entry that was restored. Not set on a miss. | Use `cache-hit` to skip work when the exact entry was restored. Compare with `'true'`, because the output is empty on a miss: ```yaml - uses: actions/cache@v4 id: deps with: path: node_modules key: modules-${{ hashFiles('package-lock.json') }} - if: steps.deps.outputs.cache-hit != 'true' run: npm ci ``` ### How keys and restore keys match An entry has a key, a version and a scope. The version is a hash of the `path` lines and the compression, computed the way `@actions/cache` computes it, so an entry only restores for the same paths saved with the same compression. The scope is the ref of the job that saved it. When a restore step runs, Diffinery searches these scopes in order, and stops at the first match: 1. The job's own ref, for example `refs/heads/feature-x`, `refs/tags/v1.2.0` or, for a `pull_request` run, `refs/pull/7/merge`. 2. For pull request runs, the pull request's base branch. For merge queue runs, the queue's target branch (`merge_group.base_ref`). 3. The default branch. In each scope it tries: 1. An entry whose key equals `key` exactly. 2. The newest entry whose key starts with `key`. 3. For each line of `restore-keys`, in order, the newest entry whose key starts with that line. ```yaml key: build-${{ github.ref_name }}-${{ hashFiles('**/go.sum') }} restore-keys: | build-${{ github.ref_name }}- build- ``` With this configuration, a run on a feature branch first looks for the exact key and the newest `build-` entry saved on that branch, then on the default branch. ### Scope A job saves only to its own ref. Branches, tags and pull requests read their own entries and those of the default branch (and pull requests those of their base branch), but never each other's. This is the same isolation GitHub has, so a pull request can't plant a cache entry that a release job on the default branch later restores. Different repositories never share entries, even within one organization. ### When the cache is saved `actions/cache` saves in a post step that runs after all other steps of the job: - It runs only if the job succeeded so far. With `save-always: true` it runs even after a failure or cancellation. - It's skipped when the restore found an exact match for `key`: `Cache hit occurred on the primary key <key>, not saving cache.` - It archives whatever exists at `path` at that moment. If none of the paths exist, it logs the warning `Path Validation Error: Path(s) specified in the action for caching do(es) not exist, hence no cache is being saved.` Entries are immutable. If an entry with the same key and version already exists in the job's scope, or another running job is saving it, the save logs `Failed to save: Unable to reserve cache with key <key>, another job may be creating this cache.` and the job continues. To refresh an entry, change its key, for example by including a hash of the lock file. An upload left unfinished by a job that has ended is discarded and the key can be saved again. `actions/cache/save` saves immediately when its step runs, so you can save a cache mid-job or from a step with its own `if:`: ```yaml - uses: actions/cache/restore@v4 id: restore with: path: .build-cache key: build-${{ github.sha }} restore-keys: build- - run: make build - uses: actions/cache/save@v4 if: always() with: path: .build-cache key: ${{ steps.restore.outputs.cache-primary-key }} ``` ### Archive format The built-in creates the same archive as `@actions/cache`: a POSIX tar file of the matched paths, stored relative to the workspace, compressed with zstd when `zstd` is installed (it is on hosted runners) and with gzip otherwise. Restoring extracts it to the same paths relative to the workspace. Restoring is built for speed. The runner downloads the archive in 8 MiB ranges, 16 at a time (4 on the 1 GiB `tiny` runner), and pipes them straight into `tar`, so the archive extracts while it is still arriving. On hosted runners a 216 MB pnpm store restores in about 3 to 5 seconds. Every range must come from the same stored entry; if one doesn't, or a range keeps failing after its retries, the log says `Streaming the cache entry failed (<reason>); downloading it in full and extracting it again.` and the runner downloads the whole archive to a file and extracts it again, which replaces anything the stream left half done. If that fails too, the restore removes what it had extracted and behaves like a miss, so a job never runs with half a cache. Artifact downloads use the same ranged download. Saving is built for speed too. Files of up to 32 MiB go up in one request; larger ones go up in 16 MiB blocks, 8 at a time (8 MiB blocks, 4 at a time, on the `tiny` runner). Diffinery then joins the blocks into one stored object. Objects over 16 MiB are joined as 8 MiB parts, copied by several workers at once, so a 200 MB artifact or cache entry is stored within seconds of its last block. The same applies to uploads from the official actions. - The compression is part of the version, so an entry saved on a runner with zstd doesn't restore on one without it. - A path outside the workspace, such as `~/.npm`, is stored relative to the workspace too. A runner whose workspace sits at a different depth restores it into a different directory. Include `runner.environment` or a label in the key if you mix runner types. - The step log shows the archive size as `Cache Size: ~<n> MB (<bytes> B)` on both save and restore. ### Size and eviction | Limit | Value | | --- | --- | | Key | 512 characters, no commas. A longer key or a comma fails the restore step with `Key Validation Error: <key> cannot be larger than 512 characters.` or `Key Validation Error: <key> cannot contain commas.` | | Restore keys | 20 per lookup | | One entry | 10 GB. A larger archive isn't saved: `Cache size of ~<n> MB (<bytes> B) is over the 10GB limit, not saving cache.` | | Repository total | 10 GB of entries, counting uploads in progress | | One upload request | 256 MiB. The built-ins and the official actions upload large archives in smaller blocks, so this only matters for your own clients. | Diffinery deletes entries in two ways: - Entries that no job has restored for 7 days are deleted the next time a job saves a cache in the repository. - When a save needs room above the 10 GB total, the least recently used entries are deleted until it fits. Restoring an entry counts as using it. If uploads still in progress use the whole budget, the save is refused (`uploads in progress use the repository's whole cache budget`) and the job continues with a warning. ### Manage caches with the API List, inspect and delete cache entries with the REST API. Reading needs read access to the repository; deleting needs write access. For a job's `GITHUB_TOKEN`, these endpoints need the `actions` permission. List entries: ```http GET /api/v3/repos/acme/website/actions/caches?key=npm-&ref=refs/heads/main Authorization: Bearer dfy_... ``` ```json { "total_count": 1, "actions_caches": [ { "id": 42, "ref": "refs/heads/main", "key": "npm-Linux-3f1c...", "version": "a1b2c3...", "last_accessed_at": "2026-10-12T09:41:07Z", "created_at": "2026-10-10T08:02:51Z", "size_in_bytes": 48211301 } ] } ``` | Query parameter | Meaning | | --- | --- | | `key` | Only entries whose key starts with this | | `ref` | Only entries saved for this ref, for example `refs/heads/main` or `refs/pull/7/merge` | | `sort` | `last_accessed_at` (default), `created_at` or `size_in_bytes` | | `direction` | `desc` (default) or `asc` | | `per_page`, `page` | Paging; `per_page` defaults to 30, at most 100 | Other endpoints: | Request | Result | | --- | --- | | `GET /repos/{owner}/{repo}/actions/cache/usage` | `{"full_name": "acme/website", "active_caches_size_in_bytes": ..., "active_caches_count": ...}` | | `DELETE /repos/{owner}/{repo}/actions/caches?key=<key>&ref=<ref>` | Deletes the entries with exactly this key (in every scope, or only `ref`'s). Returns `{"total_count": n, "actions_caches": [...]}` with the deleted entries. `422 key is required` without `key`; `404 Not Found` when nothing matches. | | `DELETE /repos/{owner}/{repo}/actions/caches/{cache_id}` | Deletes one entry. `204 No Content`, or `404 Not Found`. | ```sh diffinery api GET "/repos/acme/website/actions/caches?key=npm-" diffinery api DELETE "/repos/acme/website/actions/caches?key=npm-Linux-3f1c..." ``` Deletions are recorded in the [audit log](https://diffinery.com/docs/security/audit-log) as `actions.cache.delete`. ### Errors never fail the job Cache service problems are reported as warnings and the job carries on, as on GitHub: - `Failed to restore: <reason>` on restore. The step behaves like a miss, and nothing the restore extracted before it failed is left behind. - `Failed to save: <reason>` on save. - `The cache service is not available to this job; skipping.` when the job has no runtime services. The exceptions are a key validation error (see the table above) and `fail-on-cache-miss: true`, which fails the step with `Failed to restore cache entry. Exiting as fail-on-cache-miss is set. Input key: <key>`. ## Dependency caching with setup actions `actions/setup-node`, `actions/setup-python` and `actions/setup-go` can restore and save a dependency cache for you. They use the same service, scopes and limits as `actions/cache`, and the same keys and paths as the official setup actions, so a cache saved by the built-in restores with the official action and the other way round. The save happens in a post step only when the job succeeds, the exact key wasn't restored, and the cache directory exists. | Action | Input | Cached directory | Files hashed by default | Key on a hosted runner | | --- | --- | --- | --- | --- | | `actions/setup-node` | `cache: npm`, `pnpm` or `yarn` | `npm config get cache`, `pnpm store path`, or the Yarn cache folder | The first of `package-lock.json`, `npm-shrinkwrap.json`, `yarn.lock` in the workspace root (npm); `pnpm-lock.yaml` (pnpm); `yarn.lock` (yarn) | `node-cache-Linux-x64-<manager>-<hash>` | | `actions/setup-python` | `cache: pip` | `pip cache dir` | `**/requirements.txt`, or `**/pyproject.toml` when none match | `setup-python-Linux-x64-<os version>-<os name>-python-<version>-pip-<hash>`, with the part before the hash as a restore key | | `actions/setup-python` | `cache: pipenv` | `~/.local/share/virtualenvs` | `**/Pipfile.lock` | `setup-python-Linux-x64-python-<version>-pipenv-<hash>` | | `actions/setup-go` | `cache` (on by default) | `GOMODCACHE` and `GOCACHE` | `go.sum` in the workspace root | `setup-go-Linux-x64-ubuntu24-go-<version>-<hash>` | All three accept `cache-dependency-path` to hash different files, one pattern per line, and set the `cache-hit` output (`true` or `false`). `actions/setup-node` also sets `cache-primary-key`. ```yaml - uses: actions/setup-node@v4 with: node-version: 22 cache: pnpm cache-dependency-path: apps/web/pnpm-lock.yaml ``` Details to be aware of: - `actions/setup-node@v5` and later turn on npm caching by themselves when `package.json` names npm in `packageManager` or `devEngines.packageManager`, and log `Detected npm as the package manager from package.json's packageManager field. ...`. Set `package-manager-cache: false` to turn it off. - If setup-node's lock file is missing, the step fails with `Dependencies lock file is not found in <workspace>. Supported file patterns: <files>`. If `cache-dependency-path` matches nothing, it fails with `Some specified paths were not resolved, unable to cache dependencies.` - If setup-python's patterns match nothing, the step fails with `No file in <workspace> matched to [<patterns>], make sure you have checked out the target repository`. - `actions/setup-go` doesn't fail without a `go.sum`: it logs the warning `Restore cache failed: Dependencies lock file is not found in <workspace>. Supported file patterns: go.sum` and continues. Set `cache: false` to turn caching off. - On a miss the log says `<manager> cache is not found`, for example `npm cache is not found`. - `cache` values the built-ins don't handle (for example `cache: poetry` in setup-python) run the official action instead. ## Artifacts An artifact is a named file, normally a zip archive, that belongs to a workflow run. Jobs in the same run download it with `actions/download-artifact`, other runs can download it with `run-id` and a token, and anyone with read access to the repository can download it from the run page or the API until it expires. ### Upload an artifact ```yaml - run: npm run build - uses: actions/upload-artifact@v4 id: upload with: name: site path: | dist/ !dist/**/*.map if-no-files-found: error retention-days: 14 ``` | Input | Required | Default | Description | | --- | --- | --- | --- | | `name` | No | `artifact` | Artifact name. It can't contain `"`, `:`, `<`, `>`, `\|`, `*`, `?`, `\`, `/`, carriage return or line feed. | | `path` | Yes | | Files, directories or globs to upload, one per line. Lines starting with `!` exclude matches, and lines starting with `#` are ignored. Relative paths are relative to the workspace. | | `if-no-files-found` | No | `warn` | `warn` logs a warning, `error` fails the step, `ignore` logs a plain message. | | `retention-days` | No | 90 | Days to keep the artifact, from 1 to 90. | | `compression-level` | No | `6` | Zip compression level, `0` (none) to `9` (best). Use `0` for files that are already compressed. | | `overwrite` | No | `false` | Replace an artifact with the same name in the run instead of failing. | | `include-hidden-files` | No | `false` | Include files and directories whose names start with `.`. | | `archive` | No | `true` | `false` uploads a single file as it is, without a zip archive. The artifact is then named after the file. | The step logs `With the provided path, there will be <n> file(s) uploaded`, then `Artifact <name> has been successfully uploaded! Final size is <n> bytes. Artifact ID is <id>`. ### Outputs of upload-artifact | Output | Value | | --- | --- | | `artifact-id` | The artifact's numeric id | | `artifact-digest` | The SHA-256 digest of the uploaded file, in hex | | `artifact-url` | `https://diffinery.com/acme/website/actions/runs/<run number>/artifacts/<id>`, a link that downloads the artifact | ### What goes into the archive The runner collects the matching files, then archives them relative to the deepest directory that contains all of them, as `actions/upload-artifact` does: | `path` | Files matched | Paths inside the artifact | | --- | --- | --- | | `dist/` | `dist/index.html`, `dist/assets/app.js` | `index.html`, `assets/app.js` | | `dist/index.html` | `dist/index.html` | `index.html` | | `dist/` and `reports/junit.xml` | `dist/index.html`, `reports/junit.xml` | `dist/index.html`, `reports/junit.xml` | Only files are uploaded; empty directories are dropped. Hidden files are left out unless `include-hidden-files: true`. The archive is a zip file. With `archive: false`, exactly one file may match; otherwise the step fails with `When 'archive' is set to false, only a single file can be uploaded. Found <n> files to upload.` The file is stored with a content type from its extension, for example `text/html` for `.html`. ### Download artifacts in a later job The job must run after the job that uploaded the artifact, so list that job in `needs`: ```yaml jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - run: npm ci && npm run build - uses: actions/upload-artifact@v4 with: name: site path: dist/ deploy: needs: build runs-on: ubuntu-latest steps: - uses: actions/download-artifact@v4 id: get-site with: name: site path: public - run: ls -R ${{ steps.get-site.outputs.download-path }} ``` | Input | Default | Description | | --- | --- | --- | | `name` | | Download only the artifact with this name. | | `artifact-ids` | | Download only these artifacts, by id, separated by commas. Can't be combined with `name`. | | `pattern` | | Download the artifacts whose names match this glob, for example `junit-*`. | | `path` | The workspace | Directory to extract into, relative to the workspace. It's created if missing. | | `merge-multiple` | `false` | Extract every selected artifact into `path` itself instead of one directory per artifact. | | `skip-decompress` | `false` | Save the zip file instead of extracting it. | | `digest-mismatch` | `error` for `v8` and later, `warn` before | What to do when a download doesn't match the artifact's digest: `ignore`, `info`, `warn` or `error`. | | `github-token`, `run-id`, `repository` | | Download from another run or repository; see below. | With no `name`, `artifact-ids` or `pattern`, every artifact of the run is downloaded. Where they go: - With `name`, or `merge-multiple: true`, files go straight into `path`. - Otherwise each artifact goes into `<path>/<artifact name>`. From `v5` on, a download that selects exactly one artifact goes straight into `path`. The step sets the output `download-path` to the absolute directory, and logs `Artifact '<name>' downloaded to <path>` for each artifact. ### Download from another run or repository Pass `run-id` and a token in `github-token` to read another run's artifacts through the REST API. Add `repository` for a run in another repository: ```yaml jobs: report: runs-on: ubuntu-latest permissions: actions: read steps: - uses: actions/download-artifact@v4 with: name: coverage run-id: ${{ github.event.workflow_run.id }} github-token: ${{ secrets.GITHUB_TOKEN }} ``` - `run-id` is a run number, such as `github.run_id` or `github.event.workflow_run.id`. It covers every attempt of that run. - The job's `GITHUB_TOKEN` can read another run of its own repository when the job has `actions: read` (or the repository default gives it). It can read artifacts of other **public** repositories on Diffinery, but not of other private ones; use a personal or agent token with read access for those. See [GITHUB_TOKEN permissions](https://diffinery.com/docs/actions/workflow-permissions). - `run-id` without `github-token` is ignored, and the current run is read. Without these inputs, `actions/download-artifact` reads artifacts from the job's own run only. Jobs of a [reusable workflow](https://diffinery.com/docs/actions/reusable-workflows) are part of the caller's run, so the caller and the called workflow can exchange artifacts. A re-run attempt is a separate run for this purpose: its jobs don't see the artifacts of earlier attempts without `run-id`, except those of the jobs a failed-job or single-job re-run kept from an earlier attempt (see [Re-run a run](https://diffinery.com/docs/actions/overview#re-run-a-run)), which belong to the new attempt too. ### Uploading the same name twice Artifact names are unique within a run. Uploading a name that already exists fails: ```text Failed to CreateArtifact: an artifact with the name 'site' already exists on the workflow run. Set 'overwrite: true' to replace it. ``` Set `overwrite: true` to delete the old artifact and upload the new one. In a matrix, give each job its own name: ```yaml strategy: matrix: node: [20, 22] steps: - run: npm test -- --reporter junit > junit.xml - uses: actions/upload-artifact@v4 with: name: junit-node-${{ matrix.node }} path: junit.xml ``` A later job can collect them all with `pattern: junit-*` and `merge-multiple: true`. ### Retention and expiry Each artifact keeps its `retention-days` from 1 to 90 days, 90 when you don't set one. A value above 90 is lowered with the warning `Retention days cannot be greater than the maximum allowed retention set within the repository. Using 90 instead.` When an artifact expires: - Its content is deleted, and it no longer counts toward the storage quota. - The run page no longer lists it, and download-artifact no longer finds it. - The REST API still lists it, with `"expired": true`, for another 90 days. Downloading it answers `410 Artifact has expired`. ### Storage quota By default a repository can store up to 10 GB of artifacts. Live artifacts and uploads in progress count; expired and deleted artifacts don't. An upload beyond the quota fails the step with a message that ends in: ```text the repository's artifact storage quota (10737418240 bytes) is used up; delete artifacts or wait for them to expire ``` Delete artifacts you no longer need with the API (see below). A job's unfinished uploads are removed when the job ends. An instance's administrators change the quota with `ARTIFACT_QUOTA_BYTES` (see [Configuration](https://diffinery.com/docs/self-hosting/configuration)). ## Download artifacts from the web Open the run at `https://diffinery.com/<owner>/<repo>/actions/runs/<run number>`. When the run has artifacts that haven't expired, an **Artifacts** box lists each one with its size and upload time. Select a name to download it as `<name>.zip`, or as the file itself for an artifact uploaded with `archive: false`. The download link is `https://diffinery.com/<owner>/<repo>/actions/runs/<run>/artifacts/<name or id>`, the same form as the `artifact-url` output. Extract a zip file with: ```sh unzip site.zip -d site ``` You need read access to the repository. For a public repository, anyone can download. Artifacts uploaded by older Diffinery runner versions download as `<name>.tar.gz`. ## Download artifacts with the API The REST API has GitHub's artifact endpoints. Reading needs read access to the repository; deleting needs write access. For a job's `GITHUB_TOKEN`, they need the `actions` permission. | Request | Result | | --- | --- | | `GET /repos/{owner}/{repo}/actions/artifacts` | Every artifact of the repository, newest first, expired ones included. Takes `name`, `per_page` (default 30, at most 100) and `page`. | | `GET /repos/{owner}/{repo}/actions/runs/{run_id}/artifacts` | The artifacts of one run, expired ones included. `run_id` is the run number (every attempt) or a run's id. Takes `name`, `per_page` and `page`. | | `GET /repos/{owner}/{repo}/actions/artifacts/{artifact_id}` | One artifact. | | `GET /repos/{owner}/{repo}/actions/artifacts/{artifact_id}/zip` | `302 Found` to a signed download URL valid for 10 minutes. `410 Artifact has expired` for an expired artifact. | | `DELETE /repos/{owner}/{repo}/actions/artifacts/{artifact_id}` | Deletes the artifact. `204 No Content`. Recorded in the [audit log](https://diffinery.com/docs/security/audit-log) as `actions.artifact.delete`. | Both lists send a `Link` header, so `octokit.paginate` works. Artifacts uploaded by older Diffinery runners are listed, fetched and deleted the same way and kept for 90 days; their download is a gzip-compressed tar file (`<name>.tar.gz`) rather than a zip. Unknown ids answer `404 Not Found`. An artifact looks like this: ```json { "id": 1187, "node_id": "MDg6QXJ0aWZhY3Q1187", "name": "site", "size_in_bytes": 482113, "url": "https://diffinery.com/api/v3/repos/acme/website/actions/artifacts/1187", "archive_download_url": "https://diffinery.com/api/v3/repos/acme/website/actions/artifacts/1187/zip", "expired": false, "digest": "sha256:9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08", "created_at": "2026-10-12T09:41:07Z", "updated_at": "2026-10-12T09:41:07Z", "expires_at": "2026-10-26T09:41:07Z", "workflow_run": { "id": 128, "repository_id": "...", "head_repository_id": "...", "head_branch": "main", "head_sha": "9fceb02d0ae598e95dc970b74767f19372d61af8" } } ``` `workflow_run.id` is the run number. Download with the CLI's API command or curl; curl follows the redirect with `-L`: ```sh curl -fsSL -H "Authorization: Bearer dfy_..." \ https://diffinery.com/api/v3/repos/acme/website/actions/artifacts/1187/zip -o site.zip ``` Install the CLI with `curl -fsSL https://diffinery.com/install | bash` and sign in with `diffinery login`, then list a run's artifacts: ```sh diffinery api GET /repos/acme/website/actions/runs/128/artifacts ``` Artifacts uploaded by older Diffinery runner versions are in these endpoints too, as `.tar.gz` downloads. ## The runtime services Diffinery gives each job the same runtime services GitHub does, through these environment variables: | Variable | Value | | --- | --- | | `ACTIONS_RUNTIME_TOKEN` | A token for this job's cache and artifact requests. It works only while the job runs and expires 30 minutes after the job's timeout. | | `ACTIONS_RESULTS_URL` | `https://diffinery.com/` | | `ACTIONS_CACHE_URL`, `ACTIONS_RUNTIME_URL` | `https://diffinery.com/_runtime/` | | `ACTIONS_CACHE_SERVICE_V2` | `true` | As on GitHub, they're given to JavaScript actions, Docker container actions and the built-ins, not to `run:` steps. So `docker buildx build --cache-to type=gha` in a `run:` step finds no cache service, while `docker/build-push-action`, a JavaScript action, runs the build with them. The services speak the protocols the `@actions/cache` and `@actions/artifact` toolkit packages use: cache service v2 and artifact service v4, the older cache REST API, and signed upload and download URLs. A signed download URL is valid for one hour. Requests from a job that has finished fail with `401 the job is not running`. For Docker layer caching you can use `type=gha` through `docker/build-push-action`, which stores the layers as cache entries under the same 10 GB budget, or keep the cache in the [container registry](https://diffinery.com/docs/packages/container-registry) with `type=registry`, for example `diffinery.com/acme/website:buildcache`. See [Docker in workflows](https://diffinery.com/docs/actions/docker). ## Differences from GitHub | Area | GitHub Actions | Diffinery | | --- | --- | --- | | How the actions run | Downloaded from GitHub | Built into the runner for the listed versions; the real action otherwise | | Cache scope, size and eviction | Per ref with fallbacks, 10 GB, 7-day idle eviction | The same | | Artifact storage | Counted against the account's storage | 10 GB per repository by default, then uploads are refused | | Artifact retention | Set per repository or organization | 1 to 90 days, 90 by default | | Web page for caches | Yes | No; use the API | | `artifact-url` | Points at github.com | Points at Diffinery | | `run-id` in download-artifact | The run's id | The run number (`github.run_id`), which is the same thing on Diffinery | ## Troubleshooting | Symptom | Cause and fix | | --- | --- | | `Cache not found for input keys: ...` on every run | The save never happened, or it's in a scope this run can't read. Check the post step's log: the job may have failed (use `save-always: true`), the paths may not exist. An entry saved on a feature branch isn't visible to other branches; save on the default branch for shared entries. | | `Failed to save: Unable to reserve cache with key <key>, another job may be creating this cache.` | An entry with this key already exists for this ref, or another job is saving it. Include something that changes in the key, such as `hashFiles(...)`, and use `restore-keys` to pick up the latest. | | `Key Validation Error: ... cannot contain commas.` | Remove commas from `key` and `restore-keys`. | | `cache-hit` is `false` although files were restored | A restore key matched, not `key`. That's expected. | | A cache saved by one runner doesn't restore on another | The version includes the paths and the compression. Use the same `path` lines, and make sure `zstd` is installed on both or neither. | | Restored files appear in an unexpected directory | Paths are stored relative to the workspace. A runner with a different workspace depth restores into other directories. Add the runner type to the key. | | `The artifact name is not valid: <name>. Contains the following character: ...` | Remove `/`, `\`, `:` and the other characters listed in [Upload an artifact](#upload-an-artifact). | | `No files were found with the provided path: ... No artifacts will be uploaded.` | The paths don't match any files, or only hidden files. Check the paths relative to the workspace, or set `include-hidden-files: true`. | | `Failed to CreateArtifact: an artifact with the name '<name>' already exists on the workflow run. ...` | Set `overwrite: true`, or include matrix values in the name. | | `... artifact storage quota ... is used up; delete artifacts or wait for them to expire` | Delete old artifacts with `DELETE /repos/{owner}/{repo}/actions/artifacts/{artifact_id}`, or use shorter `retention-days`. | | `Unable to download artifact(s): Artifact not found for name: <name>` | No job in this run has uploaded that name yet, or it expired. Add the uploading job to `needs`, and check the name's spelling and case. | | `Inputs 'name' and 'artifact-ids' cannot be used together. Please specify only one.` | Remove one of the two inputs. | | `Digest validation failed for artifact(s): ...` | The downloaded file doesn't match the digest recorded at upload. Download again; set `digest-mismatch: warn` to continue anyway. | | `Listing artifacts of run <n> in <repo> failed: HTTP 403` | The token in `github-token` lacks access. Give the job `permissions: actions: read`, or use a token that can read the other repository. | | `410 Artifact has expired` | The artifact passed its retention period. Its content is gone. | --- # Compatibility with GitHub Actions > What works, what works differently and what isn't supported when you run GitHub Actions workflows on Diffinery, with a checklist for porting workflows. Source: https://diffinery.com/docs/actions/compatibility Diffinery runs GitHub Actions workflow files with the same syntax, triggers, expressions and actions, so most workflows run without changes. This page lists every place where Diffinery behaves differently, what happens when a workflow uses something that isn't supported, and a checklist for porting workflows from GitHub. ## At a glance | Area | Status on Diffinery | Details | | --- | --- | --- | | Workflow syntax | Supported, with a few keys accepted but ignored | [Workflow syntax](#workflow-syntax) | | Triggers | `push`, `pull_request`, `pull_request_target`, `merge_group`, `workflow_dispatch`, `schedule`, `workflow_run`, `release`, `repository_dispatch`, `workflow_call` | [Triggers](#triggers) | | Expressions and contexts | Supported, with small differences in values | [Contexts and environment variables](#contexts-and-environment-variables) | | Hosted runners | Linux x64 only (Ubuntu 24.04) | [Runners](#runners) | | Self-hosted runners | Supported, through `diffinery runner` | [Runners](https://diffinery.com/docs/actions/runners) | | Matrices, `needs`, outputs, job summaries | Supported | [Jobs and steps](#jobs-and-steps) | | `actions/checkout`, `actions/setup-node`, `actions/setup-python`, `actions/setup-go` | Built into the runner for speed; the real action runs for versions or inputs the built-in doesn't cover | [Built-in actions](#built-in-actions) | | `actions/cache`, `actions/upload-artifact`, `actions/download-artifact` | Built into the runner, sharing storage and formats with the official actions, which also work | [Caching and artifacts](https://diffinery.com/docs/actions/caching-and-artifacts) | | `actions/github-script` | Supported (the real action runs) | [actions/github-script](#actionsgithub-script) | | Other JavaScript, composite and Docker actions | Supported, downloaded from GitHub, with the cache and artifact runtime services | [Other actions](#other-actions) | | Job containers and service containers | Supported | [Docker in workflows](https://diffinery.com/docs/actions/docker) | | Reusable workflows | Supported, except a matrix on the calling job | [Reusable workflows](https://diffinery.com/docs/actions/reusable-workflows) | | Secrets and variables | Repository, organization and environment secrets and variables | [Secrets and variables](https://diffinery.com/docs/actions/secrets-and-variables) | | Environments | Supported: branch policies, required reviewers (people), wait timers, environment secrets and variables, deployments. | [Secrets, variables and environments](#secrets-variables-and-environments) | | `GITHUB_TOKEN` | Follows `permissions:` per endpoint, as on GitHub; read-only by default in new repositories | [GITHUB_TOKEN and permissions](#github_token-and-permissions) | | OIDC | Supported, with Diffinery as the issuer | [OIDC](https://diffinery.com/docs/actions/oidc) | | Windows and macOS hosted runners | Not available (Cloudflare Containers run Linux only); use self-hosted runners | [Runners](#runners) | ## Workflow files Diffinery reads workflows from `.github/workflows/` and from `.diffinery/workflows/`. Every `.yml` or `.yaml` file directly in either directory is a workflow; files in subdirectories are ignored. Workflows in both directories run, each as its own workflow. Put workflows that should only run on Diffinery in `.diffinery/workflows/`, so a GitHub mirror of the repository doesn't run them. See [Where workflow files live](https://diffinery.com/docs/actions/overview#where-workflow-files-live). Check a workflow before you push it: ```sh diffinery workflow validate .github/workflows/ci.yml ``` Errors fail the file; warnings, such as `unknown job key '<key>'` or `unknown event '<name>'`, are printed but the workflow still runs. ## Triggers | Event | Status | Differences | | --- | --- | --- | | `push` | Supported | `commits` in the payload lists only the pushed head commit. Path filters pass when a push creates a branch. | | `pull_request` | Supported | Activity types `opened`, `synchronize`, `reopened`, `closed`, `edited`, `ready_for_review` and `converted_to_draft` only; the `edited` payload has no `changes` object. No runs while the pull request conflicts with its base. Runs get the repository's secrets, because pull requests come from branches of the same repository. | | `pull_request_target` | Supported | Same activity types as `pull_request`. Runs also while the pull request conflicts. | | `merge_group` | Supported | `checks_requested`. | | `workflow_dispatch` | Supported | `github.event.inputs` keeps the input types instead of turning them into strings. The dispatch API answers `201 Created` with the run instead of `204 No Content`. | | `schedule` | Supported | Checked every 5 minutes, so a schedule starts within about 5 minutes after it's due. Runs have the actor `diffinery-scheduler`. | | `workflow_run` | Supported | Activity types `requested` and `completed` (not `in_progress`), `workflows` is required, and chains go at most 3 levels deep. | | `release` | Supported | Same activity types. No runs for draft releases, as on GitHub. Without `types`, a new published release starts three runs (`created`, `published`, `released`), as on GitHub. Only `types` filters; `branches`, `tags` and `paths` aren't applied. | | `repository_dispatch` | Supported | `POST /repos/{owner}/{repo}/dispatches` answers `204`. The payload has `action`, `branch`, `client_payload`, `repository` and `sender`. | | `workflow_call` | Supported | See [Reusable workflows](https://diffinery.com/docs/actions/reusable-workflows). | | Every other GitHub event (`issues`, `issue_comment`, `pull_request_review`, `create`, `delete`, `status`, `deployment`, `deployment_status` and the rest) | Accepted, never fires | The workflow parses, but Diffinery never starts a run for these events. (Webhooks do get `status`, `deployment` and `deployment_status`.) | | A name that isn't a GitHub event | Warning | `unknown event '<name>'`. | Details for each event are in [Events that trigger workflows](https://diffinery.com/docs/actions/events). ## Workflow syntax Every key of the GitHub Actions workflow syntax parses. These keys behave differently: | Key | What happens on Diffinery | | --- | --- | | `jobs.<id>.runs-on` | Supported, including the `matrix` context (`runs-on: ${{ matrix.os }}`), but only Linux labels are hosted. The `group` key is ignored. | | `jobs.<id>.environment` | Supported: the branch policy is enforced, the environment's secrets and variables are added and a deployment is recorded. Required reviewers and wait timers hold the job before it starts, and `url` can't use step outputs. See [environment](https://diffinery.com/docs/actions/workflow-syntax#environment). | | `jobs.<id>.snapshot` | Accepted and ignored. | | `jobs.<id>.timeout-minutes` | Default and maximum 360. Larger values are capped at 360. | | `jobs.<id>.strategy.matrix` | At most 256 jobs per matrix, and at most 1,000 jobs in a run, matrix legs and the jobs of called workflows included. | | `jobs.<id>.uses` with `strategy.matrix` | Not supported yet. The run fails with `jobs.<id>: a matrix on a job that calls a reusable workflow is not supported yet`. | | `concurrency` (workflow level) | Same as GitHub: one run in progress and at most one waiting per group; a newer run cancels older waiting ones. After 6 hours of waiting a run starts anyway. See [Concurrency](https://diffinery.com/docs/actions/overview#concurrency). | | `defaults.run.shell`, `jobs.<id>.steps[*].shell` | `cmd` isn't supported. `pwsh` and `powershell` need PowerShell, which hosted runners don't have. | An unknown key gives a warning such as `unknown job key '<key>'` or `unknown step key '<key>'`, and the key is ignored. The full reference is [Workflow syntax](https://diffinery.com/docs/actions/workflow-syntax). ## Runners | `runs-on` | GitHub | Diffinery | | --- | --- | --- | | `ubuntu-latest`, `ubuntu-24.04`, `ubuntu-22.04` | Hosted Ubuntu VM | Hosted Ubuntu 24.04 container, x64. All three labels get the same image. | | `ubuntu-24.04-arm` and other ARM labels | Hosted ARM runner | No hosted ARM runners. An `ubuntu-*` ARM label runs on the same x64 image as `ubuntu-latest`. For ARM, use `[self-hosted, ...]` labels on an ARM machine. | | `windows-*`, `macos-*` | Hosted Windows or macOS VM | Not available: Cloudflare Containers run Linux only. The job fails with `No Diffinery runner matches runs-on: <labels>. Linux (ubuntu-*) runners are hosted; anything else needs runs-on: [self-hosted, ...] and a registered runner.` | | Larger runners and runner groups | Supported | Not available. `group` is ignored. | | `[self-hosted, ...]` | Self-hosted runner | Self-hosted runner started with `diffinery runner`, matched on every label. | What's different about the hosted image: - It's much smaller than GitHub's image. Git, Git LFS, curl, jq, zstd, build-essential, Python 3.12, Node.js 22 with npm, pnpm and yarn, nvm, Docker and the GitHub CLI (`gh`) are preinstalled. `ImageOS` is `ubuntu24`, as on GitHub's `ubuntu-24.04` image. Go, Java, Ruby, Rust, .NET, PowerShell, browsers and cloud CLIs aren't. See [The Ubuntu 24.04 image](https://diffinery.com/docs/actions/runners#the-ubuntu-2404-image). - Jobs run as `root`. `sudo` is installed and needs no password, as on GitHub, so `sudo` in commands keeps working. - The apt package lists are removed. Run `apt-get update` before `apt-get install`. - Every job starts in a fresh container. Nothing carries over between jobs except caches and artifacts. On self-hosted runners, `runner.os` is always `Linux`, and the setup actions download Linux builds, even on a macOS machine. See [Self-hosted runners](https://diffinery.com/docs/actions/runners#self-hosted-runners). ## Jobs and steps These work the same as on GitHub: `needs` (a job starts as soon as the jobs it needs finish), job outputs, `if` conditions, status check functions (a skipped or failed job further up the `needs` chain skips the jobs below it unless their `if` says otherwise), matrices with `include` and `exclude`, `fail-fast`, `max-parallel`, job and step `continue-on-error`, job-level `concurrency`, `timeout-minutes`, `working-directory`, environment files (`GITHUB_OUTPUT`, `GITHUB_ENV`, `GITHUB_PATH`, `GITHUB_STEP_SUMMARY`, `GITHUB_STATE`), workflow commands, job summaries, masking and debug logging with `ACTIONS_STEP_DEBUG` and `ACTIONS_RUNNER_DEBUG`. Differences: | Behaviour | GitHub | Diffinery | | --- | --- | --- | | Default shell for `run` | `bash -e {0}` | `bash --noprofile --norc -eo pipefail {0}`, so a failing command in a pipeline fails the step. | | `::add-matcher::` problem matchers | Turn matching output into annotations | Not supported; the line is printed as is. | | Annotations (`::error file=...`) | Shown on pull request files | Shown in the job log only. | | `::set-env` and `::add-path` | Disabled | Disabled, with the same message. | | `github.run_number` | Counts runs of one workflow | Counts runs of every workflow in the repository. | See [Expressions and contexts](https://diffinery.com/docs/actions/expressions) and [Workflow syntax](https://diffinery.com/docs/actions/workflow-syntax) for the details. ## Built-in actions For speed, the runner has its own implementation of these actions and runs it instead of downloading the action, when the step uses a version tag in the listed range: | Action | Built-in for | | --- | --- | | `actions/checkout` | `v2` to `v6` | | `actions/setup-node` | `v3` to `v7` | | `actions/setup-python`, `actions/setup-go` | `v4` to `v6` | | `actions/cache`, `actions/cache/restore`, `actions/cache/save` | `v3` to `v6` | | `actions/upload-artifact` | `v4` to `v7` | | `actions/download-artifact` | `v4` to `v8` | The built-ins mirror the official actions' inputs, outputs and storage: a cache or artifact saved by a built-in works with the official action and the other way round. A built-in steps aside, and the real action is downloaded from GitHub and run, when: - the reference isn't `vN`, `vN.N` or `vN.N.N` in the range, for example a commit SHA, a branch or a newer major version; - the step passes an input the built-in doesn't list (see the tables below); - an input has a value the built-in doesn't handle: `ssh-key` for checkout, an `architecture` other than the runner's, a `cache` value the built-in doesn't support, a PyPy or GraalPy version, or `update-environment: false` for setup-python; - the workflow, job or step `env:` sets `DIFFINERY_BUILTINS` to `false`, `0`, `no` or `off`; - a self-hosted runner was started with `DIFFINERY_DISABLE_BUILTINS=1`. ```yaml jobs: build: runs-on: ubuntu-latest env: DIFFINERY_BUILTINS: false # this job runs the real actions steps: - uses: actions/checkout@v4 ``` Built-in actions run on the runner, not inside a job container. In a job with `container:`, they work on the shared workspace. ### actions/checkout Checks out a repository on the same Diffinery instance into the workspace. | Input | Default | Notes | | --- | --- | --- | | `repository` | The workflow's repository | `owner/name` of a repository on Diffinery. Anything else fails with `Invalid repository '<value>'. Expected format {owner}/{repo}.` | | `ref` | The commit that triggered the run, or the other repository's default branch | Branch, tag, full ref or 40-character commit SHA. A short SHA is treated as a branch or tag name. | | `token` | The job's token | The job's token reads the workflow's own repository (with `contents: read`) and any public repository on Diffinery. For another private repository, pass a personal or agent token from a secret. The job's token is only ever sent to Diffinery; a different `github-server-url` needs an explicit `token`. | | `fetch-depth` | `1` | `0` fetches all branches and tags with full history. | | `fetch-tags` | `false` | | | `path` | The workspace | Relative to the workspace. A path outside it fails with `Repository path '<path>' is not under '<workspace>'`. | | `clean` | `true` | Runs `git clean -ffdx` and `git reset --hard HEAD` on an existing checkout. | | `filter` | | A partial clone filter such as `blob:none`, passed to `git fetch`. Diffinery's git server doesn't support partial clone yet and sends everything. | | `sparse-checkout` | | Patterns, one per line. Sets up a sparse checkout and fetches with `filter: blob:none` unless you set `filter`. | | `sparse-checkout-cone-mode` | `true` | `false` uses non-cone patterns. | | `lfs` | `false` | Fetches Git LFS objects for the checked-out commit (and runs `git lfs pull` in submodules). See [Git LFS](https://diffinery.com/docs/repositories/git-lfs). | | `submodules` | `false` | `true` or `recursive`. Submodules on Diffinery are fetched with the same credentials. A failed submodule update fails the step. | | `persist-credentials` | `true` | Writes the token into the repository's local Git config at the end, so later `git` commands authenticate. A post step removes it. With `false`, nothing is written. | | `show-progress` | `true` | Passes `--progress` to `git fetch` and `git checkout`. | | `set-safe-directory` | `true` | Marks the checkout as a Git safe directory for checkout's own commands. | | `github-server-url` | Diffinery | Check out from another server, for example `https://github.com`, with an explicit `token` for private repositories. | | `ssh-key` | | Not handled by the built-in: the real action runs. `ssh-known-hosts`, `ssh-strict` and `ssh-user` are accepted and ignored. | While fetching, the token reaches git through the environment, so a checkout that fails halfway leaves no credentials in `.git/config`. Credentials a reused workspace still holds from another job are removed first. Outputs: `commit` (the checked-out SHA) and `ref`. As on GitHub, a `pull_request` run checks out the test merge commit (`refs/pull/<number>/merge`) by default, and a `pull_request_target` run checks out the base branch. Errors: - `Failed to fetch ref '<ref>' from <owner>/<repo>. Check that the ref exists and the token has read access.` - `A branch or tag with the name '<ref>' could not be found` (with `fetch-depth: 0`). - `Checked out <sha> but expected <sha>.` ### actions/setup-node | Input | Notes | | --- | --- | | `node-version` | A version or range (`22`, `20.x`, `>=20.10`), `lts/*`, `lts/<codename>`, `lts/-1`, `latest`, `current` or `node`. | | `node-version-file` | `package.json` (reads `volta.node`, then `devEngines.runtime.version`, then `engines.node`), `.tool-versions` (`nodejs` or `node`), or a file whose first non-comment line is the version, such as `.nvmrc` or `.node-version`. | | `check-latest` | Download the newest matching version even when the preinstalled one satisfies the request. | | `registry-url`, `scope`, `always-auth` | Writes an `.npmrc` that reads `NODE_AUTH_TOKEN`, and sets `NPM_CONFIG_USERCONFIG`. For `npm.pkg.github.com` the scope defaults to the repository owner. `always-auth` is written for `v4` and earlier, or when you set it. | | `cache`, `cache-dependency-path` | `npm`, `pnpm` or `yarn`. See [Dependency caching with setup actions](https://diffinery.com/docs/actions/caching-and-artifacts#dependency-caching-with-setup-actions). | | `package-manager-cache` | `v5` and later cache npm automatically when `package.json` names npm as its package manager; `false` turns that off. | | `architecture`, `token` | Accepted. An `architecture` other than the runner's runs the real action. | Hosted runners have Node.js 22.22.0. A request that it satisfies uses it without a download; any other version is downloaded from `nodejs.org` into the tool cache. Without `node-version` or `node-version-file`, the step logs `No node-version specified; using the pre-installed node <version>.` Other inputs, such as `mirror`, run the real action. Outputs: `node-version` (for example `v22.22.0`), `cache-hit` and `cache-primary-key`. ### actions/setup-python | Input | Notes | | --- | --- | | `python-version` | One CPython version or range, such as `3.12`, `3.11.9`, `3.x` or `3.14-dev`. PyPy and GraalPy versions run the real action. | | `python-version-file` | `.python-version`, `.tool-versions` (`python`), `pyproject.toml` (`requires-python`), or a file whose first non-comment line is the version. Without either input, `.python-version` in the workspace is used if present. | | `allow-prereleases` | Falls back to a pre-release build when no stable release matches. A version ending in `-dev` does the same. | | `cache`, `cache-dependency-path` | `pip` or `pipenv`. Other values run the real action. See [Dependency caching with setup actions](https://diffinery.com/docs/actions/caching-and-artifacts#dependency-caching-with-setup-actions). | | `architecture`, `check-latest`, `token`, `update-environment` | Accepted. An `architecture` other than the runner's, or `update-environment: false`, runs the real action. | Hosted runners have Python 3.12. A request that it satisfies uses it. Other versions are installed from the `actions/python-versions` builds that GitHub publishes, and the step sets `pythonLocation`, `Python_ROOT_DIR`, `Python3_ROOT_DIR`, `PKG_CONFIG_PATH` and `LD_LIBRARY_PATH` like the original action. Other inputs, such as `freethreaded`, run the real action. Outputs: `python-version`, `python-path` and `cache-hit`. ### actions/setup-go | Input | Notes | | --- | --- | | `go-version` | A version or range, `stable` or `oldstable`. | | `go-version-file` | `go.mod` or `go.work` (`toolchain` line first, then `go`), `.tool-versions` (`golang` or `go`), or a file whose first line is the version. | | `check-latest` | Download the newest matching version. | | `cache`, `cache-dependency-path` | On by default. Without a `go.sum` in the workspace root, the step warns `Restore cache failed: ...` and continues. | | `architecture`, `token` | Accepted. An `architecture` other than the runner's runs the real action. | Go isn't preinstalled on hosted runners, so the step downloads it from `go.dev`. Without a version on a runner that has no Go, it fails with `No go-version specified and Go is not installed on this runner.` Outputs: `go-version` and `cache-hit`. ### actions/cache and artifacts `actions/cache`, `actions/cache/restore`, `actions/cache/save`, `actions/upload-artifact` and `actions/download-artifact` support the official actions' inputs and outputs, including `retention-days`, `overwrite`, `compression-level`, `archive`, downloading all artifacts, `pattern`, `merge-multiple`, `artifact-ids`, and `run-id` with `github-token`. Caches are scoped per ref as on GitHub, each repository keeps up to 10 GB of caches, and artifacts are zip files kept for up to 90 days. [Caching and artifacts](https://diffinery.com/docs/actions/caching-and-artifacts) has the details. ### actions/github-script `actions/github-script` isn't a built-in: the real action runs, and its `github` client talks to Diffinery's REST API (`GITHUB_API_URL`) with the job token. What it may do follows the job's `permissions:`: ```yaml jobs: label: runs-on: ubuntu-latest permissions: issues: write pull-requests: write steps: - uses: actions/github-script@v7 with: script: | await github.rest.issues.addLabels({ owner: context.repo.owner, repo: context.repo.repo, issue_number: context.issue.number, labels: ["needs-review"], }); ``` `github.graphql` fails: Diffinery's [GraphQL API](https://diffinery.com/docs/reference/graphql-api) answers personal and agent tokens, not a job's token. REST calls to endpoints Diffinery doesn't have get a JSON `404 Not Found`. See [the REST API reference](https://diffinery.com/docs/reference/rest-api) for the endpoints Diffinery has. ## Other actions Any other `uses: owner/repo[/path]@ref` (and a built-in action the built-in doesn't cover) is downloaded from GitHub (`codeload.github.com`, or with `git` from `github.com` when that endpoint can't be reached) when the step runs and executed according to its `action.yml` or `action.yaml`. ### JavaScript actions Actions with `runs.using: node12`, `node16`, `node20`, `node24` or any other `node<version>` run with the runner's own Node.js 22, whatever version they ask for. `pre`, `main`, `post`, `pre-if` and `post-if` are supported. One difference: on GitHub, `pre` scripts run at the start of the job, before any step. On Diffinery, an action's `pre` script runs immediately before its `main` script, as part of the same step. Post scripts run at the end of the job in reverse order, as on GitHub. ### Composite actions Composite actions (`runs.using: composite`) are supported, including `inputs`, `outputs`, `env`, `if`, `shell`, `working-directory` and nested `uses`. Composite actions nest at most 10 levels; deeper nesting fails with `Composite action nesting is too deep (max 10).` ### Docker container actions Actions with `runs.using: docker` are supported, with `runs.image` set to a `Dockerfile` in the action (built on the runner) or a `docker://` image, plus `args`, `entrypoint`, `env`, `pre-entrypoint` and `post-entrypoint`. A step can also run an image directly with `uses: docker://image`. Hosted runners have Docker; self-hosted runners need it installed. See [Docker container actions](https://diffinery.com/docs/actions/docker#docker-container-actions). ### Local actions `uses: ./path/to/action` reads the action from the workspace, so check out the repository first. Otherwise the step fails with `Can't find 'action.yml' or 'action.yaml' under '<dir>'. Did you forget to run actions/checkout before running your local action?` ### Actions stored on Diffinery `uses: acme/my-action@v1` always means the repository `acme/my-action` on GitHub, not on Diffinery. Actions in private GitHub repositories can't be used on hosted runners, because the download has no GitHub credentials. A [self-hosted runner](https://diffinery.com/docs/actions/runners#self-hosted-runners) can authenticate downloads with `DIFFINERY_GITHUB_TOKEN` in its environment. To use an action that lives in a Diffinery repository, check it out into a directory and use it as a local action: ```yaml - uses: actions/checkout@v4 with: repository: acme/my-action ref: v1 path: .actions/my-action token: ${{ secrets.ACTIONS_READ_TOKEN }} - uses: ./.actions/my-action ``` `ACTIONS_READ_TOKEN` is a personal or agent token with read access to `acme/my-action`, stored as a secret, because the job token can't read other private repositories. If `acme/my-action` is public, leave `token` out: the job token can read public repositories. ### Unsupported action runtimes Any other `runs.using` value fails the step with `Unsupported action runtime 'runs.using: <value>' in <action>`. A version or repository that doesn't exist on GitHub fails with `Unable to resolve action '<owner>/<repo>@<ref>', repository or version not found`. A reference without `@ref` fails with `Expected format {owner}/{repo}[/path]@ref. Actual '<value>'`. ### Actions that call the GitHub API Actions built on `@actions/github` or Octokit read `GITHUB_API_URL` and `GITHUB_SERVER_URL`, which point at Diffinery, and authenticate with the job token. Diffinery's REST API uses GitHub's paths, JSON shapes, `Link` header pagination and error format, so calls to endpoints Diffinery has work (see [REST API](https://diffinery.com/docs/reference/rest-api)), within the job's `permissions:` (see [GITHUB_TOKEN and permissions](#github_token-and-permissions)). Calls to the GraphQL API fail, because it doesn't accept a job's token yet. ### Actions that use the cache or artifact services Diffinery sets `ACTIONS_RUNTIME_TOKEN`, `ACTIONS_RESULTS_URL`, `ACTIONS_CACHE_URL`, `ACTIONS_RUNTIME_URL` and `ACTIONS_CACHE_SERVICE_V2` for JavaScript actions, Docker container actions and the built-ins (not for `run:` steps, as on GitHub). Actions that use the `@actions/cache` or `@actions/artifact` packages directly, and `docker/build-push-action` with `cache-from: type=gha` and `cache-to: type=gha`, store their caches and artifacts in Diffinery. See [The runtime services](https://diffinery.com/docs/actions/caching-and-artifacts#the-runtime-services). The official `actions/upload-artifact` and `actions/download-artifact` refuse to run on a server that looks like GitHub Enterprise Server, so the runner shows them `GITHUB_SERVER_URL=https://github.com`. Their API calls still go to Diffinery, and their `artifact-url` output is rewritten to point at Diffinery. ## Contexts and environment variables | Value | GitHub | Diffinery | | --- | --- | --- | | `github.server_url`, `GITHUB_SERVER_URL` | `https://github.com` | `https://diffinery.com` | | `github.api_url`, `GITHUB_API_URL` | `https://api.github.com` | `https://diffinery.com/api/v3` | | `github.run_id`, `GITHUB_RUN_ID` | A number, unique per run | A number: the run's number, the same as `github.run_number`. The API accepts it wherever it takes a run id. | | `github.repository_id` | A number | Diffinery's repository ID, a string | | `github.ref_protected` | `true` for protected branches | Always `false` | | `github.event.repository` | GitHub's repository object | The same object, with Diffinery's values; `id` is numeric and `private` and `visibility` reflect the repository | | `github.actor` for scheduled runs | The last person who edited the cron | `diffinery-scheduler` | | `github.token`, `secrets.GITHUB_TOKEN` | The job token | The job token, also available as `secrets.DIFFINERY_TOKEN` | | `github.actor_id`, `github.repository_owner_id`, `github.workflow_sha` | Set | Not set | | `runner.os`, `RUNNER_OS` | `Linux`, `Windows` or `macOS` | Always `Linux`, also on self-hosted macOS machines | | `runner.name`, `RUNNER_NAME` | The runner's name | `diffinery` | | `runner.environment`, `RUNNER_ENVIRONMENT` | `github-hosted` or `self-hosted` | `diffinery-hosted`, also on self-hosted runners | | `DIFFINERY` | Not set | `true` | `CI=true` and `GITHUB_ACTIONS=true` are set as on GitHub, so tools that detect GitHub Actions keep working. To run a step only on one forge, check the server URL: ```yaml - if: github.server_url == 'https://diffinery.com' run: echo "Running on Diffinery" ``` The complete list is in [Expressions and contexts](https://diffinery.com/docs/actions/expressions). ## GITHUB_TOKEN and permissions Every job gets a token, available as `secrets.GITHUB_TOKEN`, `secrets.DIFFINERY_TOKEN` and `github.token`. As on GitHub: - Its permissions come from the job's `permissions:`, else the workflow's, else the repository's default. `read-all`, `write-all`, `{}` and per-scope maps work as on GitHub, and a map sets every scope it doesn't list to `none`. - Each REST endpoint needs its scope (`contents`, `issues`, `pull-requests`, `statuses`, `checks`, `deployments`, `actions`, `packages` and so on). A request without it gets `403 Resource not accessible by integration (the workflow's GITHUB_TOKEN needs permissions: <scope>: <level>)`. - It never administers the repository, never reads or changes secrets and variables, and never creates or changes workflow files. Branch protection applies to it. - It can open and approve pull requests only when the repository allows it, and its approvals don't count toward required reviews. - It can read any public repository on Diffinery, but no other private repository. - What it does starts no new runs, except `workflow_dispatch`. - It stops working when the job completes. Differences: - New repositories default to read-only tokens (`contents` and `packages` read), as GitHub does for new repositories. Repositories created before this setting existed default to read and write. An admin changes it on the repository's **Settings → Actions** tab or with `PUT /repos/{owner}/{repo}/actions/permissions/workflow`. - A run whose code came from an agent is held to the agent rules, so its token can't push or merge to the default branch or protected branches. [GITHUB_TOKEN permissions](https://diffinery.com/docs/actions/workflow-permissions) has the full model, the scope each endpoint needs, and how to change the default. ## Secrets, variables and environments | Feature | Status | | --- | --- | | Repository secrets and variables | Supported | | Organization secrets | Supported (CLI and API) | | Organization variables | Supported, through the REST API. Every repository in the organization sees them (no `private` or `selected` visibility). See [Organization variables](https://diffinery.com/docs/actions/secrets-and-variables#organization-variables). | | Environments with deployment branch policies (protected branches only, or branch and tag patterns) | Supported, through the REST API. See [Environments](https://diffinery.com/docs/actions/environments). | | Environment secrets and variables | Supported, through the REST API. They win over repository and organization values with the same name. Pull request runs never get environment secrets, and runs started by agents get them only from environments limited to protected branches. | | Team reviewers and custom deployment protection rules | Not supported. Required reviewers are people. | | Setting secrets with a sealed-box `encrypted_value` | Not supported yet. Send the plain `value` over HTTPS; it's encrypted at rest. | | Secrets in `pull_request` runs | Provided, because every pull request comes from a branch of the same repository | | Changing secrets and variables with an agent's token | Refused: only people manage secrets and variables | | Secret names starting with `GITHUB_` | Rejected for repository secrets, as on GitHub | See [Secrets and variables](https://diffinery.com/docs/actions/secrets-and-variables). ## Reusable workflows and OIDC Reusable workflows work as on GitHub, with these limits: they must live on the same Diffinery instance, calls nest at most 4 levels deep, one run calls at most 20, and a calling job can't have a matrix yet. See [Differences from GitHub](https://diffinery.com/docs/actions/reusable-workflows#differences-from-github) in Reusable workflows. OIDC works with `permissions: id-token: write`, and tokens are issued by `https://diffinery.com`. Configure your cloud provider to trust that issuer instead of `https://token.actions.githubusercontent.com`. The claims differ in a few places; see [Differences from GitHub](https://diffinery.com/docs/actions/oidc#differences-from-github) in OIDC. ## Porting checklist Work through this list for each workflow you move from GitHub. Most workflows need only the first two items. 1. Run `diffinery workflow validate` on each file and fix any errors. Read the warnings for ignored keys. 2. Push and watch the first run with `diffinery run watch` or on the **Actions** tab. 3. Replace `runs-on: windows-*` and `macos-*` with Linux, or with `[self-hosted, ...]` labels and a [self-hosted runner](https://diffinery.com/docs/actions/runners#self-hosted-runners). This includes `matrix.os` values used in `runs-on`. 4. Add `apt-get update` before `apt-get install`. 5. Install tools the hosted image doesn't have (Go, Java, Ruby, Rust, PowerShell, cloud CLIs) with a setup action or `apt-get`. 6. Check what each job does with `GITHUB_TOKEN`. In a new repository the default token only reads, so give jobs that push, tag, comment, label or merge the `permissions:` they need, or have an admin set the repository default to write (see [GITHUB_TOKEN permissions](https://diffinery.com/docs/actions/workflow-permissions)). Jobs that change workflow files, or that open or approve pull requests without the repository allowing it, need a personal or agent token from a secret. 7. Give `actions/checkout` of another private repository a token from a secret. 8. Recreate your environments, their branch policies, secrets and variables through the REST API (see [Environments](https://diffinery.com/docs/actions/environments)), and set required reviewers by Diffinery login (GitHub's numeric ids and teams don't carry over). 9. Replace `github.graphql` calls in `actions/github-script` with REST calls. 10. Replace `ghcr.io` with your instance's host (`diffinery.com` here) in image names and logins; see [Container registry](https://diffinery.com/docs/packages/container-registry#migrating-from-ghcrio). 11. Replace hard-coded `github.com` and `api.github.com` URLs with `${{ github.server_url }}` and `${{ github.api_url }}`. 12. Update OIDC trust policies in your cloud accounts to the Diffinery issuer and subject format. 13. Move actions from private GitHub repositories into a Diffinery repository and use them as local actions. [Migrate from GitHub](https://diffinery.com/docs/migrate-from-github#workflows) covers moving the rest of the repository. ## Troubleshooting | Message | Cause and fix | | --- | --- | | `No Diffinery runner matches runs-on: <labels>. ...` | The labels aren't hosted Linux labels and don't include `self-hosted`. Use `ubuntu-latest`, or `[self-hosted, ...]` with a registered runner. For a matrix job, check the value `runs-on` got for that combination. | | `Error evaluating the job for this matrix combination: <message>` | An expression in `runs-on`, `name`, `timeout-minutes`, `continue-on-error` or `concurrency` failed for one matrix combination. | | `Resource not accessible by integration (the workflow's GITHUB_TOKEN needs permissions: ...)` | Add the scope to the job's `permissions:`. See [GITHUB_TOKEN permissions](https://diffinery.com/docs/actions/workflow-permissions). | | `refusing to allow a GitHub App to create or update workflow ...` | Job tokens never change workflow files. Use a personal or agent token from a secret. | | `GitHub Actions is not permitted to create pull requests. ...` | An admin allows it with `can_approve_pull_request_reviews`, and the job needs `pull-requests: write`. | | `Unable to resolve action '<owner>/<repo>@<ref>', repository or version not found` | The action or version doesn't exist on GitHub, or the repository is private. | | `Can't find 'action.yml' or 'action.yaml' under '<dir>'. ...` | Check out the repository before using a local action, and check the path. | | `Unsupported action runtime 'runs.using: <value>' in <action>` | The action uses a runtime other than `node*`, `composite` or `docker`. | | ``The `cmd` shell is not supported on Linux runners.`` | Use `bash` or `sh`. | | `Failed to fetch ref '<ref>' from <owner>/<repo>. Check that the ref exists and the token has read access.` | Checking out another private repository needs a token from a secret, the job lacks `contents: read`, or the ref doesn't exist. | | `No go-version specified and Go is not installed on this runner.` | Set `go-version` or `go-version-file`. | | `jobs.<id>: a matrix on a job that calls a reusable workflow is not supported yet` | Move the matrix into the called workflow, or write one calling job per value. | | `A matrix may produce at most 256 jobs.` | Split the matrix. | | `A run may have at most 1000 jobs, ...` | Split the workflow, or trim its matrices. | | `Composite action nesting is too deep (max 10).` | Flatten nested composite actions. | | A built-in action behaves differently from the real one | Run the real action: set `DIFFINERY_BUILTINS: false` in the step's `env:`, or pin the action to a commit SHA. | | A job is skipped although its `if:` looks true | A job further up its `needs` chain failed, was cancelled or was skipped, and the `if:` doesn't call a status function. Use `if: always() && ...` or `if: ${{ !cancelled() && !failure() }}`. |