DataTalksClub / DataTalksClub/website

Add a drift check between served content and DataTalksClub/content

Open
#323 5 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

content documentation enhancement human integration operations P1 testing
Dominant language
Python
Stars
0
Forks
0
PR merge metrics
No merged PRs in 30d

Description

_docs/runbooks/data-ingest.md §12 item 11: nothing in this repository compares what we
serve against what DataTalksClub/content says, at any moment. Item 16 is the
consequence — the gap is invisible until somebody counts by hand.

Build the read-only check that makes it visible.

What grooming measured

Everything below was measured on 2026-09-07 against main, the staging artifact the
importer writes, and the real upstream repository. It changes three of the assumptions
the raw issue carried, so read it before designing anything.

1. The served catalogue was built from a revision that does not exist upstream.
scripts/build_public_projection.py:92 pins PREFERRED_CONTENT_REVISION = 1375c506dbce85c7c0e5e61f83c753128c5a48d1. GET repos/DataTalksClub/content/commits/1375c506… answers HTTP 422, "No commit found for
SHA"
. All 1,116 content-owned served records (55 articles, 203 podcasts, 201
transcripts, 98 books, 559 media) carry that revision in provenance.revision. The
commit exists only in a local, unpushed working copy. This is drift class (d) and it is
the check's first real finding, not a hypothetical.

2. Runbook item 15 is wrong about upstream, so there is no allowlist to fix.
DataTalksClub/content main is 8be8587c9201d52ae10a731bd5ab2616a479d6bb today, and
its layout is flat: articles/*.md, podcasts/*.yaml,
podcasts/transcripts/*.yaml, books/*.yaml, images/{posts,podcast,books}/**. That is
exactly what content_sync/dtc_content/contract.py:121-124 declares and exactly what
_docs/content-authoring.md:26-32 requires. The season-hierarchical layout item 15
describes belongs to the same unpushed local branch as (1) — three local commits
(5229f80, 969e81c, 1375c50) that diverged from upstream after
ACCEPTED_CONTENT_COMMIT. path_allowlist matches nothing only against a tree upstream
does not have.

3. Counts alone would report clean today while real drift exists.
ACCEPTED_CONTENT_COMMIT (e29f56ce…) is an ancestor of upstream main and is 6
commits behind
it. Upstream main holds 55 articles, 205 podcast files (203
non-draft), 203 transcripts (201 non-draft), 98 books, 815 images — identical to
ACCEPTED_SOURCE_COUNTS and ACCEPTED_COUNTS. What changed in those six commits is
bytes: 10 articles modified, 1 podcast modified, 1 podcast image renamed
(s24e07-…jpgs24e06-…jpg), plus a new
editorial-overlays/2026-09-01-source-corrections.yaml. A counts-only check is green on
all of it.

4. Identity must be the stable key, never the source path. Between the revision the
served catalogue was built from and upstream main, over fifty article and podcast
files differ only by path (R100 renames). Keyed on source_path the check reports
~100% drift and is useless; keyed on provenance.source_key it reports exactly the real
changes.

5. The machinery already exists and needs no parser. Every served record carries
provenance = {repository, revision, source_key, source_path, checksum, source_url}, and
provenance.checksum is verified to be the raw SHA-256 of the upstream file bytes
(spot-checked against git show <rev>:<path> | sha256sum for an article and a podcast).
So the served side already declares which repository owns it, which record it is, and
what its source bytes hashed to. The check is a set-diff plus a digest compare. It does
not need the adapter, the bundle, or parity.py.

Normative references

  • _docs/specs/03-github-content-and-people.md — "Source ownership" (which repository
    owns which collection), "Core read models", "Sync safety".
  • _docs/content-authoring.md — the normative DataTalksClub/content folder and schema
    rules the upstream side is enumerated against.
  • _docs/architecture/database-only-content.md — the served side is the database.
  • _docs/runbooks/data-ingest.md §10 (what a drift check must assert, §10.2 table
    (a)–(d)), §10.3 (the order to reuse the pieces in), §12 items 11, 15, 16.
  • _docs/runbooks/ingest-script-inventory.md — "Every source is ingested by a plain
    script in scripts/prod/ — never a Django management command".
  • _docs/PROCESS.md and _docs/ci/change-selective-ci.md.

Scope

One new plain script, scripts/prod/sync_content_verify.py, that reports drift between
the editorial content the database serves and a local checkout of
DataTalksClub/content at a named revision, and one Make target per step.

It is read-only in both directions: it performs no INSERT/UPDATE/DELETE, no service
call, no release transition and no last_reconciled_at/pending_follow_up write, and it
makes no network call of its own. Every git command it runs is a local object-database
read.

Settled: which collections are in scope

The five families DataTalksClub/content owns per specification 03 "Source ownership":

Family Served identity Upstream identity Drift signal
articles provenance.source_key file stem after DATE_PREFIX (scripts/build_public_projection.py:1381-1385) identity + digest
podcasts provenance.source_key the slug key in the episode YAML identity + digest
podcast_transcripts transcript_provenance.source_key on the podcast record the podcast key in the transcript YAML identity + digest
books provenance.source_key the slug key in the book YAML identity + digest
media provenance.source_path repository-relative path under images/{posts,podcast,books}/ digest + orphans (see below)

Selection is data-driven, not a hardcoded list: a served record is in scope when its
provenance.repository (or transcript_provenance.repository) is
DataTalksClub/content.

Explicitly out of scope, with the reason: people (438 records, provenance
DataTalksClub/datatalksclub.github.io), wiki (282, DataTalksClub/podwiki),
courses (12, DataTalksClub/course-management-platform), events, FAQ, docs. Each has a
different owning repository under specification 03; a check that reported them against
DataTalksClub/content would report every one of them as missing. The raw issue listed
"wiki … people" as candidates; they are ruled out here, not forgotten.

Settled: what "drift" means

All three of counts, identities and digests, produced by one algorithm per family and
reported per family. Counts are a consequence of the identity sets, not a separate check.

  • missing — an upstream record whose identity has no served record (§10.2 (a)).
  • extra — a served record whose identity is absent upstream (§10.2 (b)).
  • mismatched — same identity, provenance.checksum ≠ SHA-256 of the upstream blob
    bytes at the named revision (§10.2 (c)).
  • matched / total — the clean remainder and the served count considered.
  • A rename with unchanged bytes and unchanged identity is not drift. A rename that
    changes the identity (the measured s24e07s24e06 image) is reported as one
    missing and one extra, which is correct and must not be collapsed into a "moved"
    bucket in this issue.

Media asymmetry, ruled here rather than left to the engineer. The served media set is
by construction the referenced subset: measured, 559 of the 815 upstream images have a
served record, 256 do not (206 under images/podcast/, 50 under images/posts/), and
0 served content-media records lack an upstream file. The upstream→served direction
for media is therefore reported in its own bucket upstream_unreferenced — counted,
listed, and not part of the exit code — while extra and mismatched for media are
drift and do set it. The check reports this asymmetry; it does not resolve it, and it
must not silently drop it.

Settled: revision and ancestry, drift class (d)

The check resolves the revision it compares against locally: --revision REV (default
refs/remotes/origin/<branch> for the registered source's branch) via git rev-parse.
It then reports, using the offline primitives in
content_sync/course_repository_checkout.py (commit_is_public reads remote-tracking
refs and makes no network call):

  • the distinct provenance.revision values the served records carry;
  • the resolved comparison revision and the resolved remote-tracking head;
  • whether each served revision is reachable from the remote-tracking branch, and how
    many commits behind it is when it is;
  • unreachable when it is not — the state measured today.

Because that answer is only as fresh as the checkout's last fetch, the report states the
resolved remote-tracking sha so an operator can tell. Resolving a ref over the network is
not this script's job.

Settled: the contract allowlist is not touched

content_sync/dtc_content/contract.py's path_allowlist is correct against upstream
main and against _docs/content-authoring.md (finding 2). Nothing in it is changed by
this issue. The check does not depend on DTC_CONTENT_CONTRACT for which records
exist
; it may reuse the contract's ceilings (max_file_bytes, max_files,
max_source_bytes) and the adapter's bounded YAML loader for the two-key identity reads,
and it must not adopt verify_dtc_content_checkout's checkout_dirty /
checkout_commit_mismatch refusals — it reads a named revision out of the object
database, so a dirty worktree or a different HEAD is irrelevant to its answer. It does
keep the origin fence: a checkout whose origin is not DataTalksClub/content is
refused.

Settled: where it runs
  • make content-drift — offline, read-only, the operator entry point.
  • make content-checkout — the only networked step, cloning or refreshing
    DataTalksClub/content with the same clone/fetch/reset/clean sequence
    content-checkouts already uses, into $(CONTENT_CHECKOUT) (default
    .tmp/content-checkout), printing the resolved HEAD. It exists because make content-checkouts does not cover this repository: it selects only sources with
    adapter_type=course_repository_v1 (scripts/prod/sync_course_repositories.py:73-81),
    and the editorial source is not one. The raw issue assumed otherwise.
  • --checkout-plan on the new script prints the registered editorial source, its
    repository, its branch and the checkout it would be read from, with no writes — the
    same no-write plan shape sync_course_repositories.py --checkout-plan gives the course
    path, and what make content-checkout consumes. Which repository this is stays a
    database question: the enabled ContentSource whose repository is
    DataTalksClub/content (dtc-public-content on any database built by
    scripts/prod/import_public_content.py), never a list written into the Makefile.
  • CI wiring is deferred, deliberately. Against today's data the check exits non-zero
    by design (finding 1), and _docs/PROCESS.md does not permit a red required job to be
    carried as acceptable. It becomes a CI or scheduled job in a follow-up issue once the
    drift it reports is zero, or once an owner rules that a reported-but-nonblocking
    scheduled report is wanted.
Settled: exit codes and report shape

One JSON object on stdout, json.dumps(report, indent=2, sort_keys=True) — the shape
scripts/prod/sync_public_media_verify.py:46 uses, whose VerifyReport
(content/media_tooling.py:85-109) is the report to copy: bucket lists truncated to the
first 20 entries with a full *_count beside each.

0  clean      every family matched; no missing, extra or mismatched record
1  drift      the report is on stdout and at least one family is not clean
2  refusal    the run could not produce an answer; {"error": "..."} on stderr

Exit 2 covers: argparse refusal (already 2), a checkout that is not an absolute
non-symlink directory, an origin outside DataTalksClub/content, an unresolvable
--revision, no enabled ContentSource for DataTalksClub/content, and no active
release. Exit 1 is reserved for a report, so an operator and a future scheduler can
tell "we are behind" from "I could not look".

Report keys, at minimum:

{
  "checkout": {"origin": "...", "revision": "<40 hex>", "remote_head": "<40 hex>", "branch": "main"},
  "served": {"source": "dtc-public-content", "release_id": "...", "revisions": ["<40 hex>"]},
  "revision_status": {"state": "clean|behind|unreachable", "commits_behind": 0},
  "families": {
    "articles": {"total": 0, "matched": 0,
                 "missing": [], "missing_count": 0,
                 "extra": [], "extra_count": 0,
                 "mismatched": [], "mismatched_count": 0},
    "podcasts": {"...": "..."},
    "podcast_transcripts": {"...": "..."},
    "books": {"...": "..."},
    "media": {"...": "...", "upstream_unreferenced": [], "upstream_unreferenced_count": 0}
  },
  "clean": false
}

No record body, no secret, no email address and no personal data goes into the report —
identities are slugs and repository-relative paths, which are already public.

Non-goals

  • Push-sync. No webhook, no ingest, no release preparation or activation, no
    scheduler. This is the check that makes the gap visible, not the fix. (#275/#276 own
    that.)
  • No write of any kind, including ContentSource.last_reconciled_at,
    pending_follow_up or last_successful_commit_docs/runbooks/data-ingest.md
    §10.3 step 7 suggests recording the outcome there; that is a write, the schema slice
    belongs to #273, and it is out of scope here.
  • No network call from the script; no ref resolution over the wire.
  • No change to content_sync/dtc_content/contract.py, to the adapter, or to
    parity.py's ACCEPTED_CONTENT_COMMIT pinning.
  • Not a fix for PREFERRED_CONTENT_REVISION naming a commit upstream does not have.
    The check reports it. Repairing the staging build is #253's territory.
  • No CI gate, no scheduled job, no alerting.
  • No drift check for podwiki, faq, docs, the legacy site, or the course
    repositories.
  • No Studio or admin API surface.
  • No deployed-database read mode. scripts/prod/target has no read-only deployed
    selection — --deployment-target requires --allow-production-write, which is wrong
    for a read-only tool — so this issue uses --database PATH only, and a read-only
    deployed selection is a separate change to scripts/prod/target if an owner wants it.

Dependencies

None blocking. Verified, rather than assumed:

  • #275 (secure GitHub content ingress, immutable checkout, reconciliation) and #276
    (cut public content reads over to direct-sync authority) are both open and still
    needs grooming, blocked on owner decisions. #275 owns a reconciler that requests
    sync
    — a writer with credentials and a network. This issue owns a reader that
    reports
    . They overlap in subject and not in mechanism, and this issue must not wait
    for either: an un-implementable P0 does not block a P1 read-only report. Record the
    relationship; do not create a Depends on.
  • #273 owns the ContentSource state this check deliberately does not write.
  • #253 owns the non-reproducible staging artifact this check will report on.
  • #310 owns the scripts/prod consolidation this script lands inside.

The only real prerequisite is data an operator already has: a database with an active
dtc-public-content release, and a clone of DataTalksClub/content.

Acceptance criteria

  • scripts/prod/sync_content_verify.py exists, declares SYNC_MODEL = "git-synchronized" and BOOTSTRAPS_EMPTY_DATABASE = False, and passes
    scripts/tests/test_prod_conventions.py (the sync_ prefix is required for a
    git-synchronized entry point).
  • It takes --database PATH through scripts/prod/target's
    add_target_arguments/configure_target, plus --checkout PATH, --revision REV
    and --checkout-plan.
  • It performs no database write. A focused test asserts this — for example by running
    it against a populated database and asserting every ContentSource,
    ContentRelease and ContentDocument row, and the source's revision,
    active_release_id, last_reconciled_at and pending_follow_up, are identical
    before and after, and that no audit event was created.
  • It makes no network call. A focused test asserts this against a fixture repository
    with no reachable remote, so an attempted fetch would fail rather than silently
    succeed.
  • Every git invocation targets the checkout's local object database; no git fetch,
    git ls-remote, git clone or equivalent appears in the script.
  • A checkout whose origin is not DataTalksClub/content is refused with exit 2 and
    a condition code, and never parsed.
  • A checkout at a different HEAD than --revision, and a checkout with a dirty
    worktree, both still produce a report — the answer comes from the named revision's
    tree, not from the working copy.
  • The five families are reported: articles, podcasts, podcast_transcripts,
    books, media. people, wiki and courses are not, and a test asserts that
    served records whose provenance.repository is not DataTalksClub/content are
    excluded rather than reported missing.
  • Identity is provenance.source_key (transcript_provenance.source_key for
    transcripts, provenance.source_path for media), never the source path for the four
    document families. A test moves a fixture file to a different directory with
    unchanged bytes and asserts the report stays clean.
  • missing, extra and mismatched are each produced by a focused test with a
    fixture repository: a record only upstream, a record only served, and a record whose
    upstream bytes changed.
  • media.upstream_unreferenced is populated, counted, and does not affect the
    exit code; a test asserts an unreferenced upstream image leaves the run clean while
    a mismatched or orphaned media record does not.
  • revision_status reports clean, behind (with commits_behind) and
    unreachable, each covered by a test; the unreachable case is the one that
    matches production data today.
  • Exit codes are exactly 0 clean / 1 drift / 2 refusal, each covered by a test, with
    the report on stdout and {"error": ...} on stderr for a refusal.
  • The report is json.dumps(..., indent=2, sort_keys=True), truncates each bucket to
    20 entries and carries a full *_count beside it, and contains no record body and
    no personal data.
  • --checkout-plan prints the registered editorial source, repository, branch and
    checkout path, writes nothing, and exits 0 — including on a database with no active
    release.
  • The repository under comparison comes from the registered enabled ContentSource
    whose repository is DataTalksClub/content; a test asserts a database with no such
    enabled source is refused with exit 2 rather than falling back to a hardcoded name.
  • An empty database — no active release — is refused with exit 2 and a named
    condition, not reported as "everything is missing".
  • make content-drift and make content-checkout exist; make content-drift runs
    the script with --database $(CONTENT_DATABASE) and --checkout $(CONTENT_CHECKOUT)
    and touches no network. make content-checkout is the only networked target and is
    not invoked by make content-drift.
  • _docs/runbooks/data-ingest.md is updated: §10 gains how to run the check, §12
    item 11 is moved to §12.1 as closed by this script, and §12 item 15 is corrected
    — the allowlist matches upstream main; the season-hierarchical tree it describes is
    not upstream. §13's quick-reference table gains both Make targets.
  • _docs/runbooks/ingest-script-inventory.md lists the new script.
  • The versioned verification plan from _docs/ci/change-selective-ci.md is produced,
    with exact base/head, graph and plan digests, and every rerun/reused/skipped/
    not_applicable disposition recorded in the engineer handoff.
  • [HUMAN] One operator run against a real clone of DataTalksClub/content and a
    real dataset database, with the report pasted into the issue (identities and counts
    only). The expected result today is exit 1 with
    revision_status.state = "unreachable".

Scenarios

Django / focused tests (scripts/tests/test_sync_content_verify.py, reusing
content_sync/tests/helpers.py's fixture-repository builder and
test_support/published_content.py's publish_documents):

  1. Served catalogue and fixture checkout agree → exit 0, clean: true, every family
    matched == total.
  2. Fixture repository gains one article → articles.missing names its slug, exit 1.
  3. Served catalogue holds a book with no upstream file → books.extra, exit 1.
  4. One podcast's upstream bytes change → podcasts.mismatched names its slug and nothing
    else moves, exit 1.
  5. A transcript changes independently of its episode → podcast_transcripts.mismatched
    only.
  6. An article file moves to a subdirectory with identical bytes → still clean.
  7. An unreferenced upstream image → media.upstream_unreferenced populated, exit 0.
  8. A served media record whose upstream file is deleted → media.extra, exit 1.
  9. Served revision reachable and equal to the comparison revision → clean; reachable
    and older → behind with commits_behind; not reachable at all → unreachable,
    exit 1.
  10. Records with a non-DataTalksClub/content provenance are ignored entirely.
  11. Refusals: wrong origin, non-absolute or symlinked checkout, unresolvable revision, no
    enabled editorial source, no active release → exit 2, {"error": ...} on stderr,
    nothing on stdout.
  12. No-write assertion and no-network assertion as described in the criteria.

Repository / operations:

  1. make content-checkout on a machine with no checkout clones the repository and
    prints its HEAD; re-running refreshes it and prints the same or a newer HEAD.
  2. make content-drift immediately afterwards runs offline and prints the report.
  3. uv run --frozen python scripts/prod/sync_content_verify.py --database … --checkout … --checkout-plan prints the plan and writes nothing.
  4. No model change and no migration is added, so the migration-drift check is unaffected.

Browser: not_applicable. This issue adds an operator script, two Make targets and
documentation. No view, template, route, URL or rendered byte changes, so there is no
route/state to screenshot and no desktop/mobile gate to satisfy. Per _docs/PROCESS.md,
unrelated product screenshots are not evidence and must not be substituted; the tester
records the reason rather than a pending or skipped screenshot component.

Measured baseline for the tester

Reproduce or supersede; do not assume. Measured 2026-09-07.

Fact Value
Upstream DataTalksClub/content main 8be8587c9201d52ae10a731bd5ab2616a479d6bb
ACCEPTED_CONTENT_COMMIT e29f56ce…, an ancestor of main, 6 commits behind
PREFERRED_CONTENT_REVISION 1375c506…, not present upstream (GitHub API 422)
Served content-owned records 55 articles, 203 podcasts, 201 transcripts, 98 books, 559 media — all at 1375c506…
Upstream file counts at main 55 articles, 205 podcast files (203 non-draft), 203 transcripts (201 non-draft), 98 books, 815 images
Upstream images with no served record 256 (206 images/podcast/, 50 images/posts/)
Served media records with no upstream file 0
Byte drift between e29f56ce… and main 10 articles, 1 podcast, 1 image renamed, 1 overlay added

Contributor guide

No contributing guide indexed for this repository

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Read _docs/runbooks/data-ingest.md §10 and §10.3, then compare scripts/prod/sync_public_media_verify.py with content_sync/course_repository_checkout.py and scripts/prod/sync_course_repositories.py. Add scripts/prod/sync_content_verify.py and the content-drift and content-checkout Make entry points using the named checkout and revision. Done means the JSON report has the specified family buckets and revision details, uses the stated exit codes, stays read-only, and rejects invalid checkouts or sources.

Written by the indexing model from the issue text.

Assessment

Tech stack
git, python
Domain
data-engineering, tooling
Issue type
Feature
Difficulty
4/5
Estimated time
3-5 days
Activity status
Active
Clarity
Clearly specified
Newbie friendliness
68/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.