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
9 changes: 9 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,15 @@ OVERCAST_REPO=overcast-sh/overcast
# OVERCAST_SOURCE_REF=v0.0.1-alpha.38
OVERCAST_EDIT_REF=main

# Compatibility report (/compat/). The sync copies each release's compat-report.json asset.
# To preview a report built from a working copy (`go run ./cmd/compat --publish-report
# compat-report.json` upstream), point at it; several paths, newest first, separated by the
# platform's path delimiter (; on Windows, : elsewhere), also preview the release-over-release
# changes. It defaults to <OVERCAST_LOCAL_PATH>/compat-report.json when that file exists.
# OVERCAST_COMPAT_REPORT=/path/to/compat-report.json
# Set to 1 to skip listing release reports on GitHub (offline, or local reports only).
# OVERCAST_COMPAT_OFFLINE=1

# GitHub edit links for website-owned pages.
WEBSITE_REPO=overcast-sh/website
WEBSITE_EDIT_REF=main
Expand Down
18 changes: 18 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,24 @@ Gotchas:
- Edit-link behavior (`EDIT_LINK_MODE`, `OVERCAST_EDIT_REF`, `WEBSITE_EDIT_REF`, etc.) is
also driven by env vars in `.env.example` — see `src/lib/github-links.ts`.

### The compatibility report (/compat/)

`syncCompat()` in the same script downloads the `compat-report.json` asset from each of the
last twelve Overcast releases that have one (GitHub API, `GITHUB_TOKEN` when set) into
`src/generated/compat/<tag>.json`, with `index.json` listing them newest first. Upstream writes
the file with `go run ./cmd/compat --publish-report`; its schema is `compat/report.schema.json`
there, and `src/lib/compat-report.ts` mirrors it. A report in another schema version is skipped.
Nothing in this step can fail the build: with no report, `/compat/` renders an empty state.

- Pages: `/compat/` (newest release), `/compat/<service>/`, `/compat/reason/<code>/`,
`/compat/explore/` and `/compat/history/<tag>/` for older releases. `/compat/data/<tag>.json`
is the explorer's compact index, built from the report at build time.
- Every number on those pages comes from the pure helpers in `src/lib/compat-report.ts`, and the
explorer's matching from `src/lib/compat-filter.ts`; both are covered by
`src/lib/compat-report.test.ts`. Change a number there, never in a page.
- Local preview: `OVERCAST_COMPAT_REPORT` (see `.env.example`) points the sync at a report built
from a working copy, and `OVERCAST_COMPAT_OFFLINE=1` skips the GitHub listing.

## Repo layout highlights

- `src/pages/` — route entry points (`index.astro`, `docs/[...slug].astro`,
Expand Down
94 changes: 94 additions & 0 deletions scripts/sync-overcast-content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
walk,
warnMissingAllowlistedDocs,
} from "../src/lib/overcast-doc-allowlist.ts";
import { SUPPORTED_SCHEMA as COMPAT_SCHEMA } from "../src/lib/compat-report.ts";

const repo = process.env.OVERCAST_REPO || "overcast-sh/overcast";
const sourceRef = process.env.OVERCAST_SOURCE_REF || process.env.OVERCAST_TRACKING_REF || "alpha";
Expand Down Expand Up @@ -159,6 +160,97 @@ async function syncSupport(sourceRoot: string): Promise<ServiceSupport[]> {
return services;
}

// The compatibility report (/compat/). Every Overcast release attaches a compat-report.json,
// written by `go run ./cmd/compat --publish-report` upstream; this copies one per release into
// src/generated/compat/<tag>.json and lists them, newest first, in index.json. Older releases
// stay browsable, and the newest two give the overview its release-over-release changes.
//
// Nothing here is allowed to fail the build: a network hiccup, a release without the asset, or
// a report in a schema this site does not know all degrade to fewer (or no) reports, and the
// pages render an empty state. A local file wins over the releases, so a report generated from
// a working copy can be previewed before any release carries one.
const COMPAT_ASSET = "compat-report.json";
// Enough history to see a trend, few enough that the build does not grow with every release.
const COMPAT_RELEASES_KEPT = 12;

type CompatIndexEntry = { tag: string; publishedAt: string | null; prerelease: boolean; source: "release" | "local" };

async function readCompatReport(filePath: string): Promise<{ version?: string; schema?: number } | null> {
const report = await readJsonIfExists<{ version?: string; schema?: number } | null>(filePath, null);
if (!report || report.schema !== COMPAT_SCHEMA) return null;
return report;
}

async function fetchReleaseCompatReports(outDir: string): Promise<CompatIndexEntry[]> {
const headers: Record<string, string> = { Accept: "application/vnd.github+json", "User-Agent": "overcast-website-sync" };
if (process.env.GITHUB_TOKEN) headers.Authorization = `Bearer ${process.env.GITHUB_TOKEN}`;
const response = await fetch(`https://api.github.com/repos/${repo}/releases?per_page=50`, { headers });
if (!response.ok) throw new Error(`GitHub releases: HTTP ${response.status}`);
const releases = (await response.json()) as Array<{
tag_name: string;
published_at: string | null;
prerelease: boolean;
draft?: boolean;
assets: Array<{ name: string; url: string }>;
}>;

const entries: CompatIndexEntry[] = [];
for (const release of releases) {
if (release.draft || entries.length >= COMPAT_RELEASES_KEPT) continue;
const asset = release.assets.find((candidate) => candidate.name === COMPAT_ASSET);
if (!asset) continue;
// The API URL with an octet-stream Accept, rather than browser_download_url: it takes the
// same token, so a private fork or a rate-limited runner still gets the file.
const download = await fetch(asset.url, { headers: { ...headers, Accept: "application/octet-stream" } });
if (!download.ok) {
console.warn(`compat: ${release.tag_name}: ${COMPAT_ASSET} returned HTTP ${download.status}; skipped`);
continue;
}
const text = await download.text();
const target = path.join(outDir, `${release.tag_name}.json`);
await fs.writeFile(target, text, "utf8");
if (!(await readCompatReport(target))) {
console.warn(`compat: ${release.tag_name}: ${COMPAT_ASSET} is not schema ${COMPAT_SCHEMA}; skipped`);
await fs.rm(target, { force: true });
continue;
}
entries.push({ tag: release.tag_name, publishedAt: release.published_at, prerelease: release.prerelease, source: "release" });
}
return entries;
}

async function syncCompat(sourceRoot: string): Promise<number> {
const outDir = path.join(generatedDir, "compat");
await fs.rm(outDir, { recursive: true, force: true });
await fs.mkdir(outDir, { recursive: true });

// OVERCAST_COMPAT_REPORT takes one path, or several separated by the platform's path
// delimiter, newest first — two local reports are enough to preview the release-over-release
// changes before two releases carry one.
let entries: CompatIndexEntry[] = [];
const localPaths = process.env.OVERCAST_COMPAT_REPORT
? process.env.OVERCAST_COMPAT_REPORT.split(path.delimiter).filter(Boolean)
: [path.join(sourceRoot, COMPAT_ASSET)];
for (const localPath of localPaths) {
const local = await readCompatReport(localPath);
if (!local?.version || entries.some((entry) => entry.tag === local.version)) continue;
await fs.copyFile(localPath, path.join(outDir, `${local.version}.json`));
entries.push({ tag: local.version, publishedAt: null, prerelease: false, source: "local" });
console.log(`compat: using the local report at ${localPath} (${local.version})`);
}
if (process.env.OVERCAST_COMPAT_OFFLINE !== "1") {
try {
const released = await fetchReleaseCompatReports(outDir);
entries = [...entries, ...released.filter((entry) => !entries.some((e) => e.tag === entry.tag))];
} catch (error) {
console.warn(`compat: could not list release reports (${(error as Error).message}); /compat/ shows ${entries.length ? "the local report only" : "an empty state"}`);
}
}

await writeJson(path.join(outDir, "index.json"), entries);
return entries.length;
}

// The vendored brand assets: [path inside the branding repo, path inside this repo].
//
// These stay *tracked* in git even though a script writes them. The deploy workflow never
Expand Down Expand Up @@ -254,6 +346,7 @@ async function main() {
if (brandingRoot) await syncBranding(brandingRoot);
const docsCount = await countDocs(sourceRoot);
const services = await syncSupport(sourceRoot);
const compatReports = await syncCompat(sourceRoot);

await writeJson(path.join(generatedDir, "source-manifest.json"), {
repo,
Expand All @@ -263,6 +356,7 @@ async function main() {
generatedAt: new Date().toISOString(),
docsCount,
serviceCount: services.length,
compatReports,
});
}

Expand Down
37 changes: 37 additions & 0 deletions src/components/CompatCell.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
---
// One suite's result for one test, as a compact chip: an icon and a word, tinted by how much
// it matters. Colour is never the only signal. The word is always there, and the screen-reader
// text states the suite and the reason in full. The icon is a <use> of CompatIconSprite, which
// every page that renders cells includes once.
import { compatIconKey, reasonShort, reasonTone, type CompatReason, type CompatResult } from "../lib/compat-report";

interface Props {
result: CompatResult | undefined;
reasons: CompatReason[];
/** For the screen-reader text: "AWS SDK for Go v2". */
suiteLabel: string;
}

const { result, reasons, suiteLabel } = Astro.props;
const code = result?.reason;
const reason = code ? reasons.find((r) => r.code === code) : undefined;
const tone = reasonTone(code, reasons);
const spoken = !result ? `${suiteLabel}: does not run this test` : !code ? `${suiteLabel}: passes` : `${suiteLabel}: ${reason?.label ?? code}`;
---

{
result ? (
<span class="compat-cell" data-tone={tone} title={reason?.label}>
<svg class="icon" aria-hidden="true">
<use href={`#ci-${compatIconKey(code)}`} />
</svg>
<span aria-hidden="true">{reasonShort(code)}</span>
<span class="sr-only">{spoken}</span>
</span>
) : (
<span class="compat-cell compat-cell-empty">
<span aria-hidden="true">—</span>
<span class="sr-only">{spoken}</span>
</span>
)
}
15 changes: 15 additions & 0 deletions src/components/CompatEmpty.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
// /compat/ before any release has published a report: say so, and point at what does exist.
import { ClipboardCheck } from "@lucide/astro";
---

<section class="mx-auto max-w-3xl px-4 py-16 sm:px-6 lg:px-8">
<p class="flex items-center gap-2 font-mono text-xs uppercase tracking-[0.16em] text-muted">
<ClipboardCheck class="icon" />compatibility report
</p>
<h1 class="mt-2 font-mono text-3xl font-bold">No compatibility report has been published yet.</h1>
<p class="mt-4 leading-7 text-body">
Each release attaches one once its compatibility run has finished. Until then, the
<a class="text-accent" href="/support/">support matrix</a> lists which operations Overcast implements.
</p>
</section>
34 changes: 34 additions & 0 deletions src/components/CompatIconSprite.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
---
// The result icons, once per page. A service page shows up to a thousand result cells, and
// inlining a Lucide SVG in each came to a third of the page's bytes; every cell now points at
// one of these symbols with <use>. Hidden from assistive tech: each cell states its result in
// words.
import { Ban, CircleCheck, CircleDashed, CircleX, Container, CornerDownRight, FilePlus, Hourglass, Minus, Shuffle } from "@lucide/astro";
import { COMPAT_ICON_KEYS } from "../lib/compat-report";

const icons: Record<(typeof COMPAT_ICON_KEYS)[number], typeof CircleCheck> = {
pass: CircleCheck,
"behaviour-mismatch": CircleX,
"not-emulated": Ban,
"quarantined-flaky": Shuffle,
"dependency-failed": CornerDownRight,
"suite-not-written": FilePlus,
candidate: Hourglass,
"sdk-lacks-api": Minus,
"needs-environment": Container,
other: CircleDashed,
};
---

<svg aria-hidden="true" width="0" height="0" style="position:absolute" focusable="false">
{
COMPAT_ICON_KEYS.map((key) => {
const Icon = icons[key];
return (
<symbol id={`ci-${key}`} viewBox="0 0 24 24">
<Icon width="24" height="24" />
</symbol>
);
})
}
</svg>
Loading
Loading