├── .github └── workflows │ └── ruby.yml ├── .gitignore ├── .ruby-version ├── Appraisals ├── Gemfile ├── LICENSE.txt ├── README.md ├── Rakefile ├── gemfiles ├── rails_4.2.gemfile ├── rails_5.0.gemfile └── rails_5.1.gemfile ├── github_webhook.gemspec ├── lib ├── github_webhook.rb └── github_webhook │ ├── processor.rb │ ├── railtie.rb │ └── version.rb └── spec ├── github_webhook └── processor_spec.rb └── spec_helper.rb /.github/workflows/ruby.yml: -------------------------------------------------------------------------------- 1 | name: Ruby 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | build: 11 | strategy: 12 | fail-fast: false 13 | matrix: 14 | ruby: ['2.7', '3.0', '3.1', '3.2', head] 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v2 18 | - name: Set up Ruby 3.2 19 | uses: ruby/setup-ruby@v1 20 | with: 21 | ruby-version: ${{ matrix.ruby }} 22 | bundler-cache: true 23 | - name: Build and test with Rake 24 | run: bundle exec rake 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | *.gem 3 | coverage 4 | /Gemfile.lock 5 | /gemfiles/*.gemfile.lock 6 | -------------------------------------------------------------------------------- /.ruby-version: -------------------------------------------------------------------------------- 1 | 3.2.0 2 | -------------------------------------------------------------------------------- /Appraisals: -------------------------------------------------------------------------------- 1 | appraise "rails-4.2" do 2 | gem "rails", "~> 4.2.0" 3 | end 4 | 5 | appraise "rails-5.0" do 6 | gem "rails", "~> 5.0.0" 7 | end 8 | 9 | appraise "rails-5.1" do 10 | gem "rails", "~> 5.1.0" 11 | end 12 | 13 | appraise "rails-6.0" do 14 | gem "rails", "~> 6.0" 15 | end 16 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | # Specify your gem's dependencies in github_webhook.gemspec 4 | gemspec 5 | 6 | gem 'simplecov', require: false 7 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014 Sebastien Saunier 2 | 3 | MIT License 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | [![Gem Version](https://badge.fury.io/rb/github_webhook.svg)](http://badge.fury.io/rb/github_webhook) 3 | 4 | 5 | # Github Webhook for Rails 6 | 7 | This gem will help you to quickly setup a route in your Rails application which listens 8 | to a [GitHub webhook](https://developer.github.com/webhooks/) 9 | 10 | ## Alternatives 11 | 12 | If you want to use this logic outside of Rails, you should consider the following gems (cf [#19](https://github.com/ssaunier/github_webhook/issues/19)): 13 | 14 | - [`sinatra-github_webhooks`](https://github.com/chrismytton/sinatra-github_webhooks) 15 | - [`rack-github_webhooks`](https://github.com/chrismytton/rack-github_webhooks) 16 | 17 | If you are on Rails, please read on! 18 | 19 | ## Installation 20 | 21 | Add this line to your application's Gemfile: 22 | 23 | ```ruby 24 | gem 'github_webhook', '~> 1.4' 25 | ``` 26 | 27 | And then execute: 28 | 29 | $ bundle install 30 | 31 | ## Configuration 32 | 33 | First, configure a route to receive the github webhook POST requests. 34 | 35 | ```ruby 36 | # config/routes.rb 37 | resource :github_webhooks, only: :create, defaults: { formats: :json } 38 | ``` 39 | 40 | Then create a new controller: 41 | 42 | ```ruby 43 | # app/controllers/github_webhooks_controller.rb 44 | class GithubWebhooksController < ActionController::API 45 | include GithubWebhook::Processor 46 | 47 | # Handle push event 48 | def github_push(payload) 49 | # TODO: handle push webhook 50 | end 51 | 52 | # Handle create event 53 | def github_create(payload) 54 | # TODO: handle create webhook 55 | end 56 | 57 | private 58 | 59 | def webhook_secret(payload) 60 | ENV['GITHUB_WEBHOOK_SECRET'] 61 | end 62 | end 63 | ``` 64 | 65 | Add as many instance methods as events you want to handle in 66 | your controller. 67 | 68 | All events are prefixed with `github_`. So, a `push` event can be handled by `github_push(payload)`, or a `create` event can be handled by `github_create(payload)`, etc. 69 | 70 | You can read the [full list of events](https://developer.github.com/v3/activity/events/types/) GitHub can notify you about. 71 | 72 | ## Adding the Webhook to your git repository: 73 | 74 | First, install [octokit](https://github.com/octokit/octokit.rb), then run a rails console. 75 | 76 | ```bash 77 | $ gem install octokit 78 | $ rails console 79 | ``` 80 | 81 | In the rails console, add the WebHook to GitHub: 82 | 83 | ```ruby 84 | require "octokit" 85 | client = Octokit::Client.new(:login => 'ssaunier', :password => 's3cr3t!!!') 86 | 87 | repo = "ssaunier/github_webhook" 88 | callback_url = "yourdomain.com/github_webhooks" 89 | webhook_secret = "a_gr34t_s3cr3t" # Must be set after that in ENV['GITHUB_WEBHOOK_SECRET'] 90 | 91 | # Create the WebHook 92 | client.subscribe "https://github.com/#{repo}/events/push.json", callback_url, webhook_secret 93 | ``` 94 | 95 | The secret is set at the webhook creation. Store it in an environment variable, 96 | `GITHUB_WEBHOOK_SECRET` as per the example. It is important to have such a secret, 97 | as it will guarantee that your process legit webhooks requests, thus only from GitHub. 98 | 99 | You can have an overview of your webhooks at the following URL: 100 | 101 | ``` 102 | https://github.com/:username/:repo/settings/hooks 103 | ``` 104 | 105 | ## Contributing 106 | 107 | ### Specs 108 | 109 | This project uses [Appraisal](https://github.com/thoughtbot/appraisal) to test against multiple 110 | versions of Rails. 111 | 112 | On Travis, builds are also run on multiple versions of Ruby, each with multiple versions of Rails. 113 | 114 | When you run `bundle install`, it will use the latest version of Rails. 115 | You can then run `bundle exec rake spec` to run the test with that version of Rails. 116 | 117 | To run the specs against each version of Rails, use `bundle exec appraisal rake spec`. 118 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env rake 2 | require "rubygems" 3 | require "bundler/setup" 4 | 5 | require "bundler/gem_tasks" 6 | 7 | require 'rspec' 8 | require 'rspec/core/rake_task' 9 | 10 | desc "Run all RSpec test examples" 11 | RSpec::Core::RakeTask.new do |spec| 12 | spec.rspec_opts = ["-c", "-f progress"] 13 | spec.pattern = 'spec/**/*_spec.rb' 14 | end 15 | 16 | task :default => :spec -------------------------------------------------------------------------------- /gemfiles/rails_4.2.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "rails", "~> 4.2.0" 6 | 7 | gemspec path: "../" 8 | -------------------------------------------------------------------------------- /gemfiles/rails_5.0.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "rails", "~> 5.0.0" 6 | 7 | gemspec path: "../" 8 | -------------------------------------------------------------------------------- /gemfiles/rails_5.1.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "rails", "~> 5.1.0" 6 | 7 | gemspec path: "../" 8 | -------------------------------------------------------------------------------- /github_webhook.gemspec: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | lib = File.expand_path('../lib', __FILE__) 3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 4 | require 'github_webhook/version' 5 | 6 | Gem::Specification.new do |spec| 7 | spec.name = "github_webhook" 8 | spec.version = GithubWebhook::VERSION 9 | spec.authors = ["Sebastien Saunier"] 10 | spec.email = ["seb@saunier.me"] 11 | spec.summary = %q{Process GitHub Webhooks in your Rails app (Controller mixin)} 12 | spec.description = spec.summary 13 | spec.homepage = "https://github.com/ssaunier/github_webhook" 14 | spec.license = "MIT" 15 | 16 | spec.files = `git ls-files -z`.split("\x0") 17 | spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) } 18 | spec.test_files = spec.files.grep(%r{^(test|spec|features)/}) 19 | spec.require_paths = ["lib"] 20 | 21 | spec.add_dependency "rack", ">= 1.3" 22 | spec.add_dependency "activesupport", ">= 4" 23 | spec.add_dependency "railties", ">= 4" 24 | 25 | spec.add_development_dependency "rake", "~> 12.3" 26 | spec.add_development_dependency "rspec", "~> 3.9" 27 | spec.add_development_dependency "appraisal" 28 | end 29 | -------------------------------------------------------------------------------- /lib/github_webhook.rb: -------------------------------------------------------------------------------- 1 | require 'json' 2 | require 'openssl' 3 | require 'active_support/concern' 4 | require 'active_support/core_ext/hash/indifferent_access' 5 | 6 | require 'github_webhook/version' 7 | require 'github_webhook/processor' 8 | require 'github_webhook/railtie' 9 | 10 | module GithubWebhook 11 | class < console.log(e.text)) 13 | GITHUB_EVENTS = %w( 14 | branch_protection_rule 15 | check_run 16 | check_suite 17 | code_scanning_alert 18 | commit_comment 19 | create 20 | delete 21 | dependabot_alert 22 | deploy_key 23 | deployment 24 | deployment_status 25 | discussion 26 | discussion_comment 27 | fork 28 | github_app_authorization 29 | gollum 30 | installation 31 | installation_repositories 32 | installation_target 33 | issue_comment 34 | issues 35 | label 36 | marketplace_purchase 37 | member 38 | membership 39 | merge_group 40 | meta 41 | milestone 42 | org_block 43 | organization 44 | package 45 | page_build 46 | ping 47 | project_card 48 | project 49 | project_column 50 | projects_v2 51 | projects_v2_item 52 | public 53 | pull_request 54 | pull_request_review_comment 55 | pull_request_review 56 | pull_request_review_thread 57 | push 58 | registry_package 59 | release 60 | repository 61 | repository_dispatch 62 | repository_import 63 | repository_vulnerability_alert 64 | secret_scanning_alert 65 | secret_scanning_alert_location 66 | security_advisory 67 | security_and_analysis 68 | sponsorship 69 | star 70 | status 71 | team_add 72 | team 73 | watch 74 | workflow_dispatch 75 | workflow_job 76 | workflow_run 77 | ) 78 | 79 | def create 80 | if self.respond_to?(event_method, true) 81 | self.send event_method, json_body 82 | head(:ok) 83 | else 84 | raise AbstractController::ActionNotFound.new("GithubWebhooksController##{event_method} not implemented") 85 | end 86 | end 87 | 88 | def github_ping(payload) 89 | GithubWebhook.logger && GithubWebhook.logger.info("[GithubWebhook::Processor] Hook ping "\ 90 | "received, hook_id: #{payload[:hook_id]}, #{payload[:zen]}") 91 | end 92 | 93 | private 94 | 95 | HMAC_DIGEST = OpenSSL::Digest.new('sha256') 96 | 97 | def authenticate_github_request! 98 | raise AbstractController::ActionNotFound.new unless respond_to?(:webhook_secret, true) 99 | secret = webhook_secret(json_body) 100 | 101 | expected_signature = "sha256=#{OpenSSL::HMAC.hexdigest(HMAC_DIGEST, secret, request_body)}" 102 | unless ActiveSupport::SecurityUtils.secure_compare(signature_header, expected_signature) 103 | GithubWebhook.logger && GithubWebhook.logger.warn("[GithubWebhook::Processor] signature "\ 104 | "invalid, actual: #{signature_header}, expected: #{expected_signature}") 105 | raise AbstractController::ActionNotFound 106 | end 107 | end 108 | 109 | def check_github_event! 110 | unless GITHUB_EVENTS.include?(request.headers['X-GitHub-Event']) 111 | raise AbstractController::ActionNotFound.new("#{request.headers['X-GitHub-Event']} is not a whitelisted GitHub event. See https://developer.github.com/v3/activity/events/types/") 112 | end 113 | end 114 | 115 | def request_body 116 | @request_body ||= ( 117 | request.body.rewind 118 | request.body.read 119 | ) 120 | end 121 | 122 | def json_body 123 | @json_body ||= ( 124 | content_type = request.headers['Content-Type'] 125 | case content_type 126 | when 'application/x-www-form-urlencoded' 127 | require 'rack' 128 | payload = Rack::Utils.parse_query(request_body)['payload'] 129 | when 'application/json' 130 | payload = request_body 131 | else 132 | raise AbstractController::ActionNotFound.new( 133 | "Content-Type #{content_type} is not supported. Use 'application/x-www-form-urlencoded' or 'application/json") 134 | end 135 | ActiveSupport::HashWithIndifferentAccess.new(JSON.load(payload)) 136 | ) 137 | end 138 | 139 | def signature_header 140 | @signature_header ||= request.headers['X-Hub-Signature-256'] || '' 141 | end 142 | 143 | def event_method 144 | @event_method ||= "github_#{request.headers['X-GitHub-Event']}".to_sym 145 | end 146 | end 147 | -------------------------------------------------------------------------------- /lib/github_webhook/railtie.rb: -------------------------------------------------------------------------------- 1 | require 'rails' 2 | 3 | module GithubWebhook 4 | class Railties < ::Rails::Railtie 5 | initializer 'Rails logger' do 6 | GithubWebhook.logger = Rails.logger 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/github_webhook/version.rb: -------------------------------------------------------------------------------- 1 | module GithubWebhook 2 | VERSION = "1.4.2" 3 | end 4 | -------------------------------------------------------------------------------- /spec/github_webhook/processor_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | module GithubWebhook 4 | describe Processor do 5 | 6 | class Request 7 | attr_accessor :headers, :body 8 | 9 | def initialize 10 | @headers = {} 11 | @body = StringIO.new 12 | end 13 | end 14 | 15 | class ControllerWithoutSecret 16 | ### Helpers to mock ActionController::Base behavior 17 | attr_accessor :request, :pushed 18 | 19 | def self.skip_before_action(*args); end 20 | def self.before_action(*args); end 21 | def head(*args); end 22 | ### 23 | 24 | include GithubWebhook::Processor 25 | 26 | def github_push(payload) 27 | @pushed = payload[:foo] 28 | end 29 | end 30 | 31 | class ControllerWithPrivateSecret < ControllerWithoutSecret 32 | private 33 | def webhook_secret(payload) 34 | "secret" 35 | end 36 | end 37 | 38 | class Controller < ControllerWithoutSecret 39 | def webhook_secret(payload) 40 | "secret" 41 | end 42 | end 43 | 44 | let(:controller_class) { Controller } 45 | 46 | let(:controller) do 47 | controller = controller_class.new 48 | controller.request = Request.new 49 | controller 50 | end 51 | 52 | describe "#create" do 53 | context 'when #webhook_secret is not defined' do 54 | let(:controller_class) { ControllerWithoutSecret } 55 | 56 | it "raises a AbstractController::ActionNotFound" do 57 | expect { controller.send :authenticate_github_request! } 58 | .to raise_error(AbstractController::ActionNotFound) 59 | end 60 | end 61 | 62 | context 'when #webhook_secret is private' do 63 | let(:controller_class) { ControllerWithPrivateSecret } 64 | 65 | it "calls the #push method in controller" do 66 | expect(controller).to receive(:github_push) 67 | controller.request.body = StringIO.new({ :foo => "bar" }.to_json.to_s) 68 | controller.request.headers['X-Hub-Signature-256'] = "sha256=3f3ab3986b656abb17af3eb1443ed6c08ef8fff9fea83915909d1b421aec89be" 69 | controller.request.headers['X-GitHub-Event'] = 'push' 70 | controller.request.headers['Content-Type'] = 'application/json' 71 | controller.send :authenticate_github_request! # Manually as we don't have the before_filter logic in our Mock object 72 | controller.create 73 | end 74 | end 75 | 76 | it "calls the #push method in controller (json)" do 77 | controller.request.body = StringIO.new({ :foo => "bar" }.to_json.to_s) 78 | controller.request.headers['X-Hub-Signature-256'] = "sha256=3f3ab3986b656abb17af3eb1443ed6c08ef8fff9fea83915909d1b421aec89be" 79 | controller.request.headers['X-GitHub-Event'] = 'push' 80 | controller.request.headers['Content-Type'] = 'application/json' 81 | controller.send :authenticate_github_request! # Manually as we don't have the before_action logic in our Mock object 82 | controller.create 83 | expect(controller.pushed).to eq "bar" 84 | end 85 | 86 | it "calls the #push method (x-www-form-urlencoded encoded)" do 87 | body = "payload=" + CGI::escape({ :foo => "bar" }.to_json.to_s) 88 | controller.request.body = StringIO.new(body) 89 | controller.request.headers['X-Hub-Signature-256'] = "sha256=cefe60b775fcb22483ceece8f20be4869868a20fb4aa79829e53c1de61b99d01" 90 | controller.request.headers['X-GitHub-Event'] = 'push' 91 | controller.request.headers['Content-Type'] = 'application/x-www-form-urlencoded' 92 | controller.send :authenticate_github_request! # Manually as we don't have the before_action logic in our Mock object 93 | controller.create 94 | expect(controller.pushed).to eq "bar" 95 | end 96 | 97 | it "raises an error when signature does not match" do 98 | controller.request.body = StringIO.new({ :foo => "bar" }.to_json.to_s) 99 | controller.request.headers['X-Hub-Signature-256'] = "sha256=FOOBAR" 100 | controller.request.headers['X-GitHub-Event'] = 'push' 101 | controller.request.headers['Content-Type'] = 'application/json' 102 | expect { controller.send :authenticate_github_request! }.to raise_error(AbstractController::ActionNotFound) 103 | end 104 | 105 | it "raises an error when the github event method is not implemented" do 106 | controller.request.headers['X-GitHub-Event'] = 'deployment' 107 | controller.request.headers['Content-Type'] = 'application/json' 108 | expect { controller.create }.to raise_error( 109 | AbstractController::ActionNotFound, 110 | "GithubWebhooksController#github_deployment not implemented", 111 | ) 112 | end 113 | 114 | it "raises an error when the github event is not in the whitelist" do 115 | controller.request.headers['X-GitHub-Event'] = 'fake_event' 116 | controller.request.headers['Content-Type'] = 'application/json' 117 | expect { controller.send :check_github_event! }.to raise_error( 118 | AbstractController::ActionNotFound, 119 | "fake_event is not a whitelisted GitHub event. See https://developer.github.com/v3/activity/events/types/", 120 | ) 121 | end 122 | 123 | it "raises an error when the content type is not correct" do 124 | controller.request.body = StringIO.new({ :foo => "bar" }.to_json.to_s) 125 | controller.request.headers['X-Hub-Signature-256'] = "sha256=3f3ab3986b656abb17af3eb1443ed6c08ef8fff9fea83915909d1b421aec89be" 126 | controller.request.headers['X-GitHub-Event'] = 'ping' 127 | controller.request.headers['Content-Type'] = 'application/xml' 128 | expect { controller.send :authenticate_github_request! }.to raise_error( 129 | AbstractController::ActionNotFound, 130 | "Content-Type application/xml is not supported. Use 'application/x-www-form-urlencoded' or 'application/json", 131 | ) 132 | end 133 | 134 | it 'raises SignatureError when the X-Hub-Signature header is missing' do 135 | controller.request.body = StringIO.new('{}') 136 | controller.request.headers['Content-Type'] = 'application/json' 137 | controller.request.headers['X-GitHub-Event'] = 'ping' 138 | expect { controller.send :authenticate_github_request! }.to raise_error(AbstractController::ActionNotFound) 139 | end 140 | end 141 | end 142 | end 143 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | ENV['RAILS_ENV'] ||= 'test' 2 | 3 | require 'simplecov' 4 | SimpleCov.start 5 | 6 | require "github_webhook" 7 | 8 | # Load support files 9 | Dir["#{File.dirname(__FILE__)}/support/**/*.rb"].each { |f| require f } 10 | 11 | RSpec.configure do |config| 12 | config.order = "random" 13 | end 14 | --------------------------------------------------------------------------------