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(', ')}`