From 0602934cc1d68cef3d825d47e7738c9eb566b087 Mon Sep 17 00:00:00 2001 From: Phil Helm Date: Wed, 8 Jul 2026 14:39:03 +0100 Subject: [PATCH 01/18] Add rbnacl dependency and raise octokit lower bound --- Gemfile.lock | 8 ++++++-- rake_github.gemspec | 3 ++- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 7905603..5262cf7 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -3,8 +3,9 @@ PATH specs: rake_github (0.16.0.pre.3) colored2 (~> 3.1) - octokit (>= 4.16, < 11.0) + octokit (>= 7.0, < 11.0) rake_factory (~> 0.33) + rbnacl (~> 7.1) sshkey (~> 2.0) GEM @@ -42,6 +43,7 @@ GEM logger faraday-net_http (3.4.0) net-http (>= 0.5.0) + ffi (1.17.4) gem-release (2.2.4) git (1.19.1) addressable (~> 2.8) @@ -100,6 +102,8 @@ GEM colored2 (~> 3.1) rake_factory (~> 0.33) sshkey (~> 2.0) + rbnacl (7.1.2) + ffi (~> 1) rchardet (1.8.0) regexp_parser (2.10.0) rspec (3.13.1) @@ -157,7 +161,7 @@ GEM unicode-display_width (3.1.4) unicode-emoji (~> 4.0, >= 4.0.4) unicode-emoji (4.0.4) - uri (1.0.3) + uri (1.1.1) PLATFORMS ruby diff --git a/rake_github.gemspec b/rake_github.gemspec index 68fbc48..d3a2921 100644 --- a/rake_github.gemspec +++ b/rake_github.gemspec @@ -36,8 +36,9 @@ Gem::Specification.new do |spec| spec.required_ruby_version = '>= 3.1' spec.add_dependency 'colored2', '~> 3.1' - spec.add_dependency 'octokit', '>= 4.16', '< 11.0' + spec.add_dependency 'octokit', '>= 7.0', '< 11.0' spec.add_dependency 'rake_factory', '~> 0.33' + spec.add_dependency 'rbnacl', '~> 7.1' spec.add_dependency 'sshkey', '~> 2.0' spec.add_development_dependency 'activesupport' From e443a6e1d6d56551a753eccd119b78c87e9545b4 Mon Sep 17 00:00:00 2001 From: Phil Helm Date: Wed, 8 Jul 2026 14:42:25 +0100 Subject: [PATCH 02/18] Add secrets provision task --- lib/rake_github/tasks.rb | 1 + lib/rake_github/tasks/secrets.rb | 3 + lib/rake_github/tasks/secrets/provision.rb | 76 +++++ .../tasks/secrets/provision_spec.rb | 300 ++++++++++++++++++ 4 files changed, 380 insertions(+) create mode 100644 lib/rake_github/tasks/secrets.rb create mode 100644 lib/rake_github/tasks/secrets/provision.rb create mode 100644 spec/rake_github/tasks/secrets/provision_spec.rb diff --git a/lib/rake_github/tasks.rb b/lib/rake_github/tasks.rb index 268938f..e368e16 100644 --- a/lib/rake_github/tasks.rb +++ b/lib/rake_github/tasks.rb @@ -3,3 +3,4 @@ require_relative 'tasks/deploy_keys' require_relative 'tasks/releases' require_relative 'tasks/pull_requests' +require_relative 'tasks/secrets' diff --git a/lib/rake_github/tasks/secrets.rb b/lib/rake_github/tasks/secrets.rb new file mode 100644 index 0000000..a70c732 --- /dev/null +++ b/lib/rake_github/tasks/secrets.rb @@ -0,0 +1,3 @@ +# frozen_string_literal: true + +require_relative 'secrets/provision' diff --git a/lib/rake_github/tasks/secrets/provision.rb b/lib/rake_github/tasks/secrets/provision.rb new file mode 100644 index 0000000..90b8e4a --- /dev/null +++ b/lib/rake_github/tasks/secrets/provision.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +require 'base64' +require 'rake_factory' +require 'octokit' + +module RakeGithub + module Tasks + module Secrets + class Provision < RakeFactory::Task + default_name :provision + default_description(RakeFactory::DynamicValue.new do |t| + "Provision secrets to the #{t.repository} repository" + end) + + parameter :repository, required: true + parameter :access_token, required: true + parameter :secrets, default: [] + + action do |t| + client = Octokit::Client.new(access_token:) + + $stdout.puts 'Provisioning specified secrets on the ' \ + "'#{t.repository}' repository... " + provision_actions_secrets(client, t) + provision_dependabot_secrets(client, t) + end + + private + + def provision_actions_secrets(client, task) + public_key = client.get_actions_public_key(task.repository) + task.secrets.each do |secret| + $stdout.print "Adding '#{secret[:name]}' to Actions... " + client.create_or_update_actions_secret( + task.repository, + secret[:name], + secret_options(public_key, secret[:value]) + ) + $stdout.puts 'Done.' + end + end + + def provision_dependabot_secrets(client, task) + public_key = client.get_dependabot_public_key(task.repository) + task.secrets.each do |secret| + $stdout.print "Adding '#{secret[:name]}' to Dependabot... " + client.create_or_update_dependabot_secret( + task.repository, + secret[:name], + secret_options(public_key, secret[:value]) + ) + $stdout.puts 'Done.' + end + end + + def secret_options(public_key, value) + { + key_id: public_key.key_id, + encrypted_value: encrypt(public_key.key, value) + } + end + + def encrypt(base64_public_key, value) + require 'rbnacl' + + decoded_key = Base64.decode64(base64_public_key) + box = RbNaCl::Boxes::Sealed.from_public_key( + RbNaCl::PublicKey.new(decoded_key) + ) + Base64.strict_encode64(box.encrypt(value)) + end + end + end + end +end diff --git a/spec/rake_github/tasks/secrets/provision_spec.rb b/spec/rake_github/tasks/secrets/provision_spec.rb new file mode 100644 index 0000000..07efc86 --- /dev/null +++ b/spec/rake_github/tasks/secrets/provision_spec.rb @@ -0,0 +1,300 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'octokit' +require 'sawyer' +require 'rbnacl' +require 'base64' + +describe RakeGithub::Tasks::Secrets::Provision do + include_context 'rake' + + before do + stub_output + stub_github_client + end + + def define_task(opts = {}, &block) + opts = { namespace: :secrets }.merge(opts) + + namespace opts[:namespace] do + described_class.define(opts, &block) + end + end + + it 'adds a provision task in the namespace in which it is created' do + define_task( + repository: 'org/repo', + access_token: 'some-token' + ) + + expect(Rake.application) + .to(have_task_defined('secrets:provision')) + end + + it 'gives the provision task a description' do + define_task( + repository: 'org/repo', + access_token: 'some-token' + ) + + expect(Rake::Task['secrets:provision'].full_comment) + .to(eq('Provision secrets to the org/repo repository')) + end + + it 'fails if no repository is provided' do + define_task( + access_token: 'some-token' + ) + + expect do + Rake::Task['secrets:provision'].invoke + end.to raise_error(RakeFactory::RequiredParameterUnset) + end + + it 'fails if no access token is provided' do + define_task( + repository: 'org/repo' + ) + + expect do + Rake::Task['secrets:provision'].invoke + end.to raise_error(RakeFactory::RequiredParameterUnset) + end + + it 'has no secrets by default' do + define_task( + repository: 'org/repo', + access_token: 'some-token' + ) + + rake_task = Rake::Task['secrets:provision'] + test_task = rake_task.creator + + expect(test_task.secrets).to(eq([])) + end + + it 'uses the provided secrets when supplied' do + secrets = [ + { name: 'SOME_SECRET', value: 'some-value' } + ] + + define_task( + repository: 'org/repo', + access_token: 'some-token', + secrets: + ) + + rake_task = Rake::Task['secrets:provision'] + test_task = rake_task.creator + + expect(test_task.secrets).to(eq(secrets)) + end + + # rubocop:disable RSpec/MultipleExpectations + it 'creates or updates the actions secret with a sealed value' do + repository = 'org/repo' + access_token = 'some-token' + private_key = RbNaCl::PrivateKey.generate + + client = stub_github_client + stub_actions_public_key(client, repository, 'actions-key-id', private_key) + + define_task( + repository:, + access_token:, + secrets: [{ name: 'SOME_SECRET', value: 'some-value' }] + ) + + Rake::Task['secrets:provision'].invoke + + expect(Octokit::Client) + .to(have_received(:new) + .with(hash_including(access_token:))) + expect(decrypted_actions_value(client, private_key, 'SOME_SECRET')) + .to(eq('some-value')) + end + # rubocop:enable RSpec/MultipleExpectations + + it 'creates or updates the dependabot secret with a sealed value' do + repository = 'org/repo' + access_token = 'some-token' + private_key = RbNaCl::PrivateKey.generate + + client = stub_github_client + stub_dependabot_public_key( + client, repository, 'dependabot-key-id', private_key + ) + + define_task( + repository:, + access_token:, + secrets: [{ name: 'SOME_SECRET', value: 'some-value' }] + ) + + Rake::Task['secrets:provision'].invoke + + expect(decrypted_dependabot_value(client, private_key, 'SOME_SECRET')) + .to(eq('some-value')) + end + + # rubocop:disable RSpec/MultipleExpectations + it 'seals each store value against that store own public key' do + repository = 'org/repo' + actions_private_key = RbNaCl::PrivateKey.generate + dependabot_private_key = RbNaCl::PrivateKey.generate + + client = stub_github_client + stub_actions_public_key( + client, repository, 'actions-key-id', actions_private_key + ) + stub_dependabot_public_key( + client, repository, 'dependabot-key-id', dependabot_private_key + ) + + define_task( + repository:, + access_token: 'some-token', + secrets: [{ name: 'SOME_SECRET', value: 'some-value' }] + ) + + Rake::Task['secrets:provision'].invoke + + expect( + decrypted_actions_value(client, actions_private_key, 'SOME_SECRET') + ).to(eq('some-value')) + expect( + decrypted_dependabot_value( + client, dependabot_private_key, 'SOME_SECRET' + ) + ).to(eq('some-value')) + end + # rubocop:enable RSpec/MultipleExpectations + + it 'passes the fetched key id to the actions secret' do + repository = 'org/repo' + private_key = RbNaCl::PrivateKey.generate + + client = stub_github_client + stub_actions_public_key(client, repository, 'actions-key-id', private_key) + + define_task( + repository:, + access_token: 'some-token', + secrets: [{ name: 'SOME_SECRET', value: 'some-value' }] + ) + + Rake::Task['secrets:provision'].invoke + + expect(client) + .to(have_received(:create_or_update_actions_secret) + .with(repository, 'SOME_SECRET', + hash_including(key_id: 'actions-key-id'))) + end + + # rubocop:disable RSpec/MultipleExpectations + it 'fetches each public key once per run rather than per secret' do + repository = 'org/repo' + private_key = RbNaCl::PrivateKey.generate + + client = stub_github_client + stub_actions_public_key(client, repository, 'actions-key-id', private_key) + stub_dependabot_public_key( + client, repository, 'dependabot-key-id', private_key + ) + + define_task( + repository:, + access_token: 'some-token', + secrets: [ + { name: 'SECRET_ONE', value: 'value-one' }, + { name: 'SECRET_TWO', value: 'value-two' } + ] + ) + + Rake::Task['secrets:provision'].invoke + + expect(client).to(have_received(:get_actions_public_key).once) + expect(client).to(have_received(:get_dependabot_public_key).once) + end + # rubocop:enable RSpec/MultipleExpectations + + def public_key_resource(key_id, private_key) + agent = Sawyer::Agent.new('https://api.github.com') + Sawyer::Resource.new( + agent, + { + key_id:, + key: Base64.strict_encode64(private_key.public_key.to_bytes) + } + ) + end + + def stub_actions_public_key(client, repository, key_id, private_key) + allow(client) + .to(receive(:get_actions_public_key) + .with(repository) + .and_return(public_key_resource(key_id, private_key))) + end + + def stub_dependabot_public_key(client, repository, key_id, private_key) + allow(client) + .to(receive(:get_dependabot_public_key) + .with(repository) + .and_return(public_key_resource(key_id, private_key))) + end + + def captured_encrypted_value(client, method, name) + encrypted_value = nil + expect(client) + .to(have_received(method) do |_repo, secret_name, opts| + encrypted_value = opts[:encrypted_value] if secret_name == name + end) + encrypted_value + end + + def decrypt(private_key, encrypted_value) + box = RbNaCl::Boxes::Sealed.from_private_key(private_key) + box.decrypt(Base64.decode64(encrypted_value)) + end + + def decrypted_actions_value(client, private_key, name) + encrypted_value = captured_encrypted_value( + client, :create_or_update_actions_secret, name + ) + decrypt(private_key, encrypted_value) + end + + def decrypted_dependabot_value(client, private_key, name) + encrypted_value = captured_encrypted_value( + client, :create_or_update_dependabot_secret, name + ) + decrypt(private_key, encrypted_value) + end + + def stub_output + %i[print puts].each do |method| + allow($stdout).to(receive(method)) + allow($stderr).to(receive(method)) + end + end + + def stub_github_client + client = instance_double(Octokit::Client, default_client_stubs) + allow(Octokit::Client) + .to(receive(:new).and_return(client)) + client + end + + def default_client_stubs + private_key = RbNaCl::PrivateKey.generate + { + get_actions_public_key: + public_key_resource('actions-key-id', private_key), + get_dependabot_public_key: + public_key_resource('dependabot-key-id', private_key), + create_or_update_actions_secret: nil, + create_or_update_dependabot_secret: nil + } + end +end From 76a5f8da47ab8d79d4b06fb722081f24a200b1ed Mon Sep 17 00:00:00 2001 From: Phil Helm Date: Wed, 8 Jul 2026 14:43:30 +0100 Subject: [PATCH 03/18] Add secrets destroy task --- lib/rake_github/tasks/secrets.rb | 1 + lib/rake_github/tasks/secrets/destroy.rb | 48 +++++ .../rake_github/tasks/secrets/destroy_spec.rb | 184 ++++++++++++++++++ 3 files changed, 233 insertions(+) create mode 100644 lib/rake_github/tasks/secrets/destroy.rb create mode 100644 spec/rake_github/tasks/secrets/destroy_spec.rb diff --git a/lib/rake_github/tasks/secrets.rb b/lib/rake_github/tasks/secrets.rb index a70c732..3a86695 100644 --- a/lib/rake_github/tasks/secrets.rb +++ b/lib/rake_github/tasks/secrets.rb @@ -1,3 +1,4 @@ # frozen_string_literal: true require_relative 'secrets/provision' +require_relative 'secrets/destroy' diff --git a/lib/rake_github/tasks/secrets/destroy.rb b/lib/rake_github/tasks/secrets/destroy.rb new file mode 100644 index 0000000..b4e9e65 --- /dev/null +++ b/lib/rake_github/tasks/secrets/destroy.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +require 'rake_factory' +require 'octokit' + +module RakeGithub + module Tasks + module Secrets + class Destroy < RakeFactory::Task + default_name :destroy + default_description(RakeFactory::DynamicValue.new do |t| + "Destroys secrets from the #{t.repository} repository" + end) + + parameter :repository, required: true + parameter :access_token, required: true + parameter :secrets, default: [] + + action do |t| + client = Octokit::Client.new(access_token:) + + $stdout.puts 'Removing specified secrets from the ' \ + "'#{t.repository}' repository... " + t.secrets.each do |secret| + $stdout.print "Removing '#{secret[:name]}'... " + delete_actions_secret(client, t.repository, secret[:name]) + delete_dependabot_secret(client, t.repository, secret[:name]) + $stdout.puts 'Done.' + end + end + + private + + def delete_actions_secret(client, repository, name) + client.delete_actions_secret(repository, name) + rescue Octokit::NotFound + nil + end + + def delete_dependabot_secret(client, repository, name) + client.delete_dependabot_secret(repository, name) + rescue Octokit::NotFound + nil + end + end + end + end +end diff --git a/spec/rake_github/tasks/secrets/destroy_spec.rb b/spec/rake_github/tasks/secrets/destroy_spec.rb new file mode 100644 index 0000000..898e670 --- /dev/null +++ b/spec/rake_github/tasks/secrets/destroy_spec.rb @@ -0,0 +1,184 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'octokit' + +describe RakeGithub::Tasks::Secrets::Destroy do + include_context 'rake' + + before do + stub_output + stub_github_client + end + + def define_task(opts = {}, &block) + opts = { namespace: :secrets }.merge(opts) + + namespace opts[:namespace] do + described_class.define(opts, &block) + end + end + + it 'adds a destroy task in the namespace in which it is created' do + define_task( + repository: 'org/repo', + access_token: 'some-token' + ) + + expect(Rake.application) + .to(have_task_defined('secrets:destroy')) + end + + it 'gives the destroy task a description' do + define_task( + repository: 'org/repo', + access_token: 'some-token' + ) + + expect(Rake::Task['secrets:destroy'].full_comment) + .to(eq('Destroys secrets from the org/repo repository')) + end + + it 'fails if no repository is provided' do + define_task( + access_token: 'some-token' + ) + + expect do + Rake::Task['secrets:destroy'].invoke + end.to raise_error(RakeFactory::RequiredParameterUnset) + end + + it 'fails if no access token is provided' do + define_task( + repository: 'org/repo' + ) + + expect do + Rake::Task['secrets:destroy'].invoke + end.to raise_error(RakeFactory::RequiredParameterUnset) + end + + it 'has no secrets by default' do + define_task( + repository: 'org/repo', + access_token: 'some-token' + ) + + rake_task = Rake::Task['secrets:destroy'] + test_task = rake_task.creator + + expect(test_task.secrets).to(eq([])) + end + + it 'uses the provided secrets when supplied' do + secrets = [ + { name: 'SOME_SECRET', value: 'some-value' } + ] + + define_task( + repository: 'org/repo', + access_token: 'some-token', + secrets: + ) + + rake_task = Rake::Task['secrets:destroy'] + test_task = rake_task.creator + + expect(test_task.secrets).to(eq(secrets)) + end + + # rubocop:disable RSpec/MultipleExpectations + it 'deletes each secret from both the actions and dependabot stores' do + repository = 'org/repo' + access_token = 'some-token' + + client = stub_github_client + + define_task( + repository:, + access_token:, + secrets: [ + { name: 'SECRET_ONE', value: 'value-one' }, + { name: 'SECRET_TWO', value: 'value-two' } + ] + ) + + Rake::Task['secrets:destroy'].invoke + + expect(Octokit::Client) + .to(have_received(:new) + .with(hash_including(access_token:))) + expect(client) + .to(have_received(:delete_actions_secret) + .with(repository, 'SECRET_ONE')) + expect(client) + .to(have_received(:delete_actions_secret) + .with(repository, 'SECRET_TWO')) + expect(client) + .to(have_received(:delete_dependabot_secret) + .with(repository, 'SECRET_ONE')) + expect(client) + .to(have_received(:delete_dependabot_secret) + .with(repository, 'SECRET_TWO')) + end + # rubocop:enable RSpec/MultipleExpectations + + it 'still deletes the dependabot secret when the actions secret is absent' do + repository = 'org/repo' + + client = stub_github_client + allow(client) + .to(receive(:delete_actions_secret) + .and_raise(Octokit::NotFound)) + + define_task( + repository:, + access_token: 'some-token', + secrets: [{ name: 'SOME_SECRET', value: 'some-value' }] + ) + + Rake::Task['secrets:destroy'].invoke + + expect(client) + .to(have_received(:delete_dependabot_secret) + .with(repository, 'SOME_SECRET')) + end + + it 'does not raise when the dependabot secret is absent' do + repository = 'org/repo' + + client = stub_github_client + allow(client) + .to(receive(:delete_dependabot_secret) + .and_raise(Octokit::NotFound)) + + define_task( + repository:, + access_token: 'some-token', + secrets: [{ name: 'SOME_SECRET', value: 'some-value' }] + ) + + expect do + Rake::Task['secrets:destroy'].invoke + end.not_to raise_error + end + + def stub_output + %i[print puts].each do |method| + allow($stdout).to(receive(method)) + allow($stderr).to(receive(method)) + end + end + + def stub_github_client + client = instance_double( + Octokit::Client, + delete_actions_secret: nil, + delete_dependabot_secret: nil + ) + allow(Octokit::Client) + .to(receive(:new).and_return(client)) + client + end +end From 0fc99ef3b1e66d5a3129431e389e4313fcc3177c Mon Sep 17 00:00:00 2001 From: Phil Helm Date: Wed, 8 Jul 2026 14:44:26 +0100 Subject: [PATCH 04/18] Add secrets ensure task --- lib/rake_github/tasks/secrets.rb | 1 + lib/rake_github/tasks/secrets/ensure.rb | 27 +++++++ spec/rake_github/tasks/secrets/ensure_spec.rb | 74 +++++++++++++++++++ 3 files changed, 102 insertions(+) create mode 100644 lib/rake_github/tasks/secrets/ensure.rb create mode 100644 spec/rake_github/tasks/secrets/ensure_spec.rb diff --git a/lib/rake_github/tasks/secrets.rb b/lib/rake_github/tasks/secrets.rb index 3a86695..61df4dd 100644 --- a/lib/rake_github/tasks/secrets.rb +++ b/lib/rake_github/tasks/secrets.rb @@ -2,3 +2,4 @@ require_relative 'secrets/provision' require_relative 'secrets/destroy' +require_relative 'secrets/ensure' diff --git a/lib/rake_github/tasks/secrets/ensure.rb b/lib/rake_github/tasks/secrets/ensure.rb new file mode 100644 index 0000000..eb2c130 --- /dev/null +++ b/lib/rake_github/tasks/secrets/ensure.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +require 'rake_factory' + +module RakeGithub + module Tasks + module Secrets + class Ensure < RakeFactory::Task + default_name :ensure + default_description(RakeFactory::DynamicValue.new do |t| + 'Ensure secrets are configured on the ' \ + "#{t.repository} repository" + end) + + parameter :repository, required: true + + parameter :provision_task_name, default: :provision + parameter :destroy_task_name, default: :destroy + + action do |t, args| + t.application[t.destroy_task_name, t.scope].invoke(*args) + t.application[t.provision_task_name, t.scope].invoke(*args) + end + end + end + end +end diff --git a/spec/rake_github/tasks/secrets/ensure_spec.rb b/spec/rake_github/tasks/secrets/ensure_spec.rb new file mode 100644 index 0000000..8597ece --- /dev/null +++ b/spec/rake_github/tasks/secrets/ensure_spec.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe RakeGithub::Tasks::Secrets::Ensure do + include_context 'rake' + + def define_task(opts = {}, &block) + opts = { + namespace: :secrets, + additional_tasks: %i[provision destroy] + }.merge(opts) + + namespace opts[:namespace] do + opts[:additional_tasks].each do |t| + task t + end + + described_class.define(opts, &block) + end + end + + it 'adds an ensure task in the namespace in which it is created' do + define_task( + repository: 'org/repo' + ) + + expect(Rake.application) + .to(have_task_defined('secrets:ensure')) + end + + it 'gives the ensure task a description' do + define_task( + repository: 'org/repo' + ) + + expect(Rake::Task['secrets:ensure'].full_comment) + .to(eq('Ensure secrets are configured on the ' \ + 'org/repo repository')) + end + + it 'fails if no repository is provided' do + define_task + + expect do + Rake::Task['secrets:ensure'].invoke + end.to raise_error(RakeFactory::RequiredParameterUnset) + end + + # rubocop:disable RSpec/MultipleExpectations + it 'invokes destroy then provision with any defined arguments' do + repository = 'org/repo' + + define_task( + repository:, + argument_names: %i[thing1 thing2] + ) + + allow(Rake::Task['secrets:destroy']).to(receive(:invoke)) + allow(Rake::Task['secrets:provision']).to(receive(:invoke)) + + Rake::Task['secrets:ensure'].invoke('value1', 'value2') + + expect(Rake::Task['secrets:destroy']) + .to(have_received(:invoke) + .with('value1', 'value2') + .ordered) + expect(Rake::Task['secrets:provision']) + .to(have_received(:invoke) + .with('value1', 'value2') + .ordered) + end + # rubocop:enable RSpec/MultipleExpectations +end From 2c09b72a5e2b3628ff429dc30aba244f39d7ed73 Mon Sep 17 00:00:00 2001 From: Phil Helm Date: Wed, 8 Jul 2026 14:45:24 +0100 Subject: [PATCH 05/18] Add secrets task set --- lib/rake_github/task_sets.rb | 1 + lib/rake_github/task_sets/secrets.rb | 34 ++++ spec/rake_github/task_sets/secrets_spec.rb | 224 +++++++++++++++++++++ 3 files changed, 259 insertions(+) create mode 100644 lib/rake_github/task_sets/secrets.rb create mode 100644 spec/rake_github/task_sets/secrets_spec.rb diff --git a/lib/rake_github/task_sets.rb b/lib/rake_github/task_sets.rb index ca3fbfa..2803ba9 100644 --- a/lib/rake_github/task_sets.rb +++ b/lib/rake_github/task_sets.rb @@ -1,4 +1,5 @@ # frozen_string_literal: true require_relative 'task_sets/deploy_keys' +require_relative 'task_sets/secrets' require_relative 'task_sets/repository' diff --git a/lib/rake_github/task_sets/secrets.rb b/lib/rake_github/task_sets/secrets.rb new file mode 100644 index 0000000..2092653 --- /dev/null +++ b/lib/rake_github/task_sets/secrets.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +require 'rake_factory' + +require_relative '../tasks' + +module RakeGithub + module TaskSets + class Secrets < RakeFactory::TaskSet + prepend RakeFactory::Namespaceable + + parameter :repository, required: true + parameter :access_token, required: true + parameter :secrets, default: [] + + parameter :destroy_task_name, default: :destroy + parameter :provision_task_name, default: :provision + parameter :ensure_task_name, default: :ensure + + task Tasks::Secrets::Provision, + name: RakeFactory::DynamicValue.new { |ts| + ts.provision_task_name + } + task Tasks::Secrets::Destroy, + name: RakeFactory::DynamicValue.new { |ts| + ts.destroy_task_name + } + task Tasks::Secrets::Ensure, + name: RakeFactory::DynamicValue.new { |ts| + ts.ensure_task_name + } + end + end +end diff --git a/spec/rake_github/task_sets/secrets_spec.rb b/spec/rake_github/task_sets/secrets_spec.rb new file mode 100644 index 0000000..06f2366 --- /dev/null +++ b/spec/rake_github/task_sets/secrets_spec.rb @@ -0,0 +1,224 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'fileutils' + +describe RakeGithub::TaskSets::Secrets do + include_context 'rake' + + def define_tasks(opts = {}, &block) + described_class.define({ + access_token: 'some-token', + repository: 'org/repo' + }.merge(opts), &block) + end + + it 'adds all secrets tasks in the provided namespace ' \ + 'when supplied' do + define_tasks(namespace: :secrets) + + expect(Rake.application) + .to(have_tasks_defined( + %w[secrets:provision + secrets:destroy + secrets:ensure] + )) + end + + it 'adds all secrets tasks in the root namespace when none supplied' do + define_tasks + + expect(Rake.application) + .to(have_tasks_defined( + %w[provision + destroy + ensure] + )) + end + + describe 'destroy task' do + it 'configures with the provided repository' do + repository = 'my-org/my-repo' + + define_tasks( + repository: + ) + + rake_task = Rake::Task['destroy'] + + expect(rake_task.creator.repository).to(eq(repository)) + end + + it 'configures with the provided access token' do + access_token = 'some-access-token' + + define_tasks( + access_token: + ) + + rake_task = Rake::Task['destroy'] + + expect(rake_task.creator.access_token).to(eq(access_token)) + end + + it 'uses a name of destroy by default' do + define_tasks + + expect(Rake.application) + .to(have_task_defined('destroy')) + end + + it 'uses the provided name when supplied' do + define_tasks(destroy_task_name: :destroy_it_all) + + expect(Rake.application) + .to(have_task_defined('destroy_it_all')) + end + + it 'has no secrets by default' do + define_tasks + + rake_task = Rake::Task['destroy'] + + expect(rake_task.creator.secrets).to(eq([])) + end + + it 'uses the provided secrets when supplied' do + secrets = [ + { name: 'SOME_SECRET', value: 'some-value' } + ] + define_tasks( + secrets: + ) + + rake_task = Rake::Task['destroy'] + + expect(rake_task.creator.secrets).to(eq(secrets)) + end + end + + describe 'provision task' do + it 'configures with the provided repository' do + repository = 'my-org/my-repo' + + define_tasks( + repository: + ) + + rake_task = Rake::Task['provision'] + + expect(rake_task.creator.repository).to(eq(repository)) + end + + it 'configures with the provided access token' do + access_token = 'some-access-token' + + define_tasks( + access_token: + ) + + rake_task = Rake::Task['provision'] + + expect(rake_task.creator.access_token).to(eq(access_token)) + end + + it 'uses a name of provision by default' do + define_tasks + + expect(Rake.application) + .to(have_task_defined('provision')) + end + + it 'uses the provided name when supplied' do + define_tasks(provision_task_name: :provision_things) + + expect(Rake.application) + .to(have_task_defined('provision_things')) + end + + it 'has no secrets by default' do + define_tasks + + rake_task = Rake::Task['provision'] + + expect(rake_task.creator.secrets).to(eq([])) + end + + it 'uses the provided secrets when supplied' do + secrets = [ + { name: 'SOME_SECRET', value: 'some-value' } + ] + define_tasks( + secrets: + ) + + rake_task = Rake::Task['provision'] + + expect(rake_task.creator.secrets).to(eq(secrets)) + end + end + + describe 'ensure task' do + it 'configures with the provided repository' do + repository = 'my-org/my-repo' + + define_tasks( + repository: + ) + + rake_task = Rake::Task['ensure'] + + expect(rake_task.creator.repository).to(eq(repository)) + end + + it 'uses a name of ensure by default' do + define_tasks + + expect(Rake.application) + .to(have_task_defined('ensure')) + end + + it 'uses the provided name when supplied' do + define_tasks(ensure_task_name: :make_sure) + + expect(Rake.application) + .to(have_task_defined('make_sure')) + end + + it 'uses a destroy task name of destroy by default' do + define_tasks + + rake_task = Rake::Task['ensure'] + + expect(rake_task.creator.destroy_task_name) + .to(eq(:destroy)) + end + + it 'uses the provided destroy task name when supplied' do + define_tasks(destroy_task_name: :destroy_it_all) + + rake_task = Rake::Task['ensure'] + + expect(rake_task.creator.destroy_task_name) + .to(eq(:destroy_it_all)) + end + + it 'uses a provision task name of provision by default' do + define_tasks + + rake_task = Rake::Task['ensure'] + + expect(rake_task.creator.provision_task_name) + .to(eq(:provision)) + end + + it 'uses the provided provision task name when supplied' do + define_tasks(provision_task_name: :provision_some_things) + + rake_task = Rake::Task['ensure'] + + expect(rake_task.creator.provision_task_name) + .to(eq(:provision_some_things)) + end + end +end From 2205fc36e39df6c149203882dbb64554744d32be Mon Sep 17 00:00:00 2001 From: Phil Helm Date: Wed, 8 Jul 2026 14:46:04 +0100 Subject: [PATCH 06/18] Add define_secrets_tasks entry point --- lib/rake_github.rb | 4 ++++ spec/rake_github_spec.rb | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/lib/rake_github.rb b/lib/rake_github.rb index 7da3948..341be8d 100644 --- a/lib/rake_github.rb +++ b/lib/rake_github.rb @@ -10,6 +10,10 @@ def self.define_deploy_keys_tasks(opts = {}, &) RakeGithub::TaskSets::DeployKeys.define(opts, &) end + def self.define_secrets_tasks(opts = {}, &) + RakeGithub::TaskSets::Secrets.define(opts, &) + end + def self.define_repository_tasks(opts = {}, &) RakeGithub::TaskSets::Repository.define(opts, &) end diff --git a/spec/rake_github_spec.rb b/spec/rake_github_spec.rb index b490531..168e2aa 100644 --- a/spec/rake_github_spec.rb +++ b/spec/rake_github_spec.rb @@ -39,6 +39,38 @@ end end + describe 'define_secrets_tasks' do + context 'when instantiating RakeGithub::TaskSets::Secrets' do + # rubocop:disable RSpec/MultipleExpectations + it 'passes the provided block' do + opts = { + repository: 'org/repo' + } + + block = lambda do |t| + t.access_token = 'some-token' + t.secrets = [ + { + name: 'SOME_SECRET', + value: 'some-value' + } + ] + end + + allow(RakeGithub::TaskSets::Secrets).to(receive(:define)) + + described_class.define_secrets_tasks(opts, &block) + + expect(RakeGithub::TaskSets::Secrets) + .to(have_received(:define) do |passed_opts, &passed_block| + expect(passed_opts).to(eq(opts)) + expect(passed_block).to(eq(block)) + end) + end + # rubocop:enable RSpec/MultipleExpectations + end + end + describe 'define_repository_tasks' do context 'when instantiating RakeGithub::TaskSets::Repository' do # rubocop:disable RSpec/MultipleExpectations From bf16706365ed63b88fa49ef977fe13d3facb1547 Mon Sep 17 00:00:00 2001 From: Phil Helm Date: Wed, 8 Jul 2026 14:47:45 +0100 Subject: [PATCH 07/18] Add secrets tasks to repository task set --- lib/rake_github/task_sets/repository.rb | 25 ++ spec/rake_github/task_sets/repository_spec.rb | 217 ++++++++++++++++++ 2 files changed, 242 insertions(+) diff --git a/lib/rake_github/task_sets/repository.rb b/lib/rake_github/task_sets/repository.rb index c8a0324..ba60fa5 100644 --- a/lib/rake_github/task_sets/repository.rb +++ b/lib/rake_github/task_sets/repository.rb @@ -12,12 +12,18 @@ class Repository < RakeFactory::TaskSet parameter :repository, required: true parameter :access_token, required: true parameter :deploy_keys, default: [] + parameter :secrets, default: [] parameter :deploy_keys_namespace, default: :deploy_keys parameter :deploy_keys_destroy_task_name, default: :destroy parameter :deploy_keys_provision_task_name, default: :provision parameter :deploy_keys_ensure_task_name, default: :ensure + parameter :secrets_namespace, default: :secrets + parameter :secrets_destroy_task_name, default: :destroy + parameter :secrets_provision_task_name, default: :provision + parameter :secrets_ensure_task_name, default: :ensure + task Tasks::DeployKeys::Provision, name: RakeFactory::DynamicValue.new { |ts| ts.deploy_keys_provision_task_name @@ -36,6 +42,24 @@ class Repository < RakeFactory::TaskSet destroy_task_name: RakeFactory::DynamicValue.new { |ts| ts.deploy_keys_destroy_task_name } + task Tasks::Secrets::Provision, + name: RakeFactory::DynamicValue.new { |ts| + ts.secrets_provision_task_name + } + task Tasks::Secrets::Destroy, + name: RakeFactory::DynamicValue.new { |ts| + ts.secrets_destroy_task_name + } + task Tasks::Secrets::Ensure, + name: RakeFactory::DynamicValue.new { |ts| + ts.secrets_ensure_task_name + }, + provision_task_name: RakeFactory::DynamicValue.new { |ts| + ts.secrets_provision_task_name + }, + destroy_task_name: RakeFactory::DynamicValue.new { |ts| + ts.secrets_destroy_task_name + } task Tasks::PullRequests::Merge def define_on(application) @@ -57,6 +81,7 @@ def define_on(application) def resolve_namespace(task_definition) case task_definition.klass.to_s when /DeployKeys/ then deploy_keys_namespace + when /Secrets/ then secrets_namespace when /PullRequests/ then :pull_requests end end diff --git a/spec/rake_github/task_sets/repository_spec.rb b/spec/rake_github/task_sets/repository_spec.rb index 0e9b47f..c71f7f8 100644 --- a/spec/rake_github/task_sets/repository_spec.rb +++ b/spec/rake_github/task_sets/repository_spec.rb @@ -23,6 +23,9 @@ def define_tasks(opts = {}, &block) %w[github:deploy_keys:provision github:deploy_keys:destroy github:deploy_keys:ensure + github:secrets:provision + github:secrets:destroy + github:secrets:ensure github:pull_requests:merge] )) end @@ -35,6 +38,9 @@ def define_tasks(opts = {}, &block) %w[deploy_keys:provision deploy_keys:destroy deploy_keys:ensure + secrets:provision + secrets:destroy + secrets:ensure pull_requests:merge] )) end @@ -255,4 +261,215 @@ def define_tasks(opts = {}, &block) end end end + + describe 'secrets tasks' do + it 'adds all secrets tasks in the provided namespace when supplied' do + define_tasks(secrets_namespace: :repository_secrets) + + expect(Rake.application) + .to(have_tasks_defined( + %w[repository_secrets:provision + repository_secrets:destroy + repository_secrets:ensure] + )) + end + + it 'adds all secrets tasks in the secrets namespace when none ' \ + 'supplied' do + define_tasks + + expect(Rake.application) + .to(have_tasks_defined( + %w[secrets:provision + secrets:destroy + secrets:ensure] + )) + end + + describe 'destroy task' do + it 'configures with the provided repository' do + repository = 'my-org/my-repo' + + define_tasks( + repository: + ) + + rake_task = Rake::Task['secrets:destroy'] + + expect(rake_task.creator.repository).to(eq(repository)) + end + + it 'configures with the provided access token' do + access_token = 'some-access-token' + + define_tasks( + access_token: + ) + + rake_task = Rake::Task['secrets:destroy'] + + expect(rake_task.creator.access_token).to(eq(access_token)) + end + + it 'uses a name of destroy by default' do + define_tasks + + expect(Rake.application) + .to(have_task_defined('secrets:destroy')) + end + + it 'uses the provided name when supplied' do + define_tasks(secrets_destroy_task_name: :destroy_it_all) + + expect(Rake.application) + .to(have_task_defined('secrets:destroy_it_all')) + end + + it 'has no secrets by default' do + define_tasks + + rake_task = Rake::Task['secrets:destroy'] + + expect(rake_task.creator.secrets).to(eq([])) + end + + it 'uses the provided secrets when supplied' do + secrets = [ + { name: 'SOME_SECRET', value: 'some-value' } + ] + define_tasks( + secrets: + ) + + rake_task = Rake::Task['secrets:destroy'] + + expect(rake_task.creator.secrets).to(eq(secrets)) + end + end + + describe 'provision task' do + it 'configures with the provided repository' do + repository = 'my-org/my-repo' + + define_tasks( + repository: + ) + + rake_task = Rake::Task['secrets:provision'] + + expect(rake_task.creator.repository).to(eq(repository)) + end + + it 'configures with the provided access token' do + access_token = 'some-access-token' + + define_tasks( + access_token: + ) + + rake_task = Rake::Task['secrets:provision'] + + expect(rake_task.creator.access_token).to(eq(access_token)) + end + + it 'uses a name of provision by default' do + define_tasks + + expect(Rake.application) + .to(have_task_defined('secrets:provision')) + end + + it 'uses the provided name when supplied' do + define_tasks(secrets_provision_task_name: :provision_things) + + expect(Rake.application) + .to(have_task_defined('secrets:provision_things')) + end + + it 'has no secrets by default' do + define_tasks + + rake_task = Rake::Task['secrets:provision'] + + expect(rake_task.creator.secrets).to(eq([])) + end + + it 'uses the provided secrets when supplied' do + secrets = [ + { name: 'SOME_SECRET', value: 'some-value' } + ] + define_tasks( + secrets: + ) + + rake_task = Rake::Task['secrets:provision'] + + expect(rake_task.creator.secrets).to(eq(secrets)) + end + end + + describe 'ensure task' do + it 'configures with the provided repository' do + repository = 'my-org/my-repo' + + define_tasks( + repository: + ) + + rake_task = Rake::Task['secrets:ensure'] + + expect(rake_task.creator.repository).to(eq(repository)) + end + + it 'uses a name of ensure by default' do + define_tasks + + expect(Rake.application) + .to(have_task_defined('secrets:ensure')) + end + + it 'uses the provided name when supplied' do + define_tasks(secrets_ensure_task_name: :make_sure) + + expect(Rake.application) + .to(have_task_defined('secrets:make_sure')) + end + + it 'uses a destroy task name of destroy by default' do + define_tasks + + rake_task = Rake::Task['secrets:ensure'] + + expect(rake_task.creator.destroy_task_name) + .to(eq(:destroy)) + end + + it 'uses the provided destroy task name when supplied' do + define_tasks(secrets_destroy_task_name: :destroy_it_all) + + rake_task = Rake::Task['secrets:ensure'] + + expect(rake_task.creator.destroy_task_name) + .to(eq(:destroy_it_all)) + end + + it 'uses a provision task name of provision by default' do + define_tasks + + rake_task = Rake::Task['secrets:ensure'] + + expect(rake_task.creator.provision_task_name) + .to(eq(:provision)) + end + + it 'uses the provided provision task name when supplied' do + define_tasks(secrets_provision_task_name: :provision_some_things) + + rake_task = Rake::Task['secrets:ensure'] + + expect(rake_task.creator.provision_task_name) + .to(eq(:provision_some_things)) + end + end + end end From 668d308370497c2fa7a338b5698294012fcfc02e Mon Sep 17 00:00:00 2001 From: Phil Helm Date: Wed, 8 Jul 2026 14:48:43 +0100 Subject: [PATCH 08/18] Document secrets tasks --- CHANGELOG.md | 14 +++++++++++ README.md | 66 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 07dd17a..f0c8fa3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,20 @@ and this project adheres to ## Unreleased +### Added + +* New `secrets` task group, defined via `RakeGithub.define_secrets_tasks`, for + managing GitHub repository secrets. It exposes `provision`, `destroy` and + `ensure` tasks. Each secret is written to both the Actions and Dependabot + secret stores so that Dependabot-triggered workflow runs can read them. + + Secrets are supplied as an array of hashes, e.g. + `[{ name: 'SOME_SECRET', value: 'plaintext' }]`, and are encrypted + client-side before being sent to GitHub. + +* The `secrets` task group is also included in the tasks defined by + `RakeGithub.define_repository_tasks`. + ### Changed * The `pull_requests:merge` task no longer requires setting the branch name and diff --git a/README.md b/README.md index 2c0d54a..117f2dd 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,11 @@ end | deploy_keys_destroy_task_name | symbol | N | Option to change the destroy task name | :obliterate | :destroy | | deploy_keys_provision_task_name | symbol | N | Option to change the provision task name | :add | :provision | | deploy_keys_ensure_task_name | symbol | N | Option to change the ensure task name | :destroy_and_provision | :ensure | +| secrets | array | N | Secrets to provision on the repository | { name: string, value: string } | [ ] | +| secrets_namespace | symbol | N | Namespace to contain secrets tasks | :repository_secrets | :secrets | +| secrets_destroy_task_name | symbol | N | Option to change the secrets destroy task name | :obliterate | :destroy | +| secrets_provision_task_name | symbol | N | Option to change the secrets provision task name | :add | :provision | +| secrets_ensure_task_name | symbol | N | Option to change the secrets ensure task name | :destroy_and_provision | :ensure | | namespace | symbol | N | Namespace for tasks to live in, defaults to root namespace | :rake_github | N/A | Exposes tasks: @@ -62,6 +67,9 @@ $ rake -T rake github:deploy_keys:destroy rake github:deploy_keys:ensure rake github:deploy_keys:provision +rake github:secrets:destroy +rake github:secrets:ensure +rake github:secrets:provision rake github:pull_requests:merge[branch_name,commit_message] ``` @@ -84,6 +92,64 @@ Merges the PR associated with the `branch_name`. Branch name is required. `commit_message` is optional, and can contain the original commit message with the `%s` placeholder, e.g. `pull_requests:merge[new_feature,"%s [skip ci]"]`. +### define_secrets_tasks + +Sets up rake tasks for managing repository secrets. Each secret is written to +both the Actions and Dependabot secret stores, so that Dependabot-triggered +workflow runs can read them. Secrets are encrypted client-side before being +sent to GitHub. + +```ruby +require 'rake_github' + +RakeGithub.define_secrets_tasks( + namespace: :secrets, + repository: 'org/repo', # required +) do |t| + t.access_token = "your_github_access_token" # required + t.secrets = [ + { + name: 'SOME_SECRET', + value: 'some-plaintext-value' + } + ] +end +``` + +| Parameter | Type | Required | Description | Example | Default | +|----------------------|--------|----------|------------------------------------------------------------|---------------------------------------------|------------| +| repository | string | Y | Repository to perform tasks upon | 'organisation/repository_name' | N/A | +| access_token | string | Y | Github token for authorisation | 'ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx' | N/A | +| secrets | array | N | Secrets to provision on the repository | { name: string, value: string } | [ ] | +| destroy_task_name | symbol | N | Option to change the destroy task name | :obliterate | :destroy | +| provision_task_name | symbol | N | Option to change the provision task name | :add | :provision | +| ensure_task_name | symbol | N | Option to change the ensure task name | :destroy_and_provision | :ensure | +| namespace | symbol | N | Namespace for tasks to live in, defaults to root namespace | :secrets | N/A | + +Exposes tasks: + +```shell +$ rake -T + +rake secrets:destroy +rake secrets:ensure +rake secrets:provision +``` + +#### secrets:provision + +Provisions the specified secrets to the repository, writing each one to both +the Actions and Dependabot secret stores. + +#### secrets:destroy + +Destroys the specified secrets from the repository, removing each one from both +the Actions and Dependabot secret stores. + +#### secrets:ensure + +Destroys and then provisions the specified secrets on the repository. + ### define_release_task ## Development From de4087189324b42bd209aab3470eccc124fd14de Mon Sep 17 00:00:00 2001 From: Phil Helm Date: Wed, 8 Jul 2026 14:53:43 +0100 Subject: [PATCH 09/18] Add environments provision task --- lib/rake_github/tasks.rb | 1 + lib/rake_github/tasks/environments.rb | 3 + .../tasks/environments/provision.rb | 79 ++++ .../tasks/environments/provision_spec.rb | 387 ++++++++++++++++++ 4 files changed, 470 insertions(+) create mode 100644 lib/rake_github/tasks/environments.rb create mode 100644 lib/rake_github/tasks/environments/provision.rb create mode 100644 spec/rake_github/tasks/environments/provision_spec.rb diff --git a/lib/rake_github/tasks.rb b/lib/rake_github/tasks.rb index e368e16..f6d6503 100644 --- a/lib/rake_github/tasks.rb +++ b/lib/rake_github/tasks.rb @@ -4,3 +4,4 @@ require_relative 'tasks/releases' require_relative 'tasks/pull_requests' require_relative 'tasks/secrets' +require_relative 'tasks/environments' diff --git a/lib/rake_github/tasks/environments.rb b/lib/rake_github/tasks/environments.rb new file mode 100644 index 0000000..de41efc --- /dev/null +++ b/lib/rake_github/tasks/environments.rb @@ -0,0 +1,3 @@ +# frozen_string_literal: true + +require_relative 'environments/provision' diff --git a/lib/rake_github/tasks/environments/provision.rb b/lib/rake_github/tasks/environments/provision.rb new file mode 100644 index 0000000..925144e --- /dev/null +++ b/lib/rake_github/tasks/environments/provision.rb @@ -0,0 +1,79 @@ +# frozen_string_literal: true + +require 'rake_factory' +require 'octokit' + +module RakeGithub + module Tasks + module Environments + class Provision < RakeFactory::Task + default_name :provision + default_description(RakeFactory::DynamicValue.new do |t| + "Provision environments to the #{t.repository} repository" + end) + + parameter :repository, required: true + parameter :access_token, required: true + parameter :environments, default: [] + + action do |t| + client = Octokit::Client.new(access_token:) + org = t.repository.split('/').first + + $stdout.puts 'Provisioning specified environments on the ' \ + "'#{t.repository}' repository... " + t.environments.each do |environment| + $stdout.print "Adding '#{environment[:name]}'... " + client.create_or_update_environment( + t.repository, + environment[:name], + environment_options(client, org, environment) + ) + $stdout.puts 'Done.' + end + end + + private + + def environment_options(client, org, environment) + { + wait_timer: environment[:wait_timer], + prevent_self_review: environment[:prevent_self_review], + reviewers: resolve_reviewers(client, org, environment[:reviewers]), + deployment_branch_policy: environment[:deployment_branch_policy] + }.compact + end + + def resolve_reviewers(client, org, reviewers) + return nil if reviewers.nil? + + reviewers.map { |reviewer| resolve_reviewer(client, org, reviewer) } + end + + def resolve_reviewer(client, org, reviewer) + if reviewer[:team] + { type: 'Team', id: resolve_team_id(client, org, reviewer[:team]) } + else + { type: 'User', id: resolve_user_id(client, reviewer[:user]) } + end + end + + def resolve_team_id(client, org, slug) + team_ids[slug] ||= client.team_by_name(org, slug).id + end + + def resolve_user_id(client, login) + user_ids[login] ||= client.user(login).id + end + + def team_ids + @team_ids ||= {} + end + + def user_ids + @user_ids ||= {} + end + end + end + end +end diff --git a/spec/rake_github/tasks/environments/provision_spec.rb b/spec/rake_github/tasks/environments/provision_spec.rb new file mode 100644 index 0000000..2c4ea2a --- /dev/null +++ b/spec/rake_github/tasks/environments/provision_spec.rb @@ -0,0 +1,387 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'octokit' +require 'sawyer' + +describe RakeGithub::Tasks::Environments::Provision do + include_context 'rake' + + before do + stub_output + stub_github_client + end + + def define_task(opts = {}, &block) + opts = { namespace: :environments }.merge(opts) + + namespace opts[:namespace] do + described_class.define(opts, &block) + end + end + + it 'adds a provision task in the namespace in which it is created' do + define_task( + repository: 'org/repo', + access_token: 'some-token' + ) + + expect(Rake.application) + .to(have_task_defined('environments:provision')) + end + + it 'gives the provision task a description' do + define_task( + repository: 'org/repo', + access_token: 'some-token' + ) + + expect(Rake::Task['environments:provision'].full_comment) + .to(eq('Provision environments to the org/repo repository')) + end + + it 'fails if no repository is provided' do + define_task( + access_token: 'some-token' + ) + + expect do + Rake::Task['environments:provision'].invoke + end.to raise_error(RakeFactory::RequiredParameterUnset) + end + + it 'fails if no access token is provided' do + define_task( + repository: 'org/repo' + ) + + expect do + Rake::Task['environments:provision'].invoke + end.to raise_error(RakeFactory::RequiredParameterUnset) + end + + it 'has no environments by default' do + define_task( + repository: 'org/repo', + access_token: 'some-token' + ) + + rake_task = Rake::Task['environments:provision'] + test_task = rake_task.creator + + expect(test_task.environments).to(eq([])) + end + + it 'uses the provided environments when supplied' do + environments = [ + { name: 'release' } + ] + + define_task( + repository: 'org/repo', + access_token: 'some-token', + environments: + ) + + rake_task = Rake::Task['environments:provision'] + test_task = rake_task.creator + + expect(test_task.environments).to(eq(environments)) + end + + it 'creates or updates each environment on the repository' do + repository = 'org/repo' + access_token = 'some-token' + + client = stub_github_client + + define_task( + repository:, + access_token:, + environments: [{ name: 'release' }] + ) + + Rake::Task['environments:provision'].invoke + + expect(client) + .to(have_received(:create_or_update_environment) + .with(repository, 'release', anything)) + end + + it 'uses the provided access token' do + access_token = 'some-token' + + define_task( + repository: 'org/repo', + access_token:, + environments: [{ name: 'release' }] + ) + + Rake::Task['environments:provision'].invoke + + expect(Octokit::Client) + .to(have_received(:new) + .with(hash_including(access_token:))) + end + + # rubocop:disable RSpec/MultipleExpectations + it 'resolves a team reviewer to its numeric id against the owner org' do + repository = 'infrablocks/rake_github' + + client = stub_github_client + allow(client) + .to(receive(:team_by_name) + .with('infrablocks', 'maintainers') + .and_return(team_resource(42))) + + define_task( + repository:, + access_token: 'some-token', + environments: [ + { name: 'release', reviewers: [{ team: 'maintainers' }] } + ] + ) + + Rake::Task['environments:provision'].invoke + + expect(client) + .to(have_received(:team_by_name).with('infrablocks', 'maintainers')) + expect(client) + .to(have_received(:create_or_update_environment) + .with(repository, 'release', + hash_including(reviewers: [{ type: 'Team', id: 42 }]))) + end + # rubocop:enable RSpec/MultipleExpectations + + it 'resolves a user reviewer to its numeric id' do + repository = 'infrablocks/rake_github' + + client = stub_github_client + allow(client) + .to(receive(:user) + .with('someone') + .and_return(user_resource(7))) + + define_task( + repository:, + access_token: 'some-token', + environments: [ + { name: 'release', reviewers: [{ user: 'someone' }] } + ] + ) + + Rake::Task['environments:provision'].invoke + + expect(client) + .to(have_received(:create_or_update_environment) + .with(repository, 'release', + hash_including(reviewers: [{ type: 'User', id: 7 }]))) + end + + it 'resolves a mix of team and user reviewers in order' do + repository = 'infrablocks/rake_github' + + client = stub_github_client + allow(client) + .to(receive(:team_by_name) + .with('infrablocks', 'maintainers') + .and_return(team_resource(42))) + allow(client) + .to(receive(:user) + .with('someone') + .and_return(user_resource(7))) + + define_task( + repository:, + access_token: 'some-token', + environments: [ + { + name: 'release', + reviewers: [{ team: 'maintainers' }, { user: 'someone' }] + } + ] + ) + + Rake::Task['environments:provision'].invoke + + expect(client) + .to(have_received(:create_or_update_environment) + .with(repository, 'release', + hash_including(reviewers: [ + { type: 'Team', id: 42 }, + { type: 'User', id: 7 } + ]))) + end + + it 'passes through the provided optional keys when supplied' do + repository = 'org/repo' + + client = stub_github_client + + define_task( + repository:, + access_token: 'some-token', + environments: [ + { + name: 'release', + wait_timer: 30, + prevent_self_review: true, + deployment_branch_policy: { + protected_branches: true, + custom_branch_policies: false + } + } + ] + ) + + Rake::Task['environments:provision'].invoke + + expect(client) + .to(have_received(:create_or_update_environment) + .with(repository, 'release', + hash_including( + wait_timer: 30, + prevent_self_review: true, + deployment_branch_policy: { + protected_branches: true, + custom_branch_policies: false + } + ))) + end + + it 'omits optional keys that are not supplied from the payload' do + repository = 'org/repo' + + client = stub_github_client + + define_task( + repository:, + access_token: 'some-token', + environments: [{ name: 'release' }] + ) + + Rake::Task['environments:provision'].invoke + + expect(client) + .to(have_received(:create_or_update_environment) + .with(repository, 'release', {})) + end + + it 'retains falsy optional values such as wait_timer zero' do + repository = 'org/repo' + + client = stub_github_client + + define_task( + repository:, + access_token: 'some-token', + environments: [ + { name: 'release', wait_timer: 0, prevent_self_review: false } + ] + ) + + Rake::Task['environments:provision'].invoke + + expect(client) + .to(have_received(:create_or_update_environment) + .with(repository, 'release', + hash_including(wait_timer: 0, prevent_self_review: false))) + end + + # rubocop:disable RSpec/MultipleExpectations + it 'creates or updates each of multiple environments' do + repository = 'org/repo' + + client = stub_github_client + + define_task( + repository:, + access_token: 'some-token', + environments: [ + { name: 'staging' }, + { name: 'release' } + ] + ) + + Rake::Task['environments:provision'].invoke + + expect(client) + .to(have_received(:create_or_update_environment) + .with(repository, 'staging', anything)) + expect(client) + .to(have_received(:create_or_update_environment) + .with(repository, 'release', anything)) + end + # rubocop:enable RSpec/MultipleExpectations + + it 'resolves each distinct team once per run' do + client = stub_github_client + allow(client) + .to(receive(:team_by_name) + .with('infrablocks', 'maintainers') + .and_return(team_resource(42))) + + define_task( + repository: 'infrablocks/rake_github', + access_token: 'some-token', + environments: [ + { name: 'staging', reviewers: [{ team: 'maintainers' }] }, + { name: 'release', reviewers: [{ team: 'maintainers' }] } + ] + ) + + Rake::Task['environments:provision'].invoke + + expect(client).to(have_received(:team_by_name).once) + end + + it 'resolves each distinct user once per run' do + client = stub_github_client + allow(client) + .to(receive(:user) + .with('someone') + .and_return(user_resource(7))) + + define_task( + repository: 'infrablocks/rake_github', + access_token: 'some-token', + environments: [ + { name: 'staging', reviewers: [{ user: 'someone' }] }, + { name: 'release', reviewers: [{ user: 'someone' }] } + ] + ) + + Rake::Task['environments:provision'].invoke + + expect(client).to(have_received(:user).once) + end + + def team_resource(id) + agent = Sawyer::Agent.new('https://api.github.com') + Sawyer::Resource.new(agent, { id: }) + end + + def user_resource(id) + agent = Sawyer::Agent.new('https://api.github.com') + Sawyer::Resource.new(agent, { id: }) + end + + def stub_output + %i[print puts].each do |method| + allow($stdout).to(receive(method)) + allow($stderr).to(receive(method)) + end + end + + def stub_github_client + client = instance_double( + Octokit::Client, + create_or_update_environment: nil, + team_by_name: team_resource(1), + user: user_resource(1) + ) + allow(Octokit::Client) + .to(receive(:new).and_return(client)) + client + end +end From 9df87cbc2871b19f0734482a06bb2dd4a552d623 Mon Sep 17 00:00:00 2001 From: Phil Helm Date: Wed, 8 Jul 2026 14:54:32 +0100 Subject: [PATCH 10/18] Add environments destroy task --- lib/rake_github/tasks/environments.rb | 1 + lib/rake_github/tasks/environments/destroy.rb | 41 ++++ .../tasks/environments/destroy_spec.rb | 181 ++++++++++++++++++ 3 files changed, 223 insertions(+) create mode 100644 lib/rake_github/tasks/environments/destroy.rb create mode 100644 spec/rake_github/tasks/environments/destroy_spec.rb diff --git a/lib/rake_github/tasks/environments.rb b/lib/rake_github/tasks/environments.rb index de41efc..354c850 100644 --- a/lib/rake_github/tasks/environments.rb +++ b/lib/rake_github/tasks/environments.rb @@ -1,3 +1,4 @@ # frozen_string_literal: true require_relative 'environments/provision' +require_relative 'environments/destroy' diff --git a/lib/rake_github/tasks/environments/destroy.rb b/lib/rake_github/tasks/environments/destroy.rb new file mode 100644 index 0000000..56c577a --- /dev/null +++ b/lib/rake_github/tasks/environments/destroy.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +require 'rake_factory' +require 'octokit' + +module RakeGithub + module Tasks + module Environments + class Destroy < RakeFactory::Task + default_name :destroy + default_description(RakeFactory::DynamicValue.new do |t| + "Destroys environments from the #{t.repository} repository" + end) + + parameter :repository, required: true + parameter :access_token, required: true + parameter :environments, default: [] + + action do |t| + client = Octokit::Client.new(access_token:) + + $stdout.puts 'Removing specified environments from the ' \ + "'#{t.repository}' repository... " + t.environments.each do |environment| + $stdout.print "Removing '#{environment[:name]}'... " + delete_environment(client, t.repository, environment[:name]) + $stdout.puts 'Done.' + end + end + + private + + def delete_environment(client, repository, name) + client.delete_environment(repository, name) + rescue Octokit::NotFound + nil + end + end + end + end +end diff --git a/spec/rake_github/tasks/environments/destroy_spec.rb b/spec/rake_github/tasks/environments/destroy_spec.rb new file mode 100644 index 0000000..2d6a65b --- /dev/null +++ b/spec/rake_github/tasks/environments/destroy_spec.rb @@ -0,0 +1,181 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'octokit' + +describe RakeGithub::Tasks::Environments::Destroy do + include_context 'rake' + + before do + stub_output + stub_github_client + end + + def define_task(opts = {}, &block) + opts = { namespace: :environments }.merge(opts) + + namespace opts[:namespace] do + described_class.define(opts, &block) + end + end + + it 'adds a destroy task in the namespace in which it is created' do + define_task( + repository: 'org/repo', + access_token: 'some-token' + ) + + expect(Rake.application) + .to(have_task_defined('environments:destroy')) + end + + it 'gives the destroy task a description' do + define_task( + repository: 'org/repo', + access_token: 'some-token' + ) + + expect(Rake::Task['environments:destroy'].full_comment) + .to(eq('Destroys environments from the org/repo repository')) + end + + it 'fails if no repository is provided' do + define_task( + access_token: 'some-token' + ) + + expect do + Rake::Task['environments:destroy'].invoke + end.to raise_error(RakeFactory::RequiredParameterUnset) + end + + it 'fails if no access token is provided' do + define_task( + repository: 'org/repo' + ) + + expect do + Rake::Task['environments:destroy'].invoke + end.to raise_error(RakeFactory::RequiredParameterUnset) + end + + it 'has no environments by default' do + define_task( + repository: 'org/repo', + access_token: 'some-token' + ) + + rake_task = Rake::Task['environments:destroy'] + test_task = rake_task.creator + + expect(test_task.environments).to(eq([])) + end + + it 'uses the provided environments when supplied' do + environments = [ + { name: 'release' } + ] + + define_task( + repository: 'org/repo', + access_token: 'some-token', + environments: + ) + + rake_task = Rake::Task['environments:destroy'] + test_task = rake_task.creator + + expect(test_task.environments).to(eq(environments)) + end + + # rubocop:disable RSpec/MultipleExpectations + it 'deletes each environment from the repository' do + repository = 'org/repo' + access_token = 'some-token' + + client = stub_github_client + + define_task( + repository:, + access_token:, + environments: [ + { name: 'staging' }, + { name: 'release' } + ] + ) + + Rake::Task['environments:destroy'].invoke + + expect(Octokit::Client) + .to(have_received(:new) + .with(hash_including(access_token:))) + expect(client) + .to(have_received(:delete_environment) + .with(repository, 'staging')) + expect(client) + .to(have_received(:delete_environment) + .with(repository, 'release')) + end + # rubocop:enable RSpec/MultipleExpectations + + it 'still deletes remaining environments when one is absent' do + repository = 'org/repo' + + client = stub_github_client + allow(client) + .to(receive(:delete_environment) + .with(repository, 'staging') + .and_raise(Octokit::NotFound)) + + define_task( + repository:, + access_token: 'some-token', + environments: [ + { name: 'staging' }, + { name: 'release' } + ] + ) + + Rake::Task['environments:destroy'].invoke + + expect(client) + .to(have_received(:delete_environment) + .with(repository, 'release')) + end + + it 'does not raise when the environment is absent' do + repository = 'org/repo' + + client = stub_github_client + allow(client) + .to(receive(:delete_environment) + .and_raise(Octokit::NotFound)) + + define_task( + repository:, + access_token: 'some-token', + environments: [{ name: 'release' }] + ) + + expect do + Rake::Task['environments:destroy'].invoke + end.not_to raise_error + end + + def stub_output + %i[print puts].each do |method| + allow($stdout).to(receive(method)) + allow($stderr).to(receive(method)) + end + end + + def stub_github_client + client = instance_double( + Octokit::Client, + delete_environment: nil + ) + allow(Octokit::Client) + .to(receive(:new).and_return(client)) + client + end +end From 6830a0ac05f3cfeacb0f7d33d65ebb718d3efd87 Mon Sep 17 00:00:00 2001 From: Phil Helm Date: Wed, 8 Jul 2026 14:55:10 +0100 Subject: [PATCH 11/18] Add environments ensure task --- lib/rake_github/tasks/environments.rb | 1 + lib/rake_github/tasks/environments/ensure.rb | 27 +++++++ .../tasks/environments/ensure_spec.rb | 74 +++++++++++++++++++ 3 files changed, 102 insertions(+) create mode 100644 lib/rake_github/tasks/environments/ensure.rb create mode 100644 spec/rake_github/tasks/environments/ensure_spec.rb diff --git a/lib/rake_github/tasks/environments.rb b/lib/rake_github/tasks/environments.rb index 354c850..524563e 100644 --- a/lib/rake_github/tasks/environments.rb +++ b/lib/rake_github/tasks/environments.rb @@ -2,3 +2,4 @@ require_relative 'environments/provision' require_relative 'environments/destroy' +require_relative 'environments/ensure' diff --git a/lib/rake_github/tasks/environments/ensure.rb b/lib/rake_github/tasks/environments/ensure.rb new file mode 100644 index 0000000..b143c90 --- /dev/null +++ b/lib/rake_github/tasks/environments/ensure.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +require 'rake_factory' + +module RakeGithub + module Tasks + module Environments + class Ensure < RakeFactory::Task + default_name :ensure + default_description(RakeFactory::DynamicValue.new do |t| + 'Ensure environments are configured on the ' \ + "#{t.repository} repository" + end) + + parameter :repository, required: true + + parameter :provision_task_name, default: :provision + parameter :destroy_task_name, default: :destroy + + action do |t, args| + t.application[t.destroy_task_name, t.scope].invoke(*args) + t.application[t.provision_task_name, t.scope].invoke(*args) + end + end + end + end +end diff --git a/spec/rake_github/tasks/environments/ensure_spec.rb b/spec/rake_github/tasks/environments/ensure_spec.rb new file mode 100644 index 0000000..fdb5b2a --- /dev/null +++ b/spec/rake_github/tasks/environments/ensure_spec.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe RakeGithub::Tasks::Environments::Ensure do + include_context 'rake' + + def define_task(opts = {}, &block) + opts = { + namespace: :environments, + additional_tasks: %i[provision destroy] + }.merge(opts) + + namespace opts[:namespace] do + opts[:additional_tasks].each do |t| + task t + end + + described_class.define(opts, &block) + end + end + + it 'adds an ensure task in the namespace in which it is created' do + define_task( + repository: 'org/repo' + ) + + expect(Rake.application) + .to(have_task_defined('environments:ensure')) + end + + it 'gives the ensure task a description' do + define_task( + repository: 'org/repo' + ) + + expect(Rake::Task['environments:ensure'].full_comment) + .to(eq('Ensure environments are configured on the ' \ + 'org/repo repository')) + end + + it 'fails if no repository is provided' do + define_task + + expect do + Rake::Task['environments:ensure'].invoke + end.to raise_error(RakeFactory::RequiredParameterUnset) + end + + # rubocop:disable RSpec/MultipleExpectations + it 'invokes destroy then provision with any defined arguments' do + repository = 'org/repo' + + define_task( + repository:, + argument_names: %i[thing1 thing2] + ) + + allow(Rake::Task['environments:destroy']).to(receive(:invoke)) + allow(Rake::Task['environments:provision']).to(receive(:invoke)) + + Rake::Task['environments:ensure'].invoke('value1', 'value2') + + expect(Rake::Task['environments:destroy']) + .to(have_received(:invoke) + .with('value1', 'value2') + .ordered) + expect(Rake::Task['environments:provision']) + .to(have_received(:invoke) + .with('value1', 'value2') + .ordered) + end + # rubocop:enable RSpec/MultipleExpectations +end From 97dfb99ece13a341cc6ab9a42a4647c3247e498e Mon Sep 17 00:00:00 2001 From: Phil Helm Date: Wed, 8 Jul 2026 14:56:26 +0100 Subject: [PATCH 12/18] Add environments task set and define_environments_tasks entry point --- lib/rake_github.rb | 4 + lib/rake_github/task_sets.rb | 1 + lib/rake_github/task_sets/environments.rb | 34 +++ .../task_sets/environments_spec.rb | 224 ++++++++++++++++++ spec/rake_github_spec.rb | 31 +++ 5 files changed, 294 insertions(+) create mode 100644 lib/rake_github/task_sets/environments.rb create mode 100644 spec/rake_github/task_sets/environments_spec.rb diff --git a/lib/rake_github.rb b/lib/rake_github.rb index 341be8d..26a7030 100644 --- a/lib/rake_github.rb +++ b/lib/rake_github.rb @@ -14,6 +14,10 @@ def self.define_secrets_tasks(opts = {}, &) RakeGithub::TaskSets::Secrets.define(opts, &) end + def self.define_environments_tasks(opts = {}, &) + RakeGithub::TaskSets::Environments.define(opts, &) + end + def self.define_repository_tasks(opts = {}, &) RakeGithub::TaskSets::Repository.define(opts, &) end diff --git a/lib/rake_github/task_sets.rb b/lib/rake_github/task_sets.rb index 2803ba9..681190d 100644 --- a/lib/rake_github/task_sets.rb +++ b/lib/rake_github/task_sets.rb @@ -2,4 +2,5 @@ require_relative 'task_sets/deploy_keys' require_relative 'task_sets/secrets' +require_relative 'task_sets/environments' require_relative 'task_sets/repository' diff --git a/lib/rake_github/task_sets/environments.rb b/lib/rake_github/task_sets/environments.rb new file mode 100644 index 0000000..6a64b71 --- /dev/null +++ b/lib/rake_github/task_sets/environments.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +require 'rake_factory' + +require_relative '../tasks' + +module RakeGithub + module TaskSets + class Environments < RakeFactory::TaskSet + prepend RakeFactory::Namespaceable + + parameter :repository, required: true + parameter :access_token, required: true + parameter :environments, default: [] + + parameter :destroy_task_name, default: :destroy + parameter :provision_task_name, default: :provision + parameter :ensure_task_name, default: :ensure + + task Tasks::Environments::Provision, + name: RakeFactory::DynamicValue.new { |ts| + ts.provision_task_name + } + task Tasks::Environments::Destroy, + name: RakeFactory::DynamicValue.new { |ts| + ts.destroy_task_name + } + task Tasks::Environments::Ensure, + name: RakeFactory::DynamicValue.new { |ts| + ts.ensure_task_name + } + end + end +end diff --git a/spec/rake_github/task_sets/environments_spec.rb b/spec/rake_github/task_sets/environments_spec.rb new file mode 100644 index 0000000..814e8ee --- /dev/null +++ b/spec/rake_github/task_sets/environments_spec.rb @@ -0,0 +1,224 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'fileutils' + +describe RakeGithub::TaskSets::Environments do + include_context 'rake' + + def define_tasks(opts = {}, &block) + described_class.define({ + access_token: 'some-token', + repository: 'org/repo' + }.merge(opts), &block) + end + + it 'adds all environments tasks in the provided namespace ' \ + 'when supplied' do + define_tasks(namespace: :environments) + + expect(Rake.application) + .to(have_tasks_defined( + %w[environments:provision + environments:destroy + environments:ensure] + )) + end + + it 'adds all environments tasks in the root namespace when none supplied' do + define_tasks + + expect(Rake.application) + .to(have_tasks_defined( + %w[provision + destroy + ensure] + )) + end + + describe 'destroy task' do + it 'configures with the provided repository' do + repository = 'my-org/my-repo' + + define_tasks( + repository: + ) + + rake_task = Rake::Task['destroy'] + + expect(rake_task.creator.repository).to(eq(repository)) + end + + it 'configures with the provided access token' do + access_token = 'some-access-token' + + define_tasks( + access_token: + ) + + rake_task = Rake::Task['destroy'] + + expect(rake_task.creator.access_token).to(eq(access_token)) + end + + it 'uses a name of destroy by default' do + define_tasks + + expect(Rake.application) + .to(have_task_defined('destroy')) + end + + it 'uses the provided name when supplied' do + define_tasks(destroy_task_name: :destroy_it_all) + + expect(Rake.application) + .to(have_task_defined('destroy_it_all')) + end + + it 'has no environments by default' do + define_tasks + + rake_task = Rake::Task['destroy'] + + expect(rake_task.creator.environments).to(eq([])) + end + + it 'uses the provided environments when supplied' do + environments = [ + { name: 'release' } + ] + define_tasks( + environments: + ) + + rake_task = Rake::Task['destroy'] + + expect(rake_task.creator.environments).to(eq(environments)) + end + end + + describe 'provision task' do + it 'configures with the provided repository' do + repository = 'my-org/my-repo' + + define_tasks( + repository: + ) + + rake_task = Rake::Task['provision'] + + expect(rake_task.creator.repository).to(eq(repository)) + end + + it 'configures with the provided access token' do + access_token = 'some-access-token' + + define_tasks( + access_token: + ) + + rake_task = Rake::Task['provision'] + + expect(rake_task.creator.access_token).to(eq(access_token)) + end + + it 'uses a name of provision by default' do + define_tasks + + expect(Rake.application) + .to(have_task_defined('provision')) + end + + it 'uses the provided name when supplied' do + define_tasks(provision_task_name: :provision_things) + + expect(Rake.application) + .to(have_task_defined('provision_things')) + end + + it 'has no environments by default' do + define_tasks + + rake_task = Rake::Task['provision'] + + expect(rake_task.creator.environments).to(eq([])) + end + + it 'uses the provided environments when supplied' do + environments = [ + { name: 'release' } + ] + define_tasks( + environments: + ) + + rake_task = Rake::Task['provision'] + + expect(rake_task.creator.environments).to(eq(environments)) + end + end + + describe 'ensure task' do + it 'configures with the provided repository' do + repository = 'my-org/my-repo' + + define_tasks( + repository: + ) + + rake_task = Rake::Task['ensure'] + + expect(rake_task.creator.repository).to(eq(repository)) + end + + it 'uses a name of ensure by default' do + define_tasks + + expect(Rake.application) + .to(have_task_defined('ensure')) + end + + it 'uses the provided name when supplied' do + define_tasks(ensure_task_name: :make_sure) + + expect(Rake.application) + .to(have_task_defined('make_sure')) + end + + it 'uses a destroy task name of destroy by default' do + define_tasks + + rake_task = Rake::Task['ensure'] + + expect(rake_task.creator.destroy_task_name) + .to(eq(:destroy)) + end + + it 'uses the provided destroy task name when supplied' do + define_tasks(destroy_task_name: :destroy_it_all) + + rake_task = Rake::Task['ensure'] + + expect(rake_task.creator.destroy_task_name) + .to(eq(:destroy_it_all)) + end + + it 'uses a provision task name of provision by default' do + define_tasks + + rake_task = Rake::Task['ensure'] + + expect(rake_task.creator.provision_task_name) + .to(eq(:provision)) + end + + it 'uses the provided provision task name when supplied' do + define_tasks(provision_task_name: :provision_some_things) + + rake_task = Rake::Task['ensure'] + + expect(rake_task.creator.provision_task_name) + .to(eq(:provision_some_things)) + end + end +end diff --git a/spec/rake_github_spec.rb b/spec/rake_github_spec.rb index 168e2aa..e13b0e9 100644 --- a/spec/rake_github_spec.rb +++ b/spec/rake_github_spec.rb @@ -71,6 +71,37 @@ end end + describe 'define_environments_tasks' do + context 'when instantiating RakeGithub::TaskSets::Environments' do + # rubocop:disable RSpec/MultipleExpectations + it 'passes the provided block' do + opts = { + repository: 'org/repo' + } + + block = lambda do |t| + t.access_token = 'some-token' + t.environments = [ + { + name: 'release' + } + ] + end + + allow(RakeGithub::TaskSets::Environments).to(receive(:define)) + + described_class.define_environments_tasks(opts, &block) + + expect(RakeGithub::TaskSets::Environments) + .to(have_received(:define) do |passed_opts, &passed_block| + expect(passed_opts).to(eq(opts)) + expect(passed_block).to(eq(block)) + end) + end + # rubocop:enable RSpec/MultipleExpectations + end + end + describe 'define_repository_tasks' do context 'when instantiating RakeGithub::TaskSets::Repository' do # rubocop:disable RSpec/MultipleExpectations From 5ba92134f4cea9999d0f4876a1405be9b48593f3 Mon Sep 17 00:00:00 2001 From: Phil Helm Date: Wed, 8 Jul 2026 14:58:02 +0100 Subject: [PATCH 13/18] Add environments tasks to repository task set --- lib/rake_github/task_sets/repository.rb | 25 ++ spec/rake_github/task_sets/repository_spec.rb | 220 ++++++++++++++++++ 2 files changed, 245 insertions(+) diff --git a/lib/rake_github/task_sets/repository.rb b/lib/rake_github/task_sets/repository.rb index ba60fa5..4872a80 100644 --- a/lib/rake_github/task_sets/repository.rb +++ b/lib/rake_github/task_sets/repository.rb @@ -13,6 +13,7 @@ class Repository < RakeFactory::TaskSet parameter :access_token, required: true parameter :deploy_keys, default: [] parameter :secrets, default: [] + parameter :environments, default: [] parameter :deploy_keys_namespace, default: :deploy_keys parameter :deploy_keys_destroy_task_name, default: :destroy @@ -24,6 +25,11 @@ class Repository < RakeFactory::TaskSet parameter :secrets_provision_task_name, default: :provision parameter :secrets_ensure_task_name, default: :ensure + parameter :environments_namespace, default: :environments + parameter :environments_destroy_task_name, default: :destroy + parameter :environments_provision_task_name, default: :provision + parameter :environments_ensure_task_name, default: :ensure + task Tasks::DeployKeys::Provision, name: RakeFactory::DynamicValue.new { |ts| ts.deploy_keys_provision_task_name @@ -60,6 +66,24 @@ class Repository < RakeFactory::TaskSet destroy_task_name: RakeFactory::DynamicValue.new { |ts| ts.secrets_destroy_task_name } + task Tasks::Environments::Provision, + name: RakeFactory::DynamicValue.new { |ts| + ts.environments_provision_task_name + } + task Tasks::Environments::Destroy, + name: RakeFactory::DynamicValue.new { |ts| + ts.environments_destroy_task_name + } + task Tasks::Environments::Ensure, + name: RakeFactory::DynamicValue.new { |ts| + ts.environments_ensure_task_name + }, + provision_task_name: RakeFactory::DynamicValue.new { |ts| + ts.environments_provision_task_name + }, + destroy_task_name: RakeFactory::DynamicValue.new { |ts| + ts.environments_destroy_task_name + } task Tasks::PullRequests::Merge def define_on(application) @@ -82,6 +106,7 @@ def resolve_namespace(task_definition) case task_definition.klass.to_s when /DeployKeys/ then deploy_keys_namespace when /Secrets/ then secrets_namespace + when /Environments/ then environments_namespace when /PullRequests/ then :pull_requests end end diff --git a/spec/rake_github/task_sets/repository_spec.rb b/spec/rake_github/task_sets/repository_spec.rb index c71f7f8..43c025e 100644 --- a/spec/rake_github/task_sets/repository_spec.rb +++ b/spec/rake_github/task_sets/repository_spec.rb @@ -26,6 +26,9 @@ def define_tasks(opts = {}, &block) github:secrets:provision github:secrets:destroy github:secrets:ensure + github:environments:provision + github:environments:destroy + github:environments:ensure github:pull_requests:merge] )) end @@ -41,6 +44,9 @@ def define_tasks(opts = {}, &block) secrets:provision secrets:destroy secrets:ensure + environments:provision + environments:destroy + environments:ensure pull_requests:merge] )) end @@ -472,4 +478,218 @@ def define_tasks(opts = {}, &block) end end end + + describe 'environments tasks' do + it 'adds all environments tasks in the provided namespace ' \ + 'when supplied' do + define_tasks(environments_namespace: :repository_environments) + + expect(Rake.application) + .to(have_tasks_defined( + %w[repository_environments:provision + repository_environments:destroy + repository_environments:ensure] + )) + end + + it 'adds all environments tasks in the environments namespace when none ' \ + 'supplied' do + define_tasks + + expect(Rake.application) + .to(have_tasks_defined( + %w[environments:provision + environments:destroy + environments:ensure] + )) + end + + describe 'destroy task' do + it 'configures with the provided repository' do + repository = 'my-org/my-repo' + + define_tasks( + repository: + ) + + rake_task = Rake::Task['environments:destroy'] + + expect(rake_task.creator.repository).to(eq(repository)) + end + + it 'configures with the provided access token' do + access_token = 'some-access-token' + + define_tasks( + access_token: + ) + + rake_task = Rake::Task['environments:destroy'] + + expect(rake_task.creator.access_token).to(eq(access_token)) + end + + it 'uses a name of destroy by default' do + define_tasks + + expect(Rake.application) + .to(have_task_defined('environments:destroy')) + end + + it 'uses the provided name when supplied' do + define_tasks(environments_destroy_task_name: :destroy_it_all) + + expect(Rake.application) + .to(have_task_defined('environments:destroy_it_all')) + end + + it 'has no environments by default' do + define_tasks + + rake_task = Rake::Task['environments:destroy'] + + expect(rake_task.creator.environments).to(eq([])) + end + + it 'uses the provided environments when supplied' do + environments = [ + { name: 'release' } + ] + define_tasks( + environments: + ) + + rake_task = Rake::Task['environments:destroy'] + + expect(rake_task.creator.environments).to(eq(environments)) + end + end + + describe 'provision task' do + it 'configures with the provided repository' do + repository = 'my-org/my-repo' + + define_tasks( + repository: + ) + + rake_task = Rake::Task['environments:provision'] + + expect(rake_task.creator.repository).to(eq(repository)) + end + + it 'configures with the provided access token' do + access_token = 'some-access-token' + + define_tasks( + access_token: + ) + + rake_task = Rake::Task['environments:provision'] + + expect(rake_task.creator.access_token).to(eq(access_token)) + end + + it 'uses a name of provision by default' do + define_tasks + + expect(Rake.application) + .to(have_task_defined('environments:provision')) + end + + it 'uses the provided name when supplied' do + define_tasks(environments_provision_task_name: :provision_things) + + expect(Rake.application) + .to(have_task_defined('environments:provision_things')) + end + + it 'has no environments by default' do + define_tasks + + rake_task = Rake::Task['environments:provision'] + + expect(rake_task.creator.environments).to(eq([])) + end + + it 'uses the provided environments when supplied' do + environments = [ + { name: 'release' } + ] + define_tasks( + environments: + ) + + rake_task = Rake::Task['environments:provision'] + + expect(rake_task.creator.environments).to(eq(environments)) + end + end + + describe 'ensure task' do + it 'configures with the provided repository' do + repository = 'my-org/my-repo' + + define_tasks( + repository: + ) + + rake_task = Rake::Task['environments:ensure'] + + expect(rake_task.creator.repository).to(eq(repository)) + end + + it 'uses a name of ensure by default' do + define_tasks + + expect(Rake.application) + .to(have_task_defined('environments:ensure')) + end + + it 'uses the provided name when supplied' do + define_tasks(environments_ensure_task_name: :make_sure) + + expect(Rake.application) + .to(have_task_defined('environments:make_sure')) + end + + it 'uses a destroy task name of destroy by default' do + define_tasks + + rake_task = Rake::Task['environments:ensure'] + + expect(rake_task.creator.destroy_task_name) + .to(eq(:destroy)) + end + + it 'uses the provided destroy task name when supplied' do + define_tasks(environments_destroy_task_name: :destroy_it_all) + + rake_task = Rake::Task['environments:ensure'] + + expect(rake_task.creator.destroy_task_name) + .to(eq(:destroy_it_all)) + end + + it 'uses a provision task name of provision by default' do + define_tasks + + rake_task = Rake::Task['environments:ensure'] + + expect(rake_task.creator.provision_task_name) + .to(eq(:provision)) + end + + it 'uses the provided provision task name when supplied' do + define_tasks( + environments_provision_task_name: :provision_some_things + ) + + rake_task = Rake::Task['environments:ensure'] + + expect(rake_task.creator.provision_task_name) + .to(eq(:provision_some_things)) + end + end + end end From baac1032eec7ba420c3cb2c2fa1c0410a3e0bc17 Mon Sep 17 00:00:00 2001 From: Phil Helm Date: Wed, 8 Jul 2026 14:58:39 +0100 Subject: [PATCH 14/18] Document environments tasks --- CHANGELOG.md | 13 +++++++++ README.md | 76 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 89 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f0c8fa3..b1a5c01 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,19 @@ and this project adheres to * The `secrets` task group is also included in the tasks defined by `RakeGithub.define_repository_tasks`. +* New `environments` task group, defined via + `RakeGithub.define_environments_tasks`, for managing GitHub deployment + environments. It exposes `provision`, `destroy` and `ensure` tasks, and + supports protection rules including required reviewers. + + Environments are supplied as an array of hashes, e.g. + `[{ name: 'release', reviewers: [{ team: 'maintainers' }] }]`. Only `name` + is required; team and user reviewers are resolved to their numeric ids + before being sent to GitHub. + +* The `environments` task group is also included in the tasks defined by + `RakeGithub.define_repository_tasks`. + ### Changed * The `pull_requests:merge` task no longer requires setting the branch name and diff --git a/README.md b/README.md index 117f2dd..88b65ac 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,11 @@ end | secrets_destroy_task_name | symbol | N | Option to change the secrets destroy task name | :obliterate | :destroy | | secrets_provision_task_name | symbol | N | Option to change the secrets provision task name | :add | :provision | | secrets_ensure_task_name | symbol | N | Option to change the secrets ensure task name | :destroy_and_provision | :ensure | +| environments | array | N | Environments to provision on the repository | { name: string, reviewers: array } | [ ] | +| environments_namespace | symbol | N | Namespace to contain environments tasks | :repository_environments | :environments | +| environments_destroy_task_name | symbol | N | Option to change the environments destroy task name | :obliterate | :destroy | +| environments_provision_task_name| symbol | N | Option to change the environments provision task name | :add | :provision | +| environments_ensure_task_name | symbol | N | Option to change the environments ensure task name | :destroy_and_provision | :ensure | | namespace | symbol | N | Namespace for tasks to live in, defaults to root namespace | :rake_github | N/A | Exposes tasks: @@ -70,6 +75,9 @@ rake github:deploy_keys:provision rake github:secrets:destroy rake github:secrets:ensure rake github:secrets:provision +rake github:environments:destroy +rake github:environments:ensure +rake github:environments:provision rake github:pull_requests:merge[branch_name,commit_message] ``` @@ -150,6 +158,74 @@ the Actions and Dependabot secret stores. Destroys and then provisions the specified secrets on the repository. +### define_environments_tasks + +Sets up rake tasks for managing GitHub deployment environments, including +protection rules such as required reviewers. Team and user reviewers are +resolved to their numeric ids before being sent to GitHub. + +```ruby +require 'rake_github' + +RakeGithub.define_environments_tasks( + namespace: :environments, + repository: 'org/repo', # required +) do |t| + t.access_token = "your_github_access_token" # required + t.environments = [ + { + name: 'release', + wait_timer: 0, + prevent_self_review: false, + reviewers: [ + { team: 'maintainers' }, + { user: 'someone' } + ], + deployment_branch_policy: { + protected_branches: true, + custom_branch_policies: false + } + } + ] +end +``` + +Only `name` is required for each environment; every other key is optional and +is omitted from the GitHub API payload when absent. + +| Parameter | Type | Required | Description | Example | Default | +|----------------------|--------|----------|------------------------------------------------------------|---------------------------------------------|----------------| +| repository | string | Y | Repository to perform tasks upon | 'organisation/repository_name' | N/A | +| access_token | string | Y | Github token for authorisation | 'ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx' | N/A | +| environments | array | N | Environments to provision on the repository | { name: string, reviewers: array } | [ ] | +| destroy_task_name | symbol | N | Option to change the destroy task name | :obliterate | :destroy | +| provision_task_name | symbol | N | Option to change the provision task name | :add | :provision | +| ensure_task_name | symbol | N | Option to change the ensure task name | :destroy_and_provision | :ensure | +| namespace | symbol | N | Namespace for tasks to live in, defaults to root namespace | :environments | N/A | + +Exposes tasks: + +```shell +$ rake -T + +rake environments:destroy +rake environments:ensure +rake environments:provision +``` + +#### environments:provision + +Provisions the specified environments on the repository, resolving any team +and user reviewers to their numeric ids. + +#### environments:destroy + +Destroys the specified environments from the repository. + +#### environments:ensure + +Destroys and then provisions the specified environments on the repository. + ### define_release_task ## Development From 59ea70eeb66f7e5eee87191368930e9495d674bb Mon Sep 17 00:00:00 2001 From: Phil Helm Date: Wed, 8 Jul 2026 15:06:05 +0100 Subject: [PATCH 15/18] Raise clear error for invalid environment reviewer A reviewer hash with neither :team nor :user previously fell through to a nil user lookup at runtime. Also cover destroy continuing past a fully absent secret. --- .../tasks/environments/provision.rb | 6 +++- .../tasks/environments/provision_spec.rb | 13 +++++++ .../rake_github/tasks/secrets/destroy_spec.rb | 34 +++++++++++++++++++ 3 files changed, 52 insertions(+), 1 deletion(-) diff --git a/lib/rake_github/tasks/environments/provision.rb b/lib/rake_github/tasks/environments/provision.rb index 925144e..d4c7c99 100644 --- a/lib/rake_github/tasks/environments/provision.rb +++ b/lib/rake_github/tasks/environments/provision.rb @@ -53,8 +53,12 @@ def resolve_reviewers(client, org, reviewers) def resolve_reviewer(client, org, reviewer) if reviewer[:team] { type: 'Team', id: resolve_team_id(client, org, reviewer[:team]) } - else + elsif reviewer[:user] { type: 'User', id: resolve_user_id(client, reviewer[:user]) } + else + raise(ArgumentError, + "invalid reviewer: #{reviewer.inspect} " \ + '(must have :team or :user)') end end diff --git a/spec/rake_github/tasks/environments/provision_spec.rb b/spec/rake_github/tasks/environments/provision_spec.rb index 2c4ea2a..ef473ef 100644 --- a/spec/rake_github/tasks/environments/provision_spec.rb +++ b/spec/rake_github/tasks/environments/provision_spec.rb @@ -356,6 +356,19 @@ def define_task(opts = {}, &block) expect(client).to(have_received(:user).once) end + it 'raises for a reviewer with neither team nor user' do + define_task( + repository: 'infrablocks/rake_github', + access_token: 'some-token', + environments: [ + { name: 'release', reviewers: [{ nonsense: 'value' }] } + ] + ) + + expect { Rake::Task['environments:provision'].invoke } + .to(raise_error(ArgumentError, /must have :team or :user/)) + end + def team_resource(id) agent = Sawyer::Agent.new('https://api.github.com') Sawyer::Resource.new(agent, { id: }) diff --git a/spec/rake_github/tasks/secrets/destroy_spec.rb b/spec/rake_github/tasks/secrets/destroy_spec.rb index 898e670..4cc9788 100644 --- a/spec/rake_github/tasks/secrets/destroy_spec.rb +++ b/spec/rake_github/tasks/secrets/destroy_spec.rb @@ -145,6 +145,40 @@ def define_task(opts = {}, &block) .with(repository, 'SOME_SECRET')) end + # rubocop:disable RSpec/MultipleExpectations + it 'still deletes remaining secrets when an earlier secret is absent' do + repository = 'org/repo' + + client = stub_github_client + allow(client) + .to(receive(:delete_actions_secret) + .with(repository, 'SECRET_ONE') + .and_raise(Octokit::NotFound)) + allow(client) + .to(receive(:delete_dependabot_secret) + .with(repository, 'SECRET_ONE') + .and_raise(Octokit::NotFound)) + + define_task( + repository:, + access_token: 'some-token', + secrets: [ + { name: 'SECRET_ONE', value: 'value-one' }, + { name: 'SECRET_TWO', value: 'value-two' } + ] + ) + + Rake::Task['secrets:destroy'].invoke + + expect(client) + .to(have_received(:delete_actions_secret) + .with(repository, 'SECRET_TWO')) + expect(client) + .to(have_received(:delete_dependabot_secret) + .with(repository, 'SECRET_TWO')) + end + # rubocop:enable RSpec/MultipleExpectations + it 'does not raise when the dependabot secret is absent' do repository = 'org/repo' From ba3b860367af6686674a996f794e7339d96f29bf Mon Sep 17 00:00:00 2001 From: Phil Helm Date: Wed, 8 Jul 2026 15:47:54 +0100 Subject: [PATCH 16/18] Make Dependabot secret store opt-in per secret Previously every secret was written to both the Actions and Dependabot secret stores. Write to Actions by default and only to Dependabot when a secret sets `dependabot: true`, so Actions-only credentials are not exposed to the Dependabot attack surface. Skip fetching the Dependabot public key when no secret opts in. --- lib/rake_github/tasks/secrets/provision.rb | 23 +++-- .../tasks/secrets/provision_spec.rb | 94 ++++++++++++++++++- 2 files changed, 105 insertions(+), 12 deletions(-) diff --git a/lib/rake_github/tasks/secrets/provision.rb b/lib/rake_github/tasks/secrets/provision.rb index 90b8e4a..7bd85d0 100644 --- a/lib/rake_github/tasks/secrets/provision.rb +++ b/lib/rake_github/tasks/secrets/provision.rb @@ -42,18 +42,25 @@ def provision_actions_secrets(client, task) end def provision_dependabot_secrets(client, task) + secrets = task.secrets.select { |secret| secret[:dependabot] } + return if secrets.empty? + public_key = client.get_dependabot_public_key(task.repository) - task.secrets.each do |secret| - $stdout.print "Adding '#{secret[:name]}' to Dependabot... " - client.create_or_update_dependabot_secret( - task.repository, - secret[:name], - secret_options(public_key, secret[:value]) - ) - $stdout.puts 'Done.' + secrets.each do |secret| + write_dependabot_secret(client, task, public_key, secret) end end + def write_dependabot_secret(client, task, public_key, secret) + $stdout.print "Adding '#{secret[:name]}' to Dependabot... " + client.create_or_update_dependabot_secret( + task.repository, + secret[:name], + secret_options(public_key, secret[:value]) + ) + $stdout.puts 'Done.' + end + def secret_options(public_key, value) { key_id: public_key.key_id, diff --git a/spec/rake_github/tasks/secrets/provision_spec.rb b/spec/rake_github/tasks/secrets/provision_spec.rb index 07efc86..6dab790 100644 --- a/spec/rake_github/tasks/secrets/provision_spec.rb +++ b/spec/rake_github/tasks/secrets/provision_spec.rb @@ -116,6 +116,69 @@ def define_task(opts = {}, &block) end # rubocop:enable RSpec/MultipleExpectations + it 'does not write to the dependabot store by default' do + repository = 'org/repo' + + client = stub_github_client + + define_task( + repository:, + access_token: 'some-token', + secrets: [{ name: 'SOME_SECRET', value: 'some-value' }] + ) + + Rake::Task['secrets:provision'].invoke + + expect(client) + .not_to(have_received(:create_or_update_dependabot_secret)) + end + + it 'does not fetch the dependabot public key when no secret opts in' do + client = stub_github_client + + define_task( + repository: 'org/repo', + access_token: 'some-token', + secrets: [{ name: 'SOME_SECRET', value: 'some-value' }] + ) + + Rake::Task['secrets:provision'].invoke + + expect(client).not_to(have_received(:get_dependabot_public_key)) + end + + # rubocop:disable RSpec/MultipleExpectations + it 'writes every secret to actions but only opted-in secrets to dependabot' do + repository = 'org/repo' + + client = stub_github_client + + define_task( + repository:, + access_token: 'some-token', + secrets: [ + { name: 'ACTIONS_ONLY', value: 'value-one' }, + { name: 'BOTH', value: 'value-two', dependabot: true } + ] + ) + + Rake::Task['secrets:provision'].invoke + + expect(client) + .to(have_received(:create_or_update_actions_secret) + .with(repository, 'ACTIONS_ONLY', anything)) + expect(client) + .to(have_received(:create_or_update_actions_secret) + .with(repository, 'BOTH', anything)) + expect(client) + .to(have_received(:create_or_update_dependabot_secret) + .with(repository, 'BOTH', anything)) + expect(client) + .not_to(have_received(:create_or_update_dependabot_secret) + .with(repository, 'ACTIONS_ONLY', anything)) + end + # rubocop:enable RSpec/MultipleExpectations + it 'creates or updates the dependabot secret with a sealed value' do repository = 'org/repo' access_token = 'some-token' @@ -129,7 +192,7 @@ def define_task(opts = {}, &block) define_task( repository:, access_token:, - secrets: [{ name: 'SOME_SECRET', value: 'some-value' }] + secrets: [{ name: 'SOME_SECRET', value: 'some-value', dependabot: true }] ) Rake::Task['secrets:provision'].invoke @@ -155,7 +218,7 @@ def define_task(opts = {}, &block) define_task( repository:, access_token: 'some-token', - secrets: [{ name: 'SOME_SECRET', value: 'some-value' }] + secrets: [{ name: 'SOME_SECRET', value: 'some-value', dependabot: true }] ) Rake::Task['secrets:provision'].invoke @@ -192,6 +255,29 @@ def define_task(opts = {}, &block) hash_including(key_id: 'actions-key-id'))) end + it 'passes the fetched key id to the dependabot secret' do + repository = 'org/repo' + private_key = RbNaCl::PrivateKey.generate + + client = stub_github_client + stub_dependabot_public_key( + client, repository, 'dependabot-key-id', private_key + ) + + define_task( + repository:, + access_token: 'some-token', + secrets: [{ name: 'SOME_SECRET', value: 'some-value', dependabot: true }] + ) + + Rake::Task['secrets:provision'].invoke + + expect(client) + .to(have_received(:create_or_update_dependabot_secret) + .with(repository, 'SOME_SECRET', + hash_including(key_id: 'dependabot-key-id'))) + end + # rubocop:disable RSpec/MultipleExpectations it 'fetches each public key once per run rather than per secret' do repository = 'org/repo' @@ -207,8 +293,8 @@ def define_task(opts = {}, &block) repository:, access_token: 'some-token', secrets: [ - { name: 'SECRET_ONE', value: 'value-one' }, - { name: 'SECRET_TWO', value: 'value-two' } + { name: 'SECRET_ONE', value: 'value-one', dependabot: true }, + { name: 'SECRET_TWO', value: 'value-two', dependabot: true } ] ) From 6cb78d0e44f622de3bfed4feb33d1cdcb6e07bad Mon Sep 17 00:00:00 2001 From: Phil Helm Date: Wed, 8 Jul 2026 15:48:07 +0100 Subject: [PATCH 17/18] Omit reviewers from environment payload when empty An explicit empty `reviewers: []` was sent to the API, clearing any existing required reviewers on an already-provisioned environment. Treat an empty array like an absent key and omit it from the payload. --- .../tasks/environments/provision.rb | 2 +- .../tasks/environments/provision_spec.rb | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/lib/rake_github/tasks/environments/provision.rb b/lib/rake_github/tasks/environments/provision.rb index d4c7c99..282af8f 100644 --- a/lib/rake_github/tasks/environments/provision.rb +++ b/lib/rake_github/tasks/environments/provision.rb @@ -45,7 +45,7 @@ def environment_options(client, org, environment) end def resolve_reviewers(client, org, reviewers) - return nil if reviewers.nil? + return nil if reviewers.nil? || reviewers.empty? reviewers.map { |reviewer| resolve_reviewer(client, org, reviewer) } end diff --git a/spec/rake_github/tasks/environments/provision_spec.rb b/spec/rake_github/tasks/environments/provision_spec.rb index ef473ef..5b87367 100644 --- a/spec/rake_github/tasks/environments/provision_spec.rb +++ b/spec/rake_github/tasks/environments/provision_spec.rb @@ -267,6 +267,24 @@ def define_task(opts = {}, &block) .with(repository, 'release', {})) end + it 'omits reviewers when an empty array is supplied' do + repository = 'org/repo' + + client = stub_github_client + + define_task( + repository:, + access_token: 'some-token', + environments: [{ name: 'release', reviewers: [] }] + ) + + Rake::Task['environments:provision'].invoke + + expect(client) + .to(have_received(:create_or_update_environment) + .with(repository, 'release', {})) + end + it 'retains falsy optional values such as wait_timer zero' do repository = 'org/repo' From ea855a83b69ff269f0b98c9995ecb00b1c219951 Mon Sep 17 00:00:00 2001 From: Phil Helm Date: Wed, 8 Jul 2026 15:48:07 +0100 Subject: [PATCH 18/18] Document dependency requirements and secret opt-in behaviour Record the raised octokit lower bound and the new rbnacl/libsodium runtime requirement in the changelog and README, document the per-secret Dependabot opt-in, and note that destroying an environment is irreversible. --- CHANGELOG.md | 13 +++++++++++-- README.md | 44 ++++++++++++++++++++++++++++++++++++-------- 2 files changed, 47 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b1a5c01..4595794 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,8 +12,9 @@ and this project adheres to * New `secrets` task group, defined via `RakeGithub.define_secrets_tasks`, for managing GitHub repository secrets. It exposes `provision`, `destroy` and - `ensure` tasks. Each secret is written to both the Actions and Dependabot - secret stores so that Dependabot-triggered workflow runs can read them. + `ensure` tasks. Each secret is written to the Actions secret store, and + additionally to the Dependabot secret store when it sets `dependabot: true`, + so that Dependabot-triggered workflow runs can read the secrets they need. Secrets are supplied as an array of hashes, e.g. `[{ name: 'SOME_SECRET', value: 'plaintext' }]`, and are encrypted @@ -39,6 +40,14 @@ and this project adheres to * The `pull_requests:merge` task no longer requires setting the branch name and commit message provide via arguments as parameters within the task definition. +* The `octokit` dependency lower bound has been raised from `>= 4.16` to + `>= 7.0`, as the Dependabot secret methods used by the `secrets` task group + were introduced in octokit 7.0. Consumers pinning octokit below 7.0 will need + to relax that constraint. +* Added a new runtime dependency on `rbnacl` (`~> 7.1`), used to encrypt + secrets client-side. `rbnacl` requires the native libsodium library to be + installed on the host (see the Installation section of the README); this is + only needed when using the `secrets` task group. ## [0.9.0] 2022-01-28 diff --git a/README.md b/README.md index 88b65ac..797cea2 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,21 @@ Or install it yourself as: $ gem install rake_github +### System dependencies + +The `secrets` task group encrypts secrets client-side using `rbnacl`, which +requires the native [libsodium](https://libsodium.org) library to be present on +the host. Install it before running any `secrets` task: + + # macOS + $ brew install libsodium + + # Debian/Ubuntu + $ apt-get install libsodium23 + +libsodium is only required when using the `secrets` tasks; the other task groups +have no additional system dependencies. + ## Usage ### define_deploy_keys_tasks @@ -52,7 +67,7 @@ end | deploy_keys_destroy_task_name | symbol | N | Option to change the destroy task name | :obliterate | :destroy | | deploy_keys_provision_task_name | symbol | N | Option to change the provision task name | :add | :provision | | deploy_keys_ensure_task_name | symbol | N | Option to change the ensure task name | :destroy_and_provision | :ensure | -| secrets | array | N | Secrets to provision on the repository | { name: string, value: string } | [ ] | +| secrets | array | N | Secrets to provision on the repository | { name: string, value: string, dependabot: bool } | [ ] | | secrets_namespace | symbol | N | Namespace to contain secrets tasks | :repository_secrets | :secrets | | secrets_destroy_task_name | symbol | N | Option to change the secrets destroy task name | :obliterate | :destroy | | secrets_provision_task_name | symbol | N | Option to change the secrets provision task name | :add | :provision | @@ -103,9 +118,13 @@ the `%s` placeholder, e.g. `pull_requests:merge[new_feature,"%s [skip ci]"]`. ### define_secrets_tasks Sets up rake tasks for managing repository secrets. Each secret is written to -both the Actions and Dependabot secret stores, so that Dependabot-triggered -workflow runs can read them. Secrets are encrypted client-side before being -sent to GitHub. +the Actions secret store by default. A secret can additionally be written to the +Dependabot secret store by setting `dependabot: true`, so that +Dependabot-triggered workflow runs can read the secrets they need. Secrets are +encrypted client-side before being sent to GitHub. + +These tasks require the native libsodium library to be installed on the host — +see [System dependencies](#system-dependencies) above. ```ruby require 'rake_github' @@ -119,6 +138,11 @@ RakeGithub.define_secrets_tasks( { name: 'SOME_SECRET', value: 'some-plaintext-value' + }, + { + name: 'SHARED_SECRET', + value: 'another-plaintext-value', + dependabot: true # also write to the Dependabot store } ] end @@ -128,7 +152,7 @@ end |----------------------|--------|----------|------------------------------------------------------------|---------------------------------------------|------------| | repository | string | Y | Repository to perform tasks upon | 'organisation/repository_name' | N/A | | access_token | string | Y | Github token for authorisation | 'ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx' | N/A | -| secrets | array | N | Secrets to provision on the repository | { name: string, value: string } | [ ] | +| secrets | array | N | Secrets to provision on the repository | { name: string, value: string, dependabot: bool } | [ ] | | destroy_task_name | symbol | N | Option to change the destroy task name | :obliterate | :destroy | | provision_task_name | symbol | N | Option to change the provision task name | :add | :provision | | ensure_task_name | symbol | N | Option to change the ensure task name | :destroy_and_provision | :ensure | @@ -146,8 +170,9 @@ rake secrets:provision #### secrets:provision -Provisions the specified secrets to the repository, writing each one to both -the Actions and Dependabot secret stores. +Provisions the specified secrets to the repository, writing each one to the +Actions secret store and, for secrets marked `dependabot: true`, also to the +Dependabot secret store. #### secrets:destroy @@ -220,7 +245,10 @@ and user reviewers to their numeric ids. #### environments:destroy -Destroys the specified environments from the repository. +Destroys the specified environments from the repository. This is irreversible +and also discards each environment's protection rules, environment secrets and +deployment history. `environments:ensure` therefore fully recreates each +environment rather than patching it in place. #### environments:ensure