diff --git a/.github/workflows/create-milestones.yml b/.github/workflows/create-milestones.yml deleted file mode 100644 index 73cbdb28..00000000 --- a/.github/workflows/create-milestones.yml +++ /dev/null @@ -1,89 +0,0 @@ -name: Create milestones - -on: - workflow_dispatch: - inputs: - retag: - description: Re-apply milestones to issues and pull requests that already have one - type: boolean - default: false - -permissions: - contents: read - issues: write - pull-requests: write - -# Serialised repository-wide: the step snapshots every existing milestone -# once, before the loop, so two concurrent dispatches both see a title as -# absent and both try to create it. Not cancelled: a run stopped partway -# through has already created milestones and assigned issues. -concurrency: - group: create-milestones - cancel-in-progress: false - -jobs: - create-milestones: - runs-on: ubuntu-24.04 - timeout-minutes: 10 - steps: - - name: Check out repository - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - - name: Create milestones and assign issues - env: - GH_TOKEN: ${{ github.token }} - REPO: ${{ github.repository }} - RETAG: ${{ inputs.retag }} - run: | - set -euo pipefail - - existing="$( - gh api --method GET "repos/${REPO}/milestones" \ - -f state=all \ - --paginate \ - --jq '.[] | [.title, .number] | @tsv' - )" - - jq -c '.milestones[]' data/milestones.json | while read -r milestone; do - title="$(jq -r '.title' <<<"$milestone")" - description="$(jq -r '.description' <<<"$milestone")" - number="$( - printf '%s\n' "$existing" | - awk -F '\t' -v title="$title" '$1 == title { print $2; exit }' - )" - - if [[ -z "$number" ]]; then - echo "Creating milestone: $title" - number="$( - gh api --method POST "repos/${REPO}/milestones" \ - -f title="$title" \ - -f description="$description" \ - --jq '.number' - )" - else - echo "Milestone already exists: $title (#$number)" - fi - - jq -r '.issues[]' <<<"$milestone" | while read -r issue; do - current_milestone="$( - gh api --method GET "repos/${REPO}/issues/${issue}" \ - --jq '.milestone.number // empty' - )" - - if [[ -n "$current_milestone" && "$RETAG" != "true" ]]; then - echo "Skipping #$issue (already has milestone #$current_milestone)" - continue - fi - - echo "Assigning #$issue to $title (#$number)" - gh api --method PATCH "repos/${REPO}/issues/${issue}" \ - -F "milestone=$number" >/dev/null - done - done - - gh api --method GET "repos/${REPO}/milestones" \ - -f state=all \ - --paginate \ - --jq '.[] | "\(.title): \(.open_issues) open, \(.closed_issues) closed"' diff --git a/.npmrc b/.npmrc index a7556ff6..7a6e3cf3 100644 --- a/.npmrc +++ b/.npmrc @@ -1,7 +1,7 @@ # Dependency lifecycle scripts (preinstall/install/postinstall) are arbitrary # code from 1490 third-party packages, and CI runs `npm ci` in jobs that hold a # write-scoped GITHUB_TOKEN (architecture-submission, refresh-metrics, -# pr-queue-hygiene, create-milestones). Nothing in this project needs them, so +# pr-queue-hygiene). Nothing in this project needs them, so # they are not run. Verified: no package.json lifecycle hooks exist, and only # three dependencies declare install scripts, none of them load-bearing: # - @swc/core postinstall is an advisory warning only; the native diff --git a/.prettierignore b/.prettierignore index e192a9cc..5cba19ec 100644 --- a/.prettierignore +++ b/.prettierignore @@ -8,8 +8,6 @@ docs/architectures/*.md !docs/architectures/index.md !docs/architectures/submit.md -# Workflow file: formatting it requires a credential with the Workflows permission: -.github/workflows/create-milestones.yml # Blog posts contain MDX JSX comments ({/* truncate */}) that prettier's markdown # parser mangles into {/_ _/}, breaking Docusaurus truncation and SSG: blog/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6440245e..d783f293 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -89,21 +89,20 @@ in mind for every page. Most pages are generated from data files. Contribute by editing the data, not by hand-building pages: -| Page | Data source | Validation | -| ------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------- | -| `/community/awards` | `data/awards.json` | `npm run validate:awards` | -| `/resources/metrics` | `data/metrics.json` (generated) | `npm run validate:metrics` | -| `/architectures` | `data/architectures/records/*.json` | `npm run validate:architectures` | -| `/community/members` | `data/enduser-landscape.json` (pinned CNCF landscape snapshot) + `data/members.json` (generated from the snapshot, `data/awards.json`, and `data/architectures/catalog.json`) | `npm run validate:enduser-members` and `npm run validate:members` | -| Community people lightboxes | `data/community-roster.json` (curated) → refreshed into `data/community-people.json` | `npm run validate:community-people` | -| `docs/community/` user groups | `data/community-groups.json` (generated) | `npm run validate:community-groups` | -| ProjectsBorn (`/community/projects-born`) | `data/projects-born.json` | `npm run validate:projects-born` | -| `/resources/case-studies` | `data/case-studies.json` (generated by `npm run collect:case-studies`) | `npm run validate:case-studies` | -| `/resources/radar-reports` | `data/radar-reports.json` (generated by `npm run collect:radar-reports`) | `npm run validate:radar-reports` | -| `/events` end user talks | `data/end-user-talks.json` (generated by `npm run collect:end-user-talks`) | `npm run validate:end-user-talks` | -| `/community/members` logos | `data/enduser-landscape-assets.json` (generated by `npm run collect:enduser-members`; manifest of the mirrored landscape logos) | `npm run validate:enduser-members` | -| Launch success metrics | `data/launch-metrics.json` (generated by `npm run collect:launch-metrics`) | `npm run validate:launch-metrics` | -| GitHub milestones (`create-milestones.yml`) | `data/milestones.json` (curated) | unit tests (`npm run test:unit`) | +| Page | Data source | Validation | +| ----------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------- | +| `/community/awards` | `data/awards.json` | `npm run validate:awards` | +| `/resources/metrics` | `data/metrics.json` (generated) | `npm run validate:metrics` | +| `/architectures` | `data/architectures/records/*.json` | `npm run validate:architectures` | +| `/community/members` | `data/enduser-landscape.json` (pinned CNCF landscape snapshot) + `data/members.json` (generated from the snapshot, `data/awards.json`, and `data/architectures/catalog.json`) | `npm run validate:enduser-members` and `npm run validate:members` | +| Community people lightboxes | `data/community-roster.json` (curated) → refreshed into `data/community-people.json` | `npm run validate:community-people` | +| `docs/community/` user groups | `data/community-groups.json` (generated) | `npm run validate:community-groups` | +| ProjectsBorn (`/community/projects-born`) | `data/projects-born.json` | `npm run validate:projects-born` | +| `/resources/case-studies` | `data/case-studies.json` (generated by `npm run collect:case-studies`) | `npm run validate:case-studies` | +| `/resources/radar-reports` | `data/radar-reports.json` (generated by `npm run collect:radar-reports`) | `npm run validate:radar-reports` | +| `/events` end user talks | `data/end-user-talks.json` (generated by `npm run collect:end-user-talks`) | `npm run validate:end-user-talks` | +| `/community/members` logos | `data/enduser-landscape-assets.json` (generated by `npm run collect:enduser-members`; manifest of the mirrored landscape logos) | `npm run validate:enduser-members` | +| Launch success metrics | `data/launch-metrics.json` (generated by `npm run collect:launch-metrics`) | `npm run validate:launch-metrics` | Rules: @@ -580,8 +579,6 @@ AI agents should start at [`AGENTS.md`](AGENTS.md) and the skill manifest in ## Project direction and policies -- [`ROADMAP.md`](ROADMAP.md) — what the site is building toward and the current - phase. - [`GOVERNANCE.md`](GOVERNANCE.md) — how decisions get made and how pull requests land. - [`SECURITY.md`](SECURITY.md) — how to report a vulnerability. diff --git a/GOVERNANCE.md b/GOVERNANCE.md index 343da243..d9e950dc 100644 --- a/GOVERNANCE.md +++ b/GOVERNANCE.md @@ -13,8 +13,6 @@ policy instead of ad-hoc judgment (see issues #47 and #58). - **Direction changes** (ownership, site architecture, new content pillars): record lasting design decisions as an ADR in `adr/`. See `adr/0001-site-ownership-and-cutover-path.md` for the format. -- **Roadmap priorities**: `ROADMAP.md` is the source of truth; changes to it are - pull requests labeled `roadmap`, reviewed by a maintainer. ## Review and merge expectations diff --git a/MAINTAINERS.md b/MAINTAINERS.md index e59bf9d6..6c426657 100644 --- a/MAINTAINERS.md +++ b/MAINTAINERS.md @@ -17,7 +17,7 @@ process below, not the roster above. ## What maintainers do - Review and merge pull requests, including automated (hive/agent) PRs. -- Triage issues and keep the roadmap (`ROADMAP.md`) current. +- Triage issues. - Make project-direction decisions and record lasting ownership or site architecture decisions in `adr/`. diff --git a/MILESTONES.md b/MILESTONES.md deleted file mode 100644 index 193053bc..00000000 --- a/MILESTONES.md +++ /dev/null @@ -1,95 +0,0 @@ -# Milestone plan for endusers.cncf.io - -This document defines the five GitHub milestones that back -[ROADMAP.md](./ROADMAP.md) (see PR #50) and the tracking issue #45. The plan is -also encoded in [`data/milestones.json`](./data/milestones.json). - -A maintainer can create or reuse all five milestones and apply their issue and -pull request assignments in one click by running the **Create milestones** -workflow from the Actions tab. Re-running it is safe: it finds open and closed -milestones by title and preserves existing issue milestones unless `retag` is -enabled. - -## Milestone: Phase 0 — Foundation - -**Description**: A reliable, trustworthy site skeleton: green deploys, a -license, supply-chain hygiene, and this planning infrastructure. - -Tag these issues/PRs: - -- #44 (deploy blocker) -- #32, PR #40 (LICENSE) -- #38, #39, PRs #41–#43 (pin actions by SHA / verify installers) -- #36, PR #37 (shared validation utilities + test coverage) -- #45 (this roadmap/milestones issue), PR #50 (ROADMAP.md) - -## Milestone: Phase 1 — Content completeness - -**Description**: Every content pillar (architectures, metrics, awards, -community, events, blog) is accurate, current, and self-maintaining. - -Tag these issues: - -- #74 (metrics data refresh + validation gating) -- #75 (events listing for KubeCon + CloudNativeCon) -- #76 (blog publishing cadence) -- #77 (awards historical winner list verification) -- #79 (community/TAB membership and End User Group pathways) -- #80 (architectures sync freshness indicator) - -## Milestone: Phase 2 — Community and governance - -**Description**: The project can outlive any single maintainer. - -Tag these issues/PRs: - -- #47 (MAINTAINERS.md / bus factor) -- PR #60 (governance/agent-automation policy) -- #98 (good-first-issue curation) -- #46 (long-term ownership decision) - -## Milestone: Phase 3 — Ecosystem integration - -**Description**: endusers.cncf.io becomes the authoritative end-user -destination. - -Tag issues/PRs covering: - -- DNS cutover to endusers.cncf.io -- #1407 (cross-linking with contribute.cncf.io and cncf.io) -- #1408 (content-issue templates and a public changelog) - -## Milestone: Launch - -**Description**: Cross-cutting launch milestone: the site is announced when -every readiness gate is satisfied, not on a calendar date. - -This milestone tracks launch-specific coordination. GitHub supports one -milestone per issue, so the six content-pillar issues (#74, #75, #76, #77, #79, -and #80) remain in Phase 1; the launch milestone depends on that phase instead -of duplicating those assignments. - -Tag these issues: - -- #99 (ADR 0001 ownership decision — ratify before the announcement so it can - name the site's permanent home) -- #90 (launch/promotion strategy: announcement channels and amplification - timing) -- #100 (success metrics baseline + post-launch targets) -- #104 (the original launch-window finding; the dated countdown it opened has - since been replaced by the readiness checklist) - -Depends on: the Phase 1 milestone, whose content pillars are the readiness gates -this milestone tracks. - -## How to apply this plan - -1. In the Actions tab, open **Create milestones** and select **Run workflow**. -2. Leave `retag` disabled to preserve issue and pull request milestones that are - already set. Enable it only when the assignments in this file should replace - existing assignments. -3. Confirm that all five milestones exist and contain the expected items. - -As a manual fallback, create the five milestones from the repository's Issues → -Milestones page, using the descriptions above, then apply each listed issue and -pull request assignment. diff --git a/ROADMAP.md b/ROADMAP.md deleted file mode 100644 index 5ff9d606..00000000 --- a/ROADMAP.md +++ /dev/null @@ -1,152 +0,0 @@ -# Roadmap: endusers.cncf.io - -This roadmap describes the planned evolution of the CNCF End User Community -site. It is a living document; update it as priorities change. Tracking: issues -labeled `roadmap`. - -## Vision - -Be the trusted home of the CNCF End User Community: the place where -organizations running cloud native in production find reference architectures, -metrics, events, and pathways to participate. - -## Launch target - -The launch is gated on readiness, not on a date or event. Earlier revisions -named a specific conference as the announcement window (issue #104); the dated -countdown slipped twice (#966, #1271) and was replaced by readiness gates on the -Phase 1 content pillars below. Conferences and End User TAB meetings that fall -near readiness are amplification opportunities, not deadlines. The ownership -decision has landed (ADR 0001 is Accepted; issue #99 closed) and the DNS cutover -to `endusers.cncf.io` completed on 2026-10-22 (issue #1433). - -## Guiding principles - -- **Audience first**: content serves end users (adopters), not project - contributors. -- **Data over prose**: metrics, awards, and architectures are generated from - authoritative sources (cncf/landscape, cncf/architecture, cncf/tab), never - hand-edited. -- **Verified facts**: every CNCF fact is backed by an authoritative source link. -- **Green pipeline**: the site must always deploy; a broken deploy blocks - everything. - -## Phase 0 — Foundation - -Goal: a reliable, trustworthy site skeleton. - -- [x] Restore a green deploy pipeline (#52) -- [x] Add a LICENSE (#40, issue #32) -- [x] Pin GitHub Actions by SHA and verify downloaded installers (issues #38, - #39; PRs #42, #43, #112) — all `uses:` references across - `.github/workflows/` are SHA-pinned; the Prince-installer workflow was - removed -- [x] Shared validation utilities and test coverage for data pipelines (#37, - #65) -- [x] This roadmap (#50), plus MILESTONES.md (#53) -- [x] Create GitHub milestones matching these phases (issue #78) - -## Phase 1 — Content completeness - -Goal: every pillar section is accurate, current, and self-maintaining. - -- [x] Architectures: catalog seeded from cncf/architecture, now maintained as - committed content with new entries arriving through the submission - workflow (`architecture-submission.yml`); the automated upstream sync and - its freshness indicator (issue #80) were retired once the catalog became - submission-driven -- [x] Metrics: refresh of data/metrics.json with validation gating the build — - issue #74 closed not-planned at the time; the on-demand - `refresh-metrics.yml` workflow now runs - `collect:metrics`/`validate:metrics` -- [x] Awards: complete historical winner list, each entry verified against its - cncf.io announcement (issue #77) -- [x] Community: current TAB membership, End User Groups, and engagement - pathways — issue #79, shipped via `src/components/PeopleFreshness/` and - `src/components/GroupLinkStatus/` rendering freshness notes from - `data/community-people.json`'s `fetchedAt` -- [x] 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 - [.agents/skills/blog-management/SKILL.md](.agents/skills/blog-management/SKILL.md#publishing-cadence)) - -## Launch (tracking: #90) (current) - -Goal: the moment Phase 1 goes green, the site is announced deliberately — not -shipped silently. Content completeness alone is not a launch plan. - -- [ ] **Launch criteria**: every Phase 1 pillar (architectures, metrics, awards, - community, events, blog) is green in CI and current as of launch day. -- [ ] **Announcement channels**: a cncf.io blog post, a mention at an End User - TAB meeting, and a KubeCon + CloudNativeCon mention or session are the - minimum bar; amplify further via the End User community Slack. -- [x] **Launch success metrics** (issue #100): `data/launch-metrics.json` - defines the falsifiable signals — GitHub stars, watchers, forks, new - unique human contributors since baseline, and good-first-issues claimed — - each with a live-collected baseline and a 90-day post-launch target. - Refresh with `npm run collect:launch-metrics`, validate with - `npm run validate:launch-metrics`. The committed file is the milestone-8 - baseline (captured 2026-10-02 under the `W-6` label the earlier dated plan - used); re-run it immediately before launch so the 90-day comparison starts - from the numbers the announcement went out on. -- [x] **DNS cutover dependency**: the Phase 2 ownership decision (issue #46, - ADR 0001) is resolved — the repository has been transferred to the `cncf` - GitHub org — and the cutover to `endusers.cncf.io` (Phase 3) completed on - 2026-10-22 (#1433). -- [ ] **90-day check-in**: re-run `npm run collect:launch-metrics` 90 days after - launch and compare each signal against its `data/launch-metrics.json` - baseline to record whether the launch moved the needle. - -Related: #90 (this finding), #46 (ownership), #75 (events pillar), #76 (blog -cadence). - -## Phase 2 — Community and governance - -Goal: the project can outlive any single maintainer. - -- [x] MAINTAINERS.md with an explicit process for adding maintainers (#55) -- [x] Governance note describing review/merge expectations, including - agent-automation policy (GOVERNANCE.md, #60) -- [x] Good-first-issue curation to recruit human contributors (#98) -- [x] Decide the long-term home of the site (issue #46, ADR 0001): ADR 0001 is - Accepted — the repository has been transferred to the `cncf` GitHub org. - The remaining DNS cutover to `endusers.cncf.io` is tracked separately in - Phase 3 (see `adr/0001-site-ownership-and-cutover-path.md`) - -## Phase 3 — Ecosystem integration - -Goal: endusers.cncf.io becomes the authoritative end-user destination. - -- [x] DNS cutover to endusers.cncf.io (#1433): `endusers.cncf.io` is the Netlify - custom domain and DNS points at it as of 2026-10-22; the canonical URL and - `robots.txt` sitemap line followed without a repository change (ADR 0001 - step 3, ADR 0005) -- [ ] Cross-linking with contribute.cncf.io and cncf.io (clear audience - boundaries) (#1407). Audience boundary decided: ADR 0004. Outbound - signposts shipped (site footer, architecture submission flow, community - resources). Inbound-link requests against contribute.cncf.io and cncf.io - remain open: ready-to-file drafts are in - `adr/inbound-link-requests-for-0004.md` (origin issue #1434, closed once - the drafts shipped); with the DNS cutover (#1433) complete they are ready - to file. -- [x] Public feedback loop: content-issue templates and a visible changelog - (#1408). Shipped: the `content-issue.yml` report form and a changelog - generated at build time (`adr/0003-public-changelog-mechanism.md`), both - linked from the site footer. The fallback-page caveat from the GitHub - Pages era (#1409) no longer applies: since the move to Netlify (ADR 0005, - #1485, #1489) the deploy checkout carries full git history, and the - deployed changelog renders real dated entries. - -## Non-goals - -- Contributor-facing documentation (belongs on contribute.cncf.io) -- Project-facing marketing for individual CNCF projects -- Client-specific or proprietary framing of any kind - -## How this roadmap is maintained - -- Strategic gaps and reprioritizations are filed as GitHub issues by the - strategist agent and reviewed by the maintainer. -- Each phase maps to a GitHub milestone; issues and PRs are tagged accordingly. -- This document is updated by planning PRs, not ad hoc edits. diff --git a/SECURITY.md b/SECURITY.md index 5ecefbca..c7daa8b3 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -69,43 +69,37 @@ the import gates. ## Known unpatched dependency advisories -`npm audit` currently reports 28 high-severity packages. They all resolve to the -single advisory below, and every other entry is transitive bubbling from it. The -package is already at its newest published version, so there is no upgrade to -apply and Dependabot has nothing to offer: - -| Advisory | Package | Installed | Reached through | -| ------------------------------------------------------------------------ | -------- | --------- | ---------------------------------------------- | -| [GHSA-vfj7-8cjw-p6xm](https://github.com/advisories/GHSA-vfj7-8cjw-p6xm) | `braces` | 3.0.3 | `@docusaurus/utils` globbing, via `micromatch` | - -It is build-time only: `braces` glob patterns come from Docusaurus rather than -from submitted content, and the package is not bundled into the static output, -so the advisory is not reachable by a visitor to the published site. - -**Do not run `npm audit fix --force` against this.** No patched version exists — -`3.0.3` is the newest release and the advisory's vulnerable range is `<= 3.0.3` -— so the command can only try to force-resolve the `@docusaurus/*` tree onto -incompatible versions, breaking the build without removing the advisory. Remove -this section once upstream publishes a fix and the dependency tree picks it up. - -A second advisory, GHSA-ch52-4w7c-c8xp against `http-cache-semantics`, was -listed here until upstream published `4.3.0` — outside that advisory's -`<= 4.2.0` vulnerable range. The lockfile now resolves `4.3.0` and the advisory -no longer appears, which is why the count above dropped from 34 to 28. When an -entry here is said to have no fix, re-check the registry before trusting it: "no -patched version" is only true as of the day it was written. -`npm run check:audit` now re-checks it for you on every run, in both of the ways -such a claim can expire. Once an allowlisted advisory stops appearing in the -lockfile, the gate fails and names the entry to remove from both -`scripts/audit-gate.mjs` and the table above. And once npm reports a fix for an -advisory that is still in the lockfile — the more likely case, where upstream -publishes a patched release before this repository picks it up — the gate fails -with the release to upgrade to, so a stale "no patched version" claim cannot sit -here unnoticed the way GHSA-ch52-4w7c-c8xp did. - -CI enforces this table: `npm run check:audit` (`scripts/audit-gate.mjs`) fails -the build on any high or critical advisory other than the allowlisted ones -above, so a new lockfile advisory cannot land silently. When an advisory listed +This section is the register of high or critical `npm audit` advisories that +`scripts/audit-gate.mjs` allowlists because no patched release exists upstream. +It is currently empty: `npm audit` reports no high or critical advisory, and +`ALLOWLISTED_ADVISORIES` in `scripts/audit-gate.mjs` has no entries. + +When one has to be added, record it as a table row here with the advisory, +package, installed version and the path it is reached through, plus a note on +whether it is build-time only or reachable from the published site, and add the +matching entry to `ALLOWLISTED_ADVISORIES`. Do not run `npm audit fix --force` +against an advisory with no patched version: the command can only try to +force-resolve the dependency tree onto incompatible versions, breaking the build +without removing the advisory. + +Two advisories have been listed here and retired. GHSA-ch52-4w7c-c8xp against +`http-cache-semantics` left when upstream published `4.3.0`, outside its +`<= 4.2.0` vulnerable range. GHSA-vfj7-8cjw-p6xm against `braces` left on +2026-10-23 when GitHub withdrew the advisory; `braces` is still at `3.0.3`, but +`npm audit` no longer reports it. When an entry here is said to have no fix, +re-check the registry before trusting it: "no patched version" is only true as +of the day it was written. `npm run check:audit` re-checks it for you on every +run, in both of the ways such a claim can expire. Once an allowlisted advisory +stops appearing in the lockfile, the gate fails and names the entry to remove +from both `scripts/audit-gate.mjs` and the table here. And once npm reports a +fix for an advisory that is still in the lockfile — the more likely case, where +upstream publishes a patched release before this repository picks it up — the +gate fails with the release to upgrade to, so a stale "no patched version" claim +cannot sit here unnoticed the way GHSA-ch52-4w7c-c8xp did. + +CI enforces this register: `npm run check:audit` (`scripts/audit-gate.mjs`) +fails the build on any high or critical advisory other than the allowlisted +ones, so a new lockfile advisory cannot land silently. When an advisory listed here gains a patched release, apply the fix and remove both the table row and the matching allowlist entry in `scripts/audit-gate.mjs` — a unit test keeps the two in sync. diff --git a/adr/0001-site-ownership-and-cutover-path.md b/adr/0001-site-ownership-and-cutover-path.md index 5fdcebc5..367871f9 100644 --- a/adr/0001-site-ownership-and-cutover-path.md +++ b/adr/0001-site-ownership-and-cutover-path.md @@ -31,8 +31,8 @@ Since then, the repository has been transferred: it now lives at `cncf.github.io/endusers`. The `endusers.cncf.io` domain itself is still not wired up (no DNS record, no `static/CNAME`), so every investment in content, SEO, and metrics still accumulates under a staging URL rather than the intended -production domain. This remaining DNS cutover is what blocks Phase 3 of the -roadmap (see `ROADMAP.md`). +production domain. This remaining DNS cutover is what blocks ecosystem +integration (cross-linking with contribute.cncf.io and cncf.io). ## Decision drivers @@ -104,14 +104,13 @@ follow-up PR. 3. Execute the DNS cutover to `endusers.cncf.io`: add it as the custom domain on the Netlify site and point DNS at Netlify. The canonical URL follows Netlify's `URL` variable, so no `static/CNAME` or workflow `SITE_URL`/ - `BASE_URL` change is involved (see ADR 0005). Then update `ROADMAP.md` Phase - 3 accordingly. + `BASE_URL` change is involved (see ADR 0005). ## Consequences - The ownership question is resolved: `cncf/endusers` is the canonical, CNCF-owned repository. -- The remaining roadmap item that depends on ownership — DNS cutover to +- The remaining item that depends on ownership — DNS cutover to `endusers.cncf.io` and cross-linking with contribute.cncf.io/cncf.io — stays open until that cutover is executed. - This ADR should be superseded (not silently edited into a different decision) diff --git a/adr/0004-audience-boundaries-across-cncf-properties.md b/adr/0004-audience-boundaries-across-cncf-properties.md index f88972f5..44271b2c 100644 --- a/adr/0004-audience-boundaries-across-cncf-properties.md +++ b/adr/0004-audience-boundaries-across-cncf-properties.md @@ -64,7 +64,7 @@ Consequences for this repository: ## References -- Issue #1407 (tracking issue for this roadmap deliverable) +- Issue #1407 (tracking issue for this deliverable) - `CONTRIBUTING.md`, "Content audience" section - `AGENTS.md`, "Audience" bullet - ADR 0001 (site ownership and cutover path) — the DNS cutover this ADR diff --git a/adr/0005-host-the-site-on-netlify.md b/adr/0005-host-the-site-on-netlify.md index 6767078b..cc230c3b 100644 --- a/adr/0005-host-the-site-on-netlify.md +++ b/adr/0005-host-the-site-on-netlify.md @@ -200,8 +200,7 @@ counts toward line coverage. canonical, deploy previews on every PR, interim URL `https://endusers.netlify.app/`. - `AGENTS.md` (deployment sentence), `LAUNCH.md` ("Default-branch deployment" - pipeline row), `ROADMAP.md` (Phase 3 DNS bullet), - `adr/stakeholder-outreach-draft-for-0001.md` and + pipeline row), `adr/stakeholder-outreach-draft-for-0001.md` and `adr/inbound-link-requests-for-0004.md` (interim URL mentions) are updated to the Netlify URL. `docs/skills/` (now `.agents/skills/`) is checked for GitHub Pages references and updated if any. diff --git a/adr/inbound-link-requests-for-0004.md b/adr/inbound-link-requests-for-0004.md index 05dbffc0..59b2dac4 100644 --- a/adr/inbound-link-requests-for-0004.md +++ b/adr/inbound-link-requests-for-0004.md @@ -15,8 +15,7 @@ resolve and these requests can be filed. Origin issue: [#1434](https://github.com/cncf/endusers/issues/1434) (closed once these drafts shipped). Live status is the Status column below: when a request is -filed, record its link there and tick the ROADMAP.md cross-linking item once all -are filed. +filed, record its link there and close #1407 once all are filed. | # | Target property | Suggested page | Requested link and anchor text | Why (ADR 0004) | Status | | --- | ------------------ | --------------------------------------------- | ------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------- | ------ | diff --git a/adr/stakeholder-outreach-draft-for-0001.md b/adr/stakeholder-outreach-draft-for-0001.md index 2730f3d6..b846441b 100644 --- a/adr/stakeholder-outreach-draft-for-0001.md +++ b/adr/stakeholder-outreach-draft-for-0001.md @@ -57,8 +57,8 @@ We've recorded three options and their tradeoffs in an ADR for discussion: Full writeup with pros/cons: [link to ADR 0001 in the merged repo] Could we get this on an upcoming TAB agenda, or get a read from TOC on the -org-transfer process, so we can record a decision and unblock the Phase 2/3 -roadmap items that depend on it (see ROADMAP.md)? +org-transfer process, so we can record a decision and unblock the work that +depends on it (DNS cutover, cross-linking, or content-merge plan)? Thanks, [maintainer name] @@ -68,7 +68,7 @@ Thanks, 1. Record the outcome in ADR 0001's "Decision" section (update Status from "Proposed" to "Accepted", with the chosen option and date). -2. Update `ROADMAP.md` Phase 2/3 to reflect the decision and unblock the - dependent items (DNS cutover, cross-linking, or content-merge plan). +2. Unblock the dependent items (DNS cutover, cross-linking, or content-merge + plan). 3. Delete or trim this file once the outreach has happened — it is a one-time aid, not a living document. diff --git a/data/milestones.json b/data/milestones.json deleted file mode 100644 index 05527233..00000000 --- a/data/milestones.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "milestones": [ - { - "title": "Phase 0 — Foundation", - "description": "A reliable, trustworthy site skeleton: green deploys, a license, supply-chain hygiene, and this planning infrastructure.", - "issues": [44, 32, 40, 38, 39, 41, 42, 43, 36, 37, 45, 50] - }, - { - "title": "Phase 1 — Content completeness", - "description": "Every content pillar (architectures, metrics, awards, community, events, blog) is accurate, current, and self-maintaining.", - "issues": [74, 75, 76, 77, 79, 80] - }, - { - "title": "Phase 2 — Community and governance", - "description": "The project can outlive any single maintainer.", - "issues": [47, 60, 98, 46] - }, - { - "title": "Phase 3 — Ecosystem integration", - "description": "endusers.cncf.io becomes the authoritative end-user destination.", - "issues": [1407, 1408] - }, - { - "title": "Launch", - "description": "Cross-cutting launch milestone: the site is announced when every readiness gate is satisfied, not on a calendar date.", - "issues": [99, 90, 100, 104] - } - ] -} diff --git a/scripts/audit-gate.mjs b/scripts/audit-gate.mjs index 48899071..44b0b0eb 100644 --- a/scripts/audit-gate.mjs +++ b/scripts/audit-gate.mjs @@ -2,10 +2,10 @@ // Fails CI when `npm audit` reports a high or critical advisory that is not // explicitly allowlisted. // -// A plain `npm audit --audit-level=high` cannot be a gate here: the lockfile -// carries one unfixable high advisory (see SECURITY.md, "Known unpatched -// dependency advisories"), so the naive gate would be permanently red and -// train everyone to ignore it. This script instead allowlists exactly the +// A plain `npm audit --audit-level=high` cannot be a gate here: whenever the +// lockfile carries an unfixable high advisory (see SECURITY.md, "Known +// unpatched dependency advisories"), the naive gate would be permanently red +// and train everyone to ignore it. This script instead allowlists exactly the // advisories SECURITY.md documents as unpatched upstream, and exits 1 the // moment any *other* high or critical advisory lands in the lockfile. // @@ -23,17 +23,16 @@ import { spawnSync } from 'node:child_process'; // Advisories npm cannot fix because no patched release exists. Every entry // must correspond to a row in SECURITY.md's "Known unpatched dependency // advisories" table; remove the entry the moment upstream publishes a fix -// (for braces: when `npm view braces versions` lists a release above 3.0.3), -// so the gate starts enforcing it. unusedAllowlistEntries() below fails the -// gate once an entry stops matching anything in the lockfile, and +// (check `npm view versions` for a release outside the vulnerable +// range), so the gate starts enforcing it. unusedAllowlistEntries() below +// fails the gate once an entry stops matching anything in the lockfile, and // fixableAllowlistedAdvisories() fails it once npm reports a fix while the // advisory is still present, so that removal is prompted rather than left to // be noticed. -export const ALLOWLISTED_ADVISORIES = new Set([ - // braces <= 3.0.3 via micromatch in @docusaurus/utils globbing; build-time - // only, never shipped to the published site. - 'GHSA-vfj7-8cjw-p6xm', -]); +// +// Empty as of 2026-10-23: the last entry, GHSA-vfj7-8cjw-p6xm against +// braces <= 3.0.3, was withdrawn upstream and npm audit stopped reporting it. +export const ALLOWLISTED_ADVISORIES = new Set([]); // `npm audit --audit-level=high` semantics: moderate and low findings are // reported but do not gate the merge. @@ -291,7 +290,10 @@ function runAudit() { return { report, failure: null, output, status: result.status }; } -function main() { +// The allowlist is a parameter so the unit tests can drive every arm of the +// gate whatever the shipped allowlist happens to contain; the CLI entry below +// always runs with ALLOWLISTED_ADVISORIES. +export function main(allowlist = ALLOWLISTED_ADVISORIES) { // Retries only the answers that carry no report. A parsed report is final // whatever it says: a genuinely new advisory must never be retried away. let last = runAudit(); @@ -327,7 +329,7 @@ function main() { } const report = last.report; - const { blocked, allowlisted } = classifyAudit(report); + const { blocked, allowlisted } = classifyAudit(report, allowlist); if (blocked.length > 0) { console.error( 'audit-gate: high/critical advisories outside the allowlist:', @@ -346,7 +348,7 @@ function main() { } // Reported after the blocked check: a live advisory outside the allowlist is // the more urgent signal, and naming a dead exemption first would bury it. - const unused = unusedAllowlistEntries(report); + const unused = unusedAllowlistEntries(report, allowlist); if (unused.length > 0) { console.error( 'audit-gate: allowlisted advisories no longer present in the lockfile:', @@ -364,7 +366,7 @@ function main() { // Reported last: the two checks above describe advisories the gate is // currently letting through, while this one describes a suppression whose // stated reason ("no patched release exists") npm now contradicts. - const fixable = fixableAllowlistedAdvisories(report); + const fixable = fixableAllowlistedAdvisories(report, allowlist); if (fixable.length > 0) { console.error( 'audit-gate: allowlisted advisories npm now reports as fixable:', diff --git a/tests/audit-gate.test.mjs b/tests/audit-gate.test.mjs index d06ad69f..735de244 100644 --- a/tests/audit-gate.test.mjs +++ b/tests/audit-gate.test.mjs @@ -33,8 +33,12 @@ import { const repoRoot = fileURLToPath(new URL('..', import.meta.url)); +// A retired advisory (withdrawn upstream on 2026-10-23) reused as a fixture +// id. The shipped allowlist is empty, so every case that needs an allowlisted +// advisory passes its own Set rather than leaning on the default. const BRACES = 'GHSA-vfj7-8cjw-p6xm'; const NEW_BAD = 'GHSA-aaaa-bbbb-cccc'; +const ALLOWLIST = new Set([BRACES]); /** @returns {object} a minimal advisory `via` object for the given GHSA id */ function advisory(id, severity = 'high') { @@ -73,7 +77,7 @@ test('an entry whose only advisory is allowlisted is not blocked', () => { const report = { vulnerabilities: { braces: entry('high', [advisory(BRACES)]) }, }; - const { blocked, allowlisted } = classifyAudit(report); + const { blocked, allowlisted } = classifyAudit(report, ALLOWLIST); assert.deepEqual(blocked, []); assert.deepEqual(allowlisted, ['braces']); }); @@ -110,7 +114,7 @@ test('an allowlisted advisory clears its whole bubbled chain', () => { '@docusaurus/utils': entry('high', ['micromatch']), }, }; - const { blocked, allowlisted } = classifyAudit(report); + const { blocked, allowlisted } = classifyAudit(report, ALLOWLIST); assert.deepEqual(blocked, []); assert.deepEqual(allowlisted.sort(), [ '@docusaurus/utils', @@ -134,12 +138,15 @@ test('moderate and low findings never block, even unallowlisted', () => { test('a via cycle terminates and is judged by its advisories', () => { // npm emits mutual references when two packages each bubble the other. // With only an allowlisted advisory in the cycle, both clear... - const clean = classifyAudit({ - vulnerabilities: { - a: entry('high', [advisory(BRACES), 'b']), - b: entry('high', ['a']), + const clean = classifyAudit( + { + vulnerabilities: { + a: entry('high', [advisory(BRACES), 'b']), + b: entry('high', ['a']), + }, }, - }); + ALLOWLIST, + ); assert.deepEqual(clean.blocked, []); // ...and with a non-allowlisted one, both block. const dirty = classifyAudit({ @@ -545,6 +552,9 @@ syncBuiltinESMExports(); * @param {boolean} [options.withNpm] install the stub at all; false makes * spawnSync fail with ENOENT to exercise the spawn-error arm * @param {number} [options.timeouts] deadline errors before npm can run + * @param {Iterable} [options.allowlist] GHSA ids to run the gate + * with, through a wrapper entry that calls main(); omitted, the script runs + * as the CLI does with its shipped allowlist * @returns {{status: number, stdout: string, stderr: string, * attempts: number, argv: string[], spawns: object[]}} `argv` holds the * invocations that reached npm; `spawns` includes timed-out attempts. @@ -555,6 +565,7 @@ function runGate({ responses, withNpm = true, timeouts = 0, + allowlist, } = {}) { const work = mkdtempSync(join(tmpdir(), 'endusers-audit-')); try { @@ -562,6 +573,16 @@ function runGate({ mkdirSync(dirname(script), { recursive: true }); cpSync(join(repoRoot, 'scripts', 'audit-gate.mjs'), script); + let entrypoint = script; + if (allowlist) { + entrypoint = join(work, 'run-gate.mjs'); + writeFileSync( + entrypoint, + `import { main } from './scripts/audit-gate.mjs';\n` + + `main(new Set(${JSON.stringify([...allowlist])}));\n`, + ); + } + const binDir = join(work, '.bin'); mkdirSync(binDir, { recursive: true }); // The stub's shebang (and nothing else on the stripped PATH) needs node. @@ -576,20 +597,24 @@ function runGate({ const spawnLog = join(work, 'npm-spawns'); const preload = join(work, 'spawn-preload.mjs'); writeFileSync(preload, SPAWN_PRELOAD); - const result = spawnSync(process.execPath, ['--import', preload, script], { - encoding: 'utf8', - stdio: ['ignore', 'pipe', 'pipe'], - env: { - ...process.env, - PATH: binDir, - AUDIT_STUB_ARGV: argvLog, - AUDIT_STUB_SPAWNS: spawnLog, - AUDIT_STUB_TIMEOUTS: String(timeouts), - AUDIT_STUB_RESPONSES: JSON.stringify( - responses ?? [{ stdout, status: Number(status) }], - ), + const result = spawnSync( + process.execPath, + ['--import', preload, entrypoint], + { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + env: { + ...process.env, + PATH: binDir, + AUDIT_STUB_ARGV: argvLog, + AUDIT_STUB_SPAWNS: spawnLog, + AUDIT_STUB_TIMEOUTS: String(timeouts), + AUDIT_STUB_RESPONSES: JSON.stringify( + responses ?? [{ stdout, status: Number(status) }], + ), + }, }, - }); + ); // The stub never runs when it was not installed, so an absent log is a // legitimate record of zero invocations rather than an error to swallow. const argv = existsSync(argvLog) @@ -611,15 +636,14 @@ function runGate({ } } -test('cli: exits 1 on a clean report, because the allowlist is then stale', () => { +test('cli: exits 1 on a clean report when the allowlist still has entries', () => { // A report with no vulnerabilities at all means every allowlisted advisory // has left the lockfile. The suppression is then pointless and, worse, // still live for a future reintroduction, so the gate asks for it to be - // removed rather than reporting a green audit over a dead exemption. The - // "exits 0 when every finding is allowlisted" test below covers the ok - // path, which is what a clean *and* empty allowlist would also produce. + // removed rather than reporting a green audit over a dead exemption. const result = runGate({ stdout: JSON.stringify({ vulnerabilities: {} }), + allowlist: ALLOWLIST, }); assert.equal(result.status, 1); assert.match(result.stderr, /no longer present in the lockfile/); @@ -627,6 +651,17 @@ test('cli: exits 1 on a clean report, because the allowlist is then stale', () = assert.doesNotMatch(result.stdout, /audit-gate: ok/); }); +test('cli: exits 0 on a clean report with the shipped allowlist', () => { + // The repository's current state: nothing to suppress and nothing + // suppressed. Runs the script directly, so this is the CLI entry and the + // default allowlist that CI exercises, not the wrapper the other cases use. + const result = runGate({ + stdout: JSON.stringify({ vulnerabilities: {} }), + }); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /audit-gate: ok — 0 high\/critical finding/); +}); + test('cli: exits 0 when every finding is allowlisted, despite npm exiting 1', () => { const report = { vulnerabilities: { @@ -634,7 +669,11 @@ test('cli: exits 0 when every finding is allowlisted, despite npm exiting 1', () micromatch: entry('high', ['braces']), }, }; - const result = runGate({ stdout: JSON.stringify(report), status: '1' }); + const result = runGate({ + stdout: JSON.stringify(report), + status: '1', + allowlist: ALLOWLIST, + }); assert.equal(result.status, 0, result.stderr); assert.match(result.stdout, /2 high\/critical finding/); }); @@ -653,7 +692,11 @@ test('cli: exits 1 when npm reports a fix for an allowlisted advisory', () => { }, }, }; - const result = runGate({ stdout: JSON.stringify(report), status: '1' }); + const result = runGate({ + stdout: JSON.stringify(report), + status: '1', + allowlist: ALLOWLIST, + }); assert.equal(result.status, 1); assert.match(result.stderr, /now reports as fixable/); assert.match(result.stderr, new RegExp(`${BRACES} via braces`)); @@ -742,6 +785,7 @@ test('cli: a transient no-report answer is retried and the next report decides', { stdout: QUICK_ENDPOINT_FAILURE, status: 1 }, { stdout: ALLOWLISTED_REPORT, status: 1 }, ], + allowlist: ALLOWLIST, }); assert.equal(result.status, 0); assert.equal(result.attempts, 2); @@ -787,6 +831,7 @@ test('cli: a clean run asks npm exactly once', () => { // The retry must not cost a second registry round trip on the happy path. const result = runGate({ responses: [{ stdout: ALLOWLISTED_REPORT, status: 1 }], + allowlist: ALLOWLIST, }); assert.equal(result.status, 0); assert.equal(result.attempts, 1); @@ -809,7 +854,11 @@ test('cli: every attempt bounds the subprocess as well as each request', () => { }); test('cli: a timed-out partial report is retried before accepting a report', () => { - const result = runGate({ timeouts: 1, stdout: ALLOWLISTED_REPORT }); + const result = runGate({ + timeouts: 1, + stdout: ALLOWLISTED_REPORT, + allowlist: ALLOWLIST, + }); assert.equal(result.status, 0, result.stderr); assert.equal(result.attempts, 2); assert.equal(result.argv.length, 1); diff --git a/tests/ci-concurrency.test.mjs b/tests/ci-concurrency.test.mjs index 13f122d2..2eac3d5b 100644 --- a/tests/ci-concurrency.test.mjs +++ b/tests/ci-concurrency.test.mjs @@ -89,14 +89,6 @@ const SERIALISED_WORKFLOWS = new Map([ 'Force-pushes the constant branch automation/refresh-radar-reports; ' + 'same reasoning as refresh-metrics.yml.', ], - [ - 'create-milestones.yml', - 'Snapshots every existing milestone once before its loop, so two ' + - 'concurrent dispatches both see a title as absent and both try to ' + - 'create it. Pushes no branch, so the branch-race contract does not ' + - 'reach it; the constant group is plain serialisation of a ' + - 'read-then-create step. Not PR-triggered.', - ], [ 'pr-queue-hygiene.yml', 'Keeps its state in a hidden marker comment that it reads and then ' + @@ -265,16 +257,14 @@ function racingBranchWorkflows(parsedWorkflows) { // withoutConcurrency above only asks this of PR-triggered workflows, and // racingBranchWorkflows only of workflows that push a branch they name. A // workflow that is in neither population but still mutates shared repository -// state -- milestones, issue and pull request comments, labels -- is +// state -- issue and pull request comments, labels -- is // serialised today only by hand. SERIALISED_WORKFLOWS already says why that is -// sharp for the two such workflows the repository has: create-milestones.yml -// snapshots the milestone list before its loop, so two dispatches both see a -// title as absent and both create it, and pr-queue-hygiene.yml reads then -// writes a hidden marker comment non-atomically, so two runs both find no -// marker and both post one onto a contributor PR. Neither pushes a branch. +// sharp for the one such workflow the repository has: pr-queue-hygiene.yml +// reads then writes a hidden marker comment non-atomically, so two runs both +// find no marker and both post one onto a contributor PR. It pushes no branch. // -// Those two keep their groups only incidentally: the retirement guard below -// asserts that an allowlisted workflow still declares one, so deleting their +// It keeps its group only incidentally: the retirement guard below +// asserts that an allowlisted workflow still declares one, so deleting its // group fails on the allowlist rather than on a contract. A *new* workflow of // the same class arrives with no allowlist entry and no group, and every test // in this file passes over it -- the same hole the preamble describes for the @@ -441,7 +431,7 @@ test('every workflow that grants a write permission declares a concurrency group assert.deepEqual( offenders, [], - `a workflow that writes milestones, comments, labels or refs with no 'concurrency:' lets two runs interleave a read-then-write: both observe the state as it was before either acted, and both act. Neither the PR contract above nor the branch contract below reaches a workflow that is not PR-triggered and pushes no branch:\n${offenders.join('\n')}`, + `a workflow that writes comments, labels or refs with no 'concurrency:' lets two runs interleave a read-then-write: both observe the state as it was before either acted, and both act. Neither the PR contract above nor the branch contract below reaches a workflow that is not PR-triggered and pushes no branch:\n${offenders.join('\n')}`, ); }); diff --git a/tests/data-gate-contract.test.mjs b/tests/data-gate-contract.test.mjs index bcebca2b..f115e6c2 100644 --- a/tests/data-gate-contract.test.mjs +++ b/tests/data-gate-contract.test.mjs @@ -4,9 +4,8 @@ // repository" job: a `scripts/validate-*.mjs` wired to a `validate:*` script // (tests/workflow-scripts.test.mjs proves each one is run by a workflow), or a // contract test under tests/ that parses the committed file and asserts its -// shape. data/milestones.json has only the second kind -- no validator exists -// for it, and tests/milestones-data.test.mjs is the whole gate -- so a rule -// that demanded a validator would be wrong about the repository as it stands. +// shape. Either kind counts here: a rule that demanded a validator would be +// stricter than the repository has ever needed to be. // // What nothing checks today is the case neither kind covers: a dataset that // lands with *no* reader at all. That is not hypothetical. Until it gained @@ -281,8 +280,10 @@ test('an allowlist entry that still describes the repository is not stale', () = // or the fixtures above describe a function the repository never calls. test('the real reader lookup finds the gates this repository has', () => { assert.ok( - readersOf('milestones.json').includes('tests/milestones-data.test.mjs'), - 'expected the contract test that gates data/milestones.json', + readersOf('community-roster.json').includes( + 'tests/community-roster-data.test.mjs', + ), + 'expected the contract test that reads data/community-roster.json', ); assert.ok( readersOf('members.json').includes('scripts/validate-members.mjs'), diff --git a/tests/e2e-data-fixture-loader.test.mjs b/tests/e2e-data-fixture-loader.test.mjs index 62914fa6..bb4ea827 100644 --- a/tests/e2e-data-fixture-loader.test.mjs +++ b/tests/e2e-data-fixture-loader.test.mjs @@ -135,12 +135,9 @@ test('a data file with no committed overlay is returned byte-for-byte', () => { // Chosen from the data directory at run time rather than named here: which // files carry an overlay changes as fixtures are added, and this case is // about the absence of one, not about a particular document. - const unoverlaid = [ - 'projects-born.json', - 'milestones.json', - 'metrics.json', - 'awards.json', - ].find((name) => overlayPathsFor(join(DATA_DIR, name), {}).length === 0); + const unoverlaid = ['projects-born.json', 'metrics.json', 'awards.json'].find( + (name) => overlayPathsFor(join(DATA_DIR, name), {}).length === 0, + ); assert.ok(unoverlaid, 'every candidate data file now carries a base overlay'); const { source, patched, context } = run(unoverlaid); diff --git a/tests/governance-hygiene-parity.test.mjs b/tests/governance-hygiene-parity.test.mjs index 4d042e62..8f2b2d60 100644 --- a/tests/governance-hygiene-parity.test.mjs +++ b/tests/governance-hygiene-parity.test.mjs @@ -163,7 +163,7 @@ test('fixture: a sentence that adds an undocumented label is reported as a diffe test('fixture: a sentence about labels that names no hold label is not a hold-label sentence', () => { assert.deepEqual( holdLabelSentences( - 'The `roadmap` label marks tracking issues. Unrelated prose follows.', + 'The `tracking` label marks tracking issues. Unrelated prose follows.', ), [], ); diff --git a/tests/milestones-data.test.mjs b/tests/milestones-data.test.mjs deleted file mode 100644 index e65d520d..00000000 --- a/tests/milestones-data.test.mjs +++ /dev/null @@ -1,277 +0,0 @@ -import assert from 'node:assert/strict'; -import { readFileSync } from 'node:fs'; -import test from 'node:test'; - -// data/milestones.json is consumed by .github/workflows/create-milestones.yml -// through jq and gh, and is documented in MILESTONES.md. Neither consumer -// validates it: a shape error surfaces only as a failed (or silently wrong) -// workflow run, and doc/data drift surfaces only as a wrong milestone in the -// GitHub UI. These tests assert both contracts against the committed data. - -const data = JSON.parse( - readFileSync(new URL('../data/milestones.json', import.meta.url), 'utf8'), -); -const doc = readFileSync(new URL('../MILESTONES.md', import.meta.url), 'utf8'); - -/** - * Parse the milestone sections of MILESTONES.md. - * - * A section is a `## Milestone: ` heading. Its description is the - * `**Description**:` paragraph, unwrapped to a single line. Its issue numbers - * are the `#<n>` references inside the bullet list introduced by a line - * starting with `Tag`, including inclusive ranges such as `#41-#43`. Prose - * outside that bullet list is ignored, because MILESTONES.md also mentions - * issues that belong to other milestones. - * - * @param {string} markdown - Contents of MILESTONES.md - * @returns {Array<{title: string, description: string|null, issues: number[]}>} - */ -export function parseMilestonesDoc(markdown) { - return markdown - .split(/^## /m) - .filter((section) => section.startsWith('Milestone: ')) - .map((section) => { - const lines = section.split('\n'); - const title = lines[0].replace('Milestone: ', '').trim(); - - const described = section.match( - /\*\*Description\*\*:\s*([\s\S]*?)\n\s*\n/, - ); - const description = described - ? described[1].replace(/\s+/g, ' ').trim() - : null; - - const issues = new Set(); - let inTagList = false; - for (const line of lines.slice(1)) { - if (/^Tag\b/.test(line)) { - inTagList = true; - continue; - } - if (!inTagList) continue; - if (line.trim() === '') continue; - // A bullet, or an indented continuation of one. Anything else ends - // the list and returns the parser to prose. - if (!/^\s*-\s|^\s+\S/.test(line)) { - inTagList = false; - continue; - } - for (const match of line.matchAll( - /#(\d+)(?:\s*[\u2013\u2014-]\s*#(\d+))?/g, - )) { - const start = Number(match[1]); - const end = match[2] ? Number(match[2]) : start; - for (let n = start; n <= end; n += 1) issues.add(n); - } - } - - return { title, description, issues: [...issues].sort((a, b) => a - b) }; - }); -} - -const sorted = (numbers) => [...numbers].sort((a, b) => a - b); - -test('parseMilestonesDoc extracts title, unwrapped description and issues', () => { - const parsed = parseMilestonesDoc( - [ - '# Heading', - '', - '## Milestone: Example', - '', - '**Description**: One sentence that is', - 'wrapped across two lines.', - '', - 'Tag these issues:', - '', - '- #1 (first)', - '- #3, PR #4 (second)', - '', - 'Depends on: PR #99, which is prose and must not be collected.', - '', - ].join('\n'), - ); - - assert.equal(parsed.length, 1); - assert.equal(parsed[0].title, 'Example'); - assert.equal( - parsed[0].description, - 'One sentence that is wrapped across two lines.', - ); - assert.deepEqual(parsed[0].issues, [1, 3, 4]); -}); - -test('parseMilestonesDoc expands inclusive issue ranges', () => { - const parsed = parseMilestonesDoc( - [ - '## Milestone: Range', - '', - 'Tag these:', - '', - '- PRs #41\u2013#43', - '', - ].join('\n'), - ); - - assert.deepEqual(parsed[0].issues, [41, 42, 43]); -}); - -test('parseMilestonesDoc returns no issues for a section without a tag list', () => { - const parsed = parseMilestonesDoc( - [ - '## Milestone: Planned', - '', - '**Description**: Nothing assigned yet.', - '', - 'Tag issues/PRs covering:', - '', - '- DNS cutover', - '', - ].join('\n'), - ); - - assert.deepEqual(parsed[0].issues, []); -}); - -test('milestones.json has a non-empty milestones array', () => { - assert.ok( - data && typeof data === 'object' && !Array.isArray(data), - 'top level must be an object', - ); - assert.ok(Array.isArray(data.milestones), 'milestones must be an array'); - assert.ok(data.milestones.length > 0, 'milestones must not be empty'); -}); - -test('every milestone has a title the workflow can look up', () => { - for (const milestone of data.milestones) { - assert.ok( - milestone && typeof milestone === 'object' && !Array.isArray(milestone), - 'each milestone must be an object', - ); - assert.equal( - typeof milestone.title, - 'string', - `title must be a string: ${JSON.stringify(milestone)}`, - ); - assert.ok(milestone.title.trim().length > 0, 'title must not be blank'); - // The workflow resolves an existing milestone with - // `awk -F '\t' -v title=...`, so a tab or newline in a title would split - // the lookup key and silently create a duplicate milestone instead. - assert.doesNotMatch( - milestone.title, - /[\t\n\r]/, - `title must not contain tabs or newlines: ${milestone.title}`, - ); - } -}); - -test('milestone titles are unique', () => { - const titles = data.milestones.map((milestone) => milestone.title); - assert.deepEqual( - titles, - [...new Set(titles)], - 'duplicate titles collapse into one GitHub milestone', - ); -}); - -test('every milestone has a non-empty description', () => { - for (const milestone of data.milestones) { - // `jq -r '.description'` prints the string "null" for a missing key, and - // the workflow would post that literally as the milestone description. - assert.equal( - typeof milestone.description, - 'string', - `description must be a string: ${milestone.title}`, - ); - assert.ok( - milestone.description.trim().length > 0, - `description must not be blank: ${milestone.title}`, - ); - } -}); - -test('every milestone has an issues array of positive integers', () => { - for (const milestone of data.milestones) { - // `jq -r '.issues[]'` fails under `set -o pipefail` when the key is - // missing, aborting the whole workflow run. - assert.ok( - Array.isArray(milestone.issues), - `issues must be an array: ${milestone.title}`, - ); - for (const issue of milestone.issues) { - assert.ok( - Number.isSafeInteger(issue) && issue > 0, - `issue must be a positive integer, got ${JSON.stringify(issue)} in ${milestone.title}`, - ); - } - } -}); - -test('no issue is listed under two milestones', () => { - // GitHub allows one milestone per issue, so a second assignment silently - // overwrites the first and the earlier milestone loses the item. - const seen = new Map(); - for (const milestone of data.milestones) { - for (const issue of milestone.issues) { - const previous = seen.get(issue); - assert.equal( - previous, - undefined, - `issue #${issue} is listed under both "${previous}" and "${milestone.title}"`, - ); - seen.set(issue, milestone.title); - } - } -}); - -test('no milestone lists the same issue twice', () => { - for (const milestone of data.milestones) { - assert.deepEqual( - sorted(milestone.issues), - sorted(new Set(milestone.issues)), - `duplicate issue numbers in ${milestone.title}`, - ); - } -}); - -test('MILESTONES.md documents the same milestones in the same order', () => { - const documented = parseMilestonesDoc(doc).map( - (milestone) => milestone.title, - ); - assert.deepEqual( - documented, - data.milestones.map((milestone) => milestone.title), - 'MILESTONES.md headings have drifted from data/milestones.json', - ); -}); - -test('MILESTONES.md descriptions match data/milestones.json', () => { - const documented = new Map( - parseMilestonesDoc(doc).map((milestone) => [ - milestone.title, - milestone.description, - ]), - ); - for (const milestone of data.milestones) { - assert.equal( - documented.get(milestone.title), - milestone.description, - `description drift for "${milestone.title}"`, - ); - } -}); - -test('MILESTONES.md issue assignments match data/milestones.json', () => { - const documented = new Map( - parseMilestonesDoc(doc).map((milestone) => [ - milestone.title, - milestone.issues, - ]), - ); - for (const milestone of data.milestones) { - assert.deepEqual( - documented.get(milestone.title), - sorted(milestone.issues), - `issue assignment drift for "${milestone.title}"`, - ); - } -}); diff --git a/tests/npm-lifecycle-scripts.test.mjs b/tests/npm-lifecycle-scripts.test.mjs index 5878e987..05fa5e86 100644 --- a/tests/npm-lifecycle-scripts.test.mjs +++ b/tests/npm-lifecycle-scripts.test.mjs @@ -3,10 +3,10 @@ // // `npm ci` runs preinstall/install/postinstall/prepare from every package it // resolves -- 1490 of them here -- as arbitrary code on the runner, before any -// validator, test or lint step gets to look at the tree. Four of the eleven +// validator, test or lint step gets to look at the tree. Three of the eleven // install call sites sit in jobs holding a write-scoped GITHUB_TOKEN -// (architecture-submission, refresh-metrics, pr-queue-hygiene, -// create-milestones), so a single compromised transitive dependency gets code +// (architecture-submission, refresh-metrics, pr-queue-hygiene), so a single +// compromised transitive dependency gets code // execution next to a token that pushes branches and opens pull requests. // // The root `.npmrc` closes that off with one line. Nothing reads it back: the diff --git a/tests/refresh-script-wiring.test.mjs b/tests/refresh-script-wiring.test.mjs index 4362db93..3dda2b25 100644 --- a/tests/refresh-script-wiring.test.mjs +++ b/tests/refresh-script-wiring.test.mjs @@ -57,7 +57,7 @@ const REFRESH_NOT_RUN_BY_CI = new Map([ [ 'collect:launch-metrics', 'records a one-time launch baseline rather than a recurring dataset, and ' + - 'needs GH_TOKEN; CONTRIBUTING.md and ROADMAP.md schedule its re-runs ' + + 'needs GH_TOKEN; CONTRIBUTING.md schedules its re-runs ' + 'against launch milestones, not against a cron.', ], [ diff --git a/tests/static-assets.test.mjs b/tests/static-assets.test.mjs index ac12988d..2b24622c 100644 --- a/tests/static-assets.test.mjs +++ b/tests/static-assets.test.mjs @@ -28,7 +28,6 @@ const DATA_FILES = [ 'data/community-roster.json', 'data/members.json', 'data/metrics.json', - 'data/milestones.json', 'data/projects-born.json', ];