Skip to content

feat(cli): record and verify GitHub dependency commits and content hashes - #737

Merged
Kamirus merged 3 commits into
v3from
kamil-claude/v3-github-dep-integrity
Aug 12, 2026
Merged

feat(cli): record and verify GitHub dependency commits and content hashes#737
Kamirus merged 3 commits into
v3from
kamil-claude/v3-github-dep-integrity

Conversation

@Kamirus

@Kamirus Kamirus commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator

GitHub dependencies had no integrity story at all. 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 — mops add writes one, a hand-written ref does not. A tag-only ref silently re-fetched whatever the tag pointed at, and isDepCached only checked the directory was non-empty.

This repo's own lockfile shows both shapes:

"motoko-datetime": "…#v0.1.1@bda6139…",   // pinned, because mops add wrote it
"matchers":        "…#v1.3.0"             // tag only — moves if the tag moves

mops.lock now records, per GitHub dependency, the commit the archive came from and a sha256 over the extracted tree:

"github": {
  "motoko-datetime": { "resolved": "bda6139ec56d…", "hash": "0f4c…" }
}

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 verify now audits these trees too.

Not a lockfile format bump

CURRENT_LOCK_VERSION stays 3. The record is an optional github section, validated only when present and omitted entirely when a project has no GitHub dependency — the same shape localDepsHash established. It is deliberately 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 untouched. That last point is why a manifest declaring a plain #main keeps satisfying --locked while the lockfile pins a commit beside it.

Tests assert all three properties rather than trusting the reasoning: version === 3, hashes empty for a GitHub-only project, deps byte-identical to the manifest, then mops install --locked exits 0 on the lockfile just written; and a project with no GitHub dependency produces a lockfile with no github key that --locked accepts.

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 #…@sha deps 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 install regenerates it, --locked fails with mops.lock does not record the integrity of a GitHub dependency plus the regenerate hint. Projects with none are unaffected.

Worth stressing: GitHub dependencies are frequently transitive. This repo's mops.toml declares 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.lock is now stale and should be regenerated and committed — one API call, for matchers. 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 --locked step and no clean-tree gate, so nothing breaks today, but a future --locked job 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 #main thrash 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 touches cache.ts and changes mops sources output 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

…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>
@Kamirus Kamirus added the cli label Aug 12, 2026
@github-actions

github-actions Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Cursor AI review

👀 HUMAN REVIEW REQUESTED — significant intended changes detected

Category Assessment Details
Summary Adds optional mops.lock github integrity (resolved commit + tree hash) for repo deps, verifies archives before cache commit, pins bare refs, and extends mops verify / --locked.
Code Quality Reuses parseGithubURL / getGithubCommit / staging helpers; optional github mirrors localDepsHash (omit when unused, no lock version bump).
Consistency Same verify-before-commitStagingDir ordering as registry installs; docs (10-mops.lock.md, 06-mops-verify.md), CHANGELOG ## Next, and skill note updated; sibling --locked messaging matches existing defect kinds.
Security Traced installFromGithub (fetch by resolved commit, hash check before cache rename, no record on unresolved API failure), checkLockedGithubDeps / inspectLockFile, and verifyIntegrity tree audit; fail-closed, no auth widening.
Tests cli/tests/github-dep-lock.test.ts covers v3 shape, --locked heal/fail, hash-mismatch not poisoning cache, verify on edited tree, bare-ref pin, drifted cache replace; no snapshot hunks in this PR.
Maintainability Clear lock/install separation (deps verbatim vs sidecar github); repo mops.lock regenerated with github (+ missing graph) so this tree is not left intentionally stale.

Significant Changes Requiring Human Review

  • S2: GitHub dependency lockfile integrity and install semantics
    • References: cli/integrity.ts (approx. 34–47, 164–172, 332–338, 417–513, 663–689, 1138–1191); cli/commands/install/install-from-github.ts (116–211); mops.lock (github section); docs/docs/10-mops.lock.md
    • Base behavior: GitHub/repo deps were outside lock hashes, --locked/mops verify did not cover them, and archives were fetched by ref (commitHash \|\| branch), so a moved tag could change installed trees.
    • Diff proof: Intended addition of optional lock github records, --locked staleness when missing, fetch/verify by pinned commit, and cache trust only when the locked tree hash matches (else re-fetch).
    • Impact: Confirm the no-format-bump migration (existing locks with any GitHub dep, often transitive, become stale until mops install), that failing closed on unresolved refs is acceptable, and that the new fetch-by-commit + hash gates match the desired reproducibility bar.
    • Confidence: High

Verdict

Decision: REQUEST_HUMAN_REVIEW
Risk: Medium
Reason: Correctness looks sound and fail-closed, but this is intentional package-integrity / lockfile behavior on the install path; a maintainer should sign off on the migration and fetch-by-commit semantics before merge.


Generated for commit c0fcd92

Kamirus and others added 2 commits August 12, 2026 17:11
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>
@Kamirus
Kamirus merged commit ebdfe86 into v3 Aug 12, 2026
20 checks passed
@Kamirus
Kamirus deleted the kamil-claude/v3-github-dep-integrity branch August 12, 2026 17:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant