diff --git a/.book-release-canvas.json b/.book-release-canvas.json new file mode 100644 index 0000000..88e08cd --- /dev/null +++ b/.book-release-canvas.json @@ -0,0 +1,168 @@ +{ + "schemaVersion": 1, + "repository": { + "name": "GitHub Agentic Workflows book" + }, + "publication": { + "name": "GitHub Agentic Workflows: An Interactive Book", + "promise": "Every published claim stays traceable to current product documentation and verified examples.", + "contentVersionPath": "content/VERSION", + "frameworkVersionPath": "content/FRAMEWORK_VERSION", + "chapterPath": "content/chapters", + "examplePath": "examples" + }, + "sources": [ + { + "id": "upstream-product", + "label": "Official product docs and releases", + "owner": "gh-aw-explorer", + "url": "https://github.com/github/gh-aw", + "paths": [ + "content/research/updates/{frameworkVersion}" + ], + "evidence": "content/research/updates/{frameworkVersion}/framework-delta.md", + "why": "Defines what changed in the product and which claims may now be stale." + }, + { + "id": "manuscript", + "label": "Book manuscript", + "owner": "chapter-author", + "paths": [ + "content/chapters", + "content/toc.yml" + ], + "evidence": "content/release-review.json", + "why": "Reader-facing explanations must match the fixed framework target." + }, + { + "id": "examples", + "label": "Runnable examples", + "owner": "code-verifier", + "paths": [ + "examples", + "scripts/verify_examples.py" + ], + "evidence": "content/research/updates/{frameworkVersion}/final-verification-receipt.json", + "why": "Copyable workflows must compile before the edition can ship." + }, + { + "id": "presentation", + "label": "Interactive book experience", + "owner": "frontend-builder", + "paths": [ + "site", + "assets" + ], + "ignorePaths": [ + "site/release-canvas.html", + "site/book.html", + "site/llms-full.txt", + "site/sitemap.xml" + ], + "evidence": "site/index.html", + "why": "Accepted content must reach the book, PDF, and discovery surfaces." + } + ], + "demos": [ + { + "id": "source-drift", + "label": "Source drift to chapter impact", + "owner": "gh-aw-explorer", + "paths": [ + "content/research/updates/{frameworkVersion}/framework-delta.md", + "content/chapters" + ], + "evidence": "content/research/updates/{frameworkVersion}/framework-delta.md", + "cue": "Open one changed source and show the exact chapter decision it triggered." + }, + { + "id": "strict-compile", + "label": "Strict example compilation", + "owner": "code-verifier", + "paths": [ + "examples", + "scripts/verify_examples.py" + ], + "evidence": "content/research/updates/{frameworkVersion}/final-verification-receipt.json", + "cue": "Show the pinned compiler version and the complete workflow pass count." + }, + { + "id": "human-release-gate", + "label": "Human release decision", + "owner": "chapter-reviewer", + "paths": [ + "content/chapters", + "content/release-review.json" + ], + "evidence": "content/release-review.json", + "cue": "Show that automation prepares evidence, but ACCEPT remains an explicit review gate." + } + ], + "gates": [ + { + "id": "framework-pin", + "label": "Framework target is pinned", + "kind": "text-equals", + "path": "content/FRAMEWORK_VERSION", + "equals": "v0.88.7" + }, + { + "id": "strict-verification", + "label": "Whole example corpus passed", + "kind": "json-field", + "path": "content/research/updates/{frameworkVersion}/final-verification-receipt.json", + "field": "status", + "equals": "PASS" + }, + { + "id": "editorial-review", + "label": "Editorial review accepted", + "kind": "json-field", + "path": "content/release-review.json", + "field": "verdict", + "equals": "ACCEPT" + }, + { + "id": "published-site", + "label": "Generated book is present", + "kind": "file-exists", + "path": "site/index.html" + } + ], + "agents": [ + { + "name": "Research", + "role": "Find the exact upstream change and map its impact." + }, + { + "name": "Author", + "role": "Update only the affected explanation and example." + }, + { + "name": "Verifier", + "role": "Compile every example against the pinned product release." + }, + { + "name": "Reviewer", + "role": "Hold the human-quality gate before publication." + } + ], + "rubric": [ + { + "criterion": "GitHub-native enterprise context", + "proof": "The canvas reads versions, git changes, workflows, examples, reviews, and release evidence from the repository." + }, + { + "criterion": "Multi-agent collaboration", + "proof": "Research, authoring, verification, review, and integration have explicit owners and hand-offs." + }, + { + "criterion": "Governance and human review", + "proof": "Pinned sources, strict compilation, source-bound review, and an explicit final release decision." + }, + { + "criterion": "Customer repeatability", + "proof": "Fork the repo, edit one JSON configuration file, and reuse the same canvas for another documentation-led publication." + } + ] +} diff --git a/.github/extensions/release-book-canvas/README.md b/.github/extensions/release-book-canvas/README.md new file mode 100644 index 0000000..ced44f4 --- /dev/null +++ b/.github/extensions/release-book-canvas/README.md @@ -0,0 +1,39 @@ +# Book release canvas + +This project-scoped Copilot extension turns repository evidence into a release-control Canvas for documentation-led publications. + +## Open it + +After the extension is loaded, ask Copilot to open the **Book release canvas**, or call the canvas with: + +```text +canvasId: book-release-canvas +instanceId: book-release +``` + +The default configuration is `.book-release-canvas.json`. An alternate repo-relative file can be passed as `configPath` when opening the Canvas. + +## Reuse it + +Copy the extension directory and `.book-release-canvas.json` into another repository. Configure: + +- publication version and content paths; +- documentation source areas and their evidence; +- demo dependencies and proof artifacts; +- release gates; +- agent roles; and +- the challenge/business proof displayed in the Canvas. + +All configured paths are constrained to the repository. The extension binds its server to loopback only, requests no secret, and stores human demo decisions under the user's Copilot extension artifacts instead of editing committed evidence. + +## Actions + +- `refresh_release` — rescan git and configured evidence. +- `get_release_snapshot` — return the complete structured state. +- `set_demo_status` — persist a human `ready`, `needs-refresh`, or `blocked` decision. + +## Test + +```powershell +npm run test:canvas +``` diff --git a/.github/extensions/release-book-canvas/canvas.html b/.github/extensions/release-book-canvas/canvas.html new file mode 100644 index 0000000..b777b69 --- /dev/null +++ b/.github/extensions/release-book-canvas/canvas.html @@ -0,0 +1,378 @@ + + + + + + Release truth + + + +
+
+
+ +
Release truthReading repository evidence…
+
+
+ branch — + +
+
+ +
+
+
Documentation-led publishing
+

Know what changed before the reader does.

+

Trace every product update to the chapters, demos, and proof it can invalidate. Agents do the chase work; people keep the release decision.

+
+ Edition — + Framework — + — chapters · — examples +
+
+ +
+ +
+
+

The truth spine

One release path. Every hand-off leaves evidence.

+
ReadyReviewBlocked
+
+
+
+ +
+
+
+ + + + +
+
+
+
+
+
+ +
+ +
+ + + diff --git a/.github/extensions/release-book-canvas/copilot-extension.json b/.github/extensions/release-book-canvas/copilot-extension.json new file mode 100644 index 0000000..763efc0 --- /dev/null +++ b/.github/extensions/release-book-canvas/copilot-extension.json @@ -0,0 +1,4 @@ +{ + "name": "release-book-canvas", + "version": 1 +} diff --git a/.github/extensions/release-book-canvas/extension.mjs b/.github/extensions/release-book-canvas/extension.mjs new file mode 100644 index 0000000..ca6379e --- /dev/null +++ b/.github/extensions/release-book-canvas/extension.mjs @@ -0,0 +1,219 @@ +import { createServer } from "node:http"; +import { readFile } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; +import { dirname, isAbsolute, join, relative, resolve } from "node:path"; +import { CanvasError, createCanvas, joinSession } from "@github/copilot-sdk/extension"; +import { + loadOverrides, + saveDemoOverride, + scanRelease, +} from "./release-scanner.mjs"; + +const extensionDir = dirname(fileURLToPath(import.meta.url)); +const repositoryRoot = resolve(extensionDir, "..", "..", ".."); +const rendererPath = join(extensionDir, "canvas.html"); +const configPath = join(repositoryRoot, ".book-release-canvas.json"); +const servers = new Map(); +const listeners = new Map(); +const instanceConfigs = new Map(); + +const session = await joinSession({ + canvases: [ + createCanvas({ + id: "book-release-canvas", + displayName: "Book release canvas", + description: "Track documentation drift, impacted chapters, demo rebuilds, proof, and human release gates.", + inputSchema: { + type: "object", + properties: { + configPath: { + type: "string", + description: "Optional repo-relative canvas configuration path.", + }, + }, + additionalProperties: false, + }, + actions: [ + { + name: "refresh_release", + description: "Rescan the repository and return current release readiness.", + handler: async (ctx) => scan(ctx.instanceId), + }, + { + name: "get_release_snapshot", + description: "Return the source, demo, evidence, and release-gate snapshot shown in the canvas.", + handler: async (ctx) => scan(ctx.instanceId), + }, + { + name: "set_demo_status", + description: "Record a human demo decision without changing committed repository files.", + inputSchema: { + type: "object", + required: ["demoId", "status"], + properties: { + demoId: { type: "string", minLength: 1 }, + status: { + type: "string", + enum: ["ready", "needs-refresh", "blocked"], + }, + note: { type: "string", maxLength: 500 }, + }, + additionalProperties: false, + }, + handler: async (ctx) => { + await saveDemoOverride(repositoryRoot, ctx.input); + const snapshot = await scan(ctx.instanceId); + broadcast(snapshot); + return snapshot; + }, + }, + ], + open: async (ctx) => { + const requestedConfig = ctx.input?.configPath + ? resolve(repositoryRoot, ctx.input.configPath) + : configPath; + const relativeConfig = relative(repositoryRoot, requestedConfig); + if (relativeConfig.startsWith("..") || isAbsolute(relativeConfig)) { + throw new CanvasError("invalid_config_path", "The canvas configuration must be inside the repository."); + } + instanceConfigs.set(ctx.instanceId, requestedConfig); + let entry = servers.get(ctx.instanceId); + if (!entry) { + entry = await startServer(ctx.instanceId); + servers.set(ctx.instanceId, entry); + } + return { + title: "Release truth", + status: "Repository evidence", + url: entry.url, + }; + }, + onClose: async (ctx) => { + const entry = servers.get(ctx.instanceId); + listeners.delete(ctx.instanceId); + instanceConfigs.delete(ctx.instanceId); + if (entry) { + servers.delete(ctx.instanceId); + await new Promise((resolveClose) => entry.server.close(resolveClose)); + } + }, + }), + ], +}); + +async function scan(instanceId) { + try { + const overrides = await loadOverrides(repositoryRoot); + return await scanRelease( + repositoryRoot, + instanceConfigs.get(instanceId) ?? configPath, + overrides + ); + } catch (error) { + throw new CanvasError("release_scan_failed", error instanceof Error ? error.message : String(error)); + } +} + +function broadcast(snapshot) { + const payload = `event: snapshot\ndata: ${JSON.stringify(snapshot)}\n\n`; + for (const clients of listeners.values()) { + for (const response of clients) { + response.write(payload); + } + } +} + +async function readJsonBody(request) { + const chunks = []; + let length = 0; + for await (const chunk of request) { + length += chunk.length; + if (length > 32_768) { + throw new Error("Request body is too large."); + } + chunks.push(chunk); + } + if (chunks.length === 0) { + return {}; + } + return JSON.parse(Buffer.concat(chunks).toString("utf8")); +} + +function sendJson(response, status, body) { + response.writeHead(status, { + "Content-Type": "application/json; charset=utf-8", + "Cache-Control": "no-store", + }); + response.end(JSON.stringify(body)); +} + +async function startServer(instanceId) { + const html = await readFile(rendererPath, "utf8"); + const server = createServer(async (request, response) => { + const url = new URL(request.url ?? "/", "http://127.0.0.1"); + try { + if (request.method === "GET" && url.pathname === "/") { + response.writeHead(200, { + "Content-Type": "text/html; charset=utf-8", + "Cache-Control": "no-store", + "Content-Security-Policy": [ + "default-src 'self'", + "style-src 'self' 'unsafe-inline'", + "script-src 'self' 'unsafe-inline'", + "connect-src 'self'", + "img-src 'self' data:", + "font-src 'self' data:", + ].join("; "), + }); + response.end(html); + return; + } + if (request.method === "GET" && url.pathname === "/api/snapshot") { + sendJson(response, 200, await scan(instanceId)); + return; + } + if (request.method === "GET" && url.pathname === "/events") { + response.writeHead(200, { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + }); + const clients = listeners.get(instanceId) ?? new Set(); + clients.add(response); + listeners.set(instanceId, clients); + request.on("close", () => clients.delete(response)); + response.write(": connected\n\n"); + return; + } + if (request.method === "POST" && url.pathname === "/api/refresh") { + const snapshot = await scan(instanceId); + broadcast(snapshot); + sendJson(response, 200, snapshot); + return; + } + if (request.method === "POST" && url.pathname === "/api/demo-status") { + const input = await readJsonBody(request); + if (!input.demoId || !["ready", "needs-refresh", "blocked"].includes(input.status)) { + sendJson(response, 400, { error: "demoId and a valid status are required." }); + return; + } + await saveDemoOverride(repositoryRoot, input); + const snapshot = await scan(instanceId); + broadcast(snapshot); + sendJson(response, 200, snapshot); + return; + } + sendJson(response, 404, { error: "Not found." }); + } catch (error) { + sendJson(response, 500, { + error: error instanceof Error ? error.message : String(error), + }); + } + }); + await new Promise((resolveListen) => server.listen(0, "127.0.0.1", resolveListen)); + const address = server.address(); + const port = typeof address === "object" && address ? address.port : 0; + return { server, url: `http://127.0.0.1:${port}/` }; +} + +session.log("Book release canvas is ready.", { level: "info", ephemeral: true }); diff --git a/.github/extensions/release-book-canvas/release-scanner.mjs b/.github/extensions/release-book-canvas/release-scanner.mjs new file mode 100644 index 0000000..f4cf266 --- /dev/null +++ b/.github/extensions/release-book-canvas/release-scanner.mjs @@ -0,0 +1,310 @@ +import { createHash } from "node:crypto"; +import { execFile } from "node:child_process"; +import { homedir } from "node:os"; +import { basename, dirname, join, normalize, relative, resolve, sep } from "node:path"; +import { mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); +const STATUS_ORDER = { blocked: 0, "needs-refresh": 1, ready: 2 }; + +async function readText(path, fallback = "") { + try { + return (await readFile(path, "utf8")).trim(); + } catch (error) { + if (error?.code === "ENOENT") { + return fallback; + } + throw error; + } +} + +async function readJson(path, fallback = null) { + const text = await readText(path); + if (!text) { + return fallback; + } + return JSON.parse(text); +} + +async function exists(path) { + try { + await stat(path); + return true; + } catch (error) { + if (error?.code === "ENOENT") { + return false; + } + throw error; + } +} + +function safeResolve(root, repoPath) { + const target = resolve(root, repoPath); + const rootPrefix = normalize(root + sep); + if (target !== normalize(root) && !normalize(target).startsWith(rootPrefix)) { + throw new Error(`Configured path escapes the repository: ${repoPath}`); + } + return target; +} + +function interpolate(value, variables) { + return String(value).replace(/\{(\w+)\}/g, (_, name) => variables[name] ?? `{${name}}`); +} + +function getField(value, field) { + return field.split(".").reduce((current, key) => current?.[key], value); +} + +function normalizeRepoPath(path) { + return path.replaceAll("\\", "/").replace(/^\.\//, ""); +} + +function pathMatches(path, configuredPath) { + const normalized = normalizeRepoPath(path); + const configured = normalizeRepoPath(configuredPath).replace(/\/\*\*$/, ""); + return normalized === configured || normalized.startsWith(`${configured}/`); +} + +async function git(root, args) { + try { + const { stdout } = await execFileAsync("git", ["-C", root, ...args], { + windowsHide: true, + maxBuffer: 1024 * 1024, + }); + return stdout.replace(/\r?\n+$/, ""); + } catch { + return ""; + } +} + +async function changedFiles(root) { + const output = await git(root, ["status", "--short", "--untracked-files=all"]); + if (!output) { + return []; + } + return output.split(/\r?\n/).map((line) => { + const status = line.slice(0, 2).trim() || "?"; + const rawPath = line.slice(3).trim(); + const path = rawPath.includes(" -> ") ? rawPath.split(" -> ").at(-1) : rawPath; + return { status, path: normalizeRepoPath(path) }; + }); +} + +async function listFiles(root, repoPath, extension) { + const directory = safeResolve(root, repoPath); + if (!(await exists(directory))) { + return []; + } + const found = []; + async function walk(currentDirectory, currentRepoPath) { + for (const entry of await readdir(currentDirectory, { withFileTypes: true })) { + const entryRepoPath = normalizeRepoPath(join(currentRepoPath, entry.name)); + const entryPath = join(currentDirectory, entry.name); + if (entry.isDirectory()) { + await walk(entryPath, entryRepoPath); + } else if (entry.isFile() && (!extension || entry.name.endsWith(extension))) { + found.push(entryRepoPath); + } + } + } + await walk(directory, repoPath); + return found.sort(); +} + +function overrideStorePath(root) { + const copilotHome = process.env.COPILOT_HOME || join(homedir(), ".copilot"); + const id = createHash("sha256").update(resolve(root)).digest("hex").slice(0, 16); + return join(copilotHome, "extensions", "release-book-canvas", "artifacts", `${id}.json`); +} + +export async function loadOverrides(root) { + return (await readJson(overrideStorePath(root), { demos: {} })) ?? { demos: {} }; +} + +export async function saveDemoOverride(root, input) { + const path = overrideStorePath(root); + const state = await loadOverrides(root); + state.demos ??= {}; + state.demos[input.demoId] = { + status: input.status, + note: input.note?.trim() || "", + decidedAt: new Date().toISOString(), + }; + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, JSON.stringify(state, null, 2) + "\n", "utf8"); +} + +async function evaluateGate(root, gate, variables) { + const repoPath = interpolate(gate.path, variables); + const path = safeResolve(root, repoPath); + let actual = null; + let passed = false; + if (gate.kind === "file-exists") { + passed = await exists(path); + actual = passed ? "present" : "missing"; + } else if (gate.kind === "json-field") { + const data = await readJson(path); + actual = data ? getField(data, gate.field) : null; + passed = actual === gate.equals; + } else if (gate.kind === "text-equals") { + actual = await readText(path, null); + passed = actual === interpolate(gate.equals, variables); + } + return { + ...gate, + path: repoPath, + passed, + actual, + status: passed ? "ready" : "blocked", + }; +} + +function freshnessStatus(source, evidencePresent, relevantChanges) { + if (relevantChanges.length > 0) { + return "needs-refresh"; + } + if (!evidencePresent) { + return "blocked"; + } + return "ready"; +} + +export async function scanRelease(root, configPath, overrides = { demos: {} }) { + const config = await readJson(configPath); + if (!config || config.schemaVersion !== 1) { + throw new Error(`Expected schemaVersion 1 in ${relative(root, configPath)}.`); + } + + const contentVersion = await readText(safeResolve(root, config.publication.contentVersionPath), "unknown"); + const frameworkVersion = await readText(safeResolve(root, config.publication.frameworkVersionPath), "unknown"); + const variables = { contentVersion, frameworkVersion }; + const changes = await changedFiles(root); + const head = await git(root, ["rev-parse", "--short", "HEAD"]); + const branch = await git(root, ["branch", "--show-current"]); + const commitDate = await git(root, ["log", "-1", "--format=%cI"]); + const chapters = await listFiles(root, config.publication.chapterPath, ".html"); + const examples = await listFiles(root, config.publication.examplePath, ".md"); + + const sources = []; + for (const source of config.sources) { + const paths = source.paths.map((path) => interpolate(path, variables)); + const ignoredPaths = (source.ignorePaths ?? []).map((path) => interpolate(path, variables)); + const relevantChanges = changes.filter((change) => + paths.some((path) => pathMatches(change.path, path)) + && !ignoredPaths.some((path) => pathMatches(change.path, path)) + ); + const evidence = source.evidence + ? interpolate(source.evidence, variables) + : paths[0]; + const evidencePresent = await exists(safeResolve(root, evidence)); + sources.push({ + ...source, + paths, + ignoredPaths, + evidence, + evidencePresent, + changedFiles: relevantChanges, + status: freshnessStatus(source, evidencePresent, relevantChanges), + }); + } + + const gates = await Promise.all( + config.gates.map((gate) => evaluateGate(root, gate, variables)) + ); + const demos = []; + for (const demo of config.demos) { + const paths = demo.paths.map((path) => interpolate(path, variables)); + const evidence = demo.evidence + ? interpolate(demo.evidence, variables) + : null; + const relevantChanges = changes.filter((change) => + paths.some((path) => pathMatches(change.path, path)) + ); + const evidencePresent = evidence + ? await exists(safeResolve(root, evidence)) + : (await Promise.all(paths.map((path) => exists(safeResolve(root, path))))).some(Boolean); + const automaticStatus = relevantChanges.length > 0 + ? "needs-refresh" + : evidencePresent + ? "ready" + : "blocked"; + const decision = overrides.demos?.[demo.id]; + demos.push({ + ...demo, + paths, + evidence, + changedFiles: relevantChanges, + automaticStatus, + status: decision?.status ?? automaticStatus, + note: decision?.note ?? "", + decidedAt: decision?.decidedAt ?? null, + }); + } + + const impactedChapters = chapters + .filter((chapter) => changes.some((change) => pathMatches(change.path, chapter))) + .map((path) => ({ path, title: basename(path, ".html").replaceAll("-", " ") })); + const gateScore = gates.length + ? gates.filter((gate) => gate.passed).length / gates.length + : 1; + const sourceScore = sources.length + ? sources.reduce((sum, source) => sum + STATUS_ORDER[source.status], 0) / (sources.length * 2) + : 1; + const demoScore = demos.length + ? demos.reduce((sum, demo) => sum + STATUS_ORDER[demo.status], 0) / (demos.length * 2) + : 1; + const readiness = Math.round((gateScore * 0.55 + sourceScore * 0.25 + demoScore * 0.2) * 100); + const blockers = [ + ...gates.filter((gate) => !gate.passed).map((gate) => ({ + type: "proof", + title: gate.label, + detail: `${gate.path}: expected ${String(gate.equals ?? "present")}, found ${String(gate.actual)}`, + })), + ...sources.filter((source) => source.status !== "ready").map((source) => ({ + type: "source", + title: source.label, + detail: source.changedFiles.length + ? `${source.changedFiles.length} changed file(s) require impact review.` + : `Evidence is missing: ${source.evidence}`, + })), + ...demos.filter((demo) => demo.status !== "ready").map((demo) => ({ + type: "demo", + title: demo.label, + detail: demo.note || (demo.status === "blocked" + ? "Required evidence is missing." + : demo.changedFiles.length + ? `${demo.changedFiles.length} dependency change(s) require a rebuild.` + : "A human decision marked this demo for rebuild."), + })), + ]; + + return { + schemaVersion: 1, + generatedAt: new Date().toISOString(), + repository: { + name: config.repository.name || basename(root), + branch, + head, + commitDate, + dirtyFiles: changes.length, + }, + publication: { + ...config.publication, + contentVersion, + frameworkVersion, + chapterCount: chapters.length, + exampleCount: examples.length, + }, + readiness, + decision: blockers.length === 0 ? "ready" : "hold", + sources, + demos, + gates, + blockers, + impactedChapters, + changes, + agents: config.agents, + rubric: config.rubric, + }; +} diff --git a/.github/extensions/release-book-canvas/release-scanner.test.mjs b/.github/extensions/release-book-canvas/release-scanner.test.mjs new file mode 100644 index 0000000..53bf86a --- /dev/null +++ b/.github/extensions/release-book-canvas/release-scanner.test.mjs @@ -0,0 +1,111 @@ +import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; +import { mkdir, mkdtemp, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { promisify } from "node:util"; +import { scanRelease } from "./release-scanner.mjs"; + +const exec = promisify(execFile); + +async function fixture() { + const root = await mkdtemp(join(tmpdir(), "release-canvas-")); + const put = async (path, contents) => { + const target = join(root, path); + await mkdir(join(target, ".."), { recursive: true }); + await writeFile(target, contents, "utf8"); + }; + await put("content/VERSION", "2.4\n"); + await put("content/FRAMEWORK_VERSION", "v9.1.0\n"); + await put("content/chapters/intro.html", "

Intro

\n"); + await put("examples/demo.md", "---\nengine: copilot\n---\n"); + await put("evidence/v9.1.0/verify.json", '{"status":"PASS"}\n'); + await put("review.json", '{"verdict":"ACCEPT"}\n'); + const config = { + schemaVersion: 1, + repository: { name: "Fixture book" }, + publication: { + name: "Fixture", + promise: "Evidence before release.", + contentVersionPath: "content/VERSION", + frameworkVersionPath: "content/FRAMEWORK_VERSION", + chapterPath: "content/chapters", + examplePath: "examples", + }, + sources: [{ + id: "manuscript", + label: "Manuscript", + owner: "Author", + paths: ["content/chapters"], + evidence: "review.json", + why: "Reader-facing truth.", + }], + demos: [{ + id: "compile", + label: "Compile", + owner: "Verifier", + paths: ["examples"], + evidence: "evidence/{frameworkVersion}/verify.json", + cue: "Show the proof.", + }], + gates: [ + { + id: "compile", + label: "Compile passed", + kind: "json-field", + path: "evidence/{frameworkVersion}/verify.json", + field: "status", + equals: "PASS", + }, + { + id: "review", + label: "Review passed", + kind: "json-field", + path: "review.json", + field: "verdict", + equals: "ACCEPT", + }, + ], + agents: [], + rubric: [], + }; + await put(".book-release-canvas.json", JSON.stringify(config)); + await exec("git", ["init", "-q"], { cwd: root }); + await exec("git", ["config", "user.email", "test@example.com"], { cwd: root }); + await exec("git", ["config", "user.name", "Test"], { cwd: root }); + await exec("git", ["add", "."], { cwd: root }); + await exec("git", ["commit", "-qm", "fixture"], { cwd: root }); + return root; +} + +test("clean verified fixture is ready", async () => { + const root = await fixture(); + const snapshot = await scanRelease(root, join(root, ".book-release-canvas.json")); + assert.equal(snapshot.decision, "ready"); + assert.equal(snapshot.readiness, 100); + assert.equal(snapshot.publication.contentVersion, "2.4"); + assert.equal(snapshot.publication.frameworkVersion, "v9.1.0"); + assert.equal(snapshot.publication.chapterCount, 1); + assert.equal(snapshot.publication.exampleCount, 1); +}); + +test("changed example queues a demo rebuild and holds release", async () => { + const root = await fixture(); + await writeFile(join(root, "examples", "demo.md"), "changed\n", "utf8"); + const snapshot = await scanRelease(root, join(root, ".book-release-canvas.json")); + assert.equal(snapshot.decision, "hold"); + assert.equal(snapshot.demos[0].status, "needs-refresh"); + assert.equal(snapshot.blockers[0].type, "demo"); +}); + +test("human override is reflected without changing repository evidence", async () => { + const root = await fixture(); + await writeFile(join(root, "examples", "demo.md"), "changed\n", "utf8"); + const snapshot = await scanRelease(root, join(root, ".book-release-canvas.json"), { + demos: { compile: { status: "ready", note: "Recorded from the demo rehearsal." } }, + }); + assert.equal(snapshot.demos[0].status, "ready"); + assert.equal(snapshot.demos[0].automaticStatus, "needs-refresh"); + assert.equal(snapshot.demos[0].note, "Recorded from the demo rehearsal."); +}); diff --git a/.gitignore b/.gitignore index a38d3f6..2c993bb 100644 --- a/.gitignore +++ b/.gitignore @@ -33,6 +33,9 @@ site/gh-aw-book.pdf # Local tooling artifacts (browser automation snapshots/logs) .playwright-mcp/ +# Local hackathon submission package (video, deck, form copy, and rehearsal assets) +.hackathon/ + # APM dependencies apm_modules/ diff --git a/DESIGN.md b/DESIGN.md index 0e53b50..420e0f4 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -22,6 +22,17 @@ colors: code-caption: "#b9c3dc" code-hairline: "rgba(148, 163, 184, 0.22)" code-badge-ink: "#ffe1c2" + release-night: "#060912" + release-panel: "#0d1324" + release-panel-raised: "#121a2d" + release-panel-control: "#1a2440" + release-ink: "#f8faff" + release-muted: "#c4cbdd" + release-violet: "#b8a1ff" + release-cyan: "#70e8f5" + release-mint: "#7bf2b8" + release-amber: "#ffd36f" + release-rose: "#ff91ad" typography: display: fontFamily: "Literata, Georgia, Times New Roman, serif" diff --git a/README.md b/README.md index d4b27b9..bc9ca9b 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,38 @@ What makes this repository interesting is not just the book itself, but **how it small **fleet of GitHub Copilot primitives** (custom agents, skills, instruction files, and a driver prompt) that collaborate under an orchestrator in a wave-based pipeline. +## Release Truth: the book's release control plane + +This project is designed around a real pain point for enterprise teams: **technical literature is still +built by hand even when the product already has reliable docs, examples, and release notes**. + +The problem is simple and costly: + +- product docs change in multiple repos and systems +- the book or handbook is updated manually and slowly +- demo assets and screenshots drift from the current release +- no one can easily see which source changed, what needs a refresh, or when the release is actually ready + +This repo turns that problem into a reusable pattern: a **release-based book builder** with a real +GitHub Copilot **Book release canvas** that tracks source drift, proof artifacts, demos, and +publication readiness. The GitHub Agentic Workflows book is the reference case for that system. + +A team can reuse this same workflow for any docs-driven product book, architecture guide, onboarding +manual, or customer ebook. The opportunity is not "build a book site" — it is **turn technical content +production into a controlled, auditable release process**. + +The project-scoped extension lives in `.github/extensions/release-book-canvas/`. It scans the +repository's git changes, version pins, research, examples, verification receipts, and review record. +The visual **truth spine** connects: + +```text +source change -> chapter impact -> demo + proof -> human release +``` + +To reuse it for another publication, fork the repo and edit `.book-release-canvas.json`. The Canvas +requests no secret, binds its renderer to loopback only, constrains configured paths to the repo, and +stores human demo decisions outside committed source evidence. + > 💡 **Inspired by [*The Agentic SDLC Handbook* by Daniel Meppiel](https://danielmeppiel.github.io/agentic-sdlc-handbook/handbook/ch01-the-agentic-sdlc-thesis.html)** — in particular its [case study on agentic handbook writing](https://danielmeppiel.github.io/agentic-sdlc-handbook/case-study-handbook-writing.html). This project applies that thesis: composing **primitives** (agents + prompts + skills + instructions) into a squad that produces real software artifacts. > 🧬 **Built on the foundation of [`microsoft-agent-framework-playbook-fleets-generated` by Valentina Alto](https://github.com/Valentina-Alto/microsoft-agent-framework-playbook-fleets-generated)** — this project adapts that repository's fleet-of-primitives methodology and site tooling from the **Microsoft Agent Framework** to **GitHub Agentic Workflows**. @@ -26,6 +58,15 @@ prompt) that collaborate under an orchestrator in a wave-based pipeline. > 🛠️ **Build & preview locally:** the site is generated from `content/toc.yml` by `site/generate.py` > and served from `site/` (see [Run the book locally](#run-the-book-locally)). Published at . +### Run the Canvas + +1. Open this repository in the GitHub Copilot App. +2. Reload extensions after changing the extension source. +3. Ask Copilot to open the **Book release canvas**. +4. Run `npm run test:canvas` to verify the generic evidence scanner. + +The production artifact is the Copilot Canvas extension and its live repository scan. + --- ## The idea behind the fleet diff --git a/package.json b/package.json index fe2a438..6f1fb78 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "license": "MIT", "scripts": { "build:analytics": "esbuild analytics/analytics.entry.js --bundle --minify --format=iife --target=es2019 --legal-comments=none --outfile=site/assets/analytics.js", + "test:canvas": "node --test .github/extensions/release-book-canvas/release-scanner.test.mjs", "report": "cookieless-insights report --resource-group is-ai-native-rg --app-insights aw-book-ai --days 30 --open" }, "dependencies": { @@ -16,5 +17,12 @@ }, "engines": { "node": ">=18" - } + }, + "main": "index.js", + "directories": { + "example": "examples" + }, + "keywords": [], + "author": "", + "type": "commonjs" }