Skip to content
Open
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
85 changes: 85 additions & 0 deletions .github/workflows/plain-sync.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
name: plain-sync

# Auto-regenerate the plain (machine-UI) twins when a PR's marketing/docs
# sources drift from them, and push the refresh back to the PR branch for
# review. This is the active counterpart to the check:plain guard in ci.yml:
# the guard *detects* drift and fails; this workflow *fixes* it.
#
# Only runs for same-repo PRs — fork PRs have no access to the GEMINI_API_KEY
# secret and we can't push to a fork's branch. For forks, ci.yml's check:plain
# still fails the PR with instructions to run scripts/regen-plain.mjs locally.

on:
pull_request:
branches: [main]
types: [opened, synchronize, reopened]

permissions:
contents: write
pull-requests: write

jobs:
regen:
if: github.event.pull_request.head.repo.full_name == github.repository
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
# Check out the PR branch itself (not the detached merge ref) so we
# can commit and push the regenerated twins straight back to it.
ref: ${{ github.head_ref }}
# Optional PAT so the auto-commit re-triggers ci.yml (a push made with
# the default GITHUB_TOKEN does not start new workflow runs). Falls
# back to GITHUB_TOKEN: the fix still lands on the branch, but the
# build check won't auto-re-run — push again or re-run it to go green.
token: ${{ secrets.PLAIN_SYNC_TOKEN || secrets.GITHUB_TOKEN }}

- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'

- run: npm ci

- name: Regenerate stale plain twins
env:
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
# Exits 0 doing nothing when no twin has drifted (the common case);
# only the drifted pages hit the LLM.
run: node scripts/regen-plain.mjs --stale-only

- name: Detect regenerated files
id: diff
run: |
changed="$(git status --porcelain src/pages/plain | sed 's/^...//')"
if [ -n "$changed" ]; then
echo "changed=true" >> "$GITHUB_OUTPUT"
{ echo "files<<EOF"; echo "$changed"; echo "EOF"; } >> "$GITHUB_OUTPUT"
else
echo "changed=false" >> "$GITHUB_OUTPUT"
fi

- name: Commit and push refreshed twins
if: steps.diff.outputs.changed == 'true'
run: |
git config user.name "pilot-plain-bot"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add src/pages/plain
git commit -m "chore(plain): auto-regenerate stale machine-UI twins"
git push origin "HEAD:${{ github.head_ref }}"

- name: Comment on PR
if: steps.diff.outputs.changed == 'true'
uses: marocchino/sticky-pull-request-comment@v2
with:
header: plain-sync
message: |
🔄 **Plain twins auto-regenerated**

The machine-UI mirrors under `src/pages/plain/` had drifted from their marketing/docs sources. I regenerated the stale page(s) and pushed a commit to this branch:

```
${{ steps.diff.outputs.files }}
```

Please review the bot commit. Prose is an LLM summary of the source; commands, flags, and numeric specs are copied verbatim. If the `build` / `check:plain` check is still red, re-run it (or push any commit) — the regenerated twins now match their sources.
56 changes: 53 additions & 3 deletions scripts/check-plain-coverage.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,9 @@
//
// Exit code: 0 when coverage is complete, 1 when any page is missing/orphaned.

import { readdir } from 'node:fs/promises';
import { readdir, readFile } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { createHash } from 'node:crypto';
import { dirname, join, basename } from 'node:path';
import { fileURLToPath } from 'node:url';

Expand Down Expand Up @@ -52,6 +53,34 @@ async function astroSlugs(relDir) {
);
}

const PLAIN_DIR = 'src/pages/plain';

// Recursively collect every plain .astro file (repo-relative paths).
async function walkAstro(relDir) {
const out = [];
const entries = await readdir(join(REPO_ROOT, relDir), { withFileTypes: true });
for (const e of entries) {
const rel = `${relDir}/${e.name}`;
if (e.isDirectory()) out.push(...(await walkAstro(rel)));
else if (e.name.endsWith('.astro')) out.push(rel);
}
return out;
}

// Same hashing as scripts/regen-plain.mjs sourceHash(): raw utf8, sha256, hex.
function sha256(text) {
return createHash('sha256').update(text, 'utf8').digest('hex');
}

// Pull the regen provenance stamp out of a generated plain file, if present.
// Returns { source, hash } or null for hand-written / data-driven plain pages.
function readStamp(plainText) {
const src = plainText.match(/^\/\/ plain-source:\s*(.+)$/m);
const hash = plainText.match(/^\/\/ plain-source-sha256:\s*([0-9a-f]{64})$/m);
if (!src || !hash) return null;
return { source: src[1].trim(), hash: hash[1] };
}

async function main() {
const errors = [];

Expand Down Expand Up @@ -79,7 +108,28 @@ async function main() {
}
}

const checked = MAIN_PAIRS.length + human.size;
// 3. Content drift: every plain file that carries a regen provenance stamp
// must match the CURRENT hash of its source. A mismatch means the source
// was edited but the plain twin was never regenerated — the silent staleness
// the structural checks above can't see. Files with no stamp (data-driven
// skills/setups pages, hand-written plain pages) are skipped here.
let stamped = 0;
for (const rel of await walkAstro(PLAIN_DIR)) {
const stamp = readStamp(await readFile(join(REPO_ROOT, rel), 'utf8'));
if (!stamp) continue;
stamped += 1;
const srcAbs = join(REPO_ROOT, stamp.source);
if (!existsSync(srcAbs)) {
errors.push(`Stale plain page: ${rel} was generated from ${stamp.source}, which no longer exists (remove the plain page or restore the source)`);
continue;
}
const current = sha256(await readFile(srcAbs, 'utf8'));
if (current !== stamp.hash) {
errors.push(`Stale plain page: ${stamp.source} changed since ${rel} was generated — re-run \`node scripts/regen-plain.mjs\` and commit the result`);
}
}

const checked = MAIN_PAIRS.length + human.size + stamped;
if (errors.length) {
console.error('✗ Plain (machine UI) coverage check failed:\n');
for (const e of errors) console.error(` - ${e}`);
Expand All @@ -90,7 +140,7 @@ async function main() {
process.exit(1);
}

console.log(`✓ Plain coverage OK — ${checked} required pairs present, no orphans.`);
console.log(`✓ Plain coverage OK — ${checked} required pairs present, no orphans, ${stamped} stamped twins in sync with source.`);
}

main().catch((err) => {
Expand Down
91 changes: 82 additions & 9 deletions scripts/regen-plain.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,12 @@
// GEMINI_API_KEY=... node scripts/regen-plain.mjs [page-slug ...]
//
// If no page-slug is passed, all pages in the manifest are regenerated.
// Valid slugs: index, p2p, mcp, plans
// Valid slugs: index, p2p, mcp, plans, app-store, publish, plus every
// docs/<slug> auto-discovered from src/pages/docs/.
//
// Pass --stale-only to regenerate ONLY pages whose twin's stamped source
// hash no longer matches the current source (used by CI auto-regen). Exits
// 0 with no work when everything is in sync.
//
// Data-driven pages (skills/) are NOT regenerated here — they render
// directly from the upstream JSON at build time.
Expand All @@ -28,20 +33,29 @@
// Exit code: 0 on success, 1 on any failure.

import { readFile, writeFile, mkdir, readdir } from 'node:fs/promises';
import { createHash } from 'node:crypto';
import { dirname, join, basename } from 'node:path';
import { fileURLToPath } from 'node:url';

// Stable content hash of a marketing source file. Stamped into the generated
// plain twin so scripts/check-plain-coverage.mjs can detect when the source
// changed but the twin was never re-run (silent content drift). MUST match the
// hashing in check-plain-coverage.mjs exactly (raw utf8 bytes, sha256, hex).
export function sourceHash(sourceText) {
return createHash('sha256').update(sourceText, 'utf8').digest('hex');
}

const __dirname = dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = join(__dirname, '..');

const MODEL = process.env.GEMINI_MODEL || 'gemini-2.5-pro';
const API_KEY = process.env.GEMINI_API_KEY;
const DRY_RUN = process.env.DRY_RUN === '1';

if (!API_KEY) {
console.error('Missing GEMINI_API_KEY.');
process.exit(1);
}
// Note: GEMINI_API_KEY is required only when there is actually a page to
// regenerate (checked in main() once the work list is known). This lets
// `--stale-only` exit 0 with no key when nothing has drifted — the common
// CI case, including fork PRs where the secret is unavailable.

// Manifest: marketing source → plain destination.
//
Expand Down Expand Up @@ -80,6 +94,20 @@ const MANIFEST = {
canonical: 'https://pilotprotocol.network/plain/plans/',
description: 'Backbone is open and free. Private networks and enterprise are managed early-access.',
},
'app-store': {
source: 'src/pages/app-store.astro',
dest: 'src/pages/plain/app-store.astro',
title: 'App Store — Pilot Protocol',
canonical: 'https://pilotprotocol.network/plain/app-store/',
description: 'Agent-native apps on the Pilot Protocol network. Install with one command; publish your own from your browser or by PR.',
},
publish: {
source: 'src/pages/publish.astro',
dest: 'src/pages/plain/publish.astro',
title: 'Publish an app — Pilot Protocol',
canonical: 'https://pilotprotocol.network/plain/publish/',
description: 'Publish your existing HTTP API on the Pilot Protocol app store. Describe it once; we generate, sign, and verify an agent-first adapter.',
},
};

// Extract title/description from DocLayout props in a doc .astro file.
Expand Down Expand Up @@ -258,7 +286,7 @@ function renderBlock(block) {
return '';
}

function renderAstro(meta, payload, key) {
function renderAstro(meta, payload, key, srcHash) {
const sections = (payload.sections || [])
.map((s) => {
const body = (s.body || []).map(renderBlock).filter(Boolean).join('\n');
Expand Down Expand Up @@ -287,6 +315,8 @@ function renderAstro(meta, payload, key) {

return `---
// Auto-generated by scripts/regen-plain.mjs. Edit the marketing source and re-run.
// plain-source: ${meta.source}
// plain-source-sha256: ${srcHash}
${layoutImport}
---
<PlainLayout title=${JSON.stringify(meta.title)} description=${JSON.stringify(meta.description)} canonical=${JSON.stringify(meta.canonical)}>
Expand Down Expand Up @@ -315,7 +345,7 @@ async function regen(slug) {
if (process.env.DEBUG === '1') {
console.log(`[${slug}] payload sections=${payload.sections?.length}, first-body-len=${payload.sections?.[0]?.body?.length ?? 0}`);
}
const rendered = renderAstro(entry, payload, slug);
const rendered = renderAstro(entry, payload, slug, sourceHash(sourceAstro));

if (DRY_RUN) {
console.log(`\n--- ${destPath} ---\n${rendered}\n--- END ---\n`);
Expand All @@ -327,11 +357,49 @@ async function regen(slug) {
console.log(`[${slug}] wrote ${entry.dest}`);
}

// Slugs whose twin's stamped source hash no longer matches the current source
// (or whose twin is missing/unstamped) — i.e. exactly the pages that drifted.
async function staleSlugs() {
const out = [];
for (const [slug, entry] of Object.entries(MANIFEST)) {
let srcText;
try {
srcText = await readFile(join(REPO_ROOT, entry.source), 'utf8');
} catch {
continue; // source gone — coverage guard handles that case
}
const current = sourceHash(srcText);
let stamped = null;
try {
const twin = await readFile(join(REPO_ROOT, entry.dest), 'utf8');
const m = twin.match(/^\/\/ plain-source-sha256:\s*([0-9a-f]{64})/m);
stamped = m ? m[1] : null;
} catch {
stamped = null; // twin missing
}
if (stamped !== current) out.push(slug);
}
return out;
}

async function main() {
await loadDocManifest();

const requested = process.argv.slice(2);
const slugs = requested.length ? requested : Object.keys(MANIFEST);
const argv = process.argv.slice(2);
const staleOnly = argv.includes('--stale-only');
const requested = argv.filter((a) => !a.startsWith('--'));

let slugs;
if (staleOnly) {
slugs = await staleSlugs();
if (!slugs.length) {
console.log('No stale plain pages — nothing to regenerate.');
return;
}
console.log(`Stale page(s): ${slugs.join(', ')}`);
} else {
slugs = requested.length ? requested : Object.keys(MANIFEST);
}

const unknown = slugs.filter((s) => !MANIFEST[s]);
if (unknown.length) {
Expand All @@ -340,6 +408,11 @@ async function main() {
process.exit(1);
}

if (!API_KEY) {
console.error('Missing GEMINI_API_KEY (required to regenerate pages).');
process.exit(1);
}

let failed = 0;
for (const slug of slugs) {
try {
Expand Down
Loading
Loading