From 4afde00c8827cbdfa9acd4337a5f36270ec63f7d Mon Sep 17 00:00:00 2001 From: Jeff Martin Date: Thu, 8 Oct 2026 13:02:25 -0700 Subject: [PATCH 1/5] github_actions: TEMP build gh-actions-lock c5ee15d for staging Builds github/gh-actions-lock#137 at c5ee15d from source; no release exists for this SHA. Swap back to a release pin before review. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- github_actions/Dockerfile | 20 ++++++-------------- 1 file changed, 6 insertions(+), 14 deletions(-) diff --git a/github_actions/Dockerfile b/github_actions/Dockerfile index d573d4110a5..350f5049d27 100644 --- a/github_actions/Dockerfile +++ b/github_actions/Dockerfile @@ -1,20 +1,12 @@ # syntax=docker.io/docker/dockerfile:1.20 -FROM ghcr.io/dependabot/dependabot-updater-core +FROM docker.io/library/golang:1.27.0-bookworm AS gh-actions-lock +RUN CGO_ENABLED=0 GOBIN=/out go install \ + github.com/github/gh-actions-lock/cmd/gh-actions-lock@c5ee15d8f20e3e52d6671851192770b9a2e02152 -ARG TARGETARCH -ARG GH_ACTIONS_LOCK_VERSION=v0.1.6 -ARG GH_ACTIONS_LOCK_AMD64_CHECKSUM=4181ec1da5408b34b9a542a7ee5c6ce3a4d6ac815c7d0206a00ceca8a817f4e3 -ARG GH_ACTIONS_LOCK_ARM64_CHECKSUM=31c99e586f8f5fa2607ea79af74e3b50bc7ed4be1580a0d8449cdd73d1aba5e4 +FROM ghcr.io/dependabot/dependabot-updater-core -RUN mkdir -p /opt/github_actions/bin \ - && curl -fsSL -o /opt/github_actions/bin/gh-actions-lock \ - "https://github.com/github/gh-actions-lock/releases/download/${GH_ACTIONS_LOCK_VERSION}/linux-${TARGETARCH}" \ - && case "${TARGETARCH}" in \ - amd64) echo "${GH_ACTIONS_LOCK_AMD64_CHECKSUM} /opt/github_actions/bin/gh-actions-lock" | sha256sum -c - ;; \ - arm64) echo "${GH_ACTIONS_LOCK_ARM64_CHECKSUM} /opt/github_actions/bin/gh-actions-lock" | sha256sum -c - ;; \ - *) echo "Unsupported architecture: ${TARGETARCH}" && exit 1 ;; \ - esac \ - && chmod +x /opt/github_actions/bin/gh-actions-lock +# TEMP(staging): gh-actions-lock built from github/gh-actions-lock#137; swap back to a release pin before review. +COPY --from=gh-actions-lock /out/gh-actions-lock /opt/github_actions/bin/gh-actions-lock USER dependabot From 4f59af2c79978dd8d9bdedd0817f4a92b1339626 Mon Sep 17 00:00:00 2001 From: Jeff Martin Date: Thu, 8 Oct 2026 13:11:48 -0700 Subject: [PATCH 2/5] github_actions: pass home hostname to gh-actions-lock The engine binds every pin with an omitted lockfile hostname to its home host. It runs in a temp dir with no git remote, so without --hostname the home host silently defaults to github.com. Pass --hostname from the job source and forward only a home-host token. Accept the v0.0.3 schema the engine now writes, and report lock host-identity failures as user-actionable rather than engine errors. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../dependabot/github_actions/constants.rb | 5 ++-- .../dependabot/github_actions/file_updater.rb | 8 ++++- .../github_actions/lockfile/cli_engine.rb | 20 ++++++++++--- .../dependabot/github_actions/lockfile/env.rb | 16 +++++----- .../github_actions/lockfile/errors.rb | 2 +- .../github_actions/lockfile/version_gate.rb | 6 ++-- .../github_actions/file_updater_spec.rb | 18 +++++++++++ .../lockfile/cli_engine_spec.rb | 30 +++++++++++++++++++ .../github_actions/lockfile/env_spec.rb | 20 +++++++++++++ .../lockfile/version_gate_spec.rb | 5 ++-- .../dependabot/dependency_change_builder.rb | 1 + .../dependency_change_builder_spec.rb | 23 ++++++++++++++ 12 files changed, 134 insertions(+), 20 deletions(-) diff --git a/github_actions/lib/dependabot/github_actions/constants.rb b/github_actions/lib/dependabot/github_actions/constants.rb index d07c0d4dd9c..334dddcad67 100644 --- a/github_actions/lib/dependabot/github_actions/constants.rb +++ b/github_actions/lib/dependabot/github_actions/constants.rb @@ -34,8 +34,9 @@ module GithubActions LOCKFILE_NAME = "actions.lock" # The repo-relative path to the Actions lockfile LOCKFILE_PATH = T.let("#{WORKFLOW_DIRECTORY}/#{LOCKFILE_NAME}".freeze, String) - # The only lockfile schema version this ecosystem understands. - SUPPORTED_LOCKFILE_VERSION = "v0.0.2" + # Lockfile schema versions this ecosystem understands. gh-actions-lock migrates + # v0.0.2 input and always writes v0.0.3. + SUPPORTED_LOCKFILE_VERSIONS = %w(v0.0.2 v0.0.3).freeze OWNER_KEY = "owner" REPO_KEY = "repo" diff --git a/github_actions/lib/dependabot/github_actions/file_updater.rb b/github_actions/lib/dependabot/github_actions/file_updater.rb index d1e42223b45..8011e866598 100644 --- a/github_actions/lib/dependabot/github_actions/file_updater.rb +++ b/github_actions/lib/dependabot/github_actions/file_updater.rb @@ -82,7 +82,7 @@ def relocked_files(updated_workflow_files) # Materialize the full onboarded closure so the lock remains intact, but fix # only changed workflows so unrelated refs are not touched. - content = Lockfile::CliEngine.new(credentials).relock( + content = Lockfile::CliEngine.new(credentials, hostname: source_hostname).relock( workflow_files: rewritten_onboarded_workflow_files(reader, updated_workflow_files), lockfile: lock, workflow_paths: changed_onboarded.map { |file| repo_relative_path(file) } @@ -114,6 +114,12 @@ def onboarded_workflow_files(reader) .select { |f| reader.onboarded?(repo_relative_path(f)) } end + # The repository's home host, plumbed from the job source by the updater. + sig { returns(String) } + def source_hostname + T.cast(options.fetch(:source_hostname, GITHUB_COM), String) + end + sig { returns(T.nilable(Dependabot::DependencyFile)) } def lockfile dependency_files.find { |f| lockfile?(f) } diff --git a/github_actions/lib/dependabot/github_actions/lockfile/cli_engine.rb b/github_actions/lib/dependabot/github_actions/lockfile/cli_engine.rb index de03f2db8d1..ea758b848a0 100644 --- a/github_actions/lib/dependabot/github_actions/lockfile/cli_engine.rb +++ b/github_actions/lib/dependabot/github_actions/lockfile/cli_engine.rb @@ -27,9 +27,16 @@ class CliEngine FIXED_FINDING_CATEGORIES = %w(onboarding-required ref-changed stale).freeze UNRESOLVABLE_CATEGORIES = %w(impostor-commit lockfile-forgery).freeze - sig { params(credentials: T::Array[Dependabot::Credential]).void } - def initialize(credentials) + # Host-identity failures from the engine: the lock is bound to a host it can't + # be verified on. User-actionable (restore/regenerate), never retryable. + HOST_IDENTITY_ERROR = /verifying repository identity|repository IDs|not the selected host/ + + # `hostname` is the repository's home host. The engine binds every pin with an + # omitted lockfile `hostname` to it, so it must never be guessed. + sig { params(credentials: T::Array[Dependabot::Credential], hostname: String).void } + def initialize(credentials, hostname: GITHUB_COM) @credentials = credentials + @hostname = hostname end # Binary baked into the ecosystem image; falls back to PATH for local dev. @@ -51,7 +58,8 @@ def self.binary_path end def relock(workflow_files:, lockfile:, workflow_paths: workflow_files.map { |file| repo_path(file) }) in_repo(workflow_files, lockfile) do |dir| - args = %w(--no-onboard --no-narrow --no-interactive --json=findings) + workflow_paths + args = %w(--no-onboard --no-narrow --no-interactive --json=findings) + + ["--hostname", hostname] + workflow_paths json, exit_status = run(dir, args) skipped = onboarding_skips(json, lockfile) @@ -70,6 +78,9 @@ def relock(workflow_files:, lockfile:, workflow_paths: workflow_files.map { |fil sig { returns(T::Array[Dependabot::Credential]) } attr_reader :credentials + sig { returns(String) } + attr_reader :hostname + sig do type_parameters(:T) .params( @@ -104,6 +115,7 @@ def repo_path(file) sig { params(dir: String, args: T::Array[String]).returns([JsonObject, Integer]) } def run(dir, args) stdout, stderr, exit_status = invoke(dir, args) + raise DependencyFileNotResolvable, stderr.strip if exit_status > 1 && stderr.match?(HOST_IDENTITY_ERROR) raise EngineError, "gh-actions-lock failed (exit #{exit_status}): #{stderr.strip}" if exit_status > 1 json = case (parsed = JSON.parse(stdout)) @@ -120,7 +132,7 @@ def run(dir, args) # [stdout, stderr, exit_status]. sig { params(dir: String, args: T::Array[String]).returns([String, String, Integer]) } def invoke(dir, args) - env_cmd = [Env.build(credentials), self.class.binary_path, *args, { chdir: dir }] + env_cmd = [Env.build(credentials, hostname), self.class.binary_path, *args, { chdir: dir }] stdout, stderr, process = CommandHelpers.capture3_with_timeout(env_cmd) # A failed spawn comes back as a nil status, not an exception. diff --git a/github_actions/lib/dependabot/github_actions/lockfile/env.rb b/github_actions/lib/dependabot/github_actions/lockfile/env.rb index 56f2f7ea03f..be4cb46eb4f 100644 --- a/github_actions/lib/dependabot/github_actions/lockfile/env.rb +++ b/github_actions/lib/dependabot/github_actions/lockfile/env.rb @@ -11,7 +11,9 @@ module GithubActions module Lockfile # Builds the subprocess environment for the gh-actions-lock engine. Hosted # Dependabot is tokenless behind a MITM proxy that overwrites the auth header, - # while proxyless local runs hold the real github.com token in `credentials`. + # while proxyless local runs hold the real home-host token in `credentials`. + # Only a home-host `git_source` token is ever used; the engine reads other + # hosts (dotcom fallback) anonymously. module Env extend T::Sig @@ -20,13 +22,13 @@ module Env DUMMY_TOKEN = "x-access-token" sig do - params(credentials: T::Array[Dependabot::Credential]) + params(credentials: T::Array[Dependabot::Credential], hostname: String) .returns(T::Hash[String, String]) end - def self.build(credentials) + def self.build(credentials, hostname = GITHUB_COM) env = {} - github_credential = github_dot_com_credential(credentials) + github_credential = home_credential(credentials, hostname) env["GH_TOKEN"] = github_credential&.fetch("password", nil) || DUMMY_TOKEN env["GH_ACTIONS_LOCK_DEPENDABOT_PROXY"] = "1" unless github_credential @@ -36,12 +38,12 @@ def self.build(credentials) # Mirrors SharedHelpers.configure_git_to_use_https_with_credentials: prefer a # deliberately-added token over an app installation token ("v1." prefix). sig do - params(credentials: T::Array[Dependabot::Credential]) + params(credentials: T::Array[Dependabot::Credential], hostname: String) .returns(T.nilable(Dependabot::Credential)) end - def self.github_dot_com_credential(credentials) + def self.home_credential(credentials, hostname) candidates = credentials.select do |c| - c["type"] == "git_source" && c["host"] == GITHUB_COM && c["password"] + c["type"] == "git_source" && c["host"] == hostname && c["password"] end candidates.find { |c| !c["password"]&.start_with?("v1.") } || candidates.first diff --git a/github_actions/lib/dependabot/github_actions/lockfile/errors.rb b/github_actions/lib/dependabot/github_actions/lockfile/errors.rb index 1f67b5af6f0..b60d0507b9b 100644 --- a/github_actions/lib/dependabot/github_actions/lockfile/errors.rb +++ b/github_actions/lib/dependabot/github_actions/lockfile/errors.rb @@ -23,7 +23,7 @@ def initialize(found, supported) super( LOCKFILE_PATH, "Unsupported actions.lock version #{found.inspect}; " \ - "this version of Dependabot supports #{supported.inspect}. " \ + "this version of Dependabot supports #{supported}. " \ "Upgrade Dependabot or regenerate the lockfile with a compatible gh-actions-lock version." ) end diff --git a/github_actions/lib/dependabot/github_actions/lockfile/version_gate.rb b/github_actions/lib/dependabot/github_actions/lockfile/version_gate.rb index 0dbb5d38592..c5dd55f8ec3 100644 --- a/github_actions/lib/dependabot/github_actions/lockfile/version_gate.rb +++ b/github_actions/lib/dependabot/github_actions/lockfile/version_gate.rb @@ -10,7 +10,7 @@ module Dependabot module GithubActions module Lockfile # Pre-1.0 lockfile schema revisions may be breaking, so only the explicitly - # supported version is safe to read and rewrite. + # supported versions are safe to read and rewrite. module VersionGate extend T::Sig @@ -18,12 +18,12 @@ module VersionGate def self.assert_supported!(found) return if compatible?(found) - raise UnsupportedLockfileVersion.new(found, SUPPORTED_LOCKFILE_VERSION) + raise UnsupportedLockfileVersion.new(found, SUPPORTED_LOCKFILE_VERSIONS.join(", ")) end sig { params(found: String).returns(T::Boolean) } def self.compatible?(found) - found == SUPPORTED_LOCKFILE_VERSION + SUPPORTED_LOCKFILE_VERSIONS.include?(found) end end end diff --git a/github_actions/spec/dependabot/github_actions/file_updater_spec.rb b/github_actions/spec/dependabot/github_actions/file_updater_spec.rb index b54b14d8828..85f55e29b05 100644 --- a/github_actions/spec/dependabot/github_actions/file_updater_spec.rb +++ b/github_actions/spec/dependabot/github_actions/file_updater_spec.rb @@ -2145,6 +2145,24 @@ def flow_requirement(name, ref, path) end end + context "with a ghe.com home host" do + let(:updater) do + described_class.new( + dependency_files: files, + dependencies: [dependency], + credentials: credentials, + options: { source_hostname: "tenant.ghe.com" } + ) + end + + it "relocks against the home host" do + updated_files + + expect(Dependabot::GithubActions::Lockfile::CliEngine) + .to have_received(:new).with(credentials, hostname: "tenant.ghe.com") + end + end + context "when the workflow is converted from a tag to a SHA" do let(:sha) { "5273d0df9c603edc4284ac8402cf650b4f1f6686" } let(:workflow_file_body) do diff --git a/github_actions/spec/dependabot/github_actions/lockfile/cli_engine_spec.rb b/github_actions/spec/dependabot/github_actions/lockfile/cli_engine_spec.rb index c4b1819de9d..8b9564788ea 100644 --- a/github_actions/spec/dependabot/github_actions/lockfile/cli_engine_spec.rb +++ b/github_actions/spec/dependabot/github_actions/lockfile/cli_engine_spec.rb @@ -52,6 +52,36 @@ def stub_subprocess(stdout:, exitstatus:, stderr: "Scanning 1 workflow\nResolvin end end + describe "#relock home host" do + subject(:engine) { described_class.new([], hostname: "tenant.ghe.com") } + + before { stub_subprocess(stdout: "{}", exitstatus: 0, stderr: "") } + + it "always passes --hostname to the engine" do + engine.relock(workflow_files: [workflow], lockfile: lockfile) + + expect(Dependabot::CommandHelpers).to have_received(:capture3_with_timeout) + .with([hash_including("GH_ACTIONS_LOCK_DEPENDABOT_PROXY" => "1"), anything, + "--no-onboard", "--no-narrow", "--no-interactive", "--json=findings", + "--hostname", "tenant.ghe.com", ".github/workflows/ci.yml", anything]) + end + end + + describe "#relock when the engine cannot verify a pin's host identity" do + before do + stub_subprocess( + stdout: "", + exitstatus: 2, + stderr: "verifying repository identity for actions/checkout on tenant.ghe.com: 404\n" + ) + end + + it "raises a user-actionable DependencyFileNotResolvable, not EngineError" do + expect { engine.relock(workflow_files: [workflow], lockfile: lockfile) } + .to raise_error(Dependabot::DependencyFileNotResolvable, /verifying repository identity/) + end + end + describe "#relock when stdout is valid non-object JSON" do before { stub_subprocess(stdout: "[]", exitstatus: 0, stderr: "") } diff --git a/github_actions/spec/dependabot/github_actions/lockfile/env_spec.rb b/github_actions/spec/dependabot/github_actions/lockfile/env_spec.rb index d3dd39290f8..9c36db19d03 100644 --- a/github_actions/spec/dependabot/github_actions/lockfile/env_spec.rb +++ b/github_actions/spec/dependabot/github_actions/lockfile/env_spec.rb @@ -46,6 +46,26 @@ def cred(hash) end end + context "with a ghe.com home host" do + let(:credentials) do + [ + cred({ "type" => "git_source", "host" => "github.com", "password" => "dotcom-token" }), + cred({ "type" => "git_source", "host" => "tenant.ghe.com", "password" => "tenant-token" }) + ] + end + + it "uses only the home-host token" do + expect(described_class.build(credentials, "tenant.ghe.com")).to eq("GH_TOKEN" => "tenant-token") + end + + it "never forwards a dotcom token when the home host has none" do + expect(described_class.build(credentials.first(1), "tenant.ghe.com")).to eq( + "GH_TOKEN" => "x-access-token", + "GH_ACTIONS_LOCK_DEPENDABOT_PROXY" => "1" + ) + end + end + context "with a non-GitHub git credential" do let(:credentials) do [cred({ "type" => "git_source", "host" => "gitlab.com", "password" => "gitlab-token" })] diff --git a/github_actions/spec/dependabot/github_actions/lockfile/version_gate_spec.rb b/github_actions/spec/dependabot/github_actions/lockfile/version_gate_spec.rb index 1c3c0c3dc4f..1bfe0ff0f6e 100644 --- a/github_actions/spec/dependabot/github_actions/lockfile/version_gate_spec.rb +++ b/github_actions/spec/dependabot/github_actions/lockfile/version_gate_spec.rb @@ -6,8 +6,9 @@ RSpec.describe Dependabot::GithubActions::Lockfile::VersionGate do describe ".compatible?" do - it "accepts the supported version" do + it "accepts the supported versions" do expect(described_class.compatible?("v0.0.2")).to be(true) + expect(described_class.compatible?("v0.0.3")).to be(true) end it "rejects the previous schema" do @@ -15,7 +16,7 @@ end it "rejects a newer schema" do - expect(described_class.compatible?("v0.0.3")).to be(false) + expect(described_class.compatible?("v0.0.4")).to be(false) end it "rejects blank input" do diff --git a/updater/lib/dependabot/dependency_change_builder.rb b/updater/lib/dependabot/dependency_change_builder.rb index 9e8d49bb166..b522acbab9b 100644 --- a/updater/lib/dependabot/dependency_change_builder.rb +++ b/updater/lib/dependabot/dependency_change_builder.rb @@ -250,6 +250,7 @@ def file_updater_for(dependencies) credentials: job.credentials, options: job.experiments.merge( security_updates_only: job.security_updates_only?, + source_hostname: job.source.hostname, update_cooldown: job.security_updates_only? ? nil : job.cooldown ) ) diff --git a/updater/spec/dependabot/dependency_change_builder_spec.rb b/updater/spec/dependabot/dependency_change_builder_spec.rb index 9983fb03b55..0bcf1788d86 100644 --- a/updater/spec/dependabot/dependency_change_builder_spec.rb +++ b/updater/spec/dependabot/dependency_change_builder_spec.rb @@ -163,6 +163,29 @@ def dependency_group_source end end + context "when the file updater needs the home host" do + let(:change_source) { lead_dependency_change_source } + let(:source) do + Dependabot::Source.new( + provider: "github", + repo: "gocardless/bump", + directory: "/.", + hostname: "tenant.ghe.com", + api_endpoint: "https://api.tenant.ghe.com/" + ) + end + + before { stub_file_updater(updated_dependency_files: dependency_files.reject(&:support_file?)) } + + it "passes the source hostname in options" do + create_change + + expect(file_updater_class).to have_received(:new).with( + hash_including(options: hash_including(source_hostname: "tenant.ghe.com")) + ) + end + end + context "when the source is a lead dependency" do let(:change_source) { lead_dependency_change_source } From 9b6de3b0f5082d93ce5d4333f03ba21deffd9f69 Mon Sep 17 00:00:00 2001 From: Jeff Martin Date: Thu, 8 Oct 2026 13:11:49 -0700 Subject: [PATCH 3/5] github_actions: allow ghe.com lockfiles Fetch actions.lock for *.ghe.com sources under the existing github_actions_lockfile experiment, same as github.com. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../dependabot/github_actions/file_fetcher.rb | 2 +- .../github_actions/file_fetcher_spec.rb | 24 +++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/github_actions/lib/dependabot/github_actions/file_fetcher.rb b/github_actions/lib/dependabot/github_actions/file_fetcher.rb index b3f04a51894..c278c7331e7 100644 --- a/github_actions/lib/dependabot/github_actions/file_fetcher.rb +++ b/github_actions/lib/dependabot/github_actions/file_fetcher.rb @@ -81,7 +81,7 @@ def fetch_files sig { returns(T.nilable(DependencyFile)) } def actions_lockfile return unless Dependabot::Experiments.enabled?(:github_actions_lockfile) - return unless source.hostname == GITHUB_COM + return unless source.hostname == GITHUB_COM || source.hostname.end_with?(".ghe.com") return fetch_file_if_present(LOCKFILE_PATH) if directory == "/" fetch_file_if_present(LOCKFILE_NAME) if directory.delete_prefix("/") == WORKFLOW_DIRECTORY diff --git a/github_actions/spec/dependabot/github_actions/file_fetcher_spec.rb b/github_actions/spec/dependabot/github_actions/file_fetcher_spec.rb index d82263bffc3..7acf659709e 100644 --- a/github_actions/spec/dependabot/github_actions/file_fetcher_spec.rb +++ b/github_actions/spec/dependabot/github_actions/file_fetcher_spec.rb @@ -189,6 +189,30 @@ expect(a_request(:get, url + ".github/workflows/actions.lock?ref=sha")).not_to have_been_made end end + + context "with a ghe.com source" do + let(:credentials) do + [{ + "type" => "git_source", + "host" => "tenant.ghe.com", + "username" => "x-access-token", + "password" => "token" + }] + end + let(:source) do + Dependabot::Source.new( + provider: "github", + repo: "gocardless/bump", + directory: directory, + hostname: "tenant.ghe.com", + api_endpoint: github_url + ) + end + + it "fetches the lockfile" do + expect(file_fetcher_instance.files.map(&:name)).to include(".github/workflows/actions.lock") + end + end end context "when it has an invalid encoding" do From 64c165cb93607b2795cc093c4c3ccee55fda2432 Mon Sep 17 00:00:00 2001 From: Jeff Martin Date: Fri, 9 Oct 2026 14:32:01 -0700 Subject: [PATCH 4/5] github_actions: only treat 404 identity lookups as unresolvable gh-actions-lock wraps every RepoIDs error in 'verifying repository identity', so 5xx, rate-limit 403s and timeouts were surfacing as dependency_file_not_resolvable instead of retryable engine errors. --- .../github_actions/lockfile/cli_engine.rb | 5 +++-- .../lockfile/cli_engine_spec.rb | 19 ++++++++++++++++++- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/github_actions/lib/dependabot/github_actions/lockfile/cli_engine.rb b/github_actions/lib/dependabot/github_actions/lockfile/cli_engine.rb index ea758b848a0..5b6015383bb 100644 --- a/github_actions/lib/dependabot/github_actions/lockfile/cli_engine.rb +++ b/github_actions/lib/dependabot/github_actions/lockfile/cli_engine.rb @@ -28,8 +28,9 @@ class CliEngine UNRESOLVABLE_CATEGORIES = %w(impostor-commit lockfile-forgery).freeze # Host-identity failures from the engine: the lock is bound to a host it can't - # be verified on. User-actionable (restore/regenerate), never retryable. - HOST_IDENTITY_ERROR = /verifying repository identity|repository IDs|not the selected host/ + # be verified on. User-actionable (restore/regenerate), never retryable. The + # engine wraps every lookup error, so only a 404 counts; 5xx/429/403/timeouts stay EngineError. + HOST_IDENTITY_ERROR = /verifying repository identity .*: HTTP 404\b|repository IDs|not the selected host/ # `hostname` is the repository's home host. The engine binds every pin with an # omitted lockfile `hostname` to it, so it must never be guessed. diff --git a/github_actions/spec/dependabot/github_actions/lockfile/cli_engine_spec.rb b/github_actions/spec/dependabot/github_actions/lockfile/cli_engine_spec.rb index 8b9564788ea..b51652f5797 100644 --- a/github_actions/spec/dependabot/github_actions/lockfile/cli_engine_spec.rb +++ b/github_actions/spec/dependabot/github_actions/lockfile/cli_engine_spec.rb @@ -72,7 +72,8 @@ def stub_subprocess(stdout:, exitstatus:, stderr: "Scanning 1 workflow\nResolvin stub_subprocess( stdout: "", exitstatus: 2, - stderr: "verifying repository identity for actions/checkout on tenant.ghe.com: 404\n" + stderr: "verifying repository identity for actions/checkout on tenant.ghe.com: " \ + "HTTP 404: Not Found (https://api.tenant.ghe.com/repos/actions/checkout)\n" ) end @@ -82,6 +83,22 @@ def stub_subprocess(stdout:, exitstatus:, stderr: "Scanning 1 workflow\nResolvin end end + describe "#relock when host identity verification fails transiently" do + before do + stub_subprocess( + stdout: "", + exitstatus: 2, + stderr: "verifying repository identity for actions/checkout on github.com: " \ + "HTTP 502: Bad Gateway (https://api.github.com/repos/actions/checkout)\n" + ) + end + + it "raises a retryable EngineError" do + expect { engine.relock(workflow_files: [workflow], lockfile: lockfile) } + .to raise_error(Dependabot::GithubActions::Lockfile::EngineError, /HTTP 502/) + end + end + describe "#relock when stdout is valid non-object JSON" do before { stub_subprocess(stdout: "[]", exitstatus: 0, stderr: "") } From a9c6593aa1307738dd9490902773a52f36a87923 Mon Sep 17 00:00:00 2001 From: Jeff Martin Date: Fri, 9 Oct 2026 14:51:42 -0700 Subject: [PATCH 5/5] github_actions: bind parsed actions to their lockfile host A pinned action now uses the host recorded in the lockfile (omitted means the home host) instead of probing the tenant and falling back to github.com on any failure. --- .../dependabot/github_actions/file_parser.rb | 31 ++++++++++++++++ .../github_actions/lockfile/reader.rb | 11 ++++++ .../github_actions/file_parser_spec.rb | 36 +++++++++++++++++++ 3 files changed, 78 insertions(+) diff --git a/github_actions/lib/dependabot/github_actions/file_parser.rb b/github_actions/lib/dependabot/github_actions/file_parser.rb index ae083eec5d3..5c45e5b7923 100644 --- a/github_actions/lib/dependabot/github_actions/file_parser.rb +++ b/github_actions/lib/dependabot/github_actions/file_parser.rb @@ -9,6 +9,7 @@ require "dependabot/file_parsers" require "dependabot/file_parsers/base" require "dependabot/github_actions/constants" +require "dependabot/github_actions/lockfile/reader" require "dependabot/github_actions/version" require "dependabot/github_actions/package_manager" require "dependabot/github_actions/workflow_file" @@ -137,6 +138,9 @@ def dependency_with_resolved_version(dep, git_checker) ).returns(Dependabot::Dependency) end def build_github_dependency(file, string, metadata) + locked = locked_hostname(string) + return github_dependency(file, string, locked, metadata) if locked + unless source&.hostname == GITHUB_COM dep = github_dependency(file, string, T.must(source).hostname, metadata) git_checker = Dependabot::GitCommitChecker.new(dependency: dep, credentials: credentials) @@ -193,6 +197,33 @@ def github_dependency(file, string, hostname, metadata) ) end + # A lockfile pin already names its host, so trust it over probing the tenant: + # a tenant blip must not silently rebind an in-tenant action to github.com. + sig { params(string: String).returns(T.nilable(String)) } + def locked_hostname(string) + reader = lockfile_reader + return unless reader + + details = T.must(string.match(GITHUB_REPO_REFERENCE)).named_captures + action_ref = "#{details.fetch(OWNER_KEY)}/#{details.fetch(REPO_KEY)}@#{details.fetch(REF_KEY)}" + reader.pinned_hostname(action_ref, home: source&.hostname || GITHUB_COM) + end + + # A malformed lock must not block parsing; the updater gates it at relock. + sig { returns(T.nilable(Lockfile::Reader)) } + def lockfile_reader + return @lockfile_reader if defined?(@lockfile_reader) + + @lockfile_reader = T.let( + begin + Lockfile::Reader.from_files(dependency_files) + rescue Dependabot::DependencyFileNotParseable + nil + end, + T.nilable(Lockfile::Reader) + ) + end + sig { returns(T::Array[Dependabot::DependencyFile]) } def workflow_files dependency_files.reject { |file| file.path.delete_prefix("/") == LOCKFILE_PATH } diff --git a/github_actions/lib/dependabot/github_actions/lockfile/reader.rb b/github_actions/lib/dependabot/github_actions/lockfile/reader.rb index b12aff8fed0..b8a100914d6 100644 --- a/github_actions/lib/dependabot/github_actions/lockfile/reader.rb +++ b/github_actions/lib/dependabot/github_actions/lockfile/reader.rb @@ -67,6 +67,17 @@ def pins_action?(path, action_ref) Array(workflows[path]).include?(action_ref) end + # The host a pinned `owner/repo@ref` is bound to: its `hostname`, or `home` + # when omitted. Nil when the lockfile doesn't pin it. + sig { params(action_ref: String, home: String).returns(T.nilable(String)) } + def pinned_hostname(action_ref, home:) + entry = dependencies.find { |key, _| key.casecmp?(action_ref) }&.last + return unless entry.is_a?(Hash) + + hostname = entry["hostname"].to_s + hostname.empty? ? home : hostname + end + # Asserts every `dependencies` entry carries {REQUIRED_DEPENDENCY_KEYS}. A # missing key makes the engine silently treat the whole lockfile as empty. # Deferred to the relock gate (not the constructor) so a malformed lock diff --git a/github_actions/spec/dependabot/github_actions/file_parser_spec.rb b/github_actions/spec/dependabot/github_actions/file_parser_spec.rb index ba015c8f489..a15993e2d4d 100644 --- a/github_actions/spec/dependabot/github_actions/file_parser_spec.rb +++ b/github_actions/spec/dependabot/github_actions/file_parser_spec.rb @@ -745,6 +745,42 @@ def mock_service_pack_request(nwo) expect(dependency.requirements).to eq(expected_requirements) end end + + context "when the actions lockfile pins the action" do + let(:pin_hostname) { "" } + let(:lockfile) do + Dependabot::DependencyFile.new( + name: Dependabot::GithubActions::LOCKFILE_NAME, + directory: Dependabot::GithubActions::WORKFLOW_DIRECTORY, + content: <<~YAML + version: 'v0.0.3' + dependencies: + 'inactions/checkout@01aecccf739ca6ff86c0539fbc67a7a5007bbc81': + #{pin_hostname} + ref: '01aecccf739ca6ff86c0539fbc67a7a5007bbc81' + YAML + ) + end + let(:files) { [workflow_files, lockfile] } + let(:url) { dependencies.first.requirements.first.dig(:source, :url) } + + before { stub_request(:get, service_pack_url).to_return(status: 503) } + + it "keeps an omitted-hostname pin on the home host when the tenant blips" do + expect(url).to eq("https://ghes.other.com/inactions/checkout") + end + + context "with an explicit github.com hostname" do + let(:pin_hostname) { "hostname: 'github.com'" } + + before { mock_service_pack_request("inactions/checkout") } + + it "binds to github.com without probing the tenant" do + expect(url).to eq("https://github.com/inactions/checkout") + expect(a_request(:get, service_pack_url)).not_to have_been_made + end + end + end end context "with an inaccessible source" do