capistrano-redmine-deployment 1.0.1 → 1.2.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: 5a1d4def15c15b92236d4cad5e63ab56a469b973763bcaeb0be17a59477efc5e
4
- data.tar.gz: 91f5982de00664a05f986f714acd782464c60901d6af1ff051dd7691ecf61032
3
+ metadata.gz: 5586e101b5eda84c25017c252b6a01d26715c4fbab7ee71e609a84a20b7e47cc
4
+ data.tar.gz: 9e85e888ef2336c3bdc760188cd2d18f10586b1dbabab0367d631f07f4c729da
5
5
  SHA512:
6
- metadata.gz: af10737c30d398ab457265538ea359850403e53ec28771ba020cef4a07d178c712aeaafc0a4cbce4d760d9bf81d21938c8b5aa85ef1a95c6e8116aded924a950
7
- data.tar.gz: 9d1282a7b24f607c94603b1604138e5d30a18173bbd468be524e39eb8af6c69bf10c47a36703d1868a047a85f9ebc51c65860b0ded0599c3b46f0655db26b1ac
6
+ metadata.gz: 61c1a9c03f4f60a48f3e4c17f601020b1c6802bee6dcca09d26a1d4f79a777a145696c588dedf0927ddf7e58e665f6cd659653ba033c91ded060555d0e73756e
7
+ data.tar.gz: e18d575a41748df42b3cb263031fdf6b9152258a5a3bc42f31edf303c15a12f02bbd8cfcb50c5448f27c66a873e26a67c6981fc856a8046ad4aeba45765c2b73
data/.gitignore CHANGED
File without changes
data/.rspec ADDED
@@ -0,0 +1,2 @@
1
+ --require spec_helper
2
+ --format documentation
data/Gemfile CHANGED
@@ -6,3 +6,4 @@ source "https://rubygems.org"
6
6
  gemspec
7
7
 
8
8
  gem "rake"
9
+ gem "rspec"
data/README.md CHANGED
@@ -49,14 +49,55 @@ set(:redmine_repository, "target-redmine-repository-identifier")
49
49
 
50
50
  # in case of `SSL` issues that are caused by *CRL* (i.e. by LetsEncrypt certificates)
51
51
  set(:redmine_host_verification, false)
52
+
53
+ # alternatively, for instances with a custom CA, point to a CA bundle instead of
54
+ # disabling verification entirely (preferred over :redmine_host_verification)
55
+ set(:redmine_ca_file, "/path/to/ca-bundle.pem")
52
56
  ```
53
57
 
54
58
  ### User-specific credentials
55
59
 
60
+ The `api_key` is resolved from the following sources, **later ones win**:
61
+
62
+ 1. capistrano variables: `:redmine_api_key`, or `:redmine_api_key_command` (its stdout is used as the key) as fallback
63
+ 2. `.redmine` file in the current directory (`$PWD`) or `$HOME`
64
+ 3. the `REDMINE_API_KEY` ENV variable
65
+
66
+ #### Option A: macOS Keychain (recommended)
67
+
68
+ Store the key once per developer in the keychain (use a dedicated entry):
69
+
70
+ $ security add-generic-password -s ri-redmine-deploy -a "$USER" -w 'YOUR_KEY'
71
+
72
+ Then point capistrano at a command that reads it, in your `config/deploy.rb`:
73
+
74
+ ```ruby
75
+ set :redmine_api_key_command, 'security find-generic-password -s ri-redmine-deploy -w'
76
+ ```
77
+
78
+ No secret ends up in `deploy.rb` or your SCM — only the command. Once every developer
79
+ has switched over, the `.redmine` file can be removed entirely (it stays supported as a
80
+ fallback).
81
+
82
+ > Use a **dedicated** keychain entry (e.g. `ri-redmine-deploy`).
83
+
84
+ #### Option B: `.redmine` file via rake-task
85
+
56
86
  Setup redmine `API-KEY` through rake-task:
57
87
 
58
- $ rake capistrano:redmine:deploy:setup
88
+ $ rake capistrano:redmine:deployment:setup
89
+
90
+
91
+ ## Verifying the setup
92
+
93
+ Once configured, verify that the credentials, host, project and repository resolve
94
+ to a reachable `redmine_deployment` endpoint:
95
+
96
+ $ cap <stage> redmine:verify
59
97
 
98
+ This receives a single deployment entry from redmine. A response with **no** entries
99
+ (i.e. no deployment has been logged yet) still counts as a success — it only fails
100
+ when the host is unreachable or the credentials / project / repository are wrong.
60
101
 
61
102
  ## Redmine requirements
62
103
 
@@ -64,6 +105,7 @@ Install the plugin `redmine_deployment`.
64
105
 
65
106
  ## Features
66
107
  * logs (success/failed) deployment to associated redmine repository
108
+ * verifies redmine access / configuration (`redmine:verify`)
67
109
 
68
110
  -----
69
111
 
data/docs/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
1
1
  # Capistrano::Redmine::Deployment - CHANGELOG
2
2
 
3
+ ## [1.2.0] - 2026-07-10
4
+ * **[add]** `redmine:verify` capistrano task to verify config & access by receiving a single deployment
5
+ * **[add]** `Client#receive_deployment` (and `Client.receive_deployment`) - GETs a single deployment entry; an empty (no entries) 2xx response is still treated as valid
6
+ * **[add]** rspec test suite for `Client#receive_deployment`
7
+
8
+ ## [1.1.0] - 2026-07-10
9
+ * **[add]** ENV layer for config (`REDMINE_API_KEY`, `REDMINE_HOST`, `REDMINE_PROJECT`, `REDMINE_REPOSITORY`, `REDMINE_CA_FILE`)
10
+ * **[add]** `:redmine_api_key` capistrano variable, with `:redmine_api_key_command` (command stdout, e.g. macOS keychain) as fallback
11
+ * **[add]** `:redmine_ca_file` config (and `REDMINE_CA_FILE` ENV) as a safe alternative to disabling host verification
12
+ * **[add]** rspec test suite for `Config.resolve` layer precedence
13
+ * **[ref]** config resolution order: capistrano is the base, then `.redmine` files, then ENV win over it
14
+ * **[fix]** enable SSL by URI scheme (`https`) instead of only port 443, so HTTPS on non-standard ports works
15
+
3
16
  ## [1.0.1] - 2025-10-24
4
17
  * **[add]** config for `host_verification` to skip issues with OpenSSL / CRL issues
5
18
  * **[add]** warning message during deployment while host verification is disabled
@@ -7,37 +7,105 @@ require 'openssl'
7
7
  module Capistrano
8
8
  module Redmine
9
9
  module Deployment
10
+ # HTTP layer that talks to the Redmine-side +redmine_deployment+ plugin.
11
+ #
12
+ # The client POSTs deployment receipts to, and GETs deployment entries
13
+ # from, +{host}/projects/{project}/deploy/{repository}.json+, authenticating
14
+ # with an +X-Redmine-API-Key+ header. A response carrying a +deployment+ key
15
+ # is treated as success; anything else (or an +errors+ array) is a failure.
16
+ #
17
+ # All connection details (host, project, repository, api_key, ca_file,
18
+ # host_verification) are read from the supplied {Config}. SSL is enabled
19
+ # automatically when the host URI uses the +https+ scheme. Console logging
20
+ # is on by default and can be turned off via {#silent!} or the +logging:+
21
+ # initializer flag.
22
+ #
23
+ # @example Log a successful deployment
24
+ # Client.deploy_success!(config, from_revision: 'abc', to_revision: 'def')
25
+ #
26
+ # @example Verify connectivity without writing anything
27
+ # Client.receive_deployment(config) # => {"id"=>7, ...}, nil, or false
10
28
  class Client
11
29
 
30
+ # @return [Config] the resolved configuration this client reads from.
12
31
  attr_reader :config
13
32
 
14
33
  class << self
34
+ # Logs a successful deployment.
35
+ #
36
+ # Tags the deployment as +'success'+ and POSTs it to Redmine via a
37
+ # fresh instance.
38
+ #
39
+ # @param config [Config] the resolved configuration.
40
+ # @param deployment [Hash] the deployment payload (mutated in place to
41
+ # set +:result+).
42
+ # @return [Boolean] +true+ when Redmine accepted the deployment.
15
43
  def deploy_success!(config, deployment)
16
44
  deployment[:result] = 'success'
17
45
 
18
46
  new(config).deploy!(deployment)
19
47
  end
20
48
 
49
+ # Logs a failed deployment.
50
+ #
51
+ # Tags the deployment as +'fail'+ and POSTs it to Redmine via a fresh
52
+ # instance.
53
+ #
54
+ # @param config [Config] the resolved configuration.
55
+ # @param deployment [Hash] the deployment payload (mutated in place to
56
+ # set +:result+).
57
+ # @return [Boolean] +true+ when Redmine accepted the deployment.
21
58
  def deploy_fail!(config, deployment)
22
59
  deployment[:result] = 'fail'
23
60
 
24
61
  new(config).deploy!(deployment)
25
62
  end
63
+
64
+ # Fetches a single deployment entry to verify connectivity.
65
+ #
66
+ # Convenience wrapper that builds a fresh instance and delegates to
67
+ # {#receive_deployment}.
68
+ #
69
+ # @param config [Config] the resolved configuration.
70
+ # @return [Hash, nil, false] the deployment hash, +nil+ when the
71
+ # endpoint is reachable but empty, or +false+ on a failed request.
72
+ def receive_deployment(config)
73
+ new(config).receive_deployment
74
+ end
26
75
  end
27
76
 
77
+ # @param config [Config] the resolved configuration to read connection
78
+ # details and credentials from.
79
+ # @param logging [Boolean] whether to print progress to STDOUT
80
+ # (default +true+). See {#silent!}.
28
81
  def initialize(config, logging: true)
29
82
  @config = config
30
83
  @logging = logging
31
84
  end
32
85
 
86
+ # Disables console logging for this client.
87
+ #
88
+ # @return [false]
33
89
  def silent!
34
90
  @logging = false
35
91
  end
36
92
 
93
+ # @return [Boolean] whether console logging is currently enabled.
37
94
  def log?
38
95
  @logging
39
96
  end
40
97
 
98
+ # POSTs a deployment receipt to Redmine.
99
+ #
100
+ # Logs the outgoing deployment, sends it, and reports the outcome. A
101
+ # response containing a +deployment+ key counts as success; anything
102
+ # else is logged as an error.
103
+ #
104
+ # @param deployment [Hash] the deployment payload. Recognized keys
105
+ # include +:result+, +:from_revision+, +:to_revision+, +:environment+,
106
+ # +:branch+ and +:servers+.
107
+ # @return [Boolean] +true+ when Redmine created the deployment,
108
+ # +false+ otherwise.
41
109
  def deploy!(deployment)
42
110
  log_deploy(deployment) if log?
43
111
 
@@ -54,20 +122,107 @@ module Capistrano
54
122
  end
55
123
  end
56
124
 
125
+ # Receives a single deployment entry from the redmine repository.
126
+ #
127
+ # Used to verify that the configured credentials / host / project /
128
+ # repository actually resolve to a reachable redmine_deployment endpoint.
129
+ # A successful response with NO entries is still considered valid - it
130
+ # just means no deployment has been logged yet.
131
+ #
132
+ # @return [Hash] the single deployment entry when one exists.
133
+ # @return [nil] when the endpoint is reachable but has no entries yet.
134
+ # @return [false] when the request itself failed (non-2xx / errors).
135
+ def receive_deployment
136
+ response = fetch_deployments
137
+
138
+ return false unless response.is_a?(Hash)
139
+ return false if response['errors']
140
+
141
+ extract_deployment(response)
142
+ end
143
+
57
144
  private
58
145
 
146
+ # Builds and performs the POST request carrying the deployment payload.
147
+ #
148
+ # @param deployment [Hash] the deployment payload to serialize as JSON.
149
+ # @return [Hash, String] the parsed JSON response, or the raw body when
150
+ # it is not valid JSON.
59
151
  def send_deployment(deployment)
60
- uri = URI("#{config.host}/projects/#{config.project}/deploy/#{config.repository}.json")
61
-
62
- http = Net::HTTP.new(uri.host, uri.port)
63
- http.use_ssl = true if uri.port == 443
64
- http.verify_mode = OpenSSL::SSL::VERIFY_NONE if config.host_verification == false
152
+ uri = deploy_uri
65
153
 
66
154
  request = Net::HTTP::Post.new(uri.request_uri)
67
155
  request["Content-Type"] = "application/json"
68
156
  request['X-Redmine-API-Key'] = config.api_key
69
157
  request.body = { deployment: deployment }.to_json
70
158
 
159
+ perform(uri, request)
160
+ end
161
+
162
+ # Builds and performs the GET request that fetches deployment entries.
163
+ #
164
+ # Requests only a single entry (+limit=1+) since that is all that is
165
+ # needed to confirm access.
166
+ #
167
+ # @return [Hash, String] the parsed JSON response, or the raw body when
168
+ # it is not valid JSON.
169
+ def fetch_deployments
170
+ uri = deploy_uri
171
+ # only a single entry is needed to verify access
172
+ uri.query = 'limit=1'
173
+
174
+ request = Net::HTTP::Get.new(uri.request_uri)
175
+ request["Content-Type"] = "application/json"
176
+ request['X-Redmine-API-Key'] = config.api_key
177
+
178
+ perform(uri, request)
179
+ end
180
+
181
+ # Pulls a single deployment out of an index/show response. The
182
+ # redmine_deployment plugin may return either a single +deployment+
183
+ # object or a +deployments+ collection - an empty collection is fine.
184
+ #
185
+ # @param response [Object] the parsed response body.
186
+ # @return [Hash] the +deployment+ object, or the first entry of a
187
+ # +deployments+ collection.
188
+ # @return [nil] when +response+ is not a hash, or when a +deployments+
189
+ # collection is empty.
190
+ def extract_deployment(response)
191
+ return nil unless response.is_a?(Hash)
192
+
193
+ if response['deployment']
194
+ response['deployment']
195
+ elsif response['deployments']
196
+ response['deployments'].first
197
+ end
198
+ end
199
+
200
+ # @return [URI::Generic] the deploy endpoint URI for the configured
201
+ # host / project / repository.
202
+ def deploy_uri
203
+ URI("#{config.host}/projects/#{config.project}/deploy/#{config.repository}.json")
204
+ end
205
+
206
+ # Performs an HTTP request, applying SSL and host-verification settings.
207
+ #
208
+ # SSL is enabled when the URI scheme is +https+. A configured +ca_file+
209
+ # takes precedence; otherwise +host_verification == false+ disables peer
210
+ # verification (the LetsEncrypt/CRL workaround).
211
+ #
212
+ # @param uri [URI::Generic] the request target (supplies host/port/scheme).
213
+ # @param request [Net::HTTPRequest] the prepared request to send.
214
+ # @return [Hash, Array, String] the parsed JSON response, or the raw body
215
+ # when it is not valid JSON.
216
+ def perform(uri, request)
217
+ http = Net::HTTP.new(uri.host, uri.port)
218
+ http.use_ssl = true if uri.scheme == 'https'
219
+
220
+ if config.ca_file
221
+ http.ca_file = config.ca_file
222
+ elsif config.host_verification == false
223
+ http.verify_mode = OpenSSL::SSL::VERIFY_NONE
224
+ end
225
+
71
226
  response = http.request(request)
72
227
 
73
228
  begin
@@ -77,9 +232,14 @@ module Capistrano
77
232
  end
78
233
  end
79
234
 
235
+ # Prints the outgoing deployment details (and any SSL warnings) to STDOUT.
236
+ #
237
+ # @param deployment [Hash] the deployment payload being sent.
238
+ # @return [void]
80
239
  def log_deploy(deployment)
81
240
  puts "Sending deployment information to #{config.host} (project: '#{config.project}' | repo: '#{config.repository}')"
82
- puts "\e[33m WARNING: Host verification disabled!\e[0m" if config.host_verification == false
241
+ puts "\e[33m Using custom CA file: #{config.ca_file}\e[0m" if config.ca_file
242
+ puts "\e[33m WARNING: Host verification disabled!\e[0m" if config.ca_file.nil? && config.host_verification == false
83
243
  puts ""
84
244
  puts " Commits......: #{deployment[:from_revision]} ... #{deployment[:to_revision]}"
85
245
  puts " Environment..: #{deployment[:environment] || '-'}"
@@ -89,11 +249,20 @@ module Capistrano
89
249
  puts ""
90
250
  end
91
251
 
252
+ # Prints a success line naming the created deployment id.
253
+ #
254
+ # @param response [Hash] the parsed success response.
255
+ # @return [void]
92
256
  def log_deploy_done(response)
93
257
  puts "\e[32mSuccessfully created deployment ##{response['deployment']['id']}\e[0m"
94
258
  puts ""
95
259
  end
96
260
 
261
+ # Prints a failure line, listing +errors+ when present or the raw
262
+ # response otherwise.
263
+ #
264
+ # @param response [Object] the parsed failure response.
265
+ # @return [void]
97
266
  def log_deploy_errors(response)
98
267
  if response['errors']
99
268
  puts "\e[31mFailed to created deployment: #{response['errors'].join(', ')}\e[0m"
@@ -106,5 +275,3 @@ module Capistrano
106
275
  end
107
276
  end
108
277
  end
109
-
110
-
@@ -14,7 +14,7 @@ module Capistrano
14
14
  # build new empty config
15
15
  config = new
16
16
 
17
- # try to resolve from capistrano
17
+ # capistrano is the base - files and ENV win over it
18
18
  config.assign!(config_from_capistrano(capistrano)) if capistrano
19
19
 
20
20
  # try to resolve from current PWD
@@ -22,23 +22,58 @@ module Capistrano
22
22
  config.assign!(config_from_file(File.join(ENV['HOME'], '.redmine')))
23
23
  config.assign!(config_from_file(file)) if file
24
24
 
25
+ # ENV wins over files and capistrano
26
+ config.assign!(config_from_env)
27
+
25
28
  config
26
29
  end
27
30
 
28
31
  def config_from_capistrano(capistrano)
29
32
  config = {
33
+ api_key: capistrano.fetch(:redmine_api_key) || api_key_from_command(capistrano),
30
34
  host: capistrano.fetch(:redmine_host),
31
35
  project: capistrano.fetch(:redmine_project) || capistrano.fetch(:redmine_project_id),
32
36
  repository: capistrano.fetch(:redmine_repository),
33
- host_verification: capistrano.fetch(:redmine_host_verification)
37
+ host_verification: capistrano.fetch(:redmine_host_verification),
38
+ ca_file: capistrano.fetch(:redmine_ca_file)
34
39
  }
35
40
 
36
41
  new(config)
37
42
  end
38
43
 
44
+ # Resolves config from ENV variables. Values are filtered by `assign!`,
45
+ # so unset (nil/empty) variables never overwrite an existing value.
46
+ def config_from_env
47
+ new({
48
+ api_key: ENV['REDMINE_API_KEY'],
49
+ host: ENV['REDMINE_HOST'],
50
+ project: ENV['REDMINE_PROJECT'],
51
+ repository: ENV['REDMINE_REPOSITORY'],
52
+ ca_file: ENV['REDMINE_CA_FILE']
53
+ })
54
+ end
55
+
39
56
  def config_from_file(file)
40
57
  new(file: file)
41
58
  end
59
+
60
+ private
61
+
62
+ # Resolves the api_key by running the `:redmine_api_key_command` capistrano
63
+ # variable (if set) and taking its stdout. This keeps the secret out of
64
+ # `deploy.rb` - only the command (e.g. a keychain lookup) lives there.
65
+ #
66
+ # On any failure (non-zero exit or empty stdout) nil is returned so the
67
+ # file/ENV fallback still applies.
68
+ def api_key_from_command(capistrano)
69
+ cmd = capistrano.fetch(:redmine_api_key_command)
70
+ return nil unless cmd && cmd != ''
71
+
72
+ key = `#{cmd}`.strip
73
+ return nil unless $?.success? && !key.empty?
74
+
75
+ key
76
+ end
42
77
  end
43
78
 
44
79
  def initialize(config = {}, file: nil)
@@ -10,8 +10,8 @@ module Capistrano
10
10
 
11
11
  module VERSION
12
12
  MAJOR = 1
13
- MINOR = 0
14
- TINY = 1
13
+ MINOR = 2
14
+ TINY = 0
15
15
  PRE = nil
16
16
 
17
17
  STRING = [MAJOR, MINOR, TINY, PRE].compact.join(".")
File without changes
@@ -24,6 +24,11 @@ Configure the redmine credentials for deployment.
24
24
  puts ""
25
25
  puts " > HINT: Keep configs empty to prevent to overwrite existing configs."
26
26
  puts ""
27
+ puts " > ALTERNATIVE (recommended): the api_key can also be provided without this file"
28
+ puts " via the ENV variable 'REDMINE_API_KEY' or the capistrano variable"
29
+ puts " ':redmine_api_key_command' (e.g. a macOS keychain lookup). Both win over"
30
+ puts " the '.redmine' file. See the README for details."
31
+ puts ""
27
32
  puts "******************************************************************************************************"
28
33
  puts ""
29
34
  puts ""
@@ -0,0 +1,57 @@
1
+ namespace :capistrano do
2
+ namespace :redmine do
3
+ namespace :deployment do
4
+ desc <<-END_DESC
5
+ Verify the redmine credentials & access for deployment.
6
+ END_DESC
7
+
8
+ task :verify do
9
+ require 'capistrano/redmine/deployment/config'
10
+ require 'capistrano/redmine/deployment/client'
11
+
12
+ # resolve configs (from ENV / .redmine file - no capistrano context here)
13
+ config = Capistrano::Redmine::Deployment::Config.resolve
14
+
15
+ puts "******************************************************************************************************"
16
+ puts "== Capistrano::Redmine::Deployment - verify =="
17
+ puts "******************************************************************************************************"
18
+ puts ""
19
+ puts " This task verifies your redmine deployment configuration & access."
20
+ puts " It receives a single deployment entry from the associated redmine repository."
21
+ puts ""
22
+ puts " Shared settings are resolved from ENV or the '.redmine' file (host, project,"
23
+ puts " repository, api_key). Run 'rake capistrano:redmine:deployment:setup' to configure them."
24
+ puts ""
25
+ puts "******************************************************************************************************"
26
+ puts ""
27
+ puts ""
28
+
29
+ # -- BEGIN over here ...
30
+
31
+ unless config.valid?
32
+ puts "\e[31mYour redmine configuration is missing or unfinished.\e[0m"
33
+ puts "Run 'rake capistrano:redmine:deployment:setup' to configure the credentials."
34
+ puts ""
35
+ puts "******************************************************************************************************"
36
+ puts ""
37
+ next
38
+ end
39
+
40
+ # An empty result (no deployments logged yet) is fine - `false` means the
41
+ # request itself failed (unreachable host, bad credentials, wrong project/repo).
42
+ result = Capistrano::Redmine::Deployment::Client.receive_deployment(config)
43
+
44
+ puts ""
45
+ if result == false
46
+ puts "\e[31mVerification FAILED. Check host, project, repository and api_key.\e[0m"
47
+ else
48
+ puts "\e[32mVerification succeeded.\e[0m"
49
+ end
50
+
51
+ puts ""
52
+ puts "******************************************************************************************************"
53
+ puts ""
54
+ end
55
+ end
56
+ end
57
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: capistrano-redmine-deployment
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.1
4
+ version: 1.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Tobias Gonsior
@@ -19,6 +19,7 @@ extensions: []
19
19
  extra_rdoc_files: []
20
20
  files:
21
21
  - ".gitignore"
22
+ - ".rspec"
22
23
  - Gemfile
23
24
  - LICENSE.txt
24
25
  - README.md
@@ -35,6 +36,7 @@ files:
35
36
  - lib/capistrano/redmine/deployment/railtie.rb
36
37
  - lib/capistrano/redmine/deployment/receipts.rb
37
38
  - lib/capistrano/redmine/deployment/tasks/setup.rake
39
+ - lib/capistrano/redmine/deployment/tasks/verify.rake
38
40
  - lib/capistrano/redmine/deployment/version.rb
39
41
  homepage: https://github.com/ruby-smart/capistrano-redmine-deployment
40
42
  licenses: