From fad5625b1d08c19b407d37f5c81d9218b32ac7ea Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 09:41:41 +0000 Subject: [PATCH] validate: check record value formats, run the same check before every deploy A malformed record (the aurora TXT object) was merged although the PR check was red, and it broke the deploy of the whole skillissue.gg zone. - validate-pr.js: check each value per type (IPv4, IPv6, hostname, MX, SRV, CAA, TXT without quotes, single CNAME) and export validateContent - deploy.js: run validateContent on every file before POSTing, so a broken file fails the run instead of reaching skrime, even if a red PR is merged - README: document the new rules Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Vzw95xoy6RWUeGkRfgbMST --- .github/scripts/deploy.js | 17 +++++++--- .github/scripts/validate-pr.js | 62 +++++++++++++++++++++++++++++++--- README.md | 2 ++ 3 files changed, 72 insertions(+), 9 deletions(-) diff --git a/.github/scripts/deploy.js b/.github/scripts/deploy.js index 79d3c0f..618f273 100644 --- a/.github/scripts/deploy.js +++ b/.github/scripts/deploy.js @@ -1,5 +1,6 @@ const fs = require("fs"); const path = require("path"); +const { validateContent } = require("./validate-pr.js"); const API_URL = process.env.SKRIME_API_URL; const API_KEY = process.env.SKRIME_API_KEY; @@ -43,15 +44,21 @@ function collectRecords(domain) { const label = file.replace(/\.json$/, ""); const name = label === "@" ? sub : `${label}.${sub}`; - const data = JSON.parse(fs.readFileSync(path.join(subDir, file), "utf8")); + const where = `${domain}: ${sub}/${file}`; + let data; + try { + data = JSON.parse(fs.readFileSync(path.join(subDir, file), "utf8")); + } catch (e) { + throw new Error(`${where} is not valid JSON: ${e.message}`); + } + const problems = validateContent(data, label === "@"); + if (problems.length) throw new Error(`${where}: ${problems.join("; ")}`); + const recs = data.records || {}; for (const [type, value] of Object.entries(recs)) { const values = Array.isArray(value) ? value : [value]; for (const v of values) { - if (typeof v !== "string" && typeof v !== "number") { - throw new Error(`${domain}: ${sub}/${file} ${type} value must be a string, got ${JSON.stringify(v)}`); - } - records.push({ name, type, data: String(v) }); + records.push({ name, type, data: v }); } } } diff --git a/.github/scripts/validate-pr.js b/.github/scripts/validate-pr.js index 2c047e5..298579c 100644 --- a/.github/scripts/validate-pr.js +++ b/.github/scripts/validate-pr.js @@ -21,6 +21,8 @@ // folder, the PR author must be the owner recorded in that folder's // `@.json` on the base branch. If not, the PR is closed automatically. +const net = require("net"); + const MARKER = ""; const ALLOWED_RECORD_TYPES = ["A", "AAAA", "CNAME", "ALIAS", "MX", "SRV", "TXT", "CAA"]; @@ -250,10 +252,7 @@ function validateContent(data, isApex) { } } for (const [t, value] of Object.entries(data.records)) { - const values = Array.isArray(value) ? value : [value]; - if (values.length === 0 || values.some((v) => typeof v !== "string" || v.trim() === "")) { - errors.push(`\`${t}\` must be a string or a list of strings (for a record on another name like \`_verify\`, add a separate \`_verify.json\` file)`); - } + if (ALLOWED_RECORD_TYPES.includes(t)) errors.push(...validateValues(t, value)); } if (types.includes("CNAME") && types.length > 1) { errors.push("a `CNAME` record cannot be combined with other record types"); @@ -264,6 +263,58 @@ function validateContent(data, isApex) { return errors; } +const HOST = /^(?=.{1,253}\.?$)([a-z0-9_]([a-z0-9_-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}\.?$/i; + +// Checks the value(s) of one record type. Returns a list of error strings. +function validateValues(type, value) { + const values = Array.isArray(value) ? value : [value]; + if (values.length === 0) return [`\`${type}\` must not be an empty list`]; + + const errors = []; + for (const v of values) { + if (typeof v !== "string" || v.trim() === "") { + errors.push(`\`${type}\` values must be non-empty strings, got \`${JSON.stringify(v)}\` (for a record on another name like \`_verify\`, add a separate \`_verify.json\` file)`); + continue; + } + const bad = (hint) => errors.push(`\`${type}\` value \`${v}\` is invalid: ${hint}`); + switch (type) { + case "A": + if (!net.isIPv4(v)) bad("expected an IPv4 address like `185.199.108.153`"); + break; + case "AAAA": + if (!net.isIPv6(v)) bad("expected an IPv6 address like `2606:50c0:8000::153`"); + break; + case "CNAME": + case "ALIAS": + if (!HOST.test(v)) bad("expected a hostname like `target.example.net`"); + break; + case "MX": { + const m = v.match(/^(\d{1,5}) (\S+)$/); + if (!m || Number(m[1]) > 65535 || !HOST.test(m[2])) bad("expected ` `, e.g. `10 mail.example.net`"); + break; + } + case "SRV": { + const m = v.match(/^(\d{1,5}) (\d{1,5}) (\d{1,5}) (\S+)$/); + if (!m || m.slice(1, 4).some((n) => Number(n) > 65535) || !(m[4] === "." || HOST.test(m[4]))) { + bad("expected ` `, e.g. `10 5 25565 mc.example.net`"); + } + break; + } + case "CAA": + if (!/^\d{1,3} (issue|issuewild|iodef) \S+$/.test(v)) bad("expected ` `, e.g. `0 issue letsencrypt.org`"); + break; + case "TXT": + if (/^".*"$/.test(v)) bad("leave out the surrounding quotes, they are added automatically"); + else if (v.length > 2048) bad("longer than 2048 characters"); + break; + } + } + if ((type === "CNAME" || type === "ALIAS") && values.length > 1) { + errors.push(`\`${type}\` must be a single hostname, not a list`); + } + return errors; +} + async function listDomains(github, owner, repo, ref) { try { const res = await github.rest.repos.getContent({ owner, repo, path: "domains", ref }); @@ -313,3 +364,6 @@ async function setLabels(github, owner, repo, issue_number, labels) { } catch (e) { /* ignore */ } await github.rest.issues.addLabels({ owner, repo, issue_number, labels }).catch(() => {}); } + +// Reused by deploy.js so a broken file can never reach the DNS API. +module.exports.validateContent = validateContent; diff --git a/README.md b/README.md index 9606921..e305d8d 100644 --- a/README.md +++ b/README.md @@ -110,6 +110,8 @@ Netlify, a server IP, …) at it and you're online. | `@.json` must contain an `owner.github` | Anchors who owns the folder | | At least one record under `records` | A subdomain has to point somewhere | | `CNAME` cannot be combined with other record types | DNS spec | +| Every value is a plain string in the right format (`A` = IPv4, `AAAA` = IPv6, `CNAME` = one hostname, `MX` = `10 mail.example.net`, `TXT` without quotes) | Broken values would make the whole zone fail to deploy | +| A record on another name (e.g. `_railway-verify`) goes in its own file `/_railway-verify.json` | One file = one DNS name | | Names like `www`, `api`, `mail`, `ns1` … are reserved | Infrastructure protection | ---