From d495c2214be8e316cb6755a432f106382f732ae1 Mon Sep 17 00:00:00 2001 From: GitHub Workshop Bot Date: Thu, 2 Jul 2026 12:24:18 -0700 Subject: [PATCH] fix(provisioning): wait for asynchronous template copy before verification GitHub's generate-from-template API returns before repository contents finish copying. The verification gate ran immediately and marked every freshly created learner failed (all 10 rooms in run 28615646165 were created fine but flagged missing workflows; the healing re-run passed). Verification now retries for up to 30 seconds on fresh creates and heals; already-exists verification stays single-shot. Co-Authored-By: Claude Fable 5 --- .../__tests__/provision-core.test.js | 52 +++++++++++++++++++ .../scripts/provisioning/provision-core.js | 28 ++++++++-- 2 files changed, 75 insertions(+), 5 deletions(-) diff --git a/.github/scripts/provisioning/__tests__/provision-core.test.js b/.github/scripts/provisioning/__tests__/provision-core.test.js index ed78981e..fc6f11d9 100644 --- a/.github/scripts/provisioning/__tests__/provision-core.test.js +++ b/.github/scripts/provisioning/__tests__/provision-core.test.js @@ -102,6 +102,58 @@ test('heals an incomplete repo when seedContent is available', async () => { assert.equal(summary.healed, 1); }); +test('waits for asynchronous template copy before verifying a fresh repo', async () => { + // GitHub's generate-from-template API returns before the content copy + // finishes; verification must wait instead of marking the learner failed. + const { client, state } = makeClient(); + client.createFromTemplate = async ({ repo }) => { + state.calls.create += 1; + state.repos.set(repo, { workflows: [] }); + return { full_name: repo }; + }; + let listCalls = 0; + client.listWorkflowPaths = async ({ repo }) => { + listCalls += 1; + if (listCalls >= 3) state.repos.get(repo).workflows = [...REQUIRED]; + return state.repos.get(repo).workflows; + }; + let slept = 0; + const countingSleep = async () => { slept += 1; }; + + const roster = rosterWith({ github_handle: 'dana', cohort_id: 'c1' }); + const { roster: out, summary } = await provisionRoster({ + roster, + client, + config, + sleep: countingSleep + }); + + assert.equal(out.learners[0].provision_state, 'provisioned'); + assert.equal(summary.created, 1); + assert.equal(summary.error, 0); + assert.ok(slept >= 1, 'should have waited between verification attempts'); +}); + +test('still fails when content never arrives after creation', async () => { + const { client, state } = makeClient(); + client.createFromTemplate = async ({ repo }) => { + state.calls.create += 1; + state.repos.set(repo, { workflows: [] }); + return { full_name: repo }; + }; + const roster = rosterWith({ github_handle: 'erin', cohort_id: 'c1' }); + const { roster: out, log, summary } = await provisionRoster({ + roster, + client, + config: { ...config, verifyRetries: 2, verifyDelayMs: 0 }, + sleep: noSleep + }); + + assert.equal(out.learners[0].provision_state, 'failed'); + assert.equal(summary.error, 1); + assert.match(log[0].error_detail, /Verification failed/); +}); + 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'] }); diff --git a/.github/scripts/provisioning/provision-core.js b/.github/scripts/provisioning/provision-core.js index 0fbf7070..c6190240 100644 --- a/.github/scripts/provisioning/provision-core.js +++ b/.github/scripts/provisioning/provision-core.js @@ -40,6 +40,8 @@ const sleepReal = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); * repoNameFor, // (handle, cohortId) => name; defaults to defaultRepoName * delayMs, // delay between entries (default 1500) * maxRetries, // backoff attempts on secondary rate limit (default 5) + * verifyRetries, // verification attempts after create/heal (default 10) + * verifyDelayMs, // delay between verification attempts (default 3000) * collaboratorPermission // default 'push' * } * @@ -60,6 +62,8 @@ async function provisionRoster({ const repoNameFor = config.repoNameFor || defaultRepoName; const delayMs = config.delayMs == null ? 1500 : config.delayMs; const maxRetries = config.maxRetries == null ? 5 : config.maxRetries; + const verifyRetries = config.verifyRetries == null ? 10 : config.verifyRetries; + const verifyDelayMs = config.verifyDelayMs == null ? 3000 : config.verifyDelayMs; const permission = config.collaboratorPermission || 'push'; const log = []; @@ -85,7 +89,10 @@ async function provisionRoster({ templateRepo, repoName, requiredWorkflows, - permission + permission, + verifyRetries, + verifyDelayMs, + sleep }), { maxRetries, sleep, logger } ); @@ -135,7 +142,10 @@ async function provisionOne({ templateRepo, repoName, requiredWorkflows, - permission + permission, + verifyRetries = 10, + verifyDelayMs = 3000, + sleep = sleepReal }) { const exists = await client.repoExists({ owner: studentOwner, repo: repoName }); @@ -180,9 +190,17 @@ async function provisionOne({ permission }); - // Final verification gate: required workflows must be present. - const present = await client.listWorkflowPaths({ owner: studentOwner, repo: repoName }); - const stillMissing = missingWorkflows(requiredWorkflows, present); + // Final verification gate: required workflows must be present. On a fresh + // create (or heal), GitHub copies template content asynchronously after the + // API returns, so give the copy time to land instead of failing the learner. + const attempts = result === 'already-exists' ? 1 : Math.max(1, verifyRetries); + let stillMissing = requiredWorkflows; + for (let attempt = 1; attempt <= attempts; attempt += 1) { + const present = await client.listWorkflowPaths({ owner: studentOwner, repo: repoName }); + stillMissing = missingWorkflows(requiredWorkflows, present); + if (stillMissing.length === 0) break; + if (attempt < attempts) await sleep(verifyDelayMs); + } if (stillMissing.length > 0) { throw new Error( `Verification failed for ${studentOwner}/${repoName}: missing ${stillMissing.join(', ')}`