Skip to content
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

Add Package Manager for GitHub Actions #11043

Merged
merged 5 commits into from
Jan 2, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions common/lib/dependabot/ecosystem.rb
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,22 @@ def initialize(
sig { returns(T.nilable(Dependabot::Requirement)) }
attr_reader :requirement

# The version of the package manager or language as a string.
# @example
# version_to_s #=> "2.1"
sig { returns(String) }
def version_to_s
version.to_s
end

# The raw version of the package manager or language.
# @example
# raw_version #=> "2.1.4"
sig { returns(String) }
def version_to_raw_s
version&.to_semver.to_s
end

# Checks if the current version is deprecated.
# Returns true if the version is in the deprecated_versions array; false otherwise.
# @example
Expand Down
2 changes: 2 additions & 0 deletions github_actions/lib/dependabot/github_actions.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,15 @@

# These all need to be required so the various classes can be registered in a
# lookup table of package manager names to concrete classes.
require "dependabot/github_actions/constants"
require "dependabot/github_actions/file_fetcher"
require "dependabot/github_actions/file_parser"
require "dependabot/github_actions/update_checker"
require "dependabot/github_actions/file_updater"
require "dependabot/github_actions/metadata_finder"
require "dependabot/github_actions/requirement"
require "dependabot/github_actions/version"
require "dependabot/github_actions/package_manager"

require "dependabot/pull_request_creator/labeler"
Dependabot::PullRequestCreator::Labeler
Expand Down
44 changes: 44 additions & 0 deletions github_actions/lib/dependabot/github_actions/constants.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# typed: strong
# frozen_string_literal: true

module Dependabot
module GithubActions
# Reference to the GitHub.com domain
GITHUB_COM = T.let("github.com", String)

# Regular expression to match a GitHub repository reference
GITHUB_REPO_REFERENCE = T.let(%r{
^(?<owner>[\w.-]+)/
(?<repo>[\w.-]+)
(?<path>/[^\@]+)?
@(?<ref>.+)
}x, Regexp)

# Matches .yml or .yaml files in the .github/workflows directories
WORKFLOW_YAML_REGEX = %r{\.github/workflows/.+\.ya?ml$}
# Matches .yml or .yaml files anywhere
ALL_YAML_FILES = %r{(?:^|/).+\.ya?ml$}

# The ecosystem name for GitHub Actions
ECOSYSTEM = T.let("github_actions", String)

# The pattern to match manifest files
MANIFEST_FILE_PATTERN = /\.ya?ml$/
# The name of the manifest file
MANIFEST_FILE_YML = T.let("action.yml", String)
# The name of the manifest file
MANIFEST_FILE_YAML = T.let("action.yaml", String)
# The pattern to match any .yml or .yaml file
ANYTHING_YML = T.let("<anything>.yml", String)
# The path to the workflow directory
WORKFLOW_DIRECTORY = T.let(".github/workflows", String)
# The path to the config .yml file
CONFIG_YMLS = T.let("#{WORKFLOW_DIRECTORY}/#{ANYTHING_YML}".freeze, String)

OWNER_KEY = T.let("owner", String)
REPO_KEY = T.let("repo", String)
REF_KEY = T.let("ref", String)
USES_KEY = T.let("uses", String)
STEPS_KEY = T.let("steps", String)
end
end
18 changes: 10 additions & 8 deletions github_actions/lib/dependabot/github_actions/file_fetcher.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,17 @@

require "dependabot/file_fetchers"
require "dependabot/file_fetchers/base"
require "dependabot/github_actions/constants"

module Dependabot
module GithubActions
class FileFetcher < Dependabot::FileFetchers::Base
extend T::Sig
extend T::Helpers

FILENAME_PATTERN = /\.ya?ml$/
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just moved into constants. Here we are looking for all yml, yaml files.


sig { override.params(filenames: T::Array[String]).returns(T::Boolean) }
def self.required_files_in?(filenames)
filenames.any? { |f| f.match?(FILENAME_PATTERN) }
filenames.any? { |f| f.match?(MANIFEST_FILE_PATTERN) }
end

sig { override.returns(String) }
Expand Down Expand Up @@ -49,9 +48,9 @@ def fetch_files
if incorrectly_encoded_workflow_files.none?
expected_paths =
if directory == "/"
File.join(directory, "action.yml") + " or /.github/workflows/<anything>.yml"
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here we were looking only for yml files

File.join(directory, MANIFEST_FILE_YML) + " or /#{CONFIG_YMLS}"
else
File.join(directory, "<anything>.yml")
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here we are looking only for yml files

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this is actually looking for files, more like providing examples...

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, that's correct.

File.join(directory, ANYTHING_YML)
end

raise(
Expand All @@ -75,16 +74,19 @@ def workflow_files
# In the special case where the root directory is defined we also scan
# the .github/workflows/ folder.
if directory == "/"
@workflow_files += [fetch_file_if_present("action.yml"), fetch_file_if_present("action.yaml")].compact
@workflow_files += [
fetch_file_if_present(MANIFEST_FILE_YML),
fetch_file_if_present(MANIFEST_FILE_YAML)
].compact

workflows_dir = ".github/workflows"
workflows_dir = WORKFLOW_DIRECTORY
else
workflows_dir = "."
end

@workflow_files +=
repo_contents(dir: workflows_dir, raise_errors: false)
.select { |f| f.type == "file" && f.name.match?(FILENAME_PATTERN) }
.select { |f| f.type == "file" && f.name.match?(MANIFEST_FILE_PATTERN) }
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here we were looking for yml and yaml files

.map { |f| fetch_file_from_host("#{workflows_dir}/#{f.name}") }
end

Expand Down
43 changes: 27 additions & 16 deletions github_actions/lib/dependabot/github_actions/file_parser.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@
require "dependabot/errors"
require "dependabot/file_parsers"
require "dependabot/file_parsers/base"
require "dependabot/github_actions/constants"
require "dependabot/github_actions/version"
require "dependabot/github_actions/package_manager"

# For docs, see
# https://help.github.com/en/articles/configuring-a-workflow#referencing-actions-in-your-workflow
Expand All @@ -20,13 +22,6 @@ class FileParser < Dependabot::FileParsers::Base

require "dependabot/file_parsers/base/dependency_set"

GITHUB_REPO_REFERENCE = %r{
^(?<owner>[\w.-]+)/
(?<repo>[\w.-]+)
(?<path>/[^\@]+)?
@(?<ref>.+)
}x

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just moved into PackageManager as a constant

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TBH, this one feels even more generic, like it could be a top-level constant in utils or something. Package manager is fine too, just highlighting this is super generic.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I generally try to add constants into package managers that will provide general information. But I can create another module that used for constants in ecosystem such as constants.rb

sig { override.returns(T::Array[Dependabot::Dependency]) }
def parse
dependency_set = DependencySet.new
Expand All @@ -44,8 +39,24 @@ def parse
dependency_set.dependencies
end

sig { returns(Ecosystem) }
def ecosystem
@ecosystem ||= T.let(
Ecosystem.new(
name: ECOSYSTEM,
package_manager: package_manager
),
T.nilable(Ecosystem)
)
end

private

sig { returns(Ecosystem::VersionManager) }
def package_manager
@package_manager ||= T.let(PackageManager.new, T.nilable(Dependabot::GithubActions::PackageManager))
end
Copy link
Contributor Author

@kbukum1 kbukum1 Dec 2, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review Tip: The GitHub Actions version is extracted from uses declarations in workflow files (e.g., actions/checkout@v2.3.4 captures v2.3.4). If no version is found, it defaults to 0.0.0, ensuring the PackageManager is initialized with a valid version for proper dependency management.

Copy link
Member

@jakecoffman jakecoffman Dec 13, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This doesn't make sense to me. GitHub Actions doesn't have a package manager. This is extracting dependency information and setting it as the PackageManager version?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh good catch Jake.

I was assuming this PR was creating a generic package manager and then extracting the dependency and setting the name/version on the dependency.

Creating a pseudo package manager for GitHub Actions so that we could code generic functionality across all package managers in core makes sense to me. But I agree with Jake that the version you're extracting shouldn't be tied to the package manager, but rather the dependency, which is updated by the package manager.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see your point. In this case it may be better to make package manager version not required since we don't have package manager and add another field to share this information that will show in metrics.


sig { params(file: Dependabot::DependencyFile).returns(Dependabot::FileParsers::Base::DependencySet) }
def workfile_file_dependencies(file)
dependency_set = DependencySet.new
Expand Down Expand Up @@ -94,20 +105,20 @@ def workfile_file_dependencies(file)

sig { params(file: Dependabot::DependencyFile, string: String).returns(Dependabot::Dependency) }
def build_github_dependency(file, string)
unless source&.hostname == "github.com"
unless source&.hostname == GITHUB_COM
dep = github_dependency(file, string, T.must(source).hostname)
git_checker = Dependabot::GitCommitChecker.new(dependency: dep, credentials: credentials)
return dep if git_checker.git_repo_reachable?
end

github_dependency(file, string, "github.com")
github_dependency(file, string, GITHUB_COM)
end

sig { params(file: Dependabot::DependencyFile, string: String, hostname: String).returns(Dependabot::Dependency) }
def github_dependency(file, string, hostname)
details = T.must(string.match(GITHUB_REPO_REFERENCE)).named_captures
name = "#{details.fetch('owner')}/#{details.fetch('repo')}"
ref = details.fetch("ref")
name = "#{details.fetch(OWNER_KEY)}/#{details.fetch(REPO_KEY)}"
ref = details.fetch(REF_KEY)
version = version_class.new(ref).to_s if version_class.correct?(ref)
Dependency.new(
name: name,
Expand All @@ -124,7 +135,7 @@ def github_dependency(file, string, hostname)
file: file.name,
metadata: { declaration_string: string }
}],
package_manager: "github_actions"
package_manager: PackageManager::NAME
)
end

Expand All @@ -139,11 +150,11 @@ def deep_fetch_uses(json_obj, found_uses = [])

sig { params(json_object: T::Hash[String, T.untyped], found_uses: T::Array[String]).returns(T::Array[String]) }
def deep_fetch_uses_from_hash(json_object, found_uses)
if json_object.key?("uses")
found_uses << json_object["uses"]
elsif json_object.key?("steps")
if json_object.key?(USES_KEY)
found_uses << json_object[USES_KEY]
elsif json_object.key?(STEPS_KEY)
# Bypass other fields as uses are under steps if they exist
deep_fetch_uses(json_object["steps"], found_uses)
deep_fetch_uses(json_object[STEPS_KEY], found_uses)
else
json_object.values.flat_map { |obj| deep_fetch_uses(obj, found_uses) }
end
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
require "dependabot/errors"
require "dependabot/file_updaters"
require "dependabot/file_updaters/base"
require "dependabot/github_actions/constants"

module Dependabot
module GithubActions
Expand All @@ -16,10 +17,10 @@ class FileUpdater < Dependabot::FileUpdaters::Base
def self.updated_files_regex
[
# Matches .yml or .yaml files in the .github/workflows directories
%r{\.github/workflows/.+\.ya?ml$},
WORKFLOW_YAML_REGEX,

# Matches .yml or .yaml files in the root directory or any subdirectory
%r{(?:^|/).+\.ya?ml$}
ALL_YAML_FILES
]
end

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
# frozen_string_literal: true

require "sorbet-runtime"

require "dependabot/github_actions/constants"
require "dependabot/metadata_finders"
require "dependabot/metadata_finders/base"

Expand All @@ -19,7 +19,7 @@ def look_up_source

url =
if info.nil?
"https://github.com/#{dependency.name}"
"https://#{GITHUB_COM}/#{dependency.name}"
else
info[:url] || info.fetch("url")
end
Expand Down
40 changes: 40 additions & 0 deletions github_actions/lib/dependabot/github_actions/package_manager.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# typed: strong
# frozen_string_literal: true

require "sorbet-runtime"
require "dependabot/github_actions/constants"
require "dependabot/github_actions/version"
require "dependabot/ecosystem"
require "dependabot/github_actions/requirement"

module Dependabot
module GithubActions
class PackageManager < Dependabot::Ecosystem::VersionManager
extend T::Sig

# The package manager name for GitHub Actions
NAME = T.let("github_actions", String)

# The version of the package manager
VERSION = T.let("1.0.0", String)

sig { void }
def initialize
super(
name: NAME,
version: Version.new(VERSION)
)
end

sig { override.returns(T::Boolean) }
def deprecated?
false
end

sig { override.returns(T::Boolean) }
def unsupported?
false
end
end
end
end
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
require "sorbet-runtime"

require "dependabot/errors"
require "dependabot/github_actions/constants"
require "dependabot/github_actions/requirement"
require "dependabot/github_actions/version"
require "dependabot/update_checkers"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -582,4 +582,15 @@ def mock_service_pack_request(nwo)
end
end
end

describe "#ecosystem" do
it "returns the correct ecosystem" do
expect(parser.ecosystem).to be_a(Dependabot::Ecosystem)
end

it "returns package manager with version" do
expect(parser.ecosystem.package_manager).to be_a(Dependabot::GithubActions::PackageManager)
expect(parser.ecosystem.package_manager.version.to_s).to eq("1.0.0")
end
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# typed: false
# frozen_string_literal: true

require "dependabot/github_actions/package_manager"
require "dependabot/ecosystem"
require "spec_helper"

RSpec.describe Dependabot::GithubActions::PackageManager do
let(:package_manager) { described_class.new }

describe "#version_to_s" do
it "returns the package manager version empty" do
expect(package_manager.version_to_s).to eq("1.0.0")
end
end

describe "#version_to_raw_s" do
it "returns the package manager raw version empty" do
expect(package_manager.version_to_raw_s).to eq("1.0.0")
end
end

describe "#deprecated?" do
it "returns always false" do
expect(package_manager.deprecated?).to be false
end
end

describe "#unsupported?" do
it "returns always false" do
expect(package_manager.unsupported?).to be false
end
end
end
6 changes: 3 additions & 3 deletions updater/lib/dependabot/api_client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -330,13 +330,13 @@ def record_ecosystem_meta(ecosystem)
def version_manager_json(version_manager)
return nil unless version_manager

raw_version = version_manager.version&.to_semver.to_s
version = version_manager.version&.to_semver.to_s
version = version_manager.version_to_s
raw_version = version_manager.version_to_raw_s

{
name: version_manager.name,
raw_version: raw_version.empty? ? "N/A" : raw_version,
version: version.empty? ? "N/A" : version,
raw_version: raw_version.empty? ? "N/A" : raw_version,
requirement: version_manager_requirement_json(version_manager)
}
end
Expand Down
Loading
Loading