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
23 changes: 23 additions & 0 deletions .github/workflows/mip-upstream.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
name: MIP upstream review

on:
schedule:
- cron: "17 6 * * *"
workflow_dispatch:

permissions:
contents: read

jobs:
check:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v4
with:
node-version: 22
- name: Check upstream specifications
run: node scripts/check-mip-upstream.mjs
env:
GITHUB_TOKEN: ${{ github.token }}
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ npm run dev

## Contributing

Upstream source changes are checked daily and on demand with
`pnpm check:mip-upstream`. See [the review and baseline guide](docs/mip-upstream-review.md).

See [CONTRIBUTING.md](CONTRIBUTING.md). Every PR requires an approved Issue first.

## License
Expand Down
35 changes: 35 additions & 0 deletions content/mips/upstream-sources.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
{
"version": 1,
"sources": [
{
"slug": "mip-3",
"path": "MIPs/MIP-3.md",
"reviewedCommit": "d2fe40811999387de9ac371692198bb8f72c10fb",
"reviewedBlobSha": "7c7e97160d48144d8ecf940708294c8310e1d479"
},
{
"slug": "mip-4",
"path": "MIPs/MIP-4.md",
"reviewedCommit": "d2fe40811999387de9ac371692198bb8f72c10fb",
"reviewedBlobSha": "ec296779ccd3774cc5a6ef61d411d19d794cb347"
},
{
"slug": "mip-7",
"path": "MIPs/MIP-7.md",
"reviewedCommit": "d2fe40811999387de9ac371692198bb8f72c10fb",
"reviewedBlobSha": "4504a0dc1637eb4b4b8cbab155cbf509a5f0c259"
},
{
"slug": "mip-8",
"path": "MIPs/MIP-8.md",
"reviewedCommit": "d2fe40811999387de9ac371692198bb8f72c10fb",
"reviewedBlobSha": "32832485ce69a62b0fdac9c36d559816b0b118d8"
},
{
"slug": "mip-12",
"path": "MIPs/MIP-12.md",
"reviewedCommit": "d2fe40811999387de9ac371692198bb8f72c10fb",
"reviewedBlobSha": "3b63df10520b8f21e588fe0061e2b0231ef43065"
}
]
}
55 changes: 55 additions & 0 deletions docs/mip-upstream-review.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# Reviewing upstream MIP changes

Run `pnpm check:mip-upstream` with Node 22. The command checks the five MIPs
covered by the Markdown knowledge bundle against `monad-crypto/MIPs`.
`GITHUB_TOKEN` is optional locally and avoids the unauthenticated API rate limit.

The checker resolves upstream `main` once, then checks each canonical file at
that immutable commit. Each request has a 10-second deadline, including its
response body. The report links the recorded baseline and current source.

Exit codes: **0** means all file hashes match, **1** means an explainer review
is needed, **2** means a check failed (including missing files, malformed data
and API failures). Errors take precedence when some files also changed.

The daily/manual **MIP upstream review** workflow publishes the same report in
its job summary, uses read-only permissions and fails on either review or error.
It does not edit knowledge, move baselines or open issues. Ordinary PR tests
stay offline; they also check manifest coverage against `MARKDOWN_MIP_SLUGS`.
The existing required CI job remains `test`.

## Initial baseline

`content/mips/upstream-sources.json` starts with a **conservative historical
source snapshot**, commit `d2fe40811999387de9ac371692198bb8f72c10fb` (June 15,
2026), rather than certifying a new review of today's explainers.
At that revision MIP-12 is Draft, matching the legacy local copy; upstream
changed it to Final on August 12 in `2a7e18894f1e55fb043080cb8cef15c7f5647768`.
The first live run should therefore request review, including that change.
The other covered files also changed since this snapshot, so they are reported
for review too. This check does not make the content corrections tracked in
issue #51 or the chat-instruction changes in #52.

## Record a reviewed baseline

1. Follow the report's source links and review the change against the HTML
explainer and `content/mips/mip-N.md`. Interpret its impact and update the
affected explanation through the usual reviewed PR process.
2. Copy the report's full **current specification revision**. For each reviewed
file, get its blob hash at that exact revision:

```bash
gh api "repos/monad-crypto/MIPs/contents/MIPs/MIP-12.md?ref=<full-commit>" --jq .sha
```

3. Update that entry's `reviewedCommit` and `reviewedBlobSha` together.
`reviewedCommit` is the immutable commit used by the source link;
`reviewedBlobSha` is the Contents API's file-content hash, not a commit.
Keep unreviewed entries at their earlier baselines.
4. Run `pnpm test` and the checker, then include the review rationale in the PR.
Changes after the inspected revision will still be detected.

These baselines track **specifications only**. An unchanged spec does not mean
unchanged rollout or network activation. Establish activation separately from
dated, network-specific evidence; do not infer it from a Final status or a
matching source hash.
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@
"build": "next build",
"start": "next start",
"lint": "eslint",
"test": "vitest run"
"test": "vitest run",
"check:mip-upstream": "node scripts/check-mip-upstream.mjs"
},
"dependencies": {
"@ai-sdk/react": "^3.0.186",
Expand Down
159 changes: 159 additions & 0 deletions scripts/check-mip-upstream.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
import { appendFile, readFile, readdir } from "node:fs/promises";
import { fileURLToPath } from "node:url";

const REPO = "monad-crypto/MIPs";
const WEB = `https://github.com/${REPO}`;
const CONTENT_DIR = new URL("../content/mips/", import.meta.url);
const isSha = (value) => typeof value === "string" && /^[a-f0-9]{40}$/.test(value);
const errorMessage = (error) => error instanceof Error && error.message
? error.message : "GitHub API check failed.";

export function validateManifest(manifest, coveredSlugs) {
if (manifest?.version !== 1 || !Array.isArray(manifest.sources)) {
throw new Error("Invalid upstream manifest: expected version 1 and sources.");
}
const slugs = [];
for (const source of manifest.sources) {
if (
!source || typeof source.slug !== "string" ||
!/^mip-[1-9]\d*$/.test(source.slug) ||
source.path !== `MIPs/${source.slug.toUpperCase()}.md` ||
!isSha(source.reviewedCommit) || !isSha(source.reviewedBlobSha)
) {
throw new Error("Invalid upstream manifest: canonical paths and full commit/blob hashes are required.");
}
slugs.push(source.slug);
}
if (
!Array.isArray(coveredSlugs) || coveredSlugs.length === 0 ||
new Set(slugs).size !== slugs.length ||
[...slugs].sort().join(",") !== [...coveredSlugs].sort().join(",")
) {
throw new Error("Upstream manifest must cover every MIP Markdown source exactly once.");
}
return manifest.sources;
}

async function githubJson(path, { fetchImpl, token, timeoutMs }) {
const controller = new AbortController();
let timer;
// Keep the deadline active through body decoding, not just response headers.
const deadline = new Promise((_, reject) => {
timer = setTimeout(() => {
controller.abort();
reject(new Error(`GitHub API request timed out after ${timeoutMs} ms.`));
}, timeoutMs);
});
try {
return await Promise.race([
(async () => {
const response = await fetchImpl(`https://api.github.com/repos/${REPO}/${path}`, {
headers: {
Accept: "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
"User-Agent": "mipland-upstream-check",
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
signal: controller.signal,
});
if (!response.ok) {
throw new Error(`GitHub API returned HTTP ${response.status}.`);
}
return await response.json();
})(),
deadline,
]);
} finally {
clearTimeout(timer);
}
}

export async function checkUpstream(
manifest,
coveredSlugs,
{ fetchImpl = fetch, token, timeoutMs = 10_000 } = {},
) {
try {
const sources = validateManifest(manifest, coveredSlugs);
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
throw new Error("The GitHub API timeout must be a positive number.");
}
const options = { fetchImpl, token, timeoutMs };
const head = await githubJson("commits/main", options);
if (!isSha(head?.sha)) {
throw new Error("Malformed GitHub response: no upstream commit SHA.");
}
const currentCommit = head.sha;
const results = await Promise.all(sources.map(async (source) => {
try {
const file = await githubJson(`contents/${source.path}?ref=${currentCommit}`, options);
if (file?.type !== "file" || file.path !== source.path || !isSha(file.sha)) {
throw new Error("Malformed GitHub response: expected the requested file and its blob SHA.");
}
return {
...source,
currentBlobSha: file.sha,
status: file.sha === source.reviewedBlobSha ? "unchanged" : "review",
};
} catch (error) {
return { ...source, status: "error", error: errorMessage(error) };
}
}));
const exitCode = results.some((r) => r.status === "error") ? 2
: results.some((r) => r.status === "review") ? 1 : 0;
return { currentCommit, results, exitCode };
} catch (error) {
return { results: [], exitCode: 2, error: errorMessage(error) };
}
}

export function formatReport(report) {
const verdict = ["Unchanged", "Review needed", "ERROR: source check incomplete"][report.exitCode];
const lines = [`# MIP upstream check — ${verdict}`, ""];
if (report.error) lines.push(report.error, "");
if (report.currentCommit) {
lines.push(`Current specification revision: [${report.currentCommit}](${WEB}/commit/${report.currentCommit})`, "");
}
if (report.results.length) {
lines.push("| MIP | Result | Reviewed source | Current source |", "| --- | --- | --- | --- |");
for (const result of report.results) {
const baseline = `[${result.reviewedBlobSha.slice(0, 7)}](${WEB}/blob/${result.reviewedCommit}/${result.path})`;
const current = report.currentCommit
? `[${result.currentBlobSha?.slice(0, 7) ?? "unavailable"}](${WEB}/blob/${report.currentCommit}/${result.path})`
: "unavailable";
const status = result.status === "error" ? `ERROR: ${(result.error || "GitHub API check failed.").replace(/[|\r\n]/g, " ")}`
: result.status === "review" ? "Review needed" : "Unchanged";
lines.push(`| ${result.slug.toUpperCase()} | ${status} | ${baseline} | ${current} |`);
}
}
lines.push("", "Specification changes require human review. Matching specs do not establish network activation.", "");
return lines.join("\n");
}

async function main() {
let report;
try {
const manifest = JSON.parse(await readFile(new URL("upstream-sources.json", CONTENT_DIR), "utf8"));
const coveredSlugs = (await readdir(CONTENT_DIR))
.filter((name) => /^mip-\d+\.md$/.test(name))
.map((name) => name.slice(0, -3));
report = await checkUpstream(manifest, coveredSlugs, { token: process.env.GITHUB_TOKEN });
} catch {
report = { results: [], exitCode: 2, error: "Cannot read the upstream manifest or MIP Markdown sources." };
}
const summary = formatReport(report);
console.log(summary);
process.exitCode = report.exitCode;
if (process.env.GITHUB_STEP_SUMMARY) {
try {
await appendFile(process.env.GITHUB_STEP_SUMMARY, summary);
} catch {
console.error("Cannot write the GitHub Actions job summary.");
process.exitCode = 2;
}
}
}

if (process.argv[1] === fileURLToPath(import.meta.url)) {
await main();
}
Loading
Loading