Dock dock.build · sync from github

C6 · publish & loop

Sync from GitHub

Point Dock at a repository. It pulls the Markdown and HTML in, mirrors each file as an artifact, and groups them under one collection. The repo stays the source of truth; Dock becomes the readable, shareable, commentable view of those docs for the people who never open the repo. Edits stay in git; the conversation lives in Dock.

The one idea

GitHub renders Markdown, but it has no story for sharing a rendered doc with a non-dev, commenting on a specific paragraph, or collecting feedback. That gap is the whole feature.

GitHub owns the content; Dock owns the conversation. Sync is strictly one-way (GitHub → Dock). Synced artifacts are read-only inside Dock: no Edit, no Propose. But comments, threads, sharing, and presence stay fully on. That single rule sidesteps every two-way conflict and makes the product story obvious: write in git, discuss in Dock.

Complement to the publish Action (#111), not a duplicate. The dock-publish GitHub Action is outbound: a CI step that pushes one built artifact up to Dock. This is inbound: Dock reaches into a repo and mirrors many files on demand. Outbound suits a generated report per pipeline run; inbound suits a living /docs tree.

The loop

Every box but two already exists. The new code is the GitHub client and the sync engine; the rest is the publish pipeline, collections, the tombstone, and the sandbox we already shipped.

01
Connect
In Settings, paste owner/repo, a branch, an include glob, and a read-only token. Dock makes a collection named for the repo.
human
02
List tree
Sync calls the Git Trees API, filters to the glob (default **/*.md, **/*.html), and reads each file's blob SHA.
github api
03
Diff
Compare each path's SHA to the last sync. Unchanged files are skipped; new/changed ones are fetched; vanished ones are marked.
sync engine
04
Mirror
New file → publish a new artifact. Changed file → add a version (the publish pipeline, verbatim). Each lands in the collection.
publish · collections
05
Tombstone
A file deleted in the repo flips its artifact to removed (the existing 410 path). History is kept; the doc stops rendering.
removed_at · 410

Why it's cheap: steps 04–05 are publish / addVersion, addCollectionItem, and setArtifactRemoved — all shipped and tested. v1 adds only the front of the line: talk to GitHub, diff by SHA, drive the loop.

Data model

One new table. v1 deliberately avoids a second mapping table by storing the path→artifact map as JSON on the source row — fine at the scale of a repo's docs, and it keeps the multi-store schema (SQLite · Postgres · D1) with its compile-time parity guard to a single addition.

CREATE TABLE repo_source (
  id            TEXT PRIMARY KEY,
  org_id        TEXT NOT NULL DEFAULT 'local',
  collection_id TEXT NOT NULL REFERENCES collection(id),
  repo          TEXT NOT NULL,           -- "owner/name"
  ref           TEXT NOT NULL DEFAULT 'HEAD',   -- branch
  includes      TEXT NOT NULL,           -- comma globs: "**/*.md,**/*.html"
  token         TEXT,                    -- read-only PAT (null for public repos)
  files         TEXT NOT NULL DEFAULT '{}',  -- JSON: { "docs/intro.md": {artifact_id, sha} }
  last_synced_at TEXT,
  last_status   TEXT,                    -- "ok" | "error: ..."
  created_by    TEXT NOT NULL,
  created_at    TEXT NOT NULL
)

Read-only is derived, not a new column. An artifact is "managed" when its id appears in some repo_source.files. The artifact detail response carries a managed flag (one small scan of the few sources), the UI hides Edit/Propose, and the publish/propose handlers reject a managed artifact with 409. No widening of the hot artifact table, no parity churn on the core record.

What v1 reuses vs. builds

Reuses shipped

  • publish / addVersion — file bytes in, artifact + version out, content-addressed by blob SHA.
  • collections — repo → one collection; addCollectionItem per file.
  • tombstoneremoved_at + the 410 render path for deleted files.
  • sandbox — repo HTML renders in the opaque-origin iframe, so untrusted markup is not an XSS hole.
  • settings section + sonner toasts + the API error contract (fail()).

Builds new

  • GitHub client — Git Trees + raw blob fetch, optional PAT bearer, defensive on 404 / 403 / rate-limit.
  • Sync engine — list → glob-filter → SHA-diff → upsert/version/tombstone → record the file map.
  • repo_source table + store methods (create / list / get / update map / delete), across all three backends.
  • 4 endpoints — connect, list, run ("Sync now"), disconnect.
  • Read-only gate in the publish/propose handlers + the managed flag.
  • Settings UI — connect form, source list, last-synced + count, Sync now, disconnect.

Security & the token

The one genuinely sensitive part: a personal access token sits in the database.

ScopeAsk only for read-only repo contents (a fine-grained PAT, Contents: Read). Dock never writes to the repo in v1.
At restv1 stores the token on the source row. The token is never returned by any read endpoint (redacted to ••• in list responses). Production should encrypt it or move it to the platform secret store — flagged, not yet done.
EgressThe client only ever talks to api.github.com / raw.githubusercontent.com over HTTPS — no user-controlled host, so the webhook-style SSRF surface does not apply here.
Blast radiusOne-way + read-only means a leaked sync can, at worst, read what the token could already read. It can never push to the repo or publish on a user's behalf.
v1 caveat: plaintext token at rest is acceptable for self-host / OSS and the dev instance, but is the first thing to harden before this is offered as a hosted multi-tenant feature.

Deferred — and why it's safe to defer

LaterWhy not in v1
GitHub App + push webhookReal-time sync on every commit is the right end state, but a PAT + "Sync now" button proves the loop in a fraction of the code. The engine is identical; only the trigger changes.
Two-way write-backEditing in Dock and opening a PR is a separate product with its own auth and conflict model. One-way keeps the source of truth unambiguous.
Per-commit historyv1 versions per sync run, not per commit. Mapping the full git history onto artifact versions is a nice depth feature, not a wedge.
Monorepo → many collectionsv1 is one repo → one collection. Splitting by top-level directory is a config nicety on top.
Private-repo billingA pricing/packaging question, orthogonal to the mechanism.

Surface

The whole feature lands behind one settings section and four endpoints.

POST   /v1/sync/github            connect: { repo, ref?, includes?, token? } → creates source + collection
GET    /v1/sync/github            list sources (token redacted) + status + file counts
POST   /v1/sync/github/:id/run    "Sync now" — runs the engine, returns { added, updated, removed, skipped }
DELETE /v1/sync/github/:id        disconnect (keeps the collection + artifacts by default)

Settings → GitHub. A connect form (repo, branch, globs, token), then a row per source: repo name, last-synced time, file count, a Sync now button, and disconnect. It mirrors the existing Webhooks / Agents sections, uses the design tokens and data-testids the CI guardrails require, and reports through sonner.