renuo-cli 4.29.0 → 4.30.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ea0940fbcb4f6adcee05932e3706029dfd00c20aae778925992fd20c4e4122d7
4
- data.tar.gz: d00eb2084014053724e2c8dfdc6e4b1595bdf3edbfd7d3185d6edcc628bbfdd1
3
+ metadata.gz: e403749b46f9d289d45f218209831c68a8c624840f4df20e1315127d4ba2bbac
4
+ data.tar.gz: 85cd1b725f516ab96fa5da67355c9268d8397eeb3b7db4017263b3583558f4d8
5
5
  SHA512:
6
- metadata.gz: aee1f47d1bac9b8082fb450f6b14e4225d6af5b30233cc129fa6b1559871d0901b40924ebbbf163ab88ea493a124cddacb3725fb087acdab47b58fd8088906ab
7
- data.tar.gz: 672f554707e987a99d6dae554b2585a0bf4e7add4f69187e8a06df303e61e94938d6af17e28a54f2c8aa4624b6bafb3f2a9c2b5d56de06a69b14e7ab7bbe4a64
6
+ metadata.gz: 193344bdebb9952145a2c8e948e04b8ad85f8d04d0d642c8c0f00cd1173639454c7306a7c80665de5bcc68c7e78a5de74f4406012de7e4d61cda5d0a9a173211
7
+ data.tar.gz: c6bd00ecd6bedcde704ab346dcfa3cdd0affae38a0413c457636c575c561b12432a9a06c1422de2673b0ff0a40bb8c7e03f66626321d7c756eb0e8ca5927ae0a
data/README.md CHANGED
@@ -52,25 +52,20 @@ renuo generate-password
52
52
 
53
53
  To install this gem onto your local machine, run `bundle exec rake install`.
54
54
 
55
- To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`,
56
- which will create a git tag for the version, push git commits and tags, and push the `.gem` file to
57
- [rubygems.org](https://rubygems.org).
58
-
59
55
  ## Release
60
56
 
61
- * Bump the version number in `lib/renuo/cli/version.rb`, run `bundle install`
62
- to update the `Gemfile.lock` and commit to `main`
63
-
64
- ### Automatic
57
+ 1. Bump the version in `lib/renuo/cli/version.rb`, run `bundle install` and commit to `main`.
58
+ 1. Tag the commit with the bare version and push the tag:
65
59
 
66
- * Deploy through Semaphore console by starting a promotion to main
60
+ ```sh
61
+ git tag -a 1.2.3 -m 1.2.3
62
+ git push origin 1.2.3
63
+ ```
67
64
 
68
- ### Manual
65
+ 1. Create a GitHub release for the tag with the release notes.
69
66
 
70
- ```sh
71
- gem build renuo-cli.gemspec
72
- gem push renuo-cli-*.gem
73
- ```
67
+ Semaphore runs the tests on the tag and, when they pass, promotes the `release` pipeline, which pushes the gem to
68
+ [rubygems.org](https://rubygems.org). It fails when the tag does not match the gem version.
74
69
 
75
70
  ## Contributing
76
71
 
@@ -99,7 +99,7 @@ module Renuo::Cli::Commands::Ci
99
99
  end
100
100
 
101
101
  def succeeded?(type, name, status) # rubocop:disable Metrics/MethodLength
102
- time = Time.current.iso8601.to_s
102
+ time = Time.now.iso8601.to_s
103
103
 
104
104
  case status
105
105
  when "available", "success"
@@ -112,8 +112,9 @@ class Renuo::Cli::Commands::Release # rubocop:disable Metrics/ClassLength
112
112
  clone = "git clone --filter=blob:none git@#{@host}:#{@repo_path}.git"
113
113
  abort(">> Project not found.") unless system("#{MOVE_TO_TMP_FOLDER} && #{clone}")
114
114
 
115
- system(cmd_in_folder("git checkout #{main_branch} && git pull origin #{main_branch} &&" \
116
- "git checkout #{develop_branch} && git pull origin #{develop_branch}"))
115
+ return if system(cmd_in_folder("git checkout #{main_branch} && git checkout #{develop_branch}"))
116
+
117
+ abort(">> Unable to check out #{main_branch} and #{develop_branch}.")
117
118
  end
118
119
 
119
120
  def calculate_version
@@ -143,8 +144,9 @@ class Renuo::Cli::Commands::Release # rubocop:disable Metrics/ClassLength
143
144
  changed_files = find_and_replace_version
144
145
 
145
146
  return unless changed_files.any?
147
+ return if system(cmd_in_folder(%(git add #{changed_files.join(" ")} && git commit -m "Bump version")))
146
148
 
147
- system(cmd_in_folder(%(git add #{changed_files.join(" ")} && git commit -m "Bump version")))
149
+ abort(">> Unable to commit the version bump. Cancelling release.")
148
150
  end
149
151
 
150
152
  def find_and_replace_version
@@ -178,8 +180,7 @@ class Renuo::Cli::Commands::Release # rubocop:disable Metrics/ClassLength
178
180
  end
179
181
 
180
182
  def finish_release_branch
181
- merge_branches
182
- push_branches_and_tags
183
+ merge_branches && system(cmd_in_folder("git push --atomic origin #{develop_branch} #{main_branch} --tags"))
183
184
  end
184
185
 
185
186
  def merge_branches
@@ -194,23 +195,14 @@ class Renuo::Cli::Commands::Release # rubocop:disable Metrics/ClassLength
194
195
  )
195
196
  end
196
197
 
197
- def push_branches_and_tags
198
- push_branch(develop_branch)
199
- push_branch(main_branch)
200
- system(cmd_in_folder("git checkout #{main_branch} && git push origin #{main_branch} --tags"))
201
- end
202
-
203
- def push_branch(branch)
204
- system(cmd_in_folder("git checkout #{branch} && git push origin #{branch}"))
205
- end
206
-
207
198
  def ask_for_final_confirmation
208
199
  unless agree(">> Are you sure you wish to deploy '#{@repo_path}' " \
209
200
  "as a #{@update_type} release (#{current_version} => #{@version})?")
210
201
  abort(">> Cancelling Release.")
211
202
  end
212
203
 
213
- return unless Time.current.friday? && Time.current.hour >= 16
204
+ now = Time.now.getlocal
205
+ return unless now.friday? && now.hour >= 16
214
206
 
215
207
  unless agree(">> Are you sure you want to deploy on late Friday afternoon? " \
216
208
  "Did you think about your family...waiting for you at home?")
@@ -0,0 +1,33 @@
1
+ # Review Apps
2
+
3
+ Review apps are short-lived deployments driven by the changes on a git branch.
4
+ They can be used for smoke testing during code reviews or to show a feature sketch to
5
+ the customer.
6
+
7
+ Here we assume that you use Deploio.
8
+ Further we can generate a SemaphoreCI pipeline template for you.
9
+
10
+ These are the steps of command execution in the happy path:
11
+
12
+ 1. _(Optional)_ `renuo review-app:setup-semaphoreci`
13
+ 1. Create or update `renuo review-app:deploy`
14
+ 1. _Intentional gap for configuration. Consider this the wiggle-room for LLMs_
15
+ 1. Start the paused app `renuo review-app:start`
16
+ 1. Announce it on Slack `renuo review-app:notify`
17
+ 1. Cleanup `renuo review-app:purge`
18
+
19
+ ## Design Choices
20
+
21
+ * The git branch determines the lifetime of a review app
22
+ * The branch name **hash** is the only safe identifier.
23
+ It is being used to purge resources.
24
+ Connected services do not work across all systems (e.g. Nutanix Object Storage)
25
+ * A dry-run command flag is not easily possible because we rely on generated system output.
26
+ * We use the sunny/actor gem to concentrate reviewability to the command flow
27
+ * There is a special "safeguards" feature test. It covers the safe name contract.
28
+ * All `nctl` commands are routed through `system!` and are logged in a command trail for auditing.
29
+ * Configuration of system tools like `psql` is intentionally out of scope.
30
+ But CI templating can give you some pointers.
31
+ * `review-app:notify` posts the review app URL (carrying its Deploio-assigned basic auth credentials) and the
32
+ branch's open pull request to a Slack incoming webhook (`SLACK_WEBHOOK_URL`). The pull request lookup uses
33
+ bundler's `BUNDLE_GITHUB__COM` (a token or `user:token`) and never fails the pipeline when GitHub refuses it.
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../helpers/safe_review_app_name_contract"
4
+
5
+ module Renuo::Cli::Commands::ReviewApp
6
+ class Notify
7
+ include ReviewAppHelper
8
+ include Renuo::Cli::Services
9
+ include Contracts::Core
10
+
11
+ command "review-app:notify" do |c|
12
+ c.syntax = "renuo review-app:notify [options]"
13
+ c.summary = "Posts the branch's review app URL (with its basic auth credentials) and pull request to Slack."
14
+ c.description = "#{c.summary} The Slack incoming webhook is read from SLACK_WEBHOOK_URL, the GitHub " \
15
+ "repository from SEMAPHORE_GIT_REPO_SLUG and bundler's GitHub credentials (a token or " \
16
+ "user:token) from BUNDLE_GITHUB__COM to link the branch's open pull request."
17
+ c.option "-p", "--project <name>", String, "The name of the Deploio project"
18
+ c.option "-b", "--branch <name>", String, "The Git branch"
19
+ c.option "--channel <name>", String, "The Slack channel to post to"
20
+ c.option "--repo <owner/name>", String, "The GitHub repository, defaults to SEMAPHORE_GIT_REPO_SLUG"
21
+ c.example "renuo review-app:notify -p renuo-acme -b feature/42-migrate-to-deploio " \
22
+ "--channel '#project-acme-notifications'",
23
+ "Announces the branch's review app in the project's Slack channel"
24
+ c.action { |_args, options| new(options).run }
25
+ end
26
+
27
+ def run
28
+ post_to_slack(message_lines.join("\n"))
29
+ CmdTrail::Print.call
30
+ puts "\nnotified #{@channel}"
31
+ end
32
+
33
+ private
34
+
35
+ def initialize(options)
36
+ @project = options.project || abort("missing project name")
37
+ @branch = options.branch || abort("missing branch name")
38
+ @channel = options.channel || abort("missing Slack channel")
39
+ @repo = options.repo || ENV.fetch("SEMAPHORE_GIT_REPO_SLUG", nil).presence ||
40
+ abort("missing GitHub repository, set SEMAPHORE_GIT_REPO_SLUG or pass --repo")
41
+ @webhook_url = ENV.fetch("SLACK_WEBHOOK_URL", nil).presence || abort("SLACK_WEBHOOK_URL is not set")
42
+ derive_app_name(@branch)
43
+ end
44
+
45
+ Contract String => SafeReviewAppNameContract
46
+ def derive_app_name(branch)
47
+ @app_name = review_app_name(branch)
48
+ end
49
+
50
+ def message_lines
51
+ credentials = basic_auth_credentials
52
+ pull_request = fetch_pull_request
53
+
54
+ [
55
+ "Review app is ready: #{app_link(credentials)}",
56
+ pull_request && pull_request_line(pull_request),
57
+ "Branch: <https://github.com/#{@repo}/tree/#{@branch}|#{@branch}>"
58
+ ].compact
59
+ end
60
+
61
+ def pull_request_line(pull_request)
62
+ "Pull request: <#{pull_request["html_url"]}|##{pull_request["number"]} #{pull_request["title"]}>"
63
+ end
64
+
65
+ def app_link(credentials)
66
+ url = app_url
67
+ url.userinfo = userinfo(credentials) if credentials
68
+ "<#{url}|#{url.host}>"
69
+ end
70
+
71
+ def userinfo(credentials)
72
+ credentials.values_at("username", "password").map { |value| URI.encode_www_form_component(value) }.join(":")
73
+ end
74
+
75
+ def app_url
76
+ url = Deploio.fetch_resource("app", @app_name, @project).to_h.dig("status", "atProvider", "defaultURLs", 0)
77
+ URI(url || abort("#{@app_name} has no URL assigned yet"))
78
+ end
79
+
80
+ # Deploio assigns basic auth credentials per app, so the review app's differ from the origin's.
81
+ def basic_auth_credentials
82
+ stdout, = system!("nctl get app #{@app_name} -p #{@project} -o json --basic-auth-credentials", quiet: true)
83
+ Array(JSON.parse(stdout)).first&.dig("basicauth")
84
+ end
85
+
86
+ def fetch_pull_request
87
+ credentials = ENV.fetch("BUNDLE_GITHUB__COM", "")
88
+ return if credentials.empty?
89
+
90
+ response = Net::HTTP.start("api.github.com", 443, use_ssl: true) do |http|
91
+ http.request(pull_request_request(credentials))
92
+ end
93
+ return JSON.parse(response.body).first if response.is_a?(Net::HTTPSuccess)
94
+
95
+ warn "GitHub pull request lookup failed: #{response.code}"
96
+ nil
97
+ end
98
+
99
+ def pull_request_request(credentials)
100
+ query = URI.encode_www_form(state: "open", head: "#{@repo.split("/").first}:#{@branch}")
101
+ request = Net::HTTP::Get.new("/repos/#{@repo}/pulls?#{query}", "Accept" => "application/vnd.github+json")
102
+ user, token = credentials.split(":", 2)
103
+ request.basic_auth(user, token || "x-oauth-basic")
104
+ request
105
+ end
106
+
107
+ SENDER = { username: "Deploio Review Apps", icon_url: "https://deploio.ch/meta/apple-touch-icon.png" }.freeze
108
+ DEPLOIO_BLUE = "#141d50"
109
+
110
+ # The color bar on the side comes from the (legacy) attachment, which an incoming webhook still renders.
111
+ def post_to_slack(text)
112
+ attachment = { color: DEPLOIO_BLUE, text: text, mrkdwn_in: ["text"] }
113
+ payload = SENDER.merge(channel: @channel, attachments: [attachment]).to_json
114
+ response = Net::HTTP.post(URI(@webhook_url), payload, "Content-Type" => "application/json")
115
+ return if response.is_a?(Net::HTTPSuccess)
116
+
117
+ abort "Slack refused the notification: #{response.code} #{response.body}"
118
+ end
119
+ end
120
+ end
@@ -8,7 +8,7 @@ module Renuo::Cli::Commands::ReviewApp
8
8
  DEPLOY_PIPELINE_TARGET_FILE = ".semaphore/review-app-deploy.yml"
9
9
  PURGE_PIPELINE_TARGET_FILE = ".semaphore/review-app-purge.yml"
10
10
 
11
- attr_reader :project, :origin_pattern
11
+ attr_reader :project, :origin_pattern, :slack_channel
12
12
 
13
13
  command "review-app:setup-semaphoreci" do |c|
14
14
  c.syntax = "renuo review-app:setup-semaphoreci [options]"
@@ -16,13 +16,15 @@ module Renuo::Cli::Commands::ReviewApp
16
16
  c.description = c.summary
17
17
  c.option "-p", "--project <name>", String, "The name of the Deploio project"
18
18
  c.option "--origin-pattern <pattern>", String, "Name pattern identifying the origin app to copy from"
19
+ c.option "--slack-channel <name>", String, "The Slack channel notified once a review app is ready"
19
20
  c.action { |_args, options| new(options).run }
20
21
  end
21
22
 
22
23
  def run
23
24
  FileUtils.mkdir_p File.dirname(DEPLOY_PIPELINE_TARGET_FILE)
24
25
  write_or_warn DEPLOY_PIPELINE_TARGET_FILE,
25
- render(DEPLOY_PIPELINE_TEMPLATE_FILE, { project:, origin_pattern:, secret_name: })
26
+ render(DEPLOY_PIPELINE_TEMPLATE_FILE,
27
+ { project:, origin_pattern:, secret_name:, slack_channel: })
26
28
  write_or_warn PURGE_PIPELINE_TARGET_FILE, render(PURGE_PIPELINE_TEMPLATE_FILE, { project:, secret_name: })
27
29
  print_instructions
28
30
  end
@@ -32,10 +34,11 @@ module Renuo::Cli::Commands::ReviewApp
32
34
  def initialize(options)
33
35
  @project = options.project || abort("missing project name")
34
36
  @origin_pattern = options.origin_pattern || abort("missing origin pattern")
37
+ @slack_channel = options.slack_channel || abort("missing Slack channel")
35
38
  end
36
39
 
37
40
  def secret_name
38
- "nctl-#{project}-review-apps"
41
+ "review-apps"
39
42
  end
40
43
 
41
44
  # Deploio project names are prefixed with the name of their organization.
@@ -51,12 +54,17 @@ module Renuo::Cli::Commands::ReviewApp
51
54
 
52
55
  nctl create asa #{service_account_name} --project #{project}
53
56
 
54
- Register its credentials as the Semaphore secret #{secret_name}, used by both pipelines:
57
+ Register its credentials and the incoming webhook of the Slack channel #{slack_channel}
58
+ (https://api.slack.com/messaging/webhooks) as the project secret #{secret_name} of the Semaphore project
59
+ (assumed to be named #{project} as well), used by both pipelines:
55
60
 
56
- sem create secret #{secret_name} \\
61
+ sem create secret -p #{project} #{secret_name} \\
57
62
  -e NCTL_API_CLIENT_ID="$(nctl get asa #{service_account_name} --project #{project} --print-client-id)" \\
58
63
  -e NCTL_API_CLIENT_SECRET="$(nctl get asa #{service_account_name} --project #{project} --print-client-secret)" \\
59
- -e NCTL_ORGANIZATION=#{organization}
64
+ -e NCTL_ORGANIZATION=#{organization} \\
65
+ -e SLACK_WEBHOOK_URL=https://hooks.slack.com/services/...
66
+
67
+ The deploy pipeline also reads the bundle-github secret: its BUNDLE_GITHUB__COM links the branch's pull request.
60
68
 
61
69
  Add these promotions to .semaphore/semaphore.yml (no auto_promote, so they only run on click):
62
70
 
@@ -1,14 +1,18 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "active_support/encrypted_configuration"
4
-
5
3
  module CredentialsHelper
6
- CONFIG = ActiveSupport::EncryptedConfiguration.new(
7
- config_path: File.expand_path("../credentials.yml.enc", __dir__),
8
- key_path: "", env_key: "RENUO_CLI_MASTER_KEY", raise_if_missing_key: true
9
- )
4
+ # required lazily, it takes a large share of the CLI's startup time
5
+ def self.config
6
+ @config ||= begin
7
+ require "active_support/encrypted_configuration"
8
+ ActiveSupport::EncryptedConfiguration.new(
9
+ config_path: File.expand_path("../credentials.yml.enc", __dir__),
10
+ key_path: "", env_key: "RENUO_CLI_MASTER_KEY", raise_if_missing_key: true
11
+ )
12
+ end
13
+ end
10
14
 
11
15
  def credentials
12
- CONFIG.config
16
+ CredentialsHelper.config.config
13
17
  end
14
18
  end
@@ -20,6 +20,10 @@ do
20
20
  cache restore "sprocketscache-$SEMAPHORE_GIT_BRANCH,sprocketscache-develop"
21
21
  ;;
22
22
 
23
+ run_times)
24
+ cache restore "rspec-run-times-$SEMAPHORE_GIT_BRANCH,rspec-run-times-develop,rspec-run-times-main"
25
+ ;;
26
+
23
27
  *)
24
28
  echo "Invalid argument: Cache group $cache_group not found."
25
29
  exit 1
@@ -20,6 +20,15 @@ do
20
20
  cache store sprocketscache-$SEMAPHORE_GIT_BRANCH tmp/cache/assets/sprockets
21
21
  ;;
22
22
 
23
+ # rspec-core-parallel run times change with every build, so the key is replaced.
24
+ # Only main and develop store them, the other branches start from theirs.
25
+ run_times)
26
+ if [ -f tmp/rspec_parallel_run_times.txt ] && { [ "$SEMAPHORE_GIT_BRANCH" = "main" ] || [ "$SEMAPHORE_GIT_BRANCH" = "develop" ]; }; then
27
+ cache delete rspec-run-times-$SEMAPHORE_GIT_BRANCH
28
+ cache store rspec-run-times-$SEMAPHORE_GIT_BRANCH tmp/rspec_parallel_run_times.txt
29
+ fi
30
+ ;;
31
+
23
32
  *)
24
33
  echo "Invalid argument: Cache group $cache_group not found."
25
34
  exit 1
@@ -13,6 +13,7 @@ blocks:
13
13
  task:
14
14
  secrets:
15
15
  - name: <%= secret_name %>
16
+ - name: bundle-github
16
17
  jobs:
17
18
  - name: deploying
18
19
  commands:
@@ -42,3 +43,4 @@ blocks:
42
43
  # - renuo review-app:deploy --project <%= project %> --branch $SEMAPHORE_GIT_WORKING_BRANCH --origin-pattern <%= origin_pattern %> --keep-envs=build,sensitive-build,runtime,sensitive-runtime --start
43
44
  - renuo review-app:start --project <%= project %> --branch $SEMAPHORE_GIT_WORKING_BRANCH
44
45
  - renuo ci check-deploio-status $(renuo review-app:name --branch $SEMAPHORE_GIT_WORKING_BRANCH) --project <%= project %>
46
+ - renuo review-app:notify --project <%= project %> --branch $SEMAPHORE_GIT_WORKING_BRANCH --channel '<%= slack_channel %>'
@@ -53,10 +53,12 @@ blocks:
53
53
  - sem-service start postgres 18
54
54
  - cp .env.example .env
55
55
  - bundle exec rails db:create db:schema:load
56
+ - source .semaphore/bin/cache_restore run_times
56
57
  jobs:
57
58
  - name: tests
58
59
  commands:
59
60
  - bin/check
61
+ - source .semaphore/bin/cache_store run_times
60
62
  epilogue:
61
63
  always:
62
64
  commands:
@@ -3,7 +3,7 @@
3
3
  # simplecov:disable
4
4
  module Renuo
5
5
  class Cli
6
- VERSION = "4.29.0"
6
+ VERSION = "4.30.0"
7
7
  NAME = "renuo-cli"
8
8
  end
9
9
  end
data/lib/renuo/cli.rb CHANGED
@@ -1,18 +1,24 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "active_support"
4
- require "active_support/core_ext"
4
+ require "active_support/core_ext/enumerable"
5
+ require "active_support/core_ext/hash/keys"
6
+ require "active_support/core_ext/object/blank"
7
+ require "active_support/core_ext/string/exclude"
8
+ require "active_support/core_ext/string/filters"
9
+ require "active_support/core_ext/string/inflections"
5
10
  require "colorize"
6
11
  require "fileutils"
7
12
  require "highline"
8
13
  require "json"
9
14
  require "net/http"
10
15
  require "open3"
11
- require "psych"
12
16
  require "securerandom"
13
17
  require "service_actor"
18
+ require "time"
14
19
  require "tmpdir"
15
20
  require "uri"
21
+ require "yaml"
16
22
  require "renuo/cli/version"
17
23
 
18
24
  module Renuo
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: renuo-cli
3
3
  version: !ruby/object:Gem::Version
4
- version: 4.29.0
4
+ version: 4.30.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Renuo AG
@@ -117,8 +117,10 @@ files:
117
117
  - lib/renuo/cli/commands/heroku_users.rb
118
118
  - lib/renuo/cli/commands/list_large_git_files.rb
119
119
  - lib/renuo/cli/commands/release.rb
120
+ - lib/renuo/cli/commands/review_app/README.md
120
121
  - lib/renuo/cli/commands/review_app/deploy.rb
121
122
  - lib/renuo/cli/commands/review_app/name.rb
123
+ - lib/renuo/cli/commands/review_app/notify.rb
122
124
  - lib/renuo/cli/commands/review_app/purge.rb
123
125
  - lib/renuo/cli/commands/review_app/setup_semaphoreci.rb
124
126
  - lib/renuo/cli/commands/review_app/start.rb