-
-
Notifications
You must be signed in to change notification settings - Fork 0
Deploy Cloudflare preview URLs for pull requests #21
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -3,10 +3,16 @@ name: Deploy to Cloudflare Workers | |
| on: | ||
| push: | ||
| branches: [main] | ||
| pull_request: | ||
| workflow_dispatch: | ||
|
|
||
| concurrency: | ||
| group: cloudflare-${{ github.event.pull_request.number || github.ref }} | ||
| cancel-in-progress: true | ||
|
|
||
| jobs: | ||
| deploy: | ||
| if: github.event_name != 'pull_request' | ||
| runs-on: ubuntu-latest | ||
| permissions: | ||
| contents: read | ||
|
|
@@ -31,3 +37,153 @@ jobs: | |
| env: | ||
| CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} | ||
| CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} | ||
|
|
||
| preview: | ||
| if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository | ||
| runs-on: ubuntu-latest | ||
| permissions: | ||
| contents: read | ||
| deployments: write | ||
| pull-requests: write | ||
| steps: | ||
| - uses: actions/checkout@v4 | ||
|
|
||
| - uses: pnpm/action-setup@v4 | ||
| with: | ||
| version: 11.20.0 | ||
|
|
||
| - uses: actions/setup-node@v4 | ||
| with: | ||
| node-version: 22 | ||
| cache: pnpm | ||
|
|
||
| - name: Install dependencies | ||
| run: pnpm install --frozen-lockfile | ||
|
|
||
| - name: Build OpenNext Worker | ||
| run: pnpm exec opennextjs-cloudflare build | ||
|
|
||
| - name: Upload preview version | ||
| id: upload | ||
| env: | ||
| CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} | ||
| CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} | ||
| WRANGLER_OUTPUT_FILE_DIRECTORY: ${{ runner.temp }}/wrangler-output | ||
| PR_NUMBER: ${{ github.event.pull_request.number }} | ||
| run: | | ||
| mkdir -p "$WRANGLER_OUTPUT_FILE_DIRECTORY" | ||
| alias="pr-${PR_NUMBER}" | ||
| # Do not put spaces or '#' in wrangler flags: OpenNext runs wrangler with shell: true. | ||
| pnpm exec opennextjs-cloudflare upload --preview-alias "$alias" | ||
|
|
||
| node <<'NODE' | ||
| const fs = require("node:fs") | ||
| const path = require("node:path") | ||
| const dir = process.env.WRANGLER_OUTPUT_FILE_DIRECTORY | ||
| let previewUrl = "" | ||
| let aliasUrl = "" | ||
| for (const name of fs.readdirSync(dir)) { | ||
| const text = fs.readFileSync(path.join(dir, name), "utf8") | ||
| for (const line of text.split("\n")) { | ||
| if (!line.trim()) continue | ||
| try { | ||
| const entry = JSON.parse(line) | ||
| if (entry.type === "version-upload") { | ||
| previewUrl = entry.preview_url || previewUrl | ||
| aliasUrl = entry.preview_alias_url || aliasUrl | ||
| } | ||
| } catch { | ||
| // ignore non-JSON lines | ||
| } | ||
| } | ||
| } | ||
| const githubOutput = process.env.GITHUB_OUTPUT | ||
| if (!githubOutput) { | ||
| throw new Error("GITHUB_OUTPUT is not set") | ||
| } | ||
| fs.appendFileSync(githubOutput, `preview_url=${previewUrl}\n`) | ||
| fs.appendFileSync(githubOutput, `alias_url=${aliasUrl}\n`) | ||
| if (!previewUrl && !aliasUrl) { | ||
| console.warn( | ||
| "Wrangler did not report a preview URL. Confirm preview_urls is enabled on the Worker." | ||
| ) | ||
| } | ||
|
Comment on lines
+79
to
+110
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win Fail when Wrangler reports no preview URL. If both outputs are empty, this step only warns. Line 114 then skips the PR comment and the GitHub deployment. The workflow still succeeds and can leave an older preview comment visible. Throw an error when neither URL is present after the upload completes. 🤖 Prompt for AI Agents |
||
| NODE | ||
|
|
||
| - name: Comment preview URL on the PR | ||
| if: steps.upload.outputs.alias_url != '' || steps.upload.outputs.preview_url != '' | ||
| uses: actions/github-script@v7 | ||
| env: | ||
| ALIAS_URL: ${{ steps.upload.outputs.alias_url }} | ||
| PREVIEW_URL: ${{ steps.upload.outputs.preview_url }} | ||
| with: | ||
| script: | | ||
| const marker = "<!-- cloudflare-preview -->" | ||
| const aliasUrl = process.env.ALIAS_URL | ||
| const previewUrl = process.env.PREVIEW_URL | ||
| const url = aliasUrl || previewUrl | ||
| const rows = [ | ||
| "| | |", | ||
| "| --- | --- |", | ||
| `| Preview | ${url} |`, | ||
| ] | ||
| if (previewUrl && previewUrl !== url) { | ||
| rows.push(`| This commit | ${previewUrl} |`) | ||
| } | ||
| const body = [ | ||
| marker, | ||
| "**Cloudflare preview** is ready.", | ||
| "", | ||
| ...rows, | ||
| "", | ||
| "Production ([23rd.dev](https://23rd.dev)) is unchanged. This preview URL stays the same as you push to this PR.", | ||
| ].join("\n") | ||
|
|
||
| const { data: comments } = await github.rest.issues.listComments({ | ||
| owner: context.repo.owner, | ||
| repo: context.repo.repo, | ||
| issue_number: context.issue.number, | ||
| }) | ||
| const existing = comments.find((comment) => comment.body?.includes(marker)) | ||
|
Comment on lines
+142
to
+147
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -eu
printf '%s\n' '--- workflow files ---'
git ls-files '.github/workflows/*'
printf '%s\n' '--- relevant workflow section ---'
cat -n .github/workflows/deploy-cloudflare.yml | sed -n '1,230p'
printf '%s\n' '--- comment API and marker references ---'
rg -n -C 5 'listComments|existing|marker|updateComment|createComment|github-actions\[bot\]|comments\.find' .github/workflows .github 2>/dev/null || trueRepository: radiumcoders/23rd.dev Length of output: 15523 🏁 Script executed: #!/bin/bash
set -eu
cat -n .github/workflows/deploy-cloudflare.yml | sed -n '120,190p'
rg -n -C 8 'listComments|existing|marker|updateComment|createComment|github-actions\[bot\]' .github/workflows/deploy-cloudflare.ymlRepository: radiumcoders/23rd.dev Length of output: 5671 🏁 Script executed: #!/bin/bash
set -eu
node <<'JS'
const fs = require("node:fs")
const workflow = fs.readFileSync(".github/workflows/deploy-cloudflare.yml", "utf8")
if (/listComments\(\{[\s\S]*?per_page\s*:/.test(workflow)) {
throw new Error("The request unexpectedly sets per_page")
}
if (!/comments\.find\(\(comment\) => comment\.body\?\.includes\(marker\)\)/.test(workflow)) {
throw new Error("The reviewed lookup is not present")
}
if (/paginate\(/.test(workflow)) {
throw new Error("The reviewed block unexpectedly paginates")
}
const marker = "<!-- cloudflare-preview -->"
const firstPage = Array.from({ length: 30 }, (_, index) => ({
id: index + 1,
body: index === 29 ? "ordinary comment" : `comment ${index}`,
user: { login: "alice" },
}))
const laterPage = [{
id: 31,
body: `${marker}\n**Cloudflare preview** is ready.`,
user: { login: "github-actions[bot]" },
}]
const firstPageLookup = firstPage.find((comment) => comment.body?.includes(marker))
const markerFromUser = {
id: 99,
body: `user text ${marker}`,
user: { login: "alice" },
}
const unfilteredLookup = [markerFromUser].find((comment) => comment.body?.includes(marker))
const botOnlyLookup = [markerFromUser].find(
(comment) => comment.body?.includes(marker) &&
comment.user?.login === "github-actions[bot]"
)
console.log(JSON.stringify({
firstPageCount: firstPage.length,
managedCommentOnLaterPageIsMissed: firstPageLookup === undefined && laterPage[0].id === 31,
userMarkerIsSelectedWithoutAuthorFilter: unfilteredLookup?.id === 99,
userMarkerIsRejectedByBotFilter: botOnlyLookup === undefined,
}, null, 2))
JSRepository: radiumcoders/23rd.dev Length of output: 325 🌐 Web query:
💡 Result: The GitHub REST API endpoint for listing issue comments defaults to 30 items per page [1][2]. Key details regarding pagination and permissions for this endpoint include: Pagination: - The Citations:
Paginate comments and restrict the managed comment to the bot.
🤖 Prompt for AI Agents |
||
| if (existing) { | ||
| await github.rest.issues.updateComment({ | ||
| owner: context.repo.owner, | ||
| repo: context.repo.repo, | ||
| comment_id: existing.id, | ||
| body, | ||
| }) | ||
| } else { | ||
| await github.rest.issues.createComment({ | ||
| owner: context.repo.owner, | ||
| repo: context.repo.repo, | ||
| issue_number: context.issue.number, | ||
| body, | ||
| }) | ||
| } | ||
|
|
||
| try { | ||
| const { data: deployment } = await github.rest.repos.createDeployment({ | ||
| owner: context.repo.owner, | ||
| repo: context.repo.repo, | ||
| ref: context.payload.pull_request.head.sha, | ||
| environment: "preview", | ||
| auto_merge: false, | ||
| required_contexts: [], | ||
| description: `PR #${context.issue.number} Cloudflare preview`, | ||
| transient_environment: true, | ||
| production_environment: false, | ||
| }) | ||
| if (deployment && deployment.id) { | ||
| await github.rest.repos.createDeploymentStatus({ | ||
| owner: context.repo.owner, | ||
| repo: context.repo.repo, | ||
| deployment_id: deployment.id, | ||
| state: "success", | ||
| environment_url: url, | ||
| log_url: `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`, | ||
| description: "Cloudflare Workers preview", | ||
| }) | ||
| } | ||
| } catch (error) { | ||
| core.warning(`Could not create GitHub deployment: ${error.message}`) | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -126,6 +126,7 @@ Useful scripts: | |
| | `pnpm build` | Build registry + production app | | ||
| | `pnpm preview` | Build with OpenNext and preview in the Workers runtime | | ||
| | `pnpm cf:deploy` | Build with OpenNext and deploy to Cloudflare Workers | | ||
| | `pnpm cf:upload` | Build with OpenNext and upload a preview version | | ||
| | `pnpm registry:build` | Emit `public/r/*.json` from `registry/` | | ||
| | `pnpm test` | Run registry tests | | ||
| | `pnpm typecheck` | MDX + TypeScript check | | ||
|
|
@@ -138,7 +139,15 @@ Stack: Next.js 16, React 19, Fumadocs, Tailwind CSS 4, shadcn/ui (Base UI), Clou | |
| pnpm cf:deploy | ||
| ``` | ||
|
|
||
| Connect the repo in the [Cloudflare dashboard](https://dash.cloudflare.com/) (Workers Builds) for Git-based deploys. Point `23rd.dev` DNS at the Worker when you're ready to cut over from Vercel. | ||
| GitHub Actions deploys production on push to `main`. Opening a PR uploads a **preview version** (production is untouched) and comments a stable URL, the same idea as Vercel preview deployments: | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win Document the fork pull request limitation. The workflow in 🤖 Prompt for AI Agents |
||
|
|
||
| `https://pr-<number>-23rd-dev.<subdomain>.workers.dev` | ||
|
|
||
| That alias stays put as you push more commits to the same PR. Preview URLs live on `workers.dev`; they are public unless you later put [Cloudflare Access](https://developers.cloudflare.com/workers/configuration/cloudflare-access/) in front of them. | ||
|
|
||
| You can get the same PR comments from [Workers Builds](https://developers.cloudflare.com/workers/ci-cd/builds/) instead: connect the repo in the [Cloudflare dashboard](https://dash.cloudflare.com/), set the deploy command to `pnpm cf:deploy`, the non-production command to `pnpm cf:upload`, and enable **Builds for non-production branches**. Don’t run both Workers Builds and this GitHub Action for the same events or you’ll double-deploy. | ||
|
|
||
| Point `23rd.dev` DNS at the Worker when you’re ready to cut over from Vercel. | ||
|
|
||
| ## Contributing | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift
🧩 Analysis chain
🏁 Script executed:
Repository: radiumcoders/23rd.dev
Length of output: 10197
🏁 Script executed:
Repository: radiumcoders/23rd.dev
Length of output: 6473
🌐 Web query:
GitHub Actions pull_request same repository secrets available untrusted code environment required reviewers actions checkout persist-credentials default documentation💡 Result:
In GitHub Actions, workflows triggered by
pull_requestandpull_request_targethandle secrets and untrusted code differently due to security risks associated with code from forks [1][2][3]. Key Security Distinctions: -pull_requestEvent: Workflows triggered bypull_requestfrom a fork are unprivileged [2][4]. They do not have access to repository secrets and are granted a read-onlyGITHUB_TOKEN[3][5]. This isolation makes them the safer choice for testing or building code from untrusted forks [2][6]. -pull_request_targetEvent: Workflows triggered bypull_request_targetare privileged [2][4]. They run in the context of the base repository's default branch, granting them access to repository secrets and a read/writeGITHUB_TOKEN[1][7]. Because these workflows run with elevated privileges, checking out and executing untrusted code from the PR head can lead to "pwn request" vulnerabilities, where malicious code gains access to secrets or repository write access [8][6][7]. Mitigating Risks: - Avoid executing untrusted code: If you must usepull_request_target(e.g., for labeling or commenting), do not check out or run code from the untrusted PR head [8][3][5]. - Checkout safety: Recent updates (e.g.,actions/checkoutv7+) include protections that refuse to fetch fork pull request code inpull_request_targetworkflows by default unless explicitly opted in, as this is a common attack vector [7][9]. - Environments and Reviewers: You can further secure access to sensitive secrets by using GitHub Actions environments, which allow you to define "required reviewers" [8]. Secrets linked to an environment will not be available to a workflow until it successfully deploys to that environment, and an authorized person approves the deployment [8]. Regardingactions/checkoutandpersist-credentials: - Thepersist-credentialsinput determines whether the authentication token (or SSH key) is configured in the local Git configuration [9][10]. - Whenpersist-credentialsistrue(the traditional default), any subsequent step in the job can theoretically access these credentials [11][12]. Settingpersist-credentials: falseprevents the action from storing these credentials in the local Git config, which is a recommended hardening step if you do not need the runner to perform authenticated Git commands after the checkout step [11][9][12].Citations:
persist-credentialsor change the default tofalseactions/checkout#485🏁 Script executed:
Repository: radiumcoders/23rd.dev
Length of output: 2289
Gate Cloudflare credentials before executing PR code.
The
previewjob executes PR-controlled installation, build, and upload commands. The same-repository condition does not establish approval, and the job exposes Cloudflare secrets plus write-capableGITHUB_TOKENpermissions. ThePreviewenvironment has no protection rules, and the deployment environment is created only after the upload.Attach the job to a protected
previewenvironment with required reviewers. Store least-privilege Cloudflare secrets in that environment. Setpersist-credentials: falseon the preview checkout.🤖 Prompt for AI Agents
Source: Linters/SAST tools