diff --git a/lib/ghb/repository_configurator.rb b/lib/ghb/repository_configurator.rb index 146acb3..a6bd41f 100644 --- a/lib/ghb/repository_configurator.rb +++ b/lib/ghb/repository_configurator.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require 'json' +require 'psych' require 'uri' require_relative 'github_api_client' @@ -50,6 +51,18 @@ def configure_branch_protection(github_client, repo_url, current_protection, pro # Add Vercel check if Next.js project @required_status_checks << 'Vercel' if File.exist?('package.json') && File.read('package.json').include?('"next"') + # Add checks for a hand-maintained .github/workflows/smoke.yml. That + # workflow is intentionally NOT generated (e.g. ci-actions smoke-tests + # its own composite actions), so its jobs are absent from + # @required_status_checks; discover them here so they stay required + # across regenerations. Job names are read dynamically so renaming a + # smoke job needs no generator change. + smoke_workflow = '.github/workflows/smoke.yml' + if File.exist?(smoke_workflow) + smoke_jobs = (Psych.safe_load(File.read(smoke_workflow)) || {})['jobs'] || {} + smoke_jobs.each { |job_id, job| @required_status_checks << ((job && job['name']) || job_id.to_s) } + end + # Check for CodeQL default setup codeql_response = github_client.get("#{repo_url}/code-scanning/default-setup", expected_codes: nil) diff --git a/spec/ghb/repository_configurator_spec.rb b/spec/ghb/repository_configurator_spec.rb index ac2f76c..8d7125e 100644 --- a/spec/ghb/repository_configurator_spec.rb +++ b/spec/ghb/repository_configurator_spec.rb @@ -26,6 +26,7 @@ allow(Dir).to(receive(:pwd).and_return("/home/user/#{repository}")) allow(GHB::GitHubAPIClient).to(receive(:new).with(github_token).and_return(github_client)) allow(File).to(receive(:exist?).with('package.json').and_return(false)) + allow(File).to(receive(:exist?).with('.github/workflows/smoke.yml').and_return(false)) allow(Dir).to(receive(:exist?).with('ci_scripts').and_return(false)) end @@ -599,6 +600,83 @@ end end + context 'when .github/workflows/smoke.yml exists (smoke-test detection)' do # rubocop:disable RSpec/MultipleMemoizedHelpers + let(:repo_info_response) do + instance_double(HTTParty::Response, code: 200, body: { private: false }.to_json) + end + + let(:protection_response) do + instance_double(HTTParty::Response, code: 404, body: '{"message":"Not Found"}') + end + + let(:codeql_default_setup_response) do + instance_double(HTTParty::Response, code: 200, body: { state: 'not-configured' }.to_json) + end + + let(:codeql_get_response) do + instance_double(HTTParty::Response, code: 200, body: { state: 'not-configured' }.to_json) + end + + let(:ok_response) do + instance_double(HTTParty::Response, code: 200, body: '{}') + end + + let(:accepted_response) do + instance_double(HTTParty::Response, code: 202, body: '{}') + end + + let(:smoke_yaml) do + <<~YAML + --- + name: Smoke + 'on': [push] + jobs: + action-contracts: + name: Action contracts (all 28) + linters-smoke: + name: Linter actions (disabled path) + variables-smoke: + YAML + end + + before do + allow(File).to(receive(:exist?).with('.github/workflows/smoke.yml').and_return(true)) + allow(File).to(receive(:read).with('.github/workflows/smoke.yml').and_return(smoke_yaml)) + + allow(github_client).to(receive(:get).with(repo_url).and_return(repo_info_response)) + allow(github_client).to(receive(:get).with("#{repo_url}/branches/#{default_branch}/protection", expected_codes: [200, 404]).and_return(protection_response)) + allow(github_client).to(receive(:get).with("#{repo_url}/code-scanning/default-setup", expected_codes: nil).and_return(codeql_default_setup_response)) + allow(github_client).to(receive(:put).with("#{repo_url}/branches/#{default_branch}/protection", body: anything).and_return(ok_response)) + allow(github_client).to(receive(:post).with("#{repo_url}/branches/#{default_branch}/protection/required_signatures", expected_codes: [200, 204]).and_return(ok_response)) + allow(github_client).to(receive(:put).with("#{repo_url}/vulnerability-alerts", expected_codes: [200, 204]).and_return(ok_response)) + allow(github_client).to(receive(:put).with("#{repo_url}/automated-security-fixes", expected_codes: [200, 204]).and_return(ok_response)) + allow(github_client).to(receive(:patch).with(repo_url, body: anything).and_return(ok_response)) + allow(github_client).to(receive(:get).with("#{repo_url}/code-scanning/default-setup").and_return(codeql_get_response)) + allow(github_client).to(receive(:patch).with("#{repo_url}/code-scanning/default-setup", body: anything, expected_codes: [200, 202]).and_return(accepted_response)) + end + + it 'adds each smoke job (by name, falling back to job id) to the expected checks' do # rubocop:disable RSpec/ExampleLength + configurator.configure + + expect(github_client).to( + have_received(:put).with( + "#{repo_url}/branches/#{default_branch}/protection", + body: hash_including( + required_status_checks: hash_including( + checks: [ + { context: 'Build', app_id: nil }, + { context: 'Lint', app_id: nil }, + { context: 'Action contracts (all 28)', app_id: nil }, + { context: 'Linter actions (disabled path)', app_id: nil }, + { context: 'variables-smoke', app_id: nil } + ] + ) + ) + ) + ) + end + end + context 'when ci_scripts directory exists with no existing protection' do # rubocop:disable RSpec/MultipleMemoizedHelpers let(:repo_info_response) do instance_double(HTTParty::Response, code: 200, body: { private: false }.to_json)