-
Notifications
You must be signed in to change notification settings - Fork 61
feat: add internal/lock package for harness dependency pinning #2049
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
ggallen
merged 1 commit into
fullsend-ai:main
from
ggallen:feat/adr0038-phase3-lock-package
Jun 9, 2026
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
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
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,206 @@ | ||
| // Package lock reads and writes .fullsend/lock.yaml files that pin all | ||
| // resolved remote dependencies (URLs and integrity hashes) for reproducible | ||
| // harness execution. A lock file is produced by `fullsend lock <harness>` | ||
| // and consumed by the resolver to skip re-resolution when dependencies | ||
| // have not changed. | ||
| package lock | ||
|
|
||
| import ( | ||
| "fmt" | ||
| "os" | ||
| "path/filepath" | ||
| "sort" | ||
| "time" | ||
|
|
||
| "gopkg.in/yaml.v3" | ||
| ) | ||
|
|
||
| // LockFile is the top-level structure of .fullsend/lock.yaml. | ||
| type LockFile struct { | ||
| Version int `yaml:"version"` | ||
| GeneratedAt time.Time `yaml:"generated_at"` | ||
| Harnesses map[string]HarnessLock `yaml:"harnesses"` | ||
| } | ||
|
|
||
| // HarnessLock records resolved dependencies for one harness. | ||
| type HarnessLock struct { | ||
| Source string `yaml:"source"` | ||
| SHA256 string `yaml:"sha256"` | ||
| ResolvedAt time.Time `yaml:"resolved_at"` | ||
| Dependencies []DependencyEntry `yaml:"dependencies"` | ||
| } | ||
|
|
||
| // DependencyEntry records a single resolved remote resource. | ||
| type DependencyEntry struct { | ||
| Field string `yaml:"field"` | ||
| URL string `yaml:"url"` | ||
| SHA256 string `yaml:"sha256"` | ||
| FetchedAt time.Time `yaml:"fetched_at"` | ||
| TransitiveDeps []DependencyEntry `yaml:"transitive_deps,omitempty"` | ||
| } | ||
|
|
||
| const currentVersion = 1 | ||
|
|
||
| // Load reads a lock file from path. Returns nil (no error) if the file | ||
| // does not exist. | ||
| func Load(path string) (*LockFile, error) { | ||
| data, err := os.ReadFile(path) | ||
| if err != nil { | ||
| if os.IsNotExist(err) { | ||
| return nil, nil | ||
| } | ||
| return nil, fmt.Errorf("reading lock file: %w", err) | ||
| } | ||
|
|
||
| var lf LockFile | ||
| if err := yaml.Unmarshal(data, &lf); err != nil { | ||
| return nil, fmt.Errorf("parsing lock file: %w", err) | ||
| } | ||
|
|
||
| if lf.Version != currentVersion { | ||
| return nil, fmt.Errorf("unsupported lock file version %d (expected %d)", lf.Version, currentVersion) | ||
| } | ||
|
|
||
| return &lf, nil | ||
| } | ||
|
|
||
| // Save writes the lock file to path atomically (write to temp, then rename). | ||
| // Parent directories are created as needed. The file is written with 0o644 | ||
| // permissions (world-readable) because lock files are meant to be committed | ||
| // to version control, unlike cache files which use 0o600. | ||
| // | ||
| // Save mutates lf.Version to currentVersion when it is zero. | ||
| func Save(path string, lf *LockFile) error { | ||
| if lf.Version == 0 { | ||
| lf.Version = currentVersion | ||
| } | ||
|
|
||
| data, err := yaml.Marshal(lf) | ||
| if err != nil { | ||
| return fmt.Errorf("marshaling lock file: %w", err) | ||
| } | ||
|
|
||
| header := []byte("# Generated by fullsend lock — DO NOT EDIT\n") | ||
| content := make([]byte, 0, len(header)+len(data)) | ||
| content = append(content, header...) | ||
| content = append(content, data...) | ||
|
|
||
| dir := filepath.Dir(path) | ||
| if err := os.MkdirAll(dir, 0o755); err != nil { | ||
| return fmt.Errorf("creating lock file directory: %w", err) | ||
| } | ||
|
|
||
| tmp, err := os.CreateTemp(dir, ".lock.yaml.tmp.*") | ||
| if err != nil { | ||
| return fmt.Errorf("creating temp file: %w", err) | ||
| } | ||
| tmpName := tmp.Name() | ||
|
|
||
| if _, err := tmp.Write(content); err != nil { | ||
| tmp.Close() | ||
| os.Remove(tmpName) | ||
| return fmt.Errorf("writing temp file: %w", err) | ||
| } | ||
|
ggallen marked this conversation as resolved.
|
||
| if err := tmp.Chmod(0o644); err != nil { | ||
| tmp.Close() | ||
| os.Remove(tmpName) | ||
| return fmt.Errorf("setting file permissions: %w", err) | ||
| } | ||
| if err := tmp.Sync(); err != nil { | ||
| tmp.Close() | ||
| os.Remove(tmpName) | ||
| return fmt.Errorf("syncing temp file: %w", err) | ||
| } | ||
| if err := tmp.Close(); err != nil { | ||
| os.Remove(tmpName) | ||
| return fmt.Errorf("closing temp file: %w", err) | ||
| } | ||
|
|
||
| if err := os.Rename(tmpName, path); err != nil { | ||
| os.Remove(tmpName) | ||
| return fmt.Errorf("renaming temp file: %w", err) | ||
| } | ||
| return nil | ||
| } | ||
|
|
||
| // Lookup returns the HarnessLock entry for the named harness, or nil if | ||
| // not found. Returns nil for a nil receiver. The returned pointer is a | ||
| // snapshot — mutations do not update the LockFile map. Use SetHarness to | ||
| // modify entries. | ||
| func (lf *LockFile) Lookup(harnessName string) *HarnessLock { | ||
| if lf == nil { | ||
| return nil | ||
| } | ||
| hl, ok := lf.Harnesses[harnessName] | ||
| if !ok { | ||
| return nil | ||
| } | ||
| return &hl | ||
| } | ||
|
|
||
| // IsStale returns true if the harness source file has changed since the | ||
| // lock entry was generated. It compares the harness file's SHA256 against | ||
| // the stored hash. sourceHash is the SHA256 of the current harness file | ||
| // content. Returns true for a nil receiver. | ||
| func (hl *HarnessLock) IsStale(sourceHash string) bool { | ||
| if hl == nil { | ||
| return true | ||
| } | ||
| return hl.SHA256 != sourceHash | ||
| } | ||
|
|
||
| // LookupDep searches the dependency list (including transitive deps, | ||
| // depth-first) for the given URL and returns the matching entry or nil. | ||
| // The returned pointer references the live slice — mutations update the | ||
| // underlying Dependencies. Returns nil for a nil receiver. | ||
| func (hl *HarnessLock) LookupDep(url string) *DependencyEntry { | ||
| if hl == nil { | ||
| return nil | ||
| } | ||
| return lookupInDeps(hl.Dependencies, url, 0) | ||
|
ggallen marked this conversation as resolved.
|
||
| } | ||
|
|
||
| // Guard against excessively deep or cyclic transitive deps in lock files | ||
| // (possible via YAML anchors/aliases or manual editing). | ||
| const maxLookupDepth = 32 | ||
|
|
||
| func lookupInDeps(deps []DependencyEntry, url string, depth int) *DependencyEntry { | ||
| if depth >= maxLookupDepth { | ||
| return nil | ||
| } | ||
| for i := range deps { | ||
| if deps[i].URL == url { | ||
| return &deps[i] | ||
| } | ||
| if found := lookupInDeps(deps[i].TransitiveDeps, url, depth+1); found != nil { | ||
| return found | ||
| } | ||
| } | ||
| return nil | ||
| } | ||
|
|
||
| // SetHarness adds or replaces the lock entry for the named harness. | ||
| // Intended for use by the lock file generator (fullsend lock); callers | ||
| // should use Save to persist changes. No-op on a nil receiver. | ||
| func (lf *LockFile) SetHarness(name string, hl HarnessLock) { | ||
| if lf == nil { | ||
| return | ||
| } | ||
| if lf.Harnesses == nil { | ||
| lf.Harnesses = make(map[string]HarnessLock) | ||
| } | ||
| lf.Harnesses[name] = hl | ||
| } | ||
|
|
||
| // HarnessNames returns the sorted list of harness names in the lock file. | ||
| func (lf *LockFile) HarnessNames() []string { | ||
| if lf == nil || len(lf.Harnesses) == 0 { | ||
| return nil | ||
| } | ||
| names := make([]string, 0, len(lf.Harnesses)) | ||
| for name := range lf.Harnesses { | ||
| names = append(names, name) | ||
| } | ||
| sort.Strings(names) | ||
| return names | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
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.
Uh oh!
There was an error while loading. Please reload this page.