licensed 2.8.0 → 2.11.0

Sign up to get free protection for your applications and to get access to all the features.
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