deployangel 0.1.34 → 0.1.36
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 +4 -4
- data/CHANGELOG.md +18 -0
- data/README.md +22 -6
- data/lib/deployangel/cli.rb +50 -9
- data/lib/deployangel/exerciser.rb +79 -15
- data/lib/deployangel/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: eb03031f58bd01feb61e1f478f4108eb3c969527e28cf86d970355a6bede0031
|
|
4
|
+
data.tar.gz: 3dd9679d37e80b23e449ee99ffd29bd3db9700bf8e2b5bfa665bf4413e2e4fb7
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: bbaff8f03fce84081cdf13786dac7d26912ffeb9eae1c3f032f91d3b00afd23ba95a076c6213846cd44390017e8b7f8cdfd7b63833a07fcef9f782b4e9576dc0
|
|
7
|
+
data.tar.gz: 2919b83e169d1ac91175c733d9366df8e05b4910f8722a99cb3fdae57a07dda205be55dce418f2c0c7e1d8e382dc17a9007e23c96126b0174d24ed24588c13be
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.36 (2026-11-01)
|
|
4
|
+
|
|
5
|
+
- `deployangel exercise --param id=42` fills path parameters (`:id`,
|
|
6
|
+
`{id}`, `<int:id>`), so routes like `GET /apps/:app_id/deployments/:id`
|
|
7
|
+
can be exercised. Skipped routes name the parameters they need.
|
|
8
|
+
- `deployangel exercise --header="Name: value"`, or
|
|
9
|
+
`DEPLOYANGEL_EXERCISE_HEADERS` (one per line), sends headers with every
|
|
10
|
+
request, such as a Bearer token or a test account's session cookie. The
|
|
11
|
+
output names them but never shows their values, and they go only to the
|
|
12
|
+
`--url` host.
|
|
13
|
+
|
|
14
|
+
## 0.1.35 (2026-11-01)
|
|
15
|
+
|
|
16
|
+
- `deployangel exercise` prints each route's status codes (`404 ×1`)
|
|
17
|
+
instead of their class (`1 4xx`). When every page answers 404, 405, or
|
|
18
|
+
410, as when `--url` is the app's marketing site rather than its own
|
|
19
|
+
host, it says so, records nothing on the release, and exits 5.
|
|
20
|
+
|
|
3
21
|
## 0.1.34 (2026-11-01)
|
|
4
22
|
|
|
5
23
|
- `deployangel verify` prints a failed release's next steps: the last
|
data/README.md
CHANGED
|
@@ -597,14 +597,29 @@ evidence. A release deployed during an app's first 24 hours can't clear,
|
|
|
597
597
|
so its plan lists nothing.
|
|
598
598
|
|
|
599
599
|
`deployangel exercise --url=<production URL>` does the read-only part for
|
|
600
|
-
you, from wherever it runs: it requests the plan's GET routes
|
|
601
|
-
|
|
600
|
+
you, from wherever it runs: it requests the plan's GET routes whose path
|
|
601
|
+
parameters it can fill, spreading any request shortfall across them (at most 200
|
|
602
602
|
requests, about 5 a second, as `DeployAngel-Exercise`), then records what it
|
|
603
603
|
sent on the release, where the page lists it under "Exercised from your
|
|
604
604
|
side". It skips and names routes that change data or need a path
|
|
605
605
|
parameter, and stops requesting a page after its first 404, sending its
|
|
606
|
-
share to the pages that answered.
|
|
607
|
-
|
|
606
|
+
share to the pages that answered. It prints each route's status codes. If
|
|
607
|
+
every page answers 404, 405, or 410, it records nothing and exits 5, since
|
|
608
|
+
the URL is probably not the app's own host (a marketing site, say). A "CLI
|
|
609
|
+
& coding agents" token can run it. `--dry-run` shows what it would send.
|
|
610
|
+
|
|
611
|
+
`--param id=42` fills a path parameter (`:id`, `{id}`, `<int:id>`) in every
|
|
612
|
+
route that has it, so `--param app_id=3 --param id=212` reaches
|
|
613
|
+
`GET /apps/:app_id/deployments/:id`. A value fills every route with that
|
|
614
|
+
name, so a wrong one just gets a 404 and no more requests.
|
|
615
|
+
|
|
616
|
+
`--header="Name: value"` sends a header with every request, such as
|
|
617
|
+
`Authorization: Bearer …` for an API or a test account's session cookie for
|
|
618
|
+
signed-in pages. Put secrets in `DEPLOYANGEL_EXERCISE_HEADERS` instead (one
|
|
619
|
+
header per line), which keeps them out of shell history. Headers go only to
|
|
620
|
+
the `--url` host, since redirects aren't followed. The output names them
|
|
621
|
+
but never shows their values, and they're never sent to DeployAngel. Use a
|
|
622
|
+
test account with as little access as possible: its cookie is a full sign-in.
|
|
608
623
|
|
|
609
624
|
### Suggested instructions for your coding agent
|
|
610
625
|
|
|
@@ -637,8 +652,9 @@ verdict: call the `wait_for_verification` MCP tool with the commit and
|
|
|
637
652
|
If its status is "exercisable" or "waiting_for_activity", run
|
|
638
653
|
`bundle exec deployangel exercise --url=<production URL>`: it sends the
|
|
639
654
|
plan's read-only requests and records them on the release. Offer to
|
|
640
|
-
exercise what it skips:
|
|
641
|
-
|
|
655
|
+
exercise what it skips: fill a route's IDs with `--param name=value`,
|
|
656
|
+
using an ID you can see in the app, and run routes that change data only
|
|
657
|
+
with a test account or after asking. If the status is "warm_up", nothing run can clear it.
|
|
642
658
|
```
|
|
643
659
|
|
|
644
660
|
## Development
|
data/lib/deployangel/cli.rb
CHANGED
|
@@ -34,7 +34,8 @@ module DeployAngel
|
|
|
34
34
|
plan What to exercise so a release [--commit=SHA | --version=V | --deployment=ID]
|
|
35
35
|
clears sooner [--format=text|json]
|
|
36
36
|
exercise Send the plan's read-only --url=PRODUCTION_URL [--commit=SHA | --version=V | --deployment=ID]
|
|
37
|
-
requests to production [--
|
|
37
|
+
requests to production [--param=NAME=VALUE] [--header="Name: value"]
|
|
38
|
+
[--max-requests=200] [--dry-run]
|
|
38
39
|
exception Details for a fingerprint deployangel exception FINGERPRINT
|
|
39
40
|
check Report a smoke test result --name=NAME --status=pass|fail [--covers=a,b]
|
|
40
41
|
[--commit=SHA | --deployment=ID] [--details-url=URL]
|
|
@@ -116,8 +117,9 @@ module DeployAngel
|
|
|
116
117
|
If its status is "exercisable" or "waiting_for_activity", run
|
|
117
118
|
`bundle exec deployangel exercise --url=<production URL>`: it sends the
|
|
118
119
|
plan's read-only requests and records them on the release. Offer to
|
|
119
|
-
exercise what it skips:
|
|
120
|
-
|
|
120
|
+
exercise what it skips: fill a route's IDs with `--param name=value`,
|
|
121
|
+
using an ID you can see in the app, and run routes that change data only
|
|
122
|
+
with a test account or after asking. If the status is "warm_up", nothing run can clear it.
|
|
121
123
|
- Confidence `medium` clears a release just as `high` does (high also needs 80%
|
|
122
124
|
coverage and a full baseline), so don't call a medium clearance weak.
|
|
123
125
|
Warnings (`suspicious: true`) aren't failures: report them as "watching
|
|
@@ -282,8 +284,10 @@ module DeployAngel
|
|
|
282
284
|
# Sends the plan's read-only requests from here, then records what it
|
|
283
285
|
# sent on the release (spec §16, Exercise records).
|
|
284
286
|
def exercise
|
|
285
|
-
options = parse(max_requests: Exerciser::MAX_REQUESTS, dry_run: false) do |o, opts|
|
|
287
|
+
options = parse(max_requests: Exerciser::MAX_REQUESTS, dry_run: false, headers: [], params: []) do |o, opts|
|
|
286
288
|
o.on("--url=URL") { |v| opts[:url] = v }
|
|
289
|
+
o.on("--header=HEADER") { |v| opts[:headers] << v }
|
|
290
|
+
o.on("--param=NAME=VALUE") { |v| opts[:params] << v }
|
|
287
291
|
o.on("--commit=SHA") { |v| opts[:commit] = v }
|
|
288
292
|
o.on("--version=VERSION") { |v| opts[:version] = v }
|
|
289
293
|
o.on("--deployment=ID") { |v| opts[:deployment_id] = v }
|
|
@@ -292,6 +296,15 @@ module DeployAngel
|
|
|
292
296
|
end
|
|
293
297
|
return usage_error("exercise needs --url, the app's production URL, like https://example.com") unless options[:url].to_s.match?(%r{\Ahttps?://[^/\s]+})
|
|
294
298
|
|
|
299
|
+
# DEPLOYANGEL_EXERCISE_HEADERS, one per line, keeps a secret out of
|
|
300
|
+
# shell history and the process list.
|
|
301
|
+
begin
|
|
302
|
+
headers = Exerciser.parse_headers(options[:headers] + @env["DEPLOYANGEL_EXERCISE_HEADERS"].to_s.split("\n"))
|
|
303
|
+
params = Exerciser.parse_params(options[:params])
|
|
304
|
+
rescue ArgumentError => e
|
|
305
|
+
return usage_error(e.message)
|
|
306
|
+
end
|
|
307
|
+
|
|
295
308
|
target = target_from(options)
|
|
296
309
|
return usage_error("no target: pass --commit, --version, or --deployment, or run inside a git repository") unless target
|
|
297
310
|
|
|
@@ -308,27 +321,55 @@ module DeployAngel
|
|
|
308
321
|
return 0
|
|
309
322
|
end
|
|
310
323
|
|
|
311
|
-
exerciser = Exerciser.new(base_url: options[:url], max_requests: options[:max_requests].clamp(1, Exerciser::MAX_REQUESTS),
|
|
324
|
+
exerciser = Exerciser.new(base_url: options[:url], headers: headers, params: params, max_requests: options[:max_requests].clamp(1, Exerciser::MAX_REQUESTS),
|
|
312
325
|
sleeper: @sleeper, requester: @requester)
|
|
326
|
+
header_names = ("With headers: #{headers.keys.join(", ")} (values not shown)" if headers.any?)
|
|
313
327
|
if options[:dry_run]
|
|
314
328
|
targets, skipped = exerciser.plan(plan)
|
|
315
329
|
@stdout.puts("Would send #{targets.sum(&:count)} requests:", *targets.map { |t| " #{exerciser.url_for(t.path)} ×#{t.count}" })
|
|
316
|
-
@stdout.puts(
|
|
330
|
+
@stdout.puts(header_names) if header_names
|
|
331
|
+
print_skipped(skipped)
|
|
317
332
|
return 0
|
|
318
333
|
end
|
|
319
334
|
|
|
320
335
|
result = exerciser.run(plan)
|
|
321
336
|
@stdout.puts("Sent #{result.sent} requests to #{options[:url]}:",
|
|
322
|
-
*result.routes.map { |r| " #{r["key"]} ×#{r["requests"]}: #{r["
|
|
323
|
-
@stdout.puts(
|
|
337
|
+
*result.routes.map { |r| " #{r["key"]} ×#{r["requests"]}: #{status_codes(result.codes[r["key"]])}" })
|
|
338
|
+
@stdout.puts(header_names) if header_names
|
|
339
|
+
print_skipped(result.skipped)
|
|
324
340
|
@stdout.puts("Stopped early: #{options[:url]} isn't answering.") if result.unreachable
|
|
341
|
+
# Requests that reached no page aren't worth recording: the app
|
|
342
|
+
# likely never saw them.
|
|
343
|
+
if result.routes.any? && result.nothing_served? && !result.unreachable
|
|
344
|
+
@stdout.puts("", "No page answered at #{options[:url]}: every route said it doesn't exist. Is that the app's own host, " \
|
|
345
|
+
"and not a marketing site or a different domain? Pass the URL where the app's pages load.")
|
|
346
|
+
@stdout.puts("#{result.unsent} more requests weren't sent, since no route answered.") if result.unsent.positive?
|
|
347
|
+
@stdout.puts("Nothing was recorded on the release.")
|
|
348
|
+
return USAGE_ERROR
|
|
349
|
+
end
|
|
325
350
|
if result.routes.any?
|
|
326
|
-
client.record_exercise(document.dig("deployment", "id"), routes: result.routes,
|
|
351
|
+
client.record_exercise(document.dig("deployment", "id"), routes: result.routes,
|
|
352
|
+
skipped: result.skipped.map { |s| s.except("missing") }, ran_at: @clock.call.utc.iso8601)
|
|
327
353
|
@stdout.puts("Recorded on the release. Wait for the verdict with: deployangel verify --wait")
|
|
328
354
|
end
|
|
329
355
|
result.unreachable ? USAGE_ERROR : 0
|
|
330
356
|
end
|
|
331
357
|
|
|
358
|
+
# Each skipped route and why, naming the parameters a --param would fill.
|
|
359
|
+
def print_skipped(skipped)
|
|
360
|
+
return if skipped.empty?
|
|
361
|
+
|
|
362
|
+
@stdout.puts("Skipped:", *skipped.map { |s| " #{s["key"]} (#{s["reason"]}#{": #{s["missing"].join(", ")}" if s["missing"]&.any?})" })
|
|
363
|
+
return unless skipped.any? { |s| s["missing"]&.any? }
|
|
364
|
+
|
|
365
|
+
@stdout.puts("Fill path parameters with --param, such as --param #{skipped.find { |s| s["missing"]&.any? }["missing"].first}=42.")
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
# "200 ×4, 500 ×1", in the order they came.
|
|
369
|
+
def status_codes(codes)
|
|
370
|
+
codes.map { |code, n| "#{code || "no answer"} ×#{n}" }.join(", ")
|
|
371
|
+
end
|
|
372
|
+
|
|
332
373
|
def exception
|
|
333
374
|
fingerprint = @argv.shift or return usage_error("exception needs a FINGERPRINT")
|
|
334
375
|
format = parse(format: nil) { |o, opts| o.on("--format=FORMAT", %w[text json]) { |v| opts[:format] = v } }[:format]
|
|
@@ -7,9 +7,10 @@ module DeployAngel
|
|
|
7
7
|
# `deployangel exercise`: sends a release's exercise plan's read-only
|
|
8
8
|
# requests to production from the customer's side, then records what it
|
|
9
9
|
# sent, so the release page says which routes were exercised that way.
|
|
10
|
-
# Only GET routes
|
|
11
|
-
# the
|
|
12
|
-
#
|
|
10
|
+
# Only GET routes not marked as changing data, and only those whose path
|
|
11
|
+
# parameters the caller filled (--param id=42); the rest are skipped and
|
|
12
|
+
# named, for the agent or a smoke test with a test account. The requests
|
|
13
|
+
# count like any traffic through the app's agent.
|
|
13
14
|
class Exerciser
|
|
14
15
|
MAX_REQUESTS = 200
|
|
15
16
|
INTERVAL = 0.2 # about 5 requests a second
|
|
@@ -19,14 +20,53 @@ module DeployAngel
|
|
|
19
20
|
# A route table can list pages an app doesn't serve, like the edit page
|
|
20
21
|
# of a resource that has none. One answer like this is enough to know.
|
|
21
22
|
NOT_SERVED = [ 404, 405, 410 ].freeze
|
|
22
|
-
# Rails' :id and *path,
|
|
23
|
-
|
|
23
|
+
# A path parameter, capturing its name: Rails' :id and *path, FastAPI's
|
|
24
|
+
# and ASP.NET Core's {id}, {id:int}, and {*path}, Django's <int:pk>.
|
|
25
|
+
PARAMETER = /[:*]([A-Za-z_]\w*)|\{\*{0,2}([A-Za-z_]\w*)(?::[^}]*)?\??\}|<(?:\w+:)?([A-Za-z_]\w*)>/
|
|
26
|
+
# One that can span segments, so its value keeps its slashes.
|
|
27
|
+
GLOB = %r{\A(?:\*|\{\*|\{\w+:path\}|<path:)}
|
|
28
|
+
PARAM = /\A([A-Za-z_]\w*)=(.+)\z/
|
|
29
|
+
# "Name: value", as with curl -H: a header name, then a value on one line.
|
|
30
|
+
HEADER = /\A([!#$%&'*+.^_`|~0-9A-Za-z-]+):[ \t]*(\S[^\r\n]*)\z/
|
|
24
31
|
|
|
25
32
|
Target = Struct.new(:key, :path, :count)
|
|
26
|
-
|
|
33
|
+
# codes: each route's exact status codes, nil for no answer, for
|
|
34
|
+
# display; routes carries status classes, which is what's recorded.
|
|
35
|
+
# unsent: requests the plan wanted that went nowhere, since no route
|
|
36
|
+
# answered.
|
|
37
|
+
Result = Struct.new(:routes, :skipped, :sent, :unreachable, :codes, :unsent, keyword_init: true) do
|
|
38
|
+
# Every answer said the page isn't served. Usually the wrong host,
|
|
39
|
+
# like the app's marketing site.
|
|
40
|
+
def nothing_served?
|
|
41
|
+
answers = codes.values.flat_map(&:keys)
|
|
42
|
+
answers.any? && answers.all? { |code| NOT_SERVED.include?(code) }
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Headers to send with every request, such as a token or a test
|
|
47
|
+
# account's session cookie, from "Name: value" lines. Raises
|
|
48
|
+
# ArgumentError naming the line, never its value.
|
|
49
|
+
def self.parse_headers(lines)
|
|
50
|
+
lines.map(&:strip).reject(&:empty?).each_with_index.to_h do |line, i|
|
|
51
|
+
match = HEADER.match(line) or raise ArgumentError, "header #{i + 1} must look like \"Name: value\""
|
|
52
|
+
[ match[1], match[2].rstrip ]
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Path parameter values, from "name=value" (--param id=42). Each fills
|
|
57
|
+
# that name in every route that has it.
|
|
58
|
+
def self.parse_params(pairs)
|
|
59
|
+
pairs.to_h do |pair|
|
|
60
|
+
match = PARAM.match(pair) or raise ArgumentError, "--param must look like name=value, such as --param id=42 (got #{pair.inspect})"
|
|
61
|
+
[ match[1], match[2] ]
|
|
62
|
+
end
|
|
63
|
+
end
|
|
27
64
|
|
|
28
|
-
|
|
65
|
+
# headers go only to base_url's host: redirects aren't followed.
|
|
66
|
+
def initialize(base_url:, headers: {}, params: {}, max_requests: MAX_REQUESTS, sleeper: ->(seconds) { sleep(seconds) }, requester: nil)
|
|
29
67
|
@base = URI(base_url)
|
|
68
|
+
@headers = headers
|
|
69
|
+
@params = params
|
|
30
70
|
@max_requests = max_requests
|
|
31
71
|
@sleeper = sleeper
|
|
32
72
|
@requester = requester || method(:get)
|
|
@@ -40,11 +80,12 @@ module DeployAngel
|
|
|
40
80
|
next unless item["kind"] == "route"
|
|
41
81
|
|
|
42
82
|
method, path = item["key"].to_s.split(" ", 2)
|
|
43
|
-
reason
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
83
|
+
next skipped << { "key" => item["key"], "reason" => "changes data" } if item["mutating"] || method != "GET"
|
|
84
|
+
|
|
85
|
+
path, missing = path ? fill(path) : [ nil, [] ]
|
|
86
|
+
if path.nil? || missing.any?
|
|
87
|
+
next skipped << { "key" => item["key"], "reason" => "needs a path parameter", "missing" => missing }
|
|
88
|
+
end
|
|
48
89
|
|
|
49
90
|
needed = item["runs_needed"] ? item["runs_needed"].to_i - item["runs"].to_i : 1
|
|
50
91
|
targets << Target.new(item["key"], path, [ needed, 1 ].max)
|
|
@@ -59,6 +100,7 @@ module DeployAngel
|
|
|
59
100
|
def run(exercise_plan)
|
|
60
101
|
targets, skipped = plan(exercise_plan)
|
|
61
102
|
@statuses = Hash.new { |hash, key| hash[key] = Hash.new(0) }
|
|
103
|
+
@codes = Hash.new { |hash, key| hash[key] = Hash.new(0) }
|
|
62
104
|
@sent = 0
|
|
63
105
|
@errors_in_a_row = 0
|
|
64
106
|
leftover = 0
|
|
@@ -76,12 +118,32 @@ module DeployAngel
|
|
|
76
118
|
answered << target if @statuses[target.key].keys.intersect?(%w[2xx 3xx])
|
|
77
119
|
end
|
|
78
120
|
leftover.times { |i| send_request(answered[i % answered.size]) unless unreachable? } if answered.any?
|
|
121
|
+
unsent = answered.empty? ? leftover : 0
|
|
79
122
|
|
|
80
123
|
routes = targets.filter_map do |target|
|
|
81
124
|
statuses = @statuses.fetch(target.key, nil) or next
|
|
82
125
|
{ "key" => target.key, "requests" => statuses.values.sum, "statuses" => statuses.to_h }
|
|
83
126
|
end
|
|
84
|
-
Result.new(routes: routes, skipped: skipped, sent: @sent, unreachable: unreachable
|
|
127
|
+
Result.new(routes: routes, skipped: skipped, sent: @sent, unreachable: unreachable?,
|
|
128
|
+
codes: routes.to_h { |route| [ route["key"], @codes[route["key"]].to_h ] }, unsent: unsent)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# The path with the given parameters filled in, and the names of any
|
|
132
|
+
# left unfilled.
|
|
133
|
+
def fill(path)
|
|
134
|
+
missing = []
|
|
135
|
+
filled = path.gsub(PARAMETER) do |segment|
|
|
136
|
+
name = $1 || $2 || $3
|
|
137
|
+
value = @params[name]
|
|
138
|
+
unless value
|
|
139
|
+
missing << name
|
|
140
|
+
next segment
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
glob = segment.match?(GLOB)
|
|
144
|
+
(glob ? value.split("/", -1) : [ value ]).map { |part| URI.encode_www_form_component(part).gsub("+", "%20") }.join("/")
|
|
145
|
+
end
|
|
146
|
+
[ filled, missing.uniq ]
|
|
85
147
|
end
|
|
86
148
|
|
|
87
149
|
def url_for(path)
|
|
@@ -94,10 +156,11 @@ module DeployAngel
|
|
|
94
156
|
private
|
|
95
157
|
def send_request(target)
|
|
96
158
|
@sleeper.call(INTERVAL) if @sent.positive?
|
|
97
|
-
status = @requester.call(url_for(target.path))
|
|
159
|
+
status = @requester.call(url_for(target.path), @headers)
|
|
98
160
|
@sent += 1
|
|
99
161
|
kind = status ? "#{status / 100}xx" : "error"
|
|
100
162
|
@statuses[target.key][kind] += 1
|
|
163
|
+
@codes[target.key][status] += 1
|
|
101
164
|
@errors_in_a_row = kind == "error" ? @errors_in_a_row + 1 : 0
|
|
102
165
|
status
|
|
103
166
|
end
|
|
@@ -124,11 +187,12 @@ module DeployAngel
|
|
|
124
187
|
|
|
125
188
|
# The status code, or nil when there was no answer. Redirects aren't
|
|
126
189
|
# followed: the request already reached the app.
|
|
127
|
-
def get(url)
|
|
190
|
+
def get(url, headers)
|
|
128
191
|
uri = URI(url)
|
|
129
192
|
request = Net::HTTP::Get.new(uri)
|
|
130
193
|
request["User-Agent"] = "DeployAngel-Exercise/#{DeployAngel::VERSION} (+https://www.deployangel.com/docs#exercise)"
|
|
131
194
|
request["Accept"] = "text/html,application/json;q=0.9,*/*;q=0.8"
|
|
195
|
+
headers.each { |name, value| request[name] = value }
|
|
132
196
|
Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", open_timeout: TIMEOUT, read_timeout: TIMEOUT) do |http|
|
|
133
197
|
http.request(request).code.to_i
|
|
134
198
|
end
|
data/lib/deployangel/version.rb
CHANGED