Skip to content

feat(standards): bake a content hash into vendored sync headers (Option B from #1) #8

Description

@ciotlosm

Long-term correct fix for the drift algorithm. Closes the archaeology wrinkle exposed by the new path-filtered SHA approach (see the close comment on #1). Tracks the design work; not urgent because the path-filtered SHA in n3ary/actions#3 + n3ary/standards#3 stopped the user-visible false positives.

Why this still matters

The current drift algorithm uses "latest commit on main touching standards/" as the canonical SHA. That kills false positives (a .gitignore-only commit on n3ary/standards/main doesn't move the canonical SHA), but it has a residual quirk:

  • Two non-content commits (e.g. a21f890 and 7f9f83c) can sit between the last content commit and HEAD.
  • A new vendor run would re-stamp the vendored files with the content-touching SHA (063866e today) - backwards from HEAD (7f9f83c). The vendor side and drift side agree, but a human reading <!-- synced from n3ary/standards@063866e --> cannot infer "this content is also in 7f9f83c" without running the path-filter lookup by hand.
  • Today's n3ary/gtfs-adapters#19 / n3ary/gtfs-adapters#23 dance (7f9f83c then 063866e) was a direct consequence.

Proposed shape

Stamp a content hash into the vendored file header alongside (or instead of) the commit SHA, and have the drift check compare on content rather than SHA.

Header format

<!-- synced from n3ary/standards@<sha> on <date> -->
<!-- content-sha256: <hash> -->
<!-- do not edit locally; run scripts/vendor-standards.mjs to update -->

<standard content>

Where <hash> is sha256 of the concatenated standards/*.md file contents (sorted by filename, separated by \n---\n or similar unambiguous delimiter). Deterministic across vendors and consumers.

Drift check

Replace the SHA comparison:

LATEST_CONTENT_HASH=$(gh api .../standards/contents | ... sha256sum ...)
VENDORED_HASH=$(grep -oP 'content-sha256:\s*\K\S+' "$f")
[ "$VENDORED_HASH" != "$LATEST_CONTENT_HASH" ] && DRIFT=1

Note: the canonical content lives in standards/*.md on n3ary/standards, so the hash can be computed from a single fetch (concatenated raw content) rather than needing a local checkout.

What this buys

  • The vendored header is content-addressable: content-sha256: abc123 is the same no matter which commit produced it. Drift detection focuses on actual content changes - not git history.
  • Two non-content commits (a21f890, 7f9f83c) no longer wedge the canonical SHA out of sync with what the vendored files actually carry.
  • A first-time vendor of docs/standards/* into a fresh consumer produces a header that already passes drift-check on the next day, with no manual SHA bumps.
  • The "synced from @" header stays useful for git blame archaeology - it's a stable pointer to the commit that produced this content, not a moving target.

What this costs

Cross-repo change, three pieces:

  1. n3ary/standards/scripts/vendor-standards.mjs:
    • New lastStandardsContentHash() helper (compute from standards/*.md).
    • Header emits both SHA and content hash.
    • Keep the path-filtered SHA lookup so the "synced from @" stays meaningful (and so daily sync still triggers via sync-standards.yml).
  2. n3ary/actions/.github/workflows/check-standards-drift.yml:
    • Switch the comparison to content hash.
    • LATEST_CONTENT_HASH computed once per run from standards/* raw content.
  3. Consumer repos: [n3ary/app](https://github.com/n3ary/app), [n3ary/gtfs-publisher](https://github.com/n3ary/gtfs-publisher), [n3ary/gtfs-adapters](https://github.com/n3ary/gtfs-adapters), plus any future adopter:
    • On first deploy of the new algorithm, a one-time migration: vendor-standards.mjs re-runs and emits the new content-sha256: header. Drift-check still passes because the SHA in the header also gets refreshed in the same run.

Migration plan

  1. Land the new algorithm in n3ary/actions first (drift-check accepts both SHA and content hash during transition, prefers hash when present).
  2. Land the new header format in vendor-standards.mjs next; have it emit both old and new fields during a single sync run, so consumers automatically pick up the new header on the next sync.
  3. Once all consumers have content-sha256: ... in their headers, simplify drift-check to compare hash only.
  4. Document the new header format in standards/README.md so future consumer authors know what the second comment is.

Open questions

  • Hash input ordering: alphabetical by filename is the obvious choice, but the standards directory has a README.md and SHARED-STANDARDS.md that are excluded by the vendor - does the hash include them, or only what's vendored?
  • Hash algorithm: sha256 is fine, but if we ever want Git-blame-style traceability, git hash-object-equivalent over a tree could work. Probably overkill for "did the content change?" - lean on sha256.
  • Should the path-filtered SHA lookup be retained at all? Today it serves two purposes: (1) the human-readable "synced from @" label, (2) the daily sync-standards.yml trigger source. If we move (2) to a paths: [standards/**] workflow filter alone, the SHA lookup becomes purely informational and could be simplified to "HEAD of main" again. Worth a follow-up sub-decision.

Related

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions