-
Notifications
You must be signed in to change notification settings - Fork 29
Troubleshooting
This page collects known errors and their fixes, grouped by task and titled with the message you see, so searching the page for the error text usually lands on the answer. If nothing here matches, see Filing an issue.
Both CLIs accept --verbose / -v on any command. It shows each REST call,
response status, raw git output, and metadata writes. Try it first when
something misbehaves.
gh student submit -v
gh teacher download -v cs50-fall-2026 cs-principles helloFor raw REST request and response logging (headers and bodies), set
GH_DEBUG=api:
GH_DEBUG=api gh teacher invite cs50-fall-2026 aliceCommands with informational output also accept --quiet / -q.
Both CLIs put a limit on every network step, so a dead connection (a VPN that dropped, a firewall silently discarding packets) ends with an error instead of a command that hangs until you press Ctrl-C:
- Every GitHub API request has a 60-second limit.
- The git transfers (
gh student submit's clone and push,gh teacher download's clone and pull) use git's own stall detector: a transfer that moves no data for about 30 seconds fails, while a slow but progressing one is left alone. A 10-minute limit per repository is the backstop, mainly for SSH remotes, where the detector doesn't apply.
What to do:
-
gh student submit: check your connection, then rungh student submitagain. Submit is safe to repeat. -
gh teacher download: the batch skips the stalled repository and continues; the summary line and the error at the end list every repository that failed. Run the same command again to retry them. Existing clones are skipped (or fast-forwarded with--pull); if a pull keeps failing, delete that clone and run again to clone it fresh, as the error suggests. - Any other command: check the connection and re-run. The commands that write
(
init,roster add,assignment add,accept) check what already exists first, so a re-run picks up where the stalled one stopped.
If the same step times out on a healthy connection, run it with -v to see
which request stalls, and include that output when you file an issue.
A few ISPs and school web filters have blocked classroom50.org (a relatively
new domain) as a suspected phishing site. The symptom is a timeout, a
DNS failure, or an ISP warning page (Safari can time out without showing
the warning). Classroom 50 is not compromised; unblock requests go to ISPs and
security vendors as reports come in.
Workarounds:
-
Home connections. Add
classroom50.orgto the ISP's security-feature exception list (AT&T ActiveArmor, for example) or switch the device's DNS resolver to1.1.1.1or8.8.8.8. - School and district filters. Ask IT to allow the domains in Network and allowed domains.
- Reporting a block. Open an issue with the ISP's name, which is what an unblock request needs.
Browser sign-in routes the OAuth exchange through a small Cloudflare Worker (see The GitHub proxy for what it does and why), and this message ("Couldn't reach the sign-in service. It may be down. Check your connection and try again.") means your browser couldn't reach it. Two causes:
- You're offline. Check your connection; the app shows a separate "You appear to be offline" notice when it can tell.
- A school or corporate filter blocks the proxy. Ask IT to allow the domains in Network and allowed domains, or sign in with a personal access token, which skips the proxy entirely: on the sign-in card, click Other sign-in methods, then click Use a personal access token (classic) or Use a personal access token (fine-grained).
GitHub sometimes shows this during sign-in when a stale authorization is replayed, often after switching GitHub accounts. Sign out of classroom50.org and sign in again, or use a private browsing window.
Everything Classroom 50 does runs on GitHub, so a GitHub incident surfaces as Classroom 50 failures: assignments stuck publishing, accepts erroring, collection runs failing. Before debugging, check githubstatus.com (the API, Actions, and Pages components matter most). It is effectively Classroom 50's status page too.
GitHub only reports organizations you've granted Classroom 50 access to. A GitHub Education account doesn't change this: an organization you own can stay invisible until that grant exists. Work through these in order:
- Grant the organization. Open Classroom 50's OAuth settings and select Grant next to the organization. Classroom 50 also links this from the "Not seeing your organization?" notice on its home page.
-
Have an owner approve it. If the organization restricts third-party
applications, the same page offers Request access instead of Grant;
an owner then approves it under
https://github.com/organizations/YOUR-ORGANIZATION/settings/oauth_application_policy. - Authorize SAML SSO. On that same page, use Configure SSO to authorize the organization.
- Accept the invitation. An unaccepted invitation shows under pending invitations, not in the organization list. Check your organizations.
- Sign out and back in. A token issued before the membership existed authenticates fine but can't see the organization.
Then return to Classroom 50 and use Refresh: the organization list is cached for 10 minutes.
To check independently from a terminal:
gh auth refresh -s read:org,admin:org
gh api user/memberships/orgs --paginate \
--jq '.[] | [.organization.login, .state, .role] | @tsv'The organization should be listed active (and admin if you're setting it
up). The CLI token and the web app's token are separate, so a passing check here
still leaves the browser grant to do.
Setup creates a $0 GitHub Actions spending cap so a runaway workflow can't
run up a bill, but only when your organization has no Actions cap yet; a cap
you set yourself is never modified. It then verifies the cap, and that
verification can fail with an advisory warning (for example, Read failed: 400) when billing isn't readable by your token: typically enterprise-managed
billing, a plan that doesn't expose organization budgets, or a token without
Organization Administration read.
This is expected and doesn't block anything. Classroom 50 keeps working. Confirm your Actions spending limits in the organization's billing settings, or with your Enterprise/billing administrator.
The setup checks protect student work through two organization rulesets. For what they do, see How student repositories are protected.
On an enterprise-managed organization, an enterprise-level policy can pin these settings, so the organization-level change Classroom 50 requests is ignored: Fix it appears to succeed but the check stays red, and only an enterprise owner can change the setting. Like the spending-cap check, this one is advisory. Classroom 50 keeps working without it.
After you add the service token, the setup wizard returns to step 1 and never reaches step 3. This is stale cached state in the browser: sign out, clear the browser cache, and sign in again.
Creating a classroom can send you these emails for the classroom's student,
-hta, and -ta teams. They're expected: Classroom 50 creates the teams using
your GitHub token, which makes you a member of each, then removes you from all
but the -teacher team so you hold a single role.
init commits workflow files with the Git Data API, which GitHub gates behind
the workflow scope. A token without it is rejected with a misleading 404,
leaving classroom50 with only a README. Re-authenticate:
gh teacher login
# or add the scope in place:
gh auth refresh -s admin:org,workflowWhether a plain gh auth login already granted workflow depends on unrelated
prompt choices, which is why this appears on some machines and not others.
The Classroom 50 CLIs share the GitHub CLI's credential store, so this is worth
knowing if you already use gh for other work.
Running any teacher/student command (not login) never disturbs a working
setup unnecessarily:
- A stored token that already carries the required scopes (
admin:org,read:org,repo,workflow) is reused untouched. - An under-scoped token that
ghmanages (its config file or OS keyring) is widened in place withgh auth refresh: your existing token is kept, not replaced, and no otherghsettings change. - An under-scoped token from
GH_TOKEN/GITHUB_TOKENcan't be widened bygh, so you get an error naming the missing scopes: re-issue that token with them, or unset the variable and sign in. - With no stored token at all, the command starts a sign-in for you.
Running login explicitly is the one clobbering path. gh teacher login
wraps gh auth login, which mints a new token and replaces your stored
github.com auth. When a token already exists, the CLI warns and asks
Proceed and let gh auth login replace it? [y/N]. The default is No, and
declining leaves your auth untouched and prints the alternatives: widen your
current login in place with gh auth refresh -h github.com -s admin:org,read:org,repo,workflow, or bring your own token by exporting
GH_TOKEN set to a personal access token that carries those scopes.
So if gh is already set up, you usually don't need login at all: run
the command you want and let it add any missing scope in place.
With multiple gh accounts, the CLIs use whichever account is active for
github.com (gh auth status); switch with gh auth switch first. Not sure
whether anything needs fixing? gh teacher audit cs50-fall-2026 is read-only
and a good first probe.
Organization invitations need the admin:org scope, which a plain
gh auth login doesn't grant. The full message is missing admin:org OAuth scope; run gh teacher login to grant it. Run:
gh teacher loginThe CLI also checks your scopes before the request and widens a gh-managed
login in place (see the previous entry), so you rarely see this message.
You must be an organization owner for POST /orgs/{org}/invitations to succeed.
Check under https://github.com/orgs/YOUR-ORGANIZATION/people: you should show
Owner. (Team-based admin isn't enough for the invitation API.)
The desired state already exists, but the commands react differently:
-
gh teacher roster addandroster importreport it and exit 0, so they're safe to re-run in scripts. So doesgh teacher roster invitewhen GitHub already lists the address as a member or as invited: it prints askippedline and points you atroster sync, in case they accepted an earlier invitation. An address the classroom's own roster already carries as a pending row is different, and exits non-zero, since a second invitation would duplicate that row. - Repository invitations (
gh teacher invite cs50-fall-2026/hello alice) are idempotent: re-running updates the collaborator's permission in place. - Organization invitations (
gh teacher invite cs50-fall-2026 alice) fail with a non-zero exit: GitHub rejects re-invites to a pending or existing member. Useroster addwhen you need a re-runnable enrollment path.
GitHub organization invitations last 7 days. When one lapses, GitHub moves it to the organization's failed invitations list, and the student can no longer accept it. In Classroom 50 the row stays on the roster (nothing removes a row automatically) with an Invitation expired badge next to its state (Unlinked for an email row, Not in organization for an account row), and the Members page shows the same badge. To recover:
- One student. On the classroom's Roster page, click the row, then Re-invite. A fresh invitation is sent and GitHub's failed record is cleared once the send is confirmed.
- Several students. Select their rows, open Actions, then click Send invitations (N); or filter by Invitation expired first and select all. The count shows how many of the selected rows can be invited.
- A whole class. Upload the roster file again. Each expired address is marked Resend invitation (expired) in the preview and re-sent on import; addresses with a live invitation are skipped.
A failed invitation whose roster row was already removed shows up on the Members page as a failed invitation that isn't on any roster. Nothing can re-invite it from there: add the student to a roster again (upload or Add to classroom), and Dismiss the leftover record to clear GitHub's list.
If Re-invite reports that nothing was sent because GitHub already has a live invitation or a member for the address, the student was invited from another classroom in the organization or has already joined. Check the organization's People page on GitHub, then link the row to that member once they accept. If it reports GitHub's daily invitation limit (50 a day for organizations under a month old or on the free plan, otherwise 500), wait until the next day and try again.
Adding a student who is already in your organization (commonly someone from
a previous course) doesn't put them on the classroom roster, and re-inviting
them does nothing. GitHub won't send a fresh invitation to an existing member,
so the web app reports "Already a member or already invited, so no new
invitation was sent." (and the CLI prints already a member of the org and
exits 0). This is expected: organization membership and classroom enrollment
are separate. An invite only covers membership; enrolling an existing member
is a different action.
To enroll students who are already org members:
- In Classroom 50, open the organization's Members page (not a classroom's Roster page).
- Find each student. They show as a member with no classroom, or you can filter by No classroom.
- Select each student's row.
- Open the Actions menu, then click Add to classroom.
- Pick the destination classroom in the dialog, then click Add. The dialog previews how many members will be added before you commit.
Uploading a roster CSV or a plain list of usernames on the Roster page also enrolls existing members: the invite is skipped, but they're still added to the roster and team. A row identified only by an email address can't be enrolled that way, because GitHub won't invite an existing member and Classroom 50 has no way to tell which account owns that address, so the row is kept on the roster as "Unlinked". The same happens to a row that carries only a name (an SIS export before students have GitHub accounts).
To link or remove unlinked rows yourself, open the roster's Unlinked filter, then:
- Click a row and use Link account to open the Link to organization member picker and attach the right account, which also enrolls them on the classroom team. The picker suggests members of your classrooms first. To search every member of the organization, such as a student who joined the organization directly on GitHub, select Include organization members not in any classroom.
- Click a row and use Re-invite if the row has an email address and the student isn't in the organization yet (for example, the invitation expired).
- Select rows, open Actions, then click Remove rows to delete the ones you don't need.
Unlinked rows are never removed automatically: Classroom 50's roster sync never
deletes a row, so a row whose invitation expired (or was never sendable) waits
visibly under Unlinked with an Invitation expired badge instead of
disappearing; only linking or an explicit
delete ends it. From the CLI, gh teacher roster add cs50-fall-2026 cs-principles alice (or roster import) enrolls an existing member the same
way. Keep gh teacher up to date if you use both tools: releases older than
this behavior error on name-only rows in read commands like roster list, and
their roster sync --write still carries the old automatic cleanup that can
delete an email row nothing backs. Rows are recoverable from the classroom50
repository's commit history.
When an organization requires SAML single sign-on, GitHub rejects API calls
made without an authorized SSO session; the underlying error is
Resource protected by organization SAML enforcement. Common symptoms:
- Inviting students fails with a 403 even though you're an organization owner.
- A student's accept fails with "Couldn't confirm your membership. If your organization uses single sign-on (SSO), authorize it for this org (or open this link from your LMS), then accept again."
The fix is to establish the SSO session first: sign in to your identity provider, then to github.com, and authorize SSO for the organization (for the web app, use Configure SSO on Classroom 50's OAuth settings). Then retry the invitation or the accept link.
Every row in a CSV you import with gh teacher roster import must carry at least
one column that identifies a student. The line number points at the row where all
three cells are blank, commonly a row of empty cells, a leftover row from another
export, or a row that carries only a name (the CLI has no action for a row it
can't address; the web app's Upload roster keeps such rows as unlinked). Fill one in
or delete the row, then re-run. Every unusable line is reported in one pass and
nothing is committed, so one editing pass fixes the whole file. For the accepted
columns, see Roster CSV fields.
A github_id cell counts as present here even when it can't address an account,
so a row carrying only an unusable id passes this check and is skipped with a
notice instead. A usable id is a plain run of digits, positive, and no larger than
9007199254740991 (the largest whole number the web app holds exactly). Leading
zeros are tolerated and rewritten without them. Any other spelling, including a
leading + or a minus sign, stays unresolved, so that both tools read the row the
same way. Only a cell the CLI can't read as a number fails the line outright.
A copy of the stored roster.csv needs no trimming. import accepts the full
stored header (username,first_name,last_name,email,section,github_id,role), the
same header without role, and the first five columns alone, so a roster exported
from a web-managed classroom imports verbatim. A pending row for a student invited
by email is read too: its name and section are updated, matched by address, and
the invitation itself is never sent or cancelled. A row identified solely by
github_id is skipped, because import resolves students by username and has no
way to look up an account from an id. Its notice points at the web app's
Upload roster, and nothing stored for that student is touched.
The row names an account and an id that belongs to a different account, so it
addresses two students and gh teacher roster import refuses the line rather
than guessing which one you meant. The message names both ids: the one in the
file, and the one the username resolves to.
Usually the id was mangled by a spreadsheet: opening roster.csv in Excel can
turn 583231 into 5.83231E+05. It can also mean the columns are shifted by one,
or that the student renamed their account and you re-typed a username by hand.
Fix the username, or clear that github_id cell and let the username identify the
row, since the CLI re-resolves every id from GitHub anyway. Nothing was committed,
so correcting the file and re-running is safe.
The web app's Upload roster reads every row before changing anything, and if any row carries a value it can't use, it lists those rows and imports none of them. Each line in the report names the file line and the offending value, so one editing pass fixes the file. Re-uploading is safe: students already in the classroom are left alone, so nothing is duplicated by importing the corrected file.
It blocks rather than importing the rows it understood because a bad value usually means the file isn't what the app thinks it is: a column shifted by one, an export from another system, or the wrong format selected above the preview. In that situation "import the good rows" would enroll a handful of people and quietly drop the rest.
One case is reported but does not block: a row with no github_id, username,
email, or name at all, commonly a student who hasn't given you a GitHub account
yet. There's nothing to correct in that row, so the upload names it and imports
everyone else. A row that carries only a name is imported and kept on the roster
as an unlinked row you can link to an organization member later.
A leading column title is fine. If the first line of a one-column file is a
column name (username, email, github_id, and so on), it's recognized as a
heading and skipped rather than treated as a student. Any other unusable first
line is reported like every other bad row: a mistyped first entry looks the same
as a caption, and guessing would leave that student silently out of the import.
The web app's Upload roster reads a github_id column as the row's identity and
looks up that account's current username, which is what lets a re-uploaded export
still find a student who renamed their GitHub account. When an id matches no
account, the upload reports that row and stops rather than falling back to the
username next to it: a wrong id plus a stale username could invite a stranger
into your organization, so it refuses to guess.
Usually the id was mangled by a spreadsheet: opening roster.csv in Excel can
turn 583231 into 5.83231E+05. Re-export without reformatting that column, or
delete the github_id column entirely and let the username column identify
each row.
If instead the message says GitHub couldn't be reached to look up the id, your file is fine: that's a rate limit or a transient error. Wait a moment and upload again.
If it says there were too many github_id values to check at once, uploading again
won't help: delete the github_id column and let username identify each row, or
split the file. This only comes up for a large roster whose students aren't in the
organization yet, since ids for current members are checked for free.
If the id is right but the username beside it is out of date, nothing is blocked: the upload uses the account the id belongs to, shows both values in the preview, asks you to confirm, and corrects the stored username.
Before emailing an invitation, Classroom 50 sets up the invite team that retains the address. No invitation was sent and nothing was written to the roster, so there is nothing to cancel. If the message mentions a rate limit or a server error, wait a moment and invite the student again.
If it repeats, the setup is refusing on purpose, and retrying won't help. Two
causes: a same-named team already exists and can't be made secret, or one still
has a member from an interrupted run. Both name the team in the message. Delete
that team on github.com, then invite again. A bulk email upload reports the same
failure per address in its failed list rather than with this wording, and
gh teacher roster invite refuses for the same two reasons, naming the team and
sending nothing.
Expected until a sync runs. GitHub doesn't notify Classroom 50 when an invitation is accepted, and it stops reporting the invited address at that moment, so matching the new account to its pending row is a separate pass. Opening the classroom's roster in the web app runs one; from a terminal:
gh teacher roster sync cs50-fall-2026 cs-principles # what it would change
gh teacher roster sync cs50-fall-2026 cs-principles --write # apply itThe sync records the username and github_id onto the pending row, keeps the name
and section you'd already typed, and then deletes the invite team that retained
the address. It's idempotent, so re-running costs nothing. Don't reach for
gh teacher roster cancel-invite here: an accepted invitation is no longer
pending, so it reports that and changes nothing. For everything that runs a sync,
see What triggers a sync.
An organization invitation is org-wide, but everything cancel-invite tears down
belongs to one classroom, so it proves the invitation is this classroom's before
it deletes anything. Nothing is cancelled and the invitation stays intact. Four
refusals exit non-zero:
- No metadata team for the address. This classroom never sent it (another one in the organization may have), or the team was already deleted by hand.
- A metadata team with no invite record. An interrupted send leaves exactly that: the record is written last, so a team without one proves nothing.
- A metadata team naming a different classroom. Re-run naming that classroom; the message tells you which.
- An invitation carrying none of this classroom's teams. Two classrooms invited the same address, and the org-wide lookup found the sibling's invitation. Cancel it in that classroom.
For the first two, revoke the invitation from the web app's roster or from
https://github.com/orgs/YOUR-ORGANIZATION/people/pending_invitations, then
delete any leftover invite-… team by hand. gh teacher roster sync won't
collect a record-less team for you: it skips one for the same reason.
A fifth outcome isn't a refusal. With no pending invitation for the address at all,
cancel-invite reports that and exits 0, because a student who already accepted
looks identical from here. Run gh teacher roster sync cs50-fall-2026 cs-principles --write in that case.
Nothing to fix, and nothing is stuck. A cell that addresses no account isn't an
identity, so the row still counts as "invited, not yet joined". Unusable means
0, a negative number, one larger than 9007199254740991, or one written with a
leading +.
When the student accepts, a sync records their username and real github_id over
that cell, keeping the name and section you'd already typed. If the invitation is
gone and nothing else backs the address, the row stays on the roster as an
unlinked row for you to link or delete, like any other pending row nothing backs.
The web app reads the cell by the same rule, down to the leading +, so opening
the roster in a browser and running
gh teacher roster sync cs50-fall-2026 cs-principles --write reach the same
result.
A row that names an account and a github_id belonging to a different one is a
separate case: gh teacher roster import fails that line rather than guessing
which student you meant. See
github_id … is not this account's id.
A read was degraded, either GitHub's pending-invitation list or one of the invite teams, so the pass reported what it could and removed nothing: no pending row was dropped and no invite team was deleted, not even one whose address the roster already records. An invite team it couldn't read can't prove that a pending row is dead, and a wrong removal loses the only record of a student's invited address. The warnings on stderr name what it couldn't read.
Nothing destructive happened, so re-run once GitHub is healthy (check
GitHub status); if it was a rate limit, wait for
the window to reset. In a script, treat 1 as "try again later" and 2 as "a dry
run found changes pending". See roster sync.
Each email invitation gets a secret team named invite- plus a short hash,
which holds the invited address until that person joins. Seeing one is expected
while an invitation is outstanding. Classroom 50 deletes it once the invitation
has been accepted or cancelled, and clears one left by an expired invitation on a
later sync.
To clear them early, use Clean up invite data on the classroom's
Settings page, which writes anything still recoverable onto the roster
first. gh teacher roster sync cs50-fall-2026 cs-principles --write collects a
narrower set from a terminal: it records the invitations that were accepted, then
deletes a team that is more than 24 hours old, has no member, and has no
invitation GitHub still lists as pending. It also deletes a team whose sole member
is no longer on any of the classroom's teams, because that student was removed
and the mapping must not resurrect their row. gh teacher teardown removes every
invite team in the organization. A team whose description reads
classroom50: preparing invite is the leftover of an interrupted invitation: it
holds no address, and deleting it on github.com is safe.
A sync leaves three kinds of team standing and names each one on stderr, because none can be resolved without guessing:
- A stored address that no longer hashes to the team name. The invitee can edit their own team's description after accepting.
- More than one member. No single invitee can be identified.
-
A description that is no longer a readable invite record. The
classroom50: preparing inviteform above is the exception: that send is still in flight and holds nothing to lose.
Any pending row such a team might back is kept too. Check what happened, then delete the team on github.com by hand.
Warning
An invite team is the only thing that can match the account that accepts back to the address you invited. Deleting one by hand while its invitation is still pending breaks that link for good: the student joins the organization, no sync can complete their roster row, and the pending row stays behind as an unlinked row you have to link or delete yourself.
Expected when the account is on both a staff team and the student team.
Classroom 50 doesn't currently disallow dual roles (usually a teacher adding
themselves as a student), and the classroom's GitHub teams, not the role
column, are the enrollment authority. The automatic sync in the web app refreshes
that column to the account's highest role (teacher > hta > ta > student), so
you'll see a commit like [Classroom 50] Sync roster from teams: <classroom>
rewrite an empty/"" role to teacher. gh teacher roster sync doesn't refresh
that column; it records a role only on a row it adds for a student who accepted an
email invitation.
The student enrollment is unchanged: the account still shows a student badge
(alongside the staff one), is graded as a student, and can be unenrolled from
the student side. roster add prints a note when the target is already staff.
See Dual roles. For a
"pure" student, use a separate GitHub account.
Students generate their repositories from the template, and GitHub can't generate from an empty repository. The CLI refuses with:
template `<owner>/<repo>` has no commits: add at least one commit (a README is enough) so students can generate from it, then re-run
Push at least one commit (a README is enough) and re-run. If the template does have commits and an older CLI still reports this, update the CLI: earlier releases relied on a repository size field that GitHub computes asynchronously, which misreported freshly pushed repositories.
| Message | What it means |
|---|---|
| "the classroom may not exist yet, or its publish-pages workflow may not have run" | Setup isn't finished or Pages hasn't deployed. Wait a few minutes, or ask your teacher. |
| "assignment X is not registered" | A typo, or your teacher hasn't added the assignment yet. |
"autograder <name> not published yet" / "is malformed YAML" |
The autograder's YAML is missing or broken; see below. |
"template <owner>/<repo> is not accessible to you" |
The template is private and not shared with you; see "Template not found". |
"assignment <X> has unsupported mode <mode>" |
The manifest's mode is none of individual, group, or team (likely hand-edited). Ask your teacher. |
"assignment <X> is locked by your teacher" |
The teacher locked the assignment (for example, before a timed assessment). Wait for them to unlock it. |
"assignment <X> is closed to new submissions" |
The teacher used Close submission. Ask your teacher to reopen it. |
| "the repository name … is over GitHub's 100-character limit" | The classroom and assignment slugs are too long for your username; see below. |
| "Assignment already accepted" | Not an error: your repository already exists and your work is untouched. |
You've already accepted; the repository is at
<org>/<classroom>-<assignment>-<username>. The CLI short-circuits to protect
your work. Clone it with the URL from gh repo view if you don't have it
locally.
Both gh student accept and the web accept page can fail with a message like
"the repository name <name> is … characters, over GitHub's 100-character
limit, so it couldn't be created."
Student repositories are named <classroom>-<assignment>-<username>, and
GitHub caps repository names at 100 characters. New classrooms and assignments
can't exceed it, but an assignment created before the limit was enforced can,
and then students with long usernames can't accept. The student can't fix this;
the teacher renames the assignment slug once (see
Updating an over-budget assignment slug
or assignment rename) and the student
accepts again.
Assignment data reaches students through GitHub Pages, and a Pages deployment takes at least 20 seconds after every change (longer when GitHub Actions is queued). The web app's message is "…isn't published yet. Ask your teacher to confirm the file exists in the classroom50 repository and that the publish workflow has run." Check in order:
- Wait a minute and reload the accept link.
- The teacher confirms the publish workflow succeeded, under the Actions
tab of
<org>/classroom50. - The student has accepted their organization invitation. An unaccepted invite also blocks the accept flow (see the accept error table).
The red banner appears after you create or edit an assignment or classroom, and the run's deploy step (the build step passed) reports:
Failed to create deployment (status: 400) … Responded with: Deployment request
failed for <sha> due to in progress deployment. Please cancel <other sha> first
or wait for it to complete.
Nothing is misconfigured, and your change is saved in the classroom50
repository. GitHub Pages deploys one version of a site at a time, and it is
still marking an earlier publish (the <other sha>) as in progress, usually
after a slow deploy or a cancelled run. The lock clears on its own within about
10 minutes. Because every publish deploys the whole site, the next one that
succeeds carries every change made in the meantime.
In the web app the banner names this cause under the failed row and offers two ways out:
- Wait, then Retry. The row flips to "the earlier publish has finished" once GitHub releases it. Click Retry, which re-runs only the failed deploy step against the artifact the run already built.
- Cancel the stuck publish and retry. Cancels the blocking Pages deployment through GitHub's API, then re-runs the failed step at once. Your sign-in token needs write access to Pages, which the standard teacher sign-in has.
From a terminal, the same two calls are:
gh api -X POST repos/YOUR-ORGANIZATION/classroom50/pages/deployments/OTHER-SHA/cancel
gh run rerun RUN-ID --failed -R YOUR-ORGANIZATION/classroom50Creating a new classroom doesn't help: the lock belongs to the
YOUR-ORGANIZATION/classroom50 repository, which every classroom shares.
If several publishes queued at once and one shows "included in a newer publish"
instead of failed, that isn't an error either: GitHub cancelled it in favor of
the newer run, which published its change too. Updating the organization's
workflow files (the update banner) stops this happening, since the current
publish-pages.yaml lets publishes queue instead of cancelling each other.
The accept page shows this error card, listing the URLs it tried, when the classroom's published assignment data couldn't be fetched from the student's browser. The assignment itself is usually fine. Two common causes:
-
The organization's GitHub Pages site uses a custom domain. GitHub then
answers
github.iorequests with a redirect the browser rejects. Set the classroom's Custom Pages domain in the classroom's Settings; see Using a custom Pages domain. - A network problem or filter on the student's side blocks the Pages host. The card lists the exact URLs attempted, so a screenshot of it gives the teacher everything needed to check them against Network and allowed domains.
A custom domain that is set but mistyped or offline doesn't lock students
out: the app falls back to the default github.io address. The message
"Couldn't reach the classroom's custom Pages domain" means the domain in
Classroom Settings needs verifying.
Only applies to assignments with a template. Check, in order:
- The template is readable by the student. Public always works; a private template must be inside your organization (see Template visibility). If it's outside, re-add the assignment with an in-org copy or a public template. If a student still 404s, confirm they're on the roster (so they're in the team).
- The repository is flagged as a template. Open the template repository's Settings and check Template repository.
-
The
<assignment>argument matches the registered slug (case is normalized; spelling must be exact).
GitHub's raw 403 says the upstream "organization has enabled OAuth App access restrictions", and the CLI reports:
couldn't copy the template `<owner>/<repo>`: it is a fork of a repository in the `<upstream-org>` organization, and copying a fork is governed by that organization's third-party app restrictions
The template is a fork whose upstream lives in another organization, and GitHub applies the upstream organization's app restrictions when copying a fork, so approving Classroom 50 for your classroom organization can't fix it. Either ask an owner of the upstream organization to approve the Classroom 50 app there, or (usually better) replace the template with a non-fork copy: import the repository into your organization as a fresh repository, mark it as a template, and re-add the assignment.
A 403 when a student's repository is created. Despite the wording, the student does not need admin access, and this is usually not a problem with the template or the assignment. The classroom organization is refusing to let its members create repositories, so re-running assignment setup can't fix it.
Fix it in the organization's settings. Under Member privileges, find Repository creation:
- Select Repository creation so members can create repositories.
- Select Private, and leave Public unchecked: students' coursework and any reference solutions should not be publicly visible.
- Have the student accept again.
Re-running organization setup in Classroom 50 (Re-run setup in the organization's settings) applies this along with the rest of the audited lockdown, so it's the better fix if other settings have changed too.
If an enterprise policy pins repository creation at the enterprise level, the org-level toggle is ignored and only an enterprise owner can change it. In that case the re-run reports success but the setting stays off.
Other causes produce the same message, so if repository creation is already enabled, check that the student's org invitation was accepted (a pending invitee can't create a repository) and that they're a member rather than an outside collaborator.
The assignment references an autograder workflow whose YAML isn't on Pages. Two causes:
-
The file doesn't exist. This fires only for non-default
--autograder <name>values;<classroom>/autograders/<name>.yamlmust exist in theclassroom50repository. Ask your teacher to confirm. -
publish-pages.yamlhasn't run. A fresh classroom needs one Pages deployment. Wait a minute and retry.
("autograder <name> is malformed YAML" means the workflow has a syntax error.
gh student validates before writing, so a broken file never lands. Ask the
teacher to fix it.)
submit reads .classroom50.yaml at the repository root to identify the
assignment. Two causes:
- You're running submit from outside the cloned assignment repository, or from
a clone not created by
gh student accept.cdinto the directory thegit clonecommand created. - The assignment is an empty-repository assignment, whose repositories
carry no marker file. As the error's hint says, autograding is disabled there
and
gh student submitis not used: commit andgit pushdirectly.
submit pushes to the repository's actual default branch (main, or master
where GitHub named it that), and autograding triggers on that branch. If a
submission still isn't graded, confirm the push landed on the default branch
and that the autograde workflow ran under the repository's Actions tab.
The assignment is in submit-only mode (submission_mode: tag): plain
pushes don't trigger the autograder there. That's the point: they cost no
Actions minutes. The classroom50/autograde-skipped commit status reading
push not graded; run gh student submit is the runner telling you exactly that
(graded commits report under classroom50/autograde instead; a not-graded
commit never shows green there). To be graded, submit explicitly:
-
gh student submit(it pushes thesubmit/…tag that triggers grading), or - tag a commit yourself:
git tag submit/final && git push origin submit/final. Any tag undersubmit/grades, plus any milestone tag your teacher named, such asgit tag phase1 && git push origin phase1.
If a push shows NO workflow run at all, that's normal for tag mode too: the repository's workflow only fires on submission tags.
The tag you pushed matches neither submit/* nor any milestone tag the
teacher configured for this assignment, so nothing was graded. Check the
milestone names with your teacher (they're case-sensitive), or use
gh student submit / a submit/* tag, which always grade.
Teachers: if a repository you expected to grade on push shows that status,
the repository's workflow file is still on the every-push trigger while the
assignment is tag-mode (or vice versa). Run
gh teacher assignment submission-mode cs50-fall-2026 cs-principles hello --tag
(or --every-push), or use Update autograding triggers on the submissions
page, to update the repositories, and have students git pull afterward.
Exit code 127 means the shell couldn't find a command. For Python assignments
this used to mean a missing pytest; the built-in autograder now installs
pytest and pytest-json-report automatically. If a grading run still exits
127:
- The classroom's workflow files predate the fix: refresh them by re-running
gh teacher init cs50-fall-2026and accepting the refresh prompt. - A custom setup command replaced the Python environment: install the tools your tests import there. See the Python recipe.
The grade job itself needs a few programs besides your assignment's toolchain:
curl, python3, and the GitHub CLI (gh), which posts the commit status
and publishes the submission release. GitHub-hosted runners preinstall all of
them; a self-hosted runner or a custom container image has only what you
installed, and gh is the usual gap. Install the missing ones on the runner
and re-run the workflow. The Check runner tools step lists everything
missing at once, and warns (without failing) when git is absent, since
grading then runs without allowed_files enforcement or a review diff link. See
Custom and self-hosted runners.
Every submission ends as an error, and the Grade details step says the
bundled tests.json has the wrong shape. Someone committed a tests.json to
CLASSROOM/autograders/ASSIGNMENT/ in the classroom50 repository, usually by
copying the --tests file example from the wiki. That directory holds fixtures
and grading scripts only; the Publish Pages workflow generates tests.json
from the tests stored on the assignment, so a hand-written one is never valid
there.
- Delete
CLASSROOM/autograders/ASSIGNMENT/tests.jsonfrom theclassroom50repository and push. - Add the tests on the assignment instead: the web form's Autograding
tests section,
gh teacher assignment test addfor one test, orgh teacher assignment test set ORG CLASSROOM ASSIGNMENT --tests FILEto load the whole list from a file.--testsaccepts both the bare array and the envelope, so the file you deleted in step 1 works as-is. - Wait for the Publish Pages workflow to finish, then submit as a test student.
If the assignment already had tests, the publish log also warns
replaced by the tests stored on the assignment: grading works, but delete
the committed file so the next person doesn't edit it. See
Where tests live.
A declarative test's setup or run command fails with something like
bash: check.sh: No such file or directory, even though check.sh is
committed next to the assignment's fixtures and shows up in the bundle.
Commands run in the student's checkout, and nothing from the bundle is copied
there. Only input-file and expected-file are read from the bundle for you;
every other file is reached through the CLASSROOM50_BUNDLE_DIR variable.
Change the command to go through it:
gh teacher assignment test add ORG CLASSROOM ASSIGNMENT \
--name "hidden checks" --type run \
--run 'bash "$CLASSROOM50_BUNDLE_DIR/check.sh"' --points 5In the web form, enter bash "$CLASSROOM50_BUNDLE_DIR/check.sh" as the Run
command. On a Windows runner, write %CLASSROOM50_BUNDLE_DIR% instead. See
Teacher-only test files.
Almost always means the CLASSROOM50_SERVICE_TOKEN can't read the student
repositories, not that no one submitted. (A fine-grained PAT returns 404 for
out-of-scope repositories, indistinguishable from "no release yet".)
- Confirm the token's Repository access is All repositories (not Only select repositories: student repositories are created on demand), with Contents, Actions, and Administration set to Read and write and Organization permissions, Members set to Read.
- Re-scope and rotate with
gh teacher rotate-service-token cs50-fall-2026, or use Test token under Service token in the organization's Settings to check the stored token. - A
401/403(rather than the0 submissionswarning) means a bad or expired token or a missingMembers: Readpermission, unless the log names a throttle (see below).
A 403 that is actually GitHub's rate limiter is reported as one, not as a token problem, so don't rotate the token for it:
- A throttled staff-team access grant doesn't fail the run. The log says "GitHub is throttling, not refusing" and that the deferred repositories "are granted by the next run".
- A throttled collection is fatal (incomplete collected scores must not
report success) and the log says "collection was throttled by GitHub
(HTTP
<code>,<reason>)". Wait for the window to reset and run collection again.
Also check the assignment itself: with autograding paused or a tag-mode assignment no one has submitted to, there are no results to collect.
The warning is skipped when the run detected pushes that the autograder has not graded yet: reading those repositories proves the token works, and the gap is the autograder's (see "The collection run failed.").
See the service-token setup.
Collection grants the head TA and TA teams read access to student repositories as it runs, and reports when it had nobody to grant it to. The level tells you whether anything needs fixing:
- A notice means the classroom has no staff team at all: nothing in
classroom.jsonnames one and none exists on GitHub. A solo teacher can ignore it. It stops once you add a TA on the roster page or withgh teacher staff add cs50-fall-2026 cs-principles carol --role ta. - A warning means a staff team exists but has no members, or a team
recorded in
classroom.jsonis missing or couldn't be read. Check each TA's role on the roster page, then run collection again.
The submissions page shows this when the latest score-collection workflow
failed outright. Open the failing run under the Actions tab of
<org>/classroom50 (the collect-scores.yaml workflow); the log names the
cause. The most common one is an expired or under-scoped service token; see
collect-scores warns "collected 0 submissions"
for the token requirements, how to rotate it, and how to tell a GitHub
throttle apart (wait and rerun instead of rotating).
By default download is team-driven. If you get zero clones:
- Confirm
<org>/classroom50exists and the classroom team has members (add them withgh teacher roster add/import). - Confirm the assignment is registered (
gh teacher assignment list). - Verify a few student repositories exist under
https://github.com/orgs/YOUR-ORGANIZATION/repositories?q=CLASSROOM-ASSIGNMENT, replacingCLASSROOM-ASSIGNMENTwith the classroom and assignment slugs. - Re-run with
-vto see which members were probed.
If the classroom50 repository isn't bootstrapped, or you want every matching
repository regardless of the roster, pass --by-pattern.
gh extension install . registers the binary only the first time. After pulling
new commits, rebuild:
(cd cli/gh-teacher && go build .)
(cd cli/gh-student && go build .)If go build itself fails, run go mod tidy first.
If none of the above helps, open an issue at https://github.com/foundation50/classroom50/issues. Include:
- The exact command you ran.
- The full output, ideally with
-vand/orGH_DEBUG=api. - Your
gh --versionandgo version. - Your OS and shell.
- Start here
- Teacher guides
- Autograding
- Students
- Reference