-
Notifications
You must be signed in to change notification settings - Fork 10
feat(lock): Add lock file foundations #90
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
reubeno
merged 4 commits into
microsoft:main
from
dmcilvaney:damcilva/add_commit_lock_file_parts/1_lock_basics
Apr 10, 2026
+333
−0
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,149 @@ | ||
| // Copyright (c) Microsoft Corporation. | ||
| // Licensed under the MIT License. | ||
|
|
||
| // Package lockfile reads and writes azldev.lock files, which pin resolved | ||
| // upstream commit hashes for deterministic builds. The lock file is a TOML | ||
| // file at the project root, managed by [azldev component update]. | ||
| package lockfile | ||
|
|
||
| import ( | ||
| "fmt" | ||
| "strings" | ||
|
|
||
| "github.com/microsoft/azure-linux-dev-tools/internal/global/opctx" | ||
| "github.com/microsoft/azure-linux-dev-tools/internal/utils/fileperms" | ||
| "github.com/microsoft/azure-linux-dev-tools/internal/utils/fileutils" | ||
| toml "github.com/pelletier/go-toml/v2" | ||
| ) | ||
|
|
||
| // FileName is the lock file name, placed at the project root. | ||
| const FileName = "azldev.lock" | ||
|
|
||
| // currentVersion is the lock file format version. | ||
| const currentVersion = 1 | ||
|
|
||
| // LockFile holds the parsed contents of an azldev.lock file. | ||
| type LockFile struct { | ||
| // Version is the lock file format version. | ||
| Version int `toml:"version" comment:"azldev.lock - Managed by azldev component update. Do not edit manually."` | ||
| // Components maps component name → locked state. | ||
| Components map[string]ComponentLock `toml:"components"` | ||
| } | ||
|
|
||
| // ComponentLock holds the locked state for a single component. | ||
| // Upstream components have [ComponentLock.UpstreamCommit] set to the resolved | ||
| // commit hash. Local components have an entry but with an empty commit field. | ||
| type ComponentLock struct { | ||
| // UpstreamCommit is the resolved full commit hash from the upstream dist-git. | ||
| // Empty for local components. | ||
| UpstreamCommit string `toml:"upstream-commit,omitempty"` | ||
| } | ||
|
|
||
| // New creates an empty lock file with the current format version. | ||
| func New() *LockFile { | ||
| return &LockFile{ | ||
| Version: currentVersion, | ||
| Components: make(map[string]ComponentLock), | ||
| } | ||
| } | ||
|
|
||
| // Load reads and parses a lock file from the given path. Returns an error if the | ||
| // file cannot be read or parsed, or if the format version is unsupported. | ||
| func Load(fs opctx.FS, path string) (*LockFile, error) { | ||
| data, err := fileutils.ReadFile(fs, path) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("reading lock file %#q:\n%w", path, err) | ||
| } | ||
|
|
||
| var lockFile LockFile | ||
| if err := toml.Unmarshal(data, &lockFile); err != nil { | ||
| return nil, fmt.Errorf("parsing lock file %#q:\n%w", path, err) | ||
| } | ||
|
|
||
| if lockFile.Version != currentVersion { | ||
| return nil, fmt.Errorf( | ||
| // Backwards compatibility is a future consideration if we need to make non-compatible changes. | ||
| // For now, we can just error on unsupported versions. | ||
| "unsupported lock file version %d in %#q (expected %d)", | ||
| lockFile.Version, path, currentVersion) | ||
| } | ||
|
|
||
| if lockFile.Components == nil { | ||
| lockFile.Components = make(map[string]ComponentLock) | ||
| } | ||
|
|
||
| return &lockFile, nil | ||
| } | ||
|
|
||
| // Save writes the lock file to the given path. [toml.Marshal] sorts map keys | ||
| // alphabetically, producing deterministic output. Additionally, we post-process the output to insert extra blank lines | ||
| // between component entries, which helps reduce git merge conflicts when parallel PRs modify adjacent entries. | ||
| func (lockFile *LockFile) Save(fs opctx.FS, path string) error { | ||
| data, err := toml.Marshal(lockFile) | ||
| if err != nil { | ||
| return fmt.Errorf("marshaling lock file:\n%w", err) | ||
| } | ||
|
|
||
| // Post-process: insert extra blank lines before each [components.<name>] header. | ||
| // This helps reduce git merge conflicts when parallel PRs modify adjacent entries. | ||
| output := addPerComponentPadding(string(data)) | ||
|
|
||
dmcilvaney marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| if err := fileutils.WriteFile(fs, path, []byte(output), fileperms.PublicFile); err != nil { | ||
| return fmt.Errorf("writing lock file %#q:\n%w", path, err) | ||
| } | ||
|
|
||
| return nil | ||
| } | ||
|
|
||
| // addPerComponentPadding inserts extra blank lines between component entries in the marshaled TOML output. This | ||
| // padding prevents git merge conflicts when parallel PRs add, remove, or modify adjacent component entries — git's | ||
| // default 3-line diff context won't overlap between padded entries. | ||
| // | ||
| // This is a best-effort approach, and won't prevent all conflicts (e.g. if two PRs modify the same component entry), | ||
| // but it should help in the common case of parallel PRs modifying different components. | ||
| // The other option would be to have each component in a separate file, but that adds complexity and overhead | ||
| // to the loading process, and clutters the project with more files. The files cannot live in the rendered specs | ||
| // directory since they are required to detect changes in package state and would be removed by the rendering process or | ||
| // a manual folder removal. | ||
| func addPerComponentPadding(tomlData string) string { | ||
| const prefix = "[components." | ||
|
|
||
| var result strings.Builder | ||
|
|
||
| result.Grow(len(tomlData)) | ||
|
|
||
| for line := range strings.SplitSeq(tomlData, "\n") { | ||
| if strings.HasPrefix(strings.TrimSpace(line), prefix) { | ||
| // Add extra blank lines before each component section header. | ||
| result.WriteString("\n\n") | ||
| } | ||
|
|
||
| result.WriteString(line) | ||
| result.WriteString("\n") | ||
| } | ||
|
|
||
| return result.String() | ||
| } | ||
|
|
||
| // SetUpstreamCommit sets the locked upstream commit for a component. | ||
| func (lockFile *LockFile) SetUpstreamCommit(componentName, commitHash string) { | ||
| if lockFile.Components == nil { | ||
| lockFile.Components = make(map[string]ComponentLock) | ||
| } | ||
|
|
||
| entry := lockFile.Components[componentName] | ||
| entry.UpstreamCommit = commitHash | ||
| lockFile.Components[componentName] = entry | ||
| } | ||
dmcilvaney marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| // GetUpstreamCommit returns the locked upstream commit for a component. | ||
| // Returns empty string and false if the component has no lock entry or | ||
| // if the entry has an empty upstream commit. | ||
| func (lockFile *LockFile) GetUpstreamCommit(componentName string) (string, bool) { | ||
| entry, ok := lockFile.Components[componentName] | ||
| if !ok || entry.UpstreamCommit == "" { | ||
| return "", false | ||
| } | ||
dmcilvaney marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| return entry.UpstreamCommit, true | ||
| } | ||
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,184 @@ | ||
| // Copyright (c) Microsoft Corporation. | ||
| // Licensed under the MIT License. | ||
|
|
||
| package lockfile_test | ||
|
|
||
| import ( | ||
| "path/filepath" | ||
| "strings" | ||
| "testing" | ||
|
|
||
| "github.com/microsoft/azure-linux-dev-tools/internal/lockfile" | ||
| "github.com/microsoft/azure-linux-dev-tools/internal/utils/fileperms" | ||
| "github.com/microsoft/azure-linux-dev-tools/internal/utils/fileutils" | ||
| "github.com/spf13/afero" | ||
| "github.com/stretchr/testify/assert" | ||
| "github.com/stretchr/testify/require" | ||
| ) | ||
|
|
||
| const testProjectDir = "/project" | ||
|
|
||
| func TestNew(t *testing.T) { | ||
| lf := lockfile.New() | ||
| assert.Equal(t, 1, lf.Version) | ||
| assert.NotNil(t, lf.Components) | ||
| assert.Empty(t, lf.Components) | ||
| } | ||
|
|
||
| func TestSetAndGetUpstreamCommit(t *testing.T) { | ||
| lf := lockfile.New() | ||
|
|
||
| lf.SetUpstreamCommit("curl", "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2") | ||
|
|
||
| commit, ok := lf.GetUpstreamCommit("curl") | ||
| assert.True(t, ok) | ||
| assert.Equal(t, "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2", commit) | ||
| } | ||
|
|
||
| func TestGetUpstreamCommitMissing(t *testing.T) { | ||
| lf := lockfile.New() | ||
|
|
||
| commit, ok := lf.GetUpstreamCommit("nonexistent") | ||
| assert.False(t, ok) | ||
| assert.Empty(t, commit) | ||
| } | ||
|
|
||
| func TestSaveAndLoad(t *testing.T) { | ||
| memFS := afero.NewMemMapFs() | ||
| lockPath := filepath.Join(testProjectDir, lockfile.FileName) | ||
|
|
||
| require.NoError(t, fileutils.MkdirAll(memFS, testProjectDir)) | ||
|
|
||
| // Create and save a lock file. | ||
| original := lockfile.New() | ||
| original.SetUpstreamCommit("curl", "aaaa") | ||
| original.SetUpstreamCommit("bash", "bbbb") | ||
| original.SetUpstreamCommit("vim", "cccc") | ||
|
|
||
| require.NoError(t, original.Save(memFS, lockPath)) | ||
|
|
||
| // Load it back. | ||
| loaded, err := lockfile.Load(memFS, lockPath) | ||
| require.NoError(t, err) | ||
|
|
||
| assert.Equal(t, 1, loaded.Version) | ||
|
|
||
| commit, found := loaded.GetUpstreamCommit("curl") | ||
| assert.True(t, found) | ||
| assert.Equal(t, "aaaa", commit) | ||
|
|
||
| commit, found = loaded.GetUpstreamCommit("bash") | ||
| assert.True(t, found) | ||
| assert.Equal(t, "bbbb", commit) | ||
|
|
||
| commit, found = loaded.GetUpstreamCommit("vim") | ||
| assert.True(t, found) | ||
| assert.Equal(t, "cccc", commit) | ||
| } | ||
|
|
||
| func TestSaveSortsComponents(t *testing.T) { | ||
| memFS := afero.NewMemMapFs() | ||
| lockPath := filepath.Join(testProjectDir, lockfile.FileName) | ||
|
|
||
| require.NoError(t, fileutils.MkdirAll(memFS, testProjectDir)) | ||
|
|
||
| lockFile := lockfile.New() | ||
| // Insert in non-alphabetical order. | ||
| lockFile.SetUpstreamCommit("zlib", "zzzz") | ||
| lockFile.SetUpstreamCommit("curl", "aaaa") | ||
| lockFile.SetUpstreamCommit("bash", "bbbb") | ||
|
|
||
| require.NoError(t, lockFile.Save(memFS, lockPath)) | ||
|
|
||
| data, err := fileutils.ReadFile(memFS, lockPath) | ||
| require.NoError(t, err) | ||
|
|
||
| content := string(data) | ||
|
|
||
| // bash should appear before curl, which should appear before zlib. | ||
| bashIdx := strings.Index(content, "[components.bash]") | ||
| curlIdx := strings.Index(content, "[components.curl]") | ||
| zlibIdx := strings.Index(content, "[components.zlib]") | ||
|
|
||
| assert.Less(t, bashIdx, curlIdx, "bash should come before curl") | ||
| assert.Less(t, curlIdx, zlibIdx, "curl should come before zlib") | ||
| } | ||
|
|
||
| func TestLoadUnsupportedVersion(t *testing.T) { | ||
| memFS := afero.NewMemMapFs() | ||
| lockPath := filepath.Join(testProjectDir, lockfile.FileName) | ||
|
|
||
| content := "version = 99\n" | ||
|
|
||
| require.NoError(t, fileutils.MkdirAll(memFS, testProjectDir)) | ||
| require.NoError(t, fileutils.WriteFile(memFS, lockPath, []byte(content), fileperms.PublicFile)) | ||
|
|
||
| _, err := lockfile.Load(memFS, lockPath) | ||
| assert.ErrorContains(t, err, "unsupported lock file version") | ||
| } | ||
|
|
||
| func TestLoadMissingFile(t *testing.T) { | ||
| fs := afero.NewMemMapFs() | ||
|
|
||
| _, err := lockfile.Load(fs, "/nonexistent/azldev.lock") | ||
| assert.Error(t, err) | ||
| } | ||
|
|
||
| func TestLoadInvalidTOML(t *testing.T) { | ||
| memFS := afero.NewMemMapFs() | ||
| lockPath := filepath.Join(testProjectDir, lockfile.FileName) | ||
|
|
||
| require.NoError(t, fileutils.MkdirAll(memFS, testProjectDir)) | ||
| require.NoError(t, fileutils.WriteFile(memFS, lockPath, []byte("not valid toml {{{"), fileperms.PublicFile)) | ||
|
|
||
| _, err := lockfile.Load(memFS, lockPath) | ||
| assert.ErrorContains(t, err, "parsing lock file") | ||
| } | ||
|
|
||
| func TestSaveContainsVersion(t *testing.T) { | ||
| memFS := afero.NewMemMapFs() | ||
| lockPath := filepath.Join(testProjectDir, lockfile.FileName) | ||
|
|
||
| require.NoError(t, fileutils.MkdirAll(memFS, testProjectDir)) | ||
|
|
||
| lockFile := lockfile.New() | ||
| require.NoError(t, lockFile.Save(memFS, lockPath)) | ||
|
|
||
| data, err := fileutils.ReadFile(memFS, lockPath) | ||
| require.NoError(t, err) | ||
|
|
||
| assert.Contains(t, string(data), "version = 1") | ||
| assert.Contains(t, string(data), "# azldev.lock") | ||
| } | ||
|
|
||
| func TestRoundTripLocalComponent(t *testing.T) { | ||
| memFS := afero.NewMemMapFs() | ||
| lockPath := filepath.Join(testProjectDir, lockfile.FileName) | ||
|
|
||
| require.NoError(t, fileutils.MkdirAll(memFS, testProjectDir)) | ||
|
|
||
| // Create a lock file with a local component (empty upstream commit) | ||
| // alongside an upstream component. | ||
| original := lockfile.New() | ||
| original.SetUpstreamCommit("curl", "aaaa") | ||
| original.Components["local-pkg"] = lockfile.ComponentLock{} | ||
|
|
||
| require.NoError(t, original.Save(memFS, lockPath)) | ||
|
|
||
| // Load it back and verify both entries survived. | ||
| loaded, err := lockfile.Load(memFS, lockPath) | ||
| require.NoError(t, err) | ||
|
|
||
| // Upstream component round-trips with its commit. | ||
| commit, found := loaded.GetUpstreamCommit("curl") | ||
| assert.True(t, found) | ||
| assert.Equal(t, "aaaa", commit) | ||
|
|
||
| // Local component has an entry but no upstream commit. | ||
| _, hasEntry := loaded.Components["local-pkg"] | ||
| assert.True(t, hasEntry, "local component entry should survive round-trip") | ||
|
|
||
| commit, found = loaded.GetUpstreamCommit("local-pkg") | ||
| assert.False(t, found, "local component should not have an upstream commit") | ||
| assert.Empty(t, commit) | ||
| } |
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.