Skip to content

GitHub Emulator Specification — @deployment-dashboard/github-emulator

Status: Draft · Date: 2026-05-31

Standalone service that emulates the GitHub REST API surface the fetcher consumes, backed by an in-memory store. Enables end-to-end demo and integration-test scenarios without touching api.github.com.

Sources of truth

Source Owns
docs/FETCHER_SPECIFICATION.md §5 GitHub REST endpoint contracts, field mapping, status mapping, parent-derivation, version/artifact, backfill, rate-limit — authoritative spec for the surface this service emulates.
demo/data/github/ Curated demo fixture files (workflow YAML, deployment seeds, .deployment-dashboard/ preset files — issue #391).
docs/diagrams/github-emulation.md Visual reference for demo-mode topology and seed→backfill→poll sequence.

1. Stack

Aspect Value
Runtime Node.js / TypeScript
Framework NestJS 10 + Express
Default port 3100 (override via PORT env var)
State In-memory only — resets on restart
Location demo/github-emulator/
Auth None — internal-only service; no gateway route

2. Solution layout

demo/github-emulator/
  src/
    main.ts                       bootstrap (port, config)
    app.module.ts                 NestJS root module
    github-rest.controller.ts     emulated GitHub REST surface at root:
                                  GET /repos/{owner}/{repo}/...
                                  GET /rate_limit
    control.controller.ts         control surface: GET|POST /_github/*
    github-store.ts               in-memory store singleton (§3)
    github-store.service.ts       seed/clear/emit orchestration + store summary
    github-fixture-loader.ts      load curated demo set from demo/data/github/
    github-random-generator.ts    random repos/deployments/statuses/workflow-YAML factory
    rate-limit-headers.ts         X-RateLimit-* + Link header helper
  test/
    github-rest.controller.spec.ts
    control.controller.spec.ts
    github-store.spec.ts
    github-random-generator.spec.ts
    github-fixture-loader.spec.ts
  Dockerfile                      multi-stage: build → node:lts-alpine runtime
  package.json
  tsconfig.json

3. In-memory store

Store is process-local and independent of the dashboard API's data — it survives an API data-reset, enabling the fetcher to re-backfill from it post-reset.

Per-repo shape:

Key Contents
deployments List of deployment objects (id, sha, ref, environment, payload, creator.login, created_at)
statuses (per deployment) Status lifecycle (id, state, target_url, creator.login, created_at)
runs (keyed by run_id) Workflow run metadata (id, name, path, head_sha)
workflow YAML (keyed by path + ref) Raw YAML string
environments List of { name } objects
artifacts (per run_id) Artifact metadata + content (id, name, expired, version string)
repo files (keyed by full path, e.g. .deployment-dashboard/presets.json) Arbitrary UTF-8 repo file content — not ref-scoped. Backs the Contents API directory-listing + preset-file fetch (issue #391 — preset discovery, §5.1); parallel to, and independent of, workflow YAML.

4. Configuration

Env var Default Purpose
PORT 3100 HTTP listen port
GITHUB_SIM_RATE_LIMIT 5000 Simulated hourly request quota; reflected in X-RateLimit-* headers + GET /rate_limit response. Drives the fetcher's F16 budget logic.
SEED_ON_STARTUP true When true, seeds the demo set from demo/data/github/ on startup so the fetcher has data immediately (§9).
SCENARIOS_DIR ../../demo/data Base path for fixture resolution.
EMIT_INTERVAL_MS 8000 Interval between appended deployments while periodic emission is enabled (§6.3).

5. Emulated GitHub REST surface — /

Paths are served at the service root (no prefix). The fetcher points GITHUB_BASE_URL=http://github-emulator:3100; its root-relative paths resolve directly.

Method Path Response shape FETCHER_SPEC ref
GET /repos/{owner}/{repo}/deployments?environment=&per_page=&page= Array of deployment objects (id, sha, ref, environment, payload, creator.login, created_at); newest-first; filtered by environment when present; paginated (Link rel=next when more pages remain). §5.1, §5.8.2
GET /repos/{owner}/{repo}/deployments/{id}/statuses Array of status objects (id, state, target_url, creator.login, created_at); newest-first. §5.1
GET /repos/{owner}/{repo}/deployments/{id}/reviews Array of review objects (state, user.login, submitted_at); empty array when no reviews. Used by ResolveFailureStatusAsync to detect rejected. §5.3
GET /repos/{owner}/{repo}/actions/runs/{run_id} Run metadata (id, name, path, head_sha, conclusion). conclusion is null while in-progress; "cancelled" "success"
GET /repos/{owner}/{repo}/contents/{path}?ref={sha} Workflow-YAML single-file fetch (unchanged): { content: "<base64 YAML>", encoding: "base64" }. §5.6.2
GET /repos/{owner}/{repo}/contents/{dir} Preset-discovery directory listing (issue #391) when {dir} resolves to a directory (e.g. .deployment-dashboard): a JSON array of GhContentEntry ({name, path, type:"file"}). ETag-conditional — 304 on a matching If-None-Match. 404 when the repo has no files under {dir}. Preset discovery
GET /repos/{owner}/{repo}/contents/{dir}/{file}.json Preset-file fetch (issue #391): same {content, encoding:"base64"} shape as the workflow-YAML row above — one response shape serves both. Preset discovery
GET /repos/{owner}/{repo}/actions/workflows?per_page=100 { total_count, workflows: [{ id, name, path, state }] }. §5.8.2
GET /repos/{owner}/{repo}/environments { total_count, environments: [{ name }] }. §5.8.2
GET /repos/{owner}/{repo}/actions/runs/{run_id}/artifacts { total_count, artifacts: [{ id, name, expired }] }. §5.7.2
GET /repos/{owner}/{repo}/actions/artifacts/{artifact_id}/zip application/zip containing a single text file whose content is the version string. §5.7.2
GET /rate_limit { resources: { core: { limit, remaining, used, reset } } }. §5.9

Cross-cutting.

  • Every response carries X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Used, and X-RateLimit-Reset from a simulated per-process budget that decrements per request and rolls over hourly (budget = GITHUB_SIM_RATE_LIMIT). Drives the fetcher's F16 budget path.
  • List endpoints carry a Link: <...>; rel="next" header while more pages remain. Drives the fetcher's pagination loop.
  • Unknown repo / deployment / run / path → GitHub-shaped 404 (NOT RFC 9457 — this surface emulates GitHub, not the dashboard API).
  • Auth headers (Authorization: Bearer, Accept, X-GitHub-Api-Version) are accepted but NOT validated (internal demo service).
  • The Contents endpoint checks the repo-files store (exact path, then directory-prefix listing) BEFORE falling back to the workflow-YAML store — the two are independent maps, so adding .deployment-dashboard/*.json files never disturbs the existing .github/workflows/*.yml single-file behavior (§5.6.2 / #389 identity + workflow-graph).

6. Control surface — /_github/

Intended for the demo driver proxy, test harnesses, and manual operation. No auth required.

6.1 Status

Method · Path Response
GET /_github/status GithubStoreStatus

GET /_github/status is also consumed by the demo driver's GET /demo/health aggregator (DEMO_DRIVER_SPEC §4.10) as the emulator liveness probe — a 2xx response marks the emulator as "up".

GithubStoreStatus:

{
  "dataset":      "demo",
  "repos":        2,
  "deployments":  18,
  "statuses":     54,
  "workflows":    4,
  "environments": 3,
  "emitting":     false,
  "seeded_at":    "2026-05-31T10:00:00Z"
}

6.2 Seed + clear

Method · Path Request Response Effect
POST /_github/seed { dataset: "demo"\|"random", count?: number, reset?: boolean } GithubStoreStatus reset:true clears the store first. "demo" loads the curated fixture (§7); "random" generates synthetic data (§8). count = number of promotion chains for random (ignored for demo); repos always stay at 10.
POST /_github/clear — GithubStoreStatus Empties the store.

6.3 Emission control

Method · Path Request Response Effect
GET /_github/emit — { emitting: boolean } Read-only status.
POST /_github/emit { enabled?: boolean } — omit to toggle { emitting: boolean } When enabled, every EMIT_INTERVAL_MS appends a new deployment + in-progress→success/failure status lifecycle (created_at=now) so the fetcher's next poll picks it up.

7. Curated demo set (demo/data/github/)

The fixture files loaded when dataset: "demo" is requested. They MUST cover:

  • ≥2 repos whose workflow name: fields map to demo service identities coherent with demo/data/events.json (same service and environment names).
  • At least one workflow with a dev→staging→prod deployment-job needs chain, so parent_deployments resolves a real chain per FETCHER_SPEC §5.6 (F10).
  • At least one service whose version comes from a version.txt artifact to exercise artifact: resolution (FETCHER_SPEC §5.7, F15).
  • A spread of success, failure, and in-progress status lifecycles across deployments.
  • All 5 new statuses from issue #268 (one fixture deployment each):
Contract status Repo Deployment Mechanism
pending payments-api id 4840005, run 4840, env prod single pending status; run 4830 success is the effective slot
queued search-indexer id 1420005, run 1420, env prod single queued status
waiting billing-webhook id 826001, run 826, env prod single waiting status
cancelled ledger-projector id 1831001, run 1831, env prod failure status + run_conclusion: "cancelled" → ResolveFailureStatusAsync reads run.conclusion
rejected catalog-edge id 5161001, run 5161, env prod failure status + reviews: [{state:"rejected"}] → ResolveFailureStatusAsync reads /deployments/{id}/reviews

Provided presets (issue #391 — preset discovery, Path A demo). Two repos additionally seed a files fixture array, loaded into the repo-files store (§3) and served under .deployment-dashboard/ by the Contents API directory listing (§5):

Repo File Shape Purpose
demo-org/storefront .deployment-dashboard/presets.json BUNDLE {version:1, presets:[...]} — 3 entries Demonstrates a multi-preset bundle from one file.
demo-org/platform .deployment-dashboard/ops-focus.json SINGLE envelope {version:1, name, settings} Demonstrates the single-envelope form alongside the bundle form.

demo-org/operations and demo-org/data-pipeline have no .deployment-dashboard/ fixture — the emulator 404s that directory for them, so the fetcher's preset-discovery skips those sources (never prunes). All settings keys are valid PresetSettings fields (frontend/dashboard/src/app/core/services/presets.service.ts) so applying a provided preset visibly changes the SPA (view, service/environment filters, failOnly, theme, notifications, etc.). Attribution in the SPA reads demo-org/storefront / demo-org/platform as the preset source.


8. Random set + periodic emit

Repos. Always 10 synthetic repos (unaffected by count).

Promotion chains. count = number of promotion chains generated (default 20). Each chain walks a 5-stage ladder — dev → staging → qa → preprod → prod — with per-stage attrition: every chain reaches dev; each subsequent stage is reached with decreasing probability (≈ 85 / 75 / 70 / 55 %), so per-stage deployment counts decrease realistically. One chain produces 1–5 deployments depending on attrition.

Timestamps. Each chain starts at a random point in the trailing 14-day window (≥ 1 h before now). Subsequent stages in the chain advance the timestamp forward. This ensures multi-day analytics windows are populated and the day-truncated window (which excludes today) contains data.

DORA signals. ≈ 15 % of terminal deployments are set to failure, producing non-zero change-failure-rate and MTTR incidents. Actors, durations, and times of day are varied across chains.

parent_deployments chains. The generated workflow YAML includes a needs chain spanning all five stages, so parent_deployments resolves across the full ladder (F10 exercised on random data).

Periodic emit. Appends new deployments (created_at = now) over time; the fetcher's incremental poll picks them up on the next interval.


9. Startup defaults

State Default
Store Seeded from demo/data/github/ demo set when SEED_ON_STARTUP=true (default)
Emission Disabled
Rate-limit budget GITHUB_SIM_RATE_LIMIT (default 5 000), rolling hourly

When SEED_ON_STARTUP=true the emulator is immediately ready for the fetcher without a manual POST /_github/seed call — the fetcher starts backfilling as soon as it connects.


10. Testing

Layer File Scope
Unit github-store.spec.ts Store CRUD (seed / clear / emit); per-request rate-limit decrement + hourly rollover; store independent of API data
Unit github-rest.controller.spec.ts Each emulated endpoint returns the correct shape; X-RateLimit-* headers on every response; Link: rel="next" when more pages; unknown repo/deployment/run/path returns GitHub-shaped 404; GET .../reviews returns review objects (empty array when none); GET .../runs/:id emits conclusion field; GET .../contents/.deployment-dashboard returns GhContentEntry[] ({name,path,type}), sets a stable ETag with 304 on matching If-None-Match, 404s when a repo has no such directory, and a listed file resolves via the existing {content,encoding} shape without disturbing workflow-YAML fetches (issue #391)
Unit control.controller.spec.ts seed / clear / emit / status endpoints correct; seed + clear mutate the store; emit toggle works
Unit github-random-generator.spec.ts Generated workflow YAML includes a 5-stage needs chain (dev→staging→qa→preprod→prod); per-stage attrition produces fewer deployments at later stages; timestamps fall within the trailing 14-day window; ≈15 % terminal failures present; all required fields present; count controls chain count, repos always 10
Unit github-fixture-loader.spec.ts Curated demo fixture loads without error; covers F10 (dev→staging→prod needs chain) and F15 (artifact-sourced version); loaded dataset matches GithubStoreStatus counters; all 5 new-status fixtures present (pending/queued/waiting/cancelled/rejected) with correct run conclusions and review records; provided-preset fixtures load into the repo-files store — storefront BUNDLE, platform SINGLE envelope, repos without a fixture get an empty files map (issue #391)
Integration fetcher-emulation.e2e.spec.ts Start emulator + seed demo set (POST /_github/seed {dataset:"demo"}); run the real fetcher-host against http://github-emulator:3100; real Dashboard.Api + Postgres; assert the dashboard shows the expected services, a non-trivial parent_deployments chain, and an artifact-sourced version. Realizes FETCHER_SPEC §7.2.

11. Running

cd demo/github-emulator
npm install          # first time only
npm run start:dev    # ts-node, hot-reload

Override port or rate-limit quota:

$env:PORT                 = '3100'
$env:GITHUB_SIM_RATE_LIMIT = '1000'
npm run start:dev

12. Deployment

Aspect Spec
Image Multi-stage Dockerfile in demo/github-emulator/. Stage 1: node:lts-alpine builds TypeScript. Stage 2: node:lts-alpine runs the compiled output.
Compose service github-emulator in the demo compose profile. Internal-only — no gateway route.
Network Fetcher reaches the emulator directly on the internal compose network at http://github-emulator:3100. The demo driver proxy reaches it at http://github-emulator:3100 (or GITHUB_EMULATOR_URL).
Port Container listens on PORT (default 3100). Not exposed to the host by default.

13. Out of scope

  • ETag/304 conditional-request emulation beyond the deployments listing, statuses listing, and preset-discovery directory listing (§5) — the fetcher tolerates its absence elsewhere (FETCHER_SPEC §5.5). Single-file content fetches (workflow YAML, preset files) always return a plain 200.
  • Dynamic editing of individual seeded fixtures beyond /_github/seed, /_github/clear, and /_github/emit.
  • Emulating GitHub APIs the fetcher does not consume (e.g. issues, pull requests, checks).
  • Authentication or access control (internal-only service).