Skip to content

Commit 6424d98

Browse files
authored
Merge branch 'main' into vally-update
2 parents 2a53811 + b55f76e commit 6424d98

120 files changed

Lines changed: 8078 additions & 1192 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.github/aw/actions-lock.json‎

Lines changed: 29 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -20,15 +20,15 @@
2020
"version": "v7.0.1",
2121
"sha": "043fb46d1a93c77aae656e7c1c64a875d1fc6a0a"
2222
},
23-
"github/gh-aw-actions/setup-cli@v0.88.8": {
23+
"github/gh-aw-actions/setup-cli@v0.89.21": {
2424
"repo": "github/gh-aw-actions/setup-cli",
25-
"version": "v0.88.8",
26-
"sha": "5e508589e03a7757a7e05b26e834292f5445bfb6"
25+
"version": "v0.89.21",
26+
"sha": "924af5fdc64061cfbf66fb584c8b07e2ac230c60"
2727
},
28-
"github/gh-aw-actions/setup@v0.88.8": {
28+
"github/gh-aw-actions/setup@v0.89.21": {
2929
"repo": "github/gh-aw-actions/setup",
30-
"version": "v0.88.8",
31-
"sha": "5e508589e03a7757a7e05b26e834292f5445bfb6"
30+
"version": "v0.89.21",
31+
"sha": "924af5fdc64061cfbf66fb584c8b07e2ac230c60"
3232
}
3333
},
3434
"containers": {
@@ -42,6 +42,11 @@
4242
"digest": "sha256:f7df036c86575527b61f3f7df91c4412349a12b2a74988d929eafa2999230c98",
4343
"pinned_image": "ghcr.io/github/gh-aw-firewall/agent:0.28.14@sha256:f7df036c86575527b61f3f7df91c4412349a12b2a74988d929eafa2999230c98"
4444
},
45+
"ghcr.io/github/gh-aw-firewall/agent:0.28.23": {
46+
"image": "ghcr.io/github/gh-aw-firewall/agent:0.28.23",
47+
"digest": "sha256:2c78aaba1c108e130e2d6d01e4f2cca334ea04c53e6f258913ac34173fe7e3b2",
48+
"pinned_image": "ghcr.io/github/gh-aw-firewall/agent:0.28.23@sha256:2c78aaba1c108e130e2d6d01e4f2cca334ea04c53e6f258913ac34173fe7e3b2"
49+
},
4550
"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.43": {
4651
"image": "ghcr.io/github/gh-aw-firewall/api-proxy:0.27.43",
4752
"digest": "sha256:d85f57975af5ea23af4996e41ed73fbc8f5b4a47402472bfe82e508f352cb0c1",
@@ -52,6 +57,11 @@
5257
"digest": "sha256:6f95e2234dd9bd6333a8ff28ccea7ecf0204acd4a09108723844dbd2bf6268c5",
5358
"pinned_image": "ghcr.io/github/gh-aw-firewall/api-proxy:0.28.14@sha256:6f95e2234dd9bd6333a8ff28ccea7ecf0204acd4a09108723844dbd2bf6268c5"
5459
},
60+
"ghcr.io/github/gh-aw-firewall/api-proxy:0.28.23": {
61+
"image": "ghcr.io/github/gh-aw-firewall/api-proxy:0.28.23",
62+
"digest": "sha256:c15c3d1208df10c5b588a3657be53742aa982ae0909d1eb1812525268794ca64",
63+
"pinned_image": "ghcr.io/github/gh-aw-firewall/api-proxy:0.28.23@sha256:c15c3d1208df10c5b588a3657be53742aa982ae0909d1eb1812525268794ca64"
64+
},
5565
"ghcr.io/github/gh-aw-firewall/squid:0.27.43": {
5666
"image": "ghcr.io/github/gh-aw-firewall/squid:0.27.43",
5767
"digest": "sha256:26be5e0b8c8f4c41c8a59126b29bb5d80b07253597472ded2a16bdd75abcbf9d",
@@ -62,20 +72,25 @@
6272
"digest": "sha256:2ce8df3abf3e9b76e9c0cf5863da41f1ab3f89b20ad14b988806ab89e7bf2cd5",
6373
"pinned_image": "ghcr.io/github/gh-aw-firewall/squid:0.28.14@sha256:2ce8df3abf3e9b76e9c0cf5863da41f1ab3f89b20ad14b988806ab89e7bf2cd5"
6474
},
65-
"ghcr.io/github/gh-aw-mcpg:v0.4.18": {
66-
"image": "ghcr.io/github/gh-aw-mcpg:v0.4.18",
67-
"digest": "sha256:85b940556a8faa4e1fdbef124bfd75f2c4ebd855a10b88a1c3b6f3e97f6f1a53",
68-
"pinned_image": "ghcr.io/github/gh-aw-mcpg:v0.4.18@sha256:85b940556a8faa4e1fdbef124bfd75f2c4ebd855a10b88a1c3b6f3e97f6f1a53"
75+
"ghcr.io/github/gh-aw-firewall/squid:0.28.23": {
76+
"image": "ghcr.io/github/gh-aw-firewall/squid:0.28.23",
77+
"digest": "sha256:02ffc56dd40158223064ef03a78c2d9717473c93723b4e403d455ea6f0b09ae0",
78+
"pinned_image": "ghcr.io/github/gh-aw-firewall/squid:0.28.23@sha256:02ffc56dd40158223064ef03a78c2d9717473c93723b4e403d455ea6f0b09ae0"
79+
},
80+
"ghcr.io/github/gh-aw-mcpg:v0.4.25": {
81+
"image": "ghcr.io/github/gh-aw-mcpg:v0.4.25",
82+
"digest": "sha256:9be0a86220e807a0ecc89e53d7453468f7a53fbc6b3d1efd2299025ffe01d086",
83+
"pinned_image": "ghcr.io/github/gh-aw-mcpg:v0.4.25@sha256:9be0a86220e807a0ecc89e53d7453468f7a53fbc6b3d1efd2299025ffe01d086"
6984
},
7085
"ghcr.io/github/gh-aw-node": {
7186
"image": "ghcr.io/github/gh-aw-node",
7287
"digest": "sha256:0d9f1fb5fd6610c0ac1f5194a38e45a8a1e81f8a390d5142d8e4e6f26a4b3196",
7388
"pinned_image": "ghcr.io/github/gh-aw-node@sha256:0d9f1fb5fd6610c0ac1f5194a38e45a8a1e81f8a390d5142d8e4e6f26a4b3196"
7489
},
75-
"ghcr.io/github/github-mcp-server:v1.11.0": {
76-
"image": "ghcr.io/github/github-mcp-server:v1.11.0",
77-
"digest": "sha256:fbec75de11c255213fa08d80fb166abe73d851fff631c51c0079872967720699",
78-
"pinned_image": "ghcr.io/github/github-mcp-server:v1.11.0@sha256:fbec75de11c255213fa08d80fb166abe73d851fff631c51c0079872967720699"
90+
"ghcr.io/github/github-mcp-server:v1.12.2": {
91+
"image": "ghcr.io/github/github-mcp-server:v1.12.2",
92+
"digest": "sha256:508a0857ec762b1ab1cece29193345b501fab1dd9d1228a7b617062954cecac6",
93+
"pinned_image": "ghcr.io/github/github-mcp-server:v1.12.2@sha256:508a0857ec762b1ab1cece29193345b501fab1dd9d1228a7b617062954cecac6"
7994
}
8095
}
8196
}
Lines changed: 300 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,300 @@
1+
import { createHash } from 'node:crypto';
2+
import { readdir, readFile, mkdir, writeFile, unlink } from 'node:fs/promises';
3+
import path from 'node:path';
4+
import { fileURLToPath } from 'node:url';
5+
6+
export const SOURCE_REPO = 'github/copilot-app-for-beginners';
7+
export const DOCS_DIR = 'website/src/content/docs/learning-hub/app-for-beginners';
8+
export const IMAGES_DIR = 'website/public/images/learning-hub/copilot-app-for-beginners';
9+
const PUBLIC_ROUTE = '/learning-hub/app-for-beginners/';
10+
const PUBLIC_IMAGES = '/images/learning-hub/copilot-app-for-beginners/';
11+
const REPO_URL = `https://github.com/${SOURCE_REPO}`;
12+
const CHAPTERS = [
13+
['00-setup', 'Setup', 'Install the app, prepare your course fork, and create the practice work items.'],
14+
['01-tour-the-app', 'Tour the App', 'Explore chats, project sessions, session modes, models, and app settings.'],
15+
['02-sessions-worktrees-context', 'Sessions, Worktrees, and Context', 'Start isolated sessions and provide context with files, issues, skills, and commands.'],
16+
['03-development-workflows', 'Development and GitHub Workflows', 'Review, test, and preview changes, then work with issues, pull requests, comments, and checks.'],
17+
['04-skills-custom-agents', 'Skills and Custom Agents', 'Use a review skill and a read-only custom agent to guide a focused improvement.'],
18+
['05-mcp-plugins', 'MCP Servers and Plugins', 'Retrieve documentation through an MCP server and use a plugin for a focused task.'],
19+
['06-canvases', 'Canvases', 'Use shared canvas boards to keep plans, progress, and validation evidence visible.'],
20+
['07-automations', 'Automations', 'Create and schedule a read-only pull request report, then explore event and cloud options.'],
21+
];
22+
23+
const hash = (bytes) => createHash('sha256').update(bytes).digest('hex');
24+
const text = (bytes) => Buffer.from(bytes).toString('utf8').replace(/\r\n/g, '\n');
25+
26+
async function optionalRead(filename) {
27+
try {
28+
return await readFile(filename);
29+
} catch (error) {
30+
if (error.code === 'ENOENT') return undefined;
31+
throw error;
32+
}
33+
}
34+
35+
async function listFiles(directory, prefix = '') {
36+
const files = [];
37+
for (const entry of await readdir(path.join(directory, prefix), { withFileTypes: true })) {
38+
const filename = path.posix.join(prefix, entry.name);
39+
if (entry.isDirectory()) files.push(...await listFiles(directory, filename));
40+
else if (entry.isFile()) files.push(filename);
41+
}
42+
return files;
43+
}
44+
45+
async function fetchBytes(url) {
46+
const response = await fetch(url, { signal: AbortSignal.timeout(60_000) });
47+
if (!response.ok) throw new Error(`Download failed: ${response.status} ${url}`);
48+
return Buffer.from(await response.arrayBuffer());
49+
}
50+
51+
export function approvedPages(paths) {
52+
const pages = [{
53+
source: 'README.md',
54+
destination: 'index.md',
55+
title: 'GitHub Copilot app for Beginners',
56+
description: 'Learn to direct coding agents in the desktop app through eight chapters with a shared React sample.',
57+
}];
58+
for (const [route, title, description] of CHAPTERS) {
59+
const number = route.slice(0, 2);
60+
const matches = [...paths].filter((filename) => new RegExp(`^${number}-[^/]+/README\\.md$`).test(filename));
61+
if (matches.length > 1) throw new Error(`Ambiguous chapter ${number}: ${matches.join(', ')}`);
62+
if (matches.length) pages.push({
63+
source: matches[0],
64+
destination: `${route}.md`,
65+
title: `${number} · ${title}`,
66+
description,
67+
});
68+
}
69+
return pages;
70+
}
71+
72+
// Leave fenced commands, prompts, and inline sample paths unchanged.
73+
function mapProse(markdown, transform) {
74+
let fence;
75+
return markdown.split(/(?<=\n)/).map((line) => {
76+
const marker = line.match(/^\s*(`{3,}|~{3,})/);
77+
if (marker) {
78+
if (!fence) fence = marker[1];
79+
else if (marker[1][0] === fence[0] && marker[1].length >= fence.length) fence = undefined;
80+
return line;
81+
}
82+
if (fence) return line;
83+
const code = [];
84+
const protectedLine = line.replace(/(`+)[\s\S]*?\1/g, (match) => {
85+
code.push(match);
86+
return `\u0000${code.length - 1}\u0000`;
87+
});
88+
return transform(protectedLine).replace(/\u0000(\d+)\u0000/g, (_, index) => code[Number(index)]);
89+
}).join('');
90+
}
91+
92+
export function adaptMarkdown(markdown, source, pages, paths) {
93+
const assets = new Map();
94+
const pageRoutes = new Map(pages.map((page) => [
95+
page.source,
96+
page.destination === 'index.md' ? PUBLIC_ROUTE : `${PUBLIC_ROUTE}${page.destination.slice(0, -3)}/`,
97+
]));
98+
99+
function destination(url, image = false) {
100+
if (/^(?:[a-z][a-z\d+.-]*:|\/\/|#)/i.test(url)) return url;
101+
const [, pathname, suffix = ''] = url.match(/^([^?#]*)(.*)$/);
102+
const resolved = path.posix.normalize(path.posix.join(path.posix.dirname(source), decodeURI(pathname)));
103+
if (resolved.startsWith('../') || resolved.startsWith('/')) {
104+
throw new Error(`Source link leaves the repository: ${source} -> ${url}`);
105+
}
106+
const filename = paths.has(resolved) ? resolved : path.posix.join(resolved, 'README.md');
107+
if (image) {
108+
if (!paths.has(resolved) || !/\.(?:webp|svg|png|gif|jpe?g|avif)$/i.test(resolved)) {
109+
throw new Error(`Missing or unsupported course image: ${source} -> ${url}`);
110+
}
111+
const directory = resolved.startsWith('assets/') ? 'overview' : resolved.slice(0, 2);
112+
if (!/^(?:assets\/|0[0-7]-[^/]+\/assets\/)/.test(resolved)) {
113+
throw new Error(`Image is outside the approved course: ${resolved}`);
114+
}
115+
const local = `${directory}/${path.posix.basename(resolved)}`;
116+
if (assets.has(local) && assets.get(local) !== resolved) throw new Error(`Image path collision: ${local}`);
117+
assets.set(local, resolved);
118+
return `${PUBLIC_IMAGES}${local}${suffix}`;
119+
}
120+
if (pageRoutes.has(filename)) return pageRoutes.get(filename) + suffix;
121+
const directory = [...paths].some((entry) => entry.startsWith(`${resolved.replace(/\/$/, '')}/`));
122+
if (!paths.has(resolved) && !directory) throw new Error(`Missing source link: ${source} -> ${url}`);
123+
return `${REPO_URL}/${directory ? 'tree' : 'blob'}/main/${resolved.replace(/\/$/, '')}${suffix}`;
124+
}
125+
126+
function rewriteLinks(line) {
127+
return line.replace(/(!?\[(?:[^\[\]\n]|\[[^\]\n]*\])*\]\()([^\s)]+)([^)\n]*\))/g,
128+
(_, start, url, end) => `${rewriteLinks(start)}${destination(url, start.startsWith('!'))}${end}`);
129+
}
130+
131+
let body = mapProse(markdown, (line) => rewriteLinks(line)
132+
.replace(/^(\s*\[[^\]]+\]:\s*)(\S+)/, (_, start, url) => start + destination(url))
133+
.replace(/(<img\b[^>]*\bsrc=)(["'])(.*?)\2/gi,
134+
(_, start, quote, url) => start + quote + destination(url, true) + quote)
135+
.replace(/(<a\b[^>]*\bhref=)(["'])(.*?)\2/gi,
136+
(_, start, quote, url) => start + quote + destination(url) + quote));
137+
138+
if (source === 'README.md') {
139+
body = body.replace(/^# GitHub Copilot app for Beginners\s*$/m,
140+
'<a id="github-copilot-app-for-beginners"></a>');
141+
body += `\nSource: [${SOURCE_REPO}](${REPO_URL}). Copyright GitHub, Inc. Used under the [MIT license](${REPO_URL}/blob/main/LICENSE).\n`;
142+
}
143+
return { body, assets };
144+
}
145+
146+
function frontmatter(page, date) {
147+
const quote = (value) => `'${value.replaceAll("'", "''")}'`;
148+
return `---\ntitle: ${quote(page.title)}\ndescription: ${quote(page.description)}\nauthors:\n - GitHub, Inc.\n - Dan Wahlin\nlastUpdated: ${date}\ntags:\n - workshop\n - copilot-app\n - desktop\n---\n\n`;
149+
}
150+
151+
function bodyOf(markdown) {
152+
return markdown.replace(/^---\n[\s\S]*?\n---\n\n?/, '');
153+
}
154+
155+
function pageContent(page, body, previous, date) {
156+
if (previous) {
157+
const metadata = text(previous).match(/^---\n[\s\S]*?\n---\n\n?/);
158+
if (metadata) return Buffer.from(metadata[0].replace(/^lastUpdated:.*$/m, `lastUpdated: ${date}`) + body);
159+
}
160+
return Buffer.from(frontmatter(page, date) + body);
161+
}
162+
163+
export async function planImport({ sha, date, paths, readSource, readLocal }) {
164+
if (!/^[a-f\d]{40}$/.test(sha)) throw new Error('A full source commit SHA is required.');
165+
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) throw new Error('Use an ISO date: YYYY-MM-DD.');
166+
const pages = approvedPages(paths);
167+
const outputs = new Map();
168+
const files = [];
169+
const allAssets = new Map();
170+
const pageAssets = new Map();
171+
const existingBytes = await readLocal(`${DOCS_DIR}/source.json`);
172+
const existing = existingBytes ? JSON.parse(text(existingBytes)) : undefined;
173+
174+
for (const page of pages) {
175+
const bytes = await readSource(page.source);
176+
const { body, assets } = adaptMarkdown(text(bytes), page.source, pages, paths);
177+
const local = `${DOCS_DIR}/${page.destination}`;
178+
const previous = await readLocal(local);
179+
outputs.set(local, previous && bodyOf(text(previous)) === body
180+
? previous : pageContent(page, body, previous, date));
181+
pageAssets.set(local, { page, body, previous, assets: new Set(assets.keys()) });
182+
files.push({ source: page.source, local, sha256: hash(bytes) });
183+
for (const [localAsset, sourceAsset] of assets) {
184+
if (allAssets.has(localAsset) && allAssets.get(localAsset) !== sourceAsset) {
185+
throw new Error(`Image path collision: ${localAsset}`);
186+
}
187+
allAssets.set(localAsset, sourceAsset);
188+
}
189+
}
190+
191+
// Download every referenced image, including unchanged paths, to detect asset-only updates.
192+
for (const [localAsset, sourceAsset] of [...allAssets].sort()) {
193+
const bytes = await readSource(sourceAsset);
194+
const local = `${IMAGES_DIR}/${localAsset}`;
195+
outputs.set(local, bytes);
196+
files.push({ source: sourceAsset, local, sha256: hash(bytes) });
197+
const previous = await readLocal(local);
198+
if (!previous || !bytes.equals(previous)) {
199+
for (const [pageLocal, entry] of pageAssets) {
200+
if (entry.assets.has(localAsset)) {
201+
outputs.set(pageLocal, pageContent(entry.page, entry.body, entry.previous, date));
202+
}
203+
}
204+
}
205+
}
206+
const license = await readSource('LICENSE');
207+
outputs.set(`${DOCS_DIR}/source-license.txt`, Buffer.from(text(license).trimEnd() + '\n'));
208+
files.push({ source: 'LICENSE', local: `${DOCS_DIR}/source-license.txt`, sha256: hash(license) });
209+
const deleted = (existing?.files ?? []).map((file) => file.local)
210+
.filter((filename) => !outputs.has(filename));
211+
if (deleted.some((filename) => !isImportPath(filename))) throw new Error('Unsafe deletion in source manifest.');
212+
const changed = [];
213+
for (const [filename, bytes] of outputs) {
214+
const previous = await readLocal(filename);
215+
if (!previous || !bytes.equals(previous)) changed.push(filename);
216+
}
217+
for (const filename of deleted) {
218+
if (await readLocal(filename)) changed.push(filename);
219+
}
220+
const sourceMappingChanged = !existing || JSON.stringify(existing.files) !== JSON.stringify(files);
221+
if (changed.length || sourceMappingChanged) {
222+
outputs.set(`${DOCS_DIR}/source.json`, Buffer.from(JSON.stringify({
223+
repository: SOURCE_REPO,
224+
sourceCommit: sha,
225+
importedAt: date,
226+
scope: 'README and chapters 00-07 only; supporting files remain upstream',
227+
files,
228+
}, null, 2) + '\n'));
229+
changed.push(`${DOCS_DIR}/source.json`);
230+
}
231+
return { sha, baseline: existing?.sourceCommit, outputs, deleted, changed, pages: pages.length, assets: allAssets.size };
232+
}
233+
234+
function isImportPath(filename) {
235+
return filename.startsWith(`${DOCS_DIR}/`) || filename.startsWith(`${IMAGES_DIR}/`);
236+
}
237+
238+
export async function applyImport(plan, root) {
239+
for (const filename of [...plan.outputs.keys(), ...plan.deleted]) {
240+
if (!isImportPath(filename) || path.posix.normalize(filename) !== filename) {
241+
throw new Error(`Path is outside the import scope: ${filename}`);
242+
}
243+
}
244+
// Do not write a partial import after a failed source download.
245+
for (const [filename, bytes] of plan.outputs) {
246+
if (!plan.changed.includes(filename)) continue;
247+
await mkdir(path.dirname(path.join(root, filename)), { recursive: true });
248+
await writeFile(path.join(root, filename), bytes);
249+
}
250+
for (const filename of plan.deleted) {
251+
if (plan.changed.includes(filename)) await unlink(path.join(root, filename));
252+
}
253+
}
254+
255+
async function main() {
256+
const args = process.argv.slice(2);
257+
const options = {};
258+
for (let i = 0; i < args.length; i++) {
259+
if (args[i] === '--check') options.check = true;
260+
else if (['--sha', '--date', '--source-dir'].includes(args[i]) && args[i + 1]) {
261+
options[args[i].slice(2)] = args[++i];
262+
} else throw new Error(`Unknown or incomplete argument: ${args[i]}`);
263+
}
264+
const root = fileURLToPath(new URL('../../', import.meta.url));
265+
let sha = options.sha;
266+
let paths;
267+
let readSource;
268+
if (options['source-dir']) {
269+
if (!sha) throw new Error('--source-dir requires --sha.');
270+
paths = new Set(await listFiles(options['source-dir']));
271+
readSource = (filename) => readFile(path.join(options['source-dir'], filename));
272+
} else {
273+
if (!sha) {
274+
sha = JSON.parse(text(await fetchBytes(`https://api.github.com/repos/${SOURCE_REPO}/commits/main`))).sha;
275+
}
276+
if (!/^[a-f\d]{40}$/.test(sha)) throw new Error('A full source commit SHA is required.');
277+
const tree = JSON.parse(text(await fetchBytes(`https://api.github.com/repos/${SOURCE_REPO}/git/trees/${sha}?recursive=1`)));
278+
if (tree.truncated) throw new Error('The source tree is truncated.');
279+
paths = new Set(tree.tree.filter((entry) => entry.type === 'blob').map((entry) => entry.path));
280+
readSource = (filename) => fetchBytes(`https://raw.githubusercontent.com/${SOURCE_REPO}/${sha}/${filename}`);
281+
}
282+
const plan = await planImport({
283+
sha, date: options.date ?? new Date().toISOString().slice(0, 10), paths, readSource,
284+
readLocal: (filename) => optionalRead(path.join(root, filename)),
285+
});
286+
if (!options.check) await applyImport(plan, root);
287+
console.log(JSON.stringify({
288+
inspectedSha: plan.sha, committedBaseline: plan.baseline,
289+
pages: plan.pages, assets: plan.assets, changed: plan.changed, deleted: plan.deleted,
290+
appliedToCheckout: !options.check && plan.changed.length > 0,
291+
}, null, 2));
292+
if (options.check && plan.changed.length) process.exitCode = 1;
293+
}
294+
295+
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
296+
main().catch((error) => {
297+
console.error(error);
298+
process.exitCode = 1;
299+
});
300+
}

0 commit comments

Comments
 (0)