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
1,261 changes: 1,261 additions & 0 deletions .github/workflows/base-std-docs-sync.yml

Large diffs are not rendered by default.

115 changes: 115 additions & 0 deletions scripts/lib/workflow-fail.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
# shellcheck shell=bash
#
# workflow-fail.sh — shared fail helper for the docs-sync receiver workflow.
#
# Every rejection point in the apply job sources this file and calls
# workflow_fail "<step name>" "<reason>"
# to produce a uniform record across error annotations and the run summary.
# Operators get one shape to read; security reviewers get one place to look.
#
# Reads dispatch context from these env vars (each is optional — empty
# values render as "(unknown)" in the summary so the helper is safe to
# call before all of them are populated):
#
# SENDER_LOGIN github.event.sender.login (only authoritative id)
# PAYLOAD_SOURCE_REPO claimed source repo from client_payload
# PAYLOAD_SHA claimed SHA from client_payload
# PAYLOAD_PR_NUMBER claimed PR number from client_payload
# GITHUB_RUN_ID injected by Actions
# GITHUB_STEP_SUMMARY injected by Actions; if unset, summary write is skipped
#
# Usage:
# source "${GITHUB_WORKSPACE}/scripts/lib/workflow-fail.sh"
# if [[ ! "$thing" =~ $pattern ]]; then
# workflow_fail "Validate payload schema" "thing '${thing}' does not match ${pattern}"
# fi
#
# Exits the calling step with status 1. Never returns. Assumes jq is on PATH
# (every step that sources this helper already invokes jq elsewhere).
#
# This file does not set shell options — the caller owns set -euo pipefail
# state and we must not mutate it on source.

# Idempotent guard — if a step sources the helper twice we keep the first
# definitions. The function-existence test avoids redefining workflow_fail.
if declare -F workflow_fail > /dev/null 2>&1; then
return 0 2>/dev/null || true
fi

# GitHub workflow command annotation. The spec requires \n and \r in the
# message to be percent-encoded; otherwise multi-line reasons truncate at
# the first newline and the operator sees a half-message.
_wf_emit_annotation() {
local step="$1" reason="$2"
local safe="${reason//$'\r'/%0D}"
safe="${safe//$'\n'/%0A}"
printf '::error title=%s::%s\n' "$step" "$safe" >&2
}

# Markdown table row in $GITHUB_STEP_SUMMARY. Pipes inside cells must be
# escaped as \| in GitHub-flavored markdown; newlines collapse to spaces.
_wf_emit_step_summary() {
local step="$1" reason="$2"
local summary_file="${GITHUB_STEP_SUMMARY:-}"
[[ -z "$summary_file" ]] && return 0
local safe="${reason//|/\\|}"
safe="${safe//$'\n'/ }"
local ts
ts="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
{
printf '\n### Dispatch rejected: %s\n\n' "$step"
printf '| Field | Value |\n'
printf '|---|---|\n'
printf '| Reason | %s |\n' "$safe"
printf '| Sender (github.event.sender.login) | `%s` |\n' "${SENDER_LOGIN:-(unknown)}"
printf '| Source repo (claimed) | `%s` |\n' "${PAYLOAD_SOURCE_REPO:-(unknown)}"
printf '| SHA (claimed) | `%s` |\n' "${PAYLOAD_SHA:-(unknown)}"
printf '| PR number (claimed) | `%s` |\n' "${PAYLOAD_PR_NUMBER:-(none)}"
printf '| Run ID | `%s` |\n' "${GITHUB_RUN_ID:-(unknown)}"
printf '| Timestamp | `%s` |\n' "$ts"
} >> "$summary_file"
}

# Structured JSON log line on stderr — for alerting pipelines that scrape
# workflow logs. Per workspace rule: structured JSON, timestamp, no PII.
# Sender login is a public GitHub handle that already appears in GitHub's
# own audit-log surface.
_wf_emit_json_log() {
local step="$1" reason="$2"
local ts
ts="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
jq -nc \
--arg ts "$ts" \
--arg step "$step" \
--arg reason "$reason" \
--arg sender "${SENDER_LOGIN:-}" \
--arg source_repo "${PAYLOAD_SOURCE_REPO:-}" \
--arg sha "${PAYLOAD_SHA:-}" \
--arg pr_number "${PAYLOAD_PR_NUMBER:-}" \
--arg run_id "${GITHUB_RUN_ID:-}" \
'{
timestamp: $ts,
level: "error",
component: "docs-sync-receiver",
event: "dispatch_rejected",
step: $step,
reason: $reason,
sender_login: $sender,
source_repo_claimed: $source_repo,
sha_claimed: $sha,
pr_number_claimed: $pr_number,
github_run_id: $run_id
}' >&2
}

# Public entrypoint. Always exits non-zero — the caller never returns from
# this. Order: annotation first (operator's eye), summary second (post-
# mortem), JSON log third (alerting pipeline).
workflow_fail() {
local step="${1:-unknown step}"
local reason="${2:-no reason provided}"
_wf_emit_annotation "$step" "$reason"
_wf_emit_step_summary "$step" "$reason"
_wf_emit_json_log "$step" "$reason"
exit 1
}
84 changes: 84 additions & 0 deletions scripts/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 10 additions & 0 deletions scripts/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"name": "base-docs-base-std-sync",
"private": true,
"scripts": {
"test:base-std-sync": "node --test sync-from-base-std/__tests__/*.test.mjs __tests__/*.test.mjs"
},
"dependencies": {
"@anthropic-ai/sdk": "0.117.1"
}
}
42 changes: 42 additions & 0 deletions scripts/sync-from-base-std/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# Base Std documentation sync

This directory is installed in `base/docs` and is invoked by
`.github/workflows/base-std-docs-sync.yml`. It consumes a verified dispatch from
`base/base-std`, routes changed source files to existing B20 documentation
pages, asks Claude for grounded edits, validates the returned MDX, and reports
touched/rejected pages to the workflow.

## Supported inputs

- `code-change`: the normal `base-code-changed` event sent after a relevant
push to `base-std/main`.
- `release`: retained for protocol compatibility with the receiver.
- `manual-update`: maintainer replay using an explicitly allowlisted page.

The route table supports both exact `pages` and `page_globs`. Globs are expanded
only against existing Markdown files beneath `docs/`; they cannot create new
paths. This version intentionally does not create, rename, or delete API pages.

## Local checks

From the copied `docs-repo` root:

```bash
npm ci --prefix scripts --no-audit --no-fund
npm --prefix scripts run test:base-std-sync
```

A real transformation requires `LLM_GATEWAY_API_KEY`:

```bash
LLM_GATEWAY_API_KEY=... \
node scripts/sync-from-base-std/index.mjs \
--payload scripts/sync-from-base-std/fixtures/code-change-ib20.json
```

Configuration knobs are optional positive numbers:

- `CODE_CHANGE_PAGE_CONCURRENCY` (default `4`)
- `RELEASE_PAGE_CONCURRENCY` (default `4`)
- `CLAUDE_MAX_TOKENS` and `CLAUDE_MODEL`
- The bounded release manifest/selection settings documented in `index.mjs`
53 changes: 53 additions & 0 deletions scripts/sync-from-base-std/__tests__/base-std-routing.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { buildProvenanceComment, routeCodeChange } from "../index.mjs";

test("routeCodeChange expands page_globs only to existing docs pages", async () => {
const repoRoot = await fs.mkdtemp(path.join(os.tmpdir(), "base-std-routing-"));
const docsRoot = path.join(repoRoot, "docs", "base-chain", "b20", "IB20");
await fs.mkdir(docsRoot, { recursive: true });
await fs.writeFile(path.join(docsRoot, "transfer.mdx"), "---\ntitle: transfer\n---\n");
await fs.writeFile(path.join(docsRoot, "approve.mdx"), "---\ntitle: approve\n---\n");
try {
const work = await routeCodeChange(
{
code_changes: [
{
source_prefix: "src/interfaces/IB20.sol",
pages: ["docs/base-chain/b20/index.mdx"],
page_globs: ["docs/base-chain/b20/IB20/**/*.mdx"],
transformer: "claude",
},
],
},
["src/interfaces/IB20.sol"],
{ repoRoot },
);
assert.deepEqual(
work.map((item) => item.page).sort(),
[
"docs/base-chain/b20/IB20/approve.mdx",
"docs/base-chain/b20/IB20/transfer.mdx",
"docs/base-chain/b20/index.mdx",
],
);
assert.deepEqual(work[0].sourceFiles, ["src/interfaces/IB20.sol"]);
} finally {
await fs.rm(repoRoot, { recursive: true, force: true });
}
});

test("buildProvenanceComment cannot inject a second HTML comment boundary", () => {
const comment = buildProvenanceComment("manual-update", {
intent: "Update docs --> <script>alert(1)</script> --!>",
source_refs: ["https://example.test/<!--> --!>"],
});
const body = comment.split("\n").slice(1, -1).join("\n");

assert.match(comment, /^<!--\n/);
assert.match(comment, /\n-->$/);
assert.doesNotMatch(body, /[<>]/);
});
Loading
Loading