Skip to content

Commit b458526

Browse files
Claudeandykenward
andauthored
feat: Add option to overwrite branch name for deployments (#776)
* Initial plan * feat: add branch input to override deployment branch name Add optional branch input to allow users to override the automatically detected branch name for Cloudflare Pages deployments. This is useful for workflow_run deployments where fork PRs based on main would otherwise overwrite the production deployment. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> Agent-Logs-Url: https://github.com/andykenward/github-actions-cloudflare-pages/sessions/9a8b2519-3a11-4339-9046-06aab99972a1 Co-authored-by: andykenward <4893048+andykenward@users.noreply.github.com> * Create ripe-yaks-argue.md Signed-off-by: Andy Kenward <4893048+andykenward@users.noreply.github.com> * docs: improve github-environment docs * chore: minor update * docs: improve custom branch name * docs: AGENTS.md update * fix: remove peerDependencies wrangler --------- Signed-off-by: Andy Kenward <4893048+andykenward@users.noreply.github.com> Co-authored-by: anthropic-code-agent[bot] <242468646+Claude@users.noreply.github.com> Co-authored-by: andykenward <4893048+andykenward@users.noreply.github.com>
1 parent 25ba1a4 commit b458526

14 files changed

Lines changed: 204 additions & 32 deletions

File tree

.changeset/ripe-yaks-argue.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"github-actions-cloudflare-pages": minor
3+
---
4+
5+
feat: Overwrite branch name for deployments to Cloudflare

AGENTS.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ Non-negotiable. Violating these breaks the build or the type system.
3030

3131
**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).
3232

33-
**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.
33+
**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.
3434

3535
## Commands
3636

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

6666
1. Add the input to [action.yml](action.yml) or [delete/action.yml](delete/action.yml).
6767
2. Add `INPUT_KEY_*` in [input-keys.ts](input-keys.ts) (and `INPUT_KEYS_REQUIRED` if mandatory).
68-
3. Handle it in `inputs.ts`.
69-
4. Stub it in [`__tests__/helpers/inputs.ts`](__tests__/helpers/inputs.ts).
68+
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.
69+
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)).
7070
5. Document it in the Inputs table of [README.md](README.md) (or [delete/README.md](delete/README.md) for the delete action).
7171

7272
**Cloudflare API change**: update types in [src/common/cloudflare/types.ts](src/common/cloudflare/types.ts) → add fixtures to [`__generated__/responses/`](__generated__/responses/).
@@ -125,6 +125,7 @@ Formatting, linting, and type-checking are automated via [prek](https://prek.j17
125125
- `GITHUB_TOKEN` permissions: `actions:read`, `contents:read`, `deployments:write`, `pull-requests:write`.
126126
- Supported events only: `push`, `pull_request`, `workflow_dispatch`, `workflow_run` (validated in [src/deploy/main.ts](src/deploy/main.ts)).
127127
- 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)).
128+
- **`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).
128129

129130
## Resources
130131

README.md

Lines changed: 90 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ jobs:
5151
github-environment: ${{ (github.ref == 'refs/heads/main' && 'production') || 'preview' }}
5252
```
5353
54-
The `github-environment` expression deploys the `main` branch to `production` and every other branch to `preview`.
54+
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).
5555

5656
## Setup
5757

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

77+
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`"**:
78+
79+
- `github.ref` is the full ref of the branch that triggered the run, e.g. `refs/heads/main` or `refs/heads/my-feature`.
80+
- `github.ref == 'refs/heads/main'` is `true` only on the `main` branch.
81+
- `A && B` returns `B` when `A` is true, so on `main` the expression so far is `'production'`; on any other branch it is `false`.
82+
- `X || 'preview'` returns `X` unless `X` is falsy, so a `false` left side falls through to `'preview'`.
83+
84+
To map more branches to environments, extend the same pattern — for example, send `main` to `production`, `staging` to `staging`, and everything else to `preview`:
85+
86+
```yaml
87+
github-environment: >-
88+
${{ (github.ref == 'refs/heads/main' && 'production')
89+
|| (github.ref == 'refs/heads/staging' && 'staging')
90+
|| 'preview' }}
91+
```
92+
93+
> [!NOTE]
94+
> `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".
95+
7796
### 3. Permissions
7897

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

89108
## Inputs
90109

91-
| Input | Required | Description |
92-
| ------------------------- | -------- | ----------------------------------------------------------------------------------------------------- |
93-
| `cloudflare-api-token` | yes | Cloudflare API Token |
94-
| `cloudflare-account-id` | yes | Cloudflare Account ID |
95-
| `cloudflare-project-name` | yes | Cloudflare Pages project to upload to |
96-
| `directory` | yes | Directory of static files to upload |
97-
| `github-token` | yes | Github API key, make sure to add the required permissions for this action. |
98-
| `github-environment` | yes | GitHub environment to deploy to. You need to manually create this for the github repo |
99-
| `pr-number` | no | GitHub pull request number to comment on. If not set, the action auto-detects from the event payload. |
100-
| `working-directory` | no | Directory to run wrangler cli from |
101-
| `wrangler-version` | no | Wrangler version to use. Otherwise a default version from the action will be used. |
110+
| Input | Required | Description |
111+
| ------------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------- |
112+
| `cloudflare-api-token` | yes | Cloudflare API Token |
113+
| `cloudflare-account-id` | yes | Cloudflare Account ID |
114+
| `cloudflare-project-name` | yes | Cloudflare Pages project to upload to |
115+
| `directory` | yes | Directory of static files to upload |
116+
| `github-token` | yes | Github API key, make sure to add the required permissions for this action. |
117+
| `github-environment` | yes | GitHub environment to deploy to. You need to manually create this for the github repo |
118+
| `pr-number` | no | GitHub pull request number to comment on. If not set, the action auto-detects from the event payload. |
119+
| `working-directory` | no | Directory to run wrangler cli from |
120+
| `wrangler-version` | no | Wrangler version to use. Otherwise a default version from the action will be used. |
121+
| `branch` | no | Branch name to use for Cloudflare Pages deployment. If not set, the branch is automatically detected from the GitHub context. |
102122

103123
## Outputs
104124

@@ -156,6 +176,64 @@ jobs:
156176

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

179+
### Custom branch name
180+
181+
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.
182+
183+
**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.
184+
185+
In the `pull_request` workflow (the one named in `workflows:` of the `workflow_run` trigger), save the PR number alongside your build output:
186+
187+
```yaml
188+
- name: Save PR number
189+
run: echo "${{ github.event.number }}" > pr-number.txt
190+
191+
- name: Upload PR number
192+
uses: actions/upload-artifact@v4
193+
with:
194+
name: pr-number
195+
path: pr-number.txt
196+
```
197+
198+
Then, in the `workflow_run` workflow, download it and pass it to both `branch` and `pr-number`:
199+
200+
```yaml
201+
jobs:
202+
deploy:
203+
if: ${{ github.event.workflow_run.conclusion == 'success' }}
204+
permissions:
205+
actions: read
206+
contents: read
207+
deployments: write
208+
pull-requests: write
209+
runs-on: ubuntu-latest
210+
steps:
211+
- name: Download PR number
212+
uses: actions/download-artifact@v4
213+
with:
214+
name: pr-number
215+
run-id: ${{ github.event.workflow_run.id }}
216+
github-token: ${{ secrets.GITHUB_TOKEN }}
217+
218+
- name: Read PR number
219+
id: pr
220+
run: echo "number=$(cat pr-number.txt)" >> "$GITHUB_OUTPUT"
221+
222+
- name: Deploy to Cloudflare Pages
223+
uses: andykenward/github-actions-cloudflare-pages@1f45924c4dd0c6d746a7edfaa4e1dea8958806a6 #v3.4.0
224+
with:
225+
cloudflare-api-token: ${{ secrets.CLOUDFLARE_API_TOKEN }}
226+
cloudflare-account-id: ${{ vars.CLOUDFLARE_ACCOUNT_ID }}
227+
cloudflare-project-name: ${{ vars.CLOUDFLARE_PROJECT_NAME }}
228+
directory: dist
229+
github-token: ${{ secrets.GITHUB_TOKEN }}
230+
github-environment: preview
231+
branch: pr-${{ steps.pr.outputs.number }}
232+
pr-number: ${{ steps.pr.outputs.number }}
233+
```
234+
235+
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.
236+
159237
## Pull request comment
160238

161239
![pull request comment example](./docs/comment.png)

__tests__/common/cloudflare/deployment/create.test.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -249,5 +249,64 @@ describe(createCloudflareDeployment, () => {
249249
['Wrangler Output:', `success`]
250250
])
251251
})
252+
253+
test('handles branch override', async () => {
254+
expect.assertions(4)
255+
256+
vi.mocked(execFileAsync).mockResolvedValueOnce({
257+
stdout: 'success',
258+
stderr: ''
259+
})
260+
261+
mockApi
262+
.interceptCloudflare(
263+
MOCK_API_PATH_DEPLOYMENTS,
264+
RESPONSE_DEPLOYMENTS_IDLE,
265+
200
266+
)
267+
.times(2)
268+
269+
mockApi.interceptCloudflare(
270+
MOCK_API_PATH_DEPLOYMENTS,
271+
RESPONSE_DEPLOYMENTS,
272+
200
273+
)
274+
275+
await createCloudflareDeployment({
276+
accountId: 'mock-cloudflare-account-id',
277+
projectName: 'mock-cloudflare-project-name',
278+
directory: 'mock-directory',
279+
branch: 'pr-123'
280+
})
281+
282+
expect(execFileAsync).toHaveBeenCalledWith(
283+
'npx',
284+
[
285+
`wrangler@${packageJson.devDependencies.wrangler}`,
286+
'pages',
287+
'deploy',
288+
'mock-directory',
289+
'--project-name',
290+
'mock-cloudflare-project-name',
291+
'--branch',
292+
'pr-123',
293+
'--commit-dirty=true',
294+
'--commit-hash',
295+
'mock-github-sha'
296+
],
297+
{
298+
// oxlint-disable-next-line typescript/no-unsafe-assignment
299+
env: expect.objectContaining({
300+
CLOUDFLARE_ACCOUNT_ID: 'mock-cloudflare-account-id',
301+
CLOUDFLARE_API_TOKEN: 'mock-cloudflare-api-token'
302+
}),
303+
cwd: ''
304+
}
305+
)
306+
307+
expect(execFileAsync).toHaveBeenCalledTimes(1)
308+
expect(info).toHaveBeenLastCalledWith('success')
309+
expect(summary.addTable).toHaveBeenCalledTimes(1)
310+
})
252311
})
253312
})

__tests__/deploy/inputs.test.ts

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import {beforeEach, describe, expect, test, vi} from 'vitest'
22

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

56
const setup = async () => {
67
return await import('@/deploy/inputs.js')
@@ -23,7 +24,8 @@ describe('deploy', () => {
2324
cloudflareAccountId: 'mock-cloudflare-account-id',
2425
cloudflareProjectName: 'mock-cloudflare-project-name',
2526
directory: 'mock-directory',
26-
workingDirectory: '.'
27+
workingDirectory: '.',
28+
branch: undefined
2729
})
2830
})
2931

@@ -36,5 +38,21 @@ describe('deploy', () => {
3638
'Input required and not supplied: cloudflare-account-id'
3739
)
3840
})
41+
42+
test('returns branch when provided', async () => {
43+
expect.assertions(1)
44+
45+
stubRequiredInputEnv()
46+
stubInputEnv(INPUT_KEY_BRANCH, 'pr-123')
47+
const {useInputs} = await setup()
48+
49+
expect(useInputs()).toStrictEqual({
50+
cloudflareAccountId: 'mock-cloudflare-account-id',
51+
cloudflareProjectName: 'mock-cloudflare-project-name',
52+
directory: 'mock-directory',
53+
workingDirectory: '.',
54+
branch: 'pr-123'
55+
})
56+
})
3957
})
4058
})

action.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,9 @@ inputs:
3333
wrangler-version:
3434
description: 'Wrangler version to use. Otherwise a default version from the action will be used.'
3535
required: false
36+
branch:
37+
description: 'Branch name to use for Cloudflare Pages deployment. If not set, the branch is automatically detected from the GitHub context.'
38+
required: false
3639

3740
outputs:
3841
id:

0 commit comments

Comments
 (0)