Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/ripe-yaks-argue.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"github-actions-cloudflare-pages": minor
---

feat: Overwrite branch name for deployments to Cloudflare
7 changes: 4 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ Non-negotiable. Violating these breaks the build or the type system.

**GraphQL type safety**: Inline ``graphql(/* GraphQL */ `...`)`` operations in `src/**` and `bin/**` are typed via [@graphql-codegen/client-preset](graphql.config.ts). The custom client [src/common/github/api/client.ts](src/common/github/api/client.ts) wraps fetch with `TypedDocumentString` for compile-time validation. Preview features come from [schema/github/schema.graphql](schema/github/schema.graphql).

**Cloudflare**: Runs `wrangler pages deploy` via `execAsync()` ([src/common/cloudflare/deployment/create.ts](src/common/cloudflare/deployment/create.ts#L49-L54)). Wrangler is an external (peer) dependency, not bundled. Deployment status polling and deletion use Cloudflare's REST API.
**Cloudflare**: Runs `wrangler pages deploy` via `execAsync()` ([src/common/cloudflare/deployment/create.ts](src/common/cloudflare/deployment/create.ts#L49-L54)). Wrangler is external to the bundle (esbuild `external`, [esbuild.config.js](esbuild.config.js)) and installed at runtime via `npx wrangler@<version>` — the version comes from the `wrangler-version` input or, failing that, the default in [src/common/inputs.ts](src/common/inputs.ts), which [bin/sync-versions.ts](bin/sync-versions.ts) keeps in lockstep with `devDependencies.wrangler` (the single source of truth; tests read it too). Deployment status polling and deletion use Cloudflare's REST API.

## Commands

Expand Down Expand Up @@ -65,8 +65,8 @@ Non-negotiable. Violating these breaks the build or the type system.

1. Add the input to [action.yml](action.yml) or [delete/action.yml](delete/action.yml).
2. Add `INPUT_KEY_*` in [input-keys.ts](input-keys.ts) (and `INPUT_KEYS_REQUIRED` if mandatory).
3. Handle it in `inputs.ts`.
4. Stub it in [`__tests__/helpers/inputs.ts`](__tests__/helpers/inputs.ts).
3. Handle it in `inputs.ts`. Optional inputs: normalise empty-string to `undefined` (`getInput(KEY, {required: false}) || undefined`) so the `Inputs` field is truly optional.
4. Tests: `stubRequiredInputEnv()` only stubs `INPUT_KEYS_REQUIRED`, so a **required** input is covered automatically. An **optional** input is not — stub it per-test with `stubInputEnv(INPUT_KEY_X, value)` and assert the `undefined` default case too (see [`__tests__/deploy/inputs.test.ts`](__tests__/deploy/inputs.test.ts)).
5. Document it in the Inputs table of [README.md](README.md) (or [delete/README.md](delete/README.md) for the delete action).

**Cloudflare API change**: update types in [src/common/cloudflare/types.ts](src/common/cloudflare/types.ts) → add fixtures to [`__generated__/responses/`](__generated__/responses/).
Expand Down Expand Up @@ -125,6 +125,7 @@ Formatting, linting, and type-checking are automated via [prek](https://prek.j17
- `GITHUB_TOKEN` permissions: `actions:read`, `contents:read`, `deployments:write`, `pull-requests:write`.
- Supported events only: `push`, `pull_request`, `workflow_dispatch`, `workflow_run` (validated in [src/deploy/main.ts](src/deploy/main.ts)).
- The deployment payload embeds Cloudflare metadata so the delete workflow can find deployments ([src/common/github/deployment/types.ts](src/common/github/deployment/types.ts)).
- **`workflow_run` + fork PRs**: `github.event.workflow_run.pull_requests` is **empty for pull requests from forks** ([community #25220](https://github.com/orgs/community/discussions/25220)). Never derive `pr-number` or `branch` from `pull_requests[0].number` in docs/examples — it silently resolves to empty for the exact fork case those examples target. Instead save the number in the triggering `pull_request` workflow and read it from an artifact (`upload-artifact` → `download-artifact` with `run-id: ${{ github.event.workflow_run.id }}` + `github-token`). See the "Custom branch name" example in [README.md](README.md).

## Resources

Expand Down
102 changes: 90 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ jobs:
github-environment: ${{ (github.ref == 'refs/heads/main' && 'production') || 'preview' }}
```

The `github-environment` expression deploys the `main` branch to `production` and every other branch to `preview`.
The `github-environment` expression deploys the `main` branch to `production` and every other branch to `preview`. For a line-by-line breakdown of this expression — and how it relates to the Cloudflare `branch` input — see [GitHub Environments](#2-github-environments-required).

## Setup

Expand All @@ -74,6 +74,25 @@ Create each environment you reference (for example `production` and `preview`),
github-environment: ${{ (github.ref == 'refs/heads/main' && 'production') || 'preview' }}
```

GitHub Actions has no `condition ? a : b` ternary, so this uses the `&&`/`||` idiom to get the same result. Read it as **"if on `main`, use `production`, otherwise use `preview`"**:

- `github.ref` is the full ref of the branch that triggered the run, e.g. `refs/heads/main` or `refs/heads/my-feature`.
- `github.ref == 'refs/heads/main'` is `true` only on the `main` branch.
- `A && B` returns `B` when `A` is true, so on `main` the expression so far is `'production'`; on any other branch it is `false`.
- `X || 'preview'` returns `X` unless `X` is falsy, so a `false` left side falls through to `'preview'`.

To map more branches to environments, extend the same pattern — for example, send `main` to `production`, `staging` to `staging`, and everything else to `preview`:

```yaml
github-environment: >-
${{ (github.ref == 'refs/heads/main' && 'production')
|| (github.ref == 'refs/heads/staging' && 'staging')
|| 'preview' }}
```

> [!NOTE]
> `github-environment` only sets the **GitHub** Environment the deployment is recorded against. Whether Cloudflare treats the upload as a production or preview deployment is decided separately, by the **branch name** — Cloudflare promotes the deployment to production only when the branch matches your Pages project's production branch. By default the branch is detected from the GitHub context; use the [`branch`](#custom-branch-name) input to override it. The two inputs are independent, so make sure your branch logic and `github-environment` logic agree on what counts as "production".

### 3. Permissions

When using the workflow's built-in [`GITHUB_TOKEN`] for the `github-token` input, grant these [permissions]:
Expand All @@ -88,17 +107,18 @@ permissions:

## Inputs

| Input | Required | Description |
| ------------------------- | -------- | ----------------------------------------------------------------------------------------------------- |
| `cloudflare-api-token` | yes | Cloudflare API Token |
| `cloudflare-account-id` | yes | Cloudflare Account ID |
| `cloudflare-project-name` | yes | Cloudflare Pages project to upload to |
| `directory` | yes | Directory of static files to upload |
| `github-token` | yes | Github API key, make sure to add the required permissions for this action. |
| `github-environment` | yes | GitHub environment to deploy to. You need to manually create this for the github repo |
| `pr-number` | no | GitHub pull request number to comment on. If not set, the action auto-detects from the event payload. |
| `working-directory` | no | Directory to run wrangler cli from |
| `wrangler-version` | no | Wrangler version to use. Otherwise a default version from the action will be used. |
| Input | Required | Description |
| ------------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------- |
| `cloudflare-api-token` | yes | Cloudflare API Token |
| `cloudflare-account-id` | yes | Cloudflare Account ID |
| `cloudflare-project-name` | yes | Cloudflare Pages project to upload to |
| `directory` | yes | Directory of static files to upload |
| `github-token` | yes | Github API key, make sure to add the required permissions for this action. |
| `github-environment` | yes | GitHub environment to deploy to. You need to manually create this for the github repo |
| `pr-number` | no | GitHub pull request number to comment on. If not set, the action auto-detects from the event payload. |
| `working-directory` | no | Directory to run wrangler cli from |
| `wrangler-version` | no | Wrangler version to use. Otherwise a default version from the action will be used. |
| `branch` | no | Branch name to use for Cloudflare Pages deployment. If not set, the branch is automatically detected from the GitHub context. |

## Outputs

Expand Down Expand Up @@ -156,6 +176,64 @@ jobs:

The action supports the `workflow_run` event and uses its head commit SHA and branch for the deployment metadata.

### Custom branch name

You can override the automatically detected branch name with the `branch` input. This is useful with `workflow_run`: a fork pull request opened from the fork's `main` branch would otherwise deploy to your project's production branch and overwrite the production deployment. Giving each pull request its own branch name (for example `pr-123`) keeps it on a separate Cloudflare Pages preview.

**Do not** build the branch name from `github.event.workflow_run.pull_requests[0].number` — that array is empty for pull requests from forks ([community discussion #25220](https://github.com/orgs/community/discussions/25220)), which is the exact case this is meant to cover. Instead, save the PR number in the triggering `pull_request` workflow and read it back from an artifact in the `workflow_run` workflow.

In the `pull_request` workflow (the one named in `workflows:` of the `workflow_run` trigger), save the PR number alongside your build output:

```yaml
- name: Save PR number
run: echo "${{ github.event.number }}" > pr-number.txt

- name: Upload PR number
uses: actions/upload-artifact@v4
with:
name: pr-number
path: pr-number.txt
```

Then, in the `workflow_run` workflow, download it and pass it to both `branch` and `pr-number`:

```yaml
jobs:
deploy:
if: ${{ github.event.workflow_run.conclusion == 'success' }}
permissions:
actions: read
contents: read
deployments: write
pull-requests: write
runs-on: ubuntu-latest
steps:
- name: Download PR number
uses: actions/download-artifact@v4
with:
name: pr-number
run-id: ${{ github.event.workflow_run.id }}
github-token: ${{ secrets.GITHUB_TOKEN }}

- name: Read PR number
id: pr
run: echo "number=$(cat pr-number.txt)" >> "$GITHUB_OUTPUT"

- name: Deploy to Cloudflare Pages
uses: andykenward/github-actions-cloudflare-pages@1f45924c4dd0c6d746a7edfaa4e1dea8958806a6 #v3.4.0
with:
cloudflare-api-token: ${{ secrets.CLOUDFLARE_API_TOKEN }}
cloudflare-account-id: ${{ vars.CLOUDFLARE_ACCOUNT_ID }}
cloudflare-project-name: ${{ vars.CLOUDFLARE_PROJECT_NAME }}
directory: dist
github-token: ${{ secrets.GITHUB_TOKEN }}
github-environment: preview
branch: pr-${{ steps.pr.outputs.number }}
pr-number: ${{ steps.pr.outputs.number }}
```

This creates a Cloudflare Pages preview deployment with a branch name like `pr-123`, so each pull request — including those from forks — gets its own preview environment instead of overwriting production.

## Pull request comment

![pull request comment example](./docs/comment.png)
Expand Down
59 changes: 59 additions & 0 deletions __tests__/common/cloudflare/deployment/create.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -249,5 +249,64 @@ describe(createCloudflareDeployment, () => {
['Wrangler Output:', `success`]
])
})

test('handles branch override', async () => {
expect.assertions(4)

vi.mocked(execFileAsync).mockResolvedValueOnce({
stdout: 'success',
stderr: ''
})

mockApi
.interceptCloudflare(
MOCK_API_PATH_DEPLOYMENTS,
RESPONSE_DEPLOYMENTS_IDLE,
200
)
.times(2)

mockApi.interceptCloudflare(
MOCK_API_PATH_DEPLOYMENTS,
RESPONSE_DEPLOYMENTS,
200
)

await createCloudflareDeployment({
accountId: 'mock-cloudflare-account-id',
projectName: 'mock-cloudflare-project-name',
directory: 'mock-directory',
branch: 'pr-123'
})

expect(execFileAsync).toHaveBeenCalledWith(
'npx',
[
`wrangler@${packageJson.devDependencies.wrangler}`,
'pages',
'deploy',
'mock-directory',
'--project-name',
'mock-cloudflare-project-name',
'--branch',
'pr-123',
'--commit-dirty=true',
'--commit-hash',
'mock-github-sha'
],
{
// oxlint-disable-next-line typescript/no-unsafe-assignment
env: expect.objectContaining({
CLOUDFLARE_ACCOUNT_ID: 'mock-cloudflare-account-id',
CLOUDFLARE_API_TOKEN: 'mock-cloudflare-api-token'
}),
cwd: ''
}
)

expect(execFileAsync).toHaveBeenCalledTimes(1)
expect(info).toHaveBeenLastCalledWith('success')
expect(summary.addTable).toHaveBeenCalledTimes(1)
})
})
})
22 changes: 20 additions & 2 deletions __tests__/deploy/inputs.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {beforeEach, describe, expect, test, vi} from 'vitest'

import {stubRequiredInputEnv} from '@/tests/helpers/inputs.js'
import {INPUT_KEY_BRANCH} from '@/input-keys'
import {stubInputEnv, stubRequiredInputEnv} from '@/tests/helpers/inputs.js'

const setup = async () => {
return await import('@/deploy/inputs.js')
Expand All @@ -23,7 +24,8 @@ describe('deploy', () => {
cloudflareAccountId: 'mock-cloudflare-account-id',
cloudflareProjectName: 'mock-cloudflare-project-name',
directory: 'mock-directory',
workingDirectory: '.'
workingDirectory: '.',
branch: undefined
})
})

Expand All @@ -36,5 +38,21 @@ describe('deploy', () => {
'Input required and not supplied: cloudflare-account-id'
)
})

test('returns branch when provided', async () => {
expect.assertions(1)

stubRequiredInputEnv()
stubInputEnv(INPUT_KEY_BRANCH, 'pr-123')
const {useInputs} = await setup()

expect(useInputs()).toStrictEqual({
cloudflareAccountId: 'mock-cloudflare-account-id',
cloudflareProjectName: 'mock-cloudflare-project-name',
directory: 'mock-directory',
workingDirectory: '.',
branch: 'pr-123'
})
})
})
})
3 changes: 3 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ inputs:
wrangler-version:
description: 'Wrangler version to use. Otherwise a default version from the action will be used.'
required: false
branch:
description: 'Branch name to use for Cloudflare Pages deployment. If not set, the branch is automatically detected from the GitHub context.'
required: false

outputs:
id:
Expand Down
Loading