├── .env-example ├── .gitignore ├── Gemfile ├── Gemfile.lock ├── README.md ├── config.ru ├── server.rb └── template_server.rb /.env-example: -------------------------------------------------------------------------------- 1 | GITHUB_PRIVATE_KEY="" 2 | GITHUB_APP_IDENTIFIER= 3 | GITHUB_WEBHOOK_SECRET= 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .env 3 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'http://rubygems.org' 2 | 3 | gem 'sinatra', '~> 2.0' 4 | gem 'jwt', '~> 2.1' 5 | gem 'octokit', '~> 4.0' 6 | gem 'dotenv' 7 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | GEM 2 | remote: http://rubygems.org/ 3 | specs: 4 | addressable (2.5.2) 5 | public_suffix (>= 2.0.2, < 4.0) 6 | dotenv (2.5.0) 7 | faraday (0.15.3) 8 | multipart-post (>= 1.2, < 3) 9 | jwt (2.1.0) 10 | multipart-post (2.0.0) 11 | mustermann (1.0.3) 12 | octokit (4.13.0) 13 | sawyer (~> 0.8.0, >= 0.5.3) 14 | public_suffix (3.0.3) 15 | rack (2.0.8) 16 | rack-protection (2.0.4) 17 | rack 18 | sawyer (0.8.1) 19 | addressable (>= 2.3.5, < 2.6) 20 | faraday (~> 0.8, < 1.0) 21 | sinatra (2.0.4) 22 | mustermann (~> 1.0) 23 | rack (~> 2.0) 24 | rack-protection (= 2.0.4) 25 | tilt (~> 2.0) 26 | tilt (2.0.8) 27 | 28 | PLATFORMS 29 | ruby 30 | 31 | DEPENDENCIES 32 | dotenv 33 | jwt (~> 2.1) 34 | octokit (~> 4.0) 35 | sinatra (~> 2.0) 36 | 37 | BUNDLED WITH 38 | 1.17.1 39 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ⚠️ Note: This repository is not maintained. For more recent guides about how to build GitHub Apps, see "[About writing code for a GitHub App](https://docs.github.com/en/apps/creating-github-apps/writing-code-for-a-github-app/about-writing-code-for-a-github-app)." 2 | 3 | This is an example GitHub App that adds a label to all new issues opened in a repository. You can follow the archived "[Using the GitHub API in your app](https://web.archive.org/web/20230604175646/https://docs.github.com/en/apps/creating-github-apps/writing-code-for-a-github-app/using-the-github-api-in-your-app)" guide to learn how to build the app code in `server.rb`. 4 | 5 | This project listens for webhook events and uses the Octokit.rb library to make REST API calls. This example project consists of two different servers: 6 | * `template_server.rb` (GitHub App template code) 7 | * `server.rb` (completed project) 8 | 9 | To learn how to set up a template GitHub App, follow the archived "[Setting up your development environment](https://web.archive.org/web/20230604175646/https://docs.github.com/en/apps/creating-github-apps/writing-code-for-a-github-app/setting-up-your-development-environment-to-create-a-github-app)" guide. 10 | 11 | ## Install 12 | 13 | To run the code, make sure you have [Bundler](https://bundler.io/) installed; then enter `bundle install` on the command line. 14 | 15 | ## Set environment variables 16 | 17 | 1. Create a copy of the `.env-example` file called `.env`. 18 | 2. Add your GitHub App's private key, app ID, and webhook secret to the `.env` file. 19 | 20 | ## Run the server 21 | 22 | 1. Run `ruby template_server.rb` or `ruby server.rb` on the command line. 23 | 1. View the default Sinatra app at `localhost:3000`. 24 | -------------------------------------------------------------------------------- /config.ru: -------------------------------------------------------------------------------- 1 | require "./server" 2 | run GHAapp 3 | -------------------------------------------------------------------------------- /server.rb: -------------------------------------------------------------------------------- 1 | require 'sinatra' 2 | require 'octokit' 3 | require 'dotenv/load' # Manages environment variables 4 | require 'json' 5 | require 'openssl' # Verifies the webhook signature 6 | require 'jwt' # Authenticates a GitHub App 7 | require 'time' # Gets ISO 8601 representation of a Time object 8 | require 'logger' # Logs debug statements 9 | 10 | set :port, 3000 11 | set :bind, '0.0.0.0' 12 | 13 | class GHAapp < Sinatra::Application 14 | 15 | # Converts the newlines. Expects that the private key has been set as an 16 | # environment variable in PEM format. 17 | PRIVATE_KEY = OpenSSL::PKey::RSA.new(ENV['GITHUB_PRIVATE_KEY'].gsub('\n', "\n")) 18 | 19 | # Your registered app must have a secret set. The secret is used to verify 20 | # that webhooks are sent by GitHub. 21 | WEBHOOK_SECRET = ENV['GITHUB_WEBHOOK_SECRET'] 22 | 23 | # The GitHub App's identifier (type integer) set when registering an app. 24 | APP_IDENTIFIER = ENV['GITHUB_APP_IDENTIFIER'] 25 | 26 | # Turn on Sinatra's verbose logging during development 27 | configure :development do 28 | set :logging, Logger::DEBUG 29 | end 30 | 31 | 32 | # Executed before each request to the `/event_handler` route 33 | before '/event_handler' do 34 | get_payload_request(request) 35 | verify_webhook_signature 36 | authenticate_app 37 | # Authenticate the app installation in order to run API operations 38 | authenticate_installation(@payload) 39 | end 40 | 41 | 42 | post '/event_handler' do 43 | 44 | case request.env['HTTP_X_GITHUB_EVENT'] 45 | when 'issues' 46 | if @payload['action'] === 'opened' 47 | handle_issue_opened_event(@payload) 48 | end 49 | end 50 | 51 | 200 # success status 52 | end 53 | 54 | 55 | helpers do 56 | 57 | # When an issue is opened, add a label 58 | def handle_issue_opened_event(payload) 59 | repo = payload['repository']['full_name'] 60 | issue_number = payload['issue']['number'] 61 | @installation_client.add_labels_to_an_issue(repo, issue_number, ['needs-response']) 62 | end 63 | 64 | # Saves the raw payload and converts the payload to JSON format 65 | def get_payload_request(request) 66 | # request.body is an IO or StringIO object 67 | # Rewind in case someone already read it 68 | request.body.rewind 69 | # The raw text of the body is required for webhook signature verification 70 | @payload_raw = request.body.read 71 | begin 72 | @payload = JSON.parse @payload_raw 73 | rescue => e 74 | fail "Invalid JSON (#{e}): #{@payload_raw}" 75 | end 76 | end 77 | 78 | # Instantiate an Octokit client authenticated as a GitHub App. 79 | # GitHub App authentication requires that you construct a 80 | # JWT (https://jwt.io/introduction/) signed with the app's private key, 81 | # so GitHub can be sure that it came from the app and was not altered by 82 | # a malicious third party. 83 | def authenticate_app 84 | payload = { 85 | # The time that this JWT was issued, _i.e._ now. 86 | iat: Time.now.to_i, 87 | 88 | # JWT expiration time (10 minute maximum) 89 | exp: Time.now.to_i + (10 * 60), 90 | 91 | # Your GitHub App's identifier number 92 | iss: APP_IDENTIFIER 93 | } 94 | 95 | # Cryptographically sign the JWT. 96 | jwt = JWT.encode(payload, PRIVATE_KEY, 'RS256') 97 | 98 | # Create the Octokit client, using the JWT as the auth token. 99 | @app_client ||= Octokit::Client.new(bearer_token: jwt) 100 | end 101 | 102 | # Instantiate an Octokit client, authenticated as an installation of a 103 | # GitHub App, to run API operations. 104 | def authenticate_installation(payload) 105 | @installation_id = payload['installation']['id'] 106 | @installation_token = @app_client.create_app_installation_access_token(@installation_id)[:token] 107 | @installation_client = Octokit::Client.new(bearer_token: @installation_token) 108 | end 109 | 110 | # Check X-Hub-Signature to confirm that this webhook was generated by 111 | # GitHub, and not a malicious third party. 112 | # 113 | # GitHub uses the WEBHOOK_SECRET, registered to the GitHub App, to 114 | # create the hash signature sent in the `X-HUB-Signature` header of each 115 | # webhook. This code computes the expected hash signature and compares it to 116 | # the signature sent in the `X-HUB-Signature` header. If they don't match, 117 | # this request is an attack, and you should reject it. GitHub uses the HMAC 118 | # hexdigest to compute the signature. The `X-HUB-Signature` looks something 119 | # like this: "sha1=123456". 120 | # See https://developer.github.com/webhooks/securing/ for details. 121 | def verify_webhook_signature 122 | their_signature_header = request.env['HTTP_X_HUB_SIGNATURE'] || 'sha1=' 123 | method, their_digest = their_signature_header.split('=') 124 | our_digest = OpenSSL::HMAC.hexdigest(method, WEBHOOK_SECRET, @payload_raw) 125 | halt 401 unless their_digest == our_digest 126 | 127 | # The X-GITHUB-EVENT header provides the name of the event. 128 | # The action value indicates the which action triggered the event. 129 | logger.debug "---- received event #{request.env['HTTP_X_GITHUB_EVENT']}" 130 | logger.debug "---- action #{@payload['action']}" unless @payload['action'].nil? 131 | end 132 | 133 | end 134 | 135 | # Finally some logic to let us run this server directly from the command line, 136 | # or with Rack. Don't worry too much about this code. But, for the curious: 137 | # $0 is the executed file 138 | # __FILE__ is the current file 139 | # If they are the same—that is, we are running this file directly, call the 140 | # Sinatra run method 141 | run! if __FILE__ == $0 142 | end 143 | -------------------------------------------------------------------------------- /template_server.rb: -------------------------------------------------------------------------------- 1 | require 'sinatra' 2 | require 'octokit' 3 | require 'dotenv/load' # Manages environment variables 4 | require 'json' 5 | require 'openssl' # Verifies the webhook signature 6 | require 'jwt' # Authenticates a GitHub App 7 | require 'time' # Gets ISO 8601 representation of a Time object 8 | require 'logger' # Logs debug statements 9 | 10 | set :port, 3000 11 | set :bind, '0.0.0.0' 12 | 13 | 14 | # This is template code to create a GitHub App server. 15 | # You can read more about GitHub Apps here: # https://developer.github.com/apps/ 16 | # 17 | # On its own, this app does absolutely nothing, except that it can be installed. 18 | # It's up to you to add functionality! 19 | # You can check out one example in advanced_server.rb. 20 | # 21 | # This code is a Sinatra app, for two reasons: 22 | # 1. Because the app will require a landing page for installation. 23 | # 2. To easily handle webhook events. 24 | # 25 | # Of course, not all apps need to receive and process events! 26 | # Feel free to rip out the event handling code if you don't need it. 27 | # 28 | # Have fun! 29 | # 30 | 31 | class GHAapp < Sinatra::Application 32 | 33 | # Expects that the private key in PEM format. Converts the newlines 34 | PRIVATE_KEY = OpenSSL::PKey::RSA.new(ENV['GITHUB_PRIVATE_KEY'].gsub('\n', "\n")) 35 | 36 | # Your registered app must have a secret set. The secret is used to verify 37 | # that webhooks are sent by GitHub. 38 | WEBHOOK_SECRET = ENV['GITHUB_WEBHOOK_SECRET'] 39 | 40 | # The GitHub App's identifier (type integer) set when registering an app. 41 | APP_IDENTIFIER = ENV['GITHUB_APP_IDENTIFIER'] 42 | 43 | # Turn on Sinatra's verbose logging during development 44 | configure :development do 45 | set :logging, Logger::DEBUG 46 | end 47 | 48 | 49 | # Before each request to the `/event_handler` route 50 | before '/event_handler' do 51 | get_payload_request(request) 52 | verify_webhook_signature 53 | authenticate_app 54 | # Authenticate the app installation in order to run API operations 55 | authenticate_installation(@payload) 56 | end 57 | 58 | 59 | post '/event_handler' do 60 | 61 | # # # # # # # # # # # # 62 | # ADD YOUR CODE HERE # 63 | # # # # # # # # # # # # 64 | 65 | 200 # success status 66 | end 67 | 68 | 69 | helpers do 70 | 71 | # # # # # # # # # # # # # # # # # 72 | # ADD YOUR HELPER METHODS HERE # 73 | # # # # # # # # # # # # # # # # # 74 | 75 | # Saves the raw payload and converts the payload to JSON format 76 | def get_payload_request(request) 77 | # request.body is an IO or StringIO object 78 | # Rewind in case someone already read it 79 | request.body.rewind 80 | # The raw text of the body is required for webhook signature verification 81 | @payload_raw = request.body.read 82 | begin 83 | @payload = JSON.parse @payload_raw 84 | rescue => e 85 | fail "Invalid JSON (#{e}): #{@payload_raw}" 86 | end 87 | end 88 | 89 | # Instantiate an Octokit client authenticated as a GitHub App. 90 | # GitHub App authentication requires that you construct a 91 | # JWT (https://jwt.io/introduction/) signed with the app's private key, 92 | # so GitHub can be sure that it came from the app and wasn't alterered by 93 | # a malicious third party. 94 | def authenticate_app 95 | payload = { 96 | # The time that this JWT was issued, _i.e._ now. 97 | iat: Time.now.to_i, 98 | 99 | # JWT expiration time (10 minute maximum) 100 | exp: Time.now.to_i + (10 * 60), 101 | 102 | # Your GitHub App's identifier number 103 | iss: APP_IDENTIFIER 104 | } 105 | 106 | # Cryptographically sign the JWT. 107 | jwt = JWT.encode(payload, PRIVATE_KEY, 'RS256') 108 | 109 | # Create the Octokit client, using the JWT as the auth token. 110 | @app_client ||= Octokit::Client.new(bearer_token: jwt) 111 | end 112 | 113 | # Instantiate an Octokit client, authenticated as an installation of a 114 | # GitHub App, to run API operations. 115 | def authenticate_installation(payload) 116 | @installation_id = payload['installation']['id'] 117 | @installation_token = @app_client.create_app_installation_access_token(@installation_id)[:token] 118 | @installation_client = Octokit::Client.new(bearer_token: @installation_token) 119 | end 120 | 121 | # Check X-Hub-Signature to confirm that this webhook was generated by 122 | # GitHub, and not a malicious third party. 123 | # 124 | # GitHub uses the WEBHOOK_SECRET, registered to the GitHub App, to 125 | # create the hash signature sent in the `X-HUB-Signature` header of each 126 | # webhook. This code computes the expected hash signature and compares it to 127 | # the signature sent in the `X-HUB-Signature` header. If they don't match, 128 | # this request is an attack, and you should reject it. GitHub uses the HMAC 129 | # hexdigest to compute the signature. The `X-HUB-Signature` looks something 130 | # like this: "sha1=123456". 131 | # See https://developer.github.com/webhooks/securing/ for details. 132 | def verify_webhook_signature 133 | their_signature_header = request.env['HTTP_X_HUB_SIGNATURE'] || 'sha1=' 134 | method, their_digest = their_signature_header.split('=') 135 | our_digest = OpenSSL::HMAC.hexdigest(method, WEBHOOK_SECRET, @payload_raw) 136 | halt 401 unless their_digest == our_digest 137 | 138 | # The X-GITHUB-EVENT header provides the name of the event. 139 | # The action value indicates the which action triggered the event. 140 | logger.debug "---- received event #{request.env['HTTP_X_GITHUB_EVENT']}" 141 | logger.debug "---- action #{@payload['action']}" unless @payload['action'].nil? 142 | end 143 | 144 | end 145 | 146 | # Finally some logic to let us run this server directly from the command line, 147 | # or with Rack. Don't worry too much about this code. But, for the curious: 148 | # $0 is the executed file 149 | # __FILE__ is the current file 150 | # If they are the same—that is, we are running this file directly, call the 151 | # Sinatra run method 152 | run! if __FILE__ == $0 153 | end 154 | --------------------------------------------------------------------------------