Skip to content

Commit

Permalink
Merge pull request #52 from runwaylab/github-app-support
Browse files Browse the repository at this point in the history
GitHub App Support
  • Loading branch information
GrantBirki authored Jan 24, 2025
2 parents 97888b1 + 45c01f3 commit 885a5d1
Show file tree
Hide file tree
Showing 30 changed files with 171 additions and 25 deletions.
10 changes: 9 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,7 @@ We also need to create a `creds.env` file which contains a GitHub PAT for using

```ini
# creds.env
GITHUB_TOKEN=ghp_abcdefg
RUNWAY_GITHUB_TOKEN=ghp_abcdefg
```

At a bare minimum, the PAT will need the following permissions:
Expand All @@ -175,6 +175,14 @@ However, to unlock the full potential of runway, you will need to give the PAT t

You should be using fine-grained GitHub Access Tokens as you can apply granular permissions to them.

Alternatively, you can use a GitHub App to authenticate. To do so, set the following environment variables instead:

```ini
RUNWAY_GITHUB_APP_ID=<app_id> # app ids are found on the App's settings page
RUNWAY_GITHUB_APP_INSTALLATION_ID=<installation_id> # https://github.com/organizations/<org>/settings/installations/<8_digit_id>
RUNWAY_GITHUB_APP_PRIVATE_KEY=<private_key> # format: "-----BEGIN...key\n...END-----\n" (note the newlines)
```

Now we can fire up runway!

```bash
Expand Down
2 changes: 1 addition & 1 deletion script/build
Original file line number Diff line number Diff line change
Expand Up @@ -16,5 +16,5 @@ if [[ "$@" == *"--production"* ]] || [[ "$CRYSTAL_ENV" == "production" ]]; then
fi

echo -e "🔨 ${BLUE}building in ${PURPLE}release${BLUE} mode${OFF}"
shards build --production --release --progress --debug --error-trace -Dpreview_mt
$SHARDS_BIN build --production --release --progress --debug --error-trace -Dpreview_mt
echo -e "📦 ${GREEN}build complete${OFF}"
4 changes: 2 additions & 2 deletions script/postinstall
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ if [[ ! "$@" == *"--production"* ]]; then
# ensure the ameba binary is built and available in the bin directory
AMEBA_UP_TO_DATE=false
# first, check the version of the ameba binary in the lock file
AMEBA_VERSION=$(SHARDS_INSTALL_PATH="$SHARDS_INSTALL_PATH" shards list | grep ameba | awk '{print $3}' | tr -d '()')
AMEBA_VERSION=$(SHARDS_INSTALL_PATH="$SHARDS_INSTALL_PATH" $SHARDS_BIN list | grep ameba | awk '{print $3}' | tr -d '()')

# if the bin/ameba binary exists, check if it is the correct version
if [ -f "$DIR/bin/ameba" ]; then
Expand All @@ -72,6 +72,6 @@ if [[ ! "$@" == *"--production"* ]]; then

if [ "$AMEBA_UP_TO_DATE" = false ]; then
echo "building ameba binary"
cd "$SHARDS_INSTALL_PATH/ameba" && shards build && cp bin/ameba "$DIR/bin/ameba" && cd "$DIR"
cd "$SHARDS_INSTALL_PATH/ameba" && $SHARDS_BIN build && cp bin/ameba "$DIR/bin/ameba" && cd "$DIR"
fi
fi
4 changes: 2 additions & 2 deletions script/server
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ if [ "$1" == "--dev" ]; then
LOG_LEVEL="$3"
fi

shards run --debug --error-trace -- -c config.yml --log-level $LOG_LEVEL
$SHARDS_BIN run --debug --error-trace -- -c config.yml --log-level $LOG_LEVEL
else
shards run --release --debug --error-trace
$SHARDS_BIN run --release --debug --error-trace
fi
1 change: 1 addition & 0 deletions script/setup-env
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export SHARDS_INSTALL_PATH="$VENDOR_DIR/shards/install"
export SHARDS_CACHED="$VENDOR_DIR/shards/cache"
export SHARD_SHA_FILE=".shard.vendor.cache.sha256"
export VENDOR_SHARDS_INFO_FILE="vendor/shards/install/.shards.info"
export SHARDS_BIN="shards"

# common vendor dirs for binaries
export LINUX_VENDOR_DIR="$VENDOR_DIR/linux_x86_64/bin"
Expand Down
2 changes: 2 additions & 0 deletions script/test
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ for arg in "$@"; do
fi
done

export CRYSTAL_ENV="test"

# if -s was supplied as an argument, skip coverage reports (they can be slow)
if $skip_coverage; then
crystal spec
Expand Down
2 changes: 1 addition & 1 deletion script/update
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ echo -e "📦 ${BLUE}Running ${PURPLE}shards update${BLUE} to update all depende

script/preinstall

shards update $@
$SHARDS_BIN update $@

script/zipper
script/postinstall
Expand Down
14 changes: 13 additions & 1 deletion shard.lock
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ shards:
git: https://github.com/crystal-ameba/ameba.git
version: 1.6.1

bindata:
git: https://github.com/spider-gazelle/bindata.git
version: 2.1.0

crinja:
git: https://github.com/straight-shoota/crinja.git
version: 0.8.1
Expand Down Expand Up @@ -32,9 +36,17 @@ shards:
git: https://github.com/crystal-lang/json_mapping.cr.git
version: 0.1.1

jwt:
git: https://github.com/crystal-community/jwt.git
version: 1.6.1

octokit:
git: https://github.com/octokit-cr/octokit.cr.git
version: 0.4.0
version: 0.5.0

openssl_ext:
git: https://github.com/spider-gazelle/openssl_ext.git
version: 2.4.4

retriable:
git: https://github.com/sija/retriable.cr.git
Expand Down
5 changes: 4 additions & 1 deletion shard.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ license: MIT
dependencies:
octokit:
github: octokit-cr/octokit.cr
version: ~> 0.4.0
version: ~> 0.5.0
emoji:
github: veelenga/emoji.cr
version: ~> 0.5.0
Expand All @@ -31,6 +31,9 @@ dependencies:
crinja:
github: straight-shoota/crinja
version: ~> 0.8.1
jwt:
github: crystal-community/jwt
version: ~> 1.6.1

development_dependencies:
ameba:
Expand Down
1 change: 1 addition & 0 deletions spec/spec_helper.cr
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
# https://gist.github.com/stufro/b4d70bbf2923ca742db617fe802a6d76

require "../src/version"
require "../src/cli"
require "../src/runway/**"
require "spectator"
require "log"
Expand Down
18 changes: 17 additions & 1 deletion src/cli.cr
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,27 @@ require "./runway/core/runway"
require "./runway/core/logger"

module Runway
@@logger = Runway.setup_logger(ENV.fetch("LOG_LEVEL", "INFO"))

def self.logger
@@logger
end

def self.logger=(logger : Log)
@@logger = logger
end

def self.github
@@github ||= Runway::GitHub.new(Runway.logger)
end

module Cli
def self.run
opts = self.opts

log = Runway.setup_logger(opts[:log_level])
Runway.logger = log

log.info { Emoji.emojize(":book: loading runway configuration") }

log.debug { "attempting to load config from #{opts[:config_path]}" }
Expand Down Expand Up @@ -52,4 +68,4 @@ module Runway
end
end

Runway::Cli.run
Runway::Cli.run unless ENV.fetch("CRYSTAL_ENV", nil) == "test"
2 changes: 1 addition & 1 deletion src/runway/events/github_deployment.cr
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ class GitHubDeployment < BaseEvent

def initialize(log : Log, event : Event)
super(log, event)
@github = Runway::GitHub.new(log)
@github = Runway.github
@deployment_filter = @event.deployment_filter.try(&.to_i) || 1
@repo = @event.repo.not_nil! # in format of "owner/repo"
@success = "success"
Expand Down
44 changes: 30 additions & 14 deletions src/runway/services/github.cr
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
require "octokit"
require "./github_app"
require "../core/logger"

module Runway
class GitHub
@miniumum_rate_limit : Int32
@client : Octokit::Client
@client : Octokit::Client | GitHubApp

# The octokit class for interacting with GitHub's API
# @param log [Log] the logger to use
# @param token [String?] the GitHub token to use for authentication - if nil, the client will be unauthenticated
def initialize(log : Log, token : String? = ENV.fetch("GITHUB_TOKEN", nil))
def initialize(log : Log, token : String? = ENV.fetch("RUNWAY_GITHUB_TOKEN", nil))
@log = log
@client = create_client(token)
@miniumum_rate_limit = ENV.fetch("GITHUB_MINIMUM_RATE_LIMIT", "10").to_s.to_i
Expand Down Expand Up @@ -92,24 +93,39 @@ module Runway

# Creates an octokit.cr client with the given token (can be nil aka unauthenticated)
# @param token [String?] the GitHub token to use for authentication - if nil, the client will be unauthenticated
# @return [Octokit::Client] the client
protected def create_client(token : String?) : Octokit::Client
if (token.nil? || token.empty?) && ENV.fetch("SUPPRESS_STARTUP_WARNINGS", nil).nil?
@log.warn { "No GitHub token provided. Please set the GITHUB_TOKEN environment variable to avoid excessive rate limiting." }
# @return [Octokit::Client | GitHubApp] the client
protected def create_client(token : String?) : Octokit::Client | GitHubApp
if ENV["RUNWAY_GITHUB_APP_ID"]? && ENV["RUNWAY_GITHUB_APP_INSTALLATION_ID"]? && ENV["RUNWAY_GITHUB_APP_PRIVATE_KEY"]?
log_authentication_method("github app")
return GitHubApp.new.tap { rebuild_logger }
end

if token.nil? || token.empty?
log_missing_token_warning unless ENV["SUPPRESS_STARTUP_WARNINGS"]?
else
log_authentication_method("github token")
end

Octokit::Client.new(access_token: token).tap do |client|
client.auto_paginate = ENV.fetch("OCTOKIT_CR_AUTO_PAGINATE", "true") == "true"
client.per_page = ENV.fetch("OCTOKIT_CR_PER_PAGE", "100").to_i
rebuild_logger
end
end

private def log_authentication_method(method : String)
@log.info { Emoji.emojize(":key: using #{method} authentication") } unless Runway::QUIET
end

private def log_missing_token_warning
@log.warn { "No GitHub token provided. Please set the GITHUB_TOKEN environment variable to avoid excessive rate limiting." }
end

protected def rebuild_logger
# octokit.cr wipes out the loggers, so we need to re-apply them... bleh
# fetch the current log level
log_level = @log.level

# create the client
client = Octokit::Client.new(access_token: token)
client.auto_paginate = ENV.fetch("OCTOKIT_CR_AUTO_PAGINATE", "true") == "true"
client.per_page = ENV.fetch("OCTOKIT_CR_PER_PAGE", "100").to_i

@log = Runway.setup_logger(log_level.to_s.upcase)

return client
end
end
end
87 changes: 87 additions & 0 deletions src/runway/services/github_app.cr
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
# This class provides a wrapper around the Octokit client for GitHub App authentication.
# It handles token generation and refreshing, and delegates method calls to the Octokit client.
# Helpful: https://github.com/octokit/handbook?tab=readme-ov-file#github-app-authentication-json-web-token
#
# Usage (examples):
# github = GitHubApp.new
# github.get "/meta"
# github.get "/repos/<org>/<repo>"
# github.user "grantbirki"

# Why? In some cases, you may not want to have a static long lived token like a GitHub PAT when authenticating...
# Most importantly, this class will handle automatic token refreshing for you out-of-the-box. Simply provide the...
# correct environment variables, call `GitHubApp.new`, and then use the returned object as you would an Octokit client.

require "octokit"
require "jwt"
require "openssl"
require "json"

class GitHubApp
TOKEN_EXPIRATION_TIME = 2700 # 45 minutes
JWT_EXPIRATION_TIME = 600 # 10 minutes

@client : Octokit::Client
@app_id : Int32
@installation_id : Int32
@app_key : String

def initialize
@app_id = fetch_env_var("RUNWAY_GITHUB_APP_ID").to_i
@installation_id = fetch_env_var("RUNWAY_GITHUB_APP_INSTALLATION_ID").to_i
@app_key = fetch_env_var("RUNWAY_GITHUB_APP_PRIVATE_KEY").gsub(/\\+n/, "\n")
@token_refresh_time = Time.unix(0)
@client = create_client
end

private def fetch_env_var(key : String) : String
ENV[key]? || raise "environment variable #{key} is not set"
end

private def client
if @client.nil? || token_expired?
@client = create_client
end
@client
end

private def jwt_token : String
private_key = OpenSSL::PKey::RSA.new(@app_key)
payload = {
"iat" => Time.utc.to_unix - 60,
"exp" => Time.utc.to_unix + JWT_EXPIRATION_TIME,
"iss" => @app_id,
}
JWT.encode(payload, private_key.to_pem, JWT::Algorithm::RS256)
end

private def create_client
tmp_client = Octokit.client(bearer_token: jwt_token)
response = tmp_client.create_app_installation_access_token(@installation_id, **{headers: {authorization: "Bearer #{tmp_client.bearer_token}"}})
access_token = JSON.parse(response)["token"].to_s

client = Octokit.client(access_token: access_token)
client.auto_paginate = ENV.fetch("OCTOKIT_CR_AUTO_PAGINATE", "true") == "true"
client.per_page = ENV.fetch("OCTOKIT_CR_PER_PAGE", "100").to_i
@token_refresh_time = Time.utc
client
end

private def token_expired? : Bool
Time.utc.to_unix - @token_refresh_time.to_unix > TOKEN_EXPIRATION_TIME
end

macro method_missing(call)
{% if call.block %}
client.{{call.name}}({{*call.args}}) do |{{call.block.args}}|
{{call.block.body}}
end
{% else %}
client.{{call.name}}({{*call.args}})
{% end %}
end

def respond_to_missing?(method_name : Symbol, include_private : Bool = false) : Bool
client.respond_to?(method_name) || super
end
end
Binary file modified vendor/shards/cache/ameba-1.6.1.shard
Binary file not shown.
Binary file added vendor/shards/cache/bindata-2.1.0.shard
Binary file not shown.
Binary file modified vendor/shards/cache/crinja-0.8.1.shard
Binary file not shown.
Binary file modified vendor/shards/cache/cron_parser-0.4.0.shard
Binary file not shown.
Binary file not shown.
Binary file modified vendor/shards/cache/emoji-0.5.0.shard
Binary file not shown.
Binary file modified vendor/shards/cache/future-1.0.0.shard
Binary file not shown.
Binary file modified vendor/shards/cache/halite-0.12.0.shard
Binary file not shown.
Binary file modified vendor/shards/cache/json_mapping-0.1.1.shard
Binary file not shown.
Binary file added vendor/shards/cache/jwt-1.6.1.shard
Binary file not shown.
Binary file not shown.
Binary file added vendor/shards/cache/openssl_ext-2.4.4.shard
Binary file not shown.
Binary file modified vendor/shards/cache/retriable-0.2.5.shard
Binary file not shown.
Binary file modified vendor/shards/cache/spectator-0.12.1.shard
Binary file not shown.
Binary file modified vendor/shards/cache/ssh2-1.7.0.shard
Binary file not shown.
Binary file modified vendor/shards/cache/tasker-2.1.4.shard
Binary file not shown.

0 comments on commit 885a5d1

Please sign in to comment.