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
58 changes: 58 additions & 0 deletions .github/lib/wait-netlify-api.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
// @ts-check
/**
* @typedef {Object} Deploy
* @property {string} id
* @property {string} [context]
* @property {string} [created_at]
* @property {string} [state]
*/

/**
* Thin Netlify API wrapper: fetch and validate deploy objects
*/

/**

* Fetch raw deploy list from Netlify API
* @param {string} siteId
* @param {string} token
* @returns {Promise<unknown[]>} raw parsed JSON
* @throws {Error} on non-OK response
*/
async function listDeploysRaw(siteId, token) {
const res = await fetch(`https://api.netlify.com/api/v1/sites/${siteId}/deploys?per_page=20`, {
headers: { Authorization: 'Bearer ' + token }
})
if (!res.ok) throw new Error(`Netlify API status ${res.status}`)
return res.json()
}

/**
* Validate the shape of the deploy list returned by Netlify
* @param {unknown} data
* @returns {Deploy[]}
*/
function validateDeploys(data) {
if (!Array.isArray(data)) throw new Error('validateDeploys: expected array from Netlify API')
for (let i = 0; i < data.length; i++) {
const d = data[i]
if (!d || typeof d !== 'object') throw new Error(`validateDeploys: invalid deploy at index ${i} (not an object)`)
if (!d.context || typeof d.context !== 'string') throw new Error(`validateDeploys: invalid deploy at index ${i} (missing or invalid context)`)
if (d.created_at && isNaN(Date.parse(String(d.created_at)))) throw new Error(`validateDeploys: invalid deploy at index ${i} (invalid created_at)`)
if (!d.id) throw new Error(`validateDeploys: invalid deploy at index ${i} (missing id)`)
}
return /** @type {Deploy[]} */ (data)
}

/**
* Get validated deploy list for a site
* @param {string} siteId
* @param {string} token
* @returns {Promise<Deploy[]>}
*/
async function listDeploys(siteId, token) {
const data = await listDeploysRaw(siteId, token)
return validateDeploys(data)
}

export { listDeploys, listDeploysRaw, validateDeploys }
47 changes: 47 additions & 0 deletions .github/lib/wait-netlify-git.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
// @ts-check
/**
* @typedef {Object} Deploy
* @property {string} [commit_ref]
* @property {string} [commit_url]
* @property {string} [commit_message]
* @property {string} [sha]
* @property {string} [commit_sha]
* @property {string} [title]
*/

/**
* @typedef {{sha: string|null, field: string|null|undefined}} ExtractResult
*/

// Git-related helpers for extracting SHAs from Netlify deploy objects

/**
* Extract a SHA-like token from common deploy fields
* @param {Deploy} deploy
* @returns {ExtractResult}
*/
function extractShaFromDeploy(deploy) {
const candidateFields = [
['commit_ref', deploy.commit_ref],
['commit_url', deploy.commit_url],
['commit_message', deploy.commit_message],
['sha', deploy.sha],
['commit_sha', deploy.commit_sha],
['title', deploy.title]
]

const found = candidateFields.find(([name, value]) => {
if (!value) return false
return /[0-9a-f]{7,40}/i.test(String(value))
})

if (!found) return { sha: null, field: null }

const [fieldName, fieldValue] = found
const match = String(fieldValue).match(/[0-9a-f]{7,40}/i)
if (!match) return { sha: null, field: fieldValue }
const sha = String(match[0]).trim().toLowerCase()
return { sha, field: fieldName }
}

export { extractShaFromDeploy }
107 changes: 107 additions & 0 deletions .github/lib/wait-netlify-integrator.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { extractShaFromDeploy } from './wait-netlify-git.js'

// @ts-check
/**
* @typedef {Object} Deploy
* @property {number|string} id
* @property {string} [branch]
* @property {string} [state]
* @property {string} [created_at]
* @property {string} [ssl_url]
* @property {string} [url]
* @property {Object} [links]
* @property {string} [context]
* @property {string} [context]
*/

const FULL_SHA_LENGTH = 40
const MIN_PREFIX_MATCH = 7

function summarizeCandidates(candidates) {
return candidates.map(d => {
const { sha, field } = extractShaFromDeploy(d)
return { id: d && d.id, sha, field, state: d && d.state }
})
}

/**
* Filter deploys to those matching a branch and sort newest first
* @param {Deploy[]} deploys
* @param {string} branchName
* @returns {Deploy[]}
*/
function previewDeploysForBranch(deploys, branchName) {
return deploys
.filter(d => {
// normal PR previews: match deploy-preview + branch
if (d.context === 'deploy-preview' && d.branch === branchName) return true
// allow running on main/master: accept production deploys (branch may be absent)
if ((branchName === 'main' || branchName === 'master') && d.context === 'production') {
if (!d.branch) return true
return d.branch === branchName
}
return false
})
.sort((a, b) => new Date(b.created_at) - new Date(a.created_at))
}

/**
* Is a deploy considered ready
* @param {Deploy|any} deploy
* @returns {boolean}
*/
function isReady(deploy) {
return deploy && deploy.state === 'ready'
}

/**
* Compare candidate SHA to expected. Allow prefix matching for expected >=7 chars
* @param {string|null} deploySha
* @param {string|null} expected
* @returns {boolean}
*/
function matchesSha(deploySha, expected) {
if (!deploySha) return false
// If no expected SHA provided, treat as not matching (fail fast)
if (!expected) return false
const exp = String(expected).trim().toLowerCase()
const dep = String(deploySha).trim().toLowerCase()
// if both look like full SHAs, require exact match
if (exp.length >= FULL_SHA_LENGTH && dep.length >= FULL_SHA_LENGTH) return dep === exp
// otherwise compare by a safe prefix: at least MIN_PREFIX_MATCH, up to available length
const prefixLen = Math.max(MIN_PREFIX_MATCH, Math.min(exp.length, dep.length))
return dep.slice(0, prefixLen) === exp.slice(0, prefixLen)
}

/**
* Find the first ready deploy matching expected SHA
* @param {Deploy[]|any[]} candidates
* @param {string|null|undefined} expectedSha
* @returns {Deploy|null}
*/
function findMatchingDeploy(candidates, expectedSha) {
if (!Array.isArray(candidates) || candidates.length === 0) return null
const expected = expectedSha ? String(expectedSha).trim().toLowerCase() : null
for (const deploy of candidates) {
if (!isReady(deploy)) continue
const { sha: deploySha } = extractShaFromDeploy(deploy)
if (matchesSha(deploySha, expected)) return deploy
}
return null
}

/**
* Choose a URL from a matching deploy prioritizing permalink/alias
* @param {Deploy|any} matching
* @returns {{url:string|null, chosenField:string|null}}
*/
function choosePreviewUrl(matching) {
const links = matching.links || {}
if (links.permalink) return { url: links.permalink, chosenField: 'links.permalink' }
if (links.alias) return { url: links.alias, chosenField: 'links.alias' }
if (matching.ssl_url) return { url: matching.ssl_url, chosenField: 'ssl_url' }
if (matching.url) return { url: matching.url, chosenField: 'url' }
throw new Error('no preview url available on deploy')
}

export { previewDeploysForBranch, findMatchingDeploy, choosePreviewUrl, summarizeCandidates }
24 changes: 15 additions & 9 deletions .github/scripts/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,26 +14,25 @@ Required environment variables (in CI)

- `NETLIFY_AUTH_TOKEN` — Netlify token (least privilege)
- `NETLIFY_SITE_ID` — Netlify site id
- `PR_BRANCH` — the PR branch name (e.g. `github.head_ref`)
- `GITHUB_ENV` — path to the GitHub Actions environment file (provided by the runner)
- `PR_BRANCH` or `GITHUB_REF_NAME` — branch name used for deploy matching

Optional but important for PR runs:

- `COMMIT_ID` — commit SHA for the PR head or the push. The workflow should set this (use `github.event.pull_request.head.sha` for PRs or `github.sha` for pushes). The script uses this to match Netlify deploys to the exact commit; without it the script will not consider a deploy as matching and will eventually time out.

Usage (in workflow)

- The workflow should call:

node .github/scripts/wait-netlify.js

Comment on lines 24 to 29
Copy link

Copilot AI Feb 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This section says the workflow should call node .github/scripts/wait-netlify.js, but the workflow in this PR was changed to call wait-netlify-runner.js. Update the docs to match the new entrypoint so copy/paste instructions stay correct.

Copilot uses AI. Check for mistakes.
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot open a new pull request to apply changes based on this feedback

Options (CLI)

- `--attempts=<n>` — number of polling attempts (default 30)
- `--delay=<ms>` — delay between attempts in ms (default 10000)
- `--debug` — enable verbose logging
- `--print-only` — print the URL to stdout instead of writing `GITHUB_ENV` (useful for local debugging)
Local debugging

Local debugging example
Export the required env vars and run locally. For PR-like behavior export `PR_BRANCH` and `COMMIT_ID`:

```bash
NETLIFY_AUTH_TOKEN=xxx NETLIFY_SITE_ID=yyy PR_BRANCH=feature/abc node .github/scripts/wait-netlify.js --debug --attempts=5
NETLIFY_AUTH_TOKEN=xxx NETLIFY_SITE_ID=yyy PR_BRANCH=feature/abc COMMIT_ID=abcd1234 GITHUB_ENV=/tmp/env node .github/scripts/wait-netlify.js
```

Security notes
Expand All @@ -49,3 +48,10 @@ Maintenance
Note on `NETLIFY_PREVIEW_URL` vs `BASE_URL`

- We prefer `NETLIFY_PREVIEW_URL` for PR runs because it's the exact preview deploy URL produced by Netlify. Keep `BASE_URL` as a manual override for staging/production/local testing. The workflow and `playwright.config.ts` already use the precedence: `NETLIFY_PREVIEW_URL || BASE_URL || http://localhost:4321`.

Note on PR vs main/master runs

- The script detects the branch from `PR_BRANCH` (recommended for PR workflows) or from `GITHUB_REF_NAME`/`GITHUB_REF` for non-PR runs.
- For branches other than `main`/`master` the script searches `deploy-preview` deploys matching the branch.
- When running on `main` or `master`, the script accepts `production` deploys (Netlify may omit `branch` on production deploy objects). This enables the workflow to resolve a usable `NETLIFY_PREVIEW_URL` when testing the default branch without special-casing or skipping validations.

4 changes: 4 additions & 0 deletions .github/scripts/wait-netlify-runner.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { fileURLToPath } from 'url'
import { runAndExit } from './wait-netlify.js'

if (process.argv[1] === fileURLToPath(import.meta.url)) runAndExit()
Comment on lines +2 to +4
Copy link

Copilot AI Feb 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The direct-execution check is likely to fail when the script is invoked with a relative path (e.g. node .github/scripts/wait-netlify-runner.js), because process.argv[1] can be relative while fileURLToPath(import.meta.url) is absolute. This would prevent runAndExit() from running in CI. Consider normalizing both sides (e.g., path.resolve(process.argv[1])) or comparing URLs instead of raw paths.

Suggested change
import { runAndExit } from './wait-netlify.js'
if (process.argv[1] === fileURLToPath(import.meta.url)) runAndExit()
import { resolve } from 'path'
import { runAndExit } from './wait-netlify.js'
if (resolve(process.argv[1]) === fileURLToPath(import.meta.url)) runAndExit()

Copilot uses AI. Check for mistakes.
Loading