-
Notifications
You must be signed in to change notification settings - Fork 26
How Classroom 50 Works
This page describes the model behind Classroom 50 — what it is, where its data is stored, and why teachers remain involved in administration. It also explains behavior that is otherwise surprising, such as why a student is an admin of their repository, or why unenrolling a student does not delete their repository.
Many web tools have a server and a database: you sign in to their account, and their systems store your data. Classroom 50 does not work this way.
- The web app (classroom50.org) is a static site hosted on GitHub Pages. It runs in your browser.
- The CLI (
gh teacher/gh student) runs on your own machine.
Neither keeps a database of classroom data. The web app stores only a small amount of local state in your browser — your GitHub access token and interface preferences such as theme and language — and the CLI reuses the GitHub CLI's stored credentials. Everything else is stored in GitHub, and your classroom's state is represented by ordinary GitHub data:
| What you think of as… | Is stored as… |
|---|---|
| Your classrooms, assignments, scores | Config files in a private classroom50 repo in your org |
| Who's enrolled | GitHub organization and team membership |
| Who's staff (teacher/TA) | Membership in secret GitHub teams |
| An address you invited, before that person joins | A secret per-invite team, removed once they're on the roster |
| A student's submissions | Commit history and Releases in their repo |
| Who can do what | GitHub permissions |
Classroom 50 reads and writes this GitHub data on your behalf, then reads it back to show the current state. Most of the behavior described below follows from this.
Because there is no always-on server, the app does not change state on its own while you are signed out. It cannot sync state in the background the way a hosted service can. As a result:
- Interactive work runs as you. Creating a classroom, adding a student, saving an assignment, or inviting a TA runs at the moment you do it, using your signed-in GitHub token. These changes happen only when you make them.
- Signing in can trigger a sync. Opening a classroom lets the app correct things that have fallen out of date — for example, migrating an old team name or re-checking organization settings. Some upkeep occurs only after an owner signs in and loads the page.
- Background jobs require setup. Score collection and regrading run as GitHub Actions on a schedule, but only after you provision the service token that lets them act while you are offline.
For teachers, this means administering Classroom 50 is closer to administering your own GitHub organization than to using a hosted service.
Because state lives on GitHub, "what you see" depends on when something last read it:
- Web app — loading a page reads the current GitHub state (so opening classroom50.org effectively refreshes the roster, teams, and config), and signing in as an owner can additionally run sync upkeep. One exception: the organization list is cached for ten minutes — use Refresh to force it. If a view looks out of date, reopening the page usually updates it.
-
CLI reads (
roster list,classroom list,member list, …) report what's committed and on GitHub at that moment; they never write or sync. If the web app shows a newer roster thanroster listdid a minute earlier, someone (or a sign-in sync) committed in between. -
CLI writes (
roster add,staff add,assignment add, …) update theclassroom50repository and GitHub teams immediately, but only for the thing they change — they don't run the web app's broader sync. -
Scores refresh only when collection runs: nightly, or on demand with
Sync now /
collect-scores.yaml.
gh teacher roster sync <org> <classroom> --write is the one CLI command that
syncs rather than reading or writing a single thing: it catches the roster up with
the classroom's GitHub state, chiefly the email invitations students have accepted.
Opening the classroom in the web app as an owner does the same on its own. Without
--write, roster sync reports and changes nothing. Sync now covers scores.
See What triggers a sync.
Work happens in one of two ways. Knowing which applies explains most questions about why a change did or did not take effect:
- Interactive actions run as you, with your signed-in GitHub token, at the time you take them (create a classroom, add a student, accept an assignment). They are limited by your GitHub permissions and require you to be present.
-
Asynchronous actions run in GitHub Actions workflows in your
classroom50repository (publishing to Pages, collecting scores, regrading). They run in the background, can take a minute or more, and depend on the service token rather than on you being online.
So when a change "hasn't shown up yet," it's usually a background workflow still running (or GitHub Pages still deploying), not a lost action.
The web app and the gh teacher / gh student CLIs are two front ends over the
same GitHub operations; neither is primary. A classroom created in the web app
is fully manageable from the CLI and vice versa, because both read and write the
same files and teams in your organization. Use whichever you prefer, or mix
them.
Classroom 50 has four roles, and each maps directly onto a GitHub construct:
| Role | On GitHub | Can |
|---|---|---|
| Teacher | Organization owner, on the -teacher team |
Everything, including org + classroom settings |
| Head TA | Org member, on the -hta team |
Write the classroom50 repository; manage the classroom; not an owner |
| TA | Org member, on the -ta team |
Read the classroom50 repository; view submissions |
| Student | Org member, on the classroom team | Accept and submit assignments |
Every classroom has a set of secret GitHub teams
(classroom50-<classroom>-{teacher,hta,ta} plus the student team
classroom50-<classroom>). Membership in these teams is the role — there's no
separate role database. That's why staff you invite show up as GitHub team
invitations, and why the classroom's team is the source of truth for who's
enrolled (not the roster.csv, which carries details GitHub can't store,
such as names, sections, and the address of a student who hasn't joined yet).
Enrollment is team membership, and the chain from invitation to enrollment has three steps:
- Creating a classroom creates its secret GitHub teams, including the student
team
classroom50-<classroom>. - Inviting a student through Classroom 50 sends a GitHub organization invitation that carries the classroom team.
- When the student accepts, GitHub adds them to the organization and the team in one step. Team membership makes them enrolled.
Because the invitation carries the team, inviting a student directly on github.com bypasses enrollment: they become an organization member but never join the classroom team. Enroll them from the organization's Members page instead. See Already an org member, but not on the roster.
Inviting by username records the student on the roster right away, because the username is the identity. An email address is not: GitHub offers no way to look up an account from an address, and the person who accepts could sign in with any account. Classroom 50 bridges that gap with an invite team.
- Inviting an address creates a
secretteam namedinvite-<hash>that holds that one address. If this can't be set up, no invitation is sent. - GitHub emails the invitation, carrying both the classroom team and the invite team.
- The address is written to
roster.csvas a pending row, with a role but no username yet. - Accepting adds the student to both teams. Because the invite team holds exactly
one person, whoever is on it is the student who accepted, so a sync fills their
account into the pending row and then deletes the invite team. If the invited
person turns out to be staff on the classroom, the row a sync creates for them
records that staff role rather than
student.
The web app and the teacher CLI both invite this way, and each reads the other's
invite teams, so an invitation sent from one can be completed or revoked from the
other. In the app, invite from the classroom's Roster page; from the CLI, use
gh teacher roster invite, one address per run. Two things only the web app does:
inviting a list of addresses in one upload, and inviting someone by email as a
teacher, head TA, or TA. A gh teacher roster invite is always a student
invitation, so it can never hand out organization ownership from a mistyped
address.
An outstanding invitation keeps its team and its pending row, so a teacher can see
who was invited. Cancelling one, from the app's roster or with
gh teacher roster cancel-invite, deletes its invite team and its pending row
right away; if either write fails, the next sync clears whatever is left. From the
CLI, cancelling first proves the invitation belongs to this classroom: its
metadata team must name the classroom, and the invitation must carry one of the
classroom's teams. Otherwise cancel-invite refuses rather than revoke a sibling
classroom's invitation. A team left by an expired invitation is cleaned up the same
way, once it is more than 24 hours old and GitHub no longer lists the invitation as
pending; a still-pending invitation is never touched.
Inviting an address some other row already carries is allowed, and deliberately so: an address can be shared (a parent, a lab contact), and the real person still needs inviting. The invitation is sent, but no second row is written for the address: one row per address, whichever tool wrote it.
Note
Invite teams are the one place Classroom 50 stores an address that GitHub
hasn't yet linked to an account. Each holds only the invited address and the
classroom name, never names or sections. Because the team is secret, no other
student or TA can see it: before the invitation is accepted the team has no
members at all, and afterwards only that student and the organization's owners
can read it. The address on a pending row is the one you invited, which is
not necessarily the email on the student's GitHub account.
Step 4 needs something to run. GitHub doesn't notify Classroom 50 when a student accepts, so nothing happens while nobody is looking. Four things sync the invite record, and all of them are idempotent, so running one that has nothing to do is free.
| Trigger | Where |
|---|---|
| Opening the classroom's roster in the web app, or its refresh control | The web app, automatic on open and then on demand |
| Entering a classroom as a teacher or owner | The web app, automatic; it also repairs missing teams |
| Clean up invite data | The classroom's Settings page, to clear stored addresses early |
gh teacher roster sync <org> <classroom> --write |
The CLI, so a script or a scheduled job can run it with no browser |
A sync is deliberately conservative on both sides. If a read is degraded, it
reports and removes nothing: no row is dropped and no invite team is deleted,
because an invite team it couldn't read can't prove that a pending row is dead.
gh teacher roster sync reports by default and changes nothing until you pass
--write. See roster sync for its exit codes.
Deleting a classroom removes its invite teams too.
The org lockdown (next section) means nobody sees anything they weren't explicitly granted. What each role can see:
| Teacher (owner) | Head TA | TA | Student | |
|---|---|---|---|---|
classroom50 repository (roster, scores, settings) |
Read and write | Read and write | Read | No access |
| Private assignment templates | All | Read | Read | Read (their classroom's) |
| Student assignment repositories | All | Read, granted at each collection run | Read, granted at each collection run | Their own only (write) |
| Other students' work | All | After a collection run | After a collection run | Never |
| Pending organization invitations | Yes | No | No | No |
flowchart TB
subgraph org["Your GitHub organization (base permission: none)"]
c50["classroom50 repository"]
tpl["Private template"]
aliceRepo["alice's assignment repository"]
bobRepo["bob's assignment repository"]
end
teacher["Teacher (organization owner)"] -- "full access" --> org
hta["Head TA team"] -- "write" --> c50
ta["TA team"] -- "read" --> c50
ta -. "read, granted at collection" .-> aliceRepo
ta -. "read, granted at collection" .-> bobRepo
students["Classroom team (all students)"] -- "read" --> tpl
alice["alice"] -- "write" --> aliceRepo
bob["bob"] -- "write" --> bobRepo
Three consequences worth calling out:
- Students never see each other's work. The "No permission" base grants nothing by default, and nothing grants one student access to another's repository.
- Students can read their classroom's private templates. The whole classroom team gets read access so accept can copy the template. Never commit solutions to a template. See Known limitations.
- Only owners can read pending invitations. A TA viewing the roster can't see who has been invited but hasn't accepted yet, so an invited-but-pending student may look missing to them.
The organization is locked down to least privilege. During setup, Classroom 50 sets the org's base permission to "No permission" and disables risky member capabilities (repo deletion, transfer, visibility changes, and more). This org-wide lockdown is the safety boundary.
Against that backdrop, each student's access to their own assignment repository is deliberately broad:
- Individual assignments: the student is created as an admin of their repo, then downgraded to write, enough to push work but not enough to do damage.
- Group assignments: the first student to accept (the founder) keeps admin on the shared repo, because they need it to invite teammates as collaborators. Classroom 50 has no separate "create a team" step — students form their own groups.
This is safe because of the org lockdown: even an admin on their own repo can't delete it, change its visibility, transfer it, or reach another student's private repo (the "No permission" base blocks cross-repo access). The generous per-repo access and the strict org policy work together.
Note
TAs and head TAs get read access to student repositories through the score-collection workflow, not at accept time. A newly accepted repo therefore has no staff team attached to it, which is expected.
Setup applies two organization-wide rulesets to every repository in the organization:
-
classroom50-protect-submission-historytargets each repository's default branch and blocks force pushes and branch deletion, so a student can't rewrite or erase their submission history. -
classroom50-feedback-base-locktargets thefeedbackbranch and blocks updates and deletion, keeping the frozen base of the Feedback pull request in place.
Both rulesets include an organization-admin bypass, so teachers keep full
control. Separately, the classroom50 repository's main branch has classic
branch protection with force pushes and deletion disabled.
If the setup checks report branch protection as failing and Fix it doesn't resolve it, an enterprise-level policy is usually pinning the setting. The check is advisory: Classroom 50 works without it. See Branch protection "Fix it" does nothing.
If you run both Classroom 50 and GitHub Classroom in the same organization, they can disagree on a setting and flip it back and forth, most notably private-repo forking. That tug-of-war shows up in the setup and audit checks as settings that changed outside Classroom 50. Classroom 50 no longer enforces the forking setting for this reason; private templates work either way. If you see a setting you fixed revert later, another tool (or an org/enterprise policy) is changing it back.
- A student pushes to their repository (via
gh student submitor a plaingit push). - A small workflow in their repo calls the shared autograde runner in your
classroom50repository, which fetches the grading logic from Pages and runs it. - The result is published as a GitHub Release on the student's repo.
- The score-collection workflow gathers those results into
scores.json.
Autograding is optional — an assignment with no tests still tags submissions and supports feedback. See Autograding Basics for the full pipeline.
If you enable the Feedback pull request, it is opened when the student accepts, so it is waiting before their first submission — and it exists even when GitHub Actions is disabled for student repos. Its base is frozen at the accept commit, so the setup files (the accept marker and autograde workflow) stay out of the diff you review. Should accept not manage it, the autograde runner opens the same PR on the first submission instead. See Autograders.
Classroom 50 keeps three actions deliberately distinct, so a small mistake can't cascade into deleting a student's work:
- Unenrolling a student removes them from the classroom's roster and team. It does not remove them from the organization, and it does not delete their assignment repositories.
- Removing a student from the organization revokes their access to every repo in it (and, as a side effect, to their assignment repos) — but still doesn't delete the repositories.
- Deleting a repository is always a separate, manual action.
Each accepted assignment produces a repository named in all-lowercase:
<classroom>-<assignment>-<username>
For a group assignment, <username> is the founder who created the shared repo.
These are normal GitHub repositories — scripts that automate git operations
against them generally work the same as they did with GitHub Classroom.
Note
Adding a template after the fact is a gotcha. Classroom 50 grants the classroom team read access to a private in-org template when you create the assignment with that template. If you create an assignment first and add the template later by editing it, that grant isn't re-applied — students may then 404 on accept. Set the template when creating the assignment, or re-grant team access to it.
The service token is a fine-grained personal access token stored as a secret
in your classroom50 repository. The background workflows (score collection, regrade) use it
to read and update student repositories across the org — work that can't run as
"you" because it happens on a schedule when you're not online. It's the same
token whether you set it up in the web app or the CLI, and you need only one per
organization. See the service-token setup.
| GitHub Classroom | Classroom 50 | |
|---|---|---|
| Backend | Hosted service | None (GitHub repos + Actions) |
| Classroom ↔ org | Classrooms managed in the hosted dashboard | A folder in your organization's classroom50 repository, plus GitHub teams |
| Grading | Hosted autograder | GitHub Actions in each repo |
| Joining | Students self-select their roster entry from an invite link | The owner invites students; accept links work once they've joined the org |
| Group naming | Team names | Founder's username |
| Data | In the service | In your classroom50 repository (yours to keep) |
For a term-by-term mapping of GitHub Classroom vocabulary (cutoff date, Download grades, roster identifiers, teams) to Classroom 50's, see Coming from GitHub Classroom? in the Glossary.
For a term-by-term reference, see the Glossary; for common questions, see the FAQ.
- Start here
- Teacher guides
- Autograding
- Students
- Reference