diff --git a/README.md b/README.md index 57eb797..9c200c3 100644 --- a/README.md +++ b/README.md @@ -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: @@ -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 ids are found on the App's settings page +RUNWAY_GITHUB_APP_INSTALLATION_ID= # https://github.com/organizations//settings/installations/<8_digit_id> +RUNWAY_GITHUB_APP_PRIVATE_KEY= # format: "-----BEGIN...key\n...END-----\n" (note the newlines) +``` + Now we can fire up runway! ```bash diff --git a/script/build b/script/build index 8f99d7d..898a346 100755 --- a/script/build +++ b/script/build @@ -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}" diff --git a/script/postinstall b/script/postinstall index f2831b7..3e7c80f 100755 --- a/script/postinstall +++ b/script/postinstall @@ -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 @@ -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 diff --git a/script/server b/script/server index e404e51..95b034d 100755 --- a/script/server +++ b/script/server @@ -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 diff --git a/script/setup-env b/script/setup-env index 89bee87..c7377cd 100644 --- a/script/setup-env +++ b/script/setup-env @@ -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" diff --git a/script/test b/script/test index 61df06e..e3a18b0 100755 --- a/script/test +++ b/script/test @@ -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 diff --git a/script/update b/script/update index 7921556..4df22ad 100755 --- a/script/update +++ b/script/update @@ -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 diff --git a/shard.lock b/shard.lock index 51f83c1..6269168 100644 --- a/shard.lock +++ b/shard.lock @@ -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 @@ -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 diff --git a/shard.yml b/shard.yml index a406368..8cda252 100644 --- a/shard.yml +++ b/shard.yml @@ -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 @@ -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: diff --git a/spec/spec_helper.cr b/spec/spec_helper.cr index d9df9f1..6343eb1 100644 --- a/spec/spec_helper.cr +++ b/spec/spec_helper.cr @@ -3,6 +3,7 @@ # https://gist.github.com/stufro/b4d70bbf2923ca742db617fe802a6d76 require "../src/version" +require "../src/cli" require "../src/runway/**" require "spectator" require "log" diff --git a/src/cli.cr b/src/cli.cr index 020ffe4..1666481 100644 --- a/src/cli.cr +++ b/src/cli.cr @@ -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]}" } @@ -52,4 +68,4 @@ module Runway end end -Runway::Cli.run +Runway::Cli.run unless ENV.fetch("CRYSTAL_ENV", nil) == "test" diff --git a/src/runway/events/github_deployment.cr b/src/runway/events/github_deployment.cr index 8f620c3..ed8823e 100644 --- a/src/runway/events/github_deployment.cr +++ b/src/runway/events/github_deployment.cr @@ -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" diff --git a/src/runway/services/github.cr b/src/runway/services/github.cr index 974156d..fb87235 100644 --- a/src/runway/services/github.cr +++ b/src/runway/services/github.cr @@ -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 @@ -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 diff --git a/src/runway/services/github_app.cr b/src/runway/services/github_app.cr new file mode 100644 index 0000000..f837119 --- /dev/null +++ b/src/runway/services/github_app.cr @@ -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//" +# 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 diff --git a/vendor/shards/cache/ameba-1.6.1.shard b/vendor/shards/cache/ameba-1.6.1.shard index fa87034..5ceedbc 100644 Binary files a/vendor/shards/cache/ameba-1.6.1.shard and b/vendor/shards/cache/ameba-1.6.1.shard differ diff --git a/vendor/shards/cache/bindata-2.1.0.shard b/vendor/shards/cache/bindata-2.1.0.shard new file mode 100644 index 0000000..44d655c Binary files /dev/null and b/vendor/shards/cache/bindata-2.1.0.shard differ diff --git a/vendor/shards/cache/crinja-0.8.1.shard b/vendor/shards/cache/crinja-0.8.1.shard index 62bd010..b7b4ba1 100644 Binary files a/vendor/shards/cache/crinja-0.8.1.shard and b/vendor/shards/cache/crinja-0.8.1.shard differ diff --git a/vendor/shards/cache/cron_parser-0.4.0.shard b/vendor/shards/cache/cron_parser-0.4.0.shard index becb52f..72c6715 100644 Binary files a/vendor/shards/cache/cron_parser-0.4.0.shard and b/vendor/shards/cache/cron_parser-0.4.0.shard differ diff --git a/vendor/shards/cache/crystal-kcov-0.2.3+git.commit.7e49fe22d7d47040c9de77eb77a6daa76ce0655d.shard b/vendor/shards/cache/crystal-kcov-0.2.3+git.commit.7e49fe22d7d47040c9de77eb77a6daa76ce0655d.shard index ad2b194..4349cd0 100644 Binary files a/vendor/shards/cache/crystal-kcov-0.2.3+git.commit.7e49fe22d7d47040c9de77eb77a6daa76ce0655d.shard and b/vendor/shards/cache/crystal-kcov-0.2.3+git.commit.7e49fe22d7d47040c9de77eb77a6daa76ce0655d.shard differ diff --git a/vendor/shards/cache/emoji-0.5.0.shard b/vendor/shards/cache/emoji-0.5.0.shard index ff30ce6..326e926 100644 Binary files a/vendor/shards/cache/emoji-0.5.0.shard and b/vendor/shards/cache/emoji-0.5.0.shard differ diff --git a/vendor/shards/cache/future-1.0.0.shard b/vendor/shards/cache/future-1.0.0.shard index 46ab452..8e08a0b 100644 Binary files a/vendor/shards/cache/future-1.0.0.shard and b/vendor/shards/cache/future-1.0.0.shard differ diff --git a/vendor/shards/cache/halite-0.12.0.shard b/vendor/shards/cache/halite-0.12.0.shard index 43b2ddb..87707e5 100644 Binary files a/vendor/shards/cache/halite-0.12.0.shard and b/vendor/shards/cache/halite-0.12.0.shard differ diff --git a/vendor/shards/cache/json_mapping-0.1.1.shard b/vendor/shards/cache/json_mapping-0.1.1.shard index 6add8db..b625a83 100644 Binary files a/vendor/shards/cache/json_mapping-0.1.1.shard and b/vendor/shards/cache/json_mapping-0.1.1.shard differ diff --git a/vendor/shards/cache/jwt-1.6.1.shard b/vendor/shards/cache/jwt-1.6.1.shard new file mode 100644 index 0000000..5729aac Binary files /dev/null and b/vendor/shards/cache/jwt-1.6.1.shard differ diff --git a/vendor/shards/cache/octokit-0.4.0.shard b/vendor/shards/cache/octokit-0.5.0.shard similarity index 82% rename from vendor/shards/cache/octokit-0.4.0.shard rename to vendor/shards/cache/octokit-0.5.0.shard index a3f2e70..d71533a 100644 Binary files a/vendor/shards/cache/octokit-0.4.0.shard and b/vendor/shards/cache/octokit-0.5.0.shard differ diff --git a/vendor/shards/cache/openssl_ext-2.4.4.shard b/vendor/shards/cache/openssl_ext-2.4.4.shard new file mode 100644 index 0000000..0595fd6 Binary files /dev/null and b/vendor/shards/cache/openssl_ext-2.4.4.shard differ diff --git a/vendor/shards/cache/retriable-0.2.5.shard b/vendor/shards/cache/retriable-0.2.5.shard index 23b1e67..56b445c 100644 Binary files a/vendor/shards/cache/retriable-0.2.5.shard and b/vendor/shards/cache/retriable-0.2.5.shard differ diff --git a/vendor/shards/cache/spectator-0.12.1.shard b/vendor/shards/cache/spectator-0.12.1.shard index e47cdc3..7920889 100644 Binary files a/vendor/shards/cache/spectator-0.12.1.shard and b/vendor/shards/cache/spectator-0.12.1.shard differ diff --git a/vendor/shards/cache/ssh2-1.7.0.shard b/vendor/shards/cache/ssh2-1.7.0.shard index 490781f..7915f36 100644 Binary files a/vendor/shards/cache/ssh2-1.7.0.shard and b/vendor/shards/cache/ssh2-1.7.0.shard differ diff --git a/vendor/shards/cache/tasker-2.1.4.shard b/vendor/shards/cache/tasker-2.1.4.shard index 1912444..20553ea 100644 Binary files a/vendor/shards/cache/tasker-2.1.4.shard and b/vendor/shards/cache/tasker-2.1.4.shard differ