-
Notifications
You must be signed in to change notification settings - Fork 0
Habilita quality checks #74
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
9c3e4a5
0090287
1df7714
0ac9123
119864e
0e6ec07
cca6ac6
f1f5a88
a305878
1882aa4
0fd0822
9d07d0f
e81dacc
992d44a
e7d8565
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 |
|---|---|---|
| @@ -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 } |
| 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 } |
| 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 } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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
|
||
| 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 | ||
|
|
@@ -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. | ||
|
|
||
| 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
|
||||||||||||||||
| 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() |
Uh oh!
There was an error while loading. Please reload this page.