licensed 2.8.0 → 2.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/.gitignore CHANGED
@@ -45,6 +45,8 @@ test/fixtures/mix/mix.lock
45
45
  test/fixtures/yarn/*
46
46
  !test/fixtures/yarn/package.json
47
47
 
48
+ test/fixtures/nuget/obj/*
49
+
48
50
  vendor/licenses
49
51
  .licenses
50
52
  *.gem
@@ -6,6 +6,53 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## 2.11.0
10
+ 2020-06-02
11
+
12
+ ### Added
13
+ - `notices` command to create a `NOTICE` file for each configured app (https://github.com/github/licensed/pull/277)
14
+
15
+ ### Fixed
16
+ - NuGet source no longer crashes on a non-existent dependency path (https://github.com/github/licensed/pull/280)
17
+ - Go source no longer crashes on a non-existent dependency package path (https://github.com/github/licensed/pull/274)
18
+
19
+ ## 2.10.0
20
+ 2020-05-15
21
+
22
+ ### Changed
23
+ - NPM source ignores missing peer dependencies (https://github.com/github/licensed/pull/267)
24
+
25
+ ### Added
26
+ - NuGet source (:tada: @zarenner https://github.com/github/licensed/pull/261)
27
+ - Multiple apps can share a single cache location (https://github.com/github/licensed/pull/263)
28
+
29
+ ## 2.9.2
30
+ 2020-04-28
31
+
32
+ ### Changed
33
+ - `licensee` minimum version bumped to 9.13.2 (https://github.com/github/licensed/pull/256)
34
+
35
+ ## 2.9.1
36
+ 2020-03-24
37
+
38
+ ### Changed
39
+ - relaxed gem version restrictions on Thor (:tada: @eileencodes https://github.com/github/licensed/pull/254)
40
+
41
+ ## 2.9.0
42
+ 2020-03-19
43
+
44
+ ### Added
45
+ - Source paths use glob pattern matching (https://github.com/github/licensed/pull/245)
46
+
47
+ ### Fixed
48
+ - Mix source supports updates to mix.lock format (:tada: @bruce https://github.com/github/licensed/pull/242)
49
+ - Go source supports `go list` format changes in go 1.14 (https://github.com/github/licensed/pull/247)
50
+
51
+ ### Changed
52
+ - `licensed cache` will flag dependencies for re-review when license text changes (https://github.com/github/licensed/pull/248)
53
+ - `licensed status` will raise errors on dependencies that need re-review (https://github.com/github/licensed/pull/248)
54
+ - `licensee` minimum version bumped to 9.13.1 (https://github.com/github/licensed/pull/251)
55
+
9
56
  ## 2.8.0
10
57
  2020-01-03
11
58
 
@@ -265,4 +312,4 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.
265
312
 
266
313
  Initial release :tada:
267
314
 
268
- [Unreleased]: https://github.com/github/licensed/compare/2.8.0...HEAD
315
+ [Unreleased]: https://github.com/github/licensed/compare/2.11.0...HEAD
data/README.md CHANGED
@@ -24,13 +24,13 @@ See the [migration documentation](./docs/migrating_to_newer_versions.md) for mor
24
24
  ### Dependencies
25
25
 
26
26
  Licensed uses the `libgit2` bindings for Ruby provided by `rugged`. `rugged` requires `cmake` and `pkg-config` which you may need to install before you can install Licensed.
27
-
27
+
28
28
  > Ubuntu
29
-
29
+
30
30
  sudo apt-get install cmake pkg-config
31
-
31
+
32
32
  > OS X
33
-
33
+
34
34
  brew install cmake pkg-config
35
35
 
36
36
  ### With a Gemfile
@@ -64,8 +64,10 @@ For system wide usage, install licensed to a location on `$PATH`, e.g. `/usr/loc
64
64
 
65
65
  - `licensed list`: Output enumerated dependencies only.
66
66
  - `licensed cache`: Cache licenses and metadata.
67
- - `licensed status`: Check status of dependencies' cached licenses. For example:
67
+ - `licensed status`: Check status of dependencies' cached licenses.
68
+ - `licensed notices`: Write a `NOTICE` file for each application configuration.
68
69
  - `licensed version`: Show current installed version of Licensed. Aliases: `-v|--version`
70
+ - `licensed env`: Output environment information from the licensed configuration.
69
71
 
70
72
  See the [commands documentation](./docs/commands.md) for additional documentation, or run `licensed -h` to see all of the current available commands.
71
73
 
@@ -102,14 +104,16 @@ Dependencies will be automatically detected for all of the following sources by
102
104
  1. [Bundler](./docs/sources/bundler.md)
103
105
  1. [Cabal](./docs/sources/cabal.md)
104
106
  1. [Composer](./docs/sources/composer.md)
107
+ 1. [Git Submodules (git_submodule)](./docs/sources/git_submodule.md)
105
108
  1. [Go](./docs/sources/go.md)
106
109
  1. [Go Dep (dep)](./docs/sources/dep.md)
110
+ 1. [Gradle](./docs/sources/gradle.md)
107
111
  1. [Manifest lists (manifests)](./docs/sources/manifests.md)
112
+ 1. [Mix](./docs/sources/mix.md)
108
113
  1. [NPM](./docs/sources/npm.md)
114
+ 1. [NuGet](./docs/sources/nuget.md)
109
115
  1. [Pip](./docs/sources/pip.md)
110
116
  1. [Pipenv](./docs/sources/pipenv.md)
111
- 1. [Git Submodules (git_submodule)](./docs/sources/git_submodule.md)
112
- 1. [Mix](./docs/sources/mix.md)
113
117
  1. [Yarn](./docs/sources/yarn.md)
114
118
 
115
119
  You can disable any of them in the configuration file:
@@ -28,6 +28,14 @@ A dependency will fail the status checks if:
28
28
  3. The cached record's `licenses` data is empty
29
29
  4. The cached record's `license` metadata doesn't match an `allowed` license from the dependency's application configuration.
30
30
  - If `license: other` is specified and all of the `licenses` entries match an `allowed` license a failure will not be logged
31
+ 5. The cached record is flagged for re-review.
32
+ - This occurs when the record's license text has changed since the record was reviewed.
33
+
34
+ ## `notices`
35
+
36
+ Outputs license and notice text for all dependencies in each app into a `NOTICE` file in the app's `cache_path`. If an app uses a shared cache path, the file name will contain the app name as well, e.g. `NOTICE.my_app`.
37
+
38
+ The `NOTICE` file contents are retrieved from cached records, with the assumption that cached records have already been reviewed in a compliance workflow.
31
39
 
32
40
  ## `env`
33
41
 
@@ -19,6 +19,22 @@ If a root path is not specified, it will default to using the following, in orde
19
19
  1. the root of the local git repository, if run inside a git repository
20
20
  2. the current directory
21
21
 
22
+ ### Source path glob patterns
23
+
24
+ The `source_path` property can use a glob path to share configuration properties across multiple application entrypoints.
25
+
26
+ For example, there is a common pattern in go projects to include multiple executable entrypoints under folders in `cmd`. Using a glob pattern allows users to avoid manually configuring and maintaining multiple licensed application `source_path`s. Using a glob pattern will also ensure that any new entrypoints matching the pattern are automatically picked up by licensed commands as they are added.
27
+
28
+ ```yml
29
+ sources:
30
+ go: true
31
+
32
+ # treat all directories under `cmd` as separate apps
33
+ source_path: cmd/*
34
+ ```
35
+
36
+ Glob patterns are syntactic sugar for, and provide the same functionality as, manually specifying multiple `source_path` values. See the instructions on [specifying multiple apps](./#specifying-multiple-apps) below for additional considerations when using multiple apps.
37
+
22
38
  ## Restricting sources
23
39
 
24
40
  The `sources` configuration property specifies which sources `licensed` will use to enumerate dependencies.
@@ -193,6 +209,26 @@ apps:
193
209
 
194
210
  In this example, the root configuration will contain a default cache path of `.licenses`. `app1` will inherit this value and append it's name, resulting in a cache path of `.licenses/app1`.
195
211
 
212
+ ### Sharing caches between apps
213
+
214
+ Dependency caches can be shared between apps by setting the same cache path on each app.
215
+
216
+ ```yaml
217
+ apps:
218
+ - source_path: "path/to/app1"
219
+ cache_path: ".licenses/apps"
220
+ - source_path: "path/to/app2"
221
+ cache_path: ".licenses/apps"
222
+ ```
223
+
224
+ When using a source path with a glob pattern, the apps created from the glob pattern can share a dependency by setting an explicit cache path and setting `shared_cache` to true.
225
+
226
+ ```yaml
227
+ source_path: "path/to/apps/*"
228
+ cache_path: ".licenses/apps"
229
+ shared_cache: true
230
+ ```
231
+
196
232
  ## Source specific configuration
197
233
 
198
234
  See the [source documentation](./sources) for details on any source specific configuration.
@@ -0,0 +1,14 @@
1
+ # NuGet
2
+
3
+ The NuGet source will detect ProjectReference-style restored packages by inspecting `project.assets.json` files for dependencies. It requires that `dotnet restore` has already ran on the project.
4
+
5
+ The source currently expects that `source_path` is set to the `obj` directory containing the `project.assets.json`.
6
+ For example, if your project lives at `foo/foo.proj`, you likely want to set `source_path` to `foo/obj`.
7
+ If in MSBuild you have customized your `obj` paths (e.g. to live outside your source tree), you may need to set `source_path` to something different such as `../obj/foo`.
8
+
9
+ ### Search strategy
10
+ This source looks for licenses:
11
+ 1. Specified by SPDX expression via `<license type="expression">` in a package's `.nuspec` (via licensee)
12
+ 2. In license files such as `LICENSE.txt`, even if not specified in the `.nuspec` (via licensee)
13
+ 3. Specified by filepath via `<license type="file">` in a package's `.nuspec`, even if not a standard license filename.
14
+ 4. By downloading and inspecting the contents of `<licenseUrl>` in a package's `.nuspec`, if not found otherwise.
@@ -28,6 +28,13 @@ module Licensed
28
28
  run Licensed::Commands::List.new(config: config)
29
29
  end
30
30
 
31
+ desc "notices", "Generate a NOTICE file from cached records"
32
+ method_option :config, aliases: "-c", type: :string,
33
+ desc: "Path to licensed configuration file"
34
+ def notices
35
+ run Licensed::Commands::Notices.new(config: config)
36
+ end
37
+
31
38
  map "-v" => :version
32
39
  map "--version" => :version
33
40
  desc "version", "Show Installed Version of Licensed, [-v, --version]"
@@ -6,5 +6,6 @@ module Licensed
6
6
  require "licensed/commands/status"
7
7
  require "licensed/commands/list"
8
8
  require "licensed/commands/environment"
9
+ require "licensed/commands/notices"
9
10
  end
10
11
  end
@@ -11,20 +11,39 @@ module Licensed
11
11
  Licensed::Reporters::CacheReporter.new
12
12
  end
13
13
 
14
+ # Run the command.
15
+ # Removes any cached records that don't match a current application
16
+ # dependency.
17
+ #
18
+ # options - Options to run the command with
19
+ #
20
+ # Returns whether the command was a success
21
+ def run(**options)
22
+ begin
23
+ result = super
24
+ clear_stale_cached_records if result
25
+
26
+ result
27
+ ensure
28
+ cache_paths.clear
29
+ files.clear
30
+ end
31
+ end
32
+
14
33
  protected
15
34
 
16
- # Run the command for all enumerated dependencies found in a dependency source,
35
+ # Run the command for all enabled sources for an application configuration,
17
36
  # recording results in a report.
18
- # Removes any cached records that don't match a current application
19
- # dependency.
20
37
  #
21
- # app - The application configuration for the source
22
- # source - A dependency source enumerator
38
+ # app - An application configuration
23
39
  #
24
- # Returns whether the command succeeded for the dependency source enumerator
25
- def run_source(app, source)
40
+ # Returns whether the command succeeded for the application.
41
+ def run_app(app)
26
42
  result = super
27
- clear_stale_cached_records(app, source) if result
43
+
44
+ # add the full cache path to the list of cache paths evaluted during this run
45
+ cache_paths << app.cache_path
46
+
28
47
  result
29
48
  end
30
49
 
@@ -45,8 +64,15 @@ module Licensed
45
64
  filename = app.cache_path.join(source.class.type, "#{dependency.name}.#{DependencyRecord::EXTENSION}")
46
65
  cached_record = Licensed::DependencyRecord.read(filename)
47
66
  if options[:force] || save_dependency_record?(dependency, cached_record)
48
- # use the cached license value if the license text wasn't updated
49
- dependency.record["license"] = cached_record["license"] if dependency.record.matches?(cached_record)
67
+ if dependency.record.matches?(cached_record)
68
+ # use the cached license value if the license text wasn't updated
69
+ dependency.record["license"] = cached_record["license"]
70
+ elsif cached_record && app.reviewed?(dependency.record)
71
+ # if the license text changed and the dependency is set as reviewed
72
+ # force a re-review of the dependency
73
+ dependency.record["review_changed_license"] = true
74
+ end
75
+
50
76
  dependency.record.save(filename)
51
77
  report["cached"] = true
52
78
  end
@@ -55,6 +81,9 @@ module Licensed
55
81
  report.warnings << "expected dependency path #{dependency.path} does not exist"
56
82
  end
57
83
 
84
+ # add the absolute dependency file path to the list of files seen during this licensed run
85
+ files << filename.to_s
86
+
58
87
  true
59
88
  end
60
89
 
@@ -79,18 +108,26 @@ module Licensed
79
108
 
80
109
  # Clean up cached files that dont match current dependencies
81
110
  #
82
- # app - An application configuration
83
- # source - A dependency source enumerator
84
- #
85
111
  # Returns nothing
86
- def clear_stale_cached_records(app, source)
87
- names = source.dependencies.map { |dependency| File.join(source.class.type, dependency.name) }
88
- Dir.glob(app.cache_path.join(source.class.type, "**/*.#{DependencyRecord::EXTENSION}")).each do |file|
89
- file_path = Pathname.new(file)
90
- relative_path = file_path.relative_path_from(app.cache_path).to_s
91
- FileUtils.rm(file) unless names.include?(relative_path.chomp(".#{DependencyRecord::EXTENSION}"))
112
+ def clear_stale_cached_records
113
+ cache_paths.each do |cache_path|
114
+ Dir.glob(cache_path.join("**/*.#{DependencyRecord::EXTENSION}")).each do |file|
115
+ next if files.include?(file)
116
+
117
+ FileUtils.rm(file)
118
+ end
92
119
  end
93
120
  end
121
+
122
+ # Set of unique cache paths that are evaluted during the run
123
+ def cache_paths
124
+ @cache_paths ||= Set.new
125
+ end
126
+
127
+ # Set of unique absolute file paths of cached records evaluted during the run
128
+ def files
129
+ @files ||= Set.new
130
+ end
94
131
  end
95
132
  end
96
133
  end
@@ -23,14 +23,14 @@ module Licensed
23
23
  "allowed" => config["allowed"],
24
24
  "ignored" => config["ignored"],
25
25
  "reviewed" => config["reviewed"],
26
- "version_strategy" => self.version_strategy
26
+ "version_strategy" => self.version_strategy,
27
+ "root" => config.root
27
28
  }
28
29
  end
29
30
  end
30
31
 
31
32
  def run(**options)
32
33
  super do |report|
33
- report["root"] = config.root
34
34
  report["git_repo"] = Licensed::Git.git_repo?
35
35
  end
36
36
  end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+ module Licensed
3
+ module Commands
4
+ class Notices < Command
5
+ # Create a reporter to use during a command run
6
+ #
7
+ # options - The options the command was run with
8
+ #
9
+ # Raises a Licensed::Reporters::CacheReporter
10
+ def create_reporter(options)
11
+ Licensed::Reporters::NoticesReporter.new
12
+ end
13
+
14
+ protected
15
+
16
+ # Load stored dependency record data to add to the notices report.
17
+ #
18
+ # app - The application configuration for the dependency
19
+ # source - The dependency source enumerator for the dependency
20
+ # dependency - An application dependency
21
+ # report - A report hash for the command to provide extra data for the report output.
22
+ #
23
+ # Returns true.
24
+ def evaluate_dependency(app, source, dependency, report)
25
+ filename = app.cache_path.join(source.class.type, "#{dependency.name}.#{DependencyRecord::EXTENSION}")
26
+ report["cached_record"] = Licensed::DependencyRecord.read(filename)
27
+ if !report["cached_record"]
28
+ report["warning"] = "expected cached record not found at #{filename}"
29
+ end
30
+
31
+ true
32
+ end
33
+ end
34
+ end
35
+ end
@@ -35,7 +35,11 @@ module Licensed
35
35
  else
36
36
  report.errors << "cached dependency record out of date" if cached_record["version"] != dependency.version
37
37
  report.errors << "missing license text" if cached_record.licenses.empty?
38
- report.errors << "license needs review: #{cached_record["license"]}" if license_needs_review?(app, cached_record)
38
+ if cached_record["review_changed_license"]
39
+ report.errors << "license text has changed and needs re-review. if the new text is ok, remove the `review_changed_license` flag from the cached record"
40
+ elsif license_needs_review?(app, cached_record)
41
+ report.errors << "license needs review: #{cached_record["license"]}"
42
+ end
39
43
  end
40
44
 
41
45
  report.errors.empty?
@@ -4,40 +4,39 @@ require "pathname"
4
4
  module Licensed
5
5
  class AppConfiguration < Hash
6
6
  DEFAULT_CACHE_PATH = ".licenses".freeze
7
- DEFAULT_CONFIG_FILES = [
8
- ".licensed.yml".freeze,
9
- ".licensed.yaml".freeze,
10
- ".licensed.json".freeze
11
- ].freeze
7
+
8
+ # Returns the root for a configuration in following order of precendence:
9
+ # 1. explicitly configured "root" property
10
+ # 2. a found git repository root
11
+ # 3. the current directory
12
+ def self.root_for(configuration)
13
+ configuration["root"] || Licensed::Git.repository_root || Dir.pwd
14
+ end
12
15
 
13
16
  def initialize(options = {}, inherited_options = {})
14
17
  super()
15
18
 
16
19
  # update order:
17
20
  # 1. anything inherited from root config
18
- # 2. app defaults
19
- # 3. explicitly configured app settings
21
+ # 2. explicitly configured app settings
20
22
  update(inherited_options)
21
- update(defaults_for(options, inherited_options))
22
23
  update(options)
24
+ verify_arg "source_path"
23
25
 
24
26
  self["sources"] ||= {}
25
27
  self["reviewed"] ||= {}
26
28
  self["ignored"] ||= {}
27
29
  self["allowed"] ||= []
28
-
29
- # default the root to the git repository root,
30
- # or the current directory if no other options are available
31
- self["root"] ||= Licensed::Git.repository_root || Dir.pwd
32
-
33
- verify_arg "source_path"
34
- verify_arg "cache_path"
30
+ self["root"] = AppConfiguration.root_for(self)
31
+ # defaults to the directory name of the source path if not set
32
+ self["name"] ||= File.basename(self["source_path"])
33
+ # setting the cache path might need a valid app name
34
+ self["cache_path"] = detect_cache_path(options, inherited_options)
35
35
  end
36
36
 
37
37
  # Returns the path to the workspace root as a Pathname.
38
- # Defaults to Licensed::Git.repository_root if not explicitly set
39
38
  def root
40
- Pathname.new(self["root"])
39
+ @root ||= Pathname.new(self["root"])
41
40
  end
42
41
 
43
42
  # Returns the path to the app cache directory as a Pathname
@@ -102,13 +101,20 @@ module Licensed
102
101
 
103
102
  private
104
103
 
105
- def defaults_for(options, inherited_options)
106
- name = options["name"] || File.basename(options["source_path"])
107
- cache_path = inherited_options["cache_path"] || DEFAULT_CACHE_PATH
108
- {
109
- "name" => name,
110
- "cache_path" => File.join(cache_path, name)
111
- }
104
+ # Returns the cache path for the application based on:
105
+ # 1. An explicitly set cache path for the application, if set
106
+ # 2. An inherited root cache path joined with the app name
107
+ # 3. The default cache path joined with the app name
108
+ def detect_cache_path(options, inherited_options)
109
+ return options["cache_path"] unless options["cache_path"].to_s.empty?
110
+
111
+ # if cache_path and shared_cache are both set in inherited_options,
112
+ # don't append the app name to the cache path
113
+ cache_path = inherited_options["cache_path"]
114
+ return cache_path if cache_path && inherited_options["shared_cache"] == true
115
+
116
+ cache_path ||= DEFAULT_CACHE_PATH
117
+ File.join(cache_path, self["name"])
112
118
  end
113
119
 
114
120
  def verify_arg(property)
@@ -118,9 +124,18 @@ module Licensed
118
124
  end
119
125
  end
120
126
 
121
- class Configuration < AppConfiguration
127
+ class Configuration
128
+ DEFAULT_CONFIG_FILES = [
129
+ ".licensed.yml".freeze,
130
+ ".licensed.yaml".freeze,
131
+ ".licensed.json".freeze
132
+ ].freeze
133
+
122
134
  class LoadError < StandardError; end
123
135
 
136
+ # An array of the applications in this licensed configuration.
137
+ attr_reader :apps
138
+
124
139
  # Loads and returns a Licensed::Configuration object from the given path.
125
140
  # The path can be relative or absolute, and can point at a file or directory.
126
141
  # If the path given is a directory, the directory will be searched for a
@@ -133,21 +148,41 @@ module Licensed
133
148
 
134
149
  def initialize(options = {})
135
150
  apps = options.delete("apps") || []
136
- super(default_options.merge(options))
137
-
138
- self["apps"] = apps.map { |app| AppConfiguration.new(app, options) }
139
- end
140
-
141
- # Returns an array of the applications for this licensed configuration.
142
- # If the configuration did not explicitly configure any applications,
143
- # return self as an application configuration.
144
- def apps
145
- return [self] if self["apps"].empty?
146
- self["apps"]
151
+ apps << default_options.merge(options) if apps.empty?
152
+ apps = apps.flat_map { |app| self.class.expand_app_source_path(app) }
153
+ @apps = apps.map { |app| AppConfiguration.new(app, options) }
147
154
  end
148
155
 
149
156
  private
150
157
 
158
+ def self.expand_app_source_path(app_config)
159
+ return app_config if app_config["source_path"].to_s.empty?
160
+
161
+ source_path = File.expand_path(app_config["source_path"], AppConfiguration.root_for(app_config))
162
+ expanded_source_paths = Dir.glob(source_path).select { |p| File.directory?(p) }
163
+ # return the original configuration if glob didn't result in multiple paths
164
+ return app_config if expanded_source_paths.size <= 1
165
+
166
+ # map the expanded paths to new application configurations
167
+ expanded_source_paths.map do |path|
168
+ config = app_config.merge("source_path" => path)
169
+
170
+ # update configured values for name and cache_path for uniqueness.
171
+ # this is only needed when values are explicitly set, AppConfiguration
172
+ # will handle configurations that don't have these explicitly set
173
+ dir_name = File.basename(path)
174
+ config["name"] = "#{config["name"]}-#{dir_name}" if config["name"]
175
+
176
+ # if a cache_path is set and is not marked as shared, append the app name
177
+ # to the end of the cache path to make a unique cache path for the app
178
+ if config["cache_path"] && config["shared_cache"] != true
179
+ config["cache_path"] = File.join(config["cache_path"], dir_name)
180
+ end
181
+
182
+ config
183
+ end
184
+ end
185
+
151
186
  # Find a default configuration file in the given directory.
152
187
  # File preference is given by the order of elements in DEFAULT_CONFIG_FILES
153
188
  #
@@ -198,7 +233,7 @@ module Licensed
198
233
  # manually set a cache path without additional name
199
234
  {
200
235
  "source_path" => Dir.pwd,
201
- "cache_path" => DEFAULT_CACHE_PATH
236
+ "cache_path" => AppConfiguration::DEFAULT_CACHE_PATH
202
237
  }
203
238
  end
204
239
  end