# GitHub Actions compatibility reference

This page describes the GitHub Actions functionality supported by `buildkite-gha` v0.102.1. It's the production contract for the `hosted` profile used by `upload` and the [GitHub Actions Buildkite plugin](https://buildkite.com/resources/plugins/buildkite-plugins/github-actions-buildkite-plugin/). If a feature isn't listed on this page, treat it as unsupported. To set up a pipeline that runs GitHub Actions workflows, see [Run GitHub Actions workflows in Buildkite](/docs/pipelines/migration/run-github-actions-workflows).

`buildkite-gha` requires Buildkite agent v3.129 or newer.

Generated jobs run on Linux x86-64, Linux arm64, native macOS arm64, and [Windows Server 2022 x86-64](#windows-jobs). Linux arm64 jobs require an explicit queue. Windows jobs require a [compatible Windows queue](#windows-jobs-configure-a-windows-runner). The plugin's importer runs on Linux x86-64 or macOS arm64. A [custom importer](/docs/pipelines/migration/run-github-actions-workflows/cli#upload-from-a-custom-importer) can also run on Linux arm64. Runner labels select a platform. They don't promise GitHub image, toolchain, or Xcode parity. The runtime sets the matching `runner.os` and `runner.arch` values, and sets `runner.environment` to `self-hosted` on every platform.

Generated Linux jobs use a dedicated `runner` user and need `buildkite-gha` v0.13.7 or newer. Use `experimental-runner-user: false` temporarily if an image can't support the [privileged bootstrap](/docs/pipelines/migration/run-github-actions-workflows/cli#upload-from-a-custom-importer-run-linux-jobs-as-a-non-root-user).

The GitHub [workflow syntax reference](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax) describes the original syntax. This page describes the subset that runs on Buildkite Pipelines.

## Compatibility policy

`buildkite-gha` matches the exposed, reproducibly observed behavior of GitHub Actions, including surprising behavior. This applies to all compatibility surfaces, not just triggers. Documentation and specifications guide investigation, but they don't override reproducible observations without a documented intentional exception. Documentation silence isn't proof of rejection, and the `buildkite-gha` parser, tests, or previous implementation aren't a native oracle.

This page keeps the following distinctions explicit:

- **Observed behavior:** Records of observed GitHub behavior include the exact declarations, activities, date, method, execution context, and limits.
- **Intentional divergence:** A divergence requires a strong explicit rationale. For example, `pull_request_target` is deliberately excluded: its privileged base-repository execution model needs a separate trust boundary from ordinary pull request execution. Merely admitting the event could expose authority to untrusted contributions. Neither the runtime's supported-event set nor the webhook matcher in the backend admits it.
- **Not yet implemented:** A native capability without a Buildkite Pipelines implementation, such as `repository_dispatch`, is a gap, not a security exception or native rejection.
- **Unresolved:** Absent runs, null receipts, and untested combinations don't establish whether GitHub rejected, filtered, or never processed a workflow. This page preserves that uncertainty.

## Support matrix

| Status | Meaning |
| --- | --- |
| ✅ **Supported** | Available through the production plugin path. |
| 🟡 **Supported subset** | Available within the limits shown here. |
| ➖ **Accepted, no effect** | Validation accepts the syntax, but it doesn't change the build. |
| 🚧 **Not available in production** | The compiler or runtime supports it, but production upload blocks it. |
| ❌ **Unsupported** | Rejected or outside the compatibility contract. |
{: class="responsive-table"}

Looking for something else? [Browse open compatibility issues](https://github.com/buildkite/buildkite-gha/issues?q=is%3Aissue%20state%3Aopen%20label%3Acompatibility).

| Area | Status | Initial release boundary |
| --- | --- | --- |
| [Workflow and job names](#workflow-names-and-triggers) | 🟡 Supported subset | `name`, explicit `run-name`, and job names are retained. `run-name` supports expressions over `github`, `inputs`, and `vars`. |
| [Triggers and filters under `on`](#workflow-names-and-triggers) | 🟡 Supported subset | Buildkite Pipelines creates builds. Upload selects aggregate workflow groups for one effective event. `workflow_call` is supported for composition. |
| [Platforms](#job-syntax-job-configuration) | 🟡 Supported subset | Linux x86-64 labels have local presets. Explicit mappings can select a user-provided Linux arm64 or Windows x86-64 queue. The Agent API can map compatible selectors to Linux, native macOS arm64, or, when Windows routing is enabled, Windows x86-64 targets. Labels don't provide GitHub image, toolchain, or Xcode parity. |
| [Jobs and dependencies](#job-syntax-job-configuration) | ✅ Supported | Static dependencies, matrix fan-out and fan-in, results, and bounded outputs. |
| [Matrix strategies](#job-syntax-matrix-strategies) | 🟡 Supported subset | Static matrices, `include`, `exclude`, and literal `max-parallel`. Needs-derived matrices can also read their producer's parallel limit. Maximum 256 instances per job. `fail-fast` has no effect. |
| [Shell steps](#step-syntax-commands-and-actions) | 🟡 Supported subset | Linux and macOS `bash`, `sh`, `pwsh`, `powershell`, `python`, and custom shell templates. PowerShell and MSYS2 on Windows jobs. |
| [Conditions and expressions](#expressions-and-contexts) | 🟡 Supported subset | GitHub-compatible core operators and direct references to selected contexts. |
| [Reusable workflows](#workflow-syntax-reusable-workflows) | 🟡 Supported subset | Local, public, and approved private GitHub workflows with static or needs-derived typed inputs and direct job-output mappings. Local calls can inherit or explicitly map Buildkite secret authority. Private access requires a separate importer opt-in and existing Git access. |
| [Actions](#actions) | 🟡 Supported subset | Local and public JavaScript and composite actions on Linux, macOS, and Windows jobs. Verified Dockerfile and public prebuilt-image actions on Linux only. |
| [Checkout, artifacts, and cache](#actions) | 🟡 Supported subset | Only the audited versions and modes listed on this page. |
| [`GITHUB_TOKEN`](#repositories-credentials-and-github-services-github-token) | 🟡 Supported subset | One job-bound token for the event repository. Reusable-workflow jobs use the top-level workflow permissions. |
| [Other workflow secrets](#repositories-credentials-and-github-services-other-secrets-and-oidc) | 🟡 Supported subset | Static names in direct jobs and locally inherited or explicitly mapped reusable jobs resolve through the destination job's Buildkite secret authority. |
| [Job and service containers](#job-syntax-containers-and-services) | 🟡 Supported subset | Linux job containers and broadly compatible service definitions, including explicit registry credentials. |
| [Environments and snapshots](#job-syntax-deployment-environments) | 🟡 Supported subset | Literal environments on top-level jobs, with required-reviewer approval gates and environment-scoped secret names. Wait timers, branch policies, and custom rules are rejected. Snapshots are accepted with no effect. |
| [Variables](#job-syntax-repository-and-organization-variables) | 🟡 Supported subset | Repository, organization, and environment `vars` resolve inside a Buildkite job with the GitHub per-position scoping. |
| [OIDC](#repositories-credentials-and-github-services-other-secrets-and-oidc) | 🟡 Supported subset | Host JavaScript and composite actions can request Buildkite OIDC tokens in jobs with `id-token: write`. |
| [Windows jobs](#windows-jobs) | 🟡 Supported subset | Windows Server 2022 x86-64 through an explicit queue mapping or enabled automatic routing. No local Windows preset. |
| [Other platforms](#job-syntax-job-configuration) and [providers](#repositories-credentials-and-github-services-repositories) | ❌ Unsupported | Windows arm64, Windows Server 2025, macOS x86-64, GitHub Enterprise Server, and unlisted providers. |
| [Other GitHub services](#repositories-credentials-and-github-services-github-services) | ❌ Unsupported | No general emulation for Releases, Packages, Checks, deployments, or GitHub artifact APIs. |
{: class="responsive-table"}

## Windows jobs

Windows Server 2022 x86-64 jobs are supported within the limits on this page. As on every platform, compatibility gaps might exist. [Browse or report compatibility issues](https://github.com/buildkite/buildkite-gha/issues?q=is%3Aissue%20state%3Aopen%20label%3Acompatibility). [Buildkite hosted Windows queues](/docs/agent/buildkite-hosted/windows) need separate access. See [Configure a Windows runner](#windows-jobs-configure-a-windows-runner). Import workflows from a supported Linux or macOS importer. Windows agents run generated jobs only. `windows-latest` and `windows-2022` select Windows x86-64 when explicitly mapped to a queue or enabled through Agent API resolution. They have no local preset and are otherwise rejected, never silently redirected to Linux.

When Windows routing is enabled, the Agent API also maps `windows-2025` and standard Depot `depot-windows-2025` labels (including `-4`, `-8`, `-16`, `-32`, and `-64`) to the same `windows-medium` queue as `windows-latest`. It warns that the queue may run Windows Server 2022 instead of 2025, and that Depot hardware, caches, and networking aren't preserved. This fallback requires an existing eligible hosted Windows queue. It doesn't provision Depot runners or promise Server 2025 compatibility. These aliases have no local preset or explicit Windows mapping. Use Agent API resolution rather than a custom-label mapping.

Use a Windows Server 2022 queue with Buildkite agent v3.129 or newer, PowerShell 7 (`pwsh`), and Git on `PATH`. Runner labels don't install the GitHub runner image or its tools. `runner.os` is `Windows` and `runner.arch` is `X64`. See [explicit mappings and runtime distributions](/docs/pipelines/migration/run-github-actions-workflows/cli#upload-from-a-custom-importer-choose-runners-and-runtimes).

### Configure a Windows runner

Choose one route with your pipeline administrator:

- **Explicit mapping:** Map the workflow's `windows-latest` or `windows-2022` label to an existing Windows Server 2022 x86-64 queue in the importing job's cluster. The Agent API must validate that the queue exists and, for a hosted queue, matches `windows/amd64`. See the [plugin and CLI examples](/docs/pipelines/migration/run-github-actions-workflows/cli#upload-from-a-custom-importer-choose-runners-and-runtimes).
- **Automatic routing:** Ask the Buildkite Support team at [support@buildkite.com](mailto:support@buildkite.com) to confirm your organization's hosted Windows Medium access and Agent API routing availability. The importing job's cluster needs an active hosted Windows AMD64 **Medium** queue named `windows-medium`. A self-hosted, differently sized, wrong-platform, or other-cluster queue doesn't qualify, even if its name matches.

Neither route creates queues, grants hosted Windows access, or changes trust policy. Keep generated-job queues isolated from protected credentials and earlier jobs, including when using a self-hosted queue. An explicit mapping isn't a way to bypass queue validation. Don't change a Windows-only job to Linux to get past runner admission.

| Diagnostic | Action |
| --- | --- |
| No Windows runner target is configured | Configure one of the routes in the previous list. Offline validation has no Windows preset. This failure alone doesn't establish runtime incompatibility. |
| No hosted Windows queue (`missing_queue`) | Ask an administrator to check the importing job's cluster and all automatic-routing queue requirements, or explicitly map a compatible existing queue. |
| Job doesn't belong to a cluster (`no_cluster`) | Ask an administrator to move the pipeline to a cluster containing compatible queues. |
| Queue not found or platform mismatch | Correct the mapping to an active Windows queue in the importing job's cluster. A Linux queue can't run the Windows runtime. |
| Incompatible labels | Check the variant and use a single supported Windows label. For `windows-latest` or `windows-2022`, this can also mean hosted Windows access is unavailable. Ask the Buildkite Support team to check eligibility and routing rather than assuming Windows is wholly unsupported. |
| Windows variant has no local mapping | Use `windows-latest` or `windows-2022` only if the job can run on Server 2022 x86-64. The documented Windows 2025 aliases require Agent API resolution and preserve neither Server 2025 nor provider hardware. Native Windows arm64 remains unsupported. |
{: class="responsive-table"}

For support, include the build or job URL, workflow path, requested `runs-on`, importer version, and complete runner diagnostic. A generic rejection doesn't identify a missing entitlement, queue, or mapping by itself.

### Runtime boundaries

The default shell is `pwsh`. Explicit `pwsh` and Windows PowerShell (`powershell`) steps, JavaScript actions, and composite actions are supported within the same action restrictions documented on this page. Use UTF-8 for file commands. Windows PowerShell needs `Out-File -Encoding utf8 -Append` rather than its default UTF-16 redirection. [MSYS2 custom shells](#step-syntax-commands-and-actions) are supported. `cmd` shells, containers, services, Docker actions, custom images, and cache volumes aren't supported on Windows. Native Windows Server 2025 and arm64 execution aren't enabled by these mappings.

Windows environment overlays replace names case-insensitively but preserve the winning spelling, so `env: {toolchain: nightly}` remains `$toolchain` in Bash. `GITHUB_ENV` preserves the last assignment's spelling and value when names differ only by case. Runtime context variables remain protected from step overrides.

## How workflows run on Buildkite Pipelines

GitHub Actions combines run creation and workload definition in one file. Buildkite Pipelines keeps them separate.

| GitHub Actions concept | Buildkite Pipelines behavior |
| --- | --- |
| Workflow trigger | Buildkite integration, schedule, manual build, or API request |
| Workflow run | Existing Buildkite build |
| Job | Buildkite command job |
| Matrix entry | Buildkite command job |
| `needs` | `depends_on` with verified result transport |
| Step | Runs inside the job compatibility runtime |
{: class="responsive-table"}

No shadow GitHub Actions run is created. Pipelines owns scheduling, logs, retries, cancellation, and status.

Steps remain inside one job because they share a workspace, environment files, action state, and post-action cleanup.

### Aggregate workflow upload

The plugin accepts either one `workflow` path or a non-empty `workflows` array. GitHub Actions pipeline trigger builds can instead use an empty plugin configuration and the single workflow selected by Buildkite Pipelines. `BUILDKITE_GITHUB_WORKFLOW_PATH` marks the selection. When present, the plugin prefers the repository, path, and event ref from `GITHUB_WORKFLOW_REF`, the workflow name or fallback path from `GITHUB_WORKFLOW`, the event from `GITHUB_EVENT_NAME`, and the commit from `GITHUB_WORKFLOW_SHA`. These values must match the repository and checked-out workflow. The Buildkite-prefixed path and event remain compatibility fallbacks, while `BUILDKITE_GITHUB_ACTION` supplies the event activity. Explicit plugin selection takes precedence over server workflow selection. Without either selection, the plugin fails. The server-selected importer doesn't need a step key. The plugin uploads its artifacts before the dynamic pipeline and scopes retrieval to the importer job ID without using that UUID as a dependency key. Explicit-selector importers still require a step key, and their generated groups depend on it.

Missing or untracked explicitly configured paths warn and are skipped, so removing a workflow doesn't require a simultaneous pipeline configuration change. If every configured path is missing or untracked, the importer succeeds without uploading a pipeline. A missing or untracked pipeline trigger-selected path fails because the server claimed that exact workflow selected the build. Every present path must be a regular, tracked `.yml` or `.yaml` file inside the repository. Directories, tracked files missing from the checkout, outside paths, symlinks, and globs fail. A custom importer may upload one explicit regular workflow from outside the repository, or an untracked one, unless it holds a [matrix from a job output](#job-syntax-matrices-from-job-outputs).

Before assigning workflow identities and job keys, upload converts the paths to canonical form, sorts them, and removes duplicates.

All remaining runnable workflows use one artifact and pipeline transaction:

- Server-selected workflows emit jobs without a workflow group. Explicitly configured workflows retain groups, even when only one workflow is selected.
- Each group is labeled `\:github\: workflow · <workflow-name>`. An unnamed workflow uses its canonical path. A resolved, non-empty `run-name` appends ` — <run-name>`.
- Upload leaves the importer label unchanged.
- Each job publishes a provider check named `<workflow-name-or-path> / <job-id> (<effective-event>)`. Matrix jobs append their sorted values to the job ID.
- GitHub events publish GitHub checks. Origin events publish Origin checks.
- A workflow that doesn't declare the event becomes one top-level skipped step with no plan artifacts.
- An importer annotation links to each workflow skipped by event or filters. It shows configured events for event mismatches and the specific reason for filter mismatches. It also states when every workflow was skipped. If publication fails, upload warns but still succeeds.

Workflow names, group keys, and provider-check names stay the same across events. Only an appended run title can vary. Groups and replacement steps depend on the importer. Their child jobs don't repeat that dependency. Without a group, each step carries the workflow condition and depends on the importer if it has a step key. Deferred uploads retain the initial workflow's grouping choice.

Reusable-only `workflow_call` files remain available to local callers but don't create groups. Selecting only reusable workflows is an error.

A parse, compilation, or trigger-translation error in an aggregate upload replaces only that workflow with a failing top-level step. The step:

- Is labeled `\:github\: workflow · <workflow-name-or-path>`, with the resolved run title appended when present.
- Publishes redacted diagnostics as a job annotation.
- Publishes a `Workflow could not be run` provider check.
- Limits the check summary to 65,535 bytes.
- Exits with status 1.

Other workflows continue compiling. An unsupported matrix derived from `needs` outputs, including one inside a called reusable workflow, fails only its own workflow after the event and repository variables resolve, so it never reports `vars` values as unavailable or blocks other workflows. Missing or untracked configured paths are omitted before the transaction. Single-workflow parse errors and invalid path states, event-input, admission, artifact, and upload failures still abort the complete transaction. Upload never publishes a partial pipeline.

If a workflow has both a compiler error and a skip reason, the compiler error takes precedence.

### Compatibility diagnostics

Diagnostics keep guidance separate from implementation detail:

- `message`: Explains the visible cause and what to do.
- `detail`: Optionally records lower-level context such as resolved commits, adapter boundaries, or supported-version lists.

Text and JSON reports, Buildkite annotations, and generated failure artifacts preserve both fields. GitHub check summaries show only the concise message.

## Workflow names and triggers

| Key | Status | Behavior |
| --- | --- | --- |
| `name` | ✅ Supported | Available as `github.workflow` and used to name generated work. |
| `run-name` | 🟡 Supported subset | An explicit non-empty value is appended to the workflow group label, when grouped. Compile-time `github`, `inputs`, and `vars` expressions are supported. The importer label, Buildkite build message, and provider-check names don't change. |
| `on` | 🟡 Supported subset | Doesn't create a Buildkite build. Selects and filters workflows for the effective event as described in this section. |
{: class="responsive-table"}

A workflow name is retained in generated work:

```yaml
name: CI
```

An explicit run name adds event-specific presentation without changing static workflow or check identity:

```yaml
name: Deploy
run-name: Deploy ${{ inputs.target }} by @${{ github.actor }}
```

When grouped, this produces `\:github\: workflow · Deploy — Deploy production by @octocat`. Omitted, blank, or blank-resolving values retain the static group label. On a non-dispatch event, declared dispatch inputs use their typed zero values rather than dispatch-only defaults. A skipped workflow doesn't synthesize dispatch inputs.

Absent properties in a known input set are empty, so expressions such as `${{ inputs.name || 'default' }}` work in run names, job names, concurrency groups, and container images. Inputs waiting for job outputs remain unresolved until those outputs are available.

`run-name` uses [repository and organization variables](#job-syntax-repository-and-organization-variables), never environment-scoped variables. Upload resolves those scopes before evaluating applicable workflow names, even when `run-name` is the workflow's only `vars` reference, such as `run-name: Deploy ${{ vars.TARGET }}`.

Buildkite Pipelines controls when a build starts. The trigger declaration controls whether and under which condition the workflow group participates in that existing build:

```yaml
on:
  push:
    branches:
      - main
```

Upload selects one effective event, in this order:

1. The event in an explicit `--event-path` snapshot.
1. The GitHub event name accompanying the reserved Buildkite linked-webhook metadata.
1. A Buildkite environment fallback.

The fallback prefers `GITHUB_EVENT_NAME`, then preserves `push`, `pull_request`, `workflow_dispatch`, and `schedule` from `BUILDKITE_GITHUB_EVENT` across rebuilds. It preserves `issues` and `issue_comment` only with complete GitHub Actions pipeline trigger workflow path, ref, and SHA identity. Otherwise:

| Buildkite source | Effective event |
| --- | --- |
| Pull request build | `pull_request` |
| `ui` or `api` | `push` |
| `schedule` | `schedule` |
| Any other source, including `trigger_job` | `push` |
{: class="responsive-table"}

An explicit snapshot doesn't consult contradictory live event fields. Linked merge-group data must match the queue refs and commits. Linked release data must match the Buildkite event, action, branch, and tag. Linked issue and comment data must match the Buildkite action, default-branch ref, and repository. Comment payloads may describe either issue or pull request conversations. Review events require the original linked payload and matching immutable workflow identity, pull request number, head SHA, head and base branches, and repository identities. They can't fall back to a synthetic event on rebuilds without that payload.

Pipeline trigger release events also require the original linked payload and complete workflow ref and SHA identity. The repository, selected tag ref, Buildkite branch and tag, and payload tag must agree. The workflow SHA must equal the build's immutable peeled commit. Missing payloads on rebuilds fail explicitly.

Pipeline trigger deployment events require the original linked payload and complete workflow ref and SHA identity. The payload repository and deployment SHA must match the build. The selected branch or tag ref must match `deployment.ref`. For SHA-only deployments, the build branch is a commit-valued checkout label, the Actions ref is empty, and workflow identity uses `<owner>/<repo>/<path>@<sha>`. No default branch or current branch tip is substituted. Missing payloads on rebuilds fail explicitly. These checks don't grant token or secret authority.

Pipeline trigger merge groups require the original linked payload and complete workflow ref and SHA identity too. Repository, action, speculative head ref and SHA, and base branch and SHA must agree with the build. Rebuilds without the payload fail explicitly. These builds support tokenless workflows. Hosted merge-queue workflow-token requests remain denied. Declared permissions alone don't cause the compiler to request a token.

With the GitHub Code Access App, Buildkite Pipelines resolves a release tag to its peeled commit before creating the build. Without it, the plugin resolves the symbolic `HEAD` that Pipelines provides from the checkout as a compatibility fallback. The fallback can't infer a merge group, release, issues, or issue-comment event without linked-webhook data.

The selected event then controls applicability, event-dependent compilation, the group condition, and the provider-check suffix.

| Event | Supported trigger behavior |
| --- | --- |
| `push` | `branches`, `branches-ignore`, `tags`, and `tags-ignore`, including ordered negative patterns in an include list. Branch and tag filters select their corresponding ref kind. Matching `paths` and `paths-ignore` can be admitted for linked GitHub branch pushes when the bounded [local-diff requirements](#workflow-names-and-triggers-push-path-filters) are met. |
| `pull_request` | `branches` and `branches-ignore` match the base branch. Omitted `types`, `types: null`, and `types: []` default to `opened`, `synchronize`, and `reopened`, not every activity. Explicitly listed activity types must map exactly to a supported Buildkite source action. Matching `paths` and `paths-ignore` can be admitted when the bounded [local-diff requirements](#workflow-names-and-triggers-pull-request-path-filters) are met. |
| `merge_group` | Pipeline triggers with a compatible server, and native Buildkite merge queue builds. Native builds require merge queue builds and **Merge groups** webhook delivery in the pipeline's GitHub settings. `branches` and `branches-ignore` match the base branch. Omitted `types`, `types: null`, or `types: []` selects only `checks_requested`. Explicit `[destroyed]` and `[checks_requested, destroyed]` are supported by the CLI and runtime. Hosted execution requires [server activation and compatible runtimes](#workflow-names-and-triggers-merge-group-destruction). Unknown types and tag and workflow filters are rejected. `paths` and `paths-ignore` are ignored with a warning, matching GitHub, which doesn't evaluate path filters for `merge_group` events. The ref and SHA identify the speculative queue head, not the base commit. A push to a queue ref is still a push. |
| `release` | Pipeline triggers accept all seven GitHub release activities. Native Buildkite release builds require **Additional Webhooks** > **Releases** and **Code** trigger mode and deliver only `published`, `created`, and `released`. Workflows that select other activities emit `W_NATIVE_RELEASE_ACTIVITIES_UNDELIVERED`. Bare declarations, `types: null`, and empty `types` lists select all activities. Other explicit `types` preserve exact selection, including a scalar selecting one activity. Branch, tag, and path filters are ignored. Malformed types, unknown activities, and workflow filters are rejected. GitHub doesn't trigger `created`, `edited`, `deleted`, or `unpublished` for draft releases, and Buildkite Pipelines rejects those deliveries. Pipeline triggers require the GitHub Code Access App to select the workflow at the immutable peeled tag commit. The ref is `refs/tags/<tag_name>`. The SHA is the server-resolved peeled commit, or the checked-out commit for the native compatibility fallback. Existing hosted release `GITHUB_TOKEN` policy is unchanged. |
| `deployment`, `deployment_status` | Pipeline triggers with a compatible server, or explicit event snapshots. Bare, array, null, and empty-map declarations are supported. Branch, tag, and path filters are ignored. Activity types and other event filters are rejected. Workflows and checkout use the deployment commit. The ref identifies its branch or tag and is empty for SHA-only deployments. Status states `error`, `failure`, `in_progress`, `queued`, `pending`, `success`, and `waiting` are supported ([GitHub status enum](https://docs.github.com/en/graphql/reference/enums#deploymentstatusstate)). `inactive` can't run a workflow. The genuine payload exposes `github.event.deployment` and `github.event.deployment_status`, including environment, state, `environment_url`, `log_url`, and `target_url` when present. Use job or step conditions on these values, not `types` or environment filters. No deployment creation or environment orchestration is added. |
| `create`, `delete` | [Branch and tag lifecycle](#workflow-names-and-triggers-branch-and-tag-lifecycle-events). Branch, tag, and path filters are ignored. Activity types and other filters are rejected. Creation uses the exact ref's resolved commit. Deletion uses the default branch. |
| `label` | [Repository label lifecycle](#workflow-names-and-triggers-repository-label-lifecycle-events). `created`, `edited`, and `deleted`, all by default. Workflows and checkout use the resolved default branch. |
| `fork` | [Repository forks](#workflow-names-and-triggers-repository-fork-events). Null or empty `types` accepted. Branch, tag, and path filters ignored. Other filters rejected. Workflows and checkout use the source repository's resolved default branch, not the forkee. |
| `public` | [Repository visibility](#workflow-names-and-triggers-repository-visibility-events). Null or empty `types` accepted. Branch, tag, and path filters ignored. Other filters rejected. Workflows and checkout use the resolved default branch. |
| `gollum` | [Wiki pages](#workflow-names-and-triggers-wiki-page-events). Null or empty `types` accepted. Branch, tag, and path filters ignored. Other filters rejected. Workflows and checkout use the source repository's resolved default branch, not a wiki commit. |
| `page_build` | [GitHub Pages builds](#workflow-names-and-triggers-github-pages-build-events). Null or empty `types` accepted. Branch, tag, and path filters ignored. Other filters rejected. Workflows and checkout use the resolved default branch, not the Pages build commit. |
| `watch` | [Repository stars](#workflow-names-and-triggers-repository-star-events). Only `started`, including with null or empty `types`. Branch, tag, and path filters ignored. Other filters rejected. Workflows and checkout use the resolved default branch. |
| `milestone` | [Milestone lifecycle](#workflow-names-and-triggers-milestone-lifecycle-events). `created`, `closed`, `opened`, `edited`, and `deleted`, all by default. Workflows and checkout use the resolved default branch. |
| `branch_protection_rule` | [Branch protection rules](#workflow-names-and-triggers-branch-protection-rule-events). `created`, `edited`, and `deleted`, all by default. Workflows and checkout use the resolved default branch, not the rule's pattern. |
| `discussion` | [Discussions](#workflow-names-and-triggers-discussion-events). The 15 supported activities, all by default, including null or empty `types`. Branch, tag, and path filters ignored. Each event uses its repository's default branch, including separate source and destination events for transfers. |
| `discussion_comment` | [Discussion comments](#workflow-names-and-triggers-discussion-events). `created`, `edited`, and `deleted`, all by default, on the source default branch. |
| `issues` | Omitted `types`, `types: null`, or `types: []` accepts every GitHub Actions issue activity. Nonempty `types` may contain `opened`, `edited`, `deleted`, `transferred`, `pinned`, `unpinned`, `closed`, `reopened`, `assigned`, `unassigned`, `labeled`, `unlabeled`, `locked`, `unlocked`, `milestoned`, `demilestoned`, `typed`, `untyped`, `field_added`, and `field_removed`. Branch, tag, and path filters are ignored. Unknown types and workflow filters are rejected. In a GitHub Actions pipeline trigger build, Buildkite Pipelines selects workflows and the checkout from the latest verified default-branch SHA. Native issue-build settings, branch and path filters, and comment gating don't participate. Existing native Buildkite issue builds remain supported through linked webhook data and retain their own build-creation settings. |
| `issue_comment` | Omitted `types`, `types: null`, or `types: []` accepts `created`, `edited`, and `deleted`. Nonempty `types` may contain those activities. Both issue and pull request conversation comments are supported. Branch, tag, and path filters are ignored. Unknown types and workflow filters are rejected. GitHub Actions pipeline trigger builds select workflows and the checkout from the latest verified default-branch SHA and don't inherit native command-word, trusted-commenter, pull-request-only, branch, or path gating. |
| `pull_request_review` | Pipeline triggers support `submitted`, `edited`, and `dismissed`, all by default. Nonempty `types` selects activities, not review states. Use `if: github.event.review.state == 'approved'` on a job or step for approval-only execution. |
| `pull_request_review_comment` | Pipeline triggers support inline diff-comment `created`, `edited`, and `deleted` activities, all by default. These are distinct from submitted reviews and `issue_comment` conversation comments. |
| `workflow_dispatch` | Selected only by an explicit snapshot or authoritative `GITHUB_EVENT_NAME` or `BUILDKITE_GITHUB_EVENT` value. Webhook-style branch, tag, type, and workflow filters are unsupported. |
| `schedule` | Selected for Buildkite scheduled builds. Buildkite Pipelines owns cron configuration and doesn't expose which schedule started a build, so every `on.schedule` workflow is eligible for every Buildkite scheduled build. |
| `workflow_call` | Defines a reusable-workflow interface. A reusable-only local file is available to callers but doesn't become a top-level group. |
| `pull_request_target` | Intentionally unsupported for security reasons. Use `pull_request` with careful [checkout and ref handling](#actions-checkout-action) instead. Changing the event alone doesn't make untrusted code safe. |
| `repository_dispatch` | Not supported yet. GitHub repository dispatch requests don't start imported workflows through pipeline triggers. |
| `workflow_run` | Not supported yet. Running an imported workflow doesn't create a GitHub Actions run or emit its workflow-run events. Consider Buildkite trigger steps when migrating cross-pipeline orchestration. |
| `check_run`, `check_suite` | Not supported yet by GitHub Actions pipeline triggers. Native Buildkite builds on completed check runs are a separate integration. |
| Any other event | No Buildkite build source exists, so the trigger can never start a build. It's ignored with a `W_TRIGGER_EVENT_UNSUPPORTED` warning when the workflow also declares a supported event. A workflow declaring only unsupported events fails event-independent validation and is a skipped step in an uploaded pipeline. |
{: class="responsive-table"}

Supported `pull_request` activity types are `assigned`, `unassigned`, `labeled`, `unlabeled`, `opened`, `edited`, `closed`, `reopened`, `synchronize`, `converted_to_draft`, `locked`, `unlocked`, `enqueued`, `dequeued`, `milestoned`, `demilestoned`, `ready_for_review`, `review_requested`, `review_request_removed`, `auto_merge_enabled`, and `auto_merge_disabled`.

Both review events accept scalar, array, and map `on` declarations, including empty maps, `types: null`, and `types: []` for all activities. Branch, tag, and path filters are ignored. Unknown types and workflow filters are rejected. The workflow and checkout use the pull request head SHA, not the synthetic merge commit or an older reviewed commit. The event ref remains `refs/pull/<number>/merge`. There is no default-branch-only workflow requirement. Use `github.event.pull_request.head.ref` and `.base.ref` for branch conditions. CI-skip commit directives don't suppress these events.

Review events require `buildkite-gha` v0.64.0 or later. Fork pull requests are unsupported. Same-repository review builds retain the pull request `contents: read` token ceiling even for approving reviews. Buildkite secret and queue policies still apply. The original linked webhook is required, so rebuilds without retained payloads fail explicitly. See the [server-selected event contract](/docs/pipelines/migration/run-github-actions-workflows/cli#upload-from-a-custom-importer-pipeline-trigger-selection).

GitHub defines seven release activities: `published`, `unpublished`, `created`, `edited`, `deleted`, `prereleased`, and `released`. A bare `on: release` selects all seven. Pipeline triggers accept all seven and apply the GitHub draft-release suppression before starting workflows. Native release builds deliver three and emit `W_NATIVE_RELEASE_ACTIVITIES_UNDELIVERED` when a workflow also selects `unpublished`, `edited`, `deleted`, or `prereleased`.

### Merge-group destruction

On September 24, 2026, a single-pull-request merge queue experiment observed a successful GitHub Actions run for explicit `types: [destroyed]` with `reason: merged`. The completed run's original event, expression values, checkout, and workflow identity agreed on the group head SHA. Omitted, null, or empty types didn't select this event. Other destruction reasons and automatic-cancellation semantics weren't established. The GitHub documentation available at the time of this observation listed only `checks_requested`. The implementation follows the observed execution rather than treating that documentation as proof of rejection.

The CLI and runtime accept explicitly selected destruction events without a `reason` restriction. Mixed declarations select either listed activity:

```yaml
on:
  merge_group:
    types:
      - checks_requested
      - destroyed
    branches:
      - stable
```

The original payload, group head ref and SHA, and immutable workflow identity remain unchanged after queue-ref deletion. The default checkout fetches the recorded head SHA. It doesn't resolve the deleted ref or substitute the base branch. GitHub object retention after ref removal and hosted execution aren't yet proven.

Pipeline triggers select `destroyed` workflows, including mixed declarations, only for Buildkite organizations that have this capability enabled. It's off by default. To enable it, contact the Buildkite Support team at [support@buildkite.com](mailto:support@buildkite.com). The CLI doesn't check this setting. The server's provenance-based cancellation guard applies regardless of the setting and preserves existing cancellation settings and defaults. Existing merge queue token restrictions still apply.

The following sections describe repository events that only pipeline triggers deliver. Each requires the original linked payload and pinned workflow path, ref, and SHA. Unless noted, workflow selection and checkout use the source repository's server-resolved default-branch commit, not stale pipeline or webhook branch metadata. Explicit event snapshots must supply that resolved `repository.default_branch`. The runtime doesn't resolve it. The original payload remains available through `github.event` and [`GITHUB_EVENT_PATH`](#expressions-and-contexts-event-file).

Missing, malformed, foreign-repository, or contradictory provenance fails closed, including rebuilds without the original payload. None of these events adds token authority or creates a native GitHub Actions run. Existing repository webhooks may need an additional event subscription. Enabling backend support doesn't update existing hooks.

### Repository fork events

`fork` runs when someone forks the source repository. Scalar, array, null, and empty-map declarations and null or empty `types` are supported. Branch, tag, and path filters are ignored. Nonempty activity types and other filters are rejected. It doesn't enable pull requests from forks or `pull_request_target`.

Workflows and checkout never use the forkee's branch or commit. The complete payload, including `github.event.forkee`, is retained. Explicit snapshots must also supply a full SHA.

### Repository visibility events

`public` runs when a private repository becomes public. Scalar, array, null, and empty-map declarations and null or empty `types` are supported. Branch, tag, and path filters are ignored. Nonempty activity types and other filters are rejected. The payload must identify the source repository, mark it public, and omit `action`.

### Wiki page events

`gollum` runs when wiki pages are created or edited. Scalar, array, null, and empty-map declarations and null or empty `types` are supported. Branch, tag, and path filters are ignored. Nonempty activity types and other filters are rejected.

The payload must include a nonempty `pages` array with valid names, actions, and wiki commit SHAs. Workflows and checkout never use a wiki commit, and no wiki checkout is added. Page data remains in `github.event.pages`.

### GitHub Pages build events

`page_build` accepts scalar, array, null, empty-map, and null or empty `types` declarations. Branch, tag, and path filters are ignored. Nonempty activity types and other filters are rejected.

The payload must include the Pages build ID, commit, and status. Failed builds can trigger workflows. Execution never uses the Pages build commit. Status and error data remain in `github.event.build`. This doesn't deploy Pages.

### Repository star events

`watch` runs when someone stars the repository (`started`), not when they subscribe to notifications or remove a star. Scalar, array, null, empty-map, null or empty `types`, and `types: [started]` declarations are supported. Branch, tag, and path filters are ignored. Other types and filters are rejected.

### Milestone lifecycle events

`milestone` supports `created`, `closed`, `opened`, `edited`, and `deleted`, all by default. Scalar, array, null, empty-map, and null or empty `types` declarations select all five. Explicit `types` selects a subset. Branch, tag, and path filters are ignored. Issue and pull request `milestoned` activities and other filters are rejected.

The payload must include the milestone ID, number, and title, which remain in `github.event.milestone`.

### Branch protection rule events

`branch_protection_rule` supports `created`, `edited`, and `deleted`, all by default. Scalar, array, null, empty-map, and null or empty `types` select all three. Explicit `types` selects a subset. Branch, tag, and path filters are ignored. Other filters are rejected.

The payload's rule ID, name, and repository ID must match the source repository. Workflows and checkout never use the rule's branch pattern. The rule remains in `github.event.rule`.

### Discussion events

`discussion` supports `created`, `edited`, `deleted`, `transferred`, `pinned`, `unpinned`, `labeled`, `unlabeled`, `locked`, `unlocked`, `category_changed`, `answered`, `unanswered`, `closed`, and `reopened`, all by default. `discussion_comment` supports `created`, `edited`, and `deleted`. For both events, scalar, array, null, empty-map, and null or empty `types` select all activities. Explicit `types` selects a subset. Both events ignore branch, tag, and path filters. Other filters and unknown activities are rejected.

The payload must include the discussion ID, number, and title. Comments require a positive ID and a `discussion_id` matching the discussion. There is no command-word or trusted-commenter gating. A transfer emits `transferred` in the source repository and a separate `created` event in the destination. Each runs that repository's workflows on its own default branch. `github.event.discussion` and `github.event.comment` retain the original data. GitHub lists discussion webhooks as public preview.

### Repository label lifecycle events

`label` supports `created`, `edited`, and `deleted` activities, all by default. Scalar, array, null, empty-map, `types: null`, and `types: []` declarations select all three. Explicit `types` selects a subset. Branch, tag, and path filters are ignored. Issue and pull request `labeled` actions and other filters are rejected. `github.event.label` retains the label data.

### Branch and tag lifecycle events

`create` and `delete` support scalar, array, null, and empty-map declarations. Branch, tag, and path filters are ignored. Activity types and other filters are rejected. These events refer to Git refs, not repository creation or deletion. GitHub doesn't deliver them when more than three tags are created or deleted at once.

`create` uses the server-resolved commit of the created branch or tag, peeled for annotated tags. `delete` uses the default-branch commit, never the deleted ref. The deleted ref remains available in `github.event.ref`. `github.ref` names the default branch. Explicit snapshots must supply a full SHA, matching repository identity, and, for deletion, the resolved `repository.default_branch`. The runtime doesn't resolve mutable refs or attest event-time SHAs from these SHA-less webhooks.

### Ignored event filters and native evidence

`issues`, `issue_comment`, `label`, `release`, `create`, `delete`, `deployment`, `deployment_status`, `pull_request_review`, and `pull_request_review_comment` accept but ignore all six branch, tag, and path filter keys. Activity selection, default and null normalization, unknown-key rejection, and provenance and security checks still apply. This doesn't change real `push` and `pull_request` path and ref filters or `merge_group` base-branch filters.

On September 29, 2026, isolated native GitHub Actions workflows each declared one of these filters, plus the matching `types: [activity]` where shown in the following tables:

| Key | Exact tested value |
| --- | --- |
| `branches` | `["__lab_never_branch__"]` |
| `tags` | `["__lab_never_tag__"]` |
| `paths` | `["__lab_never_path__/**"]` |
| `branches-ignore`, `tags-ignore`, `paths-ignore` | `["**"]`, tested separately |
{: class="responsive-table"}

| Event | Tested activity or context | Declared `types` |
| --- | --- | --- |
| `issues` | `opened` | `[opened]` |
| `issue_comment` | `created`, issue conversation | `[created]` |
| `label` | `created`, repository label | `[created]` |
| `release` | `published` | `[published]` |
| `create`, `delete` | Tag creation and deletion, not branch lifecycle | Omitted |
| `deployment` | Payload action `created` | Omitted |
| `deployment_status` | Payload action `created`, state `success` | Omitted |
| `pull_request_review` | `submitted`, COMMENT review | `[submitted]` |
| `pull_request_review_comment` | `created`, inline diff comment | `[created]` |
{: class="responsive-table"}

All 60 native filter workflows and their ten unfiltered positive controls succeeded on GitHub Actions. Before this change, the Buildkite backend selected only those controls with runtime v0.91.1 and created no builds for the 60 filter workflows. This was a selection gap, not an intentional difference. Native review runs used synthetic merge commits, while hosted Buildkite review builds used pull request heads.

This evidence doesn't prove branch `create` or `delete`, other activities, combined filters, malformed values, or unknown keys. The implementation ignores these keys at the event level, not only for the sampled activity or ref kind. Four separate `tags` and `tags-ignore` cases on `pull_request.opened` and `merge_group.checks_requested` had no native or hosted runs despite successful unfiltered controls. Their cause remains ambiguous, and Buildkite Pipelines doesn't admit those filters. A quiet observation window doesn't prove per-delivery completion. This experiment ran before the change, so it doesn't prove post-change hosted execution or fleet-wide runtime selection.

### Activity defaults and native evidence

For `issues`, `issue_comment`, `label`, `release`, `pull_request_review`, `pull_request_review_comment`, `pull_request`, and `merge_group`, these forms select the event's default activities listed in the [event table](#workflow-names-and-triggers):

| Form | Example declaration |
| --- | --- |
| Omitted types | `on: {pull_request: {}}` |
| Literal YAML null | `on: {pull_request: {types: null}}` |
| Empty sequence | `on: {pull_request: {types: []}}` |
{: class="responsive-table"}

Defaults aren't an unconditional match: pull request defaults exclude `edited` and `closed`, and merge-group defaults exclude `destroyed`. Other event restrictions still apply. A nonempty `types` declaration selects its listed supported activities instead. This rule doesn't permit `types` on events that reject it, such as `push`. The null normalization for these eight event families requires v0.90.0 or later and a compatible backend. Publishing a runtime doesn't prove that a hosted build selected it.

The following table records GitHub Actions executions observed on September 24 and 29, 2026, not an exhaustive activity matrix. Other default activities listed in the event table follow [GitHub's documented defaults](https://docs.github.com/en/actions/reference/workflows-and-actions/events-that-trigger-workflows) and the Buildkite Pipelines implementation. They weren't emitted in this evidence set.

| Date (2026) | Event | Observed activities selected by omitted, null, or empty types |
| --- | --- | --- |
| September 29 | `issues` | `opened` |
| September 24 | `issue_comment` | `created` |
| September 24 | `label` | `created` |
| September 24 | `release` | `published`, `released`, `created` |
| September 24 | `pull_request` | `opened` |
| September 29 | `pull_request` | `synchronize` |
| September 29 | `pull_request` | `reopened` |
| September 24 | `pull_request_review` | `submitted` |
| September 24 | `pull_request_review_comment` | `created` |
| September 24 | `merge_group` | `checks_requested` |
{: class="responsive-table"}

The omitted and empty controls selected the same observed activities as null. Explicit controls restricted selection to the configured activities. Explicit `[edited]` didn't select `issues.opened`. `[synchronize, reopened]` selected both pull request activities, while `[opened]` selected neither. On GitHub, pull request `closed` selected none of those five declarations, including omitted, null, and empty, during the same experiment. This doesn't test an explicit `[closed]` declaration.

Earlier controls also distinguish defaults from all activities: pull request `edited` ran only with explicit `[opened, edited]`, not omitted, null, or empty declarations. The [merge-group destruction observation](#workflow-names-and-triggers-merge-group-destruction) also excluded those default forms.

Buildkite Pipelines rejects quoted `'null'` and `[null]` rather than treating them as default declarations. It also rejects malformed types and unknown activities. These are Buildkite Pipelines rules, not native results established by this matrix. The matrix doesn't establish native handling of mixed valid and unknown lists either. Alternative null spellings (`~`, `NULL`, or a blank `types:`) and YAML aliases aren't covered by this native evidence. The `buildkite-gha` parser accepts an aliased trigger mapping (for example, `issue_comment: *activities` referring to `types: null`), but this doesn't establish GitHub parity or support for every alias shape.

For push and pull request path filters, once the workflow and checkout are verified against the webhook commit, non-path exclusions retain their branch or action conditions even if diff history is unavailable. Identity failures remain errors regardless of those exclusions.

### Push path filters

For a linked GitHub branch push, the importer binds the webhook repository, ref, commit range, force state, and complete pushed-commit list to the Buildkite build and local checkout. `HEAD` and the workflow file must match the pushed commit. The `origin` repository must match, but its branch tip may have advanced since the webhook arrived.

Normal and force pushes use the GitHub two-dot `before..after` comparison. For a new branch, the importer uses the parent of the oldest pushed commit only when the complete commit set has one clear, single-parent boundary.

Added, modified, deleted, and type-changed paths can match. Patterns use the same ordered matching as pull requests.

Admission fails when the evidence is unsafe or incomplete, including:

- A deleted ref or non-GitHub repository.
- Missing or shallow history.
- Stale or mismatched repository, ref, checkout, workflow, commit set, or force state.
- Ambiguous new-branch history.
- More than 1,000 pushed commits or 3,000 changed files.
- Renames, combined additions and deletions, malformed Git output, or invalid patterns.

A verified local result with no matching paths produces an explicit skipped workflow step without executing workflow jobs. The importer uses the local diff result. It doesn't reproduce the GitHub 1,000-commit or diff-timeout fallback that can run a workflow without matching changed paths.

Tag pushes don't evaluate path filters, matching GitHub. Explicit and generated event snapshots, and Buildkite environment fallbacks, can't admit push path filters because they aren't linked webhook evidence.

When evaluation fails, the diagnostic distinguishes missing evidence from a Buildkite Pipelines compatibility limit:

| Diagnostic detail | Next step |
| --- | --- |
| `push path filters require linked Buildkite webhook data` | Use a build triggered by a GitHub push with its original webhook payload. Manual builds and explicit event snapshots can't supply linked evidence. |
| `new-branch push requires complete pushed commit evidence` or `webhook push requires its commits array` | Contact the Buildkite Support team at [support@buildkite.com](mailto:support@buildkite.com) with the build URL and diagnostic detail to investigate the original GitHub webhook and retained evidence. Workflow checkout settings can't supply this evidence. |
| `push before commit is unavailable in the local checkout` | Contact the Buildkite Support team with the build URL and diagnostic detail. The importer has already verified that its checkout is non-shallow. Fetching more branch history isn't a proven fix. |
| `combined added and deleted files require provider rename conformance data` or `renamed and copied files require provider conformance data` | Contact the Buildkite Support team with the build URL and diagnostic detail. This is a Buildkite Pipelines compatibility limit pending GitHub rename-conformance evidence, not an invalid workflow. |
| `push path filters require a complete non-shallow checkout` | If `git rev-parse --is-shallow-repository` returns `true` in the importer checkout, ask the agent administrator to fetch full history before import. Workflow `actions/checkout` runs after filtering and can't fix the importer checkout. If the checkout isn't shallow or the check fails, contact the Buildkite Support team with the build URL and diagnostic detail. |
| `push exceeds GitHub's 1000-commit path-filter diff bound` | Push in batches of at most 1,000 commits. Buildkite Pipelines doesn't reproduce the GitHub run-anyway fallback above this limit. |
| `changed paths exceed the importer's 3000-file local evaluation bound` | Split the push into smaller diffs to stay within the limit. |
{: class="responsive-table"}

Removing `paths` or `paths-ignore` is a workaround that changes which workflows run, not a fix for missing evidence or compatibility limits. Unresolved path filters remain errors. Buildkite Pipelines doesn't guess whether a workflow should run.

### Pull request path filters

`paths` and `paths-ignore` support ordered GitHub patterns. For example, this runs for changes under `src`, except generated files:

```yaml
on:
  pull_request:
    paths:
      - "src/**"
      - "!src/generated/**"
```

Before upload, the importer compares the pull request merge base with its head in the local checkout (`base...head`). The linked webhook must provide full base and head commit SHAs with one common merge base verified as an ancestor of both commits. The checkout and filtered workflow must match the pull request head. The comparison uses those pinned commits, not the current base-branch tip.

Buildkite Pipelines builds the pull request head, not the GitHub synthetic merge. Path evaluation doesn't depend on `merge_commit_sha` or `mergeable`, including for conflicting and closed pull requests. Workflows without path filters don't require diff history.

The check uses the checkout's existing Git access for public, private, and fork pull requests. It doesn't call GitHub or use Buildkite `if_changed`.

| Admitted | Rejected |
| --- | --- |
| A matching added, modified, deleted, or type-changed path | Unavailable changed-path evidence |
| A copied destination that matches | A rename, or a diff containing both additions and deletions |
| At most 3,000 changed files from complete local history | Missing or shallow history, multiple merge bases, or more than 3,000 files |
| Matching webhook, pull request head checkout, and workflow data | Unrelated history, mismatched identity or workflow, path or pattern containing a backslash, invalid pattern, or malformed Git output |
{: class="responsive-table"}

A verified local result with no matching paths, including an empty diff or changes all excluded by `paths-ignore`, produces an explicit skipped workflow step without executing workflow jobs. The unobservable GitHub diff-timeout fallback isn't reproduced.

An unsupported or inexact filter replaces only the affected workflow with a failing step. It never broadens when the workflow runs. The failure identifies unavailable or mismatched evidence, or the local 3,000-file limit. Fetch complete checkout history when commits are missing. Reduce the diff or remove the filter when the file limit is exceeded. The 3,000-file bound follows the GitHub documented evaluation window, not a claim that the GitHub file ordering or diff-timeout fallback is reproduced.

A top-level workflow that doesn't declare the effective event is excluded before event-dependent validation or compilation and represented by one top-level skipped command step. A workflow that declares that event remains represented by a group even when a same-event branch, tag, base-branch, or action condition evaluates false in Buildkite Pipelines. If no directly runnable workflow declares the event, upload succeeds with a skipped-only pipeline.

## Workflow syntax

This section covers workflow-level keys beyond names and triggers, which are described in [Workflow names and triggers](#workflow-names-and-triggers).

### Reusable workflows

**🟡 Supported subset.** Calls may use a local path or a literal GitHub reference such as `owner/repository/.github/workflows/ci.yml@v1`. A remote reference resolves once per operation to an immutable commit and repository digest. Nested `./.github/workflows/...` calls resolve in that pinned repository.

Self-repository calls such as `$/.github/workflows/ci.yml` select the repository and exact commit containing the calling workflow. They use the same source verification described under [self-repository actions](#step-syntax-self-repository-actions), and retain the existing reusable-workflow access, secret-forwarding, cycle, and depth limits.

Verified self calls in the root workflow's repository support `secrets: inherit` and explicit secret mappings. Nested `$/` and `./` calls retain this forwarding scope. An explicit `owner/repository/...@ref` call leaves the scope, even if it names the same repository and commit; its nested self or local calls cannot restore it. Every forwarding edge still needs its own `secrets` declaration.

Private references work for the pipeline repository and cross-repository sources available to the importer's existing Git credentials. Enable them with the plugin's default-off `private-reusable-workflows` field or the matching `upload` flag. When the Buildkite agent repository-provider credential helper supplies access, Buildkite approves each requested repository. Git access is also used when GitHub's anonymous API quota is exhausted, so a rate limit does not fail an otherwise authorized call. Missing and denied repositories, refs, and paths produce the same error.

**✅ Supported:**

- Local `./.github/workflows/...` paths.
- Literal public or approved private references to a `.yml` or `.yaml` file directly under `owner/repository/.github/workflows/`.
- `boolean`, `number`, and `string` inputs.
- Static input values. Caller values may use graph-time `github`, matrix, and parent reusable-workflow inputs with the supported operators and pure functions.
- String inputs that read `needs.<job>.outputs.<name>`, alone or inside a larger value such as `type=raw,value=${{ needs.meta.outputs.tag }}` or `${{ format('{0}-{1}', github.ref_name, needs.meta.outputs.tag) }}`. The call must list each job in `needs`. Every other part of the value must resolve before jobs run: literals, graph-time `github`, `vars`, matrix values, static parent inputs, and the supported operators and pure functions. Buildkite Pipelines resolves the verified outputs and renders the value before each flattened callee job runs.
- Boolean and number inputs derived from needs outputs through a single expression, such as `deps: ${{ needs.detect.outputs.deps == 'true' }}` or `count: ${{ fromJSON(needs.detect.outputs.count) }}`. The same dependency and graph-time restrictions apply. Runtime evaluation preserves the declared type and rejects a mismatched result; a raw output string is not a boolean or number.
- Forwarding a needs-dependent parent input to a nested call as exactly `${{ inputs.<name> }}`, with the same declared type.
- Literal defaults and expression defaults over graph-time `github` values.
- Nested calls up to four levels.
- `secrets: inherit` for repository-local calls. Each nested edge must repeat it.
- Explicit repository-local mappings from a declared callee alias to one direct `${{ secrets.NAME }}` or `${{ secrets['NAME'] }}` caller reference.
- Required and optional `on.workflow_call.secrets` declarations.
- Caller-visible aggregate results.
- Outputs mapped directly from `jobs.<job>.outputs.<name>`.
- Call-level `if` over caller `github`, `inputs`, direct `needs`, and status functions.
- Workflow-level concurrency in local and public called workflows, including behind a call-level `if`. Groups may use the called workflow's static inputs. Each static call-matrix instance gets its own workflow gate. See [Concurrency](#workflow-syntax-concurrency).

**❌ Unsupported:**

- Dynamic workflow paths.
- Secret forwarding for public remote calls.
- Literal, compound, dynamic, or non-secret explicit mapping values.
- `needs.<job>.result`, whole `needs.<job>.outputs` objects, or needs values mixed with runtime-only values such as `github.run_id` in inputs.
- Combining a needs-dependent parent input with other text in a nested call.
- Dynamic matrices.
- Input defaults that reference `inputs`.
- Literal or compound output expressions.

Job-level `uses`, `with`, and `secrets` follow these boundaries.

If compilation fails after the complete job graph expands, upload preserves one Buildkite Pipelines item per expanded job. Jobs with compiler errors fail with their own diagnostics, and their dependants are skipped. Independent jobs keep their compiled plans and run normally. The items keep their normal labels, keys, checks, and `needs` links. A runnable job is never emitted unless every job it needs also has a plan. If compilation fails before the complete graph is known, or the workflow takes a [matrix](#job-syntax-matrices-from-job-outputs) or [runner selection](#job-syntax-runners-from-job-outputs) from a job output, upload uses one workflow-level failing item instead.

For a local call with `secrets: inherit`, each flattened callee job requests only the static ordinary secret names referenced by that job or its workflow-authored action inputs. Inheritance is one hop: an omitted nested `secrets: inherit` removes ordinary secret authority from every job below that edge. It does not affect direct caller jobs or `GITHUB_TOKEN`.

Explicit mappings must target aliases declared by the called workflow. Every required alias must receive authority; an unmapped optional alias is empty. Nested mappings compose to the original Buildkite secret name and cannot recover an omitted same-named secret. Plans contain aliases and original names, never values. The runtime retrieves each original once, registers its value with both redactors, then projects it to the callee aliases.

`${{ secrets.GITHUB_TOKEN }}` may be forwarded to a declared alias. The alias remains part of the scoped workflow-token contract and never becomes an ordinary Buildkite secret.

Upload configures Git fallback before validating remote calls. After anonymous access fails, Git fetches a canonical credential-free HTTPS URL with the importer's existing credential configuration. Each Git invocation allows only the HTTPS transport, refuses redirects, verifies TLS, and checks received objects; these values are pinned for the exact repository URL and Git environment variables that would relax them are removed. The fetch stops if inherited `url.<base>.insteadOf` configuration rewrites the URL. Git pack input and extracted trees keep the anonymous source size and entry limits. Git output is suppressed, terminal prompts and askpass programs are disabled and inherited `http.extraHeader` and `http.cookieFile` values are reset so credentials come only from credential helpers, and credential material is never added to plans, generated pipeline YAML, workflow environments, or runtime jobs. Called repositories that may use Git fallback skip the one-hour mutable ref cache and resolve once per operation. Private actions remain unsupported.

A call condition runs in caller scope before static call-matrix expansion. It keeps the implicit `success()` guard. A false condition skips every flattened descendant, including jobs with `if: always()`, and exposes `skipped` with empty outputs to downstream `needs`. Nested calls evaluate ordered outer-to-inner guards. Callee job results do not change an outer guard. Call conditions cannot use `matrix`, `strategy`, callee inputs or needs, `steps`, `env`, `runner`, or `secrets`.

A call's `needs` governs its condition and scheduling, but does not appear in the called jobs' `needs` context. Each called job sees only dependencies it declares within its own workflow; a job without `needs` sees `{}` when serialized with `toJSON(needs)`.

A call without `if` checks only dependency results; conflicting matrix outputs do not affect that implicit success check.

The called workflow declares its inputs and outputs:

```yaml
on:
  workflow_call:
    inputs:
      target:
        type: string
        required: true
    outputs:
      image:
        value: ${{ jobs.build.outputs.image }}

jobs:
  build:
    runs-on: ubuntu-latest
    outputs:
      image: ${{ steps.image.outputs.value }}
    steps:
      - id: image
        run: echo "value=app-${{ inputs.target }}" >> "$GITHUB_OUTPUT"
```

The caller can pass a static input:

```yaml
jobs:
  build:
    uses: ./.github/workflows/build.yml
    with:
      target: production
```

Or defer a string input until a prerequisite publishes its output:

```yaml
jobs:
  call:
    needs: prepare
    uses: ./.github/workflows/build.yml
    with:
      target: ${{ needs.prepare.outputs.target }}
```

### Permissions

**🟡 Supported subset.** Permissions matter only when a job statically references `secrets.GITHUB_TOKEN` or `github.token`, or an effective action input default can reach `github.token` for the event provider.

A workflow-level permissions map can request repository access:

```yaml
permissions:
  contents: read
  pull-requests: write
```

Supported values are `read`, `write`, and `none`. Supported repository permission names are `actions`, `artifact-metadata`, `attestations`, `checks`, `contents`, `deployments`, `discussions`, `issues`, `packages`, `pages`, `pull-requests`, `security-events`, and `statuses`. The separate `id-token` permission is also supported.

An omitted map defaults to exactly `contents: read` when a token is needed. This deterministic default does not inherit GitHub repository or organization settings. Hosted token issuance uses only the top-level map. Job-level repository permission maps do not narrow or expand `GITHUB_TOKEN`; the separate `id-token` permission retains job-level behavior. Write access therefore requires an explicit top-level map.

Put `permissions: read-all` at the top level to apply read access to all 13 supported repository permissions listed above. This shorthand excludes `id-token`, `models`, `repository-projects`, `code-quality`, `metadata`, and `vulnerability-alerts`. The compiler also accepts `permissions: write-all`, but Buildkite Pipelines rejects it when issuing the token, so jobs in that workflow can't receive `GITHUB_TOKEN`.

Jobs expanded from reusable workflows use the top-level requesting workflow's repository permissions for `GITHUB_TOKEN`. Only this immutable top-level map is enforced server-side; permission maps in called workflows do not narrow `GITHUB_TOKEN`. The separate `id-token` permission retains called-workflow narrowing. Warnings identify job-level repository maps that differ from the applied top-level permissions and called-workflow maps that would have narrowed the token scope.

To grant repository access, list each required permission at the top level, such as `contents: write`. Every job that receives `GITHUB_TOKEN` gets that access. You cannot give different repository permissions to individual jobs. Use top-level `read-all` only when every job should get read access for every supported repository permission. For write access, list each permission explicitly. An empty top-level permissions map, or one that contains only `none`, creates no token. Use GitHub's exact permission names.

### Environment and defaults

| Key | Status | Behavior |
| --- | --- | --- |
| `env` | 🟡 Supported subset | Workflow, job, and step maps use normal precedence; the most specific value wins. Individual values may use supported interpolation. An entire map cannot be expression-valued. |
| `defaults.run.shell` | 🟡 Supported subset | Supported at workflow and job level, subject to [shell and platform limits](#step-syntax-commands-and-actions). Windows defaults to `pwsh`, other host jobs to `bash`, and job containers to `sh`. |
| `defaults.run.working-directory` | 🟡 Supported subset | Supported at workflow and job level for workspace-relative paths. |
{: class="responsive-table"}

A job-level value overrides the same workflow-level environment variable:

```yaml
env:
  GOFLAGS: -mod=readonly

jobs:
  test:
    env:
      GOFLAGS: -race
```

Workflow-level run defaults set the shell and working directory:

```yaml
defaults:
  run:
    shell: bash
    working-directory: ./src
```

### Concurrency

**🟡 Supported subset with different queue behavior.** A static group becomes a repository-scoped, case-insensitive Buildkite Pipelines concurrency group. Groups may use supported `github` fields, static reusable-workflow inputs, and concrete `matrix` and `strategy` values at job level. Core operators, `fromJSON`, and the case-insensitive string functions `startsWith`, `contains`, and `endsWith` are supported when the whole expression resolves during compilation. A needs-derived matrix consumer can also read its producer's outputs for its job group; see [Scheduling from matrix-producer outputs](#job-syntax-scheduling-from-matrix-producer-outputs). Other runtime `needs` values remain unsupported.

A workflow can set a group and cancellation expression while a job uses a matrix-derived group:

```yaml
concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: ${{ startsWith(github.ref, 'refs/pull/') }}

jobs:
  deploy:
    concurrency: deploy-${{ matrix.target }}
```

Called workflows keep workflow-level concurrency around all flattened jobs. Inputs can derive the group from a caller matrix:

```yaml
on:
  workflow_call:
    inputs:
      target:
        type: string
        required: true

concurrency: deploy-${{ inputs.target }}

jobs:
  plan:
    runs-on: ubuntu-latest
    steps:
      - run: ./plan
  apply:
    needs: plan
    runs-on: ubuntu-latest
    steps:
      - run: ./apply
```
{: codeblock-file=".github/workflows/deploy.yml"}

```yaml
jobs:
  deploy:
    strategy:
      matrix:
        target:
          - staging
          - production
    uses: ./.github/workflows/deploy.yml
    with:
      target: ${{ matrix.target }}
```

Nested called workflows keep nested gates. Jobs within one called workflow remain parallel except for their declared `needs` and job-level concurrency. Jobs or nested called workflows that reuse an enclosing workflow group remain unsupported.

Calls with `needs` are supported when their prerequisites have no concurrency or statically disjoint groups. The compiler checks transitive prerequisites, matrix instances, and nested reusable-workflow groups. A prerequisite sharing the called workflow's group, an unresolved group, or a cycle involving gate dependencies and the ordered concurrency queues of Buildkite Pipelines is rejected. For example, `needs: prepare` is supported for a called workflow in group `deploy` when `prepare` has no concurrency or uses a separate group such as `build`.

The opening gate waits for every external prerequisite, including failed or skipped jobs, so runtime conditions still decide whether the call runs. Pipelines reserves the gate's queue position at pipeline upload, before those prerequisites finish; GitHub admits the called workflow afterward. A later call or build using the group can therefore wait behind an earlier call whose prerequisites are unfinished. `W_REUSABLE_WORKFLOW_CONCURRENCY_QUEUED_BEFORE_PREREQUISITES` reports this difference once per root call. Mutual exclusion is preserved. This analysis covers the generated pipeline, not dependencies introduced by other pipelines or later manual uploads.

A call with `if` keeps the called workflow's gate unless its condition is already false. Pipelines reserves the group at pipeline upload, before the runtime evaluates the condition:

| Call condition at compile time | Gate | Condition diagnostic |
| --- | --- | --- |
| True, such as `github.event_name == 'pull_request'` on a pull request | Emitted | None |
| False, such as the same condition on a push | Omitted; the jobs skip without entering the group, as on GitHub | None |
| Runtime-dependent, such as `vars.DEPLOY == 'true'` | Emitted; a skipped call still waits for the group and holds it until its jobs finish | `W_REUSABLE_WORKFLOW_CONCURRENCY_ENTERED_BEFORE_CALL_CONDITION` |
{: class="responsive-table"}

Pipelines queues every waiting entry. It does not replace GitHub's existing pending entry. The `queue` key is unsupported.

A workflow with workflow-level `concurrency` cannot hold a matrix from `fromJSON(needs.<job>.outputs.<name>)`; see [Matrices from job outputs](#job-syntax-matrices-from-job-outputs).

When workflow-level `cancel-in-progress` is `true`, Pipelines warns and leaves superseded builds running. The same behavior applies when an expression resolves to `true` and when a called workflow sets it. Job-level `cancel-in-progress` is unsupported.

In an explicit-workflow pipeline, to cancel earlier running builds on the same branch, turn on [Cancel Intermediate Builds](/docs/pipelines/configure/canceling-builds#cancel-running-intermediate-builds) under pipeline **Settings** > **Builds**. This setting works by branch, not by concurrency group. Leave it disabled for server-side dispatch, where it can cancel sibling workflow builds from the same event.

Cancel the whole Buildkite build rather than one job when a workflow-level concurrency gate is active.

## Job syntax

### Job configuration

| Key | Status | Behavior |
| --- | --- | --- |
| `name` | ✅ Supported | Labels may use static `github`, reusable-workflow `inputs`, and concrete `matrix` and `strategy` values. |
| `needs` | ✅ Supported | Accepts a string or list of static job IDs. Matrix fan-out and fan-in are automatic. |
| `runs-on` | 🟡 Supported subset | Explicit mappings are authoritative. The Agent API returns a complete target for every other selector and can return a fallback warning annotation. The local preset accepts `ubuntu-latest`, `ubuntu-24.04`, `ubuntu-22.04`, and `macos-latest`. Labels are case-insensitive. Expressions may use concrete `matrix` and `strategy` values or [values from a job output](#job-syntax-runners-from-job-outputs) to resolve to an accepted label or label list. |
| `if` | 🟡 Supported subset | Runs before the job starts. See [Conditions](#expressions-and-contexts-conditions). |
| `outputs` | 🟡 Supported subset | Maps step outputs for consumption through `needs`. A job may publish 64 outputs of up to 1 KiB each. Ambiguous matrix output values stop the job with an error. |
| `env`, `defaults.run` | 🟡 Supported subset | Uses the [workflow-level behavior](#workflow-syntax-environment-and-defaults). |
| `timeout-minutes` | 🟡 Supported subset | Accepts literal timeouts up to 360 minutes. Expressions are rejected. |
| `continue-on-error` | ✅ Supported | Accepts literal Boolean values or expressions that produce a Boolean. A tolerated failure remains visible as a Buildkite Pipelines soft failure and reports `success` through downstream `needs`. |
| `environment` | 🟡 Supported subset | Requires GitHub environment access at compile time. See [Deployment environments](#job-syntax-deployment-environments). |
| `snapshot` | ➖ Accepted, no effect | Custom image creation is not implemented. |
{: class="responsive-table"}

Job names can interpolate matrix values:

```yaml
jobs:
  test:
    name: Test Go ${{ matrix.go }}
```

A job can depend on another job by ID:

```yaml
jobs:
  build:
    # ...
  test:
    needs: build
```

Runner labels can resolve from a matrix value:

```yaml
runs-on: ${{ matrix.os }}
```

A job-level condition can combine a branch check with status:

```yaml
if: github.ref == 'refs/heads/main' && success()
```

Job outputs can pass a step output to a dependent job:

```yaml
jobs:
  build:
    runs-on: ubuntu-latest
    outputs:
      image: ${{ steps.image.outputs.value }}
    steps:
      - id: image
        run: echo "value=app:latest" >> "$GITHUB_OUTPUT"

  deploy:
    needs: build
    runs-on: ubuntu-latest
    steps:
      - run: echo "${{ needs.build.outputs.image }}"
```

Results and outputs come from verified producer manifests. Retrying one producer can make selection ambiguous; retry the whole build.

A job with `continue-on-error: true`, or an expression that resolves to `true`, stops ordinary steps after a failure, runs eligible failure and always steps plus post-actions, publishes its outputs, and reports `success` through `needs.<job>.result`. The generated Buildkite Pipelines job returns reserved status `78` for the tolerated workflow failure and soft-fails only that status, so the failure remains visible without blocking dependent jobs. Job timeout expiry remains `cancelled` and is never tolerated.

Runner labels are case-insensitive. Runner aliases such as `macOS-latest` use the same target as `macos-latest`. Local Linux presets use the corresponding Noble or Jammy hosted-toolchains image with default Buildkite agent targeting. An explicit immutable image overrides the preset for a configured profile.

An explicit mapping overrides automatic runner selection. It declares that the selector runs on Linux x86-64, except for known Linux arm64 labels (`ubuntu-24.04-arm`, `ubuntu-22.04-arm`, and labels ending in `-arm64` or `-aarch64`), known macOS labels, and Windows labels. Linux arm64 requires an explicit queue and rejects images; this does not provision or enable a Buildkite hosted ARM queue. macOS and Windows also reject images. During import, the job-scoped Agent API checks that the configured queue exists in the job's cluster and that a hosted queue matches the declared OS and architecture. Self-hosted queues are checked for existence only; their operators remain responsible for the agents' platform. Successful validation preserves the configured queue, image, and cache. For every other selector, the job-scoped Agent API owns compatibility and returns the complete queue, platform, and host environment. The importer applies that target verbatim and publishes returned fallback warnings as annotations.

For eligible Namespace-backed Linux queues, the backend selects native GitHub Actions images through opaque Buildkite agent tags, such as `agents: {queue: linux-medium, nsc-gha-image: ubuntu-24.04}`, instead of a step-level `image`. The backend owns eligibility and label mapping; the CLI does not interpret provider tag names. macOS and Windows do not use this path. Immutable `image` targets remain supported for other agents, explicit mappings, heuristic fallbacks, and older backends. A workflow's `jobs.<job>.container.image` remains a separate job container executed inside the selected host environment.

When the Agent API rejects a selector, the importer reports the server's reason at the job's `runs-on` in the workflow diagnostics annotation instead of falling back to a built-in preset, so a cluster without the expected hosted queue fails before pipeline upload with the cluster and queue named:

| Rejection | Meaning |
| --- | --- |
| `missing_queue` | The labels are compatible, but the job's cluster has none of the hosted queues they need. Create the named queue or configure an explicit runner mapping. |
| `incompatible_labels` | The selector is unsupported, or hosted Windows access is unavailable. See [Configure a Windows runner](#windows-jobs-configure-a-windows-runner) before changing Windows labels. |
| `no_cluster` | The job is not in a cluster, so no hosted queue can be selected. |
| `queue_not_found` | The explicitly configured queue is not active in the job's cluster. Correct the mapping or create the queue. |
| `queue_platform_mismatch` | The configured hosted queue has a different OS or architecture. Map the label to a compatible queue. |
{: class="responsive-table"}

Windows rejections preserve the server's reason and add setup or variant guidance for incompatible selectors. Unknown rejection codes render the server message with generic mapping guidance.

Imports using explicit mappings require job-scoped Agent API credentials and a server that acknowledges configured-target validation. If validation is unavailable, the import stops before uploading jobs rather than trusting an unchecked mapping. Imports without explicit mappings still warn and fall back to built-in presets when the Agent API cannot be reached.

Explicit mappings can also attach one [Buildkite hosted cache volume](/docs/pipelines/migration/run-github-actions-workflows/cli#upload-from-a-custom-importer-configure-generated-job-cache-volumes) to generated jobs. This configuration is outside the GitHub workflow and does not change workflow syntax or action inputs.

Offline `validate` and `compile` do not check queues against the cluster. `validate --profile hosted` has no job-scoped API and admits only the local `ubuntu-latest`, `ubuntu-24.04`, `ubuntu-22.04`, and `macos-latest` presets.

### Runners from job outputs

**🟡 Supported subset.** A job without a matrix can select its runner from one declared output of a prerequisite with exactly one static instance:

```yaml
plan:
  runs-on: ubuntu-latest
  outputs:
    runner: ${{ steps.select.outputs.runner }}
  steps:
    - id: select
      run: echo 'runner=["ubuntu-22.04"]' >> "$GITHUB_OUTPUT"
build:
  needs: plan
  runs-on: ${{ fromJSON(needs.plan.outputs.runner) }}
  steps:
    - run: echo ready
```

A plain output can supply a single label with `runs-on: ${{ needs.plan.outputs.runner }}`. Label templates, label lists, and expressions using the existing pure functions are also supported. Every output reference, including in an unselected branch, must name the same producer output through `needs.<job>.outputs.<name>`. A reusable workflow may receive that value through a string input and forward the input unchanged to another workflow. Its caller's output retains its original producer binding; it does not become part of the callee's `needs` context.

The initial upload creates `\:github\: runs-on · build`, with check `build (runs-on)`, and reports `W_RUNS_ON_DEFERRED`. This step reads the verified output, recompiles the workflow, and uploads `build` and every job that transitively depends on it. Runner mappings, live Agent API resolution, platform availability, admission, and token authority apply as for static jobs. Runner fallback warnings appear as annotations on the deferred step. The output supplies scheduling data only, never workflow source or credential authority.

Runner selection uses the same [deferred upload rules](#job-syntax-matrices-from-job-outputs) for snapshots, action locks, graph budgets, downstream ownership, skipped producers, and retries. The producer output is limited to 1 KiB. Independent runner and matrix continuations may coexist, but a component containing runner selection cannot join or start another deferred stage. Matrix-only components retain their supported joins and chained stages. A dynamic-runner consumer cannot also declare a matrix. Workflow-level concurrency is unsupported on the root or on a reusable workflow containing or depending on a deferred job. This does not add dynamic environments or concurrency values. Missing outputs, invalid label types, and rejected runner targets fail the deferred step without uploading its jobs.

Use `validate` or `compile --format ir-json` to inspect the deferred graph; `compile --format pipeline` cannot emit it. See [Expand a matrix inside the build](/docs/pipelines/migration/run-github-actions-workflows/cli#upload-from-a-custom-importer-expand-a-matrix-inside-the-build).

### Deployment environments

A job-level `environment` needs GitHub environment configuration at compile time. Inside a Buildkite job, `upload` and `compile` resolve each declared environment automatically through the job-scoped Agent API (`github-actions/environments`). The Buildkite backend reads the environment's protection rules, secret names, and variables from GitHub with its own credentials, restricted to the pipeline's configured repository, and returns only that snapshot: no GitHub token and no secret value ever reaches the importer. This is the only environment access path; there is no GitHub token option. Resolution is GitHub.com-only, so GitHub Enterprise Server repositories cannot declare environments. Any resolution failure fails the compile instead of degrading to an unprotected default, and environments are only resolved when a workflow declares them. One upload resolves all of its distinct environments in one batched request. The backend owns the resolution limits (at most 20 environments per request plus per-job and per-App-installation hourly budgets) and its rejection fails the compile with the error from the backend.

Outside a Buildkite job, `compile` has no environment access, so workflows that declare environments fail to compile with an error naming the job. `validate --profile hosted` reports the same failure as a diagnostic.

| Environment feature | Behavior |
| --- | --- |
| Literal `environment` name, with or without `url` | ✅ Supported on top-level workflow jobs. Expression names and reusable-workflow jobs are rejected. |
| Required reviewers | 🟡 One Buildkite Pipelines block step per workflow and environment gates the affected jobs. Any user who can unblock the pipeline can approve; GitHub reviewer lists, `prevent_self_review`, and administrator bypass are not enforced. |
| Environment secrets | 🟡 Referenced secret names defined in the environment resolve to the Buildkite secret `<ENVIRONMENT>_<NAME>`. Other names resolve unchanged. Values stay in Buildkite secrets; only names are read from GitHub. |
| Environment variables | 🟡 `${{ vars.NAME }}` resolves in runner-evaluated fields of jobs that declare the environment, over repository and organization variables. Job `if` and compile-time fields never see environment variables. See [Repository and organization variables](#job-syntax-repository-and-organization-variables). |
| Wait timers | ❌ Rejected at compile time. |
| Deployment branch policies | ❌ Rejected at compile time. |
| Custom deployment protection rules | ❌ Rejected at compile time. |
| `environment.url`, deployment records | ➖ Accepted, no effect. Builds do not create GitHub deployments or deployment statuses. |
{: class="responsive-table"}

Secret `DEPLOY_KEY` in environment `production` resolves to Buildkite secret `PRODUCTION_DEPLOY_KEY`: the prefix is the upper-cased environment name with every character outside `A-Z`, `0-9`, and `_` replaced by `_`, and a leading `_` added when the name starts with a digit (environment `1st` gives `_1ST_DEPLOY_KEY`). Distinct environments keep distinct values under Buildkite secret access policies. Generated keys must be keys that Buildkite secrets can store: compilation fails when a key would exceed 255 characters or begin with `BK` or `BUILDKITE`, so rename such environments or secrets.

The approval gate is a block step with `blocked_state: running`, so jobs without a gate keep running while approval is pending. The gate appears whenever the workflow compiles, even if the gated job's own condition would skip it. Matrix instances of one job share one gate. Gated jobs cannot be retried manually; run a new build for a fresh approval.

Environment configuration is read once per compile, so changes on GitHub apply to the next build. Buildkite OIDC tokens do not carry a GitHub `environment` claim.

### Repository and organization variables

`${{ vars.NAME }}` reads configuration variables by scope. Inside a Buildkite job, `upload` and `compile` read the event repository's repository and organization variables through the job-scoped Agent API (`github-actions/variables`) when any applicable workflow, a reusable workflow it calls, or an input default of an action it uses references `vars`; workflows without a `vars` reference make no request, and neither do events from providers other than GitHub.com. One upload makes at most one request, whether or not a workflow declares an environment. The Buildkite backend reads the variables from GitHub with its own credentials, restricted to the pipeline's configured repository, and bounds the response (500 repository and 1000 organization names, 48 KiB per value, 256 KiB combined). Its rejection, rate limit (10 requests per job per hour), or GitHub outage fails the compile of every workflow that references `vars` with the error from the backend and any `Retry-After` delay; other workflows still upload. Variable-resolution failures use diagnostic code `E_VARIABLE_RESOLUTION`; other processing-environment failures continue to use `E_ENVIRONMENT`.

If the backend returns only `GitHub variables could not be resolved`, the CLI cannot identify the cause. Contact the Buildkite Support team at [support@buildkite.com](mailto:support@buildkite.com) with the build URL so they can investigate the server-side failure. When the backend provides a specific policy, permission, installation, or repository-access error, the CLI preserves it.

A backend without the endpoint, or an organization that has opted out, returns 404, which resolves both scopes as empty rather than failing the compile. A repository and organization that define no variables resolve the same way. Either way every `vars` name evaluates to an empty string, in compile-time fields too, so `runs-on: ${{ vars.FAILOVER_RUNNER || 'ubuntu-latest' }}` selects `ubuntu-latest`. Outside a Buildkite job, `compile` has no variable source: runtime references evaluate to empty strings, and compile-time fields that reference `vars` fail to compile.

Each job's plan carries the scopes as `organization_vars`, `repository_vars`, and, for jobs that declare an environment, `environment_vars`. The compiler and runtime use these scopes:

| Position | `vars` context |
| --- | --- |
| `jobs.<id>.if` and reusable-workflow call `if` | Repository over organization variables. GitHub evaluates these before the job's environment applies, so environment variables are never visible here. Check environment variables in a step `if`. |
| Job `env`, `defaults.run`, `outputs`, service `env` and credentials, every step field, and action input defaults | Environment over repository over organization variables. |
| Compile-time fields (`run-name`, `runs-on`, `strategy`, `concurrency`, job names, container images, reusable-workflow inputs) | Repository over organization variables. Without a source, such as `compile` outside a Buildkite job, a reference fails to compile. `environment` names must stay literal. See [Compile-time expressions](#expressions-and-contexts-compile-time-expressions). |
{: class="responsive-table"}

The GitHub [variable reference](https://docs.github.com/en/actions/reference/workflows-and-actions/variables#configuration-variable-precedence) documents environment-over-repository-over-organization precedence, but also says environment-level variables arrive after job start and "won't overwrite variables in the `env` and `vars` contexts." The table records the behavior of Buildkite Pipelines. The server-side scope merge in GitHub remains unconfirmed by a native run; runner source establishes evaluation order, not that merge.

Names match case-insensitively, and a higher scope replaces a lower scope's name spelled differently. A name no scope defines evaluates to an empty string, as on GitHub; it is not a compile error. Dynamic access such as `vars[matrix.name]`, `vars.*`, and `toJSON(vars)` reads the same per-position context. Matrix instances share their job's environment variables; jobs without an environment, including every reusable-workflow job, see repository and organization variables only. `GITHUB_TOKEN` and secret authority planning never resolves `vars`: a job or step gated by `if: vars.PUBLISH == 'true'` keeps its token and secret requests whatever the variable's value, because every condition keeps `vars` for the runtime to evaluate, and a step input such as `${{ vars.ENABLED == 'yes' && github.token || '' }}` requests the token and fails under `permissions: {}` as it does without variables.

```yaml
deploy:
  runs-on: ${{ vars.RUNNER }}
  if: vars.DEPLOY_ENABLED == 'true'
  environment: production
  env:
    AWS_REGION: ${{ vars.AWS_REGION }}
  steps:
    - if: vars.TIER == 'gold'
      run: echo "$AWS_REGION"
```

Values are plain configuration, not secrets: they are stored in the build's job plan artifacts and are visible to anyone who can read build artifacts, and `compile --format ir-json` prints both scopes in the IR whenever the workflow references `vars`. A value a compile-time field uses also appears wherever that field does: a job name or matrix value becomes a step label in the pipeline YAML, and a runner label that cannot be mapped is quoted in the compile diagnostic. Runtime references, processing reports, and resolution errors never carry values. See [Variables](/docs/pipelines/migration/run-github-actions-workflows/security#credential-boundaries-variables) in the security guide.

### Matrix strategies

| Key | Status | Behavior |
| --- | --- | --- |
| `matrix` | 🟡 Supported subset | Literal rows. Authored values and expression-valued definitions can use compile-time `github`, `event`, reusable-workflow `inputs`, and `fromJSON` values. A whole matrix can be `fromJSON(needs.<job>.outputs.<name>)`; see [Matrices from job outputs](#job-syntax-matrices-from-job-outputs). |
| `include`, `exclude` | 🟡 Supported subset | Literal combinations or expressions that resolve to arrays of objects during compilation. A whole `include` list can be `fromJSON(needs.<job>.outputs.<name>)`. |
| `max-parallel` | 🟡 Supported subset | Literal value on ordinary job matrices, or [a limit from the matrix producer's output](#job-syntax-scheduling-from-matrix-producer-outputs). Reusable-workflow call matrices with more than one instance are rejected because flattening cannot preserve invocation-level parallelism. |
| `fail-fast` | ➖ Accepted, no effect | A failed matrix entry does not cancel its siblings. |
{: class="responsive-table"}

Each expanded job retains four scalar `strategy` values, including jobs in called workflows and matrices expanded from job outputs:

| Property | Value |
| --- | --- |
| `job-index` | Zero-based position in the final expanded rows, after exclusions and includes. It follows expansion order, not sorted matrix keys. |
| `job-total` | Number of final expanded rows. |
| `fail-fast` | Configured Boolean, or `true` when omitted. This reports the setting; it does not enable cancellation. |
| `max-parallel` | Configured limit, or the number of final expanded rows when omitted. |
{: class="responsive-table"}

A job without a matrix has index `0`, total `1`, and default parallel limit `1`. Values follow the runner's [matrix expansion](https://github.com/actions/runner/blob/15231bede4aacecb6686f4b7de25c62398607993/src/Sdk/WorkflowParser/Conversion/WorkflowTemplateConverter.cs#L1006-L1049) and [singleton defaults](https://github.com/actions/runner/blob/15231bede4aacecb6686f4b7de25c62398607993/src/Sdk/WorkflowParser/WorkflowTemplateEvaluator.cs#L166-L195). See [Runtime interpolation](#expressions-and-contexts-runtime-interpolation) for admitted fields.

A strategy can combine parallelism, static matrix values, and exclusions:

```yaml
strategy:
  max-parallel: 2
  matrix:
    go:
      - "1.25"
      - "1.26"
    os:
      - ubuntu-22.04
      - ubuntu-24.04
    exclude:
      - go: "1.25"
        os: ubuntu-24.04
```

Whole matrices, dimensions, and `include` or `exclude` lists can use static JSON:

```yaml
strategy:
  matrix:
    os: ${{ fromJSON(inputs.OPERATING_SYSTEMS) }}
    include: ${{ fromJSON(github.event.inputs.extra_jobs) }}
    exclude: ${{ fromJSON(github.event.matrix_exclusions) }}
```

Static matrices on reusable-workflow calls compose with static matrices in called workflows, including nested calls. Each call instance receives its concrete `matrix` values and each called workflow receives its declared `inputs` before its matrix expands.

A job may expand to at most 256 instances.

### Matrices from job outputs

**🟡 Supported subset.** A matrix whose whole `matrix` or whole `include` list is exactly `fromJSON(needs.<job>.outputs.<name>)` expands after the producing job runs:

```yaml
plan:
  runs-on: ubuntu-latest
  outputs:
    matrix: ${{ steps.plan.outputs.matrix }}
  steps:
    - id: plan
      run: echo 'matrix=[{"target":"amd64","runner":"ubuntu-latest"}]' >> "$GITHUB_OUTPUT"
build:
  needs: plan
  runs-on: ${{ matrix.runner }}
  strategy:
    matrix:
      include: ${{ fromJSON(needs.plan.outputs.matrix) }}
publish:
  needs: build
  runs-on: ubuntu-latest
```

The initial upload creates `plan` and one deferred step, `\:github\: matrix · build`, with check `build (matrix)`. That step waits for `plan`, reads the verified `matrix` output, expands it with the static-matrix rules, recompiles only `build` and the jobs that transitively need it, such as `publish`, and uploads them with the initial workflow's grouping. Every other job is uploaded once, up front. The deferred step reports `W_MATRIX_DEFERRED` at compile time and leaves the deferred jobs `not-evaluated` in processing reports, so `validate` and `compile --format ir-json` show the graph shape while `compile` cannot render the pipeline YAML for the workflow. See [Expand a matrix inside the build](/docs/pipelines/migration/run-github-actions-workflows/cli#upload-from-a-custom-importer-expand-a-matrix-inside-the-build).

Deferred matrices can join. Each matrix owns every downstream job, including dependencies introduced by reusable-workflow inputs and call conditions. When two downstream sets intersect, they merge under one deferred step; merging continues through indirect intersections. A job with `needs: [build, test]` gives the deferred `build` and `test` matrices one owner, which uploads both branches and their join once. Sharing only a producer does not merge otherwise independent matrices. Prerequisites outside the downstream sets stay in the initial upload. The owner waits for all its matrix producers before expanding any branch.

Deferred matrices can chain. When a matrix reads its rows from a job that is itself deferred, the component expands in stages:

```yaml
package:
  needs: build            # build is the deferred matrix above
  runs-on: ubuntu-latest
  outputs:
    targets: ${{ steps.targets.outputs.targets }}
deploy:
  needs: package
  runs-on: ${{ matrix.runner }}
  strategy:
    matrix:
      include: ${{ fromJSON(needs.package.outputs.targets) }}
release:
  needs:
    - publish
    - deploy
  runs-on: ubuntu-latest
```

The initial upload still creates one deferred step for the component, `\:github\: matrix · build`, which owns `build`, `package`, `publish`, `deploy`, and `release`. After `plan` runs, that step expands `build`, uploads `package` and `publish`, and adds the next stage's step, `\:github\: matrix · deploy`, which waits for `package` and uploads `deploy` and `release`. Each stage recompiles the workflow with the rows the stages before it resolved and uploads only the jobs whose rows now exist, to any depth. `W_MATRIX_DEFERRED` names the matrices that later stages expand.

The expanded jobs are identical to the jobs a literal matrix with the same rows produces: same step keys, labels, checks, `needs`, and outputs. Row values reach `runs-on`, `name`, `if`, `env`, and steps exactly as static matrix values do, and nothing else. `runs-on: ${{ matrix.runner }}` resolves through the importer's explicit runner mappings or the same Agent API resolution as static jobs, with the same admission, capability, and token rules, so a producer cannot select an unmapped queue or widen permissions. Separate declared outputs can supply the consumer's [scheduling values](#job-syntax-scheduling-from-matrix-producer-outputs) within that subset's limits. The deferred step also reads remote reusable workflows and actions the way the importer did, including through Git when `private-reusable-workflows` is enabled.

Limits and rejected shapes:

- The matrix producer must run on Linux or macOS: its queue also runs the deferred upload, and Windows importers are unsupported. Expanded jobs may target an explicitly enabled Windows queue when its runtime is provided.
- The producer must be a job with exactly one instance, so it has no matrix or a matrix that expands to one row, and its output must be declared in `outputs`. The output value is JSON of at most 1 KiB, the [job output limit](#runtime-behavior-and-limits-key-limits), so a large matrix must stay compact. Rows are objects of scalar values with at most 64 properties each; the expansion honors the 256-instance and 1,024-job limits.
- The deferred uploads of a workflow share the jobs the 1,024-job limit leaves after the jobs uploaded up front, in equal parts: with four static jobs and two independent deferred components, each component may upload at most 510 jobs, counting every root's rows and each downstream instance once. The share is recorded at upload time, so the deferred steps together cannot grow the build past the limit whatever the producers publish. In a chained component each stage passes the share it did not use to the next stage. A producer output that needs more than the share fails the deferred step before it uploads anything, and a share too small for the jobs a deferred step already promises fails the workflow with `E_MATRIX_INVALID` at upload time.
- An output that is missing, not JSON, the wrong shape, has zero rows, exceeds a limit, or names a runner that fails compilation or admission fails the deferred step, and the dependent jobs never run. The step prints the compile diagnostics.
- A called workflow with workflow-level `concurrency` can neither hold a needs-derived matrix nor need a deferred job. A workflow with its own workflow-level `concurrency` cannot hold a needs-derived matrix: the group is released when the jobs of the initial upload finish, before the jobs a deferred step adds. These fail the workflow with `E_MATRIX_INVALID` at upload time.
- The step keys of the deferred step of every stage, of each consumer's placeholder, of every statically known instance of a deferred dependent, and of the approval gate of every environment a deferred job declares are reserved at upload time. A key that collides with another job's key or gate, such as a static or deferred job `build-matrix` next to a needs-derived matrix `build`, a deferred job whose static matrix has duplicate rows, or a job whose ID equals an approval gate key, fails the workflow with `E_MATRIX_INVALID` before anything runs, instead of failing the later upload or letting a job stand in for an approval gate. Only the keys the deferred jobs will take are reserved: a reusable-workflow job `call.publish` with a matrix does not block a static job `call-publish`.
- The deferred step reads the workflow again from a checkout of the build commit, so the workflow must be a tracked file inside the repository whose content at that commit (`BUILDKITE_COMMIT`, or `HEAD` when the agent did not resolve it) is the content the importer compiled. A custom importer that uploads one workflow from outside the repository, an untracked or only staged file, or a file edited since the commit fails the upload when that workflow holds a needs-derived matrix, before anything runs.
- Deferred jobs that deploy to a protected environment use the same approval gate as static jobs. When no static job created the gate, the first deferred step to upload creates it and later deferred steps reference it. A step whose upload was rejected because another step created a shared gate in the meantime uploads again, referencing that gate and still creating the gates only it uses.
- The importer records the workflow event once per upload, as one artifact shared by every deferred step, so many needs-derived matrices do not multiply the artifact size.
- When a producer fails, is skipped, or is cancelled, the deferred step uploads skipped placeholders for the deferred jobs: one for the consumer, one for each matrix a later stage would have expanded, and one per statically known matrix instance of each dependent, under the keys and check names a static expansion would use. Dependents with `if: always()` are skipped as well; this subset does not run descendants of an unknown matrix. In a merged component, only that root's downstream jobs are skipped; healthy branches still expand, and a shared join is skipped once. In a chained component, a producer that does not succeed skips only the jobs from its stage on; the jobs earlier stages uploaded stay as they are. Missing or invalid result manifests fail the deferred step without uploading jobs, even when another producer did not succeed. They are never treated as skips.
- The deferred step recompiles the workflow from the checkout at the build commit with the event, variables, runner mappings, OIDC settings, and rows of earlier stages the importer or the step before it recorded, and requires the result to reproduce the jobs the earlier uploads created, to leave the workflow's other deferred matrices exactly as recorded, to defer the next stage exactly as the initial upload promised, and to compile every deferred job from the workflow source the importer recorded for it: the same file content and, for a reusable workflow from another repository, the same commit. Any difference, such as a reusable-workflow tag that now resolves to another commit, fails the step with instructions to retry the whole build.
- The initial upload resolves the actions the deferred jobs use, so an action that cannot be resolved fails the workflow before anything runs, and records each resolved commit and source digest in the stage record. The deferred jobs use those revisions: a public action tag that moves between the initial upload and the deferred step does not change them, and a local action whose files changed in the checkout fails the step.
- Repository and organization variables are resolved once, at upload time, when any job of the workflow reads `vars` in the workflow file or in an action it uses, deferred jobs included. The scopes are recorded in the stage record, so a deferred job whose action defaults an input to `${{ vars.REGION }}` sees the same value a static job would, and the deferred step never requests variables itself.
- A workflow that holds a needs-derived matrix is never uploaded job by job. When any of its jobs fails compilation, such as a job whose local action is missing, the whole workflow is replaced with one failing step, `E_PIPELINE_GENERATION` names the deferred jobs, and no job of the workflow runs. A per-job upload would keep only the static jobs and drop the deferred steps, so the build could pass without them. Without deferred uploads, only the failed job is replaced and independent jobs run; see [Reusable workflows](#workflow-syntax-reusable-workflows).
- Retrying the deferred step is safe: a replayed upload is rejected by Buildkite Pipelines because its step keys already exist, and the step then confirms the earlier upload. Each later stage re-reads earlier producers' verified manifests and compares them with the result digests recorded when their matrices expanded. A missing manifest or a changed result, including a new attempt with identical output, fails before any upload; it is not a skip. Retry the whole build after retrying a producer. See [Results, retries, and cancellation](#runtime-behavior-and-limits-results-retries-and-cancellation).

The `detail` line of `E_MATRIX_INVALID` says why a reference is not supported. Matrices derived from `steps` or from anything other than exactly one `fromJSON(needs.<job>.outputs.<name>)` remain unsupported.

### Scheduling from matrix-producer outputs

A job whose matrix comes from a producer output can read other declared outputs of that same ordinary, single-instance producer for scheduling:

```yaml
build:
  needs: plan
  runs-on: ubuntu-latest
  strategy:
    max-parallel: ${{ fromJSON(needs.plan.outputs.parallel) }}
    matrix:
      include: ${{ fromJSON(needs.plan.outputs.matrix) }}
  steps:
    - run: ./build "${{ matrix.target }}"
```

`parallel` must contain a JSON integer from 1 through 256. The generated limit applies only to this job's matrix in this Buildkite build. See the complete [parallelism example](https://github.com/buildkite/buildkite-gha/blob/main/testdata/scheduling/.github/workflows/parallel.yml).

Alternatively, set `concurrency: ${{ needs.plan.outputs.group }}` on the matrix job. The group can be a template with direct `needs.<job>.outputs.<name>` references and supported pure functions, but no other expression contexts. It must resolve to a nonempty string and uses the same repository-scoped, case-insensitive group mapping as a static group. All instances use that group and run one at a time; adding `max-parallel` does not raise this limit. See the complete [group example](https://github.com/buildkite/buildkite-gha/blob/main/testdata/scheduling/.github/workflows/group.yml).

The deferred upload step waits for every job in the workflow's initial upload, not just the producer, before uploading the consumer and its complete downstream graph. It holds no concurrency slot. This conservative delay avoids ordered-queue edges blocking unfinished static prerequisites. Missing, ambiguous, or invalid scheduling outputs fail the deferred upload. Retrying it verifies the scheduling attributes as well as the job-plan digests.

This support requires a real needs-derived matrix. Scheduling on static matrices, non-matrix jobs, downstream dependents, or reusable-workflow calls remains unsupported, as do projected called-workflow outputs and outputs from other producers. The scheduling consumer's deferred component must have only one matrix and one expansion stage: it cannot join or chain another needs-derived matrix. Independent components and components without output-derived scheduling retain the join and chaining support described in [Matrices from job outputs](#job-syntax-matrices-from-job-outputs). Workflow-level concurrency remains rejected, including called-workflow gates anywhere in the workflow. This does not change gate lifetimes or weaken the early-release rejection described in [Matrices from job outputs](#job-syntax-matrices-from-job-outputs). The existing retry and cancellation rules still apply; this does not add `cancel-in-progress` support.

### Containers and services

**🟡 Supported subset.** Linux jobs support job containers and GitHub-compatible services. Runner-mapped Buildkite cache volumes are not supported for jobs that set `container`.

A typical PostgreSQL service works without Buildkite-specific syntax:

```yaml
services:
  postgres:
    image: postgres:16
    env:
      POSTGRES_PASSWORD: test
    ports:
      - 5432
    options: >-
      --health-cmd pg_isready
      --health-interval 2s
      --health-timeout 5s
      --health-retries 10
```

Job containers support `image`, `env`, `ports`, `volumes`, and `options`. Services support `image`, `credentials`, `env`, `ports`, `volumes`, `options`, `command`, and `entrypoint`. Container `env` keys must start with a letter or underscore and can otherwise contain letters, digits, underscores, or dots. For example, an Elasticsearch service can use `discovery.type: single-node`.

- Job container images can use compile-time `github`, `inputs`, `strategy`, and `matrix` values. A null or exactly empty evaluated image runs the job on the host, including object-form containers, without applying container `env` or `ports`. For example, `container: ${{ matrix.target.container }}` selects host execution when the matrix entry omits `container`. Other results must be strings containing valid image references; whitespace-only results are invalid. Literal images must be non-empty. Secrets, `needs`, step outputs, and whole or dynamic contexts are unsupported.
- Service fields can use compile-time `github`, `inputs`, `strategy`, and `matrix` values or runtime `needs` outputs, including fallback expressions such as `${{ needs.build.outputs.image || 'redis:7' }}`. An empty evaluated image skips the service.
- Service `env` also accepts job environment values and named secrets, including `${{ format('{0}-{1}', env.DATABASE, needs.build.outputs.suffix) }}` and `${{ needs.build.outputs.password || secrets.DB_PASSWORD }}`. It evaluates after job `env` and prerequisite outputs, before standard step environment variables are added. Entries read the job environment, not sibling service entries. Named secrets remain required and redacted even in an unused fallback. Dynamic secret access, direct `github.token`, `runner`, `job`, step outputs, and `hashFiles()` remain unsupported here; this does not broaden other service fields.
- Service `env` resolves `vars` at runtime with the job's [variable scopes](#job-syntax-repository-and-organization-variables). Direct references, separate expression regions such as `${{ env.NAME }}-${{ vars.VALUE }}`, and mixed expressions such as `${{ format('{0}-{1}-{2}', env.NAME, needs.build.outputs.name, vars.VALUE) }}` all use the same snapshot. Other service fields except credentials retain compile-time `vars` values.
- A complete non-credential service map can use `${{ fromJSON(needs.build.outputs.services || '{}') }}`. The argument supports needs-output expressions and pure functions. Declare credentials statically so the compiler can prove their secret authority.
- Credentials accept values and expressions using `github`, `vars`, `secrets`, `env`, `needs`, known `matrix` values, or scalar `strategy` properties, including `${{ needs.auth.outputs.password || secrets.REGISTRY_PASSWORD }}`. Matrix values resolve per expanded job, including deferred matrices and reusable jobs. Both `${{ format('{0}:{1}', matrix.user, secrets.REGISTRY_PASSWORD) }}` and `${{ matrix.user }}:${{ secrets.REGISTRY_PASSWORD }}` work; `vars` retains its runtime scope. Prerequisite results and outputs resolve before service setup. Ordinary secrets remain in the job's required inventory even in an unused fallback. Dynamic secret names and unsupported contexts fail even in unreachable branches; this does not add workflow-input support. Passwords pass to `docker login` through standard input. Authentication uses a private per-job Docker configuration and never reads ambient Docker credentials.
- Job container volumes accept `DESTINATION` for an anonymous volume or `SOURCE:DESTINATION[:ro|rw]` for a named volume or bind mount. `DESTINATION` must be absolute. `SOURCE` must be a Docker volume name or absolute host path. A job can define 128 unique declarations. Expressions are unsupported.
- Job container options pass through to `docker create`, except `--network`, `--net`, and `--entrypoint`, including their `--flag=value` forms. Options split into arguments without a shell. Double quotes group arguments; single quotes are ordinary characters. Expressions, line breaks, NUL bytes, and values over 65,536 bytes are unsupported.
- Service Docker options pass through except `--network` and its `--net` aliases, which GitHub Actions does not support. Options can grant privileges, mount host paths, publish ports, and change resource settings.
- Service named, anonymous, and absolute bind volumes are supported.
- A job can define 32 services. Each service can define 256 environment entries and 128 ports or volumes.

Service `env` admission follows the [GitHub context table](https://docs.github.com/en/actions/reference/workflows-and-actions/contexts#context-availability). On 2026-09-30, source inspection confirmed that the GitHub runner [evaluates services after job env with the same expression context](https://github.com/actions/runner/blob/2009b20729fdf49c50a88e0ca368906c16a3129c/src/Runner.Worker/JobExtension.cs#L235-L259), populated from [server-supplied contexts](https://github.com/actions/runner/blob/2009b20729fdf49c50a88e0ca368906c16a3129c/src/Runner.Worker/ExecutionContext.cs#L1000-L1048). See the [variable-scope evidence and limits](#job-syntax-repository-and-organization-variables). Startup `runner` and `job` contexts remain unimplemented despite their documented GitHub admission.

Matrix credential admission follows the same context table, checked on 2026-09-30. Credential compilation rejects direct `github.token`; use `secrets.GITHUB_TOKEN` under the existing [token authorization](#repositories-credentials-and-github-services-github-token) rules.

Implicit GHCR authentication is unsupported; provide explicit credentials. Mutable tags resolve at job start. Use a digest when image immutability matters. Job container images must provide `sh` and run the mounted self-contained Linux runtime executable.

Each job uses a private Docker bridge network. Container jobs reach services by service name. Host jobs use declared published ports; omitted host ports are assigned dynamically. The `job.services.<service>` context exposes `id`, `network`, and `ports`.

Service IDs can contain dots, for example `s3.docker.test`. Use bracket indexing for these keys: `${{ job.services['s3.docker.test'].ports[6379] }}`. IDs pass unchanged to network aliases and context keys; `s3.docker.test` and `s3-docker-test` remain distinct. Static declarations and expression-generated service maps use the same validation: at most 255 ASCII characters, starting with a lowercase letter or underscore, followed by lowercase letters, digits, underscores, hyphens, or dots. These are the limits that Buildkite Pipelines supports, not a claim that GitHub rejects every other spelling.

On 2026-10-05, a GitHub-hosted run on `ubuntu-latest` confirmed this behavior on GitHub, both on the host and in a digest-pinned Redis job container. Two services named `s3.docker.test` and `s3-docker-test` retained distinct IDs, bracket-indexed `id`, `network`, and `ports`, and separate published ports. The bracket-indexed `id` and `network` step condition evaluated true, and the context checks ran rather than being skipped. Asymmetric Redis `SET` and `GET` checks inside the job container verified that the DNS aliases reached different services. This observation covers dotted service IDs, not uppercase IDs, other punctuation, or maximum-length DNS names.

A service with a Docker health check must become healthy before steps run. A service without one is ready after it starts. Failures include bounded status, health, port, and log diagnostics.

Cleanup removes the job container, emits masked and bounded service logs, then removes services in declaration order, the network, owned volumes, and private Docker configuration. Unless `options` specifies `--volume-driver`, volumes newly created for job `container.volumes` receive a unique ownership label before container creation, so cleanup can recover them after failed or cancelled creation. Pre-existing named volumes retain their labels and are not removed. Docker removes anonymous volumes attached through job `options`, a custom driver, or the image when removing the container. Named volumes supplied only through `options` or created by a custom driver remain because their ownership is ambiguous. Service volumes retain mount-based tracking. Cleanup does not delete unrelated volumes to recover unattributable resources. Remaining owned resources fail the job. Docker resources are not a security or resource-isolation boundary: the hosted queue must isolate the whole job and enforce host CPU, memory, disk, and network limits. See the [security model](/docs/pipelines/migration/run-github-actions-workflows/security#isolate-the-whole-job).

macOS and Windows jobs reject containers, services, Docker actions, and Docker capability.

## Step syntax

This section describes how workflow step keys, commands, actions, and workflow commands run on Buildkite Pipelines.

### Step configuration

| Key | Status | Behavior |
| --- | --- | --- |
| `name`, `id` | ✅ Supported | Use `id` to read outputs or target background work. IDs must be unique within a job. |
| `if` | 🟡 Supported subset | May use step status, step outputs, `env`, and service ports in addition to job-condition contexts. |
| `env` | 🟡 Supported subset | Values override job and workflow values and may use supported expressions, including fallbacks. |
| `continue-on-error` | ✅ Supported | Accepts Boolean literals or expressions that produce a Boolean. A failure records `outcome: failure` and `conclusion: success`, then the job continues. |
| `timeout-minutes` | 🟡 Supported subset | Accepts literal numbers or expressions that produce a number greater than 0 and at most 360. |
{: class="responsive-table"}

Tolerated step failures, including expression failures before a process starts, produce masked warnings in the job log and the workflow warning annotation. This also applies to composite child steps and JavaScript `pre` hooks. The warning doesn't change the step's conclusion. If a composite action's `main` step is skipped, its tolerated `pre` hook warnings appear before result publication.

A step can continue after failure and expose its outcome to a later condition:

```yaml
- id: test
  run: go test ./...
  continue-on-error: true

- if: steps.test.outcome == 'failure'
  run: ./scripts/report-failure
```

### Commands and actions

**🟡 Supported subset.** Use `bash`, `sh`, `pwsh`, `powershell`, `python`, or a custom shell template on Linux or macOS. Custom templates use `command [options] {0} [more-options]`. The `{0}` placeholder receives a temporary script path. Arguments can use single quotes, double quotes, and backslash escapes. They don't expand shell syntax.

Install the interpreter on the runner or in the job container, and add it to `PATH`. PowerShell, R, and Julia aren't installed automatically. Named `pwsh` and `powershell` shells use the corresponding executable without fallback.

Named PowerShell shells run UTF-8 `.ps1` temporary scripts with `$ErrorActionPreference = 'stop'` and propagate the last native command's exit status. PowerShell custom templates receive a `.ps1` script but control their own error behavior. `GITHUB_ENV`, `GITHUB_OUTPUT`, `GITHUB_STATE`, and `GITHUB_PATH` files ignore a leading UTF-8 byte order mark (BOM). BOMs within values remain unchanged.

Windows defaults to `pwsh`. Linux and macOS retain their existing bash default. `cmd` remains unsupported. If the shell name is known before the job starts, the workflow fails before an agent starts the job. A shell expression that needs a runtime value is checked before its step starts. If the expression resolves to an unsupported shell, the step fails.

Native Windows executable paths can use drive-absolute backslashes, such as `D:\cygwin\bin\bash.exe '{0}'`. Quote executable paths containing spaces: `"C:\Program Files\PowerShell\7\pwsh.exe" -File {0}`. The runner must provide the executable at that path. A single backslash after the drive prefix selects literal separators for the executable only. Templates with an escaped first backslash keep their escape rules. Argument escaping is unchanged.

On Windows, `msys2/setup-msys2@v2` can install the `msys2 {0}` custom shell, including through `defaults.run.shell`. The runtime resolves its `.cmd` wrapper from the step's `PATH`, including earlier `GITHUB_PATH` updates. The runtime passes an extensionless, unmodified script and the Windows working directory to the wrapper. MSYS2 supplies its login environment, error handling, and path conversion.

Windows `.cmd` custom shells must forward arguments directly, without `CALL` or delayed expansion. Arguments can contain spaces, single quotes, and metacharacters such as `%`, `!`, `&`, and `^`. Arguments containing double quotes, line breaks, NUL, or a trailing backslash are rejected. These limits don't apply to the script contents. Native executable templates retain their existing argument handling.

Working directories must stay inside the workspace.

A shell step can specify its shell and workspace-relative working directory:

```yaml
- name: Test
  shell: bash
  working-directory: ./src
  run: go test ./...
```

Use an interpreter installed by an earlier step or included in the job image:

```yaml
- shell: pwsh
  run: '"greeting=héllo" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append'

- shell: Rscript {0}
  run: print("R script")

- shell: julia --color=yes {0}
  run: println("Julia script")

- shell: bash -l {0}
  run: conda info
```

A `uses` step may call a supported local or public action. Action inputs under `with` may use supported expressions, including fallbacks. Direct workflow `uses: docker://...` actions are rejected. Prebuilt-image declarations belong in locked action metadata.

Local actions must exist in the event repository when the workflow is compiled. An earlier step can't create a local action with `actions/checkout`, an artifact download, or a command. Use a public `owner/repository/path@ref` action instead.

At execution, local actions require both their action tree and the local workflow bytes to match the plan. Checking out an older commit with a different workflow fails this check even if the action itself is unchanged. `$/` actions don't depend on the checkout and retain their containing source commit.

Action steps can call public and local actions:

```yaml
- uses: actions/checkout@v7

- uses: ./.github/actions/build
  with:
    target: production
```

### Self-repository actions

**🟡 Supported subset.** `uses: $/.github/actions/build` downloads the action from the repository and exact commit containing the workflow, without `actions/checkout`. Inside a remote composite action, `$/` selects that action's repository and commit, not the caller's. `uses: $/` selects an action at the repository root. Existing `./` action paths remain checkout-relative.

Inside a remote composite selected with `@v1`, nested `$/` actions expose `v1` as `github.action_ref`, while their source stays pinned to the resolved commit. A `$/` action called directly by a workflow or from a local composite exposes the containing workflow's commit, not its requested branch or tag. The runtime doesn't populate `GITHUB_ACTION_REF`. Pass `github.action_ref` through a step's `env` when a command needs it.

For local workflow input, `compile`, `upload`, and event-specific validation treat the event repository and commit as candidates, not proof of workflow identity. Before resolving `$/`, the compiler fetches the same workflow path at that commit and requires its bytes to match the supplied file. This also applies to self references inside local reusable workflows. A fetched remote reusable workflow supplies its own immutable identity instead. Modified local workflow bytes fail verification. No Git `HEAD` or workspace fallback is used. Synthetic `validate --event` input has no source identity and reports self-repository resolution as indeterminate. Use an exact `--event-path` snapshot.

Self-repository actions retain the public-action access boundary, immutable tree verification, capability planning, and nested-action limits. A privately fetched reusable workflow doesn't grant access to private actions in that tree. Self paths must be repository-relative, without traversal, backslashes, or an explicit `@ref`.

### Background and parallel steps

**✅ Supported.** The `background`, `wait`, `wait-all`, `cancel`, and `parallel` controls are supported. At most ten background steps run at once inside a job. Use `wait: <id>` for selected steps, `wait-all:` for all active work, or `parallel:` for a fixed group.

Background work can be canceled by step ID:

```yaml
steps:
  - id: server
    run: ./scripts/start-server
    background: true

  - run: ./scripts/test

  - cancel: server
```

A parallel group runs a fixed set of child steps together:

```yaml
steps:
  - parallel:
      - run: ./scripts/lint
      - run: ./scripts/test
```

Outputs, environment changes, and failures become visible at the covering wait. Remaining work is joined before post-action cleanup. These controls aren't supported inside a composite action.

### Environment files

**✅ Supported.** The runtime supports `GITHUB_OUTPUT`, `GITHUB_ENV`, `GITHUB_PATH`, `GITHUB_STATE`, and `GITHUB_STEP_SUMMARY`. Multi-line values are supported. `NODE_OPTIONS` can't be set through `GITHUB_ENV`.

### Workflow commands

| Command | Status | Behavior |
| --- | --- | --- |
| `add-mask`, `stop-commands` | ✅ Supported | Standard command behavior. |
| `warning`, `error` | ✅ Supported | Creates Buildkite build annotations. |
| `group`, `endgroup` | ✅ Supported | Creates linear log sections. |
| Debug and matcher commands | ➖ Accepted, no effect | Consumed without presentation behavior. |
| `notice`, command echo control, other legacy commands | ❌ Unsupported | Not implemented. |
{: class="responsive-table"}

The total job summary is limited to 1 MiB.

## Expressions and contexts

Three expression modes intentionally support different syntax.

| Syntax | Conditions | Runtime interpolation | Other compile-time expressions |
| --- | --- | --- | --- |
| `!`, `&&`, `\|\|`, `==`, `!=`, `<`, `<=`, `>`, `>=` | ✅ Supported | 🟡 Listed workflow fields | 🟡 When the result resolves fully |
| `always()`, `success()`, `failure()`, `cancelled()` | ✅ Without arguments | ❌ Unsupported | ❌ Unsupported |
| `startsWith()`, `contains()`, `endsWith()`, `format()`, `join()`, `toJSON()`, `fromJSON()`, `case()` | ✅ Supported | 🟡 Listed workflow fields | 🟡 When the result resolves fully |
| `hashFiles()` | 🟡 Workflow and composite step `if` and JavaScript lifecycle conditions | 🟡 Workflow and composite step fields | ❌ Unsupported |
{: class="responsive-table"}

### Conditions

Job and step `if` conditions use GitHub-style:

- Truthiness and loose numeric coercion
- Case-insensitive string comparison
- Operand-returning `&&` and `||`
- Primitive conversion for string functions
- Array search with `contains()`
- Lazy `case()` evaluation with 3–255 odd-numbered arguments and Boolean predicates

A missing property in an available `github` or matrix context evaluates to null. An unavailable context is an error. Unlisted functions are unsupported. `hashFiles()` accepts 1–255 literal or direct-reference arguments in step and JavaScript action lifecycle conditions.

Conditions support computed object indexes, numeric array indexes, whole `matrix` and `needs` objects, step-scoped `steps`, and `.*` projections.

- Missing and out-of-range indexes evaluate to null.
- Projections omit missing children.
- A later wildcard flattens one collection level.
- The equivalent `[*]` spelling is unsupported by the parser.
- Whole or dynamic `github`, except event-rooted access, and whole `inputs` and `strategy` remain unsupported.

| Context | Job `if` | Step `if` |
| --- | --- | --- |
| `github.actor`, `github.base_ref`, `github.event_name`, `github.head_ref`, `github.ref`, `github.ref_name`, `github.ref_type`, `github.repository`, `github.repository_owner`, `github.sha`, `github.workflow_ref`, `github.workflow_sha` | ✅ Yes | ✅ Yes |
| `runner.os`, `runner.arch`, `runner.environment` | ✅ Yes | ✅ Yes |
| `runner.temp` | ❌ No | ✅ Yes |
| `runner.debug` | ❌ No | ✅ Yes, always absent |
| `needs.<job>.result`, `needs.<job>.outputs.<name>` | ✅ Yes | ✅ Yes |
| `matrix.<name>` | ✅ Yes | ✅ Yes |
| [Scalar `strategy` properties](#job-syntax-matrix-strategies) | ❌ No | ✅ Workflow steps only |
| `vars.<name>` | ✅ Yes, [repository and organization variables](#job-syntax-repository-and-organization-variables) | ✅ Yes, environment over repository over organization variables |
| `inputs.<name>` and computed input indexes | ✅ Yes | ✅ Yes |
| `steps.<id>.outcome`, `steps.<id>.conclusion`, `steps.<id>.outputs.<name>` | ❌ No | ✅ Yes |
| `env.<name>` | ❌ No | ✅ Yes |
| [`job.services` IDs, networks, and ports](#job-syntax-containers-and-services), including bracket-indexed service IDs | ❌ No | ✅ Yes |
| `github.event`, including direct, projected, and dynamically indexed properties | ✅ Yes | ✅ Yes |
| `secrets` and other contexts | ❌ No | ❌ No |
{: class="responsive-table"}

Before runtime validation, the compiler reduces event-backed conditions from the immutable snapshot. Resolvable `github.event` expressions become literals. For whole or runtime-selected event access, the plan retains a marker and digest for the build's shared event payload artifact.

Every branch is validated first, so short-circuiting can't hide an unsupported function, context, or matrix type.

Reusable-workflow call conditions use the same operators and status functions but only the caller contexts listed in [Reusable workflows](#workflow-syntax-reusable-workflows). The runtime evaluates their ordered guards before the called job's own condition.

### Runtime interpolation

The `||` operator selects its right operand when the left is falsy: `false`, zero, an empty string, or null. The operator doesn't recover from evaluation or secret-retrieval errors, grant access to an unavailable context, or make a literal-only field accept expressions. Each field retains its context and result-type restrictions.

These step fields support the operators and pure functions listed above:

- `run`, `env`, `with`, and `name`
- Explicit `shell` and `working-directory`
- `continue-on-error` and `timeout-minutes`

These fields support computed indexes and projections over available `matrix`, `inputs`, `env`, `vars`, and `runner` values. `toJSON(needs)` serializes only direct dependencies, with each job's `result` and `outputs` object (empty when there are no outputs). Transitive dependencies aren't included. Computed and projected `needs` access and computed, whole, and projected `steps` access remain unsupported. Reading an unavailable background output is an error.

Before creating a job plan, the compiler resolves scalar `github.event.*` values and event-dependent parts of otherwise runtime expressions.

Missing event members become null. Template interpolation renders null as an empty string. Event values can't introduce new `${{ ... }}` regions. A job that still needs whole, projected, or dynamically indexed `github.event` access loads the digest-verified event artifact uploaded by the exact importer job. This preserves the original event for runtime use and retries without duplicating it across immutable plans. Jobs with an [event file](#expressions-and-contexts-event-file) also load this artifact, even without event expressions.

Job-level expressions support the same operators and pure functions with these field-specific contexts:

| Field | Contexts |
| --- | --- |
| `continue-on-error` | `github`, `needs`, `strategy`, `matrix`, `vars`, `inputs` |
| `env` | `github`, `needs`, `strategy`, `matrix`, `vars`, `secrets`, `inputs` |
| `defaults.run` | `github`, `needs`, `strategy`, `matrix`, `env`, `vars`, `inputs` |
| `outputs` | `github`, `needs`, `strategy`, `matrix`, `job`, `runner`, `env`, `vars`, `secrets`, `steps`, `inputs` |
{: class="responsive-table"}

Workflow-level `env` values use the job `env` expression rules except for `strategy`. Fallbacks such as `${{ github.head_ref || github.ref_name }}` are supported. Workflow-level `defaults.run` remains limited to direct context references.

Workflow step fields and the composite step `run`, `env`, `with`, `shell`, and `working-directory` fields support `hashFiles()` and the listed operators and pure functions. Job-level fields, action input defaults, and composite output metadata can't call `hashFiles()`. A composite action can expose a hash through a step output instead.

The listed workflow step fields and job fields accept the four [scalar `strategy` properties](#job-syntax-matrix-strategies), including static bracket access such as `${{ strategy['job-index'] }}`. These fields can combine strategy with runtime values, for example `${{ format('{0}-{1}', strategy.job-index, steps.build.outputs.version) }}`. Whole, projected, and computed strategy access remains unsupported. This doesn't add strategy to job `if`, workflow-level fields, reusable-call `with`, or action-authored metadata, including composite steps.

Admission follows the [GitHub context table](https://docs.github.com/en/actions/reference/workflows-and-actions/contexts#context-availability), checked against the [runner schema](https://github.com/actions/runner/blob/15231bede4aacecb6686f4b7de25c62398607993/src/Sdk/DTPipelines/workflow-v1.0.json) and [language-services schema](https://github.com/actions/languageservices/blob/4043eda158e16579cc5fb1b0b07a4bce2a76f0b5/workflow-parser/src/workflow-v1.0.json). The GitHub documentation and language-services schema disagree on caller `with` admission. That field is unchanged.

Job outputs support service IDs, networks, and published ports through `job.services`, such as `${{ job.services.redis.ports[6379] }}`. Other `job` fields remain unsupported. The listed job-level fields also support `toJSON(needs)`, with the same direct-dependency scope and access restrictions as step fields.

Expression-valued `continue-on-error` must produce a Boolean. Expression-valued `timeout-minutes` must produce a number greater than 0 and at most 360.

Direct `github.token` references are step-only. Step runtime fields also support the exact, case-insensitive call `toJSON(github)`. This call serializes the retained context listed below, including `token`, with sorted keys and two-space indentation.

The compiler treats that call as a token reference, so normal permissions, admission, and redaction apply. Composite steps can consume an already authorized context, but composite metadata can't grant token authority. A tokenless context is an error.

Job-level fields and action input defaults can't call `toJSON(github)`. Bare, projected, or dynamically indexed `github`, and passing the whole context to another function, remain unsupported. These limits don't apply to access rooted at `github.event`.

`runner.os` and `runner.arch` resolve to `Linux`/`X64`, `Linux`/`ARM64`, `macOS`/`ARM64`, or `Windows`/`X64`. `runner.environment` resolves to `self-hosted`. GitHub assigns this value to runners registered outside GitHub, including managed providers. Buildkite agents are in the same class, whether they're Buildkite hosted agents or run on your own infrastructure.

After runner setup, step runtime fields and job outputs can also use `runner.temp`, which resolves to the canonical temporary directory exposed as `RUNNER_TEMP`. Those fields, action lifecycle conditions, and action metadata input defaults can use `runner.debug`. Buildkite Pipelines has no equivalent step-debug mode, so the property is absent: direct interpolation is empty and comparison with the GitHub enabled value `1` is false. Other runner fields and compile-time positions that require runner identity are unsupported. `job.check_run_id` defaults, including the static indexed spelling, resolve to an empty string because Pipelines doesn't create a GitHub check run. Other `job` identity fields remain unsupported.

A runtime interpolation can read a verified upstream output directly:

```yaml
run: echo "${{ needs.build.outputs.image }}"
```

The runtime retains this bounded `github` context:

| Fields | Behavior |
| --- | --- |
| `actor`, `event_name`, `ref`, `repository`, `sha` | Event identity from the compiled plan. |
| `repository_owner` | Derived from `repository`. |
| `server_url` | Identifies the event repository provider. |
| `job` | Workflow job ID. |
| `workflow` | Workflow name, or its path when unnamed. |
| `head_ref`, `base_ref` | Pull request source and target branches. Empty for other events. |
| `ref_name` | Ref without `refs/heads/`, `refs/tags/`, or `refs/pull/`. Pull request refs use `<number>/merge` or `<number>/head`. |
| `ref_type` | `branch` for branch and pull request refs. `tag` for tag refs. SHA-only `deployment` and `deployment_status` events also use `branch`, while `ref` and `ref_name` remain empty. |
| `action_path` | Composite action directory inside composite steps. Empty elsewhere. |
| `action_repository`, `action_ref` | Remote composite repository and requested ref. Empty for local composites and outside composite steps. |
| `workspace` | The workspace directory: the fixed job-container mount for container jobs, the host checkout directory otherwise. Exposed as `GITHUB_WORKSPACE`. |
| `run_id`, `run_number`, `run_attempt` | Buildkite Pipelines build identity: the build ID, the build number, and the retry count plus one. Exposed as `GITHUB_RUN_ID`, `GITHUB_RUN_NUMBER`, and `GITHUB_RUN_ATTEMPT`. Referencing them outside a Buildkite build is an error, and GitHub run URLs or API calls built from them don't resolve because no GitHub Actions run exists. |
| `token` | Available only in an authorized step expression. |
| `event` | The immutable, digest-verified event payload, loaded for runtime event expressions or an [event file](#expressions-and-contexts-event-file). |
{: class="responsive-table"}

This isn't the full GitHub context.

`hashFiles()` evaluates when its step field is consumed, so it sees files from earlier steps such as checkout. The `with` and `env` of a JavaScript action can be evaluated for `pre`, then evaluated again for `main`, including inside a composite action. Composite patterns use the job workspace, not the action directory or the step's `working-directory`. The same hashing limits and filesystem boundaries apply to workflow and composite steps.

Patterns apply in order. `!` excludes matches. A later positive pattern can add them back. Directory matches include descendants, hidden files match normally, and overlapping patterns hash each path once. Matching is case-insensitive only on Windows. An empty match returns an empty string, not an error or warning, matching GitHub behavior. For example, a key such as `npm-${{ hashFiles('package-lock.json') }}` becomes `npm-` if the lockfile is missing. When the file is required, reject an empty digest before using the cache:

```yaml
# Run after checkout. These steps also work inside a composite action.
- id: lockfile
  shell: sh
  env:
    HASH: ${{ hashFiles('package-lock.json') }}
  run: |
    if [ -z "$HASH" ]; then
      echo "package-lock.json was not found; check checkout order and the hashFiles pattern" >&2
      exit 1
    fi
    printf 'digest=%s\n' "$HASH" >> "$GITHUB_OUTPUT"
- uses: actions/cache@v5
  with:
    path: ~/.npm
    key: npm-${{ runner.os }}-${{ steps.lockfile.outputs.digest }}
```

Hashing failures reach the job log with step and field context. Composite errors include the child step number, and `env` and `with` errors also name the binding. Direct calls in composite output metadata explain the restriction and suggest exposing a step output. Step or job cancellation and deadline messages identify hashing as the interrupted operation.

On Linux, literal paths use direct lookups. macOS and Windows enumerate directory names to preserve platform-specific matching. Each positive pattern searches below its literal directory prefix, then walks recursively from its first wildcard. For example, `packages/service/*.go` searches under `packages/service`, while `packages/s*/value` searches under `packages`. Negative patterns filter matches but don't prune traversal, since later patterns can re-include files. The entry limit counts inspected entries, including non-matching entries, rather than the size of the workspace.

Each call has an execution budget covering traversal, matching, sorting, hashing, and verification. An earlier step or job deadline still applies. Cancellation is checked between operations, and can't interrupt a blocked filesystem call. Entry-limit and execution-budget errors list the positive patterns being searched and recommend more specific paths to reduce traversal and hashing. The budget is shared across those patterns.

For each file, `hashFiles()` calculates SHA-256 over its contents. The function then hashes the concatenated binary digests in lexical path order. GitHub Runner doesn't specify glob traversal order, so a multi-file digest can differ when the GitHub order isn't lexical.

Patterns can't be absolute or contain `..` segments or ASCII control characters. Hashing stays inside the workspace and doesn't enter symlinked directories. A matched symlink or other non-regular file fails the step.

GitHub Runner can hash a file symlink and has an optional symlink-following mode. This runtime deliberately does neither.

### Event file

When upload receives a linked webhook or an explicit `--event-path` snapshot, `GITHUB_EVENT_PATH` points to a job-scoped JSON file containing its complete `payload` object, not the snapshot wrapper. JSON formatting may differ from the original. The reduced fallback synthesized from Buildkite environment variables doesn't create this file.

The file is available to shell steps, JavaScript `pre`, `main`, and `post` hooks, nested composites, and Docker actions. Job containers and Docker actions receive a read-only mount with a container-local path. The file lives outside the checkout and writable runner temp, survives through post hooks, and is removed at job teardown. Job and step environment overrides can't replace the runtime path. `github.event_path` expressions aren't supported.

```yaml
- run: jq -e '.issue.number == 42' "$GITHUB_EVENT_PATH" >/dev/null
```

Event retention follows the [event payload security boundary](/docs/pipelines/migration/run-github-actions-workflows/security#repository-data-does-not-grant-authority-source-and-event-checks).

### Compile-time expressions

Matrices, runner labels, names, concurrency groups, retained runtime templates, and event-backed conditions can use statically known `github`, `event`, and matrix values.

Compile-time `github` fields are `actor`, `base_ref`, `event_name`, `head_ref`, `ref`, `ref_name`, `ref_type`, `repository`, `repository_owner`, `sha`, and `workflow`. Expressions can use computed indexes, numeric array indexes, and `.*` projections when the complete result resolves during compilation.

Whole or dynamic `github` access remains unsupported unless it's rooted at `github.event`. Event-backed runtime expressions can combine reducible event parts with values supported by their runtime surface. Action references in `uses` must remain static and can't use `github.event`.

## Actions

This section describes which action sources and runtimes are supported, and how common GitHub-authored actions run on Buildkite Pipelines.

### Action sources and runtimes

| Action type | Status | Boundary |
| --- | --- | --- |
| Local `./...` action | 🟡 Supported subset | Source tree is digest-locked and verified again. |
| Public `owner/repo[/path]@ref` action | 🟡 Supported subset | Resolved to an exact commit and digest. |
| Private action | ❌ Unsupported | No private action source access. |
| JavaScript action | ✅ Supported | Declares `node16`, `node20`, or `node24`. |
| Composite action | 🟡 Supported subset | Nested shell steps and locked local or public actions. [Supported shells](#step-syntax-commands-and-actions), including expression-backed custom templates, for `run`. Literal `continue-on-error`. |
| Docker action | 🟡 Supported subset | Verified local or public Dockerfile or prebuilt-image action on Linux with optional bounded `runs.args`. Rejected on macOS and Windows, including through a composite action. |
| Direct workflow `uses: docker://...` action | ❌ Unsupported | Rejected during validation. |
| Top-level action metadata `env` | ➖ Accepted, no effect | Any valid YAML value is discarded. The value isn't evaluated, injected, retained in plans, or used to request secrets or tokens. |
{: class="responsive-table"}

Mutable public refs are resolved during upload, then locked to a commit. The importer lazily requests one Buildkite action-source token and reuses it across all workflow roots and nested composite actions. This token authenticates only public metadata requests for repositories other than the credential repository. The credential repository and codeload requests remain anonymous. If token issuance is unavailable during rollout, resolution safely falls back to anonymous GitHub API access. Exact lowercase commit SHAs need no GitHub API lookup. Complete source trees are verified again at runtime.

Authenticated resolution shares successful repository visibility checks within each compilation. Later compilations recheck visibility when resolving uncached refs. When GitHub rate-limits requests, the resolver suppresses new API requests against the affected authenticated or anonymous budget until the retry deadline, or for one minute if GitHub supplies none. Already running requests may finish. Cached refs and exact commit pins remain usable. Rate-limit suppression stays local to that resolver and isn't written to source caches.

Nested calls from a repository-local composite must be local. Public composites may call local children or other public actions. Every child is resolved and locked.

Prebuilt-image actions declare `docker://` in action metadata, not in a workflow step:

```yaml
name: Check formatting
inputs:
  path:
    default: .
runs:
  using: docker
  image: docker://ghcr.io/example/formatter@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
  entrypoint: /usr/local/bin/formatter
  args:
    - ${{ inputs.path }}
```
{: codeblock-file="action.yml"}

```yaml
steps:
  - uses: example/formatter-action@v2
    with:
      path: .
```
{: codeblock-file=".github/workflows/check.yml"}

The compiler locks the action source that declares the image. At job start, the runtime pulls each declared image anonymously through an empty private Docker configuration. Action metadata has no registry-credential field, so private images and ambient Docker credentials are unsupported. Digest references are supported. Mutable tags resolve when the job starts and can drift between jobs. Use a digest when image immutability matters.

Direct workflow syntax such as `uses: docker://alpine:3.20` remains unsupported. This syntax doesn't have the locked action-source provenance used by metadata-declared images.

Dockerfile actions require exact `runs.image: Dockerfile`. Optional `runs.args` must be an ordered YAML string array. Each item becomes one argument after the image name, without a shell. Empty strings, whitespace, and shell metacharacters remain literal. Omitted or empty args keep the image `CMD`. Any non-empty array replaces `CMD` while preserving the image `ENTRYPOINT`. The optional `runs.entrypoint` of a prebuilt-image action overrides the image `ENTRYPOINT`.

Items in `args` may contain literals and expressions using action `inputs`, operators, and the supported pure functions, such as `${{ inputs.name || 'default' }}`. Other contexts, credentials, status functions, and `hashFiles()` are rejected, including in unreachable branches. Invocation inputs and metadata defaults resolve before args evaluation. Substituted values remain literal arguments. The compiler stores args as action-authored sites in the normalized job program. The runtime verifies the locked action tree but doesn't parse its metadata again.

Dockerfile actions can't declare explicit entrypoints. Docker actions can't declare `pre` or `post` lifecycle or request credentials, volumes, arbitrary options, or privileged mode. An argument such as `--privileged` remains a container argument. It can't become a Docker option.

Action metadata parsing remains strict for every other unknown top-level field and for unknown nested fields. The inert top-level `env` exception doesn't replace workflow or action-step environments, populate `runs.env`, or add `GITHUB_TOKEN` authority.

| Action declaration | Runtime |
| --- | --- |
| `node16` | Managed Node 16.20.2, with one end-of-job deprecation warning. |
| `node20` | Managed Node 24.18.0. |
| `node24` | Managed Node 24.18.0. |
{: class="responsive-table"}

The `pre`, `main`, and `post` phases, inputs, outputs, state, and LIFO post ordering are supported. Other Node declarations are rejected.

JavaScript action `pre-if` and `post-if` metadata uses the condition operators, status functions, pure functions, and `hashFiles()` described in [Conditions](#expressions-and-contexts-conditions). Lifecycle conditions can read direct properties from workflow `inputs`, `env`, `github`, `job.services`, `matrix`, `runner`, and `steps`, and direct or dynamic `github.event` properties. Other contexts and dynamic or whole-context access return an error. An empty lifecycle condition always runs and doesn't receive an implicit `success()` guard.

The `pre-if` conditions use the status and action-scoped environment available when preparation reaches the action. The `post-if` conditions run during job teardown and use the final job status and environment, including `GITHUB_ENV` changes from main. Root action posts also see final workflow step state. Nested composite actions retain their isolated step context. Cancellation remains distinct from failure, and posts keep LIFO order.

### Repository archive symlinks

Repository archive extraction omits relative symlinks whose targets are extracted regular files or directories inside the repository. Extraction never creates or follows the aliases. Actions that need those alias paths remain unsupported. This allows unused repository fixtures and tooling aliases, not general symlink compatibility. Source digests describe the extracted tree without aliases.

Dangling links, link chains, absolute or escaping targets, hardlinks, special files, and duplicate or case-colliding paths remain rejected. An alias can't be an ancestor of another archive entry, in either archive order. Archive size, entry-count, and path limits apply even when aliases are omitted.

For example, the [TruffleHog archive at `e283608`](https://codeload.github.com/trufflesecurity/trufflehog/tar.gz/e28360875917c9c730f9c64aae29fb93aa6c359e) contains `.claude/skills/dep-updates` and `.codex/skills/dep-updates`, both pointing to `../../.cursor/skills/dep-updates`. That directory and its `SKILL.md` are present, and the [action](https://github.com/trufflesecurity/trufflehog/blob/e28360875917c9c730f9c64aae29fb93aa6c359e/action.yml) uses neither alias.

On Linux, [GitHub Runner 2.337.0 extracts remote actions with `tar -xzf`](https://github.com/actions/runner/blob/397b032cbf865e9c3ddfab89d533ec19325e1273/src/Runner.Worker/ActionManager.cs#L1330-L1375), then [recursively copies directories](https://github.com/actions/runner/blob/397b032cbf865e9c3ddfab89d533ec19325e1273/src/Runner.Sdk/Util/IOUtil.cs#L394-L429), following directory aliases into real copied directories. Buildkite Pipelines deliberately omits the alias paths instead. This preserves the files an action reads, not the full tree semantics of the GitHub runner. On Windows, GitHub Runner uses ZIP extraction, which this comparison doesn't cover.

### Checkout action

**🟡 Supported subset.** Immutable commits captured from frozen upstream tags, `main`, `master`, and `releases/v1` through `releases/v6` snapshots are admitted. The snapshot includes historical development and release commits across v1 through v7. These known releases identify the principal contracts:

| Release | Commit |
| --- | --- |
| v1.2.0 | [`50fbc622fc4ef5163becd7fab6573eac35f8462e`](https://github.com/actions/checkout/tree/50fbc622fc4ef5163becd7fab6573eac35f8462e) |
| v2.8.0 | [`0717577d45739eb3c851188b29f50ed6c0b2194e`](https://github.com/actions/checkout/tree/0717577d45739eb3c851188b29f50ed6c0b2194e) |
| v3.7.0 | [`a37ce9120846195fa4ece8f58b268e6043cb2f26`](https://github.com/actions/checkout/tree/a37ce9120846195fa4ece8f58b268e6043cb2f26) |
| v4 | [`11d5960a326750d5838078e36cf38b85af677262`](https://github.com/actions/checkout/tree/11d5960a326750d5838078e36cf38b85af677262) |
| v5 | [`fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09`](https://github.com/actions/checkout/tree/fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09) |
| v6 | [`d23441a48e516b6c34aea4fa41551a30e30af803`](https://github.com/actions/checkout/tree/d23441a48e516b6c34aea4fa41551a30e30af803) |
| v7.0.0 corpus pin | [`9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0`](https://github.com/actions/checkout/tree/9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0) |
| v7.0.1 | [`3d3c42e5aac5ba805825da76410c181273ba90b1`](https://github.com/actions/checkout/tree/3d3c42e5aac5ba805825da76410c181273ba90b1) |
{: class="responsive-table"}

Every resolved immutable commit uses the native adapter. The upstream JavaScript doesn't run. Commits in the frozen snapshots retain their exact inputs, full-history default, and outputs. For example, early v2 commits reject later v2 inputs, and v4.0 and v4.1 commits don't expose the `ref` and `commit` outputs.

An immutable commit absent from the snapshots uses the stable v7.0.1 contract as a compatibility fallback. Compilation emits one `W_CHECKOUT_UNKNOWN_COMMIT_FALLBACK` warning for each distinct unknown commit. This higher-risk fallback can differ from the upstream manifest of the commit, but it doesn't widen the native adapter: repository, ref, path, credentials, and every other input still use the restrictions below. Known snapshotted commits never use the fallback. Compilation emits `W_CHECKOUT_LEGACY_RELEASE` for v1.2.0 and v2.8.0 to nudge an upgrade to v4 or later.

The frozen refs and per-commit profiles are refreshed when `buildkite-gha` is updated. Only commits reachable from the selected upstream tags and branches at that time receive exact profiles. Other valid resolved SHAs continue to use the fallback.

Buildkite Pipelines runs v1.2.0 like v1 and v2.8.0 like v2, and warns about their differences from v4 and later. Neither release sets the `ref` or `commit` outputs added in v4.2.0. v1.2.0 also fetches full history by default when `fetch-depth` is omitted. Upgrade only if your workflow needs those outputs or different v1 history behavior. Otherwise, keep the current version.

The adapter checks out a detached commit or static branch from the event repository at the workspace root or a clean nested directory. The adapter uses Buildkite repository-provider Git credentials when the job provides them. Otherwise, the adapter fetches anonymously. Credentials are scoped to the Git commands that fetch repository, LFS, or submodule data and are never persisted.

Checkout input restrictions don't block a whole job whose `if` condition is statically false after reusable inputs and matrix values resolve. The skipped job and locked action remain in the plan so the job can publish a `skipped` result. Step-level and runtime-dependent conditions remain conservative and validate the adapter inputs.

An explicit input is accepted only when the exact snapshotted contract declares it, or when the v7.0.1 fallback contract declares it for an unknown commit. The following value restrictions then apply:

| Input | Supported values |
| --- | --- |
| `repository` | Omitted, or the event `owner/repo`. |
| `ref` | Omitted, empty, a lowercase 40-hex commit, or a static branch in the event repository. A direct `github.sha` or `needs.<job>.outputs.<name>` expression must resolve at runtime to the exact event SHA. |
| `token` | Omitted only. |
| `ssh-key`, `ssh-known-hosts` | When declared by the commit: omitted or empty. Otherwise omitted. |
| `ssh-strict` | When declared by the commit: omitted or `true`. Otherwise omitted. |
| `ssh-user` | When declared by the commit: omitted or `git`. Otherwise omitted. |
| `persist-credentials` | When declared by the commit: omitted or `false`. Otherwise omitted. |
| `path` | Omitted, empty, or a clean relative directory without a `.git` path segment. The resolved path stays inside the workspace and can't traverse symbolic-link parents. |
| `clean` | Omitted, `true`, or `false`. The root workspace must be empty, or the selected path must be absent. Existing-directory reuse is unsupported, so `false` differs only by matching workflows that select a fresh target. |
| `filter` | When declared by the commit: omitted, empty, or one Git partial-clone filter without control characters. Otherwise omitted. |
| `sparse-checkout` | When declared by the commit: omitted, empty, or up to 1,000 non-empty patterns totaling at most 1 MiB. Otherwise omitted. |
| `sparse-checkout-cone-mode` | When declared by the commit: omitted, `true`, or `false`. Otherwise omitted. |
| `fetch-depth` | Omitted or a nonnegative integer. `0` fetches full history. Historical runner-plugin commits fetch full history when omitted. |
| `fetch-tags` | When declared by the commit: omitted, `true`, or `false`. Otherwise omitted. |
| `show-progress` | When declared by the commit: omitted, `true`, or `false`. Otherwise omitted. |
| `lfs` | Omitted, `true`, or `false`. `true` requires Git LFS in the job image. |
| `submodules` | Omitted, `false`, `true`, or `recursive`. Whitespace is trimmed and casing is ignored. |
| `set-safe-directory` | When declared by the commit: omitted or `true`. Otherwise omitted. |
| `github-server-url` | When declared by the commit: omitted, empty, or `https://github.com`. Otherwise omitted. |
| `allow-unsafe-pr-checkout` | When declared by the commit: omitted or `false`. Otherwise omitted. |
{: class="responsive-table"}

The `ref` and `commit` outputs are available when the selected exact or fallback contract declares them. Exact contracts follow the action manifest of each commit. The v7.0.1 fallback exposes both outputs. Upstream added both outputs in v4.2.0.

The `false` value and omission don't run submodule commands. The `true` value runs native Git for direct children, and `recursive` includes nested children. Relative URLs and `fetch-depth` follow native Git behavior. Public and private GitHub submodules are supported under the repository access of the job. External HTTPS submodules are anonymous. `git@github.com:` URLs are rewritten to HTTPS. Other SSH and non-HTTPS transports are unsupported.

Sparse checkout applies `blob:none` automatically unless `filter` is explicit. Cone mode treats each line as a directory. Non-cone mode uses Git ignore-style patterns. LFS configures repository-local filters before fetch without installing push or locking hooks. A regular checkout fetches the LFS objects of the selected revision before checkout. Sparse checkout lets Git LFS download only materialized paths.

```yaml
- uses: actions/checkout@v7
  with:
    path: sources/application
    filter: blob:limit=1m
    fetch-depth: 20
    show-progress: false

- uses: actions/checkout@v7
  with:
    path: sources/docs
    sparse-checkout: |
      docs
      schemas

- uses: actions/checkout@v7
  with:
    lfs: true
    sparse-checkout: |
      /*.md
      /assets/
      !/assets/archive/
    sparse-checkout-cone-mode: false
```

See the [security model](/docs/pipelines/migration/run-github-actions-workflows/security#checkout-and-submodules) for credential, Git, and job-isolation boundaries.

Alternate repositories, tags, non-event dynamic commits, GitHub Enterprise Server, credential persistence, and existing-directory reuse remain unsupported. Commit and branch checkouts remain detached and confined to the event repository.

### Upload artifact action

**🟡 Supported subset.** Resolved commits in the frozen upstream release and `main` snapshots use a native Buildkite ZIP adapter. These principal releases remain named compatibility points:

| Release | Commit |
| --- | --- |
| v1.0.0 | [`3446296876d12d4e3a0f3145a3c87e67bf0a16b5`](https://github.com/actions/upload-artifact/tree/3446296876d12d4e3a0f3145a3c87e67bf0a16b5) |
| v2.3.1 | [`82c141cc518b40d92cc801eee768e7aafc9c2fa2`](https://github.com/actions/upload-artifact/tree/82c141cc518b40d92cc801eee768e7aafc9c2fa2) |
| v3.2.1 | [`ff15f0306b3f739f7b6fd43fb5d26cd321bd4de5`](https://github.com/actions/upload-artifact/tree/ff15f0306b3f739f7b6fd43fb5d26cd321bd4de5) |
| v4.6.0 | [`65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08`](https://github.com/actions/upload-artifact/tree/65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08) |
| v4.6.2 | [`ea165f8d65b6e75b540449e92b4886f43607fa02`](https://github.com/actions/upload-artifact/tree/ea165f8d65b6e75b540449e92b4886f43607fa02) |
| v5.0.0 | [`330a01c490aca151604b8cf639adc76d48f6c5d4`](https://github.com/actions/upload-artifact/tree/330a01c490aca151604b8cf639adc76d48f6c5d4) |
| v6.0.0 | [`b7c566a772e6b6bfb58ed0dc250532a479d7789f`](https://github.com/actions/upload-artifact/tree/b7c566a772e6b6bfb58ed0dc250532a479d7789f) |
| v7.0.1 | [`043fb46d1a93c77aae656e7c1c64a875d1fc6a0a`](https://github.com/actions/upload-artifact/tree/043fb46d1a93c77aae656e7c1c64a875d1fc6a0a) |
{: class="responsive-table"}

Each snapshotted commit retains the inputs, outputs, hidden-file default, and v1 path behavior declared by its upstream contract. For example, v4.0.0 accepts `compression-level` but rejects the later `overwrite` and `include-hidden-files` inputs, and exposes `artifact-id` without the later `artifact-digest`. The v3.2.2 and v3.2.2-node20 commits remain unsupported because upstream publishes them only as GitHub Enterprise Server security backports and deprecates them on github.com.

An immutable commit absent from the snapshot uses the stable v7.0.1 contract as a compatibility fallback. Compilation emits one `W_UPLOAD_ARTIFACT_UNKNOWN_COMMIT_FALLBACK` warning for each distinct unknown commit. The fallback can differ from the upstream manifest of the commit, but it doesn't widen the native adapter or execute upstream JavaScript. Malformed commits remain unsupported. Compilation emits `W_UPLOAD_ARTIFACT_LEGACY_RELEASE` for the principal v1 through v3 releases to recommend v4 or later.

The frozen tags, branches, and per-commit profiles are refreshed when `buildkite-gha` is updated. Only manifests whose inputs and outputs fit the bounded adapter are recorded. Other valid resolved SHAs continue to use the fallback.

| Input | Supported values |
| --- | --- |
| `name` | Required by v1 runner-plugin contracts. Later contracts default to `artifact`. |
| `path` | Required. v1 runner-plugin contracts accept one literal file or directory. Later contracts accept literal paths or bounded `*`, `?`, character-class, and `**` file globs. |
| `if-no-files-found` | When declared: `warn`, `error`, or `ignore`. The v1 runner-plugin contract fails when its literal path is missing and uploads an empty existing directory. |
| `retention-days` | When declared: nonnegative integer. Advisory only. |
| `compression-level` | When declared: `0` through `9`. |
| `overwrite` | When declared: omitted or `false`. |
| `include-hidden-files` | When declared: GitHub Actions boolean, default `false`. Earlier contracts without this input retain hidden paths. |
| `archive` | When declared: omitted or `true`. |
{: class="responsive-table"}

Native jobs accept workspace-relative or absolute paths on the execution platform, including Windows drive paths such as `C:\build\dist\*.zip`. An absolute glob such as `/tmp/baipp/dist/*` archives paths relative to its literal search root (`package.whl`, not `tmp/baipp/dist/package.whl`). Multiple selections use their least common ancestor as the archive root.

Job-container uploads require workspace-relative paths. The native adapter rejects absolute paths at runtime rather than interpreting a container path in the host filesystem. This restriction doesn't apply to native jobs that only use service containers.

Unsupported path forms include exclusions, symlinks, traversal, UNC and drive-relative paths, alternate data streams, braces, extglobs, leading glob comments, and special files. Selections on different Windows volumes can't share an archive root. At most 32 path roots may be selected. Contracts that declare `include-hidden-files` exclude hidden path segments unless explicitly enabled.

An artifact may contain at most 10,000 files. `buildkite-gha` doesn't impose a source or ZIP byte limit. The Buildkite agent and configured artifact storage enforce their limits. A job may publish 64 artifacts.

Downloads verify the recorded archive size and digest before staging every member. File-count, path, format, and filesystem limits protect extraction. There is no separate fixed expansion-byte policy.

The adapter sets `artifact-id` and `artifact-digest` only when the snapshotted or fallback contract declares them. `artifact-url` remains empty because no GitHub run-scoped URL exists. Merge, raw upload, overwrite, and effective retention control are unsupported.

### Download artifact action

**🟡 Supported subset.** These root `actions/download-artifact` actions use the same producer-bound ZIP mode:

| Release | Commit |
| --- | --- |
| v4.3.0 | [`d3f86a106a0bac45b974a628896c90dbdf5c8093`](https://github.com/actions/download-artifact/tree/d3f86a106a0bac45b974a628896c90dbdf5c8093) |
| v5.0.0 | [`634f93cb2916e3fdff6788551b99b062d0335ce0`](https://github.com/actions/download-artifact/tree/634f93cb2916e3fdff6788551b99b062d0335ce0) |
| v6.0.0 | [`018cc2cf5baa6db3ef3c5f8a56943fffe632ef53`](https://github.com/actions/download-artifact/tree/018cc2cf5baa6db3ef3c5f8a56943fffe632ef53) |
| v7.0.0 | [`37930b1c2abaa49bbe596cd826c3c89aef350131`](https://github.com/actions/download-artifact/tree/37930b1c2abaa49bbe596cd826c3c89aef350131) |
| v8.0.0 | [`70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3`](https://github.com/actions/download-artifact/tree/70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3) |
| v8.0.1 | [`3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c`](https://github.com/actions/download-artifact/tree/3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c) |
{: class="responsive-table"}

An unknown lowercase 40-hex immutable commit uses the stable v8.0.1 contract as a compatibility fallback. Compilation emits one `W_DOWNLOAD_ARTIFACT_UNKNOWN_COMMIT_FALLBACK` warning for each distinct unknown commit. The fallback can differ from the upstream manifest of the commit, but it doesn't widen the native adapter or execute upstream JavaScript. Malformed commits remain unsupported.

| Input | Supported values |
| --- | --- |
| `name` | Exact name, mutually exclusive with `pattern`. Runtime expressions are allowed. |
| `pattern` | Bounded artifact-name glob. Requires `merge-multiple: true`. Runtime expressions are allowed. |
| `path` | Optional literal workspace-relative path. |
| `merge-multiple` | Omitted or `false` with `name`. Required `true` with `pattern`. |
| v8 `skip-decompress` | Omitted or `false`. |
| v8 `digest-mismatch` | Omitted or `error`. |
{: class="responsive-table"}

Artifacts must come from verified direct `needs` producers. Exact-name lookup must find one unique artifact. A bounded pattern may select and deterministically merge up to 64 distinct names. The shipped pattern contract accepts `*`, `?`, character classes, and `**`. Brace alternation remains unsupported.

When artifacts contain the same exact member path, the later artifact by name wins. All matched archives are validated and staged before the destination changes. Artifact ID, all-artifact, cross-run, cross-repository, raw, REST, and non-merged pattern modes are unsupported.

Only ZIP archives produced by the supported upload adapter are accepted. Digest or ZIP validation failure is fatal. The `download-path` output is supported.

### Cache action

Workflow-level and job-level `cache-mode` accept exactly `read`, `write`, `write-only`, and `none`. Jobs inherit the workflow mode unless they override it. For example, workflow `cache-mode: write` with job `cache-mode: read` selects read-only client behavior for that job. Modes on reusable-workflow calls or in workflows declaring `workflow_call` aren't supported.

The compiled plan carries the effective mode. JavaScript action `pre`, `main`, and `post` phases receive it as `ACTIONS_CACHE_MODE`, overriding workflow or action environment values. Omitting both keys preserves existing behavior. The runtime doesn't choose a trigger-based default.

| Mode | Client restore | Client save |
| --- | --- | --- |
| `read` | Allowed | Skipped |
| `write` | Allowed | Allowed |
| `write-only` | Skipped | Allowed |
| `none` | Skipped | Skipped |
{: class="responsive-table"}

> 🚧 Cache mode isn't a security boundary
> Cache mode is best-effort client behavior. It doesn't change cache-token issuance or widen the existing provenance-based authorization of the server. Direct service requests remain subject to that authorization, not the requested mode. Clients that ignore `ACTIONS_CACHE_MODE` don't apply these additional restrictions.

The audited [upstream commit `3edfce9`](https://github.com/actions/cache/commit/3edfce9056124e459a23f683a21433670d47daca) bundles `@actions/cache` 6.2.0, which logs and skips disallowed operations. The v6.1.0 release listed below and older bundled clients ignore the variable. A skipped restore is a cache miss, so `fail-on-cache-miss: true` can still fail the action. Docker actions don't receive the compiled mode.

**🟡 Supported subset.** Immutable commits captured from frozen upstream tags and the `main` and `releases/v5` branches are admitted when their root, `restore`, and `save` bundles all speak the cache-v2 protocol the Buildkite Results service implements. The snapshot covers historical development and release commits from v3.4.0 and v4.2.0 onward, including untagged `main` commits. The admitted release commits run their stock cache-v2 clients. These principal releases are named in diagnostics:

| Release | Commit | Node | `@actions/cache` |
| --- | --- | --- | --- |
| v3.4.0 | [`f4b3439a656ba812b8cb417d2d49f9c810103092`](https://github.com/actions/cache/tree/f4b3439a656ba812b8cb417d2d49f9c810103092) | 16 | 4.0.0 |
| v3.4.2 | [`387e18722e6ff315b24a3b8b071feddd27b7bf7e`](https://github.com/actions/cache/tree/387e18722e6ff315b24a3b8b071feddd27b7bf7e) | 16 | 4.0.1 |
| v3.4.3 | [`2f8e54208210a422b2efd51efaa6bd6d7ca8920f`](https://github.com/actions/cache/tree/2f8e54208210a422b2efd51efaa6bd6d7ca8920f) | 16 | 4.0.2 |
| v3.5.0 | [`6f8efc29b200d32929f49075959781ed54ec270c`](https://github.com/actions/cache/tree/6f8efc29b200d32929f49075959781ed54ec270c) | 16 | 4.1.0 |
| v4.2.0 | [`1bd1e32a3bdc45362d1e726936510720a7c30a57`](https://github.com/actions/cache/tree/1bd1e32a3bdc45362d1e726936510720a7c30a57) | 20 | 4.0.0 |
| v4.2.1 | [`0c907a75c2c80ebcb7f088228285e798b750cf8f`](https://github.com/actions/cache/tree/0c907a75c2c80ebcb7f088228285e798b750cf8f) | 20 | 4.0.1 |
| v4.2.2 | [`d4323d4df104b026a6aa633fdb11d772146be0bf`](https://github.com/actions/cache/tree/d4323d4df104b026a6aa633fdb11d772146be0bf) | 20 | 4.0.2 |
| v4.2.3 | [`5a3ec84eff668545956fd18022155c47e93e2684`](https://github.com/actions/cache/tree/5a3ec84eff668545956fd18022155c47e93e2684) | 20 | 4.0.3 |
| v4.2.4 | [`0400d5f644dc74513175e3cd8d07132dd4860809`](https://github.com/actions/cache/tree/0400d5f644dc74513175e3cd8d07132dd4860809) | 20 | 4.0.5 |
| v4.3.0 | [`0057852bfaa89a56745cba8c7296529d2fc39830`](https://github.com/actions/cache/tree/0057852bfaa89a56745cba8c7296529d2fc39830) | 20 | 4.1.0 |
| v5.0.0 | [`a7833574556fa59680c1b7cb190c1735db73ebf0`](https://github.com/actions/cache/tree/a7833574556fa59680c1b7cb190c1735db73ebf0) | 24 | 5.0.0 |
| v5.0.1 | [`9255dc7a253b0ccc959486e2bca901246202afeb`](https://github.com/actions/cache/tree/9255dc7a253b0ccc959486e2bca901246202afeb) | 24 | 5.0.1 |
| v5.0.2 | [`8b402f58fbc84540c8b491a91e594a4576fec3d7`](https://github.com/actions/cache/tree/8b402f58fbc84540c8b491a91e594a4576fec3d7) | 24 | 5.0.3 |
| v5.0.3 | [`cdf6c1fa76f9f475f3d7449005a359c84ca0f306`](https://github.com/actions/cache/tree/cdf6c1fa76f9f475f3d7449005a359c84ca0f306) | 24 | 5.0.5 |
| v5.0.4 | [`668228422ae6a00e4ad889ee87cd7109ec5666a7`](https://github.com/actions/cache/tree/668228422ae6a00e4ad889ee87cd7109ec5666a7) | 24 | 5.0.5 |
| v5.0.5 | [`27d5ce7f107fe9357f9df03efb73ab90386fccae`](https://github.com/actions/cache/tree/27d5ce7f107fe9357f9df03efb73ab90386fccae) | 24 | 5.0.5 |
| v5.1.0 | [`caa296126883cff596d87d8935842f9db880ef25`](https://github.com/actions/cache/tree/caa296126883cff596d87d8935842f9db880ef25) | 24 | 5.1.0 |
| v6.0.0 | [`2c8a9bd7457de244a408f35966fab2fb45fda9c8`](https://github.com/actions/cache/tree/2c8a9bd7457de244a408f35966fab2fb45fda9c8) | 24 | 6.0.1 |
| v6.1.0 | [`55cc8345863c7cc4c66a329aec7e433d2d1c52a9`](https://github.com/actions/cache/tree/55cc8345863c7cc4c66a329aec7e433d2d1c52a9) | 24 | 6.1.0 |
{: class="responsive-table"}

The v3 releases use managed Node 16 and emit its standard deprecation warning. Node 20 declarations run with managed Node 24. Every admitted bundle selects cache v2 from `ACTIONS_CACHE_SERVICE_V2`, uses `ACTIONS_RESULTS_URL` and a job-scoped runtime token, and preserves the root restore and post-save lifecycle and separate entry points. A non-routable `ACTIONS_CACHE_URL` satisfies the legacy availability gate. Cache traffic still uses `ACTIONS_RESULTS_URL`. Their tar with zstd-or-gzip archive versioning is compatible across releases.

On [Windows jobs](#windows-jobs), cache actions use a restricted tool path: `Git\usr\bin` and `zstd` under the Windows Program Files directory, then Windows System32. Install Git for Windows and zstd there for GNU tar with zstd compression. Without zstd, the upstream client uses gzip. Without the tar provided by Git, the client can use Windows System32 tar. Cross-OS archives require GNU tar and zstd. The runtime obtains installation paths from Windows, replaces tool-selection environment variables case-insensitively, and excludes workflow `PATH` and `GITHUB_PATH` additions. Keep these installation directories outside workflow write access. This doesn't enable Buildkite cache volumes.

The snapshot admits a commit only when every bundle it runs selects cache v2 and embeds one `@actions/cache` client version of 4.0.0 or later. Commits before v3.4.0 and v4.2.0 bundle cache-v1 clients and are absent. v3.4.1 is snapshotted but excluded because [its upstream release warns that it was published with an incorrect SHA](https://github.com/actions/cache/releases/tag/v3.4.1).

A resolved commit outside the snapshot doesn't run. `actions/cache` runs upstream JavaScript with a job-scoped cache token, so an unaudited bundle could act on the cache service. Instead, the compiler runs the newest principal release for the requested major version (`v5` or `v5.2.0` runs v5.1.0), or v6.1.0 when the ref names no admitted major, a branch, or a bare commit. The plan records the requested ref and the substitute commit, and compilation emits one `W_CACHE_UNKNOWN_COMMIT_SUBSTITUTED` warning per distinct resolved commit:

```text
actions/cache@v6 resolved to commit <resolved-commit>, which is not in the frozen actions/cache snapshot admitted to the Buildkite cache-v2 service. The audited v6.1.0 release (55cc8345863c7cc4c66a329aec7e433d2d1c52a9) runs instead. Pin actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 to remove this warning.
```

The substitute must resolve to its recorded commit, or compilation fails. Substitution keeps floating `v3` through `v6` refs working when upstream publishes a release after the last snapshot refresh, and it also covers pre-cache-v2 releases, withdrawn v3.4.1, and pinned unknown commits. The substitute is a different upstream bundle from the one requested, so pin a listed commit to run an exact release. The frozen refs and per-commit profiles are refreshed when `buildkite-gha` is updated.

JavaScript and Docker actions with compatible bundled cache clients also receive job-bound cache-v2 credentials when the service is available. Root invocations of `actions/setup-node`, `actions/setup-java`, `actions/setup-python`, `actions/setup-go`, and `actions/setup-dotnet` use a subprocess-scoped synthetic `GITHUB_SERVER_URL` when the real host would make their clients select cache v1. Each allowlisted action is audited, including its bundled dependencies, to confirm that `GITHUB_SERVER_URL` affects only caching behavior and isn't load-bearing for any request the action makes. The workflow expression context retains the real server URL. Ordinary `run` steps and native action adapters don't receive cache credentials.

## Repositories, credentials, and GitHub services

This section describes which repositories jobs can access, and how GitHub tokens, secrets, OIDC, and other GitHub services work on Buildkite Pipelines.

### Repositories

| Source | Status | Boundary |
| --- | --- | --- |
| Public GitHub event repository | ✅ Supported | No additional boundary. |
| Private GitHub event repository | 🟡 Supported subset | Buildkite must authorize repository-provider Git credentials. Buildkite issues these credentials only to jobs on Buildkite hosted agents. |
| Internal or private Origin event repository | 🟡 Supported subset | `BUILDKITE_REPO` must be the exact `https://origin.cursor.com/git/<namespace>/<repository>.git` URL of the pipeline. Buildkite must authorize repository-provider Git credentials. |
| Alternate repository in `actions/checkout` | ❌ Unsupported | Not available. |
| Public GitHub action | 🟡 Supported subset | Subject to the [action boundaries](#actions-action-sources-and-runtimes). |
| Private reusable workflow | 🟡 Supported subset | Same-repository or explicitly approved cross-repository source. Resolved by the importer only. |
| Private action | ❌ Unsupported | No private action source access. |
| GitHub Enterprise Server or an unlisted provider | ❌ Unsupported | Not available. |
{: class="responsive-table"}

### GitHub token

**🟡 Supported subset.** A job requests a short-lived `GITHUB_TOKEN` for the event repository when the job:

- Statically references `secrets.GITHUB_TOKEN` or `github.token`.
- Uses an action whose effective input default can reach `github.token` for the event provider.

A `github.server_url == 'https://github.com'` guard skips the token branch for an Origin repository. Native adapters ignore upstream input defaults, so `actions/checkout` alone doesn't request a token.

The `permissions` of the top-level workflow set the scope. Token issuance needs the pipeline's **Allow workflow-authorized GitHub access tokens** setting, which is off by default for existing pipelines and on for pipelines created with GitHub Actions setup mode. Tokens are issued only to running command jobs on [Buildkite hosted agents](/docs/agent/buildkite-hosted) in `github.com` pipelines that use the full-access **GitHub** repository provider. If the setting is off, the runtime links to the pipeline repository setting. If an enabled request is rejected, the runtime instead asks you to check the top-level permissions and the access of the Buildkite GitHub App to the event repository. If those checks don't find the cause, contact the Buildkite Support team at [support@buildkite.com](mailto:support@buildkite.com) and include the build URL shown in the error.

In `buildkite-gha` v0.102.1 and later, workflow-token acquisition retries temporary failures: Agent API `503 Service Unavailable` responses, and `429 Too Many Requests` responses with a valid `Retry-After` header. Acquisition makes at most three attempts within 45 seconds, bounded by job cancellation and the job deadline. Each attempt has a 15-second limit. Retry delays use 1-second and 2-second bases, plus random jitter up to the base. A valid `Retry-After` value, in seconds or as an HTTP date, sets the minimum delay before jitter. If the delay would exhaust the remaining time, acquisition fails without waiting.

Other statuses, transport errors, and invalid successful responses aren't retried. This includes `400 Bad Request` responses. Action-source token requests and checkout don't retry. Each retry uses the same repository, workflow, and permissions. Buildkite Pipelines checks authorization and the [per-job request limit](/docs/pipelines/migration/run-github-actions-workflows#supported-functionality-and-limitations-credentials-secrets-and-oidc) for every attempt.

Buildkite Pipelines reads that policy from the pipeline repository at the immutable build commit. The workflow must be a simple `.yml` or `.yaml` file directly under `.github/workflows/`.

- Omitted permissions mean exactly `contents: read`.
- GitHub repository and organization defaults aren't inherited.
- `read-all` becomes an explicit 13-scope read map. `write-all` is rejected and creates no token.
- Write access needs an explicit, non-empty top-level map.
- An empty map, or scopes that all resolve to `none`, creates no token.
- Job-level repository permission maps don't change the scope.

Compilation warns when job permissions differ from the applied top-level map.

Reusable-workflow jobs receive the top-level repository permissions of the requesting workflow. Pipelines doesn't inspect called-workflow maps for `GITHUB_TOKEN`, so those maps can't narrow it. The separate `id-token` permission still supports called-workflow narrowing. Compilation warns when a called policy would have narrowed the repository token.

Pull requests and their triggered or rebuilt descendants have a `contents: read` ceiling. Merge-queue builds and their descendants can't request a token. GitHub Enterprise Server is unsupported. The backend verifies provenance and remains authoritative.

A job can request read-only repository access:

```yaml
permissions:
  contents: read

jobs:
  inspect:
    runs-on: ubuntu-latest
    steps:
      - env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        run: gh api repos/{owner}/{repo}
```

> 🚧 Restrict write tokens to trusted code
> The server restricts pull requests, merge queues, and their descendants. For other builds, job binding doesn't establish that an arbitrary commit is trusted. Restrict who can create builds and enable write tokens only when branch builds run trusted code.

The token isn't part of the initial job environment. Workflow-authored `github.token` references are step-only and use the same token as `secrets.GITHUB_TOKEN`. Effective action input defaults can also use the token. Automatic ambient `GITHUB_TOKEN` is unsupported.

### Other secrets and OIDC

**🟡 Supported subset.** Direct jobs can use static `${{ secrets.NAME }}` references. Local reusable-workflow jobs can inherit or explicitly map declared secret aliases.

The compiler records names, not values, in the destination job plan. At runtime, the job calls `buildkite-agent secret get NAME` and registers the value with both redactors before use. Missing or denied secrets fail without printing the secret or agent error. The job annotation explains how to create or migrate the secret, check its access policy, or retry a temporarily unavailable secret service. To copy GitHub Actions repository secrets into Buildkite secrets, see [Migrate GitHub Actions secrets](/docs/pipelines/migration/github-actions-secrets).

These are Buildkite destination-job secrets, not GitHub repository, environment, event, or fork-scoped secrets. [Buildkite secret access policies](/docs/pipelines/security/secrets/buildkite-secrets/access-policies) are the authorization boundary. Code in the same job can also call `buildkite-agent secret get`. Jobs with a [GitHub environment](#job-syntax-deployment-environments) resolve environment-defined secret names through the `<ENVIRONMENT>_<NAME>` naming convention. The values remain ordinary Buildkite secrets.

`GITHUB_TOKEN` stays on its separate [workflow-token contract](#repositories-credentials-and-github-services-github-token) and can't be replaced by an ordinary Buildkite secret.

Unsupported secret uses include:

- Dynamic, whole-context, filtered, or projected access
- Conditions and other compile-time expressions
- Remote reusable-workflow secret forwarding
- Literals, compound expressions, or references through `needs`, `vars`, `env`, `inputs`, or arbitrary `github` properties in explicit mappings

Action metadata can't add secret authority to a plan. A secret used only by an optional action input becomes an empty value unless another field requires it.

Jobs with `id-token: write` expose the GitHub Actions `getIDToken()` contract to host JavaScript actions, including those called by composite actions. The endpoint mints a [Buildkite OIDC token](/docs/pipelines/security/oidc) for the requested audience. Cloud identity providers must trust the Buildkite issuer and claims, not the GitHub ones. For an AWS example, see [Use OIDC with AWS](/docs/pipelines/migration/run-github-actions-workflows#supported-functionality-and-limitations-use-oidc-with-aws).

`id-token: read`, `id-token: none`, and omitted permissions don't expose the endpoint.

The plugin can apply additional Buildkite OIDC settings to every mint:

```yaml
plugins:
  - github-actions#latest:
      workflow: .github/workflows/deploy.yml
      oidc:
        claims:
          - organization_id
        aws-session-tags:
          - organization_slug
          - pipeline_id
        subject-claim: pipeline_id
```

`claims` and `aws-session-tags` accept non-empty lists. `subject-claim` accepts one non-empty immutable claim name. The Agent API owns the claim vocabulary and rejects unsupported names when the job mints a token.

Plugin OIDC configuration doesn't grant `id-token: write`. A job without that permission receives no endpoint.

The endpoint variables are scoped to each host action lifecycle invocation. Shell steps, Docker container actions, and actions running in job containers don't receive them. Container actions that call `getIDToken()` fail with its missing endpoint variable diagnostic.

### GitHub services

**❌ Unsupported beyond the integrations listed above.** The runtime of an action may still require unsupported GitHub services. Buildkite Pipelines provides no GitHub Packages, Releases, Checks, or deployment service emulation beyond the documented integrations.

## Runtime behavior and limits

This section describes the default environment, runner tools, result handling, and limits of generated jobs.

### Default environment

The runtime sets `GITHUB_REF`, `GITHUB_REF_NAME`, and `GITHUB_REF_TYPE` from the corresponding [`github` context fields](#expressions-and-contexts). Shell steps and actions receive the same values. Workflow, job, step, action, and `GITHUB_ENV` entries can't override these process variables.

The runtime sets `GITHUB_WORKFLOW` to the top-level `name` of the workflow. If the workflow has no name, the runtime uses the repository-relative workflow path. `GITHUB_WORKFLOW_REF` identifies that top-level workflow as `<owner>/<repo>/<path>@<event-ref>`, and `GITHUB_WORKFLOW_SHA` is the event commit. Jobs expanded from local, public, or private reusable workflows retain this caller identity. Workflow and step environment entries can't override these values.

### Runner tools

Linux tools come from the [selected host environment](#job-syntax-job-configuration). macOS and Windows agents must provide tools used by shell steps. Runner labels don't guarantee GitHub image parity. The runtime sets `RUNNER_OS` and `RUNNER_ARCH` to `Linux`/`X64`, `Linux`/`ARM64`, `macOS`/`ARM64`, or `Windows`/`X64`, and `RUNNER_ENVIRONMENT` to `self-hosted`. Workflow and step environment entries can't override these values.

On Linux, `RUNNER_TOOL_CACHE` is job-private unless the backend requests the hosted tool cache. An explicit `tool_cache: true` selects `/opt/hostedtoolcache` and requires it to exist. `false` uses the job-private cache without changing the preinstalled tools of the host. Native Namespace targets currently return `false`. When the field is absent, immutable image targets retain their existing `/opt/hostedtoolcache` behavior, including local hosted-toolchains presets and explicitly configured images.

On native Namespace runners (`nsc-gha-image`), the Docker CLI routes `docker build` to a buildx builder with the `remote` driver, so `docker build -t app . && docker run app` would fail with `No output specified with remote driver`. At job start, the runtime adds `default-load=true` to the stored single-node buildx builders of the runner that use the `remote`, `docker-container`, or `kubernetes` driver, so plain `docker build` loads the image into the Docker daemon as on GitHub-hosted runners. Explicit `--push` or `--output` flags still control the output.

Builders with more than one node are left unchanged, because buildx rejects the load that `default-load` implies when a build spans nodes, and an output-less multi-platform build that ran cache-only would fail. The job log names each skipped builder, and plain `docker build` still needs `--load` there. The node count of `nsc-remote` depends on the Namespace tenant. Problems print one warning and never fail the job.

On macOS, the bootstrap creates `/Users/runner/hostedtoolcache`, makes it owned and writable by the agent user, and selects it as `RUNNER_TOOL_CACHE`. This requires non-interactive `sudo` and preserves the fixed installation prefix used by actions such as `ruby/setup-ruby`. Agents must still provide compatible native dependencies for downloaded tools. macOS container images are unsupported.

On Windows, `RUNNER_TOOL_CACHE` is job-private. Windows container images are unsupported.

### Results, retries, and cancellation

- A runtime-skipped Actions job remains successful in Buildkite Pipelines, appends `(skipped)` to its job label, and publishes a logical `skipped` result for downstream imported jobs.
- Retry the whole build if a producer result or artifact becomes ambiguous. A deferred matrix step may be retried on its own. Retrying its producer job after the matrix expanded requires a new build.
- Cancellation targets the complete process tree. Linux and macOS send `SIGINT`, `SIGTERM` after 7.5 seconds, then `SIGKILL` after another 2.5 seconds. Windows terminates the process tree through a Job Object without a signal grace period.
- Summary, annotation, or skipped-label publication failure produces a warning and doesn't change a completed job result.

### Key limits

| Item | Limit |
| --- | --- |
| Matrix instances per job | 256 |
| Reusable workflow nesting | 4 levels |
| Workflow file | 1 MiB |
| Jobs after reusable workflow expansion | 1,024 |
| Nested local action depth | 10 levels |
| Background steps active at once | 10 |
| Job outputs | 64 |
| Output value | 1 KiB |
| Job or step timeout | 360 minutes |
| Artifacts per job | 64 |
| Files per uploaded artifact | 10,000 |
| Uploaded source data or ZIP | No `buildkite-gha` limit. Subject to Buildkite agent and storage limits. |
| Job summary | 1 MiB |
| `hashFiles()` patterns | 255 per call, 1 KiB each, 64 KiB total |
| `hashFiles()` inspected entries | 1,000,000 per call |
| `hashFiles()` execution budget | 30 seconds per call |
| `hashFiles()` matched files | 10,000 per call |
| `hashFiles()` selected bytes | 1 GiB per call |
{: class="responsive-table"}

## Validation

Check syntax, static graph construction, and every declared trigger without an event:

```bash
buildkite-gha validate .github/workflows/ci.yml
```

This result is event-independent and doesn't claim hosted admission.

Apply the same profile as production upload:

```bash
buildkite-gha validate \
  --profile hosted \
  --event-path .buildkite/events/current.json \
  .github/workflows/ci.yml
```

Use `--event` instead of `--event-path` to evaluate the hosted profile with a generated minimal snapshot. See [Validate a workflow](/docs/pipelines/migration/run-github-actions-workflows/cli#validate-a-workflow) for supported events and representative payloads, including deployment events. Generated snapshots are compatibility test inputs, not proof of every activity or equivalents to real payloads. The `--event` and `--event-path` options are mutually exclusive.

Use `--all-events` to evaluate every declared supported event separately. Its `processing-report/v3` output preserves the event-independent result and the v2 report of each generated event. Aggregate admission means every generated snapshot was admitted. It doesn't cover other payload shapes. A `context-required` result means compilation and hosted-policy checks passed, but generated inputs can't measure a supported admission path, such as push or pull-request path filters without linked webhook and local diff evidence. This result doesn't claim admission.

The results mean:

- **Compilable:** Syntax, declared triggers, and the static job graph can be translated.
- **Not applicable:** The workflow doesn't declare the selected event, so upload would skip it without compiling it.
- **Admitted:** Resolved actions and generated plans pass production policy.
- **Context required:** A supported admission path needs evidence that this validation input doesn't provide.
- **Runtime-proven:** Repository tests or hosted evidence have executed the behavior.

Admission doesn't execute arbitrary action code. An admitted action may still depend on an unsupported GitHub service.

See [Use the buildkite-gha CLI](/docs/pipelines/migration/run-github-actions-workflows/cli) for event snapshots, JSON reports, compilation, and direct upload.
