Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions .github/scripts/provisioning/__tests__/provision-core.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'] });
Expand Down
28 changes: 23 additions & 5 deletions .github/scripts/provisioning/provision-core.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
* }
*
Expand All @@ -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 = [];
Expand All @@ -85,7 +89,10 @@ async function provisionRoster({
templateRepo,
repoName,
requiredWorkflows,
permission
permission,
verifyRetries,
verifyDelayMs,
sleep
}),
{ maxRetries, sleep, logger }
);
Expand Down Expand Up @@ -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 });

Expand Down Expand Up @@ -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(', ')}`
Expand Down
Loading