DataTalksClub / DataTalksClub/website
Add a drift check between served content and DataTalksClub/content
Nobody has claimed this yet.
- 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-…jpg → s24e06-…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 normativeDataTalksClub/contentfolder 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 inscripts/prod/— never a Django management command"._docs/PROCESS.mdand_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 measureds24e07→s24e06image) is reported as one
missingand oneextra, 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.revisionvalues 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; unreachablewhen 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/contentwith the same clone/fetch/reset/clean sequence
content-checkoutsalready uses, into$(CONTENT_CHECKOUT)(default
.tmp/content-checkout), printing the resolved HEAD. It exists becausemake content-checkoutsdoes 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-planon 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 shapesync_course_repositories.py --checkout-plangives the course
path, and whatmake content-checkoutconsumes. Which repository this is stays a
database question: the enabledContentSourcewhose repository is
DataTalksClub/content(dtc-public-contenton 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.mddoes 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_uporlast_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'sACCEPTED_CONTENT_COMMITpinning. - Not a fix for
PREFERRED_CONTENT_REVISIONnaming 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/targethas no read-only deployed
selection —--deployment-targetrequires--allow-production-write, which is wrong
for a read-only tool — so this issue uses--database PATHonly, and a read-only
deployed selection is a separate change toscripts/prod/targetif 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 aDepends on. - #273 owns the
ContentSourcestate this check deliberately does not write. - #253 owns the non-reproducible staging artifact this check will report on.
- #310 owns the
scripts/prodconsolidation 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.pyexists, declaresSYNC_MODEL = "git-synchronized"andBOOTSTRAPS_EMPTY_DATABASE = False, and passes
scripts/tests/test_prod_conventions.py(thesync_prefix is required for a
git-synchronized entry point). - It takes
--database PATHthroughscripts/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 everyContentSource,
ContentReleaseandContentDocumentrow, and the source'srevision,
active_release_id,last_reconciled_atandpending_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 cloneor equivalent appears in the script. - A checkout whose
originis notDataTalksClub/contentis 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,wikiandcoursesare not, and a test asserts that
served records whoseprovenance.repositoryis notDataTalksClub/contentare
excluded rather than reported missing. - Identity is
provenance.source_key(transcript_provenance.source_keyfor
transcripts,provenance.source_pathfor 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,extraandmismatchedare 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_unreferencedis 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_statusreportsclean,behind(withcommits_behind) and
unreachable, each covered by a test; theunreachablecase 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*_countbeside it, and contains no record body and
no personal data. -
--checkout-planprints 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 isDataTalksClub/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-driftandmake content-checkoutexist;make content-driftruns
the script with--database $(CONTENT_DATABASE)and--checkout $(CONTENT_CHECKOUT)
and touches no network.make content-checkoutis the only networked target and is
not invoked bymake content-drift. -
_docs/runbooks/data-ingest.mdis 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 upstreammain; the season-hierarchical tree it describes is
not upstream. §13's quick-reference table gains both Make targets. -
_docs/runbooks/ingest-script-inventory.mdlists the new script. - The versioned verification plan from
_docs/ci/change-selective-ci.mdis produced,
with exact base/head, graph and plan digests, and everyrerun/reused/skipped/
not_applicabledisposition recorded in the engineer handoff. -
[HUMAN]One operator run against a real clone ofDataTalksClub/contentand 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):
- Served catalogue and fixture checkout agree → exit 0,
clean: true, every family
matched == total. - Fixture repository gains one article →
articles.missingnames its slug, exit 1. - Served catalogue holds a book with no upstream file →
books.extra, exit 1. - One podcast's upstream bytes change →
podcasts.mismatchednames its slug and nothing
else moves, exit 1. - A transcript changes independently of its episode →
podcast_transcripts.mismatched
only. - An article file moves to a subdirectory with identical bytes → still clean.
- An unreferenced upstream image →
media.upstream_unreferencedpopulated, exit 0. - A served media record whose upstream file is deleted →
media.extra, exit 1. - Served revision reachable and equal to the comparison revision →
clean; reachable
and older →behindwithcommits_behind; not reachable at all →unreachable,
exit 1. - Records with a non-
DataTalksClub/contentprovenance are ignored entirely. - 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. - No-write assertion and no-network assertion as described in the criteria.
Repository / operations:
make content-checkouton a machine with no checkout clones the repository and
prints its HEAD; re-running refreshes it and prints the same or a newer HEAD.make content-driftimmediately afterwards runs offline and prints the report.uv run --frozen python scripts/prod/sync_content_verify.py --database … --checkout … --checkout-planprints the plan and writes nothing.- 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
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- 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