Automating Code Plagiarism Checks in GitHub Actions

I've spent the last three semesters wiring code plagiarism checker runs into our GitHub Classroom stack, and I want to show you the exact steps so you can copy the workflow in about thirty minutes. We run a Java-based CS200 course with 240 students, six TAs, and a per-assignment grading budget of roughly four hours across the team. Before the automation, a TA would manually pull every repo, zip it, upload to a similarity tool, wait, then dump the report. That was maybe ninety minutes per assignment. Now it's a scheduled GitHub Action that produces a CSV before we even open the grading queue.

The core idea is simple: on every student push, a workflow script calls Codequiry's API, sends the changed files plus the assignment context, and writes a per-submission similarity and AI score into a CSV artifact. You don't need a server. You don't need to leave GitHub. You do need an API key and a small Python script, and I'll give you both below.

What the workflow actually does

When a student pushes to a repository in your GitHub organization, the action runs a Python script that gathers the relevant source files (we use src/main/java for the assignment), packages them into a single submission, and sends them to Codequiry's /v2/check endpoint with a few parameters. The key parameters are peer_score=True to compare against the rest of the class, web_score=True to check public GitHub, Stack Overflow, and other web sources, and ai_score=True to run the AI code detector on each file. You also pass a course_id and assignment_id so the results land in the right bucket.

Codequiry API keys page with a masked key, signed webhook configuration and API resources
The API surface: an account key, signed webhooks for finished checks, and docs for wiring scans into CI.

One flag I forgot for a full assignment cycle: include_web=True. Without it the API only returns peer matches, which meant we missed three students who had copied from a public tutorial repo. The web-source flag is easy to overlook if you copy the minimal example from the docs, so add it from the start.

The GitHub Actions YAML file

Here is the workflow file I drop into a new private repo called plagiarism-runner that we trigger on a schedule or manually per assignment. I keep it separate from student repos so students never see the scoring logic. The workflow is intentionally boring: checkout the runner repo, set up Python 3.11, install the requests library, run the script, and upload the CSV artifact.

name: Run Codequiry Plagiarism Check
on:
  workflow_dispatch:
    inputs:
      assignment_id:
        description: 'Assignment ID in Codequiry'
        required: true
      course_id:
        description: 'Course ID in Codequiry'
        required: true
jobs:
  scan:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: '3.11'
      - run: pip install requests pandas
      - name: Run similarity and AI scan
        env:
          CODEQUIRY_API_KEY: ${{ secrets.CODEQUIRY_API_KEY }}
          ASSIGNMENT_ID: ${{ github.event.inputs.assignment_id }}
          COURSE_ID: ${{ github.event.inputs.course_id }}
        run: python scanner.py
      - uses: actions/upload-artifact@v4
        with:
          name: codequiry-results
          path: results.csv

The manual trigger with workflow_dispatch means you don't need a cron job firing every hour when nobody is submitting. We run it once after the deadline, or twice if we want an early look at the weekend. The environment variables keep the API key out of the YAML, which is the only part I insist on from a security perspective.

The Python scanner that calls Codequiry

This is the script, trimmed to the parts that matter. It iterates over student repos via the GitHub API using a personal access token, collects Java files, sends them to Codequiry, and waits for the scan to finish before writing results. I've stripped error handling and retries to keep it readable, but in production I add a 60-second timeout and a retry on 429 rate limits.

import os, time, csv, requests
from pathlib import Path

GITHUB_TOKEN = os.environ["GITHUB_TOKEN"]
CODEQUIRY_KEY = os.environ["CODEQUIRY_API_KEY"]
ASSIGNMENT_ID = os.environ["ASSIGNMENT_ID"]
COURSE_ID = os.environ["COURSE_ID"]

headers = {"Authorization": f"Bearer {CODEQUIRY_KEY}"}
base = "https://api.codequiry.com/v2"

# Get list of student repos from the GitHub org (simplified)
org = "cs200-fall2025"
repos = requests.get(f"https://api.github.com/orgs/{org}/repos?per_page=100",
                     headers={"Authorization": f"token {GITHUB_TOKEN}"}).json()

results = []
for repo in repos:
    if not repo["name"].startswith("assignment4-"):
        continue
    # Collect Java files from the default branch
    files = []
    # ... clone or fetch each repo, walk src/main/java ...
    # For brevity, assume we have a local path variable
    submission_payload = {
        "course_id": COURSE_ID,
        "assignment_id": ASSIGNMENT_ID,
        "files": files,
        "peer_score": True,
        "web_score": True,
        "include_web": True,
        "ai_score": True,
        "language": "java"
    }
    resp = requests.post(f"{base}/check", json=submission_payload, headers=headers, timeout=60)
    check_id = resp.json()["check_id"]
    # Poll until done
    status = "running"
    while status == "running":
        time.sleep(5)
        status_resp = requests.get(f"{base}/check/{check_id}", headers=headers)
        status = status_resp.json()["status"]
    report = requests.get(f"{base}/check/{check_id}/report", headers=headers).json()
    results.append({
        "repo": repo["name"],
        "peer_score": report.get("peer_score", 0.0),
        "web_score": report.get("web_score", 0.0),
        "ai_score": report.get("ai_score", 0.0),
        "top_peer_match_repo": report.get("top_peer_match_repo", ""),
        "top_web_source": report.get("top_web_source", ""),
    })

with open("results.csv", "w", newline="") as f:
    writer = csv.DictWriter(f, fieldnames=results[0].keys())
    writer.writeheader()
    writer.writerows(results)

The polling loop is the only mildly annoying part. Codequiry's scan on 200 Java files takes about 40 seconds for peer plus web, and an extra 15 seconds when AI detection is enabled. We poll every five seconds, which means the whole class finishes in under a minute per repo even with network overhead. The CSV output is deliberately flat: one row per student, four columns of scores, plus the top match source. That's enough for the triage spreadsheet.

Codequiry match review workspace with a Java code viewer, match explorer and per-submission analytics
The match review workspace: matched code, every peer and web source, and per-submission analytics on one screen.

Why AST matching catches what text matching misses

Here is the thing I explain to new TAs every semester: code plagiarism is not about identical characters; it's about identical structure. A student who renames every variable, reorders methods, and changes whitespace will fool a text-based diff but not a token plus AST comparison. Codequiry's engine converts each Java file into an abstract syntax tree, discards identifier names and formatting, and fingerprints the control flow. Two submissions that differ only in superficial naming produce a very high structural similarity score.

Here is a concrete pair from our fall 2025 assignment 4. The original on the left, the "refactored" version on the right:

// Original submission
public int computeTotal(List<Item> items) {
    int sum = 0;
    for (Item item : items) {
        sum += item.getPrice() * item.getQuantity();
    }
    return sum;
}

// "Refactored" submission
public int calculateTotal(ArrayList<Element> elems) {
    int accumulator = 0;
    for (Element e : elems) {
        accumulator = accumulator + e.price * e.qty;
    }
    return accumulator;
}

Text similarity between those is almost nothing. Token plus AST similarity is 91% because the loop structure, the accumulation pattern, and the multiply-add inside the loop are identical. When we saw a pair like this, the dashboard flagged it immediately. The old MOSS output would have shown something like 22%, and a busy TA would have missed it.

Side-by-side code comparison in Codequiry showing a 91% match between two student submissions
Side-by-side comparison: Codequiry lines up matching code between two submissions, with confirmed and false-positive review labels.

The misconception worth killing here: refactoring to hide plagiarism is a known strategy, and most tools that only do line-level comparison let it through. If your current checker just runs diff or a string-similarity algorithm, you are catching only the laziest copiers. The students who put effort into hiding their copying are exactly the ones who should be detected, because they've demonstrated enough understanding to know what to change.

Adding AI detection to the same workflow

Starting in spring 2025 we added ai_score=True to the same API call. That runs Codequiry's AI-written code detector on each file, producing a probability that the code was generated by ChatGPT, Copilot, or Claude. The output is a per-file score and an aggregate for the submission. We use a threshold of 0.75 for a flag, not a verdict. Scores between 0.5 and 0.75 get a manual look if the peer similarity is also high, because that usually means a student generated code, then tried to clean it up, then copied a classmate's generated code.

Codequiry AI code detection report with average AI score, highest file score and a risk distribution
AI code detection: probability scores per file, flagging submissions likely written by ChatGPT, Copilot, Claude or Gemini.

One honest caveat: we have not tested this past a few hundred submissions per assignment, and the AI detector does produce occasional false positives on very formulaic Java boilerplate. But because we stack AI score with peer and web similarity, a high AI score alone is rarely our sole evidence. The stronger signal is AI score plus zero web matches plus a near-identical peer match, which usually means one student generated the same solution and shared it.

Triage in the grading spreadsheet

After the action finishes, the CSV artifact is downloaded, and I do a three-minute spreadsheet setup. Sort by peer_score descending, add conditional formatting on peer_score and ai_score above 0.7, and color the row red if either is high. That usually surfaces five to eight submissions out of 240 that need a closer look. Each close look takes me about forty seconds: open the repo, compare the flagged files side by side using the link in the report, and decide. Most are confirmed within two minutes. The rest of the submissions I never open individually; the numbers are low enough to trust.

I used to think this kind of automation was overkill for a single course. But with six TAs spread across different sections, the consistency matters. One TA might spot a suspicious pair, another might not. The CSV gives us a shared, sorted list, and everyone starts at the top.

Codequiry assignment insights with a class integrity score and the submissions to review first
Assignment insights: a class-wide integrity score and the submissions worth reviewing first.

If you already use MOSS or JPlag, the migration path is straightforward: keep the existing tool for historical comparison, add Codequiry for the peer plus web plus AI combination in one pass. The Codequiry vs MOSS page has a more detailed comparison, but the short version is that Codequiry gives you a real dashboard, a web-source check, and AI detection without any Perl scripts or server setup.

One configuration detail people forget

The single most common support question I get from other instructors is why their web matches are empty. Nine times out of ten it's the include_web flag missing from the payload. The API default is peer-only unless you explicitly opt in, which is a sensible default for privacy but catches people off guard. Add the flag, run a small test on one repo, and confirm you see a web score before running the whole class.

The second detail is git file exclusions. If your student repo includes boilerplate files or starter code, exclude them from the scan or you'll get inflated similarity from the starter package. I pass a list of file paths to ignore, usually build/ and src/test/. In the script above, that filtering happens before building the files list, which I skipped for brevity. The production version walks only src/main/java and skips anything under src/test.

After three semesters of running this workflow, the total time per assignment dropped from roughly ninety minutes to ten: five minutes to trigger the action and wait, three minutes to triage the flagged rows, two minutes to write an email to the academic integrity officer when needed. The system isn't perfect, and I still eyeball every high score, but it has caught cases we would have missed entirely, including one student who copied from a two-year-old GitHub repository that none of us had ever seen.

If you are teaching a programming course and still doing manual checks, start with the plagiarism checker for code product page to see the API docs and get a key. The setup is an afternoon, and the time savings compound every single assignment after that.