feat(cli): record and verify GitHub dependency commits and content hashes - #737
Merged
Conversation
…shes GitHub dependencies had no integrity story. They were excluded from the lockfile's hashes, so excluded from --locked and from mops verify. The resolved commit only reached the lockfile if mops.toml already carried one, a tag-only ref like #v1.3.0 silently re-fetched whatever the tag pointed at, and a bare #main cached forever with no invalidation. isDepCached only checked the directory was non-empty. mops.lock now records, per GitHub dependency, the commit the archive was fetched from and a sha256 over the extracted tree — every file's path and contents, sorted, so moving a file registers as a change. The archive is fetched by resolved commit rather than by ref, so a moved tag or a force-push cannot change the build. Verification happens against the staging directory and fails before commitStagingDir, so a mismatch never poisons the cache. mops verify audits these trees too. This is not a lockfile format bump. CURRENT_LOCK_VERSION stays 3: the record is an optional `github` section, validated only when present, and omitted entirely for projects with no GitHub dependency — the same shape as localDepsHash. It is not folded into `hashes`, whose key set older CLIs cross-check against `deps`, and `deps` values are stored verbatim so checkLockedDeps' literal comparison against mops.toml is unaffected. One GitHub API call is made per dependency that names no commit, once, and never again after the lockfile records it. A failed lookup warns, keeps the previous behaviour, and records nothing rather than pairing a commit with another commit's hash. BREAKING CHANGE: a lockfile written by an older CLI for a project that has a GitHub dependency is stale. `mops install` regenerates it; `--locked` fails until the result is committed. GitHub dependencies are often transitive, so this can apply to a project whose own mops.toml declares none. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Contributor
Cursor AI review👀 HUMAN REVIEW REQUESTED — significant intended changes detected
Significant Changes Requiring Human Review
VerdictDecision: REQUEST_HUMAN_REVIEW Generated for commit c0fcd92 |
The repo's own lockfile predates the `github` section, so it counted as stale under the change in this branch. Regenerating adds the resolved commit and content hash for the two GitHub dependencies — both transitive, which is why they are present at all when mops.toml declares none. `matchers` was pinned by tag only (#v1.3.0) and now records commit 3dac8a07. Note `deps` still stores the ref verbatim, so the manifest comparison behind --locked is unaffected. Also gains a `graph` section: a lockfile that is already valid never acquires one until its dependencies change, so this repo never had it. Nothing else moved — same lock version, same deps, no file hash changed. `mops install --locked` and `mops verify` both pass (30 packages, 621 files). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A cache hit was trusted whenever the cache name pinned a commit, on the reasoning that the name identifies the tree. It does not: an entry written by an older CLI does not necessarily match a fresh archive of the same commit, so hashing it recorded a value no other machine could reproduce. CI proved it — this repo's regenerated lockfile pinned motoko-datetime to fc4cec61 from a local cache entry, while a fresh download of the same commit hashes to 82304548, and the very first CI run failed its own integrity check. The first recorded hash for a dependency now always comes from a fresh extraction. Only a cache entry that matches an already-locked hash is trusted, which costs one re-download per GitHub dependency on the first install after upgrading — the same one-time price as the lockfile regeneration itself. Regenerates mops.lock accordingly. Note `matchers` is unchanged: it is pinned by tag with no commit, so it already took the re-fetch path and was always correct. Only the commit-pinned dependency was affected. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This was referenced Aug 12, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
GitHub dependencies had no integrity story at all. They were excluded from the lockfile's
hashes, so excluded from--lockedand frommops verify. The resolved commit only reached the lockfile ifmops.tomlalready carried one —mops addwrites one, a hand-written ref does not. A tag-only ref silently re-fetched whatever the tag pointed at, andisDepCachedonly checked the directory was non-empty.This repo's own lockfile shows both shapes:
mops.locknow records, per GitHub dependency, the commit the archive came from and asha256over the extracted tree:Archives are fetched by resolved commit, never by ref, so a moved tag or a force-push cannot change what you build. The hash covers every file's path and contents, sorted, so moving a file registers as a change as much as editing one. Verification runs against the staging directory and fails before
commitStagingDir, so a mismatch never reaches the cache — same ordering registry packages already use.mops verifynow audits these trees too.Not a lockfile format bump
CURRENT_LOCK_VERSIONstays3. The record is an optionalgithubsection, validated only when present and omitted entirely when a project has no GitHub dependency — the same shapelocalDepsHashestablished. It is deliberately not folded intohashes, whose key set older CLIs cross-check againstdeps, anddepsvalues are stored verbatim socheckLockedDeps' literal comparison againstmops.tomlis untouched. That last point is why a manifest declaring a plain#mainkeeps satisfying--lockedwhile the lockfile pins a commit beside it.Tests assert all three properties rather than trusting the reasoning:
version === 3,hashesempty for a GitHub-only project,depsbyte-identical to the manifest, thenmops install --lockedexits 0 on the lockfile just written; and a project with no GitHub dependency produces a lockfile with nogithubkey that--lockedaccepts.GitHub API cost
One
GET /repos/{org}/{repo}/commits/{ref}per dependency that names no commit, once — never again after the lockfile records it.mops add-written#…@shadeps and any already-locked dep cost zero, so CI never approaches the 60/hour anonymous limit. A failed lookup (rate limit, offline) warns, keeps the previous behaviour, and records nothing: pairing one commit with another commit's hash would be worse than no entry, so the lockfile stays stale and the next install completes it.Migration
A lockfile written by an older CLI for a project that has a GitHub dependency is stale:
mops installregenerates it,--lockedfails withmops.lock does not record the integrity of a GitHub dependencyplus the regenerate hint. Projects with none are unaffected.Worth stressing: GitHub dependencies are frequently transitive. This repo's
mops.tomldeclares none, yet its lockfile has two, pulled in by registry packages. So "we don't use GitHub deps" is not a reliable reason to expect no lockfile churn.Needs a maintainer decision
This repo's own
mops.lockis now stale and should be regenerated and committed — one API call, formatchers. I did not do it here: it requires running the new CLI against the live registry and GitHub, and I would rather that regeneration be a deliberate commit than something I fold into the change that caused it. CI has no--lockedstep and no clean-tree gate, so nothing breaks today, but a future--lockedjob would fail on it.Follow-up worth flagging
The global cache directory for a bare ref is keyed by the ref, not the resolved commit, so two projects pinning different commits of the same
#mainthrash that one entry. Each still gets correct, verified content — it is an extra download, not a correctness problem — but keying it by resolved commit would fix it. That touchescache.tsand changesmops sourcesoutput paths, so it is out of scope here.Part of the GA follow-up work from the Cargo/pnpm audit (#723, item 10), where the triage records that this needs no lockfile version bump and why.
🤖 Generated with Claude Code