From 3a08047101ee45888356e324b199cdff2f06bf57 Mon Sep 17 00:00:00 2001 From: GitHub Workshop Bot Date: Thu, 2 Jul 2026 15:15:48 -0700 Subject: [PATCH] fix(provisioning): seed Challenge 1 at provision time and tolerate pending invitations Provisioned learning rooms started with an empty Issues tab: the Student Progression Bot only fires on issue close or manual dispatch, so nothing ever created Challenge 1 and the progression chain never began (reported in #226 and #216; every room in the self-paced-2026 cohort was affected). - provision-core: after content verification, seed the first challenge issue via an optional client capability; a seeding failure marks the learner failed and surfaces to the watchdog. - seed-first-challenge: new provisioning module that runs the learning-room challenge-progression script against the student repo with the App installation token. Idempotent via the script's own duplicate check. - challenge-progression: validate assignability in the repository (assignees endpoint) instead of global user existence. At provision time the collaborator invitation is still pending, so assignment 422s; the issue is now created unassigned and later challenges are assigned to whoever closes the previous one. - SPEC.md 7.2a/7.2b updated to match; HTML regenerated. Co-Authored-By: Claude Fable 5 --- .../challenge-progression-assignee.test.js | 52 +++++++++++ .../__tests__/provision-core.test.js | 48 ++++++++++ .../__tests__/seed-first-challenge.test.js | 67 ++++++++++++++ .github/scripts/provisioning/provision-cli.js | 9 +- .../scripts/provisioning/provision-core.js | 12 +++ .../provisioning/seed-first-challenge.js | 72 +++++++++++++++ SPEC.md | 12 ++- html/SPEC.html | 12 ++- html/search-index.json | 24 ++--- .../.github/scripts/challenge-progression.js | 88 +++++++++++-------- 10 files changed, 342 insertions(+), 54 deletions(-) create mode 100644 .github/scripts/__tests__/challenge-progression-assignee.test.js create mode 100644 .github/scripts/provisioning/__tests__/seed-first-challenge.test.js create mode 100644 .github/scripts/provisioning/seed-first-challenge.js diff --git a/.github/scripts/__tests__/challenge-progression-assignee.test.js b/.github/scripts/__tests__/challenge-progression-assignee.test.js new file mode 100644 index 00000000..4a9a60b3 --- /dev/null +++ b/.github/scripts/__tests__/challenge-progression-assignee.test.js @@ -0,0 +1,52 @@ +// Regression tests for the assignee handling in the student-facing challenge +// progression script. At provisioning time the learner's collaborator +// invitation is still pending, so they exist as a GitHub user but cannot yet +// be assigned issues in the repository; the script must degrade to an +// unassigned issue instead of failing with HTTP 422 (see learning rooms for +// waphi and accesswatch-student on 2026-07-02). +const test = require('node:test'); +const assert = require('node:assert/strict'); +const path = require('node:path'); + +process.env.GITHUB_TOKEN = 'test-token'; +process.env.GITHUB_REPOSITORY = 'org/room'; +process.env.START_CHALLENGE = ''; +process.env.GITHUB_EVENT_NAME = ''; +process.env.CLOSED_ISSUE_TITLE = ''; + +// The script resolves its issue-template directory from the working +// directory, so requiring it must happen from inside learning-room/. +process.chdir(path.join(__dirname, '..', '..', '..', 'learning-room')); + +const { resolveValidAssignee } = require('../../../learning-room/.github/scripts/challenge-progression'); + +test('validates assignability in the repository, not global user existence', async () => { + const routes = []; + const request = async (route) => { + routes.push(route); + return null; + }; + + const result = await resolveValidAssignee('waphi', request); + + assert.equal(result, 'waphi'); + assert.deepEqual(routes, ['/repos/org/room/assignees/waphi']); +}); + +test('returns null when the user cannot be assigned yet (pending invitation)', async () => { + const request = async () => { + throw new Error('GET /repos/org/room/assignees/waphi failed: 404'); + }; + + assert.equal(await resolveValidAssignee('waphi', request), null); +}); + +test('returns null for a missing actor without calling the API', async () => { + let called = false; + const request = async () => { + called = true; + }; + + assert.equal(await resolveValidAssignee('', request), null); + assert.equal(called, false); +}); diff --git a/.github/scripts/provisioning/__tests__/provision-core.test.js b/.github/scripts/provisioning/__tests__/provision-core.test.js index fc6f11d9..996606ea 100644 --- a/.github/scripts/provisioning/__tests__/provision-core.test.js +++ b/.github/scripts/provisioning/__tests__/provision-core.test.js @@ -216,6 +216,54 @@ test('a failure does not stop the batch; others still provision', async () => { assert.equal(summary.created, 1); }); +test('seeds the first challenge issue after workflows are verified', async () => { + const seedCalls = []; + const { client, state } = makeClient({ + async seedFirstChallenge({ owner, repo, assignee }) { + seedCalls.push({ owner, repo, assignee, listCallsSoFar: state.calls.list }); + } + }); + const roster = rosterWith({ github_handle: 'alice', cohort_id: 'c1' }); + const { roster: out, summary } = await provisionRoster({ roster, client, config, sleep: noSleep }); + + assert.equal(out.learners[0].provision_state, 'provisioned'); + assert.equal(summary.error, 0); + assert.equal(seedCalls.length, 1); + assert.equal(seedCalls[0].owner, 'Community-Access'); + assert.equal(seedCalls[0].repo, 'learning-room-c1-alice'); + assert.equal(seedCalls[0].assignee, 'alice'); + assert.ok(seedCalls[0].listCallsSoFar >= 1, 'seeding must happen after workflow verification'); +}); + +test('seeds the first challenge on the already-exists path too', async () => { + const seedCalls = []; + const { client, state } = makeClient({ + async seedFirstChallenge(args) { + seedCalls.push(args); + } + }); + state.repos.set('learning-room-c1-alice', { workflows: [...REQUIRED] }); + const roster = rosterWith({ github_handle: 'alice', cohort_id: 'c1' }); + const { summary } = await provisionRoster({ roster, client, config, sleep: noSleep }); + + assert.equal(summary.already_exists, 1); + assert.equal(seedCalls.length, 1); +}); + +test('marks the learner failed when first-challenge seeding fails', async () => { + const { client } = makeClient({ + async seedFirstChallenge() { + throw new Error('seed boom'); + } + }); + 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, 'failed'); + assert.equal(summary.error, 1); + assert.match(log[0].error_detail, /seed boom/); +}); + test('isSecondaryRateLimit detects status and message forms', () => { assert.ok(isSecondaryRateLimit({ status: 429 })); assert.ok(isSecondaryRateLimit({ message: 'oops HTTP 403 here' })); diff --git a/.github/scripts/provisioning/__tests__/seed-first-challenge.test.js b/.github/scripts/provisioning/__tests__/seed-first-challenge.test.js new file mode 100644 index 00000000..a97d9326 --- /dev/null +++ b/.github/scripts/provisioning/__tests__/seed-first-challenge.test.js @@ -0,0 +1,67 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { createChallengeSeeder } = require('../seed-first-challenge'); + +function fakeSpawn(result) { + const calls = []; + const spawn = (cmd, args, opts) => { + calls.push({ cmd, args, opts }); + return { status: 0, stdout: '', stderr: '', ...result }; + }; + return { calls, spawn }; +} + +test('requires a token and a learning room directory', () => { + assert.throws(() => createChallengeSeeder({ learningRoomDir: '/lr' }), /token/); + assert.throws(() => createChallengeSeeder({ token: 't' }), /learningRoomDir/); +}); + +test('runs the challenge progression script against the student repo', async () => { + const { calls, spawn } = fakeSpawn({ stdout: 'Created Challenge 1' }); + const seeder = createChallengeSeeder({ token: 'tok', learningRoomDir: '/lr', spawn }); + + const result = await seeder.seedFirstChallenge({ + owner: 'org', + repo: 'room', + assignee: 'alice' + }); + + assert.equal(calls.length, 1); + const call = calls[0]; + assert.equal(call.cmd, process.execPath); + assert.match(call.args[0], /challenge-progression\.js$/); + assert.equal(call.opts.cwd, '/lr'); + assert.equal(call.opts.env.GITHUB_TOKEN, 'tok'); + assert.equal(call.opts.env.GITHUB_REPOSITORY, 'org/room'); + assert.equal(call.opts.env.START_CHALLENGE, '1'); + assert.equal(call.opts.env.CHALLENGE_ASSIGNEE, 'alice'); + assert.equal(result.output, 'Created Challenge 1'); +}); + +test('passes an empty assignee through as an empty string', async () => { + const { calls, spawn } = fakeSpawn({}); + const seeder = createChallengeSeeder({ token: 'tok', learningRoomDir: '/lr', spawn }); + await seeder.seedFirstChallenge({ owner: 'org', repo: 'room' }); + assert.equal(calls[0].opts.env.CHALLENGE_ASSIGNEE, ''); +}); + +test('throws with script output when the script exits non-zero', async () => { + const { spawn } = fakeSpawn({ status: 1, stderr: 'FATAL: creation exploded' }); + const seeder = createChallengeSeeder({ token: 'tok', learningRoomDir: '/lr', spawn }); + + await assert.rejects( + () => seeder.seedFirstChallenge({ owner: 'org', repo: 'room', assignee: 'alice' }), + /creation exploded/ + ); +}); + +test('throws when the script cannot be spawned at all', async () => { + const { spawn } = fakeSpawn({ error: new Error('ENOENT node missing'), status: null }); + const seeder = createChallengeSeeder({ token: 'tok', learningRoomDir: '/lr', spawn }); + + await assert.rejects( + () => seeder.seedFirstChallenge({ owner: 'org', repo: 'room', assignee: 'alice' }), + /ENOENT/ + ); +}); diff --git a/.github/scripts/provisioning/provision-cli.js b/.github/scripts/provisioning/provision-cli.js index daea5fa2..e4643cc7 100644 --- a/.github/scripts/provisioning/provision-cli.js +++ b/.github/scripts/provisioning/provision-cli.js @@ -26,6 +26,7 @@ const path = require('node:path'); const { parseRoster, serializeRoster } = require('./roster'); const { provisionRoster } = require('./provision-core'); const { createFetchClient, REQUIRED_WORKFLOWS } = require('./github-client'); +const { createChallengeSeeder } = require('./seed-first-challenge'); const { mintInstallationToken, discoverInstallationId } = require('./github-app-auth'); function parseArgs(argv) { @@ -161,7 +162,13 @@ async function main() { } const token = await resolveToken(env, apiBaseUrl); - const client = createFetchClient({ token, apiBaseUrl }); + // The seeder runs the learning-room progression script from this checkout, + // so provisioned rooms start with their Challenge 1 issue already open. + const seeder = createChallengeSeeder({ + token, + learningRoomDir: path.join(__dirname, '..', '..', '..', 'learning-room') + }); + const client = { ...createFetchClient({ token, apiBaseUrl }), ...seeder }; // Preflight: fail with one clear message if the token cannot even see the // template, instead of a confusing per-learner error cascade. diff --git a/.github/scripts/provisioning/provision-core.js b/.github/scripts/provisioning/provision-core.js index c6190240..7bcdaa37 100644 --- a/.github/scripts/provisioning/provision-core.js +++ b/.github/scripts/provisioning/provision-core.js @@ -207,6 +207,18 @@ async function provisionOne({ ); } + // A room without a Challenge 1 issue never starts the progression chain: + // the Student Progression Bot only fires on issue close or manual dispatch. + // Seed after content verification so the issue never points at an empty + // repo. The seeder is idempotent (skips when a challenge issue exists). + if (typeof client.seedFirstChallenge === 'function') { + await client.seedFirstChallenge({ + owner: studentOwner, + repo: repoName, + assignee: handle + }); + } + let templateSha = null; if (typeof client.getDefaultBranchSha === 'function') { templateSha = await client.getDefaultBranchSha({ owner: studentOwner, repo: repoName }); diff --git a/.github/scripts/provisioning/seed-first-challenge.js b/.github/scripts/provisioning/seed-first-challenge.js new file mode 100644 index 00000000..57f6ec37 --- /dev/null +++ b/.github/scripts/provisioning/seed-first-challenge.js @@ -0,0 +1,72 @@ +/** + * First-challenge seeding for provisioning (SPEC.md section 7.2b step 3). + * + * A freshly provisioned learning room has no issues, and the Student + * Progression Bot inside the room only fires when a challenge issue is closed + * or manually dispatched - so without this step every new room starts with an + * empty Issues tab and the progression chain never begins. + * + * Rather than duplicating the issue-rendering logic, this module runs the same + * challenge-progression.js script the student repo uses, from the workshop + * checkout's learning-room/ directory, pointed at the student repo with the + * App installation token. The script is idempotent (it skips creation when a + * matching challenge issue already exists) and degrades to an unassigned issue + * when the learner's collaborator invitation is still pending. + * + * Note: challenge-progression.js talks to https://api.github.com directly; it + * does not honor GITHUB_API_URL. That matches every current deployment. + */ + +'use strict'; + +const fs = require('node:fs'); +const path = require('node:path'); +const { spawnSync } = require('node:child_process'); + +function createChallengeSeeder({ + token, + learningRoomDir, + spawn = spawnSync, + execPath = process.execPath +}) { + if (!token) throw new Error('createChallengeSeeder requires a token'); + if (!learningRoomDir) throw new Error('createChallengeSeeder requires learningRoomDir'); + + const scriptPath = path.join(learningRoomDir, '.github', 'scripts', 'challenge-progression.js'); + // Fail at construction, not per learner, when the checkout layout is wrong. + if (spawn === spawnSync && !fs.existsSync(scriptPath)) { + throw new Error(`createChallengeSeeder cannot find ${scriptPath}`); + } + + return { + async seedFirstChallenge({ owner, repo, assignee }) { + const result = spawn(execPath, [scriptPath], { + cwd: learningRoomDir, + encoding: 'utf8', + env: { + ...process.env, + GITHUB_TOKEN: token, + GITHUB_REPOSITORY: `${owner}/${repo}`, + GITHUB_EVENT_NAME: 'provisioning-seed', + START_CHALLENGE: '1', + CHALLENGE_ASSIGNEE: assignee || '' + } + }); + + if (result.error) throw result.error; + if (result.status !== 0) { + const detail = String(result.stderr || result.stdout || '') + .trim() + .split(/\r?\n/) + .slice(-5) + .join(' | '); + throw new Error( + `seedFirstChallenge failed for ${owner}/${repo} (exit ${result.status}): ${detail}` + ); + } + return { output: String(result.stdout || '').trim() }; + } + }; +} + +module.exports = { createChallengeSeeder }; diff --git a/SPEC.md b/SPEC.md index 84d8934e..f82818a2 100644 --- a/SPEC.md +++ b/SPEC.md @@ -219,7 +219,7 @@ Grant only these. Anything beyond this list is over-privileged and fails the sec | 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) | +| Issues | Write | Seed the first challenge issue at provision time. Not deferrable: the Progression Bot only fires on issue close or manual dispatch, so a room provisioned without a Challenge 1 issue never starts the progression chain | App configuration rules: @@ -256,7 +256,15 @@ for each roster entry where provision_state in (pending, failed): - 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. + 5. Seed the Challenge 1 issue if no challenge issue exists yet + (idempotent; runs the learning-room progression script against the + student repo). The learner's collaborator invitation is usually still + pending at this point, so the issue is created unassigned; the + Progression Bot assigns subsequent challenges to whoever closes the + previous one. A seeding failure marks the learner failed and surfaces + to the watchdog. + + 6. 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. ``` diff --git a/html/SPEC.html b/html/SPEC.html index 35f09b53..c7b3b66e 100644 --- a/html/SPEC.html +++ b/html/SPEC.html @@ -628,7 +628,7 @@

7.2a GitHub App permission set

Issues Write -Seed the first challenge issue and provisioning status (optional; can defer to the Progression Bot) +Seed the first challenge issue at provision time. Not deferrable: the Progression Bot only fires on issue close or manual dispatch, so a room provisioned without a Challenge 1 issue never starts the progression chain

App configuration rules:

@@ -663,7 +663,15 @@

7.2b Provisioning algorith - 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. + 5. Seed the Challenge 1 issue if no challenge issue exists yet + (idempotent; runs the learning-room progression script against the + student repo). The learner's collaborator invitation is usually still + pending at this point, so the issue is created unassigned; the + Progression Bot assigns subsequent challenges to whoever closes the + previous one. A seeding failure marks the learner failed and surfaces + to the watchdog. + + 6. 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.

diff --git a/html/search-index.json b/html/search-index.json index b3be3b4c..1be23c3d 100644 --- a/html/search-index.json +++ b/html/search-index.json @@ -2183,18 +2183,18 @@ "url": "admin/classroom admin access.html", "body": "https://classroom.github.com/classrooms/263509777-git-going-with-github Authoritative Sources Use these official references when you need the current source of truth for facts in this chapter. GitHub Docs, home GitHub Changelog Section-Level Source Map Use this map to verify facts for each major section in this file. File Overview: GitHub Docs, home , GitHub Changelog" }, - { - "id": "admin/OWNED_PROVISIONING.html", - "title": "Owned, GitHub-native Provisioning", - "url": "admin/OWNED_PROVISIONING.html", - "body": "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 and specified in 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 first. To review the whole deliverable from one place, see 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 The three owned sources of truth How provisioning works One-time setup Running a cohort Idempotency and self-healing Failure modes and recovery The optional Flask companion Security and least privilege Local development and testing 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. 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 and roster.schema.json . An example is in examples/roster.example.json . 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 . 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 Owned roster: parse, validate, upsert, serialize, redact progress.js Derive learner status from deterministic signals github-app-auth.js Sign an App JWT and mint a short-lived installation token github-client.js Minimal GitHub REST client (fetch or Octokit) provision-core.js The idempotent, serial, backoff provisioning algorithm provision-cli.js Standalone runner used by the workflow 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. 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. Install the App on the organization. Creating student repositories requires an organization-wide installation (repository creation cannot be granted through a selected-repositories installation). Do not skip this step: an App that exists but is not installed fails every token mint with HTTP 404. 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 (optional; when unset, provisioning discovers the installation from the App itself, which also survives re-installation) 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 PROVISIONING_COHORT_ID = the cohort that new enrollees are added to Provide PRIVATE_STUDENT_DATA_TOKEN , a token that can check out and push to the admin roster repository. Verify credent" - }, { "id": "admin/HYBRID_DEPLOYMENT_GUIDE.html", "title": "Hybrid Deployment Guide", "url": "admin/HYBRID_DEPLOYMENT_GUIDE.html", "body": "Hybrid Deployment Guide This is the step-by-step guide to deploying the Hybrid architecture from golden.md and 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 . For the legacy Classroom-based flow, see ../classroom/README.md . To review every piece of the Hybrid deliverable from one place, start at 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 Prerequisites Phase 1: Prepare the admin roster repository Phase 2: Create the provisioning GitHub App Phase 3: Configure variables and secrets Phase 4: Deploy and smoke-test provisioning Phase 5: Deploy the optional companion Phase 6: Go-live checklist Operating a cohort Rollback and fallback Reference: configuration surface 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: 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. Create (or choose) a private repository, for example Community-Access/glow-admin . Add a starter roster at its root. Copy the shape from examples/roster.example.json , or start empty: { "version" : 1 , "cohorts" : [ ] , "learners" : [ ] } Commit it as roster.json . The provisioning run will also create and maintain provisioning-log.json next to it. Verification: the file validates locally. 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. In the organization, go to Settings, Developer settings, GitHub Apps, New GitHub App. Name it (for example GLOW Provisioning ). Set a homepage URL (the repo is fine). Disable the webhook (this App is called by Actions, not by webhooks). 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). Create the App, then note the numeric App ID. Generate a private key; a .pem file downloads. Keep it secret. Install the App on the Community-Access organization with All repositories access. Creating new student repositories requires an organization-wide installation; a selected-repositories installation cannot create repos. This step is mandatory: an App that exists but is not installed fails every token mint with HTTP 404, and nothing downstream can work. Open the installation and note the numeric Installation ID (it is in the installation settings URL). Storing it is optional - provisioning discovers it automatically when the secret is unset. Verification: you now hold three values: App ID, Installation ID (optional), and the PEM key. Confirm they work before continuing: run the Provisioning Credentials Health Check workflow, or locally node .github/scripts/provisioning/provision-cli.js --check-auth . Do not proceed to Phase 4 until the check passes. 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: " }, + { + "id": "admin/OWNED_PROVISIONING.html", + "title": "Owned, GitHub-native Provisioning", + "url": "admin/OWNED_PROVISIONING.html", + "body": "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 and specified in 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 first. To review the whole deliverable from one place, see 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 The three owned sources of truth How provisioning works One-time setup Running a cohort Idempotency and self-healing Failure modes and recovery The optional Flask companion Security and least privilege Local development and testing 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. 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 and roster.schema.json . An example is in examples/roster.example.json . 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 . 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 Owned roster: parse, validate, upsert, serialize, redact progress.js Derive learner status from deterministic signals github-app-auth.js Sign an App JWT and mint a short-lived installation token github-client.js Minimal GitHub REST client (fetch or Octokit) provision-core.js The idempotent, serial, backoff provisioning algorithm provision-cli.js Standalone runner used by the workflow 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. 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. Install the App on the organization. Creating student repositories requires an organization-wide installation (repository creation cannot be granted through a selected-repositories installation). Do not skip this step: an App that exists but is not installed fails every token mint with HTTP 404. 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 (optional; when unset, provisioning discovers the installation from the App itself, which also survives re-installation) 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 PROVISIONING_COHORT_ID = the cohort that new enrollees are added to Provide PRIVATE_STUDENT_DATA_TOKEN , a token that can check out and push to the admin roster repository. Verify credent" + }, { "id": "classroom/assignment-issue-template.html", "title": "Assignment Issue Template Reference", @@ -2363,16 +2363,16 @@ "url": "DEPLOYMENT_ASSESSMENT.html", "body": "Deployment Assessment: Hybrid Provisioning System Date: June 2, 2026 Status: READY FOR PRODUCTION (with caveat below) Summary The Hybrid provisioning system is architecturally sound, hardened, and production-ready . However, the end-to-end workflow has not yet been tested with real student enrollment . Recommendation: Run Phase 4 smoke test before admitting first cohort. Hardening Assessment by Component OK: GitHub App Configuration Status: HARDENED Why: Fine-grained permissions (least privilege), secrets stored in GitHub Actions only, PEM key never in code Risk: None identified Action: READY OK: Secrets & Variables Management Status: HARDENED Why: All three secrets (App ID, Installation ID, PEM) stored securely in GitHub repository Actions secrets; variables configured correctly Risk: None identified Action: READY OK: Infrastructure Code (Provisioning Scripts) Status: HARDENED Why: Idempotent: Re-running is safe (already-exists vs. created state) Self-healing: Missing students auto-provisioned on next run Deterministic: Uses GitHub API signals, not external state Auditable: All history in provisioning-log.json and git Risk: None identified Action: READY OK: Documentation Updates Status: COMPLETE What: 15+ files updated, 37+ files deleted, HTML regenerated on both sites Coverage: Student-facing, facilitator, operator all covered Risk: None identified Action: READY OK: Public Site Deployments Status: COMPLETE Coverage: community-access.org/git-going-with-github (auto via GitHub Pages) lp.csedesigns.com/ggg (manual via rsync + Caddy reload) Verification: Both sites live and tested (404s work, navigation intact) Risk: None identified Action: READY Warning: End-to-End Student Workflow (NOT YET TESTED) Status: UNTESTED What's missing: Admin roster repo creation Smoke test with test student account Verification that provision workflow runs end-to-end Verification that invitation email sent to test account Verification that student can accept invite and access repo Risk: Medium-logical gaps possible, though architecture is solid Action: REQUIRED-Run Phase 4 before admitting real students What Has Been Tested OK: Automation tests: 78/78 passing (was 98, reduced after removing Classroom tests) OK: Provisioning tests: 55/55 passing OK: HTML regeneration: 393 markdown files -> HTML OK: Site deployments: Both production sites live OK: GitHub App creation: Properly configured with correct permissions OK: Secrets storage: All three values stored securely What Has NOT Been Tested Missing: End-to-end provisioning flow with a real test student Enrollment form submission Issue creation in test account Automation comment with learning room link Provisioning workflow execution Private repo creation and invitation Student repo readiness for challenges Failsafe Mechanisms Mechanism How It Works When It Helps Idempotence Re-running workflow is safe; state tracked in roster.json If automation fails, re-run fixes it Self-healing Missing students auto-provisioned on next run If student missed by error, they're caught on retry Audit Trail provisioning-log.json records every action Troubleshooting, rollback, verification Source of Truth roster.json is canonical; all state derives from it Repo can be recreated, roster is immutable Least Privilege GitHub App has minimal permissions Limits blast radius if credentials compromised Secrets in Actions PEM never stored in code or logs Prevents accidental exposure Deterministic Signals Progress tracked via GitHub events No hidden external dependencies Recommendations Before Go-Live MUST DO (Blocking) Run Phase 4 Smoke Test Create test admin roster repo Add one test learner (e.g., a throwaway GitHub account you control) Trigger provisioning workflow with dry-run first Trigger provisioning workflow without dry-run Verify: Private repo created Invitation sent to test account Roster entry updated to "provisioned" provisioning-log.json records "created" Run again; verify log records "already-exists" Test account accepts invite; repo is ready for challenges Verify End-to-End Student Flow Submit enrollment form as test student Verify issue created in your account Reply ack to issue Verify automation comment posts learning room link Verify you receive GitHub invitation Accept invitation; verify repo has challenges SHOULD DO (Recommended, not blocking) Verify Flask Companion (Optional) If deploying companion for web enrollment, test locally first See Phase 5 of deployment guide Document Rollback Plan If provisioning fails, how to revert? If student repo corrupted, how to re-provision? Document in runbook Test with Multiple Students Once Phase 4 passes, add 3-5 test students to roster Verify all get provisioned correctly Verify no duplicates or conflicts NICE TO HAVE (After launch) Monitor first week of production: Track provisioning success rate Log any failures to support channel Verify no duplicate repos Verify all students receive invitations Set up alert" }, - { - "id": "SPEC.html", - "title": "SPEC.md", - "url": "SPEC.html", - "body": "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 . Where this spec and reality disagree, fix one of them and note it here. Table of contents 1. Overview 2. Goals and non-goals 3. Personas and primary journeys 4. System architecture 5. Component catalog 6. Data model and state of record 7. Provisioning subsystem (the Classroom replacement) 8. Automation contracts 9. Curriculum subsystem 10. Content pipeline: docs, HTML, EPUB, audio 11. Optional Flask companion 12. Accessibility requirements 13. Security requirements 14. Reliability, observability, and recovery 15. Testing and quality strategy 16. Configuration surface 17. Repository layout 18. Migration plan: from Classroom to owned provisioning 19. Acceptance criteria 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/ , 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 . 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 Register through an accessible front door (Flask companion, GitHub Pages form, or issue form fallback). Receive a provisioned private Learning Room repository. Acknowledge readiness ( ack ), complete Day 1 challenges, signal day1-complete . Receive Day 2 release, complete Day 2 challenges and the capstone. Open or prepare a real upstream contribution; continue asynchronously with support hub access. Primary facilitator journey Sync and validate the Learning Room template. Open a cohort; provisioning creates student repositories. Seed Challenge 1 (and Challenge 10 for Day 2) per student. Monitor the dashboard; intervene on watchdog alerts. 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. 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 tab" - }, { "id": "REVIEW-2026-07-02.html", "title": "Operations Review: Provisioning Failures and Workshop Model Robustness", "url": "REVIEW-2026-07-02.html", "body": "Operations Review: Provisioning Failures and Workshop Model Robustness Date: July 2, 2026 Scope: GitHub Actions failure analysis, provisioning subsystem, registration pipeline, repo hygiene. Implementation status (July 2, 2026): everything in sections 3, 4.1-4.4, and 6 that is code has been implemented (installation-ID auto-discovery, watchdog alert issue, work-free early exit, event-driven provisioning dispatch, weekly credentials health check, preflight assertions, intake-to-roster sync, Classroom link removal, [REGISTER] private intake plus redaction, authoritative-sources gate fixes, scratch cleanup). The remaining manual step is section 2: install the GitHub App on the Community-Access organization. Template drift (4.5) is still open. 1. Headline Finding: The Provisioning GitHub App Is Not Installed on the Org The "Provision Learning Rooms" workflow has failed on every real run since it was deployed. Verified numbers from the GitHub API: 280 total runs of provision-learning-rooms.yml . Exactly 1 success ever (June 2, 2026, 20:16 UTC) - and that run was a dry run, which skips authentication entirely. Every non-dry run, including roughly 12 scheduled runs per day for the past 30 days, fails with: Failed to mint installation token (HTTP 404) Root cause, confirmed directly: GET /orgs/Community-Access/installations shows exactly one GitHub App installed on the org - digitalocean (app id 64711). The provisioning App was created (App ID and PEM are stored as secrets) but was never installed on the Community-Access organization. There is no installation to mint a token against, so the token mint returns 404 no matter what PROVISIONING_APP_INSTALLATION_ID contains. Consequences, also confirmed: The private roster ( git-going-with-github-administration/roster.json ) has 2 learners in provision_state: pending - unchanged since June 2. Two registered people have been waiting a month for learning rooms that were never created. The admin repo has had no roster commits since June 2 because the workflow dies before the commit step. This is precisely the failure class SPEC.md's golden principle warns about ("never let a single point of failure hold a cohort hostage") and violates the section 7.2b invariant "any failure visible to a human before a learner notices." The watchdog the spec calls for does not exist. Note on the local working-tree changes: the uncommitted edits to github-app-auth.js (normalize URL-form installation IDs, better 404 hint) are good hardening and all 58 provisioning tests pass, but they will not fix production. No installation ID is valid until the App is installed. 2. Immediate Fix (Operator Steps, ~15 Minutes) Install the App: GitHub -> Organization settings -> Developer settings -> GitHub Apps -> your provisioning App -> Install App -> Community-Access. Because the App must create new repositories, install it org-wide (repository creation cannot be granted through a selected-repositories installation). Capture the installation ID from the post-install URL ( .../settings/installations/<id> ) or from GET /app/installations using an App JWT. Update the PROVISIONING_APP_INSTALLATION_ID secret (it lives in the provisioning environment, not repo-level secrets). Run the workflow manually with dry_run: true (expect: "2 learner(s) would be provisioned"), then run it for real. Verify: two new learning-room-* repos, invitations sent, roster entries flipped to provisioned , provisioning-log.json committed to the admin repo. 3. Make the Failure Class Impossible: Code Changes These are ordered by payoff. 3.1 Auto-discover the installation ID Stop storing the installation ID as a secret at all. With an App JWT you can call GET /app/installations and select the installation whose account matches PROVISIONING_STUDENT_OWNER . Add this as the fallback (or the default) in github-app-auth.js / provision-cli.js . This removes an entire class of copy-paste misconfiguration and survives App re-installation (installation IDs change when an App is uninstalled and reinstalled). Alternative for the workflow path: the official actions/create-github-app-token action needs only app-id and private-key and resolves the installation from the repo owner automatically. It keeps the zero-npm-dependency property of the repo since it runs as an action, and the CLI keeps the hand-rolled path for local use. 3.2 Alert on failure - implement the spec's watchdog Thirty days of silent failure proves the need. Add a final if: failure() step to provision-learning-rooms.yml that creates or updates a pinned, labeled issue (for example provisioning-broken ) in this repo, including the error line from the log. Close it automatically on the next success. Requires issues: write on this workflow only. 3.3 Skip work-free runs Most of the 280 failed runs had zero learners to provision. Read the roster first; if there are no pending / failed entries, exit success before minting" + }, + { + "id": "SPEC.html", + "title": "SPEC.md", + "url": "SPEC.html", + "body": "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 . Where this spec and reality disagree, fix one of them and note it here. Table of contents 1. Overview 2. Goals and non-goals 3. Personas and primary journeys 4. System architecture 5. Component catalog 6. Data model and state of record 7. Provisioning subsystem (the Classroom replacement) 8. Automation contracts 9. Curriculum subsystem 10. Content pipeline: docs, HTML, EPUB, audio 11. Optional Flask companion 12. Accessibility requirements 13. Security requirements 14. Reliability, observability, and recovery 15. Testing and quality strategy 16. Configuration surface 17. Repository layout 18. Migration plan: from Classroom to owned provisioning 19. Acceptance criteria 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/ , 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 . 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 Register through an accessible front door (Flask companion, GitHub Pages form, or issue form fallback). Receive a provisioned private Learning Room repository. Acknowledge readiness ( ack ), complete Day 1 challenges, signal day1-complete . Receive Day 2 release, complete Day 2 challenges and the capstone. Open or prepare a real upstream contribution; continue asynchronously with support hub access. Primary facilitator journey Sync and validate the Learning Room template. Open a cohort; provisioning creates student repositories. Seed Challenge 1 (and Challenge 10 for Day 2) per student. Monitor the dashboard; intervene on watchdog alerts. 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. 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 tab" } ] diff --git a/learning-room/.github/scripts/challenge-progression.js b/learning-room/.github/scripts/challenge-progression.js index 93b9a3e6..7f1058bb 100644 --- a/learning-room/.github/scripts/challenge-progression.js +++ b/learning-room/.github/scripts/challenge-progression.js @@ -269,6 +269,28 @@ async function issueAlreadyExists(titlePrefix) { } } +// A GitHub user can only be assigned when they have access to the repository. +// Right after provisioning the learner's collaborator invitation is still +// pending, so they exist as a user but are not yet assignable; creating the +// issue with them as assignee fails with HTTP 422. Check assignability in +// this repository (204 when assignable, 404 when not) and degrade to an +// unassigned issue instead of failing the run. +async function resolveValidAssignee(candidate, request = githubRequest) { + if (!candidate) { + return null; + } + log('DEBUG', `Validating assignee: ${candidate}`); + try { + await request(`/repos/${owner}/${repo}/assignees/${candidate}`, {}, 1); + log('DEBUG', `Assignee ${candidate} can be assigned in this repository`); + return candidate; + } catch (error) { + log('WARN', `Assignee ${candidate} cannot be assigned yet (pending invitation or no repo access): ${error.message}`); + log('INFO', 'Issue will be created without an assignee.'); + return null; + } +} + async function createChallenge(challengeNumber) { const templateName = findTemplate(challengeNumber); return createIssueFromTemplate(templateName, `Challenge ${challengeNumber}`); @@ -305,19 +327,7 @@ async function createIssueFromTemplate(templateName, fallbackLabel) { return; } - // Validate actor if assignees will be used - let validAssignee = actor; - if (actor) { - log('DEBUG', `Validating assignee: ${actor}`); - try { - await githubRequest(`/users/${actor}`); - log('DEBUG', `Assignee ${actor} exists`); - } catch (error) { - log('WARN', `Assignee ${actor} does not exist or is inaccessible: ${error.message}`); - log('INFO', `Issue will be created without assignee. Ensure assignee is valid in GitHub.`); - validAssignee = null; - } - } + const validAssignee = await resolveValidAssignee(actor); const bodyParts = [ ...readMarkdownBlocks(content), @@ -389,29 +399,33 @@ async function seedMergeConflict() { } } -const progressionTargets = getProgressionTargets(); -if (!progressionTargets.length) { - log('INFO', 'No challenge to create for this event.'); - console.log('No challenge to create for this event.'); -} else { - log('INFO', `Proceeding with ${progressionTargets.length} progression target(s).`); - (async () => { - for (const target of progressionTargets) { - if (target.kind === 'challenge') { - await createChallenge(target.number); - } else if (target.kind === 'bonus') { - await createIssueFromTemplate(target.template, 'Bonus'); +if (require.main === module) { + const progressionTargets = getProgressionTargets(); + if (!progressionTargets.length) { + log('INFO', 'No challenge to create for this event.'); + console.log('No challenge to create for this event.'); + } else { + log('INFO', `Proceeding with ${progressionTargets.length} progression target(s).`); + (async () => { + for (const target of progressionTargets) { + if (target.kind === 'challenge') { + await createChallenge(target.number); + } else if (target.kind === 'bonus') { + await createIssueFromTemplate(target.template, 'Bonus'); + } } - } - if (progressionTargets.some(target => target.kind === 'challenge' && target.number === 7)) { - await seedMergeConflict(); - } - })() - .catch(error => { - log('ERROR', `Challenge progression failed: ${error.message}`); - log('ERROR', error.stack); - console.error(`FATAL: ${error.message}`); - process.exitCode = 1; - }); -} \ No newline at end of file + if (progressionTargets.some(target => target.kind === 'challenge' && target.number === 7)) { + await seedMergeConflict(); + } + })() + .catch(error => { + log('ERROR', `Challenge progression failed: ${error.message}`); + log('ERROR', error.stack); + console.error(`FATAL: ${error.message}`); + process.exitCode = 1; + }); + } +} + +module.exports = { resolveValidAssignee }; \ No newline at end of file