-
Notifications
You must be signed in to change notification settings - Fork 26
Autograding Basics
How Classroom 50 grades submissions and how to read the results: define tests on the assignment, and every submission gets a score you can read on the submissions page and export as CSV. For custom grading scripts and environments, see Advanced Autograding; for ready-made per-language setups, see Autograder Recipes; for reducing what grading costs, see Managing Actions cost.
- You define tests on the assignment — usually
declarative tests (input/output checks, run commands,
or pytest), written in the web form or with
gh teacher assignment test add. - A student submits by pushing to their repository, or explicitly with
gh student submit, depending on the assignment's submission type. - GitHub Actions grades the submission in the student's own repository: a small workflow calls the shared autograde runner, which fetches your grading config and runs the tests against the submitted commit.
- The runner publishes the result on the student's repository as a GitHub
Release on a
submit/<UTC-timestamp>-<short-sha>tag — the score, a per-test PASS/FAIL table, and a machine-readableresult.json. The graded commit also gets aclassroom50/autogradecommit status, and the student's View grade link opens the Release. - The score-collection workflow gathers the collected scores
(
scores.jsonin yourclassroom50repository) — on demand with Sync now on the submissions page. That's what the web app's submissions page and both CSV exports read. See Reading results.
Steps 3–5 run with no per-repository maintenance: everything substantive (the runner
workflow, runner.py, autograders, runtime config) lives in the classroom50 repository
and is fetched at run time, so a grading edit reaches every existing student
repository on the next submission.
Both surfaces drive the same pipeline: the web assignment form and
gh teacher assignment write the same assignments.json, and the submissions
page and gh teacher download read the same results.
Note
Grading and publishing share one job and runner, so the workflow is not a credential or hostile-workflow isolation boundary between them.
Each assignment has a Grading choice (the web form's Grading field; the
grading block in assignments.json):
- Not graded — no scores; submissions still tag and publish Releases, and the Feedback PR still works.
-
Autograded — the default meaning of grading here: tests or an
autograder.pyscore each submission automatically. The rest of this page is about this mode. - Manual — you enter each score by hand on the submissions page, out of the assignment's Max points. No autograder score is used — though the built-in workflow still runs on submissions unless you also turn the built-in autograder off.
Two related, optional settings:
- Pass threshold — an advisory percentage of the max score (0–100) at or above which the submissions page shows a submission as passing (badges, passing/failing rollups, and filters). It never changes a student's actual score, and leaving it unset turns the passing concept off.
-
Built-in autograder off (
no_autograder) — accept installs no autograding workflow at all; a templated assignment's own CI runs instead, and score collection skips the assignment. See Turning autograding off or pausing it.
The lowest-friction way to grade: describe io/run/pytest checks directly on the assignment, and the runner grades them with a built-in interpreter — no grading code to write. The three types map onto GitHub Classroom's legacy autograder presets.
In the web app, add tests in the assignment form's Autograding tests section. From the CLI, author them one at a time:
gh teacher assignment test add cs50-fall-2026 cs-principles hello \
--name compiles --type run --run "gcc -o hello hello.c" --points 1
gh teacher assignment test add cs50-fall-2026 cs-principles hello \
--name "prints hello" --type io --setup "gcc -o hello hello.c" \
--run ./hello --expected "Hello, world!" --comparison included --points 2
gh teacher assignment test list cs50-fall-2026 cs-principles hello
gh teacher assignment test remove cs50-fall-2026 cs-principles hello compilesOr set the whole array at once with gh teacher assignment add ... --tests <file.json> (--tests - reads stdin). The file is a bare JSON array — the same
shape assignment test list --json emits:
[
{ "name": "compiles", "type": "run", "run": "gcc -o hello hello.c", "timeout": 30, "points": 1 },
{ "name": "prints Hello, world!", "type": "io", "setup": "gcc -o hello hello.c",
"run": "./hello", "expected": "Hello, world!", "comparison": "included", "points": 2 },
{ "name": "greets by name", "type": "io", "setup": "gcc -o hello hello.c",
"run": "./hello", "input": "Alice\n", "expected": "^hello,\\s+Alice\\b",
"comparison": "regex", "points": 2 },
{ "name": "pytest suite", "type": "python", "run": "python -m pytest -q", "timeout": 120, "points": 10 }
]| Type | Passes when | Type-specific fields |
|---|---|---|
io |
stdout of run matches expected per comparison
|
input / input-file, expected / expected-file, comparison
|
run |
exit code of run equals exit-code (default 0) |
exit-code |
python |
pytest passes; points split across cases | — |
Note
The runner auto-installs pytest and pytest-json-report for python tests.
Add a setup install line only to pin a version.
| Field | Notes |
|---|---|
name |
Required. Unique within the assignment; ≤ 100 UTF-8 bytes; no control characters. |
type |
Required. io, run, or python. |
run |
Required. Shell command, run in the student checkout. |
setup |
Optional pre-command (for example, compile). Non-zero exit fails the test. |
input / input-file
|
io only, mutually exclusive. Inline stdin or a bundled fixture. |
expected / expected-file
|
io only, mutually exclusive. Must be non-empty for included/regex. |
comparison |
io only. included (substring), exact (trimmed equality), or regex (Python re.search, multiline). |
timeout |
Seconds, 1–600. Omit or 0 for the default of 10s. Applies to setup and run separately. |
exit-code |
run only, 0–255. Omit to require 0. |
points |
Required, 0–1000. A 0-point test does not affect the numeric score; a failure still sets the autograde status to failure. |
At most 100 tests per assignment. Put large fixtures in files
(input-file / expected-file) under CLASSROOM/autograders/ASSIGNMENT/, not
inline. In paths and commands on this page, replace CLASSROOM with the
classroom's short name and ASSIGNMENT with the assignment slug.
The web assignment's Setup command is stored as the leading zero-point
run test named setup. New setup commands start with a 120-second timeout.
Set the timeout to 0 for the runner's 10-second default, or choose a whole
number from 1 through 600. A failure or timeout sets the autograde status to
failure without changing the numeric score; later tests still run.
Use the command for filesystem changes that later tests need. Install a requirements file with:
python3 -m pip install -r requirements.txtFor a packaged project, including one with a src/ layout, install the package
in editable mode:
python3 -m pip install -e .Choose the command that matches the project. An editable package install reads the project's package metadata; a separate requirements install is needed only when the project uses that file.
Every assignment setup, per-test setup, and run command starts in a separate
shell process in the student checkout. Files, virtual-environment directories,
and installed packages persist between commands. Shell state does not: cd,
export, aliases, and virtual-environment activation end when their command
exits. Invoke a virtual environment's interpreter by path in later commands,
for example .venv/bin/python -m pytest -q on Linux or macOS and
.venv\Scripts\python.exe -m pytest -q on Windows.
For pytest-only import paths, set pythonpath in pyproject.toml or
pytest.ini. A command that needs one environment value can set it inline on
Linux or macOS:
PYTHONPATH=src python3 -m pytest -qOn Windows:
set "PYTHONPATH=src" && python -m pytest -qDo not write grading environment variables to $GITHUB_ENV. All declarative
commands run as child processes inside the single Grade details workflow step,
and GitHub Actions reads $GITHUB_ENV only after that step finishes. A write
cannot change the runner process or the environment of later tests.
How tests flow, and where failures surface
Tests live inline in assignments.json. On the next push to the classroom50
repository, publish-pages materializes them into the assignment's Pages
bundle as tests.json. At grade time, runner.py runs each spec in the student checkout:
one row per test in result.json, plus a failure breakdown in three places — the
Release body, the grade job log ("Grade details"), and the run Summary
page. Captured output is truncated at 2000 characters.
Specs are validated three times: by the CLI at write time, by the runner
workflow at submission setup, and by runner.py before executing.
When declarative tests aren't enough, write the grading logic yourself as an
autograder.py, customize the grading environment with the runtime block,
or swap the grading pipeline entirely. All three live in
Advanced Autograding. For worked per-language
setups, see Autograder Recipes.
What triggers grading is a per-assignment choice (the web form's
Submission type; submission_mode in assignments.json; gh teacher assignment add --submission-mode / gh teacher assignment submission-mode):
every-push (the default) — grading triggers on two events:
-
Push to the default branch — every commit grades, except the acceptance
commit (the one that introduced
.classroom50.yaml, with nothing on top). -
Push of a
submit/*tag — manual tag pushes work too.
tag — grading triggers only on submit/* tag pushes. A plain
git push runs nothing and costs no GitHub Actions minutes — the cost lever for
large cohorts. Submissions become an explicit act:
-
gh student submitpushes asubmit/<UTC-timestamp>-<short-sha>tag after the branch commit — that tag push is what grades. - A hand-pushed tag works exactly the same:
git tag submit/anything && git push origin submit/anything. Any tag undersubmit/grades; no CLI required.
Milestone submission tags — with either mode, the assignment can also
name milestone tags (submission_tags in assignments.json, the web form's
Submission tags field, for example ["phase1", "phase2", "complete"], settable at
creation or from the assignment settings / gh teacher assignment add --submission-tag). Pushing a matching tag grades that commit — plain git, no
CLI required:
git tag phase1
git push origin phase1Simple globs work too (v*), though exact milestone names are safer — a
broad glob grades every matching tag a student pushes. The milestone tag
triggers grading; the graded record still lives at the canonical
submit/<UTC-timestamp>-<short-sha> tag the runner mints at that commit (its
Release title notes "via phase1"), so history stays one-immutable-release-
per-submission and collection, regrade, and the collected scores are unaffected. The
submit/* namespace always keeps working alongside milestone tags.
Because the trigger lives in each student repository's workflow (GitHub evaluates a
workflow's on: block before any job runs), changing the mode or the
milestone patterns after repositories exist requires retrofitting each repository's
workflow — see
Changing the trigger on existing repositories.
Why the acceptance commit is skipped
Accepting lands .classroom50.yaml + the workflow in one commit, which fires
the workflow — but that's accepting, not submitting. The runner detects it
and skips tagging, grading, and the Release (the run still appears in the
Actions tab with a notice). Detection is fail-open: any uncertainty grades
rather than risk dropping a real submission. Your first gh student submit
always stacks a fresh commit, so it's never mistaken for the acceptance.
Tag-mode defenses in the runner
Two guards keep tag mode honest even when a repository's workflow trigger is stale:
-
Stale-trigger suppression — a repository accepted before the mode flipped to
tag(or whose retrofit failed) still carries the every-push trigger. The runner readssubmission_modefrom the published assignments.json at setup time and, when the assignment is tag-mode but the run was branch-triggered, skips tagging and grading, posting aclassroom50/autograde-skippedsuccess status: "tag-mode assignment — push not graded; run gh student submit". -
Retrofit-commit skip — the teacher-side trigger update commits with
[skip ci], so it fires no workflow. As a backstop (for example, a client that dropped the marker), the runner also recognizes a tip commit touching ONLY.github/workflows/autograde.yamland skips it with the status "autograder trigger updated — nothing to grade". -
Foreign-tag suppression — a pushed tag matching neither
submit/*nor any configured milestone pattern (possible only with a stale or hand-edited workflow) is skipped gracefully with theclassroom50/autograde-skippedstatus "tag is not a submission trigger — not graded", never a failed run.
The two suppression statuses use the separate classroom50/autograde-skipped
context deliberately: in both cases the student's real work exists but was
not graded, and a green classroom50/autograde would read as "graded
successfully". Graded commits alone report under classroom50/autograde.
(The nothing-to-grade skips — acceptance commit, trigger-update commit, no
autograder configured — stay on the main context: there is no work there to
mistake for graded.)
The autograding workflow is written into each student repository at accept time and
otherwise never changes, so flipping submission_mode on an assignment with
accepted repositories needs a retrofit:
-
CLI:
gh teacher assignment submission-mode ORG CLASSROOM ASSIGNMENT --tag(or--every-push) flips the field AND rewrites the workflow across every student repository (add--user USERNAMEfor one repository,--dry-runto preview). Requires theworkflowOAuth scope (gh auth refresh -s workflow). - Web: change the trigger on the assignment settings page, then run Update autograding triggers from the submissions page's actions menu (or per-repository from a row's manage dialog).
The rewrite is surgical — only the trigger lines change; a workflow a student
hand-edited is reported and left untouched. Custom (non-default)
autograders are never rewritten: you own their on: block; edit it
yourself and use --update-shims=false to flip only the field.
After a retrofit, students must git pull — clones made before the change
conflict on their next push.
To turn autograding off for an assignment, pause it over a break, or reduce what grading costs, see Managing Actions cost.
Every graded submission produces the same three records:
| Record | Where | What it shows |
|---|---|---|
| Release | The student repository, on the submit/<UTC-timestamp>-<short-sha> tag |
The score, a per-test PASS/FAIL table, and the machine-readable result.json. This is what View grade / View autograder details links open — and the only place with the per-test breakdown. |
| Commit status | The graded commit (classroom50/autograde) |
success / failure / error at a glance, right on the commit. |
| Collected score |
scores.json in the classroom50 repository, after collection |
What the submissions page and the CSV exports read: score, submission time, links, late flag, and full attempt history. |
Releases and statuses appear the moment grading finishes. The collected scores
lag until collection runs — on demand with Sync now on the
submissions page (gh workflow run collect-scores.yaml from the shell). If a
student says "I submitted" and you see no score, sync first.
On the submissions page, each row shows the student's (or group's) current score with links to the repository, the graded commit, the Release (View autograder details), the full review diff (starter code → graded commit), and the Feedback PR (Review).
The score on a row — and in the web CSV's summary columns — is the latest submission's (or a teacher override); in the CLI CSV the latest is the first line per member. "Latest" follows the submission, not the commit: if a student deliberately submits an older commit (a milestone tag pointing at earlier work, or a regrade), that submission's Release becomes the latest. The badge and the collected scores always agree because they use the same rule.
The full history is kept everywhere:
- Web — click a row's submission count to open its details: every attempt, newest first, each with its commit link and a per-attempt View grade Release link.
-
Student repository — one immutable Release per attempt, under the repository's
Releases tab (each
submit/*tag is one graded attempt). -
scores.json— each entry'ssubmissionsarray holds every collected attempt, newest first. -
gh teacher download— writes each repository'sresults.json(all attempts) next toresult.json(latest), and one CSV line per attempt (see below).
Students sometimes ask you to grade a particular commit, not their latest:
-
Every attempt already has its own frozen result — find that commit's
submit/*Release in the history; its score and per-test table are exactly as graded. -
To grade an arbitrary commit, have the student push a
submit/*tag (or a configured milestone tag) pointing at it:git tag submit/regrade-me SHA && git push origin submit/regrade-me. That mints a normal graded submission at that commit. -
Regrade (per-row, or Regrade all in the Actions menu) re-runs each
repository's latest submission at its original commit — useful after
fixing a broken test. A never-graded repository is first-graded at its current
HEAD instead (a new submission). On a re-run,
datetime(the submission instant) stays fixed so late-marking never changes;graded_atrecords the re-run.
-
owner— the repository owner (theUSERNAMEin the repository name); the identity scores are keyed by. -
submitted_by— the GitHub account that actually pushed that submission. For group work this is how you see who did the pushing even though the score is shared. - Group scores are credited to every teammate on the classroom team, recorded
as the entry's
member_usernames— see Group attribution model.
A group assignment is graded once, in the founder's repository. collect-scores
credits the shared score to every collaborator on the classroom team (the
owner is always included), recorded as the entry's member_usernames.
-
Crediting is by team membership, not permission level. A teammate is
credited whether they hold
pushoradmin. Teachers and TAs are excluded automatically because they aren't on the student team. - Classmates on the team are mutually trusted. Collection can't tell how a collaborator was added, so a student could credit a teammate who's on the team. The team intersection bounds this to classmates — an account off the team is never credited. Review each group repository's collaborators if you need stricter control.
-
Owner-only submissions warn. If a group submission resolves to only the
owner, collection emits a
::warning::so the "team submission scored as solo" case is visible. -
submitted_byrecords the pusher, so you can see who did the work even though the score is shared. - Rows are keyed by the repository owner, so re-collecting a group repository whose members changed updates the same row in place.
Two CSV exports cover most needs; the raw JSON is always there for anything custom.
Download scores (CSV) on the submissions page saves
CLASSROOM-ASSIGNMENT-scores.csv — one row per student (or group),
sorted by last name, with the latest submission's data:
| Column | Description |
|---|---|
name / first_name / last_name
|
From the roster (blank if the login isn't on it). |
usernames |
The credited GitHub username(s) — one for individual work, every credited member (alphabetical) for a group. |
score / max_score
|
The latest submission's score. Blank if submitted but not yet collected; 0 with blanks for a non-submitter. |
submissions |
How many attempts were collected. |
submitted_at |
The latest submission instant (ISO 8601 UTC). |
late |
yes / no against the due date; blank for non-submitters. |
commit / review / release
|
Links: the graded commit, the full starter→graded diff, and the Release. |
gh teacher download ORG CLASSROOM ASSIGNMENT clones every student
repository and writes a scores.csv at the destination root — one line per
submission (a student with several attempts contributes several lines,
newest first), plus one blank-score line per non-submitter:
| Column | Description |
|---|---|
username |
The team member. For a group, every credited member repeats the shared submission's lines under their own username. |
first_name / last_name / email / section
|
Joined from roster.csv when present. |
score / max_score
|
This attempt's score. Blank for non-submitters. |
datetime |
This attempt's submission instant (ISO 8601 UTC). |
submission_tag |
The submit/… tag identifying the attempt. |
submitted_by |
Who pushed this attempt. |
review_url |
The starter→graded diff for this attempt. |
late |
true / false against the due date; blank when unknown. |
override |
true when a teacher override is in effect for the entry. |
Per-test breakdowns aren't in either CSV — they're in each attempt's Release
(and in the per-repository result.json / results.json files the download also
refreshes).
-
CLASSROOM/scores.jsonin yourclassroom50repository is the authoritative record (see scores.json shape) — build any custom report from it. -
gh teacher downloadleavesresult.json(latest attempt) andresults.json(all attempts, newest first) in each cloned repository, including the per-test arrays.
The Feedback PR is on by default for assignments created with gh teacher assignment add (--feedback-pr=false to disable). When on, there is
one long-lived "Feedback" pull request per student repository so you review
cumulative work with inline comments alongside the scored Release.
-
Frozen base branch. Accept creates a
feedbackbranch at the student's baseline commit (the accept commit) and never advances it. The PR's base isfeedbackand its head is the default branch, so it always shows the full starter→latest diff. - Opened at accept. The PR is there before the first submission and exists even when GitHub Actions is disabled for student repositories. The diff still starts at the baseline, so the setup files never appear in it.
- One PR, reused across submissions, labeled Individual Assignment or Group Assignment. A student closing it reopens it; a teacher merge is left alone.
-
Default body. The PR opens with Classroom 50's built-in "here is where your teacher leaves
feedback" text by default. Set
feedback_pr_template: true(or check the box on the web form) to use the template repository's own pull request template as the body instead. Accept reads the first existing of.github/pull_request_template.md,pull_request_template.md, ordocs/pull_request_template.mdfrom the template and uses it verbatim. It requires a template and the Feedback PR itself. The read is best-effort: a missing, empty, oversized, or unreadable file falls back to the built-in body and never blocks the PR. Keeping the template's contents correct is up to you. - Maintained by the runner. The runner adopts the PR by base and head and maintains it from then on. If accept could not open it (a permissions oddity, or a repository accepted before this feature), the runner opens it on the first submission instead, and re-accepting also retries, which is the only route with GitHub Actions off. On that fallback open the runner honors the template too, best-effort: its GitHub Actions token cannot always read a private or external template, so it uses the built-in body and logs a warning when it has to.
Baseline resolution and prerequisites
Both accept and the runner resolve the baseline as the commit that introduced
.classroom50.yaml (a structural marker, not a commit subject) so they agree
on where the base is frozen. The runner refuses to open or update the PR when
the feedback branch sits at any other commit, since a student can create that
branch themselves; an organization administrator deleting it lets the next submission re-freeze
it correctly. If no marker commit is found, the runner opens the PR against the
root commit and warns that the baseline is untrusted; if no baseline resolves
at all, it skips with a warning.
Prerequisites (handled by gh teacher init): the organization setting "Allow GitHub
Actions to create and approve pull requests" must be on, and two organization rulesets
protect submission history and the frozen feedback branch. If you enable
feedback on an organization set up before this feature, re-run gh teacher init.
Student repositories accepted before this feature use an older workflow and must be re-created (delete + re-accept) to pick up the new one.
- Start here
- Teacher guides
- Autograding
- Students
- Reference