diff --git a/.github/scripts/provisioning/__tests__/github-app-auth.test.js b/.github/scripts/provisioning/__tests__/github-app-auth.test.js new file mode 100644 index 00000000..831a7423 --- /dev/null +++ b/.github/scripts/provisioning/__tests__/github-app-auth.test.js @@ -0,0 +1,83 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const crypto = require('node:crypto'); + +const { createAppJwt, mintInstallationToken, base64url } = require('../github-app-auth'); + +const { privateKey, publicKey } = crypto.generateKeyPairSync('rsa', { + modulusLength: 2048, + publicKeyEncoding: { type: 'spki', format: 'pem' }, + privateKeyEncoding: { type: 'pkcs8', format: 'pem' } +}); + +function decodeSegment(seg) { + return JSON.parse(Buffer.from(seg.replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8')); +} + +test('createAppJwt produces a verifiable RS256 token with correct claims', () => { + const now = 1_700_000_000_000; + const jwt = createAppJwt({ appId: '12345', privateKey, now }); + const [headerB64, payloadB64, sigB64] = jwt.split('.'); + const header = decodeSegment(headerB64); + const payload = decodeSegment(payloadB64); + + assert.equal(header.alg, 'RS256'); + assert.equal(payload.iss, '12345'); + assert.equal(payload.iat, Math.floor(now / 1000) - 60); + + const verifier = crypto.createVerify('RSA-SHA256'); + verifier.update(`${headerB64}.${payloadB64}`); + verifier.end(); + const signature = Buffer.from(sigB64.replace(/-/g, '+').replace(/_/g, '/'), 'base64'); + assert.ok(verifier.verify(publicKey, signature)); +}); + +test('createAppJwt rejects lifetimes over 600 seconds', () => { + assert.throws(() => createAppJwt({ appId: '1', privateKey, expiresInSeconds: 601 }), /600/); +}); + +test('createAppJwt requires appId and privateKey', () => { + assert.throws(() => createAppJwt({ privateKey }), /appId/); + assert.throws(() => createAppJwt({ appId: '1' }), /privateKey/); +}); + +test('base64url has no padding or url-unsafe chars', () => { + const out = base64url('hello world??>>'); + assert.equal(/[+/=]/.test(out), false); +}); + +test('mintInstallationToken posts to the right endpoint and returns the token', async () => { + let captured; + const fakeFetch = async (url, opts) => { + captured = { url, opts }; + return { + status: 201, + async json() { + return { token: 'ghs_faketoken', expires_at: '2026-01-01T01:00:00Z' }; + } + }; + }; + const result = await mintInstallationToken({ + appId: '12345', + privateKey, + installationId: '99', + fetchImpl: fakeFetch + }); + assert.equal(result.token, 'ghs_faketoken'); + assert.match(captured.url, /\/app\/installations\/99\/access_tokens$/); + assert.equal(captured.opts.method, 'POST'); + assert.match(captured.opts.headers.Authorization, /^Bearer /); +}); + +test('mintInstallationToken throws a clear error on failure', async () => { + const fakeFetch = async () => ({ + status: 403, + async text() { + return 'forbidden'; + } + }); + await assert.rejects( + () => mintInstallationToken({ appId: '1', privateKey, installationId: '2', fetchImpl: fakeFetch }), + /HTTP 403.*forbidden/ + ); +}); diff --git a/.github/scripts/provisioning/__tests__/github-client.test.js b/.github/scripts/provisioning/__tests__/github-client.test.js new file mode 100644 index 00000000..1fc2d691 --- /dev/null +++ b/.github/scripts/provisioning/__tests__/github-client.test.js @@ -0,0 +1,117 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { createFetchClient, fromOctokit, REQUIRED_WORKFLOWS } = require('../github-client'); + +function fakeFetchFactory(routes) { + return async (url, opts) => { + const method = (opts && opts.method) || 'GET'; + const key = `${method} ${url.replace('https://api.github.com', '')}`; + const handler = routes[key]; + if (!handler) throw new Error(`Unexpected request: ${key}`); + return handler(opts); + }; +} + +test('REQUIRED_WORKFLOWS lists the core automation files', () => { + assert.ok(REQUIRED_WORKFLOWS.includes('pr-validation-bot.yml')); + assert.ok(REQUIRED_WORKFLOWS.includes('student-progression.yml')); +}); + +test('createFetchClient requires a token', () => { + assert.throws(() => createFetchClient({}), /token/); +}); + +test('repoExists returns true on 200 and false on 404', async () => { + const client = createFetchClient({ + token: 't', + fetchImpl: fakeFetchFactory({ + 'GET /repos/o/yes': () => ({ status: 200 }), + 'GET /repos/o/no': () => ({ status: 404 }) + }) + }); + assert.equal(await client.repoExists({ owner: 'o', repo: 'yes' }), true); + assert.equal(await client.repoExists({ owner: 'o', repo: 'no' }), false); +}); + +test('createFromTemplate posts to generate endpoint', async () => { + let body; + const client = createFetchClient({ + token: 't', + fetchImpl: fakeFetchFactory({ + 'POST /repos/o/tmpl/generate': (opts) => { + body = JSON.parse(opts.body); + return { status: 201, async json() { return { full_name: 'o/new' }; } }; + } + }) + }); + const repo = await client.createFromTemplate({ + templateOwner: 'o', + templateRepo: 'tmpl', + owner: 'o', + repo: 'new' + }); + assert.equal(repo.full_name, 'o/new'); + assert.equal(body.private, true); + assert.equal(body.name, 'new'); +}); + +test('ensureCollaborator treats 201 and 204 as success', async () => { + const client = createFetchClient({ + token: 't', + fetchImpl: fakeFetchFactory({ + 'PUT /repos/o/r/collaborators/alice': () => ({ status: 201 }), + 'PUT /repos/o/r/collaborators/bob': () => ({ status: 204 }) + }) + }); + assert.equal((await client.ensureCollaborator({ owner: 'o', repo: 'r', username: 'alice' })).status, 'invited'); + assert.equal((await client.ensureCollaborator({ owner: 'o', repo: 'r', username: 'bob' })).status, 'already-collaborator'); +}); + +test('listWorkflowPaths returns file names and [] on 404', async () => { + const client = createFetchClient({ + token: 't', + fetchImpl: fakeFetchFactory({ + 'GET /repos/o/has/contents/.github/workflows': () => ({ + status: 200, + async json() { + return [ + { type: 'file', name: 'a.yml' }, + { type: 'dir', name: 'sub' } + ]; + } + }), + 'GET /repos/o/none/contents/.github/workflows': () => ({ status: 404 }) + }) + }); + assert.deepEqual(await client.listWorkflowPaths({ owner: 'o', repo: 'has' }), ['a.yml']); + assert.deepEqual(await client.listWorkflowPaths({ owner: 'o', repo: 'none' }), []); +}); + +test('fromOctokit adapts repoExists 404 to false', async () => { + const octokit = { + rest: { + repos: { + get: async () => { + const err = new Error('nf'); + err.status = 404; + throw err; + } + } + } + }; + const client = fromOctokit(octokit); + assert.equal(await client.repoExists({ owner: 'o', repo: 'r' }), false); +}); + +test('fromOctokit listWorkflowPaths filters files and handles 404', async () => { + const octokit = { + rest: { + repos: { + getContent: async () => ({ data: [{ type: 'file', name: 'x.yml' }, { type: 'dir', name: 'd' }] }) + } + } + }; + const client = fromOctokit(octokit); + assert.deepEqual(await client.listWorkflowPaths({ owner: 'o', repo: 'r' }), ['x.yml']); +}); diff --git a/.github/scripts/provisioning/__tests__/progress.test.js b/.github/scripts/provisioning/__tests__/progress.test.js new file mode 100644 index 00000000..84d6cf11 --- /dev/null +++ b/.github/scripts/provisioning/__tests__/progress.test.js @@ -0,0 +1,86 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { + deriveStatus, + isDay2Eligible, + completedChallenges, + closedIssueRefs, + challengeNumberFromTitle, + hasTextSignal +} = require('../progress'); + +test('awaiting-ack when no signals present', () => { + const { status } = deriveStatus({ comments: [], labels: [], issues: [] }); + assert.equal(status, 'awaiting-ack'); +}); + +test('ack text signal moves to active-day1', () => { + const { status, evidence } = deriveStatus({ comments: [{ body: 'ack' }] }); + assert.equal(status, 'active-day1'); + assert.equal(evidence.acked, true); +}); + +test('ack matches as a standalone token only', () => { + assert.equal(hasTextSignal([{ body: 'I acknowledge the rules' }], 'ack'), false); + assert.equal(hasTextSignal([{ body: 'ack!' }], 'ack'), true); + assert.equal(hasTextSignal([{ body: 'Ready: ack' }], 'ack'), true); +}); + +test('day1-complete signal moves to day1-complete', () => { + const { status } = deriveStatus({ comments: [{ body: 'day1-complete' }] }); + assert.equal(status, 'day1-complete'); +}); + +test('day2-only learners are day1-complete by default', () => { + const { status } = deriveStatus({ path: 'day2-only', comments: [] }); + assert.equal(status, 'day1-complete'); +}); + +test('day2-released label takes precedence', () => { + const { status } = deriveStatus({ + comments: [{ body: 'day1-complete' }], + labels: ['day2-released'] + }); + assert.equal(status, 'day2-released'); +}); + +test('needs-info label overrides everything', () => { + const { status } = deriveStatus({ + comments: [{ body: 'day1-complete' }], + labels: [{ name: 'needs-info' }, 'day2-released'] + }); + assert.equal(status, 'needs-info'); +}); + +test('completed challenges from closed issues, deduped and sorted', () => { + const issues = [ + { title: 'Challenge 2: file an issue', state: 'closed' }, + { title: 'Challenge 1: find your way', state: 'closed' }, + { title: 'Challenge 1: find your way', state: 'closed' }, + { title: 'Challenge 3: branch out', state: 'open' } + ]; + assert.deepEqual(completedChallenges(issues), [1, 2]); +}); + +test('a closed challenge makes a silent learner active-day1', () => { + const { status } = deriveStatus({ + issues: [{ title: 'Challenge 1', state: 'closed' }] + }); + assert.equal(status, 'active-day1'); +}); + +test('closedIssueRefs parses closing keywords', () => { + assert.deepEqual(closedIssueRefs('This closes #12 and fixes #34, resolves #12'), [12, 34]); +}); + +test('challengeNumberFromTitle extracts the number', () => { + assert.equal(challengeNumberFromTitle('Challenge 10: capstone'), 10); + assert.equal(challengeNumberFromTitle('No number here'), null); +}); + +test('isDay2Eligible true only at day1-complete', () => { + assert.equal(isDay2Eligible({ comments: [{ body: 'day1-complete' }] }), true); + assert.equal(isDay2Eligible({ comments: [{ body: 'ack' }] }), false); + assert.equal(isDay2Eligible({ labels: ['day2-released'] }), false); +}); diff --git a/.github/scripts/provisioning/__tests__/provision-cli.test.js b/.github/scripts/provisioning/__tests__/provision-cli.test.js new file mode 100644 index 00000000..8b856ad9 --- /dev/null +++ b/.github/scripts/provisioning/__tests__/provision-cli.test.js @@ -0,0 +1,56 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); + +const { parseArgs, readPrivateKey, resolveToken, usage } = require('../provision-cli'); + +test('parseArgs reads roster, log, dry-run, help', () => { + const a = parseArgs(['--roster', 'r.json', '--log', 'l.json', '--dry-run']); + assert.equal(a.roster, 'r.json'); + assert.equal(a.log, 'l.json'); + assert.equal(a.dryRun, true); + assert.equal(parseArgs(['-h']).help, true); +}); + +test('usage mentions both modes', () => { + assert.match(usage(), /github-app/); + assert.match(usage(), /actions-bot/); +}); + +test('readPrivateKey reads @file references', () => { + const tmp = path.join(os.tmpdir(), `key-${Date.now()}.pem`); + fs.writeFileSync(tmp, 'PEMDATA'); + try { + assert.equal(readPrivateKey(`@${tmp}`), 'PEMDATA'); + } finally { + fs.unlinkSync(tmp); + } +}); + +test('readPrivateKey converts literal escaped newlines', () => { + assert.equal(readPrivateKey('line1\\nline2'), 'line1\nline2'); +}); + +test('resolveToken returns PAT in actions-bot mode', async () => { + const token = await resolveToken( + { PROVISIONING_MODE: 'actions-bot', PROVISIONING_TOKEN: 'pat123' }, + 'https://api.github.com' + ); + assert.equal(token, 'pat123'); +}); + +test('resolveToken actions-bot mode requires a token', async () => { + await assert.rejects( + () => resolveToken({ PROVISIONING_MODE: 'actions-bot' }, 'https://api.github.com'), + /PROVISIONING_TOKEN/ + ); +}); + +test('resolveToken rejects unknown mode', async () => { + await assert.rejects( + () => resolveToken({ PROVISIONING_MODE: 'banana' }, 'https://api.github.com'), + /Unknown PROVISIONING_MODE/ + ); +}); diff --git a/.github/scripts/provisioning/__tests__/provision-core.test.js b/.github/scripts/provisioning/__tests__/provision-core.test.js new file mode 100644 index 00000000..ed78981e --- /dev/null +++ b/.github/scripts/provisioning/__tests__/provision-core.test.js @@ -0,0 +1,171 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { provisionRoster, defaultRepoName, missingWorkflows, isSecondaryRateLimit } = require('../provision-core'); +const { upsertLearner, emptyRoster } = require('../roster'); + +const REQUIRED = ['pr-validation-bot.yml', 'student-progression.yml', 'content-validation.yml']; + +function makeClient(overrides = {}) { + const state = { + repos: new Map(), // repoName -> { workflows: [...] } + calls: { create: 0, collaborator: 0, list: 0 } + }; + const client = { + async repoExists({ repo }) { + return state.repos.has(repo); + }, + async createFromTemplate({ repo }) { + state.calls.create += 1; + state.repos.set(repo, { workflows: [...REQUIRED] }); + return { full_name: repo }; + }, + async ensureCollaborator() { + state.calls.collaborator += 1; + return { status: 'invited' }; + }, + async listWorkflowPaths({ repo }) { + state.calls.list += 1; + const r = state.repos.get(repo); + return r ? r.workflows : []; + }, + async getDefaultBranchSha() { + return 'abc123'; + }, + ...overrides + }; + return { client, state }; +} + +const noSleep = async () => {}; +const config = { + studentOwner: 'Community-Access', + templateOwner: 'Community-Access', + templateRepo: 'learning-room-template', + requiredWorkflows: REQUIRED, + delayMs: 0 +}; + +function rosterWith(...learners) { + let r = emptyRoster(); + for (const l of learners) r = upsertLearner(r, l); + return r; +} + +test('defaultRepoName is deterministic and sanitized', () => { + assert.equal(defaultRepoName('Octo.Cat', 'Spring 2026'), 'learning-room-spring-2026-octo-cat'); +}); + +test('missingWorkflows is case-insensitive', () => { + assert.deepEqual(missingWorkflows(['A.yml', 'B.yml'], ['a.yml']), ['B.yml']); +}); + +test('provisions a new learner end to end', async () => { + const { client, state } = makeClient(); + const roster = rosterWith({ github_handle: 'alice', cohort_id: 'c1' }); + const { roster: out, log, summary } = await provisionRoster({ roster, client, config, sleep: noSleep }); + + assert.equal(out.learners[0].provision_state, 'provisioned'); + assert.equal(out.learners[0].learning_room_repo, 'Community-Access/learning-room-c1-alice'); + assert.equal(state.calls.create, 1); + assert.equal(state.calls.collaborator, 1); + assert.equal(summary.created, 1); + assert.equal(log[0].result, 'created'); + assert.equal(log[0].template_sha, 'abc123'); +}); + +test('idempotent: existing complete repo is already-exists, no create', async () => { + const { client, state } = makeClient(); + state.repos.set('learning-room-c1-alice', { workflows: [...REQUIRED] }); + const roster = rosterWith({ github_handle: 'alice', cohort_id: 'c1' }); + const { roster: out, summary } = await provisionRoster({ roster, client, config, sleep: noSleep }); + + assert.equal(state.calls.create, 0); + assert.equal(out.learners[0].provision_state, 'provisioned'); + assert.equal(summary.already_exists, 1); +}); + +test('heals an incomplete repo when seedContent is available', async () => { + let seeded = false; + const { client, state } = makeClient({ + async seedContent({ repo }) { + seeded = true; + state.repos.get(repo).workflows = [...REQUIRED]; + } + }); + state.repos.set('learning-room-c1-bob', { workflows: ['pr-validation-bot.yml'] }); + const roster = rosterWith({ github_handle: 'bob', cohort_id: 'c1' }); + const { roster: out, summary } = await provisionRoster({ roster, client, config, sleep: noSleep }); + + assert.ok(seeded); + assert.equal(out.learners[0].provision_state, 'healed'); + assert.equal(summary.healed, 1); +}); + +test('fails loudly when repo exists but incomplete and cannot heal', async () => { + const { client, state } = makeClient(); + state.repos.set('learning-room-c1-carol', { workflows: ['pr-validation-bot.yml'] }); + const roster = rosterWith({ github_handle: 'carol', cohort_id: 'c1' }); + const { roster: out, log, summary } = await provisionRoster({ roster, client, config, sleep: noSleep }); + + assert.equal(out.learners[0].provision_state, 'failed'); + assert.equal(summary.error, 1); + assert.match(log[0].error_detail, /missing required workflows/); +}); + +test('retries on secondary rate limit then succeeds', async () => { + let attempts = 0; + const { client } = makeClient({ + async repoExists() { + attempts += 1; + if (attempts === 1) { + const err = new Error('HTTP 403 secondary rate limit'); + err.status = 403; + throw err; + } + return false; + } + }); + const roster = rosterWith({ github_handle: 'dave', cohort_id: 'c1' }); + const sleeps = []; + const { roster: out } = await provisionRoster({ + roster, + client, + config, + sleep: async (ms) => sleeps.push(ms) + }); + + assert.equal(out.learners[0].provision_state, 'provisioned'); + assert.ok(sleeps.includes(1000)); +}); + +test('a failure does not stop the batch; others still provision', async () => { + let firstChecked = false; + const { client, state } = makeClient({ + async repoExists({ repo }) { + if (repo.endsWith('-eve') && !firstChecked) { + firstChecked = true; + throw new Error('boom'); + } + return state.repos.has(repo); + } + }); + const roster = rosterWith( + { github_handle: 'eve', cohort_id: 'c1' }, + { github_handle: 'frank', cohort_id: 'c1' } + ); + const { roster: out, summary } = await provisionRoster({ roster, client, config, sleep: noSleep }); + + const eve = out.learners.find((l) => l.github_handle === 'eve'); + const frank = out.learners.find((l) => l.github_handle === 'frank'); + assert.equal(eve.provision_state, 'failed'); + assert.equal(frank.provision_state, 'provisioned'); + assert.equal(summary.error, 1); + assert.equal(summary.created, 1); +}); + +test('isSecondaryRateLimit detects status and message forms', () => { + assert.ok(isSecondaryRateLimit({ status: 429 })); + assert.ok(isSecondaryRateLimit({ message: 'oops HTTP 403 here' })); + assert.equal(isSecondaryRateLimit({ message: 'HTTP 404' }), false); +}); diff --git a/.github/scripts/provisioning/__tests__/roster.test.js b/.github/scripts/provisioning/__tests__/roster.test.js new file mode 100644 index 00000000..31867dfd --- /dev/null +++ b/.github/scripts/provisioning/__tests__/roster.test.js @@ -0,0 +1,109 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { + emptyRoster, + parseRoster, + upsertLearner, + serializeRoster, + findLearner, + entriesToProvision, + isValidHandle, + normalizeHandle, + learnerKey, + publicView, + validateLearner +} = require('../roster'); + +test('parseRoster returns empty roster for empty input', () => { + const r = parseRoster(''); + assert.equal(r.version, 1); + assert.deepEqual(r.learners, []); +}); + +test('parseRoster validates and rejects bad version', () => { + assert.throws(() => parseRoster(JSON.stringify({ version: 2, learners: [] })), /version/); +}); + +test('parseRoster rejects duplicate learner keys', () => { + const data = { + version: 1, + learners: [ + { github_handle: 'alice', cohort_id: 'c1', path: 'day1-day2', provision_state: 'pending', status: 'awaiting-ack' }, + { github_handle: 'Alice', cohort_id: 'c1', path: 'day1-day2', provision_state: 'pending', status: 'awaiting-ack' } + ] + }; + assert.throws(() => parseRoster(JSON.stringify(data)), /Duplicate/); +}); + +test('isValidHandle accepts valid and rejects invalid handles', () => { + assert.ok(isValidHandle('octo-cat')); + assert.ok(isValidHandle('@octocat')); + assert.equal(isValidHandle('-bad'), false); + assert.equal(isValidHandle('bad-'), false); + assert.equal(isValidHandle('two--hyphens'), false); + assert.equal(isValidHandle(''), false); +}); + +test('normalizeHandle strips @ and lowercases', () => { + assert.equal(normalizeHandle('@OctoCat'), 'octocat'); +}); + +test('learnerKey is case-insensitive on handle', () => { + assert.equal(learnerKey('Alice', 'c1'), learnerKey('alice', 'c1')); +}); + +test('upsertLearner inserts then updates without mutating input', () => { + const r0 = emptyRoster(); + const r1 = upsertLearner(r0, { github_handle: 'alice', cohort_id: 'c1' }); + assert.equal(r0.learners.length, 0); + assert.equal(r1.learners.length, 1); + assert.equal(r1.learners[0].provision_state, 'pending'); + + const r2 = upsertLearner(r1, { github_handle: 'alice', cohort_id: 'c1', provision_state: 'provisioned' }); + assert.equal(r2.learners.length, 1); + assert.equal(r2.learners[0].provision_state, 'provisioned'); +}); + +test('upsertLearner rejects invalid handle', () => { + assert.throws(() => upsertLearner(emptyRoster(), { github_handle: '-bad', cohort_id: 'c1' }), /handle/); +}); + +test('findLearner locates by key', () => { + const r = upsertLearner(emptyRoster(), { github_handle: 'Bob', cohort_id: 'c2' }); + assert.ok(findLearner(r, 'bob', 'c2')); + assert.equal(findLearner(r, 'bob', 'cX'), null); +}); + +test('entriesToProvision returns pending and failed only', () => { + let r = emptyRoster(); + r = upsertLearner(r, { github_handle: 'a', cohort_id: 'c', provision_state: 'pending' }); + r = upsertLearner(r, { github_handle: 'b', cohort_id: 'c', provision_state: 'failed' }); + r = upsertLearner(r, { github_handle: 'c', cohort_id: 'c', provision_state: 'provisioned' }); + const targets = entriesToProvision(r); + assert.equal(targets.length, 2); +}); + +test('serializeRoster sorts learners and stamps generated_at', () => { + let r = emptyRoster(); + r = upsertLearner(r, { github_handle: 'zed', cohort_id: 'c1' }); + r = upsertLearner(r, { github_handle: 'amy', cohort_id: 'c1' }); + const out = serializeRoster(r, { now: new Date('2026-01-01T00:00:00Z') }); + const parsed = JSON.parse(out); + assert.equal(parsed.learners[0].github_handle, 'amy'); + assert.equal(parsed.generated_at, '2026-01-01T00:00:00.000Z'); + assert.ok(out.endsWith('\n')); +}); + +test('publicView omits notes and extra fields', () => { + let r = emptyRoster(); + r = upsertLearner(r, { github_handle: 'a', cohort_id: 'c', notes: 'secret note' }); + const view = publicView(r); + assert.equal(view.learners[0].notes, undefined); + assert.equal(view.learners[0].github_handle, 'a'); +}); + +test('validateLearner reports each problem', () => { + const problems = validateLearner({ github_handle: '-x', cohort_id: '', path: 'nope', provision_state: 'nope', status: 'nope' }); + assert.ok(problems.length >= 4); +}); diff --git a/.github/scripts/provisioning/github-app-auth.js b/.github/scripts/provisioning/github-app-auth.js new file mode 100644 index 00000000..3f501cc5 --- /dev/null +++ b/.github/scripts/provisioning/github-app-auth.js @@ -0,0 +1,97 @@ +/** + * GitHub App authentication helpers, implemented with Node's built-in `crypto` + * so the provisioning subsystem needs no extra npm dependencies. See SPEC.md + * section 7.2a. + * + * A GitHub App is the production identity for provisioning because it is not tied + * to a human account, mints short-lived installation tokens, and uses fine-grained + * least-privilege permissions. This module turns an App ID plus a PEM private key + * into a short-lived installation token, fetched fresh on every provisioning run + * and never persisted. + */ + +'use strict'; + +const crypto = require('node:crypto'); + +function base64url(input) { + return Buffer.from(input) + .toString('base64') + .replace(/=/g, '') + .replace(/\+/g, '-') + .replace(/\//g, '_'); +} + +/** + * Create a signed RS256 JWT for a GitHub App. Valid for `expiresInSeconds` + * (max 600 per GitHub) with a 60s backdated `iat` to tolerate clock drift. + */ +function createAppJwt({ appId, privateKey, now = Date.now(), expiresInSeconds = 540 }) { + if (!appId) throw new Error('createAppJwt requires appId'); + if (!privateKey) throw new Error('createAppJwt requires privateKey (PEM)'); + if (expiresInSeconds > 600) { + throw new Error('GitHub App JWT lifetime must not exceed 600 seconds'); + } + const iat = Math.floor(now / 1000) - 60; + const header = { alg: 'RS256', typ: 'JWT' }; + const payload = { iat, exp: iat + expiresInSeconds + 60, iss: String(appId) }; + const signingInput = `${base64url(JSON.stringify(header))}.${base64url( + JSON.stringify(payload) + )}`; + const signer = crypto.createSign('RSA-SHA256'); + signer.update(signingInput); + signer.end(); + const signature = signer + .sign(privateKey) + .toString('base64') + .replace(/=/g, '') + .replace(/\+/g, '-') + .replace(/\//g, '_'); + return `${signingInput}.${signature}`; +} + +/** + * Exchange an App JWT for a short-lived installation access token. + * Uses global fetch (Node 18+). Returns { token, expires_at }. + */ +async function mintInstallationToken({ + appId, + privateKey, + installationId, + apiBaseUrl = 'https://api.github.com', + fetchImpl = fetch, + now = Date.now() +}) { + if (!installationId) throw new Error('mintInstallationToken requires installationId'); + const jwt = createAppJwt({ appId, privateKey, now }); + const res = await fetchImpl( + `${apiBaseUrl}/app/installations/${installationId}/access_tokens`, + { + method: 'POST', + headers: { + Authorization: `Bearer ${jwt}`, + Accept: 'application/vnd.github+json', + 'X-GitHub-Api-Version': '2022-11-28', + 'User-Agent': 'glow-provisioning' + } + } + ); + if (res.status !== 201) { + const detail = await safeText(res); + throw new Error( + `Failed to mint installation token (HTTP ${res.status}): ${detail}` + ); + } + const body = await res.json(); + return { token: body.token, expires_at: body.expires_at }; +} + +async function safeText(res) { + try { + return await res.text(); + } catch (err) { + return ``; + } +} + +module.exports = { createAppJwt, mintInstallationToken, base64url }; diff --git a/.github/scripts/provisioning/github-client.js b/.github/scripts/provisioning/github-client.js new file mode 100644 index 00000000..d25896dd --- /dev/null +++ b/.github/scripts/provisioning/github-client.js @@ -0,0 +1,179 @@ +/** + * Minimal GitHub REST client for the provisioning subsystem. + * + * Implements exactly the operations the idempotent provisioning algorithm needs + * (SPEC.md section 7.2b) and nothing more, so the surface stays auditable. Two + * constructors are provided: + * + * - createFetchClient(...) uses global fetch + a bearer token (App installation + * token or a fine-grained PAT). This is what the CLI uses. + * - fromOctokit(octokit) wraps the `github` client that actions/github-script + * injects, so the workflow path needs no extra dependency. + * + * Every method is idempotent-friendly: existence checks return booleans rather + * than throwing, so the algorithm can branch on state instead of on exceptions. + */ + +'use strict'; + +const REQUIRED_WORKFLOWS = Object.freeze([ + 'pr-validation-bot.yml', + 'student-progression.yml', + 'content-validation.yml' +]); + +function createFetchClient({ + token, + apiBaseUrl = 'https://api.github.com', + fetchImpl = fetch, + userAgent = 'glow-provisioning' +}) { + if (!token) throw new Error('createFetchClient requires a token'); + + async function request(method, path, body) { + const res = await fetchImpl(`${apiBaseUrl}${path}`, { + method, + headers: { + Authorization: `token ${token}`, + Accept: 'application/vnd.github+json', + 'X-GitHub-Api-Version': '2022-11-28', + 'User-Agent': userAgent, + ...(body ? { 'Content-Type': 'application/json' } : {}) + }, + body: body ? JSON.stringify(body) : undefined + }); + return res; + } + + return { + async repoExists({ owner, repo }) { + const res = await request('GET', `/repos/${owner}/${repo}`); + if (res.status === 200) return true; + if (res.status === 404) return false; + throw new Error(`repoExists failed (HTTP ${res.status}) for ${owner}/${repo}`); + }, + + async createFromTemplate({ templateOwner, templateRepo, owner, repo, isPrivate = true }) { + const res = await request( + 'POST', + `/repos/${templateOwner}/${templateRepo}/generate`, + { + owner, + name: repo, + private: isPrivate, + include_all_branches: false + } + ); + if (res.status === 201) return res.json(); + const detail = await res.text(); + throw new Error( + `createFromTemplate failed (HTTP ${res.status}) for ${owner}/${repo}: ${detail}` + ); + }, + + async ensureCollaborator({ owner, repo, username, permission = 'push' }) { + const res = await request( + 'PUT', + `/repos/${owner}/${repo}/collaborators/${username}`, + { permission } + ); + // 201 = invitation created, 204 = already a collaborator. Both are success. + if (res.status === 201 || res.status === 204) { + return { status: res.status === 201 ? 'invited' : 'already-collaborator' }; + } + const detail = await res.text(); + throw new Error( + `ensureCollaborator failed (HTTP ${res.status}) for ${username} on ${owner}/${repo}: ${detail}` + ); + }, + + async listWorkflowPaths({ owner, repo }) { + const res = await request( + 'GET', + `/repos/${owner}/${repo}/contents/.github/workflows` + ); + if (res.status === 404) return []; + if (res.status !== 200) { + throw new Error(`listWorkflowPaths failed (HTTP ${res.status}) for ${owner}/${repo}`); + } + const entries = await res.json(); + return entries.filter((e) => e.type === 'file').map((e) => e.name); + }, + + async getDefaultBranchSha({ owner, repo }) { + const repoRes = await request('GET', `/repos/${owner}/${repo}`); + if (repoRes.status !== 200) { + throw new Error(`getDefaultBranchSha failed (HTTP ${repoRes.status}) for ${owner}/${repo}`); + } + const meta = await repoRes.json(); + const branch = meta.default_branch || 'main'; + const refRes = await request( + 'GET', + `/repos/${owner}/${repo}/git/ref/heads/${branch}` + ); + if (refRes.status !== 200) return null; + const ref = await refRes.json(); + return ref.object ? ref.object.sha : null; + } + }; +} + +/** + * Adapt the Octokit instance that actions/github-script injects as `github` to + * the client interface above. Lets the workflow reuse the algorithm verbatim. + */ +function fromOctokit(octokit) { + return { + async repoExists({ owner, repo }) { + try { + await octokit.rest.repos.get({ owner, repo }); + return true; + } catch (err) { + if (err.status === 404) return false; + throw err; + } + }, + async createFromTemplate({ templateOwner, templateRepo, owner, repo, isPrivate = true }) { + const res = await octokit.rest.repos.createUsingTemplate({ + template_owner: templateOwner, + template_repo: templateRepo, + owner, + name: repo, + private: isPrivate, + include_all_branches: false + }); + return res.data; + }, + async ensureCollaborator({ owner, repo, username, permission = 'push' }) { + const res = await octokit.rest.repos.addCollaborator({ owner, repo, username, permission }); + return { status: res.status === 201 ? 'invited' : 'already-collaborator' }; + }, + async listWorkflowPaths({ owner, repo }) { + try { + const res = await octokit.rest.repos.getContent({ + owner, + repo, + path: '.github/workflows' + }); + const entries = Array.isArray(res.data) ? res.data : []; + return entries.filter((e) => e.type === 'file').map((e) => e.name); + } catch (err) { + if (err.status === 404) return []; + throw err; + } + }, + async getDefaultBranchSha({ owner, repo }) { + const meta = await octokit.rest.repos.get({ owner, repo }); + const branch = meta.data.default_branch || 'main'; + try { + const ref = await octokit.rest.git.getRef({ owner, repo, ref: `heads/${branch}` }); + return ref.data.object.sha; + } catch (err) { + if (err.status === 404) return null; + throw err; + } + } + }; +} + +module.exports = { createFetchClient, fromOctokit, REQUIRED_WORKFLOWS }; diff --git a/.github/scripts/provisioning/progress.js b/.github/scripts/provisioning/progress.js new file mode 100644 index 00000000..a2d15f06 --- /dev/null +++ b/.github/scripts/provisioning/progress.js @@ -0,0 +1,141 @@ +/** + * Owned progress of record for the GLOW workshop. + * + * Progress is never authored by a vendor. It is *derived* from deterministic + * signals the project controls: challenge issue state, PR closing keywords, + * labels, and the plain-text signals `ack` and `day1-complete`. See SPEC.md + * section 6.2. Re-running this over the same signals yields the same status, + * so the roster status column is always reconstructable. + * + * Pure and synchronous: the caller supplies an already-collected snapshot of + * signals (typically gathered by the workflow via the GitHub API) and gets back + * a derived status plus the evidence behind it. + */ + +'use strict'; + +const CHALLENGE_TITLE_RE = /challenge\s+(\d+)/i; +const CLOSES_RE = /\b(?:closes|fixes|resolves)\s+#(\d+)/gi; + +function hasTextSignal(comments, signal) { + const needle = String(signal).toLowerCase(); + return (comments || []).some((c) => { + const body = String((c && c.body) || '').toLowerCase(); + // Match the signal as a standalone token so "acknowledge" never fires "ack". + return new RegExp(`(^|[^a-z0-9-])${escapeRegExp(needle)}([^a-z0-9-]|$)`).test(body); + }); +} + +function escapeRegExp(value) { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function hasLabel(labels, name) { + const target = String(name).toLowerCase(); + return (labels || []).some((l) => { + const value = typeof l === 'string' ? l : (l && l.name); + return String(value || '').toLowerCase() === target; + }); +} + +function challengeNumberFromTitle(title) { + const match = String(title || '').match(CHALLENGE_TITLE_RE); + return match ? Number(match[1]) : null; +} + +/** + * Count distinct completed (closed) challenge issues from an issues snapshot. + * snapshot.issues: array of { title, state, labels }. + */ +function completedChallenges(issues) { + const done = new Set(); + for (const issue of issues || []) { + if (String(issue.state).toLowerCase() !== 'closed') continue; + const n = challengeNumberFromTitle(issue.title); + if (n !== null) done.add(n); + } + return [...done].sort((a, b) => a - b); +} + +/** + * Extract issue numbers a PR body closes via closing keywords. + */ +function closedIssueRefs(prBody) { + const refs = new Set(); + let match; + const re = new RegExp(CLOSES_RE); + while ((match = re.exec(String(prBody || ''))) !== null) { + refs.add(Number(match[1])); + } + return [...refs]; +} + +/** + * Derive a learner's status from a signals snapshot. + * + * snapshot = { + * path: 'day1-day2' | 'day2-only', + * comments: [{ body }], // issue/PR comments in the learner repo + * labels: [{ name } | string], // labels on the enrollment/tracking issue + * issues: [{ title, state, labels }] + * } + * + * Returns { status, evidence: { acked, day1Complete, day2Released, completedChallenges } }. + * The precedence intentionally walks the journey forward, latest milestone wins. + */ +function deriveStatus(snapshot) { + const path = snapshot.path || 'day1-day2'; + const comments = snapshot.comments || []; + const labels = snapshot.labels || []; + const issues = snapshot.issues || []; + + const acked = hasTextSignal(comments, 'ack') || hasLabel(labels, 'acked'); + const day1Complete = + hasTextSignal(comments, 'day1-complete') || hasLabel(labels, 'day1-complete'); + const day2Released = hasLabel(labels, 'day2-released'); + const needsInfo = hasLabel(labels, 'needs-info'); + const done = completedChallenges(issues); + + let status; + if (needsInfo) { + status = 'needs-info'; + } else if (day2Released) { + status = 'day2-released'; + } else if (day1Complete || path === 'day2-only') { + status = 'day1-complete'; + } else if (acked || done.length > 0) { + status = 'active-day1'; + } else { + status = 'awaiting-ack'; + } + + return { + status, + evidence: { + acked, + day1Complete, + day2Released, + needsInfo, + completedChallenges: done + } + }; +} + +/** + * Whether a learner is eligible for the Day 2 release. Eligible when Day 1 is + * complete (or they are a day2-only learner) and Day 2 has not been released yet. + */ +function isDay2Eligible(snapshot) { + const { status } = deriveStatus(snapshot); + return status === 'day1-complete'; +} + +module.exports = { + deriveStatus, + isDay2Eligible, + completedChallenges, + closedIssueRefs, + challengeNumberFromTitle, + hasTextSignal, + hasLabel +}; diff --git a/.github/scripts/provisioning/provision-cli.js b/.github/scripts/provisioning/provision-cli.js new file mode 100644 index 00000000..e66f875d --- /dev/null +++ b/.github/scripts/provisioning/provision-cli.js @@ -0,0 +1,168 @@ +#!/usr/bin/env node +/** + * Provisioning CLI: the standalone runner for the owned, GitHub-native + * provisioning subsystem. Reads the roster of record, provisions every + * pending/failed learner idempotently, writes the roster back, and appends to + * the provisioning log. See SPEC.md sections 7 and 16, and admin/OWNED_PROVISIONING.md. + * + * Modes (PROVISIONING_MODE): + * github-app (production) mints a short-lived installation token from + * PROVISIONING_APP_ID + PROVISIONING_APP_PRIVATE_KEY + * + PROVISIONING_APP_INSTALLATION_ID. + * actions-bot (Phase 1 spike only) uses PROVISIONING_TOKEN, a least-privilege + * fine-grained PAT. + * + * Usage: + * node provision-cli.js --roster path/to/roster.json [--log path/to/log.json] [--dry-run] + * + * No third-party dependencies: uses Node built-ins and global fetch. + */ + +'use strict'; + +const fs = require('node:fs'); +const path = require('node:path'); + +const { parseRoster, serializeRoster } = require('./roster'); +const { provisionRoster } = require('./provision-core'); +const { createFetchClient, REQUIRED_WORKFLOWS } = require('./github-client'); +const { mintInstallationToken } = require('./github-app-auth'); + +function parseArgs(argv) { + const args = { dryRun: false }; + for (let i = 0; i < argv.length; i += 1) { + const a = argv[i]; + if (a === '--roster') args.roster = argv[++i]; + else if (a === '--log') args.log = argv[++i]; + else if (a === '--dry-run') args.dryRun = true; + else if (a === '--help' || a === '-h') args.help = true; + } + return args; +} + +function usage() { + return [ + 'Usage: node provision-cli.js --roster [--log ] [--dry-run]', + '', + 'Environment:', + ' PROVISIONING_MODE github-app | actions-bot', + ' LEARNING_ROOM_TEMPLATE_REPO owner/name of the template (required)', + ' PROVISIONING_STUDENT_OWNER org/user that owns student repos (required)', + ' GITHUB_API_URL default https://api.github.com', + ' github-app mode:', + ' PROVISIONING_APP_ID', + ' PROVISIONING_APP_PRIVATE_KEY (PEM, or @path to a PEM file)', + ' PROVISIONING_APP_INSTALLATION_ID', + ' actions-bot mode:', + ' PROVISIONING_TOKEN' + ].join('\n'); +} + +function readPrivateKey(value) { + if (!value) return value; + if (value.startsWith('@')) { + return fs.readFileSync(value.slice(1), 'utf8'); + } + // Support keys passed with literal \n escapes (common in CI secrets). + return value.includes('\\n') && !value.includes('\n') + ? value.replace(/\\n/g, '\n') + : value; +} + +async function resolveToken(env, apiBaseUrl) { + const mode = env.PROVISIONING_MODE || 'github-app'; + if (mode === 'actions-bot') { + if (!env.PROVISIONING_TOKEN) throw new Error('actions-bot mode requires PROVISIONING_TOKEN'); + return env.PROVISIONING_TOKEN; + } + if (mode === 'github-app') { + const { token } = await mintInstallationToken({ + appId: env.PROVISIONING_APP_ID, + privateKey: readPrivateKey(env.PROVISIONING_APP_PRIVATE_KEY), + installationId: env.PROVISIONING_APP_INSTALLATION_ID, + apiBaseUrl + }); + return token; + } + throw new Error(`Unknown PROVISIONING_MODE: ${mode}`); +} + +async function main() { + const args = parseArgs(process.argv.slice(2)); + if (args.help || !args.roster) { + process.stdout.write(`${usage()}\n`); + process.exit(args.help ? 0 : 2); + } + + const env = process.env; + const apiBaseUrl = env.GITHUB_API_URL || 'https://api.github.com'; + const templateRepoFull = env.LEARNING_ROOM_TEMPLATE_REPO; + const studentOwner = env.PROVISIONING_STUDENT_OWNER; + if (!templateRepoFull) throw new Error('LEARNING_ROOM_TEMPLATE_REPO is required'); + if (!studentOwner) throw new Error('PROVISIONING_STUDENT_OWNER is required'); + const [templateOwner, templateRepo] = templateRepoFull.split('/'); + if (!templateOwner || !templateRepo) { + throw new Error('LEARNING_ROOM_TEMPLATE_REPO must be owner/name'); + } + + const rosterJson = fs.readFileSync(args.roster, 'utf8'); + const roster = parseRoster(rosterJson); + + if (args.dryRun) { + const { entriesToProvision } = require('./roster'); + const targets = entriesToProvision(roster); + process.stdout.write( + `Dry run: ${targets.length} learner(s) would be provisioned:\n` + + targets.map((t) => ` - ${t.github_handle} (${t.cohort_id})`).join('\n') + + '\n' + ); + return; + } + + const token = await resolveToken(env, apiBaseUrl); + const client = createFetchClient({ token, apiBaseUrl }); + + const { roster: updated, log, summary } = await provisionRoster({ + roster, + client, + config: { + studentOwner, + templateOwner, + templateRepo, + requiredWorkflows: REQUIRED_WORKFLOWS + }, + logger: (msg) => process.stdout.write(`${msg}\n`) + }); + + fs.writeFileSync(args.roster, serializeRoster(updated)); + + if (args.log) { + appendLog(args.log, log); + } + + process.stdout.write(`\nProvisioning summary: ${JSON.stringify(summary)}\n`); + if (summary.error > 0) { + process.exitCode = 1; + } +} + +function appendLog(logPath, entries) { + let existing = []; + if (fs.existsSync(logPath)) { + const raw = fs.readFileSync(logPath, 'utf8').trim(); + if (raw) existing = JSON.parse(raw); + } else { + fs.mkdirSync(path.dirname(logPath), { recursive: true }); + } + const merged = existing.concat(entries); + fs.writeFileSync(logPath, `${JSON.stringify(merged, null, 2)}\n`); +} + +if (require.main === module) { + main().catch((err) => { + process.stderr.write(`Provisioning failed: ${err.message}\n`); + process.exit(1); + }); +} + +module.exports = { parseArgs, resolveToken, readPrivateKey, usage }; diff --git a/.github/scripts/provisioning/provision-core.js b/.github/scripts/provisioning/provision-core.js new file mode 100644 index 00000000..0fbf7070 --- /dev/null +++ b/.github/scripts/provisioning/provision-core.js @@ -0,0 +1,253 @@ +/** + * Idempotent provisioning core for the GLOW workshop: the GitHub-native + * replacement for GitHub Classroom. Implements the serial, backoff, idempotent + * algorithm in SPEC.md section 7.2b. + * + * This module is pure orchestration. It takes an injected `client` (see + * github-client.js) so it can run against the real GitHub API from the workflow + * or the CLI, and against a fake client in unit tests. It never reads secrets, + * never touches the network directly, and never persists state itself: it + * returns an updated roster plus a provisioning log for the caller to store. + */ + +'use strict'; + +const { entriesToProvision, learnerKey } = require('./roster'); +const { REQUIRED_WORKFLOWS } = require('./github-client'); + +function defaultRepoName(handle, cohortId) { + const safeHandle = String(handle).toLowerCase().replace(/[^a-z0-9-]/g, '-'); + const safeCohort = String(cohortId).toLowerCase().replace(/[^a-z0-9-]/g, '-'); + return `learning-room-${safeCohort}-${safeHandle}`; +} + +function isSecondaryRateLimit(err) { + if (!err) return false; + if (err.status === 403 || err.status === 429) return true; + return /HTTP (403|429)/.test(String(err.message || '')); +} + +const sleepReal = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +/** + * Provision (or heal) every pending/failed learner in the roster. + * + * config = { + * studentOwner, // org/user that owns student repos + * templateOwner, + * templateRepo, + * requiredWorkflows, // defaults to REQUIRED_WORKFLOWS + * repoNameFor, // (handle, cohortId) => name; defaults to defaultRepoName + * delayMs, // delay between entries (default 1500) + * maxRetries, // backoff attempts on secondary rate limit (default 5) + * collaboratorPermission // default 'push' + * } + * + * Returns { roster (updated), log: [entries], summary }. + */ +async function provisionRoster({ + roster, + client, + config = {}, + sleep = sleepReal, + now = () => new Date(), + logger = () => {} +}) { + const studentOwner = required(config, 'studentOwner'); + const templateOwner = required(config, 'templateOwner'); + const templateRepo = required(config, 'templateRepo'); + const requiredWorkflows = config.requiredWorkflows || REQUIRED_WORKFLOWS; + const repoNameFor = config.repoNameFor || defaultRepoName; + const delayMs = config.delayMs == null ? 1500 : config.delayMs; + const maxRetries = config.maxRetries == null ? 5 : config.maxRetries; + const permission = config.collaboratorPermission || 'push'; + + const log = []; + const targets = entriesToProvision(roster); + + for (let i = 0; i < targets.length; i += 1) { + const learner = targets[i]; + const handle = learner.github_handle; + const cohortId = learner.cohort_id; + const repoName = repoNameFor(handle, cohortId); + const repo = `${studentOwner}/${repoName}`; + const attemptAt = now().toISOString(); + + let result; + try { + result = await withRateLimitRetry( + () => + provisionOne({ + client, + handle, + studentOwner, + templateOwner, + templateRepo, + repoName, + requiredWorkflows, + permission + }), + { maxRetries, sleep, logger } + ); + } catch (err) { + learner.provision_state = 'failed'; + learner.learning_room_repo = repo; + const entry = { + github_handle: handle, + cohort_id: cohortId, + attempt_at: attemptAt, + result: 'error', + repo, + template_sha: null, + error_detail: String(err && err.message ? err.message : err) + }; + log.push(entry); + logger(`FAILED ${learnerKey(handle, cohortId)}: ${entry.error_detail}`); + if (i < targets.length - 1) await sleep(delayMs); + continue; + } + + learner.provision_state = result.healed ? 'healed' : 'provisioned'; + learner.learning_room_repo = repo; + log.push({ + github_handle: handle, + cohort_id: cohortId, + attempt_at: attemptAt, + result: result.result, + repo, + template_sha: result.templateSha, + error_detail: null + }); + logger(`OK ${learnerKey(handle, cohortId)}: ${result.result} -> ${repo}`); + + if (i < targets.length - 1) await sleep(delayMs); + } + + const summary = summarize(log); + return { roster, log, summary }; +} + +async function provisionOne({ + client, + handle, + studentOwner, + templateOwner, + templateRepo, + repoName, + requiredWorkflows, + permission +}) { + const exists = await client.repoExists({ owner: studentOwner, repo: repoName }); + + let result = 'created'; + let healed = false; + + if (exists) { + const present = await client.listWorkflowPaths({ owner: studentOwner, repo: repoName }); + const missing = missingWorkflows(requiredWorkflows, present); + if (missing.length === 0) { + result = 'already-exists'; + } else if (typeof client.seedContent === 'function') { + await client.seedContent({ + owner: studentOwner, + repo: repoName, + templateOwner, + templateRepo, + missing + }); + healed = true; + result = 'healed'; + } else { + throw new Error( + `Repo ${studentOwner}/${repoName} exists but is missing required workflows: ${missing.join(', ')}` + ); + } + } else { + await client.createFromTemplate({ + templateOwner, + templateRepo, + owner: studentOwner, + repo: repoName, + isPrivate: true + }); + result = 'created'; + } + + await client.ensureCollaborator({ + owner: studentOwner, + repo: repoName, + username: handle, + permission + }); + + // Final verification gate: required workflows must be present. + const present = await client.listWorkflowPaths({ owner: studentOwner, repo: repoName }); + const stillMissing = missingWorkflows(requiredWorkflows, present); + if (stillMissing.length > 0) { + throw new Error( + `Verification failed for ${studentOwner}/${repoName}: missing ${stillMissing.join(', ')}` + ); + } + + let templateSha = null; + if (typeof client.getDefaultBranchSha === 'function') { + templateSha = await client.getDefaultBranchSha({ owner: studentOwner, repo: repoName }); + } + + return { result, healed, templateSha }; +} + +async function withRateLimitRetry(fn, { maxRetries, sleep, logger }) { + let attempt = 0; + // eslint-disable-next-line no-constant-condition + while (true) { + try { + return await fn(); + } catch (err) { + if (isSecondaryRateLimit(err) && attempt < maxRetries) { + const backoff = Math.min(60000, 1000 * 2 ** attempt); + attempt += 1; + logger(`Secondary rate limit hit; backing off ${backoff}ms (attempt ${attempt})`); + await sleep(backoff); + continue; + } + throw err; + } + } +} + +function missingWorkflows(required, present) { + const have = new Set((present || []).map((p) => String(p).toLowerCase())); + return required.filter((w) => !have.has(String(w).toLowerCase())); +} + +function summarize(log) { + const summary = { + total: log.length, + created: 0, + already_exists: 0, + healed: 0, + error: 0 + }; + for (const entry of log) { + if (entry.result === 'created' || entry.result === 'seeded') summary.created += 1; + else if (entry.result === 'already-exists') summary.already_exists += 1; + else if (entry.result === 'healed') summary.healed += 1; + else if (entry.result === 'error') summary.error += 1; + } + return summary; +} + +function required(config, key) { + if (!config[key]) throw new Error(`provisionRoster config requires "${key}"`); + return config[key]; +} + +module.exports = { + provisionRoster, + provisionOne, + defaultRepoName, + isSecondaryRateLimit, + missingWorkflows, + summarize +}; diff --git a/.github/scripts/provisioning/roster.js b/.github/scripts/provisioning/roster.js new file mode 100644 index 00000000..a39e4aa5 --- /dev/null +++ b/.github/scripts/provisioning/roster.js @@ -0,0 +1,226 @@ +/** + * Owned roster of record for the GIT Going with GitHub (GLOW) workshop. + * + * The roster maps a learner GitHub handle to a cohort, path, provisioning state, + * and progression status. It is the canonical, vendor-independent source of truth + * required by the decoupling contract in golden.md and SPEC.md section 6.1. + * + * Everything here is pure and synchronous so it is fully unit-testable and can be + * driven from a workflow, the CLI, or the Flask companion without a network call. + * Persistence (where the JSON physically lives) is the caller's concern. + */ + +'use strict'; + +const PROVISION_STATES = Object.freeze(['pending', 'provisioned', 'failed', 'healed']); +const STATUSES = Object.freeze([ + 'awaiting-ack', + 'active-day1', + 'day1-complete', + 'day2-released', + 'needs-info' +]); +const PATHS = Object.freeze(['day1-day2', 'day2-only']); + +// GitHub usernames: 1-39 chars, alphanumeric or single hyphens, no leading/trailing hyphen. +const HANDLE_RE = /^[A-Za-z0-9](?:[A-Za-z0-9]|-(?=[A-Za-z0-9])){0,38}$/; + +function normalizeHandle(handle) { + return String(handle || '').trim().replace(/^@+/, '').toLowerCase(); +} + +function isValidHandle(handle) { + return HANDLE_RE.test(String(handle || '').replace(/^@+/, '')); +} + +function emptyRoster() { + return { version: 1, cohorts: [], learners: [] }; +} + +/** + * Parse a roster from a JSON string, tolerating an empty/missing input by + * returning a fresh empty roster. Throws on malformed JSON or wrong shape. + */ +function parseRoster(json) { + if (json === undefined || json === null || String(json).trim() === '') { + return emptyRoster(); + } + const data = typeof json === 'string' ? JSON.parse(json) : json; + return validateRoster(data); +} + +function validateRoster(data) { + const errors = []; + if (typeof data !== 'object' || data === null || Array.isArray(data)) { + throw new Error('Roster must be an object'); + } + if (data.version !== 1) { + errors.push(`Unsupported roster version: ${data.version}`); + } + if (!Array.isArray(data.learners)) { + errors.push('Roster.learners must be an array'); + } + if (data.cohorts !== undefined && !Array.isArray(data.cohorts)) { + errors.push('Roster.cohorts must be an array'); + } + if (errors.length) { + throw new Error(`Invalid roster: ${errors.join('; ')}`); + } + const seen = new Set(); + for (const learner of data.learners) { + const problems = validateLearner(learner); + if (problems.length) { + throw new Error(`Invalid learner ${learner && learner.github_handle}: ${problems.join('; ')}`); + } + const key = learnerKey(learner.github_handle, learner.cohort_id); + if (seen.has(key)) { + throw new Error(`Duplicate learner for key ${key}`); + } + seen.add(key); + } + return { + version: 1, + generated_at: data.generated_at, + cohorts: data.cohorts || [], + learners: data.learners + }; +} + +function validateLearner(learner) { + const problems = []; + if (!learner || typeof learner !== 'object') { + return ['learner must be an object']; + } + if (!isValidHandle(learner.github_handle)) { + problems.push('invalid github_handle'); + } + if (!learner.cohort_id) { + problems.push('missing cohort_id'); + } + if (!PATHS.includes(learner.path)) { + problems.push(`invalid path: ${learner.path}`); + } + if (!PROVISION_STATES.includes(learner.provision_state)) { + problems.push(`invalid provision_state: ${learner.provision_state}`); + } + if (!STATUSES.includes(learner.status)) { + problems.push(`invalid status: ${learner.status}`); + } + return problems; +} + +function learnerKey(handle, cohortId) { + return `${normalizeHandle(handle)}@@${String(cohortId || '').trim()}`; +} + +function findLearner(roster, handle, cohortId) { + const key = learnerKey(handle, cohortId); + return roster.learners.find((l) => learnerKey(l.github_handle, l.cohort_id) === key) || null; +} + +/** + * Insert or update a learner, keyed on (github_handle, cohort_id). Returns a new + * roster object (does not mutate the input) so callers can diff/compare safely. + */ +function upsertLearner(roster, entry) { + const problems = validateLearner({ + github_handle: entry.github_handle, + cohort_id: entry.cohort_id, + path: entry.path || 'day1-day2', + provision_state: entry.provision_state || 'pending', + status: entry.status || 'awaiting-ack' + }); + if (problems.length) { + throw new Error(`Cannot upsert learner: ${problems.join('; ')}`); + } + const next = cloneRoster(roster); + const key = learnerKey(entry.github_handle, entry.cohort_id); + const existing = next.learners.find( + (l) => learnerKey(l.github_handle, l.cohort_id) === key + ); + if (existing) { + Object.assign(existing, entry); + return next; + } + next.learners.push({ + github_handle: entry.github_handle, + cohort_id: entry.cohort_id, + path: entry.path || 'day1-day2', + learning_room_repo: entry.learning_room_repo || null, + provision_state: entry.provision_state || 'pending', + status: entry.status || 'awaiting-ack', + registered_at: entry.registered_at || null, + last_signal_at: entry.last_signal_at || null, + notes: entry.notes || '' + }); + return next; +} + +function cloneRoster(roster) { + return { + version: 1, + generated_at: roster.generated_at, + cohorts: (roster.cohorts || []).map((c) => ({ ...c })), + learners: (roster.learners || []).map((l) => ({ ...l })) + }; +} + +/** + * Serialize a roster to canonical, stable JSON: learners sorted by key so diffs + * are minimal and reviewable in the admin repository. + */ +function serializeRoster(roster, { now = new Date() } = {}) { + const next = cloneRoster(roster); + next.generated_at = now.toISOString(); + next.learners.sort((a, b) => + learnerKey(a.github_handle, a.cohort_id).localeCompare( + learnerKey(b.github_handle, b.cohort_id) + ) + ); + return `${JSON.stringify(next, null, 2)}\n`; +} + +/** + * Return the entries the provisioning loop should act on: pending or failed. + * Mirrors the algorithm guard in SPEC.md section 7.2b. + */ +function entriesToProvision(roster) { + return roster.learners.filter( + (l) => l.provision_state === 'pending' || l.provision_state === 'failed' + ); +} + +/** + * Public, redacted view of the roster suitable for any non-private surface. + * Drops facilitator notes and never leaks anything beyond handle/cohort/status. + */ +function publicView(roster) { + return { + version: 1, + learners: roster.learners.map((l) => ({ + github_handle: l.github_handle, + cohort_id: l.cohort_id, + path: l.path, + provision_state: l.provision_state, + status: l.status + })) + }; +} + +module.exports = { + PROVISION_STATES, + STATUSES, + PATHS, + emptyRoster, + parseRoster, + validateRoster, + validateLearner, + isValidHandle, + normalizeHandle, + learnerKey, + findLearner, + upsertLearner, + serializeRoster, + entriesToProvision, + publicView +}; diff --git a/.github/workflows/automation-tests.yml b/.github/workflows/automation-tests.yml index 382f3f60..ef58848d 100644 --- a/.github/workflows/automation-tests.yml +++ b/.github/workflows/automation-tests.yml @@ -46,3 +46,13 @@ jobs: - name: Run automation test suite run: npm run test:automation + + - name: Run provisioning test suite + run: npm run test:provisioning + + - name: Install companion dependencies + run: pip install -r companion/requirements.txt + + - name: Run companion test suite + working-directory: companion + run: python -m unittest discover -s tests diff --git a/.github/workflows/provision-learning-rooms.yml b/.github/workflows/provision-learning-rooms.yml new file mode 100644 index 00000000..ed3eb47c --- /dev/null +++ b/.github/workflows/provision-learning-rooms.yml @@ -0,0 +1,93 @@ +name: Provision Learning Rooms (owned, GitHub-native) + +# Owned, GitHub-native replacement for GitHub Classroom provisioning. +# Implements SPEC.md section 7. The roster of record lives in a private admin +# repository; this workflow reads it, idempotently provisions every pending or +# failed learner, and commits the updated roster plus the provisioning log back. +# +# Nothing here is destructive: re-running heals partial failures and never +# corrupts an existing student repository (idempotent on github_handle+cohort). + +on: + workflow_dispatch: + inputs: + dry_run: + description: List who would be provisioned without making changes + type: boolean + default: false + schedule: + # Trickle-provision newly registered learners every 30 minutes. Provisioning + # on registration (a trickle) avoids the burst that would trip rate limits. + - cron: '*/30 * * * *' + +# Least privilege: this workflow needs no write scope on THIS repository. All +# writes happen against the private admin repo and student repos via a scoped +# token or GitHub App installation token configured in secrets. +permissions: + contents: read + +concurrency: + group: provision-learning-rooms + cancel-in-progress: false + +jobs: + provision: + name: Provision learning rooms + runs-on: ubuntu-latest + environment: provisioning + steps: + - name: Check out workshop repo (provisioning scripts) + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Check out admin roster repo + uses: actions/checkout@v4 + with: + repository: ${{ vars.ADMIN_ROSTER_REPO }} + token: ${{ secrets.PRIVATE_STUDENT_DATA_TOKEN }} + path: _roster + + - name: Provision (dry run) + if: ${{ inputs.dry_run }} + run: | + node .github/scripts/provisioning/provision-cli.js \ + --roster _roster/roster.json --dry-run + env: + LEARNING_ROOM_TEMPLATE_REPO: ${{ vars.LEARNING_ROOM_TEMPLATE_REPO }} + PROVISIONING_STUDENT_OWNER: ${{ vars.PROVISIONING_STUDENT_OWNER }} + + - name: Provision learners + if: ${{ !inputs.dry_run }} + run: | + node .github/scripts/provisioning/provision-cli.js \ + --roster _roster/roster.json \ + --log _roster/provisioning-log.json + env: + PROVISIONING_MODE: ${{ vars.PROVISIONING_MODE }} + LEARNING_ROOM_TEMPLATE_REPO: ${{ vars.LEARNING_ROOM_TEMPLATE_REPO }} + PROVISIONING_STUDENT_OWNER: ${{ vars.PROVISIONING_STUDENT_OWNER }} + GITHUB_API_URL: ${{ github.api_url }} + # github-app mode (production): + PROVISIONING_APP_ID: ${{ secrets.PROVISIONING_APP_ID }} + PROVISIONING_APP_PRIVATE_KEY: ${{ secrets.PROVISIONING_APP_PRIVATE_KEY }} + PROVISIONING_APP_INSTALLATION_ID: ${{ secrets.PROVISIONING_APP_INSTALLATION_ID }} + # actions-bot mode (Phase 1 spike only): + PROVISIONING_TOKEN: ${{ secrets.PROVISIONING_TOKEN }} + + - name: Commit updated roster and provisioning log + if: ${{ !inputs.dry_run }} + working-directory: _roster + run: | + if [ -n "$(git status --porcelain)" ]; then + git config user.name "glow-provisioning[bot]" + git config user.email "provisioning@users.noreply.github.com" + git add roster.json provisioning-log.json + git commit -m "chore(provisioning): update roster and provisioning log" + git push + else + echo "No roster changes to commit." + fi diff --git a/SPEC.md b/SPEC.md new file mode 100644 index 00000000..68ed9786 --- /dev/null +++ b/SPEC.md @@ -0,0 +1,521 @@ +# SPEC.md + +Complete project specification for GIT Going with GitHub (GLOW). This is the technical source of truth: what the system is, what every component does, how data and state flow, the interfaces between parts, and the requirements every piece must meet. It implements the vision in [golden.md](golden.md). Where this spec and reality disagree, fix one of them and note it here. + +## Table of contents + +- [1. Overview](#1-overview) +- [2. Goals and non-goals](#2-goals-and-non-goals) +- [3. Personas and primary journeys](#3-personas-and-primary-journeys) +- [4. System architecture](#4-system-architecture) +- [5. Component catalog](#5-component-catalog) +- [6. Data model and state of record](#6-data-model-and-state-of-record) +- [7. Provisioning subsystem (the Classroom replacement)](#7-provisioning-subsystem-the-classroom-replacement) +- [8. Automation contracts](#8-automation-contracts) +- [9. Curriculum subsystem](#9-curriculum-subsystem) +- [10. Content pipeline: docs, HTML, EPUB, audio](#10-content-pipeline-docs-html-epub-audio) +- [11. Optional Flask companion](#11-optional-flask-companion) +- [12. Accessibility requirements](#12-accessibility-requirements) +- [13. Security requirements](#13-security-requirements) +- [14. Reliability, observability, and recovery](#14-reliability-observability-and-recovery) +- [15. Testing and quality strategy](#15-testing-and-quality-strategy) +- [16. Configuration surface](#16-configuration-surface) +- [17. Repository layout](#17-repository-layout) +- [18. Migration plan: from Classroom to owned provisioning](#18-migration-plan-from-classroom-to-owned-provisioning) +- [19. Acceptance criteria](#19-acceptance-criteria) +- [20. Open questions](#20-open-questions) + +## 1. Overview + +GLOW is a two-day, accessibility-first workshop that teaches blind and low vision technologists to navigate and contribute to open source on GitHub using a screen reader and keyboard alone. It is delivered as: + +- A **curriculum** of 22 chapters plus appendices in [docs/](docs/), published to HTML, EPUB, and an audio podcast series. +- A **Learning Room**: a per-student private repository, created from a template, that drives 16 core challenges plus 5 bonus challenges through GitHub-native automation (a PR validation bot named Gandalf, a Student Progression Bot, and a suite of autograders). +- A **registration and cohort system** that intakes learners, provisions their Learning Room, releases Day 2 content, and gives facilitators a live status dashboard. + +The current production system depends on GitHub Classroom for provisioning. This spec defines both the present system and the target system in which Classroom is replaced by owned, GitHub-native provisioning, per [golden.md](golden.md). + +## 2. Goals and non-goals + +### Goals + +- A learner completes the full arc from first GitHub navigation to a real, review-ready open source contribution. +- Every learner-facing surface is fully operable with NVDA, JAWS, and VoiceOver, by keyboard alone. +- Provisioning, roster, and progress are owned and reconstructable, with no single vendor as a point of failure. +- Facilitators can open a cohort, run it, and recover from any failure using documented runbooks. +- The system degrades gracefully: every automated step has a manual fallback. + +### Non-goals + +- Email delivery is not a dependency. All flows complete using the GitHub web notification inbox. +- The system does not require learners to join an organization, hold a paid plan, or change Actions settings. +- The system does not aim to replace VS Code, Copilot, or GitHub itself; it teaches their accessible use. +- Real-time chat, grading-for-credit, and LMS integration are out of scope. + +## 3. Personas and primary journeys + +### Personas + +- **Learner.** New-to-GitHub, uses assistive technology, may not code. Needs belonging, clarity, and a forgiving path. +- **Facilitator.** Runs a cohort, seeds challenges, monitors progress, recovers stuck learners. +- **Maintainer.** Owns the curriculum, automation, content pipeline, and this spec. + +### Primary learner journey + +1. Register through an accessible front door (Flask companion, GitHub Pages form, or issue form fallback). +2. Receive a provisioned private Learning Room repository. +3. Acknowledge readiness (`ack`), complete Day 1 challenges, signal `day1-complete`. +4. Receive Day 2 release, complete Day 2 challenges and the capstone. +5. Open or prepare a real upstream contribution; continue asynchronously with support hub access. + +### Primary facilitator journey + +1. Sync and validate the Learning Room template. +2. Open a cohort; provisioning creates student repositories. +3. Seed Challenge 1 (and Challenge 10 for Day 2) per student. +4. Monitor the dashboard; intervene on watchdog alerts. +5. Run teardown after the cohort. + +## 4. System architecture + +The architecture separates a dependable GitHub-native core (the critical learner path) from an optional companion at the edges. The companion can vanish without breaking any learner. + +```text + ACCESSIBLE FRONT DOOR + (Flask companion OR GitHub Pages form OR issue form fallback) + | + v + REGISTRATION + ROSTER (owned) + private admin repo roster record <----+ companion mirror (optional) + | + v + PROVISIONING SUBSYSTEM (GitHub-native) + idempotent action: template ----> per-student private Learning Room repo + | + v + THE LEARNING ROOM (per student) + Gandalf (PR bot) | Student Progression Bot | Autograders | Challenge issues + | + v + PROGRESSION + DAY 2 RELEASE (deterministic text signals) + | + v + FACILITATOR DASHBOARD (admin issues + optional companion view) +``` + +Architectural rules: + +- The critical path (front door fallback, provisioning, Learning Room, progression, release) runs entirely on GitHub. +- The companion only ever renders owned state more nicely; it never holds state the learner depends on. +- Each arrow is a documented contract (Section 8) with a manual fallback. + +## 5. Component catalog + +This table is the index of moving parts. Each component has an owner, a trigger, and a fallback. + +| Component | Location | Trigger | Responsibility | Manual fallback | +|---|---|---|---|---| +| Registration workflow | [.github/workflows/registration.yml](.github/workflows/registration.yml) | Registration issue opened | Validate, redact, label, store intake, post links | Facilitator labels and replies by hand | +| Day 2 release | [.github/workflows/day2-release.yml](.github/workflows/day2-release.yml) | Schedule, dispatch | Post Day 2 link when `day1-complete` signal present | Facilitator posts Day 2 link | +| Dashboard sync | [.github/workflows/instructor-dashboard-sync.yml](.github/workflows/instructor-dashboard-sync.yml) | Schedule, events | Upsert per-student status issue in admin repo | Facilitator reads enrollment issues | +| Student grouping | [.github/workflows/student-grouping.yml](.github/workflows/student-grouping.yml) | Schedule, dispatch | Cohort grouping support | Manual roster grouping | +| Skills progression | [.github/workflows/skills-progression.yml](.github/workflows/skills-progression.yml) | Issue events | Track skills progression | Manual tracking | +| Learning Room validation | [.github/workflows/learning-room-validation.yml](.github/workflows/learning-room-validation.yml) | PR, dispatch | Validate template integrity | Manual template review | +| Authoritative sources validation | [.github/workflows/validate-authoritative-sources.yml](.github/workflows/validate-authoritative-sources.yml) | PR | Enforce source maps in content | Run validator script locally | +| Gandalf (PR bot) | [learning-room/.github/workflows/pr-validation-bot.yml](learning-room/.github/workflows/pr-validation-bot.yml) | PR events, comments | Welcome, validate PR, answer help | Facilitator comments | +| Student Progression Bot | [learning-room/.github/workflows/student-progression.yml](learning-room/.github/workflows/student-progression.yml) | Issue closed, dispatch | Create next challenge issue | Dispatch with `start_challenge` | +| Autograders (8) | [learning-room/.github/workflows/autograder-*.yml](learning-room/.github/workflows/) | Issue, PR, push, schedule | Verify objective challenge evidence | Facilitator verifies manually | +| Content validation | [learning-room/.github/workflows/content-validation.yml](learning-room/.github/workflows/content-validation.yml) | PR | Validate student content edits | Manual review | +| Provisioning action (target) | new, GitHub App or Actions bot | Roster entry created | Create and seed Learning Room repo idempotently | Classroom invite during transition | +| Content pipeline | [.github/workflows/](.github/workflows/) build-* and deploy-* | Push, dispatch | Build HTML, EPUB, diagrams, Pages | Run build scripts locally | +| Flask companion (optional) | new service | HTTP | Accessible front door and dashboard | GitHub Pages form and admin issues | + +### The three Learning Room automation systems + +- **Gandalf (PR Validation Bot).** Welcomes first-time contributors, validates PR structure, responds to help requests, posts real-time feedback on every push. Idempotent comment updates keyed by a marker string so it never spams a thread. +- **Student Progression Bot.** On close of a `Challenge N` issue, creates the next challenge issue; on `workflow_dispatch` with `start_challenge`, seeds a specific challenge. Concurrency-guarded per issue. See [challenge-progression.js](learning-room/.github/scripts/challenge-progression.js). +- **Autograders.** Eight workflows verifying objective evidence: issue filed, branch commit, PR link, merge conflicts resolved, local commit, template validity, capstone, and a watchdog. Each posts a single, kind, idempotent pass or fail comment and a separate error notice on workflow failure so the learner is never blamed for infrastructure faults. + +## 6. Data model and state of record + +The decoupling contract from [golden.md](golden.md) requires three owned, reconstructable sources of truth. This section defines their schemas. + +### 6.1 Roster of record + +Canonical store: one record per learner in the private admin repository (optionally mirrored by the companion). Logical schema: + +| Field | Type | Notes | +|---|---|---| +| `github_handle` | string | Primary identity key | +| `cohort_id` | string | Cohort the learner belongs to | +| `path` | enum | `day1-day2` or `day2-only` | +| `learning_room_repo` | string | Owner/name of provisioned repo, null until provisioned | +| `provision_state` | enum | `pending`, `provisioned`, `failed`, `healed` | +| `status` | enum | `awaiting-ack`, `active-day1`, `day1-complete`, `day2-released`, `needs-info` | +| `registered_at` | timestamp | Intake time | +| `last_signal_at` | timestamp | Last deterministic signal observed | +| `notes` | string | Facilitator notes, redacted in any public surface | + +Reconstruction rule: the roster can be rebuilt from registration issues plus provisioning results without any third party. + +### 6.2 Progress of record + +Derived, never authored by a vendor. Progress is computed from signals the project controls: + +- Challenge issue state (open or closed) and titles (`Challenge N`). +- PR state and closing-keyword links (`Closes #N`). +- Labels (`enrolled`, `day1-complete`, `day2-eligible`, `day2-released`). +- Deterministic text signals in comments: `ack`, `day1-complete`. + +Reconstruction rule: re-derivable by replaying issue and PR events in the student repository. + +### 6.3 Provisioning of record + +A log of provisioning attempts and outcomes per learner, sufficient to prove a repository is correctly seeded and to safely re-run. Fields: `github_handle`, `attempt_at`, `result` (`created`, `already-exists`, `seeded`, `healed`, `error`), `repo`, `template_sha`, `error_detail`. + +Reconstruction rule: running the idempotent provisioning action against the roster reproduces a healthy state for every learner. + +## 7. Provisioning subsystem (the Classroom replacement) + +This is the only piece that fundamentally changes when Classroom departs. Everything downstream is unchanged because the resulting student repository is byte-shaped identically to a Classroom-created one. + +### 7.1 Responsibilities + +1. Given a roster entry, create a private repository from `Community-Access/learning-room-template`. +2. Grant the learner access. +3. Confirm all required automation files are present (the workflow set listed in the go-live gate). +4. Record the outcome in the provisioning log. +5. Be idempotent: re-running heals partial failures and never corrupts an existing repository. + +### 7.2 Implementation options and decision + +- **Option A, GitHub App (decided for production).** A GitHub App with least-privilege permissions (repository administration to create, contents to seed, metadata). Scoped, auditable, installable, and not tied to any human account. +- **Option B, Actions bot (Phase 1 spike only).** A scheduled or dispatched workflow in the admin repo using a least-privilege fine-grained token with repo-creation scope. Faster to stand up; acceptable only as a throwaway validation of seeding logic, then discarded. + +Both must implement the same internal contract so the method is swappable via `PROVISIONING_MODE`. + +**Decision: build the GitHub App for production; use the PAT path only as an optional Phase 1 spike.** The deciding factor is not rate limits or convenience, it is the first principle in [golden.md](golden.md): never let a single point of failure hold a cohort hostage. A PAT is bound to a facilitator's account, so that person leaving, resetting credentials, or changing 2FA can break provisioning. The App's properties (not tied to a human, short-lived installation tokens, fine-grained least-privilege) are the literal embodiment of the golden vision, and the extra one-time setup is small. + +The pros and cons that drove the decision: + +| Factor | GitHub App | Actions bot (PAT) | +|---|---|---| +| Identity | Purpose-built, survives staff change | Bound to a human account, single point of failure | +| Token lifetime | Short-lived installation token (~1 hour), minted on demand | Long-lived (months); larger leak blast radius | +| Permission granularity | Fine-grained (Administration, Contents, Metadata only) | Account-level even when fine-grained | +| Rate limit ceiling | Scales with org (floor 5,000/hour, scaling up) | Flat 5,000/hour | +| Audit trail | Clean App identity per action | Everything looks like the token owner | +| Setup cost | ~30 to 60 minutes one-time (App, App ID, PEM secret, token mint step) | Fastest; afternoon prototype | +| Key custody | Holds a signing key; must be in Secrets and rotatable | One token to store and rotate | + +Because the downstream system cannot tell which mode created a repository, spiking with a PAT first and graduating to the App loses nothing. + +### 7.2a GitHub App permission set + +Grant only these. Anything beyond this list is over-privileged and fails the security review in Section 13. + +| Permission | Level | Why | +|---|---|---| +| Repository administration | Write | Create the per-student private repository from the template | +| Contents | Write | Seed and heal repository content (workflows, issue templates) | +| Metadata | Read | Mandatory baseline for any App | +| Issues | Write | Seed the first challenge issue and provisioning status (optional; can defer to the Progression Bot) | + +App configuration rules: + +- Install the App only on the `Community-Access` organization, scoped to the template and student repositories. +- Store `PROVISIONING_APP_ID` and `PROVISIONING_APP_PRIVATE_KEY` in GitHub Secrets; never in code or public repos. +- Mint a short-lived installation token at the start of each provisioning run; never persist it. +- Document a private-key rotation procedure and rotate on any suspected exposure. + +### 7.2b Provisioning algorithm (idempotent, serial) + +Provisioning runs serially with a small delay and exponential backoff, not parallel fan-out, to stay clear of GitHub secondary (abuse) rate limits on content-creating requests. The algorithm is idempotent on `(github_handle, cohort_id)`, so a re-run resumes a half-finished batch instead of duplicating or corrupting work. + +```text +for each roster entry where provision_state in (pending, failed): + key = (github_handle, cohort_id) + expected_repo = name_for(key) + + 1. If expected_repo already exists: + - Verify required workflow files and template SHA. + - If complete: mark provisioned (or healed), log already-exists, continue. + - If incomplete: re-seed missing files, mark healed, log healed. + Else: + - Create private repo from template at the pinned template SHA. + - Log created. + + 2. Grant the learner access (idempotent; skip if already granted). + + 3. Seed required content if not already present + (workflows, issue templates). Log seeded. + + 4. Verify required workflow set is present (go-live gate list). + - On success: provision_state = provisioned (or healed). + - On failure: provision_state = failed, write error_detail, + surface to watchdog. Do NOT leave a half-seeded repo silently. + + 5. Wait a short delay (1 to a few seconds) before the next entry. + On HTTP 403/429 (secondary limit), back off exponentially and retry + the same entry; never skip it. +``` + +Invariants: at-most-one repository per key; pinned template SHA; learner has access; required workflows present; every outcome written to the provisioning log; any failure visible to a human before a learner notices. + +### 7.3 Provisioning contract + +- Input: roster entry (`github_handle`, `cohort_id`, `path`). +- Output: `learning_room_repo`, `provision_state`, provisioning log entry. +- Idempotency key: `(github_handle, cohort_id)`. +- Guarantees: at-most-one repository per key; correct template SHA; learner has access; required workflows present. +- Failure behavior: on any error, set `provision_state = failed`, write `error_detail`, and surface to the watchdog. Never leave a half-seeded repo silently. + +### 7.4 Rate and scale considerations + +For any realistic size of this workshop, documented hourly rate limits are a non-issue. The real risk is GitHub secondary (abuse) rate limits, which trigger on bursts of content-creating requests fired in parallel. The serial-with-delay-and-backoff algorithm in Section 7.2b avoids them, and idempotency means hitting a limit just means run it again, it heals. + +**Design target: 1 to 100 learners.** That is what a high-touch, accessibility-first, facilitator-led workshop actually is. A cohort large enough to hit real rate limits would also be too large to run with the recover-every-stuck-learner quality bar that defines this project. The constraint that bites first is facilitator attention, not API quota. + +This table is the planning guidance by cohort size. + +| Cohort size | Reality | What to do | +|---|---|---| +| 1 to 30 (likely case) | Trivial; serial creation finishes in minutes | Serial loop, 1 to 2 second delay, backoff on error | +| 30 to 100 | Comfortable within an hour | Same pattern; rely on idempotent re-run to heal mid-batch failures | +| 100 to 300 | Approaching where bursting would trip secondary limits | Throttle to a steady rate, chunk the batch, resume via idempotency; the App ceiling helps | +| 300+ | Plan deliberately | Stagger provisioning over time windows, pre-flight seat and quota checks, monitor for 403s | + +Two design choices make cohort size a permanent non-worry, and both are already specified: + +- **Idempotent provisioning keyed on `(github_handle, cohort_id)`** (Section 7.2b). A re-run resumes a half-finished batch rather than duplicating or corrupting it. +- **Provision on registration, not big-bang.** Creating repositories as learners register (a trickle) rather than all at once on go-live morning (a burst) means you essentially never approach a burst limit regardless of total size. + +Operational rules: + +- Pre-flight check org seat and repository quotas before a cohort opens. +- Template sync and smoke validation (`Prepare-LearningRoomTemplate.ps1`, `Test-LearningRoomTemplate.ps1`) run before any provisioning. + +## 8. Automation contracts + +Each arrow in the architecture is a stable contract. Contracts are intentionally simple text and label signals so facilitators can test and recover them by hand. + +| Contract | Producer | Consumer | Signal | Idempotent | Manual fallback | +|---|---|---|---|---|---| +| Enroll | Registration workflow | Roster | `enrolled` label, roster record | Yes | Apply label, add record | +| Acknowledge | Learner | Roster, dashboard | comment `ack` | Yes | Facilitator marks acked | +| Day 1 complete | Learner | Day 2 release | comment `day1-complete` or label | Yes | Facilitator posts Day 2 link | +| Day 2 release | Release workflow | Learner | comment with Day 2 link, `day2-released` label | Yes | Facilitator posts link | +| Next challenge | Progression bot | Learner | new `Challenge N+1` issue | Yes (per issue) | Dispatch with `start_challenge` | +| Autograde | Autograder | Learner | single pass/fail comment keyed by marker | Yes | Facilitator verifies and comments | +| Dashboard | Dashboard sync | Facilitator | upserted status issue | Yes | Read enrollment issues | +| Provision | Provisioning action | Roster, learner | repo created and seeded | Yes (per key) | Classroom invite during transition | + +Contract invariants: + +- Every bot comment is updated in place via a marker string, never duplicated. +- Every workflow that can fail posts a separate, blameless error notice on failure. +- Every signal is plain text or a label so it is human-testable and human-recoverable. + +## 9. Curriculum subsystem + +- 22 chapters ([docs/00-pre-workshop-setup.md](docs/00-pre-workshop-setup.md) through [docs/22-what-comes-next.md](docs/22-what-comes-next.md)) plus appendices A through Z and AA through AC. +- 16 core challenges and 5 bonus challenges, indexed in [docs/CHALLENGES.md](docs/CHALLENGES.md), each with instructions, evidence requirements, and a reference solution in [docs/solutions/](docs/solutions/). +- Each chapter is authored screen-reader-first: every step keyboard-accessible, every concept explained without sight, an "If You Get Stuck" section, and a Section-Level Source Map enforced by the authoritative-sources validator. +- The arc is one journey: browser, then github.dev, then desktop VS Code with Accessibility Agents. Every Day 1 skill maps to a Day 2 agent command. + +Curriculum requirements: + +- Source maps required on in-scope content; podcasts and non-content paths excluded (per repo memory and the validator). +- Every reference URL must resolve; broken links fail the content gate. +- Tools-change resilience: chapters teach exploration so learners survive UI drift. + +## 10. Content pipeline: docs, HTML, EPUB, audio + +The pipeline turns Markdown into every delivery format. + +| Stage | Input | Output | Workflow or script | +|---|---|---|---| +| HTML build | `docs/*.md` | `html/` | build per [BUILD.md](BUILD.md) | +| EPUB build | `docs/*.md`, `epub/epub.css` | EPUB | [build-epub.yml](.github/workflows/build-epub.yml) | +| Diagrams | diagram sources | SVGs | [generate-diagram-svgs.yml](.github/workflows/generate-diagram-svgs.yml) | +| Pages deploy | HTML | published site | [deploy-pages.yml](.github/workflows/deploy-pages.yml) | +| Wiki sync | docs | wiki | [sync-docs-to-wiki.yml](.github/workflows/sync-docs-to-wiki.yml) | +| Audio series | transcripts | MP3, RSS | batch scripts, RSS feed builder | + +Audio policy: minimize MP3 regeneration. Prefer transcript-only and site-only updates unless a podcast release is explicitly in scope. The catalog currently covers 54 companion episodes plus planned Challenge Coach episodes. + +Pipeline requirements: + +- HTML must build from current Markdown before any cohort opens. +- The RSS feed validates before publish. +- All generated HTML preserves heading structure, descriptive links, and table semantics. + +## 11. Optional Flask companion + +The companion is strictly optional and lives at the edges. It must add delight without becoming a dependency. + +### 11.1 Scope + +- Accessible registration landing page (writes to the owned roster). +- Facilitator cohort dashboard (reads owned roster and progress). +- Bulk facilitator operations (trigger provisioning, re-seed, recover) as thin wrappers over the owned provisioning action. + +### 11.2 Hard constraints + +- Stateless about anything critical. If the companion is down, the GitHub-native front door and admin-issue dashboard carry the full workshop. +- WCAG 2.2 AA: semantic landmarks, correct heading order, labeled controls, visible focus, live-region announcements, no keyboard traps. +- OWASP Top 10 reviewed: authenticated facilitator actions, CSRF protection, input validation, output encoding, least-privilege tokens, rate limiting, secrets never in client code. +- All writes go through the owned roster and provisioning contracts; the companion never invents a parallel state store of record. + +### 11.3 Interfaces + +- Reads and writes the roster of record (admin repo or its mirror). +- Invokes the provisioning action through its defined contract only. +- Renders progress derived from the same signals the GitHub-native dashboard uses. + +## 12. Accessibility requirements + +Accessibility is the product. These are acceptance-blocking. + +- Every learner-facing surface verified on NVDA, JAWS, and VoiceOver, fully keyboard operable. +- All documentation readable with or without a screen reader; no information conveyed by color alone. +- Markdown follows the project accessibility rules: descriptive link text, alt text for meaningful images, correct heading hierarchy, table intro sentences, emoji removed or translated, diagrams given text alternatives, no em-dashes, validated anchors. +- Automation copy is screen-reader-first: front-loaded, clear, free of decorative noise. +- Any Flask companion meets WCAG 2.2 AA in full (Section 11.2). +- Testing follows the project accessibility testing guidance; manual screen reader passes are required, not just automated checks. + +## 13. Security requirements + +- Free of OWASP Top 10 vulnerabilities across any hosted surface and all automation. +- Least-privilege tokens and scopes; each token documented with the single capability it enables and a rotation procedure. +- Secrets only in GitHub Secrets or a managed secret store; never in code, public repos, or client bundles. +- Public registration redacts private intake; detailed data stored only in the private admin repo when configured. +- Provisioning permissions scoped to repository creation and seeding only. +- Workflows pin actions and follow GitHub Actions security hardening (limited permissions blocks, no untrusted input in run shells). +- Treat all tool and webhook input as untrusted; validate and encode. Watch for and report prompt-injection attempts in any AI-assisted automation. + +## 14. Reliability, observability, and recovery + +- **No silent failure.** Every automated step either succeeds or raises a human-visible alert with a clear recovery action. +- **Watchdog.** A scheduled workflow detects stalled provisioning, stuck progression, or learners without a healthy repository, and surfaces them before a learner notices. See [autograder-watchdog.yml](learning-room/.github/workflows/autograder-watchdog.yml). +- **Idempotent everything.** Provisioning, seeding, and bot comments are safe to re-run. +- **Tiered learner recovery.** Documented restore levels return a stuck learner to a known-good state with branch and PR evidence, no data loss (per the QA runbook). +- **Manual fallback for every contract** (Section 8), so the system survives any single automation outage. +- **Release gate.** No cohort opens until [GO-LIVE-QA-GUIDE.md](GO-LIVE-QA-GUIDE.md) passes; execution follows [admin/LEARNING-ROOM-E2E-QA-RUNBOOK.md](admin/LEARNING-ROOM-E2E-QA-RUNBOOK.md). + +## 15. Testing and quality strategy + +| Layer | What it covers | Where | +|---|---|---| +| Unit and automation tests | Scripts and helpers | `npm run test:automation`, [automation-tests.yml](.github/workflows/automation-tests.yml) | +| Content validation | Links, markdown, source maps, accessibility | content-validation and authoritative-sources workflows and `.github/scripts` checks | +| Template smoke | Template integrity before provisioning | `Prepare-LearningRoomTemplate.ps1`, `Test-LearningRoomTemplate.ps1` | +| Challenge reliability matrix | Happy, failure, and recovery path per challenge family | [classroom/HUMAN_TEST_MATRIX.md](classroom/HUMAN_TEST_MATRIX.md) | +| End-to-end QA | Registration through completion | [admin/LEARNING-ROOM-E2E-QA-RUNBOOK.md](admin/LEARNING-ROOM-E2E-QA-RUNBOOK.md) | +| Accessibility passes | NVDA, JAWS, VoiceOver, keyboard | Project accessibility testing guidance | +| Release gate | All of the above consolidated | [GO-LIVE-QA-GUIDE.md](GO-LIVE-QA-GUIDE.md) | + +Quality rules: + +- Every challenge has happy-path, failure-path, and recovery-path evidence before go-live. +- Provisioning is tested with at least one re-run to prove idempotency. +- Any companion ships only after an accessibility pass and an OWASP review. + +## 16. Configuration surface + +Variables: + +- `CLASSROOM_DAY1_ASSIGNMENT_URL`, `CLASSROOM_DAY2_ASSIGNMENT_URL` (transitional; retired after Classroom removal). +- `PRIVATE_STUDENT_DATA_REPO`. +- `ENABLE_PUBLIC_CLASSROOM_INTAKE_EXPORT`, `ENABLE_PUBLIC_REGISTRATION_EXPORT`. +- New for owned provisioning: `LEARNING_ROOM_TEMPLATE_REPO`, `ADMIN_ROSTER_REPO`, `PROVISIONING_MODE` (`github-app` or `actions-bot`). + +Secrets: + +- `PRIVATE_STUDENT_DATA_TOKEN`, `INSTRUCTOR_DASHBOARD_TOKEN`. +- New for owned provisioning: `PROVISIONING_APP_ID` and `PROVISIONING_APP_PRIVATE_KEY` (App mode) or `PROVISIONING_TOKEN` (bot mode), least-privilege and rotatable. + +## 17. Repository layout + +| Path | Purpose | +|---|---| +| [docs/](docs/) | Curriculum chapters, appendices, challenges, solutions | +| [learning-room/](learning-room/) | Source of the per-student template: workflows, scripts, issue templates | +| [.github/workflows/](.github/workflows/) | Cohort-level automation and content pipeline | +| [classroom/](classroom/) | Deployment guide, assignments, test matrix, grading guide, teardown | +| [admin/](admin/) | Facilitator operations, QA runbooks, architecture, podcasts | +| [scripts/](scripts/) | Generation and classroom preparation scripts | +| [html/](html/), [epub/](epub/), [podcasts/](podcasts/) | Generated delivery formats | +| [golden.md](golden.md) | Vision and quality north star | +| [SPEC.md](SPEC.md) | This specification | +| [GO-LIVE-QA-GUIDE.md](GO-LIVE-QA-GUIDE.md) | Release gate | + +## 18. Migration plan: from Classroom to owned provisioning + +Aligned to the phased roadmap in [golden.md](golden.md). + +1. **Phase 0, harden the present.** Pass the full go-live gate on the current Classroom flow. +2. **Phase 1, prove decoupling.** Implement the owned roster, owned progress derivation, and the idempotent provisioning action. Validate end to end with test accounts. Classroom remains primary. +3. **Phase 2, production provisioning.** Run a real test cohort on GitHub-native provisioning with Classroom kept as parallel fallback. Prove idempotency with a re-run. +4. **Phase 3, accessible front door.** Add the optional companion (or Pages form) under the full accessibility and security bar. +5. **Phase 4, retire Classroom.** After a clean full cohort, remove the hard Classroom dependency, retire `CLASSROOM_*` variables, and mark owned provisioning canonical. +6. **Phase 5, continuous delight.** Iterate on copy, recovery speed, audio companions, and accessibility. + +No phase ships until the prior phase is golden. No phase weakens accessibility, reliability, or learner trust. + +## 19. Acceptance criteria + +The system is acceptable for a cohort when: + +- The full learner journey completes end to end with provisioning that does not require GitHub Classroom (or with both paths proven during transition). +- Provisioning is idempotent; a re-run heals partial failures and never corrupts a repo. +- Roster, progress, and provisioning are owned and reconstructable without a third party. +- Every learner-facing surface passes NVDA, JAWS, and VoiceOver review and is fully keyboard operable. +- Any companion passes WCAG 2.2 AA and an OWASP Top 10 review with least-privilege, rotatable secrets. +- Every automation contract has a tested manual fallback. +- The watchdog surfaces stalled provisioning or progression before a learner notices. +- Zero broken learner-facing links or dead assignment URLs. +- Automation copy is warm, screen-reader-first, and frames failure as the next step. +- The go-live release gate passes. + +## 20. Open questions + +- GitHub App versus Actions bot for production provisioning: **Resolved.** Build a GitHub App for production; PAT path is a Phase 1 spike only (Section 7.2). +- Maximum cohort size for rate-limit and quota planning: **Resolved.** Design for 1 to 100 learners with serial, idempotent, provision-on-registration behavior (Section 7.4). +- Where does the companion (if built) get hosted, and what is its uptime expectation given it must be non-critical? +- Should the owned roster mirror live in the companion database, or stay solely in admin-repo records for a single source of truth? +- Which Challenge Coach audio episodes are in scope for the next release, given the minimize-regeneration policy? + +--- + +This spec is a living contract. When provisioning moves off Classroom, when the companion ships, or when a challenge changes, update the relevant section and the acceptance criteria together. Measure every change against [golden.md](golden.md): more dependable, more accessible, more flexible, more error free, more delightful. + +## Implementation status + +The owned, GitHub-native core of the Hybrid plan is implemented and tested: + +- Owned roster (section 6.1): [roster.js](.github/scripts/provisioning/roster.js), [roster.schema.json](roster.schema.json), example in [admin/examples/roster.example.json](admin/examples/roster.example.json). +- Owned progress derivation (section 6.2): [progress.js](.github/scripts/provisioning/progress.js). +- Idempotent provisioning subsystem (section 7): [provision-core.js](.github/scripts/provisioning/provision-core.js), [github-app-auth.js](.github/scripts/provisioning/github-app-auth.js), [github-client.js](.github/scripts/provisioning/github-client.js), [provision-cli.js](.github/scripts/provisioning/provision-cli.js), and the [provisioning workflow](.github/workflows/provision-learning-rooms.yml). +- Optional accessible Flask companion (section 11): [companion/](companion/). +- Operator guide: [admin/OWNED_PROVISIONING.md](admin/OWNED_PROVISIONING.md). +- Tests: `npm run test:provisioning` (Node) and `companion/tests` (Python). + +## Authoritative Sources + +Use these official references when you need the current source of truth for facts in this document. + +- [GitHub Docs, home](https://docs.github.com/en) +- [Authenticating as a GitHub App installation](https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/authenticating-as-a-github-app-installation) +- [Generate a repository from a template (REST)](https://docs.github.com/en/rest/repos/repos#create-a-repository-using-a-template) +- [Web Content Accessibility Guidelines (WCAG) 2.2](https://www.w3.org/TR/WCAG22/) +- [OWASP Top Ten](https://owasp.org/www-project-top-ten/) + +### Section-Level Source Map + +Use this map to verify facts for each major section in this file. + +- **Overview, Goals and non-goals, Personas, System architecture, Component catalog, Curriculum subsystem, Content pipeline, Repository layout:** [GitHub Docs, home](https://docs.github.com/en) +- **Data model, Provisioning subsystem, Automation contracts, Configuration surface, Migration plan, Acceptance criteria, Implementation status:** [Authenticating as a GitHub App installation](https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/authenticating-as-a-github-app-installation), [Generate a repository from a template (REST)](https://docs.github.com/en/rest/repos/repos#create-a-repository-using-a-template) +- **Optional Flask companion, Accessibility requirements:** [Web Content Accessibility Guidelines (WCAG) 2.2](https://www.w3.org/TR/WCAG22/) +- **Security requirements, Reliability and recovery, Testing and quality strategy:** [OWASP Top Ten](https://owasp.org/www-project-top-ten/), [GitHub Docs, home](https://docs.github.com/en) diff --git a/admin/COHORT_PROVISIONING.md b/admin/COHORT_PROVISIONING.md index 73044219..5350f70f 100644 --- a/admin/COHORT_PROVISIONING.md +++ b/admin/COHORT_PROVISIONING.md @@ -6,6 +6,8 @@ The previous "multi-player sandbox" provisioning model (shared repo + batch issu See the [Workshop Deployment Guide](../classroom/README.md) for all setup instructions. +> **Looking ahead, beyond Classroom.** The owned, GitHub-native replacement for Classroom provisioning is specified in [SPEC.md](../SPEC.md) and operated per [OWNED_PROVISIONING.md](OWNED_PROVISIONING.md). That subsystem removes the single-vendor dependency by owning the roster, progress, and provisioning of record. + ## Authoritative Sources Use these official references when you need the current source of truth for facts in this chapter. diff --git a/admin/HYBRID_DEPLOYMENT_GUIDE.md b/admin/HYBRID_DEPLOYMENT_GUIDE.md new file mode 100644 index 00000000..18d9665a --- /dev/null +++ b/admin/HYBRID_DEPLOYMENT_GUIDE.md @@ -0,0 +1,302 @@ +# Hybrid Deployment Guide + +This is the step-by-step guide to deploying the Hybrid architecture from +[golden.md](../golden.md) and [SPEC.md](../SPEC.md): the owned, GitHub-native +provisioning core that replaces GitHub Classroom, plus the optional accessible Flask +companion. For the conceptual model and day-to-day operations, see +[OWNED_PROVISIONING.md](OWNED_PROVISIONING.md). For the legacy Classroom-based flow, +see [../classroom/README.md](../classroom/README.md). To review every piece of the +Hybrid deliverable from one place, start at [HYBRID_REVIEW_INDEX.md](HYBRID_REVIEW_INDEX.md). + +Follow the phases in order. Each phase ends with a verification step. Do not advance +until the current phase verifies, exactly as the phased roadmap in golden.md requires. + +## Table of contents + +- [What you are deploying](#what-you-are-deploying) +- [Prerequisites](#prerequisites) +- [Phase 1: Prepare the admin roster repository](#phase-1-prepare-the-admin-roster-repository) +- [Phase 2: Create the provisioning GitHub App](#phase-2-create-the-provisioning-github-app) +- [Phase 3: Configure variables and secrets](#phase-3-configure-variables-and-secrets) +- [Phase 4: Deploy and smoke-test provisioning](#phase-4-deploy-and-smoke-test-provisioning) +- [Phase 5: Deploy the optional companion](#phase-5-deploy-the-optional-companion) +- [Phase 6: Go-live checklist](#phase-6-go-live-checklist) +- [Operating a cohort](#operating-a-cohort) +- [Rollback and fallback](#rollback-and-fallback) +- [Reference: configuration surface](#reference-configuration-surface) +- [Authoritative Sources](#authoritative-sources) + +## What you are deploying + +| Piece | Where it runs | Required? | +|---|---|---| +| Provisioning scripts | This repository, `.github/scripts/provisioning/` | Yes | +| Provisioning workflow | GitHub Actions in this repository | Yes | +| Roster of record (`roster.json`) | A private admin repository | Yes | +| Provisioning identity | A GitHub App in the `Community-Access` org | Yes (production) | +| Learning Room template | `Community-Access/learning-room-template` | Yes (already exists) | +| Flask companion | A small host you control (or none) | No, optional | + +The critical path is the first five rows. The companion is a convenience at the edges +and can be skipped entirely; the issue-form front door and admin-issue dashboard carry +the workshop without it. + +## Prerequisites + +- Owner or admin access to the `Community-Access` GitHub organization. +- The Learning Room template repository exists and passes template validation + (`scripts/classroom/Test-LearningRoomTemplate.ps1`). +- A private admin repository you control, to hold the roster and provisioning log. +- Node.js 20 or newer for local runs and tests. +- Python 3.12 or newer if you deploy the companion. +- The `gh` CLI authenticated, for the convenience commands below. + +Confirm your toolchain: + +```bash +node --version # v20+ +python --version # 3.12+ +gh auth status +``` + +## Phase 1: Prepare the admin roster repository + +The roster of record lives in a private repository so intake data is never public. + +1. Create (or choose) a private repository, for example `Community-Access/glow-admin`. +2. Add a starter roster at its root. Copy the shape from + [examples/roster.example.json](examples/roster.example.json), or start empty: + + ```json + { "version": 1, "cohorts": [], "learners": [] } + ``` + +3. Commit it as `roster.json`. The provisioning run will also create and maintain + `provisioning-log.json` next to it. + +Verification: the file validates locally. + +```bash +node -e "require('./.github/scripts/provisioning/roster').parseRoster(require('fs').readFileSync('roster.json','utf8')); console.log('roster ok')" +``` + +## Phase 2: Create the provisioning GitHub App + +A GitHub App is the production identity because it is not tied to a human account, +mints short-lived tokens, and uses fine-grained least-privilege permissions +(SPEC.md section 7.2). Create it once. + +1. In the organization, go to Settings, Developer settings, GitHub Apps, New GitHub App. +2. Name it (for example `GLOW Provisioning`). Set a homepage URL (the repo is fine). +3. Disable the webhook (this App is called by Actions, not by webhooks). +4. Grant only these repository permissions, nothing more: + - Administration: Read and write (create student repositories). + - Contents: Read and write (seed and heal repository content). + - Metadata: Read-only (mandatory baseline). + - Issues: Read and write (optional; only if the App seeds the first issue). +5. Create the App, then note the numeric App ID. +6. Generate a private key; a `.pem` file downloads. Keep it secret. +7. Install the App on the `Community-Access` organization, scoped to the template + repository and the student repositories (or all repositories if simpler for now). +8. Open the installation and note the numeric Installation ID (it is in the + installation settings URL). + +Verification: you now hold three values: App ID, Installation ID, and the PEM key. + +## Phase 3: Configure variables and secrets + +Set these on this repository (Settings, Secrets and variables, Actions). Using an +Environment named `provisioning` is recommended so you can add required reviewers. + +Repository or environment variables: + +| Variable | Value | +|---|---| +| `PROVISIONING_MODE` | `github-app` | +| `LEARNING_ROOM_TEMPLATE_REPO` | `Community-Access/learning-room-template` | +| `PROVISIONING_STUDENT_OWNER` | `Community-Access` | +| `ADMIN_ROSTER_REPO` | `Community-Access/glow-admin` (your admin repo) | + +Secrets: + +| Secret | Value | +|---|---| +| `PROVISIONING_APP_ID` | The App ID from Phase 2 | +| `PROVISIONING_APP_INSTALLATION_ID` | The Installation ID from Phase 2 | +| `PROVISIONING_APP_PRIVATE_KEY` | The full contents of the `.pem` file | +| `PRIVATE_STUDENT_DATA_TOKEN` | A token that can check out and push to the admin repo | + +Using `gh` for the App secrets (run from this repository): + +```bash +gh secret set PROVISIONING_APP_ID --body "123456" +gh secret set PROVISIONING_APP_INSTALLATION_ID --body "98765432" +gh secret set PROVISIONING_APP_PRIVATE_KEY < glow-provisioning.private-key.pem +``` + +Security rules: the PEM lives only in Secrets, never in code or a public repo. Mint +tokens on demand and never persist them (the workflow already does this). Document and +rehearse key rotation; rotate on any suspected exposure. + +## Phase 4: Deploy and smoke-test provisioning + +The provisioning workflow ships in this repository at +[../.github/workflows/provision-learning-rooms.yml](../.github/workflows/provision-learning-rooms.yml). +Merging this branch deploys it. It runs on a 30-minute schedule and on demand. + +Smoke test with a single test account before any real cohort: + +1. Add one test learner to the admin `roster.json` (a throwaway GitHub account you + control), `provision_state` set to `pending`. +2. In the Actions tab, run **Provision Learning Rooms** with `dry_run` checked. + Confirm it lists exactly your test learner. +3. Run it again with `dry_run` unchecked. Confirm: + - A private student repository is created from the template. + - The test account receives a repository invitation. + - The roster entry flips to `provisioned` and `provisioning-log.json` records + `created`. +4. Prove idempotency: run it a second time. The log should record `already-exists` + and make no changes. + +You can also run it locally against a checkout of the admin repo: + +```bash +LEARNING_ROOM_TEMPLATE_REPO=Community-Access/learning-room-template \ +PROVISIONING_STUDENT_OWNER=Community-Access \ +PROVISIONING_MODE=github-app \ +PROVISIONING_APP_ID=123456 \ +PROVISIONING_APP_INSTALLATION_ID=98765432 \ +PROVISIONING_APP_PRIVATE_KEY="@glow-provisioning.private-key.pem" \ +node .github/scripts/provisioning/provision-cli.js --roster roster.json --log provisioning-log.json +``` + +Verification: a healthy student repository exists, the invite arrived, the roster and +log updated, and a re-run heals rather than duplicates. + +## Phase 5: Deploy the optional companion + +Skip this phase entirely if you are not running the companion. The workshop is fully +functional without it. + +The companion lives in [../companion/](../companion/). See its +[README](../companion/README.md) for full detail. Production deployment outline: + +1. Provision a small host with Python 3.12+ and TLS (a reverse proxy terminating + HTTPS in front of the app). +2. Install dependencies and a WSGI server: + + ```bash + cd companion + python -m venv .venv + .venv/bin/pip install -r requirements.txt gunicorn + ``` + +3. Provide configuration via the environment (never in code): + + ```bash + export COMPANION_SECRET_KEY="$(python -c 'import secrets;print(secrets.token_hex(32))')" + export COMPANION_FACILITATOR_TOKEN="a-strong-random-token" + export COMPANION_ROSTER_PATH="/srv/glow/roster.json" + export COMPANION_SECURE_COOKIES=1 + ``` + +4. Run behind gunicorn (bind to localhost; let the proxy handle TLS and the public port): + + ```bash + .venv/bin/gunicorn --workers 2 --bind 127.0.0.1:8000 app:app + ``` + +5. Wire the roster: the companion writes registrations to `COMPANION_ROSTER_PATH`. A + separate scheduled job (or a small commit hook) must sync that file to the admin + repository so provisioning picks it up. The companion deliberately does not push to + GitHub itself; it only owns the local roster file through the shared contract. + +Verification: + +- `GET /healthz` returns `{"status":"ok"}`. +- The registration page loads, and a screen reader pass (NVDA, JAWS, or VoiceOver) + confirms landmarks, labeled fields, the error summary, and live-region messages. +- Facilitator sign-in with the token reaches the dashboard; a bad token is rejected. +- Response headers include `Content-Security-Policy` and `X-Frame-Options: DENY`. +- A registration appears in `roster.json` with `provision_state` `pending`. + +## Phase 6: Go-live checklist + +Do not open a cohort until every item holds. This complements the release gate in +[../GO-LIVE-QA-GUIDE.md](../GO-LIVE-QA-GUIDE.md). + +- [ ] Template validation passes; the template has all required workflows. +- [ ] Admin `roster.json` exists and validates. +- [ ] The GitHub App is installed with only the four permissions listed. +- [ ] All variables and secrets are set on the provisioning environment. +- [ ] A single-account smoke test provisioned cleanly and a re-run reported + `already-exists` (idempotency proven). +- [ ] Org seat and repository quotas are sufficient for the cohort size. +- [ ] If used, the companion passes its accessibility and security verification. +- [ ] The manual fallback (issue-form registration, Classroom invite during + transition) is documented and ready. + +## Operating a cohort + +- **Provision on registration, not big-bang.** Let the 30-minute schedule trickle new + learners in. This keeps you far below any rate limit regardless of cohort size. +- **Watch the roster and log.** `failed` entries and `error` log lines are your signal + to intervene; re-running provisioning heals pending and failed entries. +- **Recover a stuck learner.** Fix the root cause (seat, name clash, permission), then + re-run provisioning. Only pending and failed entries are retried; healthy learners + are untouched. +- **Day 2 and progression** continue to run on the existing deterministic text signals + (`ack`, `day1-complete`); the owned provisioning swap does not change them. + +## Rollback and fallback + +Because the owned core is additive and the downstream system cannot tell which method +created a repository, rollback is low-risk. + +- **Disable owned provisioning.** Turn off the schedule on the provisioning workflow + (or set its environment to require manual approval) and fall back to the Classroom + invite during the transition window. No learner data is lost; the roster is still the + source of truth. +- **Companion outage.** None of the critical path depends on it. Point learners at the + issue-form front door and read the admin-issue dashboard until it returns. +- **Bad App credential.** Re-issue the App private key, update + `PROVISIONING_APP_PRIVATE_KEY`, and re-run. Idempotency means the re-run heals. + +## Reference: configuration surface + +See SPEC.md section 16 for the authoritative list. Summary: + +| Name | Type | Purpose | +|---|---|---| +| `PROVISIONING_MODE` | variable | `github-app` (production) or `actions-bot` (spike) | +| `LEARNING_ROOM_TEMPLATE_REPO` | variable | `owner/name` of the template | +| `PROVISIONING_STUDENT_OWNER` | variable | Org or user that owns student repos | +| `ADMIN_ROSTER_REPO` | variable | `owner/name` of the private admin repo | +| `PROVISIONING_APP_ID` | secret | GitHub App ID (App mode) | +| `PROVISIONING_APP_INSTALLATION_ID` | secret | Installation ID (App mode) | +| `PROVISIONING_APP_PRIVATE_KEY` | secret | PEM private key (App mode) | +| `PROVISIONING_TOKEN` | secret | Fine-grained PAT (actions-bot spike only) | +| `PRIVATE_STUDENT_DATA_TOKEN` | secret | Checkout and push to the admin repo | +| `COMPANION_SECRET_KEY` | env | Flask session signing key (companion) | +| `COMPANION_FACILITATOR_TOKEN` | env | Facilitator sign-in token (companion) | +| `COMPANION_ROSTER_PATH` | env | Path to `roster.json` (companion) | + +## Authoritative Sources + +Use these official references when you need the current source of truth for facts in +this document. + +- [Registering a GitHub App](https://docs.github.com/en/apps/creating-github-apps/registering-a-github-app/registering-a-github-app) +- [Authenticating as a GitHub App installation](https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/authenticating-as-a-github-app-installation) +- [Using secrets in GitHub Actions](https://docs.github.com/en/actions/security-for-github-actions/security-guides/using-secrets-in-github-actions) +- [Generate a repository from a template (REST)](https://docs.github.com/en/rest/repos/repos#create-a-repository-using-a-template) +- [Rate limits for the REST API](https://docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api) + +### Section-Level Source Map + +Use this map to verify facts for each major section in this file. + +- **What you are deploying, Prerequisites, Phase 1, Operating a cohort:** [Generate a repository from a template (REST)](https://docs.github.com/en/rest/repos/repos#create-a-repository-using-a-template), [Rate limits for the REST API](https://docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api) +- **Phase 2: Create the provisioning GitHub App:** [Registering a GitHub App](https://docs.github.com/en/apps/creating-github-apps/registering-a-github-app/registering-a-github-app), [Authenticating as a GitHub App installation](https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/authenticating-as-a-github-app-installation) +- **Phase 3: Configure variables and secrets, Reference: configuration surface:** [Using secrets in GitHub Actions](https://docs.github.com/en/actions/security-for-github-actions/security-guides/using-secrets-in-github-actions) +- **Phase 4, Phase 5, Phase 6, Rollback and fallback:** [Authenticating as a GitHub App installation](https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/authenticating-as-a-github-app-installation), [Using secrets in GitHub Actions](https://docs.github.com/en/actions/security-for-github-actions/security-guides/using-secrets-in-github-actions) diff --git a/admin/HYBRID_REVIEW_INDEX.md b/admin/HYBRID_REVIEW_INDEX.md new file mode 100644 index 00000000..f2356185 --- /dev/null +++ b/admin/HYBRID_REVIEW_INDEX.md @@ -0,0 +1,117 @@ +# Hybrid Plan: Review Index + +This is the single entry point for reviewing everything built for the **Hybrid plan** +(Option C): a dependable, GitHub-native provisioning core that replaces GitHub Classroom, +plus an optional, fully accessible Flask companion at the edges. It is described in +[golden.md](../golden.md) (vision) and specified in [SPEC.md](../SPEC.md) (technical spec). + +Most functional files stay where the repository expects them (workflows under +`.github/workflows/`, Node scripts under `.github/scripts/`) so the system keeps working. +This page points you to all of them, grouped by concern, in a suggested reading order. + +## Suggested review order + +1. **Start with the vision and spec** so the rest has context. +2. **Read the deployment guide** to see how it is operated end to end. +3. **Skim the provisioning core** (the critical path). +4. **Skim the optional companion** (the edges). +5. **Confirm the tests and CI wiring.** + +--- + +## 1. Planning and specification (repo root) + +| File | What it is | +| --- | --- | +| [golden.md](../golden.md) | Vision document for the Hybrid plan. | +| [SPEC.md](../SPEC.md) | Technical specification, including an Implementation status section. | +| [roster.schema.json](../roster.schema.json) | JSON Schema for the owned roster of record. | + +## 2. Documentation (`admin/`) + +| File | What it is | +| --- | --- | +| [HYBRID_DEPLOYMENT_GUIDE.md](HYBRID_DEPLOYMENT_GUIDE.md) | Phased, verification-driven deployment guide (start here to operate it). | +| [OWNED_PROVISIONING.md](OWNED_PROVISIONING.md) | Operator guide: data model, running provisioning, failure recovery. | +| [COHORT_PROVISIONING.md](COHORT_PROVISIONING.md) | Existing cohort doc, now pointing at the owned subsystem. | +| [examples/roster.example.json](examples/roster.example.json) | Example owned roster you can copy. | + +## 3. Provisioning core (`.github/scripts/provisioning/`) — the critical path + +These stay in place because the workflow and CLI load them from here. + +| File | Responsibility | +| --- | --- | +| `.github/scripts/provisioning/roster.js` | Owned roster contract: parse, validate, upsert, serialize, redact. Keyed on `(github_handle, cohort_id)`. | +| `.github/scripts/provisioning/progress.js` | Derives learner progress from owned signals. | +| `.github/scripts/provisioning/github-app-auth.js` | RS256 GitHub App JWT + installation-token minting (Node `crypto`, no deps). | +| `.github/scripts/provisioning/github-client.js` | Minimal REST client; `fetch` adapter (CLI) + `fromOctokit` adapter (workflows). | +| `.github/scripts/provisioning/provision-core.js` | Idempotent, serial, backoff-aware provisioning algorithm. | +| `.github/scripts/provisioning/provision-cli.js` | Standalone CLI runner. | +| `.github/scripts/provisioning/__tests__/` | 6 test files, 55 tests, run with `node --test`. | + +## 4. Optional accessible companion (`companion/`) — the edges + +Fully self-contained Flask app. It writes a local `roster.json`; a separate sync job +commits it to the admin repo. It deliberately does not push to GitHub itself. + +| Path | What it is | +| --- | --- | +| [companion/README.md](../companion/README.md) | Companion overview, run, and security/accessibility notes. | +| `companion/app.py` | Flask application factory and routes. | +| `companion/roster_store.py` | Python mirror of the owned roster contract. | +| `companion/templates/` | WCAG 2.2 AA templates (base, register, success, login, dashboard, error). | +| `companion/static/styles.css` | Accessible styling. | +| `companion/requirements.txt` | Python dependencies (Flask). | +| `companion/tests/` | 2 test files, 25 tests (`python -m unittest`). | + +## 5. CI wiring and commands + +| File | What changed | +| --- | --- | +| `.github/workflows/provision-learning-rooms.yml` | New provisioning workflow: scheduled + manual `dry_run`; `github-app` and `actions-bot` modes. | +| `.github/workflows/automation-tests.yml` | Added provisioning + companion test steps. | +| [package.json](../package.json) | Added `test:provisioning` script. | + +Run the suites locally: + +```bash +# Existing automation tests +npm run test:automation + +# Provisioning subsystem (Node, no install needed) +npm run test:provisioning + +# Flask companion (Python) +cd companion +python -m venv .venv && .venv/bin/pip install -r requirements.txt +.venv/bin/python -m unittest discover -s tests +``` + +## Commits on this branch + +The work landed in two commits on `feature/hybrid-owned-provisioning`: + +- `d6dd05ddc` — provisioning core, companion, tests, docs, schema, CI wiring. +- `89bc204bd` — Hybrid deployment guide + cross-link. + +This index adds one more documentation commit. Nothing was moved out of its +conventional location, so the workflows and CLI continue to resolve their imports. + +## Authoritative Sources + +Use these official references when you need the current source of truth for facts in +this document. + +- [GitHub Actions workflows](https://docs.github.com/en/actions/using-workflows/about-workflows) +- [Authenticating as a GitHub App installation](https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/authenticating-as-a-github-app-installation) +- [Web Content Accessibility Guidelines (WCAG) 2.2](https://www.w3.org/TR/WCAG22/) + +### Section-Level Source Map + +Use this map to verify facts for each major section in this file. + +- **Planning and specification, Provisioning core:** [golden.md](../golden.md), [SPEC.md](../SPEC.md) +- **CI wiring and commands:** [GitHub Actions workflows](https://docs.github.com/en/actions/using-workflows/about-workflows) +- **Provisioning core, Documentation:** [Authenticating as a GitHub App installation](https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/authenticating-as-a-github-app-installation) +- **Optional accessible companion:** [Web Content Accessibility Guidelines (WCAG) 2.2](https://www.w3.org/TR/WCAG22/) diff --git a/admin/OWNED_PROVISIONING.md b/admin/OWNED_PROVISIONING.md new file mode 100644 index 00000000..73f304b9 --- /dev/null +++ b/admin/OWNED_PROVISIONING.md @@ -0,0 +1,210 @@ +# Owned, GitHub-native Provisioning + +This is the operator guide for the GitHub-native provisioning subsystem: the owned +replacement for GitHub Classroom described in [golden.md](../golden.md) and specified +in [SPEC.md](../SPEC.md) sections 6 and 7. It explains the data model, how to run +provisioning, how to recover from failure, and how the optional Flask companion fits +in at the edges. For first-time setup and deployment, follow the +[Hybrid Deployment Guide](HYBRID_DEPLOYMENT_GUIDE.md) first. To review the whole +deliverable from one place, see [HYBRID_REVIEW_INDEX.md](HYBRID_REVIEW_INDEX.md). + +The whole point of this subsystem is captured in one promise: a vendor sunset is a +non-event. Provisioning, roster, and progress are owned and reconstructable, so no +future change to GitHub Classroom can strand a cohort. + +## Table of contents + +- [Why this exists](#why-this-exists) +- [The three owned sources of truth](#the-three-owned-sources-of-truth) +- [How provisioning works](#how-provisioning-works) +- [One-time setup](#one-time-setup) +- [Running a cohort](#running-a-cohort) +- [Idempotency and self-healing](#idempotency-and-self-healing) +- [Failure modes and recovery](#failure-modes-and-recovery) +- [The optional Flask companion](#the-optional-flask-companion) +- [Security and least privilege](#security-and-least-privilege) +- [Local development and testing](#local-development-and-testing) +- [Authoritative Sources](#authoritative-sources) + +## Why this exists + +GitHub Classroom does only two things for this workshop: it copies a template +repository into a per-student private repository, and it maps a GitHub identity to a +roster entry. Everything else already lives in infrastructure the project controls. +This subsystem replaces those two things with code we own, so the critical learner +path no longer depends on a single vendor feature. + +## The three owned sources of truth + +The decoupling contract requires three owned, reconstructable records. See SPEC.md +section 6 for the full schemas. + +1. **Roster of record.** One canonical JSON document mapping each learner handle to + cohort, path, provisioning state, and progression status. Lives in the private + admin repository as `roster.json`. Schema and validation live in + [roster.js](../.github/scripts/provisioning/roster.js) and + [roster.schema.json](../roster.schema.json). An example is in + [examples/roster.example.json](examples/roster.example.json). +2. **Progress of record.** Never authored by a vendor. Derived from deterministic + signals the project controls (challenge issue state, PR closing keywords, labels, + and the plain-text signals `ack` and `day1-complete`) by + [progress.js](../.github/scripts/provisioning/progress.js). +3. **Provisioning of record.** An append-only log of provisioning attempts and + outcomes (`provisioning-log.json`), sufficient to prove a repository is correctly + seeded and to safely re-run. + +Reconstruction rule: running the idempotent provisioning action against the roster +reproduces a healthy state for every learner, with no third party involved. + +## How provisioning works + +The provisioning subsystem is plain Node with no third-party dependencies (it uses +built-in `crypto` and global `fetch`). The pieces are: + +| File | Role | +|---|---| +| [roster.js](../.github/scripts/provisioning/roster.js) | Owned roster: parse, validate, upsert, serialize, redact | +| [progress.js](../.github/scripts/provisioning/progress.js) | Derive learner status from deterministic signals | +| [github-app-auth.js](../.github/scripts/provisioning/github-app-auth.js) | Sign an App JWT and mint a short-lived installation token | +| [github-client.js](../.github/scripts/provisioning/github-client.js) | Minimal GitHub REST client (fetch or Octokit) | +| [provision-core.js](../.github/scripts/provisioning/provision-core.js) | The idempotent, serial, backoff provisioning algorithm | +| [provision-cli.js](../.github/scripts/provisioning/provision-cli.js) | Standalone runner used by the workflow | +| [provision-learning-rooms.yml](../.github/workflows/provision-learning-rooms.yml) | Scheduled and manual workflow wrapper | + +The algorithm (SPEC.md section 7.2b) runs serially with a short delay and exponential +backoff, not a parallel fan-out, to stay clear of GitHub secondary rate limits. For +each pending or failed learner it: checks whether the repository exists; creates it +from the template if not; ensures the learner is a collaborator; verifies the required +workflow set is present; and records the outcome. Every step is safe to repeat. + +## One-time setup + +Provisioning supports two modes via the `PROVISIONING_MODE` variable. + +### Production: GitHub App (`github-app`) + +A GitHub App is the production identity because it is not tied to a human account, +mints short-lived tokens, and uses fine-grained least-privilege permissions. + +1. Create a GitHub App in the `Community-Access` organization with only these + permissions: Repository administration (write), Contents (write), Metadata (read), + and optionally Issues (write). Nothing more. +2. Install the App on the organization, scoped to the template and student repositories. +3. Generate a private key (PEM). Store these as repository or environment secrets: + - `PROVISIONING_APP_ID` + - `PROVISIONING_APP_PRIVATE_KEY` (the PEM contents) + - `PROVISIONING_APP_INSTALLATION_ID` +4. Set repository variables: + - `PROVISIONING_MODE` = `github-app` + - `LEARNING_ROOM_TEMPLATE_REPO` = `Community-Access/learning-room-template` + - `PROVISIONING_STUDENT_OWNER` = `Community-Access` + - `ADMIN_ROSTER_REPO` = the private admin repository holding `roster.json` +5. Provide `PRIVATE_STUDENT_DATA_TOKEN`, a token that can check out and push to the + admin roster repository. + +### Phase 1 spike only: Actions bot (`actions-bot`) + +For an early validation spike you may use a least-privilege fine-grained PAT in +`PROVISIONING_TOKEN` with `PROVISIONING_MODE` = `actions-bot`. Because the downstream +system cannot tell which mode created a repository, spiking with a PAT and graduating +to the App loses nothing. Do not use the PAT path for a real cohort: a PAT is bound to +a person and is a single point of failure. + +## Running a cohort + +Provisioning is designed to run as a trickle, on registration, rather than as a +big-bang on go-live morning. The scheduled workflow picks up newly registered learners +every 30 minutes; you can also run it on demand. + +- **Dry run (no changes).** In the Actions tab, run *Provision Learning Rooms* with + the `dry_run` input checked. It lists who would be provisioned. +- **Provision.** Run the same workflow with `dry_run` unchecked. It provisions every + pending or failed learner, commits the updated `roster.json` and + `provisioning-log.json` back to the admin repository, and prints a summary. +- **Local run.** From a checkout of the admin repo: + + ```bash + LEARNING_ROOM_TEMPLATE_REPO=Community-Access/learning-room-template \ + PROVISIONING_STUDENT_OWNER=Community-Access \ + PROVISIONING_MODE=actions-bot PROVISIONING_TOKEN=*** \ + node .github/scripts/provisioning/provision-cli.js \ + --roster roster.json --log provisioning-log.json + ``` + + Add `--dry-run` to preview without making changes. + +## Idempotency and self-healing + +The provisioning action is idempotent on `(github_handle, cohort_id)`: + +- Running it twice is safe. An existing, complete repository is recorded as + `already-exists` and left untouched. +- A re-run resumes a half-finished batch instead of duplicating work. +- If a repository exists but is missing required workflows, the run heals it (when a + content-seeding capability is available) or fails loudly so the watchdog and the + facilitator see it before a learner does. + +Always prove idempotency before go-live by running provisioning twice and confirming +the second run reports `already-exists` for healthy learners and makes no changes. + +## Failure modes and recovery + +| Symptom | What it means | Recovery | +|---|---|---| +| One learner is `failed` in the roster | Repo creation or verification failed for that entry | Fix the cause (seat, permission, name clash), then re-run provisioning. Only pending/failed entries are retried. | +| `provisioning-log.json` shows `error` with a rate-limit detail | Secondary rate limit during a burst | Re-run. The algorithm already backs off; idempotency means a re-run heals. | +| Repo exists but missing workflows | Partial seed | Re-run; the verify gate re-checks and heals or flags. | +| App token mint fails | Bad App ID, key, or installation ID | Re-check the three App secrets; rotate the private key if exposure is suspected. | + +No learner should ever be the first to discover a failure. The watchdog and the +facilitator find it first. + +## The optional Flask companion + +The companion in [../companion/](../companion/) is strictly optional and lives at the +edges. It renders the owned roster more nicely (an accessible registration front door +and a facilitator dashboard) but never holds state the learner depends on. If it is +down, the GitHub-native issue-form front door and the admin-issue dashboard carry the +entire workshop. See [../companion/README.md](../companion/README.md). + +## Security and least privilege + +- Grant only the four App permissions listed above. Anything more fails the security + review in SPEC.md section 13. +- Store the App ID, installation ID, and private key only in GitHub Secrets. Never in + code, never in a public repository. +- Mint the installation token at the start of each run and never persist it. +- Document and rehearse private-key rotation; rotate on any suspected exposure. +- The companion authenticates facilitator actions, protects forms with per-session + CSRF tokens, validates and encodes all input, and sets strict security headers. + +## Local development and testing + +```bash +# Provisioning subsystem (Node, no install needed) +npm run test:provisioning + +# Flask companion (Python) +cd companion +python -m venv .venv && .venv/bin/pip install -r requirements.txt +.venv/bin/python -m unittest discover -s tests +``` + +## Authoritative Sources + +Use these official references when you need the current source of truth for facts in +this document. + +- [Authenticating as a GitHub App installation](https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/authenticating-as-a-github-app-installation) +- [Generate a repository from a template (REST)](https://docs.github.com/en/rest/repos/repos#create-a-repository-using-a-template) +- [Rate limits for the REST API](https://docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api) +- [Choosing permissions for a GitHub App](https://docs.github.com/en/apps/creating-github-apps/registering-a-github-app/choosing-permissions-for-a-github-app) + +### Section-Level Source Map + +Use this map to verify facts for each major section in this file. + +- **Why this exists, The three owned sources of truth:** [golden.md](../golden.md), [SPEC.md](../SPEC.md) +- **How provisioning works, Idempotency and self-healing:** [Generate a repository from a template (REST)](https://docs.github.com/en/rest/repos/repos#create-a-repository-using-a-template), [Rate limits for the REST API](https://docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api) +- **One-time setup, Security and least privilege:** [Authenticating as a GitHub App installation](https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/authenticating-as-a-github-app-installation), [Choosing permissions for a GitHub App](https://docs.github.com/en/apps/creating-github-apps/registering-a-github-app/choosing-permissions-for-a-github-app) +- **Running a cohort, Failure modes and recovery, The optional Flask companion, Local development and testing:** [SPEC.md](../SPEC.md), [golden.md](../golden.md) diff --git a/admin/examples/roster.example.json b/admin/examples/roster.example.json new file mode 100644 index 00000000..e9e3e63c --- /dev/null +++ b/admin/examples/roster.example.json @@ -0,0 +1,35 @@ +{ + "version": 1, + "generated_at": "2026-01-15T09:00:00.000Z", + "cohorts": [ + { + "cohort_id": "spring-2026", + "label": "Spring 2026 cohort", + "opens_at": "2026-03-02T15:00:00.000Z" + } + ], + "learners": [ + { + "github_handle": "ada-lovelace", + "cohort_id": "spring-2026", + "path": "day1-day2", + "learning_room_repo": "Community-Access/learning-room-spring-2026-ada-lovelace", + "provision_state": "provisioned", + "status": "active-day1", + "registered_at": "2026-01-14T18:22:00.000Z", + "last_signal_at": "2026-03-02T16:05:00.000Z", + "notes": "" + }, + { + "github_handle": "grace-hopper", + "cohort_id": "spring-2026", + "path": "day2-only", + "learning_room_repo": null, + "provision_state": "pending", + "status": "awaiting-ack", + "registered_at": "2026-01-15T08:40:00.000Z", + "last_signal_at": null, + "notes": "" + } + ] +} diff --git a/companion/.gitignore b/companion/.gitignore new file mode 100644 index 00000000..8bda93fd --- /dev/null +++ b/companion/.gitignore @@ -0,0 +1,8 @@ +# Local instance data should never be committed. +.venv/ +venv/ +instance/ +*.local.json +__pycache__/ +*.pyc +.pytest_cache/ diff --git a/companion/README.md b/companion/README.md new file mode 100644 index 00000000..0f4eb09b --- /dev/null +++ b/companion/README.md @@ -0,0 +1,108 @@ +# GLOW Companion (optional, accessible front door) + +The companion is the optional Flask service that sits at the edges of the Hybrid +architecture in [golden.md](../golden.md) (Option C) and [SPEC.md](../SPEC.md) +section 11. It makes a good thing nicer; it never makes a working thing necessary. + +If this service is ever down, learners can still register and complete the entire +workshop on GitHub through the issue-form front door, and facilitators can still read +the admin-issue dashboard. The companion is non-critical by design. + +## What it provides + +- An accessible registration landing page that writes to the owned roster. +- A facilitator cohort dashboard that reads the owned roster and derived progress. +- A redacted `roster.json` endpoint for facilitator tooling. + +## What it deliberately does not do + +- It does not invent a parallel store of record. The only state it touches is the + owned roster JSON, through the same contract the provisioning subsystem uses. +- It does not provision repositories directly. Provisioning is the GitHub-native + subsystem's job (see [../admin/OWNED_PROVISIONING.md](../admin/OWNED_PROVISIONING.md)). + +## Accessibility (WCAG 2.2 AA) + +Every template is authored screen-reader-first and keyboard-operable: + +- Semantic landmarks (`header`, `nav`, `main`, `footer`) and a visible skip link. +- Correct heading order and labeled form controls with hint and error associations + via `aria-describedby` and `aria-invalid`. +- An error summary with in-page links to each invalid field. +- Live-region announcements for flash messages (`role="status"`, `aria-live`). +- Visible focus styles and no keyboard traps. +- No information conveyed by color alone; no emoji. +- A data table with a `caption`, `scope`-d headers, and an intro sentence. + +Run a manual screen reader pass (NVDA, JAWS, VoiceOver) before any cohort, per the +project accessibility testing guidance. + +## Security (OWASP-aware) + +- Output is auto-escaped by Jinja2. +- Per-session CSRF tokens protect every state-changing POST. +- Facilitator actions require sign-in; the token is compared in constant time. +- Input is validated (GitHub handle regex, cohort, path enum) before any write. +- Best-effort per-IP rate limiting on registration and sign-in. +- Strict security headers: a restrictive `Content-Security-Policy` (no inline + scripts), `X-Content-Type-Options`, `X-Frame-Options`, and `Referrer-Policy`. +- Secrets come only from the environment; cookies are `HttpOnly`, `SameSite=Lax`, + and `Secure` by default. + +## Configuration + +All configuration is environment-driven: + +| Variable | Purpose | Default | +|---|---|---| +| `COMPANION_SECRET_KEY` | Flask session signing key | random per process (set this in production) | +| `COMPANION_ROSTER_PATH` | Path to the owned `roster.json` | `roster.json` | +| `COMPANION_FACILITATOR_TOKEN` | Facilitator sign-in token | empty (dashboard locked until set) | +| `COMPANION_SECURE_COOKIES` | Set `0` only for local HTTP testing | `1` | +| `COMPANION_RATE_LIMIT_MAX` | Requests per window per IP | `10` | +| `COMPANION_RATE_LIMIT_WINDOW` | Rate-limit window in seconds | `300` | +| `PORT` | Port for the built-in dev server | `5000` | + +## Run locally + +```bash +cd companion +python -m venv .venv +.venv/bin/pip install -r requirements.txt # Windows: .venv\Scripts\pip install -r requirements.txt + +export COMPANION_SECRET_KEY="dev-only-not-a-real-secret" +export COMPANION_FACILITATOR_TOKEN="dev-token" +export COMPANION_SECURE_COOKIES=0 +export COMPANION_ROSTER_PATH="../admin/examples/roster.example.json" + +.venv/bin/python app.py +# Visit http://127.0.0.1:5000/ +``` + +For production, serve `app:app` behind a WSGI server (for example gunicorn) and a +TLS-terminating reverse proxy, set a strong `COMPANION_SECRET_KEY`, and point +`COMPANION_ROSTER_PATH` at a working copy of the admin roster that a separate job +commits back to the admin repository. + +## Test + +```bash +cd companion +.venv/bin/python -m unittest discover -s tests +``` + +The suite covers routing, CSRF enforcement, input validation, facilitator auth, +rate limiting, roster redaction, and the accessible page shell. + +## Routes + +| Method | Path | Auth | Purpose | +|---|---|---|---| +| GET | `/` | none | Redirects to `/register` | +| GET, POST | `/register` | none | Accessible registration form; writes the roster | +| GET | `/register/success` | none | Confirmation and next steps | +| GET, POST | `/login` | none | Facilitator sign-in | +| POST | `/logout` | session | Sign out | +| GET | `/dashboard` | facilitator | Cohort dashboard | +| GET | `/roster.json` | facilitator | Redacted roster JSON | +| GET | `/healthz` | none | Liveness probe | diff --git a/companion/app.py b/companion/app.py new file mode 100644 index 00000000..693358f8 --- /dev/null +++ b/companion/app.py @@ -0,0 +1,295 @@ +"""Optional accessible Flask companion for the GLOW workshop. + +This service lives strictly at the edges (golden.md Option C, SPEC.md section 11). +It makes a good thing nicer; it never makes a working thing necessary. If it is +down, the GitHub-native issue-form front door and admin-issue dashboard carry the +entire workshop. + +What it provides: + - An accessible registration landing page that writes to the owned roster. + - A facilitator cohort dashboard that reads the owned roster and progress. + +Hard constraints honored here (SPEC.md sections 11.2, 12, 13): + - WCAG 2.2 AA templates: landmarks, heading order, labeled controls, visible + focus, aria-live announcements, an error summary, no keyboard traps. + - OWASP-aware: Jinja2 autoescaping, per-session CSRF tokens, facilitator + authentication with constant-time comparison, input validation, rate + limiting, security headers, secrets only from the environment. + - Stateless about anything critical: the only store of record it touches is the + owned roster JSON, through the same contract the provisioning subsystem uses. +""" + +from __future__ import annotations + +import hmac +import os +import secrets +import time +from collections import defaultdict, deque +from datetime import datetime, timezone +from functools import wraps +from typing import Deque, Dict + +from flask import ( + Flask, + abort, + flash, + get_flashed_messages, + redirect, + render_template, + request, + session, + url_for, +) + +from roster_store import Roster, RosterError, is_valid_handle, PATHS + +# In-memory, best-effort rate limiter. Good enough for a low-traffic facilitator +# front door; a multi-process deployment should front this with a shared store. +_RATE_BUCKETS: Dict[str, Deque[float]] = defaultdict(deque) + + +def create_app(config: Dict | None = None) -> Flask: + app = Flask(__name__) + app.config.update( + SECRET_KEY=os.environ.get("COMPANION_SECRET_KEY") or secrets.token_hex(32), + ROSTER_PATH=os.environ.get("COMPANION_ROSTER_PATH", "roster.json"), + FACILITATOR_TOKEN=os.environ.get("COMPANION_FACILITATOR_TOKEN", ""), + SESSION_COOKIE_HTTPONLY=True, + SESSION_COOKIE_SAMESITE="Lax", + SESSION_COOKIE_SECURE=os.environ.get("COMPANION_SECURE_COOKIES", "1") == "1", + RATE_LIMIT_MAX=int(os.environ.get("COMPANION_RATE_LIMIT_MAX", "10")), + RATE_LIMIT_WINDOW=int(os.environ.get("COMPANION_RATE_LIMIT_WINDOW", "300")), + ) + if config: + app.config.update(config) + + register_security(app) + register_routes(app) + return app + + +# --------------------------------------------------------------------------- +# Security plumbing +# --------------------------------------------------------------------------- + +def register_security(app: Flask) -> None: + @app.after_request + def set_security_headers(response): + response.headers["X-Content-Type-Options"] = "nosniff" + response.headers["X-Frame-Options"] = "DENY" + response.headers["Referrer-Policy"] = "no-referrer" + # No inline scripts are used anywhere in the templates. + response.headers["Content-Security-Policy"] = ( + "default-src 'self'; img-src 'self'; style-src 'self'; " + "script-src 'self'; base-uri 'none'; form-action 'self'; " + "frame-ancestors 'none'" + ) + return response + + @app.context_processor + def inject_csrf(): + return {"csrf_token": _ensure_csrf_token()} + + +def _ensure_csrf_token() -> str: + token = session.get("_csrf_token") + if not token: + token = secrets.token_urlsafe(32) + session["_csrf_token"] = token + return token + + +def _check_csrf() -> None: + expected = session.get("_csrf_token", "") + provided = request.form.get("csrf_token", "") + if not expected or not hmac.compare_digest(expected, provided): + abort(400, description="Invalid or missing CSRF token.") + + +def _rate_limited(app: Flask, key: str) -> bool: + window = app.config["RATE_LIMIT_WINDOW"] + limit = app.config["RATE_LIMIT_MAX"] + now = time.time() + bucket = _RATE_BUCKETS[key] + while bucket and now - bucket[0] > window: + bucket.popleft() + if len(bucket) >= limit: + return True + bucket.append(now) + return False + + +def login_required(view): + @wraps(view) + def wrapped(*args, **kwargs): + if not session.get("facilitator"): + return redirect(url_for("login", next=request.path)) + return view(*args, **kwargs) + + return wrapped + + +# --------------------------------------------------------------------------- +# Roster persistence (the only store of record the companion touches) +# --------------------------------------------------------------------------- + +def load_roster(app: Flask) -> Roster: + path = app.config["ROSTER_PATH"] + if not os.path.exists(path): + return Roster.empty() + with open(path, "r", encoding="utf-8") as handle: + return Roster.parse(handle.read()) + + +def save_roster(app: Flask, roster: Roster) -> None: + path = app.config["ROSTER_PATH"] + directory = os.path.dirname(os.path.abspath(path)) + os.makedirs(directory, exist_ok=True) + with open(path, "w", encoding="utf-8") as handle: + handle.write(roster.to_json()) + + +# --------------------------------------------------------------------------- +# Routes +# --------------------------------------------------------------------------- + +def register_routes(app: Flask) -> None: + @app.route("/") + def index(): + return redirect(url_for("register")) + + @app.route("/healthz") + def healthz(): + return {"status": "ok"}, 200 + + @app.route("/register", methods=["GET", "POST"]) + def register(): + errors = [] + form = {"github_handle": "", "cohort_id": "", "path": "day1-day2"} + if request.method == "POST": + _check_csrf() + if _rate_limited(app, f"register:{request.remote_addr}"): + abort(429, description="Too many registrations from this address. Please wait and try again.") + form["github_handle"] = (request.form.get("github_handle") or "").strip() + form["cohort_id"] = (request.form.get("cohort_id") or "").strip() + form["path"] = (request.form.get("path") or "day1-day2").strip() + + if not is_valid_handle(form["github_handle"]): + errors.append(("github_handle", "Enter a valid GitHub username (letters, numbers, single hyphens).")) + if not form["cohort_id"]: + errors.append(("cohort_id", "Choose the cohort you are registering for.")) + if form["path"] not in PATHS: + errors.append(("path", "Choose a valid workshop path.")) + + if not errors: + roster = load_roster(app) + try: + roster.upsert( + { + "github_handle": form["github_handle"].lstrip("@"), + "cohort_id": form["cohort_id"], + "path": form["path"], + "provision_state": "pending", + "status": "awaiting-ack", + "registered_at": datetime.now(timezone.utc) + .isoformat() + .replace("+00:00", "Z"), + } + ) + save_roster(app, roster) + except RosterError as exc: + errors.append(("github_handle", str(exc))) + else: + flash("Registration received. Your Learning Room will be provisioned shortly.", "success") + return redirect(url_for("register_success", handle=form["github_handle"].lstrip("@"))) + + return render_template("register.html", errors=errors, form=form, paths=PATHS) + + @app.route("/register/success") + def register_success(): + handle = request.args.get("handle", "") + if not is_valid_handle(handle): + handle = "" + return render_template("register_success.html", handle=handle) + + @app.route("/login", methods=["GET", "POST"]) + def login(): + error = None + if request.method == "POST": + _check_csrf() + if _rate_limited(app, f"login:{request.remote_addr}"): + abort(429, description="Too many sign-in attempts. Please wait and try again.") + token = request.form.get("token", "") + expected = app.config["FACILITATOR_TOKEN"] + if expected and hmac.compare_digest(token, expected): + session["facilitator"] = True + target = request.args.get("next") or url_for("dashboard") + # Only allow local redirects. + if not target.startswith("/"): + target = url_for("dashboard") + return redirect(target) + error = "That facilitator access token was not recognized." + return render_template("login.html", error=error) + + @app.route("/logout", methods=["POST"]) + def logout(): + _check_csrf() + session.pop("facilitator", None) + flash("You have signed out.", "success") + return redirect(url_for("login")) + + @app.route("/dashboard") + @login_required + def dashboard(): + roster = load_roster(app) + cohort_filter = request.args.get("cohort", "").strip() + learners = roster.learners + if cohort_filter: + learners = [l for l in learners if l["cohort_id"] == cohort_filter] + learners = sorted(learners, key=lambda l: (l["cohort_id"], l["github_handle"].lower())) + cohorts = sorted({l["cohort_id"] for l in roster.learners}) + summary = _summarize(roster.learners) + return render_template( + "dashboard.html", + learners=learners, + cohorts=cohorts, + cohort_filter=cohort_filter, + summary=summary, + ) + + @app.route("/roster.json") + @login_required + def roster_json(): + roster = load_roster(app) + return roster.public_view(), 200 + + @app.errorhandler(400) + def bad_request(err): + return render_template("error.html", code=400, message=err.description), 400 + + @app.errorhandler(404) + def not_found(err): + return render_template("error.html", code=404, message="Page not found."), 404 + + @app.errorhandler(429) + def too_many(err): + return render_template("error.html", code=429, message=err.description), 429 + + +def _summarize(learners): + summary = {"total": len(learners)} + for learner in learners: + key = f"provision_{learner['provision_state']}" + summary[key] = summary.get(key, 0) + 1 + status_key = f"status_{learner['status']}" + summary[status_key] = summary.get(status_key, 0) + 1 + return summary + + +# Module-level app for `flask run` / gunicorn `companion.app:app` usage. +app = create_app() + + +if __name__ == "__main__": + app.run(host="127.0.0.1", port=int(os.environ.get("PORT", "5000"))) diff --git a/companion/requirements.txt b/companion/requirements.txt new file mode 100644 index 00000000..840d434b --- /dev/null +++ b/companion/requirements.txt @@ -0,0 +1 @@ +Flask>=3.0,<4.0 diff --git a/companion/roster_store.py b/companion/roster_store.py new file mode 100644 index 00000000..06beee7d --- /dev/null +++ b/companion/roster_store.py @@ -0,0 +1,184 @@ +"""Owned roster store for the optional Flask companion. + +This is a faithful Python port of the roster contract in +.github/scripts/provisioning/roster.js (SPEC.md section 6.1). The companion +never invents a parallel state store of record: it reads and writes the same +roster JSON shape that the GitHub-native provisioning subsystem owns, so if the +companion disappears the roster is still valid and complete. + +Pure, synchronous, dependency-free (standard library only) so it is trivially +unit-testable and safe to run without a network call. +""" + +from __future__ import annotations + +import json +import re +from dataclasses import dataclass, field +from datetime import datetime, timezone +from typing import Any, Dict, List, Optional + +PROVISION_STATES = ("pending", "provisioned", "failed", "healed") +STATUSES = ( + "awaiting-ack", + "active-day1", + "day1-complete", + "day2-released", + "needs-info", +) +PATHS = ("day1-day2", "day2-only") + +# GitHub usernames: 1-39 chars, alphanumeric or single hyphens, no leading or +# trailing hyphen. Mirrors the regex in roster.js. +_HANDLE_RE = re.compile(r"^[A-Za-z0-9](?:[A-Za-z0-9]|-(?=[A-Za-z0-9])){0,38}$") + + +class RosterError(ValueError): + """Raised when roster data or an input fails validation.""" + + +def normalize_handle(handle: str) -> str: + return (handle or "").strip().lstrip("@").lower() + + +def is_valid_handle(handle: str) -> bool: + return bool(_HANDLE_RE.match((handle or "").lstrip("@"))) + + +def learner_key(handle: str, cohort_id: str) -> str: + return f"{normalize_handle(handle)}@@{(cohort_id or '').strip()}" + + +@dataclass +class Roster: + version: int = 1 + cohorts: List[Dict[str, Any]] = field(default_factory=list) + learners: List[Dict[str, Any]] = field(default_factory=list) + generated_at: Optional[str] = None + + @staticmethod + def empty() -> "Roster": + return Roster() + + @staticmethod + def parse(text: Optional[str]) -> "Roster": + if text is None or str(text).strip() == "": + return Roster.empty() + data = json.loads(text) + return Roster.from_dict(data) + + @staticmethod + def from_dict(data: Dict[str, Any]) -> "Roster": + if not isinstance(data, dict): + raise RosterError("Roster must be an object") + if data.get("version") != 1: + raise RosterError(f"Unsupported roster version: {data.get('version')}") + learners = data.get("learners") + if not isinstance(learners, list): + raise RosterError("Roster.learners must be a list") + seen = set() + for learner in learners: + problems = validate_learner(learner) + if problems: + raise RosterError( + f"Invalid learner {learner.get('github_handle')}: {'; '.join(problems)}" + ) + key = learner_key(learner["github_handle"], learner["cohort_id"]) + if key in seen: + raise RosterError(f"Duplicate learner for key {key}") + seen.add(key) + return Roster( + version=1, + cohorts=data.get("cohorts", []) or [], + learners=learners, + generated_at=data.get("generated_at"), + ) + + def find(self, handle: str, cohort_id: str) -> Optional[Dict[str, Any]]: + key = learner_key(handle, cohort_id) + for learner in self.learners: + if learner_key(learner["github_handle"], learner["cohort_id"]) == key: + return learner + return None + + def upsert(self, entry: Dict[str, Any]) -> Dict[str, Any]: + """Insert or update a learner keyed on (github_handle, cohort_id). + + Returns the stored learner record. Raises RosterError on invalid input. + """ + candidate = { + "github_handle": (entry.get("github_handle") or "").lstrip("@"), + "cohort_id": entry.get("cohort_id"), + "path": entry.get("path") or "day1-day2", + "provision_state": entry.get("provision_state") or "pending", + "status": entry.get("status") or "awaiting-ack", + } + problems = validate_learner(candidate) + if problems: + raise RosterError("; ".join(problems)) + existing = self.find(candidate["github_handle"], candidate["cohort_id"]) + if existing is not None: + existing.update({k: v for k, v in entry.items() if v is not None}) + return existing + record = { + "github_handle": candidate["github_handle"], + "cohort_id": candidate["cohort_id"], + "path": candidate["path"], + "learning_room_repo": entry.get("learning_room_repo"), + "provision_state": candidate["provision_state"], + "status": candidate["status"], + "registered_at": entry.get("registered_at"), + "last_signal_at": entry.get("last_signal_at"), + "notes": entry.get("notes", ""), + } + self.learners.append(record) + return record + + def to_json(self, now: Optional[datetime] = None) -> str: + now = now or datetime.now(timezone.utc) + learners = sorted( + self.learners, + key=lambda l: learner_key(l["github_handle"], l["cohort_id"]), + ) + payload = { + "version": 1, + "generated_at": now.astimezone(timezone.utc) + .isoformat() + .replace("+00:00", "Z"), + "cohorts": self.cohorts, + "learners": learners, + } + return json.dumps(payload, indent=2) + "\n" + + def public_view(self) -> Dict[str, Any]: + """Redacted view: never leaks facilitator notes.""" + return { + "version": 1, + "learners": [ + { + "github_handle": l["github_handle"], + "cohort_id": l["cohort_id"], + "path": l["path"], + "provision_state": l["provision_state"], + "status": l["status"], + } + for l in self.learners + ], + } + + +def validate_learner(learner: Any) -> List[str]: + problems: List[str] = [] + if not isinstance(learner, dict): + return ["learner must be an object"] + if not is_valid_handle(learner.get("github_handle", "")): + problems.append("invalid github_handle") + if not learner.get("cohort_id"): + problems.append("missing cohort_id") + if learner.get("path") not in PATHS: + problems.append(f"invalid path: {learner.get('path')}") + if learner.get("provision_state") not in PROVISION_STATES: + problems.append(f"invalid provision_state: {learner.get('provision_state')}") + if learner.get("status") not in STATUSES: + problems.append(f"invalid status: {learner.get('status')}") + return problems diff --git a/companion/static/styles.css b/companion/static/styles.css new file mode 100644 index 00000000..ff898543 --- /dev/null +++ b/companion/static/styles.css @@ -0,0 +1,259 @@ +/* GLOW companion styles. Accessibility-first: high contrast, visible focus, + generous spacing, no information conveyed by color alone. */ + +:root { + --ink: #11181c; + --bg: #ffffff; + --accent: #0a3d62; + --error: #8a1f11; + --muted: #44505a; + --border: #11181c; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; + font-size: 1.05rem; + line-height: 1.6; + color: var(--ink); + background: var(--bg); +} + +main { + max-width: 52rem; + margin: 0 auto; + padding: 1.5rem 1.25rem 3rem; +} + +header, +footer { + max-width: 52rem; + margin: 0 auto; + padding: 1rem 1.25rem; +} + +header { + border-bottom: 2px solid var(--border); + display: flex; + flex-wrap: wrap; + align-items: baseline; + justify-content: space-between; + gap: 1rem; +} + +footer { + border-top: 2px solid var(--border); + color: var(--muted); +} + +.brand { + font-weight: 700; + font-size: 1.2rem; + margin: 0; +} + +nav ul { + list-style: none; + display: flex; + flex-wrap: wrap; + gap: 1rem; + margin: 0; + padding: 0; + align-items: center; +} + +a { + color: var(--accent); +} + +a:focus, +button:focus, +input:focus, +select:focus, +.skip-link:focus { + outline: 3px solid var(--accent); + outline-offset: 2px; +} + +.skip-link { + position: absolute; + left: -999px; + top: 0; + background: var(--bg); + padding: 0.5rem 1rem; + border: 2px solid var(--accent); +} + +.skip-link:focus { + left: 0.5rem; + top: 0.5rem; + z-index: 10; +} + +h1 { + font-size: 1.9rem; + line-height: 1.25; +} + +h2 { + font-size: 1.35rem; + margin-top: 2rem; +} + +.field { + margin: 1.25rem 0; +} + +label, +legend { + display: block; + font-weight: 600; + margin-bottom: 0.35rem; +} + +.hint { + margin: 0 0 0.4rem; + color: var(--muted); + font-size: 0.95rem; +} + +input[type="text"], +input[type="password"], +select { + width: 100%; + max-width: 28rem; + padding: 0.6rem 0.7rem; + font-size: 1rem; + border: 2px solid var(--border); + border-radius: 4px; +} + +fieldset { + border: 2px solid var(--border); + border-radius: 4px; + padding: 1rem; +} + +.radio { + display: flex; + align-items: center; + gap: 0.5rem; + margin: 0.4rem 0; +} + +.radio label { + margin: 0; + font-weight: 400; +} + +button { + font-size: 1rem; + padding: 0.65rem 1.4rem; + background: var(--accent); + color: #ffffff; + border: 2px solid var(--accent); + border-radius: 4px; + cursor: pointer; +} + +button:hover { + background: #082f4a; +} + +.link-button { + background: none; + border: none; + color: var(--accent); + text-decoration: underline; + padding: 0; + font-size: 1rem; + cursor: pointer; +} + +.inline-form { + display: inline; +} + +.error-summary { + border: 3px solid var(--error); + padding: 1rem 1.25rem; + margin: 1.5rem 0; +} + +.error-summary h2 { + margin-top: 0; + color: var(--error); +} + +.error-summary a { + color: var(--error); + font-weight: 600; +} + +.field-error { + color: var(--error); + font-weight: 600; + margin: 0 0 0.4rem; +} + +.flash { + max-width: 52rem; + margin: 1rem auto 0; + padding: 0 1.25rem; +} + +.flash ul { + list-style: none; + margin: 0; + padding: 0; +} + +.flash-success { + border-left: 6px solid var(--accent); + background: #eef4f9; + padding: 0.75rem 1rem; +} + +.filter-form { + display: flex; + flex-wrap: wrap; + align-items: end; + gap: 0.75rem; + margin: 1rem 0; +} + +.filter-form label { + margin: 0; +} + +.filter-form select { + width: auto; +} + +table { + border-collapse: collapse; + width: 100%; + margin-top: 1rem; +} + +caption { + text-align: left; + margin-bottom: 0.5rem; + color: var(--muted); +} + +th, +td { + border: 1px solid var(--border); + padding: 0.55rem 0.7rem; + text-align: left; + vertical-align: top; +} + +thead th { + background: #eef4f9; +} diff --git a/companion/templates/base.html b/companion/templates/base.html new file mode 100644 index 00000000..f8f1fc66 --- /dev/null +++ b/companion/templates/base.html @@ -0,0 +1,53 @@ + + + + + + {% block title %}GIT Going with GitHub{% endblock %} - GLOW companion + + + + +
+

GIT Going with GitHub

+ +
+ + {% set messages = get_flashed_messages(with_categories=true) %} + {% if messages %} +
+
    + {% for category, message in messages %} +
  • {{ message }}
  • + {% endfor %} +
+
+ {% endif %} + +
+ {% block content %}{% endblock %} +
+ +
+

+ This optional companion is a convenience. If it is ever unavailable, you + can still register and complete the workshop entirely on GitHub. +

+
+ + diff --git a/companion/templates/dashboard.html b/companion/templates/dashboard.html new file mode 100644 index 00000000..a0beedca --- /dev/null +++ b/companion/templates/dashboard.html @@ -0,0 +1,66 @@ +{% extends "base.html" %} +{% block title %}Facilitator dashboard{% endblock %} +{% block content %} +

Cohort dashboard

+

+ This dashboard renders the owned roster of record. It is a read-only view; the + same data lives in the private admin repository, so this page can disappear + without losing anything. +

+ +

Summary

+

+ {{ summary.total }} learner(s) total. + Provisioned: {{ summary.get('provision_provisioned', 0) }}. + Pending: {{ summary.get('provision_pending', 0) }}. + Failed: {{ summary.get('provision_failed', 0) }}. + Healed: {{ summary.get('provision_healed', 0) }}. +

+ +
+ + + +
+ +

Learners

+{% if learners %} + + + + + + + + + + + + + + {% for l in learners %} + + + + + + + + + {% endfor %} + +
+ Roster of {{ learners|length }} learner(s){% if cohort_filter %} in cohort {{ cohort_filter }}{% endif %}. + Columns: GitHub username, cohort, path, provisioning state, and progression status. +
GitHub usernameCohortPathProvisioningStatusLearning Room
{{ l.github_handle }}{{ l.cohort_id }}{{ l.path }}{{ l.provision_state }}{{ l.status }} + {% if l.learning_room_repo %}{{ l.learning_room_repo }}{% else %}Not yet provisioned{% endif %} +
+{% else %} +

No learners match this view yet.

+{% endif %} +{% endblock %} diff --git a/companion/templates/error.html b/companion/templates/error.html new file mode 100644 index 00000000..4a36a1c8 --- /dev/null +++ b/companion/templates/error.html @@ -0,0 +1,7 @@ +{% extends "base.html" %} +{% block title %}Error {{ code }}{% endblock %} +{% block content %} +

Something went wrong (error {{ code }})

+

{{ message }}

+

Return to registration

+{% endblock %} diff --git a/companion/templates/login.html b/companion/templates/login.html new file mode 100644 index 00000000..4b8f8ee5 --- /dev/null +++ b/companion/templates/login.html @@ -0,0 +1,22 @@ +{% extends "base.html" %} +{% block title %}Facilitator sign in{% endblock %} +{% block content %} +

Facilitator sign in

+

Enter your facilitator access token to view the cohort dashboard.

+ +{% if error %} + +{% endif %} + +
+ +
+ + +
+ +
+{% endblock %} diff --git a/companion/templates/register.html b/companion/templates/register.html new file mode 100644 index 00000000..95940ddf --- /dev/null +++ b/companion/templates/register.html @@ -0,0 +1,81 @@ +{% extends "base.html" %} +{% block title %}Register{% endblock %} +{% block content %} +

Register for the workshop

+

+ Welcome. Tell us your GitHub username and which cohort you are joining. We will + create your private Learning Room and invite you to it. +

+ +{% if errors %} + +{% endif %} + +
+ + + {% set field_errors = {} %} + {% for field, message in errors %}{% set _ = field_errors.update({field: message}) %}{% endfor %} + +
+ +

For example, octocat. Do not include the @ sign.

+ {% if field_errors.get('github_handle') %} +

{{ field_errors['github_handle'] }}

+ {% endif %} + +
+ +
+ +

Your facilitator gives you this cohort identifier.

+ {% if field_errors.get('cohort_id') %} +

{{ field_errors['cohort_id'] }}

+ {% endif %} + +
+ +
+ Which path are you joining? + {% if field_errors.get('path') %} +

{{ field_errors['path'] }}

+ {% endif %} +
+ + +
+
+ + +
+
+ + +
+{% endblock %} diff --git a/companion/templates/register_success.html b/companion/templates/register_success.html new file mode 100644 index 00000000..5a61cf5c --- /dev/null +++ b/companion/templates/register_success.html @@ -0,0 +1,21 @@ +{% extends "base.html" %} +{% block title %}Registration received{% endblock %} +{% block content %} +

You are registered

+{% if handle %} +

Thank you, {{ handle }}. Your registration is recorded.

+{% else %} +

Thank you. Your registration is recorded.

+{% endif %} +

What happens next

+
    +
  1. Your private Learning Room repository is created automatically, usually within half an hour.
  2. +
  3. You receive a repository invitation in your GitHub notifications inbox.
  4. +
  5. Accept the invitation, open the first challenge issue, and reply with ack to begin.
  6. +
+

+ If you do not see an invitation within an hour, contact your facilitator. Nothing + is lost: provisioning is safe to re-run. +

+

Register another learner

+{% endblock %} diff --git a/companion/tests/test_app.py b/companion/tests/test_app.py new file mode 100644 index 00000000..10a9e74f --- /dev/null +++ b/companion/tests/test_app.py @@ -0,0 +1,185 @@ +import json +import os +import sys +import tempfile +import unittest + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from app import create_app # noqa: E402 + + +class CompanionAppTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.roster_path = os.path.join(self.tmp.name, "roster.json") + self.app = create_app( + { + "TESTING": True, + "SECRET_KEY": "test-secret", + "ROSTER_PATH": self.roster_path, + "FACILITATOR_TOKEN": "letmein", + "SESSION_COOKIE_SECURE": False, + "RATE_LIMIT_MAX": 100, + } + ) + self.client = self.app.test_client() + + def tearDown(self): + self.tmp.cleanup() + + def _csrf(self, path): + resp = self.client.get(path) + html = resp.get_data(as_text=True) + marker = 'name="csrf_token" value="' + start = html.index(marker) + len(marker) + end = html.index('"', start) + return html[start:end] + + def test_index_redirects_to_register(self): + resp = self.client.get("/") + self.assertEqual(resp.status_code, 302) + self.assertIn("/register", resp.headers["Location"]) + + def test_healthz(self): + resp = self.client.get("/healthz") + self.assertEqual(resp.status_code, 200) + self.assertEqual(resp.get_json()["status"], "ok") + + def test_register_page_is_accessible_shell(self): + resp = self.client.get("/register") + html = resp.get_data(as_text=True) + self.assertIn('lang="en"', html) + self.assertIn("Skip to main content", html) + self.assertIn('