Importing & Repo Bindings
How organizers get challenges onto the platform — by hand for a one-off, or as a continuously-synced GitHub repository for a whole event. This page covers the import endpoints, the per-game submission gate, and the repo-binding poller that turns a tree of .gzevent manifests into live games.
If you are authoring the challenges themselves, start with Challenge YAML and the Templates — this page assumes you already have challenge.yml files in hand and want to get them imported at scale.
The five import paths
There are five ways a challenge (or a whole game) reaches the platform — four one-shot endpoints plus the continuous repo binding. They differ in who can call them, whether the result is auto-approved or lands in a review queue, and whether it's a one-shot or a continuous sync.
The repo-binding poller is the scalable option and gets its own section below. The one-shot endpoints are described first.
(a) Admin tarball import — auto-approved
A game admin uploads a single-challenge tarball; the resulting challenge(s) are imported and approved in one shot (they go straight to Active and are visible to participants once enabled).
The endpoint is gated by game-admin permission and passes AutoApprove: true into the import service. There is no per-game opt-in required for this path — being a game admin is the authorization.
(b) Public web submit — gated, lands Pending
Any logged-in user can submit a single-challenge tarball, but only if the game has user submissions enabled. This is the path the web upload templates point at (/games/<id>/submit).
Behaviour:
- The game's
AllowUserSubmissionsflag must be on (admin → game → Info). When it is off, the endpoint returns403 Forbiddenwith"User submissions are disabled for this game."and the submit page is disabled in the UI. - On success the challenge is imported with
AutoApprove: false, so it lands withChallengeReviewStatus.Pendingand is hidden from participants until an admin approves it via the review queue (GET /api/Edit/Games/{id}/PendingChallenges, thenPOST .../Challenges/{cId}/Approve).
A&D and KotH challenges carry no author flag. For these types the platform plants and rotates the flag itself (A&D) or scores a planted control token (KotH), so a submitted AttackDefense / KingOfTheHill archive does not need a flags: list to pass import. See Attack & Defense and King of the Hill for the flag/scoring flow.
(c) One-shot import from GitHub
A game admin can pull challenges directly from a GitHub location (repo URL + optional ref + optional subpath) without registering a persistent binding. Results are auto-approved.
The URL/ref/subpath are validated by the GitHub locator; an unparseable URL returns 400. The token is optional (only needed for private repos) and is used for that single pull — it is not persisted. For an ongoing sync, register a binding instead.
(d) Bulk game-package import
POST /api/Edit/Games/Import takes a full exported game ZIP (the counterpart to the game export endpoint) and rebuilds the entire game. Admin-only.
It accepts only .zip files with a zip content type and returns the new game ID on success.
Size limits (from the code)
These limits are enforced server-side. Exceeding them returns 400 before anything is unpacked.
The 64 MB tarball ceiling is per challenge archive. Large container challenges should ship their build context (src/, Dockerfile) — not a pre-built image — and put only player-facing files in dist/. If your archive is bigger than 64 MB, prefer a repo binding (which clones via git, not a single multipart upload).
Repo bindings
A repo binding is a platform-level registration of a GitHub repository. A background poller keeps it in sync: it clones the repo, walks the tree for every .gzevent manifest, turns each manifest into a Game, and imports every challenge.yml under that event root. This is the path for running a whole event — or many events — out of one repo.
A binding is distinct from a per-game repo watch: a watch updates one already-existing game, whereas a binding creates games automatically and lives at the platform level.
What you register
The PAT is stored encrypted via ASP.NET DataProtection and is [JsonIgnore]d, so it is never returned by the API.
How the poller works
The RepoBindingScanService background worker ticks every 30 seconds. On each tick it:
- Runs a stale-activity watchdog — clears the live
CurrentActivityfield on any binding whose scan hasn't completed in 15 minutes (recovers a UI that's stuck on "Syncing…" after a mid-fetch crash). - Selects bindings that are
Activeand due (NextScanUtcis null or in the past), oldest first, at most 8 per tick (Take(8)) to bound GitHub work. - Scans each due binding, then always advances
NextScanUtcby the clamped interval — even on failure — so a permanently-broken repo can't hot-loop the poller.
So a binding with the default IntervalSeconds = 60 is re-scanned roughly once a minute (subject to the 30 s tick granularity and the 8-per-tick fan-out cap).
Persistent shallow clone + SHA short-circuit
Each binding keeps a persistent shallow git checkout at /app/repos/binding/{id} (on the gzctf-repos Docker volume, so it survives container restarts):
- First scan =
git clone --depth 1(one full payload). - Every later scan =
git fetch --depth 1(a small delta).
After the fetch, the worker compares the resolved commit SHA against the binding's LastCommitSha. If they match and the scan was not forced, the import is skipped entirely — the status row records "No change since <sha7> — skipped import.". On an unchanged repo this is the cheap (~150 ms) common case, which is why a 60 s cadence is affordable.
"Scan now" (force)
The admin "Scan now" action calls the scan with force = true, which bypasses the SHA short-circuit and re-downloads + re-imports unconditionally even when git hasn't moved. Use it to recover from a partial scan or to re-run the import pipeline against an unchanged tree. Because discovery is idempotent (games are upserted by (BindingId, EventManifestPath)), forcing a re-scan is safe.
What a scan does to each manifest
For every .gzevent found anywhere in the tree:
- The manifest YAML is parsed into the event model (see below); a missing
titleor invalid YAML is recorded as a per-manifest failure and the scan moves on. - The Game is upserted — keyed on
(BindingId, manifestRelPath). A detached game with a matching title is adopted rather than duplicated (the delete-then-rebind recovery path). - Every
challenge.ymlunder that event root directory is imported via the standard challenge-import pipeline withAutoApprove: true.
Sparse update semantics: on a re-scan, only the manifest fields that are actually present override the Game; omitted fields keep their current value, so a re-scan never resets hand-tuned settings.
Live progress is written to CurrentActivity as the scan walks ("Syncing git checkout", "Discovering .gzevent manifests", "Processing event 2/5: final/.gzevent", …) and surfaced on the admin repo-bindings card. A summary like games +1 ~0, challenges +12 ~3, failures 0 lands in LastScanMessage when it finishes.
The .gzevent manifest
A .gzevent file at the root of an event directory tells the binding "this folder is one game." Every challenge.yml beneath it becomes a challenge in that game. All fields are optional except title; omitted fields keep platform defaults on create (and current values on update).
Dates use ISO-8601 and may carry a local offset (e.g. +08:00); the importer normalizes everything to UTC before persisting.
What the event-wide ad: block configures
The ad: block on the manifest carries only the game-wide A&D/KotH timing knobs — the ones that must be shared because a round spans the whole game (tick length, flag lifetime, warmup, reset cooldown, snapshot policy, and the getflag jitter/grace timing that are fractions of the tick).
It is separate from the per-challenge ad: block inside each challenge.yml, which carries that one service's own properties (checkerImage, allowEgress, allowSelfReset). Don't confuse the two. See Attack & Defense for the per-challenge block and the checker/flag flow.
The KotH refresh interval and hold-points-per-tick are not carried in the manifest and are not exposed in the admin UI — they are currently fixed at their defaults. Tuning them today means editing the Game row directly (KothRefreshTicks / KothHoldPointsPerTick). See King of the Hill.
ignore: true — stop a challenge syncing
Deleting a challenge in the admin UI removes it from the platform but not from the repo, so the next scan would re-import (resurrect) it. To keep it gone, add ignore: true to its challenge.yml:
ignore: true makes the importer skip the challenge (no create, no update), so a one-time UI delete sticks. It does not delete an already-imported copy — remove that in the UI (after which it simply stops receiving updates). Drop the key to resume syncing.
value: and any visible: / enabled: fields in challenge.yml are ignored on import — points and visibility are admin-controlled after review, not set by the repo.
PushOnEdit — write approved edits back to the repo
By default a binding is read-only: the platform imports from the repo and never writes to it. Enabling PushOnEdit reverses this for one specific flow — when an admin approves a user-submitted challenge, the platform serializes that challenge back into the binding repo as a fresh commit.
The written path mirrors the layout the scanner imports from:
where eventDir is the directory of the game's EventManifestPath, category is the challenge category, and slug is the normalized challenge title. After pushing, the challenge's SourceYamlPath is updated to that new path so subsequent edits push back to the same file. The push is best-effort and fire-and-forget — approval succeeds in the database even if the push later fails.
Gates (all must hold, or the approval is purely DB-side):
- the game has a
RepoBindingIdand a non-emptyEventManifestPath, - the binding has
PushOnEdit = true, - the binding has a stored token that decrypts successfully.
The PAT must have Contents:write scope for push-back. A read-only token is fine for import but will fail the push.
Also note: regenerating the YAML drops comments and reorders fields per the serializer. Don't enable PushOnEdit on a repo whose challenge.yml files carry hand-edited comments you want to keep.
Binding health: Status and TokenStatus
Two fields, both surfaced on the admin repo-bindings card, tell you whether a binding is healthy.
Status — Active (polled every interval) or Paused (skipped by the poller).
TokenStatus — updated on every scan so the UI can distinguish a bad token from an unrelated scan failure:
A private or missing repo, or a missing/expired token, does not crash the poller — it surfaces as a concise scan warning on the binding row (a friendly hint plus git's stderr), logged once per tick as a single warning rather than a stack-trace flood. There is no TokenStatus value for "present but rejected," because a rejected token can't be told apart from a correct one without a probe; the actionable hint rides in LastScanMessage instead.
Worked example: a ready-to-run A&D + KotH event
If you just want to try the Attack & Defense and King of the Hill engine without authoring anything, point a binding at the public testing repo github.com/TCP1P/TCP1PADTesting. It's a focused four-challenge event — two A&D services and two KotH hills — that exercises the whole A&D/KotH pipeline end to end (the round engine, per-team containers, the shared hill, the auto-built ./src services and ./checker SLA images, and scoring). The server clones the repo itself, so there is nothing to build or run locally first; just register the binding.
What's in it
A single .gzevent under events/tcp1p-testing/ defines the game. Each challenge ships its service in ./src, a functionality/health SLA checker in ./checker (both auto-built on import — no containerImage/checkerImage is pinned), a reference ./solver, and — for the two KotH hills — a player handout in ./dist:
The manifest
events/tcp1p-testing/.gzevent defines the one game and carries the event-wide ad: block (tick length, flag lifetime, warmup, reset cooldown) that every A&D/KotH challenge in this event shares — abridged:
A challenge — A&D and KotH side by side
Web/owasp-portal/challenge.yml — an Attack & Defense service whose image is auto-built from ./src/Dockerfile (no containerImage), with a functionality-only checker auto-built from ./checker:
Web/koth-throne/challenge.yml — a King of the Hill challenge (one shared hill). KotH is hold-scored, not flag-scored, so value is fixed; provide: "./dist" ships the hill source as the player handout:
challenge.yaml and challenge.yml are both accepted. Neither entry pins a containerImage or checkerImage, so the platform auto-builds each service from ./src/Dockerfile and each SLA checker from ./checker/Dockerfile — eight images for the four challenges. See Challenge Build Pipeline for the build flow and Challenge YAML for the per-challenge ad: block.
Bind it
The repo is public, so no token is needed:
- Admin → Repo Bindings → Add, with
RepoUrl = https://github.com/TCP1P/TCP1PADTesting, an empty ref (default branch), andIntervalSeconds = 60. Leave the token blank. - Hit Scan now (or wait one poll cycle). The
events/tcp1p-testing/.gzeventmanifest becomes the TCP1P A&D + KotH Testing game and the fourchallenge.ymlfiles import;LastScanMessageshowsgames +1,challenges +4,failures 0. The eight images (one./srcservice + one./checkerper challenge) build in the background — watch them under admin → Builds. - The game imports hidden — open admin → game → Info, set your own start/end time, and unhide it. The Info page also exposes the round settings; those game-level edits are yours to keep, and a later sync refreshes the challenges without reverting your Info-page changes (see the event-wide
ad:block).
This is the fastest way to see a populated A&D/KotH scoreboard, the live attack feed (/games/{id}/attack), and the per-game cheat analysis with real data on a fresh deploy. The auto-built checker images are local-only — if an aggressive docker image prune removes one, the platform rebuilds it from the persisted build context on the next tick.
For a private repo, the only difference is adding a PAT on the binding (and Contents:write if you want PushOnEdit). The layout, manifest, and challenge files are identical.
For the engine these challenges drive, see Attack & Defense and King of the Hill.