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
2 changes: 1 addition & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ Goal: every pillar section is accurate, current, and self-maintaining.

- [ ] Architectures: automated import from cncf/architecture stays in sync (scheduled workflow exists; add freshness indicator on the page — issue #80)
- [ ] Metrics: scheduled refresh of data/metrics.json with validation gating the build (issue #74)
- [ ] Awards: complete historical winner list, each entry verified against its cncf.io announcement (issue #76)
- [x] Awards: complete historical winner list, each entry verified against its cncf.io announcement (issue #77)
- [ ] Community: current TAB membership, End User Groups, and engagement pathways (issue #79)
- [ ] Events: upcoming end-user events at KubeCon + CloudNativeCon (issue #75)
- [x] Blog: establish a publishing cadence beyond the welcome post — monthly "Month in Metrics" post sourced from `data/metrics.json` diffs (issue #76; cadence documented in [docs/skills/blog-management.md](docs/skills/blog-management.md#publishing-cadence))
Expand Down
5 changes: 4 additions & 1 deletion data/awards.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
{
"description": "CNCF End User award winners rendered on /awards. Winners are data entries, not pages. Verify every entry against its cncf.io announcement before adding. Schema: year (int), award (slug), awardLabel (display name), organization, slug (kebab-case org id), logo (path under /static, or null for text fallback), citation (one sentence, from the announcement), event (where it was presented), announcementUrl, caseStudyUrl (nullable), talkUrl (nullable).",
"description": "CNCF End User award winners rendered on /awards. Winners are data entries, not pages. Verify every entry against its cncf.io announcement before adding. Canonical historical source for completeness checks: https://contribute.cncf.io/community/awards/ (Top End User sections, 2018-present). Schema: year (int), award (slug), awardLabel (display name), organization, slug (kebab-case org id), logo (path under /static, or null for text fallback), citation (one sentence, from the announcement), event (where it was presented), announcementUrl (nullable, only when cncf.io issued no standalone announcement), caseStudyUrl (nullable), talkUrl (nullable).",
"verifiedAt": "2026-08-08",
"verifiedAgainst": "https://contribute.cncf.io/community/awards/",
"verificationNote": "Audited all 15 entries (2018-2026) against the Top End User Award history on contribute.cncf.io/community/awards. Count matches the published history exactly (no gaps, no extras). Every announcementUrl, caseStudyUrl, and talkUrl was fetched and confirmed to resolve to the matching organization/award page as of this date.",
"awards": [
{
"year": 2026,
Expand Down
79 changes: 68 additions & 11 deletions scripts/validate-awards.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,26 +3,83 @@ import { readFileSync } from 'node:fs';
import { existsSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { reportAndExit } from './lib/validate-utils.mjs';
const data = JSON.parse(readFileSync(new URL('../data/awards.json', import.meta.url)));
const data = JSON.parse(
readFileSync(new URL('../data/awards.json', import.meta.url)),
);
const errors = [];
if (!Array.isArray(data.awards) || !data.awards.length) errors.push({ path: 'awards.json', severity: 'error', message: 'awards must be a non-empty array' });
if (Number.isNaN(Date.parse(data.verifiedAt)))
errors.push({
path: 'awards.json',
severity: 'error',
message:
'verifiedAt must be a parseable date recording the last completeness audit against verifiedAgainst',
});
if (!data.verifiedAgainst || !/^https:\/\//.test(data.verifiedAgainst))
errors.push({
path: 'awards.json',
severity: 'error',
message:
'verifiedAgainst must be an https URL to the canonical award history source',
});
if (!Array.isArray(data.awards) || !data.awards.length)
errors.push({
path: 'awards.json',
severity: 'error',
message: 'awards must be a non-empty array',
});
let lastYear = Infinity;
for (const entry of data.awards || []) {
const id = `${entry.year}/${entry.slug}`;
if (!Number.isInteger(entry.year) || entry.year < 2015) errors.push({ path: id, severity: 'error', message: 'invalid year' });
if (entry.year > lastYear) errors.push({ path: id, severity: 'error', message: 'awards must be sorted newest first' });
if (!Number.isInteger(entry.year) || entry.year < 2015)
errors.push({ path: id, severity: 'error', message: 'invalid year' });
if (entry.year > lastYear)
errors.push({
path: id,
severity: 'error',
message: 'awards must be sorted newest first',
});
lastYear = Math.max(entry.year, 2015);
for (const field of ['award', 'awardLabel', 'organization', 'slug', 'citation', 'event']) {
if (!entry[field]) errors.push({ path: id, severity: 'error', message: `missing ${field}` });
for (const field of [
'award',
'awardLabel',
'organization',
'slug',
'citation',
'event',
]) {
if (!entry[field])
errors.push({ path: id, severity: 'error', message: `missing ${field}` });
}
if (!entry.announcementUrl && !entry.talkUrl) errors.push({ path: id, severity: 'error', message: 'entry needs an announcementUrl or talkUrl' });
if (!entry.announcementUrl && !entry.talkUrl)
errors.push({
path: id,
severity: 'error',
message: 'entry needs an announcementUrl or talkUrl',
});
for (const field of ['announcementUrl', 'caseStudyUrl', 'talkUrl']) {
if (entry[field] && !/^https:\/\//.test(entry[field])) errors.push({ path: id, severity: 'error', message: `${field} must be https` });
if (entry[field] && !/^https:\/\//.test(entry[field]))
errors.push({
path: id,
severity: 'error',
message: `${field} must be https`,
});
}
if (entry.logo) {
if (!entry.logo.startsWith('/img/awards/')) errors.push({ path: id, severity: 'error', message: 'logo must live under /img/awards/' });
const file = fileURLToPath(new URL(`../static${entry.logo}`, import.meta.url));
if (!existsSync(file)) errors.push({ path: id, severity: 'error', message: `logo file missing: ${entry.logo}` });
if (!entry.logo.startsWith('/img/awards/'))
errors.push({
path: id,
severity: 'error',
message: 'logo must live under /img/awards/',
});
const file = fileURLToPath(
new URL(`../static${entry.logo}`, import.meta.url),
);
if (!existsSync(file))
errors.push({
path: id,
severity: 'error',
message: `logo file missing: ${entry.logo}`,
});
}
}
reportAndExit(errors, 'awards');
Expand Down
34 changes: 32 additions & 2 deletions src/components/AwardsTimeline/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,16 @@ import awardsData from '@site/data/awards.json';
import styles from './styles.module.css';

function WinnerCard({ entry }) {
const { organization, awardLabel, citation, event, logo, announcementUrl, caseStudyUrl, talkUrl } = entry;
const {
organization,
awardLabel,
citation,
event,
logo,
announcementUrl,
caseStudyUrl,
talkUrl,
} = entry;
const primaryUrl = announcementUrl || talkUrl;
const logoUrl = useBaseUrl(logo || '');
return (
Expand All @@ -17,7 +26,12 @@ function WinnerCard({ entry }) {
aria-label={`${organization} — ${awardLabel}`}
>
{logo ? (
<img src={logoUrl} alt={`${organization} logo`} className={styles.logo} loading="lazy" />
<img
src={logoUrl}
alt={`${organization} logo`}
className={styles.logo}
loading="lazy"
/>
) : (
<span className={styles.logoFallback}>{organization}</span>
)}
Expand Down Expand Up @@ -56,9 +70,25 @@ export default function AwardsTimeline() {
byYear.get(entry.year).push(entry);
}
const years = Array.from(byYear.keys()).sort((a, b) => b - a);
const verifiedDate = awardsData.verifiedAt
? new Date(awardsData.verifiedAt).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
})
: null;

return (
<div className={styles.timeline}>
{verifiedDate && awardsData.verifiedAgainst && (
<p className={styles.verification}>
Winner history audited for completeness against{' '}
<a href={awardsData.verifiedAgainst} target="_blank" rel="noreferrer">
contribute.cncf.io/community/awards
</a>{' '}
on {verifiedDate}.
</p>
)}
{years.map((year) => (
<section key={year} className={styles.yearGroup}>
<div className={styles.yearRail}>
Expand Down
6 changes: 6 additions & 0 deletions src/components/AwardsTimeline/styles.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@
margin-top: 2rem;
}

.verification {
color: var(--ifm-color-emphasis-600);
font-size: 0.85rem;
margin: -1rem 0 1.5rem;
}

.yearGroup {
display: grid;
grid-template-columns: 7rem 1fr;
Expand Down
31 changes: 29 additions & 2 deletions tests/validate-awards.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,15 @@ const validEntry = {
announcementUrl: 'https://www.cncf.io/announcements/2024/example',
};

function awardsFixture(awards) {
return { 'data/awards.json': JSON.stringify({ awards }) };
function awardsFixture(awards, overrides = {}) {
return {
'data/awards.json': JSON.stringify({
verifiedAt: '2026-08-08',
verifiedAgainst: 'https://contribute.cncf.io/community/awards/',
awards,
...overrides,
}),
};
}

test('accepts a valid awards file', () => {
Expand All @@ -31,6 +38,26 @@ test('rejects an empty awards array', () => {
assert.match(result.stderr, /non-empty array/);
});

test('rejects a missing verifiedAt', () => {
const result = runScriptWithFixtures(
SCRIPT,
awardsFixture([validEntry], { verifiedAt: undefined }),
);
assert.equal(result.status, 1);
assert.match(result.stderr, /verifiedAt must be a parseable date/);
});

test('rejects a non-https verifiedAgainst', () => {
const result = runScriptWithFixtures(
SCRIPT,
awardsFixture([validEntry], {
verifiedAgainst: 'contribute.cncf.io/community/awards/',
}),
);
assert.equal(result.status, 1);
assert.match(result.stderr, /verifiedAgainst must be an https URL/);
});

test('rejects years before 2015', () => {
const result = runScriptWithFixtures(
SCRIPT,
Expand Down