diff --git a/package.json b/package.json
index 8a8d3008..b2029dbb 100644
--- a/package.json
+++ b/package.json
@@ -58,6 +58,7 @@
"validate:radar-reports": "node scripts/validate-radar-reports.mjs",
"validate:community-people": "node scripts/validate-community-people.mjs",
"validate:community-groups": "node scripts/validate-community-groups.mjs",
+ "validate:projects-born": "node scripts/validate-projects-born.mjs",
"pr-queue-hygiene": "node scripts/pr-queue-hygiene.mjs",
"test:unit": "TZ=UTC node --test",
"test:unit:coverage": "TZ=UTC node tests/tools/coverage-report.mjs",
diff --git a/scripts/validate-projects-born.mjs b/scripts/validate-projects-born.mjs
new file mode 100644
index 00000000..8b9bab5a
--- /dev/null
+++ b/scripts/validate-projects-born.mjs
@@ -0,0 +1,105 @@
+#!/usr/bin/env node
+import { readFileSync } from 'node:fs';
+import { reportAndExit } from './lib/validate-utils.mjs';
+
+// data/projects-born.json is hand-maintained, and src/components/ProjectsBorn
+// renders every entry's `url` as an . That component is mounted in
+// src/theme/Footer, so these links ship on every page of the site rather than
+// on one section of the homepage.
+//
+// It was the only data file feeding an with no validator behind it,
+// which left a maintainer skimming a JSON diff as the sole gate. A scheme
+// prefix test would not be one either: /^https:\/\// accepts
+// "https://www.cncf.io@evil.example/", whose visible prefix and real host
+// disagree, and accepts unparseable values such as "https://". Parse the URL
+// instead, the same standard checkHttpsUrl() in validate-awards.mjs and
+// checkUrl() in validate-metrics.mjs already apply.
+//
+// No host allow-list: these are legitimately third-party project sites
+// (envoyproxy.io, jaegertracing.io, backstage.io), unlike the cncf.io feeds
+// guarded by validate-radar-reports.mjs and validate-case-studies.mjs.
+
+const REQUIRED_TEXT_FIELDS = ['name', 'origin', 'description'];
+
+function checkUrl(errors, path, value) {
+ if (typeof value !== 'string' || !value.trim()) {
+ errors.push({
+ path,
+ severity: 'error',
+ message: 'url must be a non-empty string',
+ });
+ return;
+ }
+
+ let parsed;
+ try {
+ parsed = new URL(value.trim());
+ } catch {
+ errors.push({
+ path,
+ severity: 'error',
+ message: `url must be an absolute https URL: ${JSON.stringify(value)}`,
+ });
+ return;
+ }
+
+ if (parsed.protocol !== 'https:') {
+ errors.push({
+ path,
+ severity: 'error',
+ message: `url must use https, got ${parsed.protocol}`,
+ });
+ return;
+ }
+
+ if (parsed.username || parsed.password) {
+ errors.push({
+ path,
+ severity: 'error',
+ message: `url must not carry a userinfo component, which only disguises the real host (${parsed.hostname})`,
+ });
+ }
+}
+
+const data = JSON.parse(
+ readFileSync(new URL('../data/projects-born.json', import.meta.url)),
+);
+const errors = [];
+
+if (!Array.isArray(data) || !data.length) {
+ errors.push({
+ path: 'projects-born.json',
+ severity: 'error',
+ message: 'projects-born.json must be a non-empty array',
+ });
+}
+
+const names = new Set();
+for (const entry of Array.isArray(data) ? data : []) {
+ const path = typeof entry?.name === 'string' ? entry.name : 'unknown';
+
+ for (const field of REQUIRED_TEXT_FIELDS) {
+ const value = entry?.[field];
+ if (typeof value !== 'string' || !value.trim()) {
+ errors.push({
+ path,
+ severity: 'error',
+ message: `${field} must be a non-empty string`,
+ });
+ }
+ }
+
+ if (typeof entry?.name === 'string' && names.has(entry.name)) {
+ errors.push({
+ path,
+ severity: 'error',
+ message: 'duplicate name',
+ });
+ }
+ names.add(entry?.name);
+
+ checkUrl(errors, path, entry?.url);
+}
+
+reportAndExit(errors, 'projects born');
+console.log(`Validated ${data.length} born projects`);
diff --git a/tests/validate-projects-born.test.mjs b/tests/validate-projects-born.test.mjs
new file mode 100644
index 00000000..a839f403
--- /dev/null
+++ b/tests/validate-projects-born.test.mjs
@@ -0,0 +1,166 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { runScriptWithFixtures } from './helpers.mjs';
+
+const SCRIPT = 'validate-projects-born.mjs';
+
+const validEntry = {
+ name: 'Envoy',
+ origin: 'Lyft',
+ description: 'Originally built at Lyft before becoming a CNCF project.',
+ url: 'https://www.envoyproxy.io/',
+};
+
+const validData = [
+ validEntry,
+ {
+ name: 'Jaeger',
+ origin: 'Uber',
+ description: 'Open sourced by Uber to make distributed tracing practical.',
+ url: 'https://www.jaegertracing.io/',
+ },
+];
+
+function fixture(data) {
+ return { 'data/projects-born.json': JSON.stringify(data) };
+}
+
+test('accepts a valid projects-born file', () => {
+ const result = runScriptWithFixtures(SCRIPT, fixture(validData));
+ assert.equal(result.status, 0, result.stderr);
+ assert.match(result.stdout, /Validated 2 born projects/);
+});
+
+test('rejects a file that is not an array', () => {
+ const result = runScriptWithFixtures(SCRIPT, fixture({ projects: [] }));
+ assert.equal(result.status, 1);
+ assert.match(result.stderr, /must be a non-empty array/);
+});
+
+test('rejects an empty array', () => {
+ const result = runScriptWithFixtures(SCRIPT, fixture([]));
+ assert.equal(result.status, 1);
+ assert.match(result.stderr, /must be a non-empty array/);
+});
+
+// The control this validator exists for: the visible prefix reads as the
+// project's own site while the request resolves to the userinfo-suffixed host.
+test('rejects a url carrying a userinfo component', () => {
+ const result = runScriptWithFixtures(
+ SCRIPT,
+ fixture([
+ { ...validEntry, url: 'https://www.envoyproxy.io@evil.example/' },
+ ]),
+ );
+ assert.equal(result.status, 1);
+ assert.match(result.stderr, /must not carry a userinfo component/);
+ assert.match(result.stderr, /evil\.example/);
+});
+
+test('rejects a non-https url', () => {
+ const result = runScriptWithFixtures(
+ SCRIPT,
+ fixture([{ ...validEntry, url: 'http://www.envoyproxy.io/' }]),
+ );
+ assert.equal(result.status, 1);
+ assert.match(result.stderr, /must use https, got http:/);
+});
+
+test('rejects a javascript: url', () => {
+ const result = runScriptWithFixtures(
+ SCRIPT,
+ fixture([{ ...validEntry, url: 'javascript:alert(1)' }]),
+ );
+ assert.equal(result.status, 1);
+ assert.match(result.stderr, /must use https, got javascript:/);
+});
+
+test('rejects an unparseable url', () => {
+ const result = runScriptWithFixtures(
+ SCRIPT,
+ fixture([{ ...validEntry, url: 'www.envoyproxy.io' }]),
+ );
+ assert.equal(result.status, 1);
+ assert.match(result.stderr, /must be an absolute https URL/);
+});
+
+test('rejects a missing url', () => {
+ const result = runScriptWithFixtures(
+ SCRIPT,
+ fixture([{ ...validEntry, url: undefined }]),
+ );
+ assert.equal(result.status, 1);
+ assert.match(result.stderr, /url must be a non-empty string/);
+});
+
+test('rejects a blank url', () => {
+ const result = runScriptWithFixtures(
+ SCRIPT,
+ fixture([{ ...validEntry, url: ' ' }]),
+ );
+ assert.equal(result.status, 1);
+ assert.match(result.stderr, /url must be a non-empty string/);
+});
+
+test('rejects a non-string url', () => {
+ const result = runScriptWithFixtures(
+ SCRIPT,
+ fixture([{ ...validEntry, url: 42 }]),
+ );
+ assert.equal(result.status, 1);
+ assert.match(result.stderr, /url must be a non-empty string/);
+});
+
+for (const field of ['name', 'origin', 'description']) {
+ test(`rejects a missing ${field}`, () => {
+ const result = runScriptWithFixtures(
+ SCRIPT,
+ fixture([{ ...validEntry, [field]: undefined }]),
+ );
+ assert.equal(result.status, 1);
+ assert.match(
+ result.stderr,
+ new RegExp(`${field} must be a non-empty string`),
+ );
+ });
+
+ test(`rejects a blank ${field}`, () => {
+ const result = runScriptWithFixtures(
+ SCRIPT,
+ fixture([{ ...validEntry, [field]: ' ' }]),
+ );
+ assert.equal(result.status, 1);
+ assert.match(
+ result.stderr,
+ new RegExp(`${field} must be a non-empty string`),
+ );
+ });
+}
+
+// A non-string name still has to produce a readable error path rather than
+// crashing the report, so the entry is labelled "unknown".
+test('labels an entry with a non-string name as unknown', () => {
+ const result = runScriptWithFixtures(
+ SCRIPT,
+ fixture([{ ...validEntry, name: 7 }]),
+ );
+ assert.equal(result.status, 1);
+ assert.match(result.stderr, /unknown: name must be a non-empty string/);
+});
+
+test('rejects duplicate names', () => {
+ const result = runScriptWithFixtures(
+ SCRIPT,
+ fixture([validEntry, { ...validEntry }]),
+ );
+ assert.equal(result.status, 1);
+ assert.match(result.stderr, /duplicate name/);
+});
+
+// A null entry must be reported rather than crash the walk on property access.
+test('reports a null entry instead of throwing', () => {
+ const result = runScriptWithFixtures(SCRIPT, fixture([null]));
+ assert.equal(result.status, 1);
+ assert.match(result.stderr, /unknown: name must be a non-empty string/);
+ assert.match(result.stderr, /unknown: url must be a non-empty string/);
+});
diff --git a/tests/validators-smoke.test.mjs b/tests/validators-smoke.test.mjs
index fe9c2752..b64a04b0 100644
--- a/tests/validators-smoke.test.mjs
+++ b/tests/validators-smoke.test.mjs
@@ -21,6 +21,7 @@ const READ_ONLY_VALIDATORS = [
'validate-radar-reports.mjs',
'validate-community-people.mjs',
'validate-community-groups.mjs',
+ 'validate-projects-born.mjs',
];
for (const script of READ_ONLY_VALIDATORS) {