deployangel 0.1.18 → 0.1.20
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 +20 -0
- data/README.md +42 -14
- data/lib/deployangel/cli.rb +190 -5
- data/lib/deployangel/client.rb +6 -0
- data/lib/deployangel/exerciser.rb +115 -0
- data/lib/deployangel/version.rb +1 -1
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 59eed3c6629b0dc5b81cbec2636583d50f0038c2f51af8011108205a1a23eb8c
|
|
4
|
+
data.tar.gz: d10e3a967bed17b69b854a5b74deba72b66ad4e204b3c007bd1958fc81e1ca66
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: cecef6e5f9328a9b72877c16bbd51388d67a5673b98a75a56c78e6916f5bf99fd033baaf9056f101b25cd36553d0e6033a253800f571ecfab3724655f1301901
|
|
7
|
+
data.tar.gz: cb8460812b9f04eaf761e23282a644c62f4de9aaca20fcf42e1bc81478f898e096675fd1bdfc67e70aef60ac3053bce969de7310539b9261b036c262dae70bd1
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,25 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.20 (2026-10-09)
|
|
4
|
+
|
|
5
|
+
- `deployangel exercise --url=<production URL>` sends a release's exercise
|
|
6
|
+
plan's read-only requests (GET routes without path parameters, at most
|
|
7
|
+
200, about 5 a second) so a quiet release can clear sooner, then records
|
|
8
|
+
what it sent on the release, which lists it as exercised from your side.
|
|
9
|
+
It skips and names routes that change data or need a path parameter.
|
|
10
|
+
`--dry-run` shows what it would send.
|
|
11
|
+
- The agent instructions `deployangel install agents` writes now tell the
|
|
12
|
+
agent to run `deployangel exercise` when a release isn't cleared yet.
|
|
13
|
+
|
|
14
|
+
## 0.1.19 (2026-10-09)
|
|
15
|
+
|
|
16
|
+
- `deployangel install agents` sets up Claude Code, Cursor, and Codex in a
|
|
17
|
+
project: the MCP server in `.mcp.json`, `.cursor/mcp.json`, and
|
|
18
|
+
`.codex/config.toml` (forwarding `DEPLOYANGEL_API_TOKEN`), and instructions
|
|
19
|
+
to wait for a verdict after deploying in `AGENTS.md`, with a `CLAUDE.md`
|
|
20
|
+
that imports it. It never replaces existing entries, and re-running it only
|
|
21
|
+
updates its own instructions.
|
|
22
|
+
|
|
3
23
|
## 0.1.18 (2026-10-08)
|
|
4
24
|
|
|
5
25
|
- In Rails, the agent gets its file digests when it starts, so the reporter
|
data/README.md
CHANGED
|
@@ -392,6 +392,7 @@ bundle exec deployangel verify --wait # current git HEAD, until
|
|
|
392
392
|
bundle exec deployangel verify --wait --until=initial # return at the 15-minute initial check
|
|
393
393
|
bundle exec deployangel status # latest deployment
|
|
394
394
|
bundle exec deployangel plan # what to exercise so a release clears sooner
|
|
395
|
+
bundle exec deployangel exercise --url=https://example.com # send the plan's read-only requests
|
|
395
396
|
bundle exec deployangel release --commit=$SHA # register a deploy (manual or CI)
|
|
396
397
|
bundle exec deployangel check --name="smoke: signup" --status=pass --covers=registration
|
|
397
398
|
bundle exec deployangel exception <fingerprint>
|
|
@@ -413,6 +414,22 @@ choose with `--format=text|json`. Exit codes:
|
|
|
413
414
|
|
|
414
415
|
### MCP server
|
|
415
416
|
|
|
417
|
+
To set up Claude Code, Cursor, and Codex for a project in one step, run this
|
|
418
|
+
in the project:
|
|
419
|
+
|
|
420
|
+
```bash
|
|
421
|
+
bundle exec deployangel install agents
|
|
422
|
+
```
|
|
423
|
+
|
|
424
|
+
It adds the MCP server to `.mcp.json` (Claude Code), `.cursor/mcp.json`
|
|
425
|
+
(Cursor), and `.codex/config.toml` (Codex, which reads it only in projects you
|
|
426
|
+
trust), and the instructions below to `AGENTS.md`, with a `CLAUDE.md` that
|
|
427
|
+
imports it. It never replaces an existing entry, and running it again updates
|
|
428
|
+
only its own instructions. Commit the files; the token stays in your
|
|
429
|
+
environment.
|
|
430
|
+
|
|
431
|
+
Or add the server yourself:
|
|
432
|
+
|
|
416
433
|
```bash
|
|
417
434
|
claude mcp add deployangel -- bundle exec deployangel mcp # Claude Code
|
|
418
435
|
```
|
|
@@ -448,15 +465,27 @@ check labels what it covered; only the requests themselves count as
|
|
|
448
465
|
evidence. A release deployed during an app's first 24 hours can't clear,
|
|
449
466
|
so its plan lists nothing.
|
|
450
467
|
|
|
468
|
+
`deployangel exercise --url=<production URL>` does the read-only part for
|
|
469
|
+
you, from wherever it runs: it requests the plan's GET routes that have no
|
|
470
|
+
path parameters, spreading any request shortfall across them (at most 200
|
|
471
|
+
requests, about 5 a second, as `DeployAngel-Exercise`), then records what it
|
|
472
|
+
sent on the release, where the page lists it under "Exercised from your
|
|
473
|
+
side". It skips and names routes that change data or need a path
|
|
474
|
+
parameter. A "CLI & coding agents" token can run it. `--dry-run` shows what
|
|
475
|
+
it would send.
|
|
476
|
+
|
|
451
477
|
### Suggested instructions for your coding agent
|
|
452
478
|
|
|
453
|
-
|
|
479
|
+
`deployangel install agents` adds instructions like these. To add them
|
|
480
|
+
yourself, put this in your `CLAUDE.md` or `AGENTS.md`:
|
|
454
481
|
|
|
455
482
|
```markdown
|
|
456
|
-
## Production verification
|
|
483
|
+
## Production verification (DeployAngel)
|
|
457
484
|
|
|
458
|
-
After deploying,
|
|
459
|
-
|
|
485
|
+
After deploying, or pushing a commit that deploys, wait for DeployAngel's
|
|
486
|
+
verdict: call the `wait_for_verification` MCP tool with the commit and
|
|
487
|
+
`until: "initial"`, calling again while it's in progress, or run
|
|
488
|
+
`bundle exec deployangel verify --commit=<sha> --wait --until=initial`.
|
|
460
489
|
|
|
461
490
|
- Exit 0 / verified: the release is cleared. Report the clearance line and
|
|
462
491
|
anything DeployAngel is still watching, then move on.
|
|
@@ -466,17 +495,16 @@ After deploying, run `bundle exec deployangel verify --wait --until initial`
|
|
|
466
495
|
- Exit 7: warnings at the initial check. Report them and review the findings.
|
|
467
496
|
The release is NOT cleared.
|
|
468
497
|
- Exit 2 / inconclusive: the release is NOT verified. Do not claim success.
|
|
469
|
-
- Exit 1 / failed: read the findings and exceptions,
|
|
470
|
-
cause, and propose a fix. Do not roll back or change
|
|
471
|
-
explicit approval.
|
|
498
|
+
- Exit 1 / failed: read the findings and exceptions (`get_exception`),
|
|
499
|
+
investigate the likely cause, and propose a fix. Do not roll back or change
|
|
500
|
+
production without explicit approval.
|
|
472
501
|
- Exit 3: still verifying; run the command again.
|
|
473
|
-
- Not cleared yet
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
anything: the release can't clear until DeployAngel has a day of history.
|
|
502
|
+
- Not cleared yet: call `get_exercise_plan` (or `bundle exec deployangel plan`).
|
|
503
|
+
If its status is "exercisable" or "waiting_for_activity", run
|
|
504
|
+
`bundle exec deployangel exercise --url=<production URL>`: it sends the
|
|
505
|
+
plan's read-only requests and records them on the release. Offer to
|
|
506
|
+
exercise what it skips: routes that change data only with a test account
|
|
507
|
+
or after asking. If the status is "warm_up", nothing run can clear it.
|
|
480
508
|
```
|
|
481
509
|
|
|
482
510
|
## Development
|
data/lib/deployangel/cli.rb
CHANGED
|
@@ -11,13 +11,14 @@ require_relative "client"
|
|
|
11
11
|
require_relative "ci_environment"
|
|
12
12
|
require_relative "verification_waiter"
|
|
13
13
|
require_relative "cli/formatter"
|
|
14
|
+
require_relative "exerciser"
|
|
14
15
|
|
|
15
16
|
module DeployAngel
|
|
16
17
|
# `deployangel` command for developers, CI, and coding agents.
|
|
17
18
|
# Runs without booting Rails.
|
|
18
19
|
class CLI
|
|
19
20
|
USAGE_ERROR = 5
|
|
20
|
-
COMMANDS = %w[release verify status exception check install mcp version help].freeze
|
|
21
|
+
COMMANDS = %w[release verify status plan exercise exception check install mcp version help].freeze
|
|
21
22
|
|
|
22
23
|
HELP = <<~TEXT
|
|
23
24
|
Usage: deployangel <command> [options]
|
|
@@ -30,11 +31,14 @@ module DeployAngel
|
|
|
30
31
|
status Latest deployment and its verification
|
|
31
32
|
plan What to exercise so a release [--commit=SHA | --version=V | --deployment=ID]
|
|
32
33
|
clears sooner [--format=text|json]
|
|
34
|
+
exercise Send the plan's read-only --url=PRODUCTION_URL [--commit=SHA | --version=V | --deployment=ID]
|
|
35
|
+
requests to production [--max-requests=200] [--dry-run]
|
|
33
36
|
exception Details for a fingerprint deployangel exception FINGERPRINT
|
|
34
37
|
check Report a smoke test result --name=NAME --status=pass|fail [--covers=a,b]
|
|
35
38
|
[--commit=SHA | --deployment=ID] [--details-url=URL]
|
|
36
|
-
install Add a Kamal deploy hook,
|
|
37
|
-
bake the commit into an image
|
|
39
|
+
install Add a Kamal deploy hook, deployangel install kamal
|
|
40
|
+
bake the commit into an image, deployangel install docker
|
|
41
|
+
or set up coding agents deployangel install agents
|
|
38
42
|
mcp Run the MCP server over stdio (for coding agents)
|
|
39
43
|
|
|
40
44
|
With no target, verify, plan, and check use the current git HEAD commit.
|
|
@@ -71,6 +75,47 @@ module DeployAngel
|
|
|
71
75
|
Kamal apps don't need this: DeployAngel reads Kamal's KAMAL_VERSION.
|
|
72
76
|
HELP
|
|
73
77
|
|
|
78
|
+
# The block `deployangel install agents` keeps in AGENTS.md (and CLAUDE.md
|
|
79
|
+
# when it doesn't import AGENTS.md), replaced whole on each run.
|
|
80
|
+
AGENTS_START = "<!-- deployangel:start -->"
|
|
81
|
+
AGENTS_END = "<!-- deployangel:end -->"
|
|
82
|
+
AGENT_INSTRUCTIONS = <<~MARKDOWN
|
|
83
|
+
#{AGENTS_START}
|
|
84
|
+
## Production verification (DeployAngel)
|
|
85
|
+
|
|
86
|
+
After deploying, or pushing a commit that deploys, wait for DeployAngel's
|
|
87
|
+
verdict: call the `wait_for_verification` MCP tool with the commit and
|
|
88
|
+
`until: "initial"`, calling again while it's in progress, or run
|
|
89
|
+
`bundle exec deployangel verify --commit=<sha> --wait --until=initial`.
|
|
90
|
+
|
|
91
|
+
- Exit 0 / verified: the release is cleared. Report the clearance line and
|
|
92
|
+
anything DeployAngel is still watching, then move on.
|
|
93
|
+
- Exit 6: no problems so far, but NOT cleared. Report "no problems so far, not
|
|
94
|
+
yet cleared" and the expected clearance time. DeployAngel keeps verifying and
|
|
95
|
+
alerts on failure.
|
|
96
|
+
- Exit 7: warnings at the initial check. Report them and review the findings.
|
|
97
|
+
The release is NOT cleared.
|
|
98
|
+
- Exit 2 / inconclusive: the release is NOT verified. Do not claim success.
|
|
99
|
+
- Exit 1 / failed: read the findings and exceptions (`get_exception`),
|
|
100
|
+
investigate the likely cause, and propose a fix. Do not roll back or change
|
|
101
|
+
production without explicit approval.
|
|
102
|
+
- Exit 3: still verifying; run the command again.
|
|
103
|
+
- Not cleared yet: call `get_exercise_plan` (or `bundle exec deployangel plan`).
|
|
104
|
+
If its status is "exercisable" or "waiting_for_activity", run
|
|
105
|
+
`bundle exec deployangel exercise --url=<production URL>`: it sends the
|
|
106
|
+
plan's read-only requests and records them on the release. Offer to
|
|
107
|
+
exercise what it skips: routes that change data only with a test account
|
|
108
|
+
or after asking. If the status is "warm_up", nothing run can clear it.
|
|
109
|
+
#{AGENTS_END}
|
|
110
|
+
MARKDOWN
|
|
111
|
+
|
|
112
|
+
AGENTS_TOKEN_HELP = <<~HELP.chomp
|
|
113
|
+
The MCP server and CLI need DEPLOYANGEL_API_TOKEN, a "CLI & coding agents" token
|
|
114
|
+
from the app's Settings, in the environment your agent runs in (for example in
|
|
115
|
+
an .envrc with direnv). Never put it in these files: they're meant to be committed.
|
|
116
|
+
Codex reads .codex/config.toml only in projects you've marked as trusted.
|
|
117
|
+
HELP
|
|
118
|
+
|
|
74
119
|
DOCKERFILE_LINES = <<~DOCKERFILE
|
|
75
120
|
# The commit this image runs, for DeployAngel. Build with --build-arg GIT_SHA=$(git rev-parse HEAD).
|
|
76
121
|
ARG GIT_SHA
|
|
@@ -78,7 +123,7 @@ module DeployAngel
|
|
|
78
123
|
DOCKERFILE
|
|
79
124
|
|
|
80
125
|
def initialize(argv, env: ENV, stdin: $stdin, stdout: $stdout, stderr: $stderr, client: nil,
|
|
81
|
-
sleeper: ->(seconds) { sleep(seconds) }, clock: -> { Time.now }, git_head: nil, root: Dir.pwd)
|
|
126
|
+
sleeper: ->(seconds) { sleep(seconds) }, clock: -> { Time.now }, git_head: nil, root: Dir.pwd, requester: nil)
|
|
82
127
|
@root = root
|
|
83
128
|
@argv = argv.dup
|
|
84
129
|
@env = env
|
|
@@ -89,6 +134,7 @@ module DeployAngel
|
|
|
89
134
|
@sleeper = sleeper
|
|
90
135
|
@clock = clock
|
|
91
136
|
@git_head = git_head
|
|
137
|
+
@requester = requester
|
|
92
138
|
end
|
|
93
139
|
|
|
94
140
|
def run
|
|
@@ -98,6 +144,7 @@ module DeployAngel
|
|
|
98
144
|
when "verify" then verify
|
|
99
145
|
when "status" then verify(status: true)
|
|
100
146
|
when "plan" then plan
|
|
147
|
+
when "exercise" then exercise
|
|
101
148
|
when "exception" then exception
|
|
102
149
|
when "check" then check
|
|
103
150
|
when "install" then install
|
|
@@ -197,6 +244,56 @@ module DeployAngel
|
|
|
197
244
|
0
|
|
198
245
|
end
|
|
199
246
|
|
|
247
|
+
# Sends the plan's read-only requests from here, then records what it
|
|
248
|
+
# sent on the release (spec §16, Exercise records).
|
|
249
|
+
def exercise
|
|
250
|
+
options = parse(max_requests: Exerciser::MAX_REQUESTS, dry_run: false) do |o, opts|
|
|
251
|
+
o.on("--url=URL") { |v| opts[:url] = v }
|
|
252
|
+
o.on("--commit=SHA") { |v| opts[:commit] = v }
|
|
253
|
+
o.on("--version=VERSION") { |v| opts[:version] = v }
|
|
254
|
+
o.on("--deployment=ID") { |v| opts[:deployment_id] = v }
|
|
255
|
+
o.on("--max-requests=N", Integer) { |v| opts[:max_requests] = v }
|
|
256
|
+
o.on("--dry-run") { opts[:dry_run] = true }
|
|
257
|
+
end
|
|
258
|
+
return usage_error("exercise needs --url, the app's production URL, like https://example.com") unless options[:url].to_s.match?(%r{\Ahttps?://[^/\s]+})
|
|
259
|
+
|
|
260
|
+
target = target_from(options)
|
|
261
|
+
return usage_error("no target: pass --commit, --version, or --deployment, or run inside a git repository") unless target
|
|
262
|
+
|
|
263
|
+
outcome = VerificationWaiter.new(client: client, sleeper: @sleeper, clock: @clock).wait(target, wait: false)
|
|
264
|
+
if outcome.not_found
|
|
265
|
+
@stderr.puts("deployangel: no deployment found for #{target.values.first}")
|
|
266
|
+
return outcome.exit_code
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
document = outcome.document
|
|
270
|
+
plan = document["exercise_plan"] || {}
|
|
271
|
+
unless %w[exercisable waiting_for_activity].include?(plan["status"])
|
|
272
|
+
@stdout.puts("Nothing to exercise. #{plan["summary"]}".rstrip)
|
|
273
|
+
return 0
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
exerciser = Exerciser.new(base_url: options[:url], max_requests: options[:max_requests].clamp(1, Exerciser::MAX_REQUESTS),
|
|
277
|
+
sleeper: @sleeper, requester: @requester)
|
|
278
|
+
if options[:dry_run]
|
|
279
|
+
targets, skipped = exerciser.plan(plan)
|
|
280
|
+
@stdout.puts("Would send #{targets.sum(&:count)} requests:", *targets.map { |t| " #{exerciser.url_for(t.path)} ×#{t.count}" })
|
|
281
|
+
@stdout.puts("Skipped:", *skipped.map { |s| " #{s["key"]} (#{s["reason"]})" }) if skipped.any?
|
|
282
|
+
return 0
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
result = exerciser.run(plan)
|
|
286
|
+
@stdout.puts("Sent #{result.sent} requests to #{options[:url]}:",
|
|
287
|
+
*result.routes.map { |r| " #{r["key"]} ×#{r["requests"]}: #{r["statuses"].map { |kind, n| "#{n} #{kind}" }.join(", ")}" })
|
|
288
|
+
@stdout.puts("Skipped:", *result.skipped.map { |s| " #{s["key"]} (#{s["reason"]})" }) if result.skipped.any?
|
|
289
|
+
@stdout.puts("Stopped early: #{options[:url]} isn't answering.") if result.unreachable
|
|
290
|
+
if result.routes.any?
|
|
291
|
+
client.record_exercise(document.dig("deployment", "id"), routes: result.routes, skipped: result.skipped, ran_at: @clock.call.utc.iso8601)
|
|
292
|
+
@stdout.puts("Recorded on the release. Wait for the verdict with: deployangel verify --wait")
|
|
293
|
+
end
|
|
294
|
+
result.unreachable ? USAGE_ERROR : 0
|
|
295
|
+
end
|
|
296
|
+
|
|
200
297
|
def exception
|
|
201
298
|
fingerprint = @argv.shift or return usage_error("exception needs a FINGERPRINT")
|
|
202
299
|
format = parse(format: nil) { |o, opts| o.on("--format=FORMAT", %w[text json]) { |v| opts[:format] = v } }[:format]
|
|
@@ -230,7 +327,8 @@ module DeployAngel
|
|
|
230
327
|
case (target = @argv.shift)
|
|
231
328
|
when "kamal" then install_kamal
|
|
232
329
|
when "docker" then install_docker
|
|
233
|
-
|
|
330
|
+
when "agents" then install_agents
|
|
331
|
+
else usage_error("install needs a target: deployangel install kamal, docker, or agents")
|
|
234
332
|
end
|
|
235
333
|
end
|
|
236
334
|
|
|
@@ -256,6 +354,93 @@ module DeployAngel
|
|
|
256
354
|
0
|
|
257
355
|
end
|
|
258
356
|
|
|
357
|
+
# Sets up Claude Code, Cursor, and Codex in this project: the MCP server
|
|
358
|
+
# in each one's project config, and instructions to wait for a verdict
|
|
359
|
+
# after deploying. Never overwrites what's there: an existing entry is
|
|
360
|
+
# kept, and a file it can't read is left for you to edit.
|
|
361
|
+
def install_agents
|
|
362
|
+
command = %w[bundle exec deployangel mcp]
|
|
363
|
+
lines = [
|
|
364
|
+
install_json_mcp(".mcp.json", command, "Claude Code"),
|
|
365
|
+
install_json_mcp(File.join(".cursor", "mcp.json"), command, "Cursor"),
|
|
366
|
+
install_codex_mcp(command),
|
|
367
|
+
*install_agent_instructions
|
|
368
|
+
]
|
|
369
|
+
@stdout.puts(*lines, "", AGENTS_TOKEN_HELP)
|
|
370
|
+
0
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
def install_json_mcp(relative, command, agent)
|
|
374
|
+
path = File.join(@root, relative)
|
|
375
|
+
config = File.exist?(path) ? JSON.parse(File.read(path)) : {}
|
|
376
|
+
raise JSON::ParserError, "not a JSON object" unless config.is_a?(Hash)
|
|
377
|
+
|
|
378
|
+
servers = (config["mcpServers"] ||= {})
|
|
379
|
+
return "#{relative} already has a deployangel MCP server (#{agent})." if servers.key?("deployangel")
|
|
380
|
+
|
|
381
|
+
created = !File.exist?(path)
|
|
382
|
+
servers["deployangel"] = { "command" => command.first, "args" => command.drop(1) }
|
|
383
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
384
|
+
File.write(path, "#{JSON.pretty_generate(config)}\n")
|
|
385
|
+
"#{created ? "Created" : "Updated"} #{relative}: the DeployAngel MCP server for #{agent}."
|
|
386
|
+
rescue JSON::ParserError
|
|
387
|
+
"Couldn't read #{relative}, so it's unchanged. Add a \"deployangel\" server to its mcpServers: " \
|
|
388
|
+
"command \"#{command.first}\", args #{command.drop(1).to_json}."
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
def install_codex_mcp(command)
|
|
392
|
+
relative = File.join(".codex", "config.toml")
|
|
393
|
+
path = File.join(@root, relative)
|
|
394
|
+
existing = File.exist?(path) ? File.read(path) : nil
|
|
395
|
+
return "#{relative} already has a deployangel MCP server (Codex)." if existing&.include?("[mcp_servers.deployangel]")
|
|
396
|
+
|
|
397
|
+
table = <<~TOML
|
|
398
|
+
[mcp_servers.deployangel]
|
|
399
|
+
command = #{command.first.to_json}
|
|
400
|
+
args = #{command.drop(1).to_json.gsub(",", ", ")}
|
|
401
|
+
env_vars = ["DEPLOYANGEL_API_TOKEN", "DEPLOYANGEL_URL"]
|
|
402
|
+
TOML
|
|
403
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
404
|
+
File.write(path, existing ? "#{existing.sub(/\n*\z/, "\n")}\n#{table}" : table)
|
|
405
|
+
"#{existing ? "Updated" : "Created"} #{relative}: the DeployAngel MCP server for Codex."
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
# AGENTS.md serves Codex and Cursor. Claude Code reads CLAUDE.md, so it
|
|
409
|
+
# gets the block too, unless it already imports AGENTS.md; a project
|
|
410
|
+
# without one gets a CLAUDE.md that does.
|
|
411
|
+
def install_agent_instructions
|
|
412
|
+
lines = [ upsert_instructions("AGENTS.md") ]
|
|
413
|
+
claude = File.join(@root, "CLAUDE.md")
|
|
414
|
+
if !File.exist?(claude)
|
|
415
|
+
File.write(claude, "@AGENTS.md\n")
|
|
416
|
+
lines << "Created CLAUDE.md, which imports AGENTS.md for Claude Code."
|
|
417
|
+
elsif !File.read(claude).match?(/^@AGENTS\.md\s*$/)
|
|
418
|
+
lines << upsert_instructions("CLAUDE.md")
|
|
419
|
+
end
|
|
420
|
+
lines
|
|
421
|
+
end
|
|
422
|
+
|
|
423
|
+
def upsert_instructions(relative)
|
|
424
|
+
path = File.join(@root, relative)
|
|
425
|
+
unless File.exist?(path)
|
|
426
|
+
File.write(path, AGENT_INSTRUCTIONS)
|
|
427
|
+
return "Created #{relative} with instructions to wait for DeployAngel's verdict after deploying."
|
|
428
|
+
end
|
|
429
|
+
|
|
430
|
+
content = File.read(path)
|
|
431
|
+
block = /#{Regexp.escape(AGENTS_START)}.*?#{Regexp.escape(AGENTS_END)}\n?/m
|
|
432
|
+
if content.match?(block)
|
|
433
|
+
updated = content.sub(block, AGENT_INSTRUCTIONS)
|
|
434
|
+
return "#{relative} already has DeployAngel's instructions." if updated == content
|
|
435
|
+
|
|
436
|
+
File.write(path, updated)
|
|
437
|
+
"Updated DeployAngel's instructions in #{relative}."
|
|
438
|
+
else
|
|
439
|
+
File.write(path, "#{content.sub(/\n*\z/, "\n")}\n#{AGENT_INSTRUCTIONS}")
|
|
440
|
+
"Added instructions to wait for DeployAngel's verdict after deploying to #{relative}."
|
|
441
|
+
end
|
|
442
|
+
end
|
|
443
|
+
|
|
259
444
|
# Adds DEPLOYANGEL_REVISION, from a GIT_SHA build arg, to the Dockerfile.
|
|
260
445
|
# It never edits CI workflows; it says what to pass instead.
|
|
261
446
|
def install_docker
|
data/lib/deployangel/client.rb
CHANGED
|
@@ -49,6 +49,12 @@ module DeployAngel
|
|
|
49
49
|
{ name: name, status: status, covers: covers, details_url: details_url }.compact)
|
|
50
50
|
end
|
|
51
51
|
|
|
52
|
+
# What `deployangel exercise` sent (spec §16, Exercise records). A
|
|
53
|
+
# "CLI & coding agents" token may record one: it only labels the release.
|
|
54
|
+
def record_exercise(deployment_id, routes:, skipped:, ran_at:)
|
|
55
|
+
post("/api/v1/deployments/#{deployment_id}/exercises", { routes: routes, skipped: skipped, ran_at: ran_at })
|
|
56
|
+
end
|
|
57
|
+
|
|
52
58
|
private
|
|
53
59
|
def get(path, params = {})
|
|
54
60
|
uri = URI("#{@endpoint}#{path}")
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "uri"
|
|
5
|
+
|
|
6
|
+
module DeployAngel
|
|
7
|
+
# `deployangel exercise`: sends a release's exercise plan's read-only
|
|
8
|
+
# requests to production from the customer's side, then records what it
|
|
9
|
+
# sent, so the release page says which routes were exercised that way.
|
|
10
|
+
# Only GET routes with no path parameters and not marked as changing data;
|
|
11
|
+
# the rest are skipped and named, for the agent or a smoke test with a test
|
|
12
|
+
# account. The requests count like any traffic through the app's agent.
|
|
13
|
+
class Exerciser
|
|
14
|
+
MAX_REQUESTS = 200
|
|
15
|
+
INTERVAL = 0.2 # about 5 requests a second
|
|
16
|
+
TIMEOUT = 10
|
|
17
|
+
# Stop when the app isn't answering, rather than send the rest into it.
|
|
18
|
+
MAX_CONSECUTIVE_ERRORS = 5
|
|
19
|
+
# Rails' :id and *path, Django's <int:pk>, FastAPI's {id}.
|
|
20
|
+
PARAMETER = /\/[:*{<]/
|
|
21
|
+
|
|
22
|
+
Target = Struct.new(:key, :path, :count)
|
|
23
|
+
Result = Struct.new(:routes, :skipped, :sent, :unreachable, keyword_init: true)
|
|
24
|
+
|
|
25
|
+
def initialize(base_url:, max_requests: MAX_REQUESTS, sleeper: ->(seconds) { sleep(seconds) }, requester: nil)
|
|
26
|
+
@base = URI(base_url)
|
|
27
|
+
@max_requests = max_requests
|
|
28
|
+
@sleeper = sleeper
|
|
29
|
+
@requester = requester || method(:get)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# What would be sent: [targets, skipped].
|
|
33
|
+
def plan(exercise_plan)
|
|
34
|
+
skipped = []
|
|
35
|
+
targets = []
|
|
36
|
+
Array(exercise_plan["items"]).each do |item|
|
|
37
|
+
next unless item["kind"] == "route"
|
|
38
|
+
|
|
39
|
+
method, path = item["key"].to_s.split(" ", 2)
|
|
40
|
+
reason =
|
|
41
|
+
if item["mutating"] || method != "GET" then "changes data"
|
|
42
|
+
elsif path.nil? || path.match?(PARAMETER) then "needs a path parameter"
|
|
43
|
+
end
|
|
44
|
+
next skipped << { "key" => item["key"], "reason" => reason } if reason
|
|
45
|
+
|
|
46
|
+
needed = item["runs_needed"] ? item["runs_needed"].to_i - item["runs"].to_i : 1
|
|
47
|
+
targets << Target.new(item["key"], path, [ needed, 1 ].max)
|
|
48
|
+
end
|
|
49
|
+
spread_shortfall(targets, exercise_plan.dig("shortfall", "requests"))
|
|
50
|
+
cap(targets)
|
|
51
|
+
[ targets, skipped ]
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def run(exercise_plan)
|
|
55
|
+
targets, skipped = plan(exercise_plan)
|
|
56
|
+
routes = []
|
|
57
|
+
errors_in_a_row = 0
|
|
58
|
+
sent = 0
|
|
59
|
+
targets.each do |target|
|
|
60
|
+
statuses = Hash.new(0)
|
|
61
|
+
target.count.times do
|
|
62
|
+
break if errors_in_a_row >= MAX_CONSECUTIVE_ERRORS
|
|
63
|
+
|
|
64
|
+
@sleeper.call(INTERVAL) if sent.positive?
|
|
65
|
+
status = @requester.call(url_for(target.path))
|
|
66
|
+
sent += 1
|
|
67
|
+
kind = status ? "#{status / 100}xx" : "error"
|
|
68
|
+
statuses[kind] += 1
|
|
69
|
+
errors_in_a_row = kind == "error" ? errors_in_a_row + 1 : 0
|
|
70
|
+
end
|
|
71
|
+
requests = statuses.values.sum
|
|
72
|
+
routes << { "key" => target.key, "requests" => requests, "statuses" => statuses.to_h } if requests.positive?
|
|
73
|
+
end
|
|
74
|
+
Result.new(routes: routes, skipped: skipped, sent: sent, unreachable: errors_in_a_row >= MAX_CONSECUTIVE_ERRORS)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def url_for(path)
|
|
78
|
+
uri = @base.dup
|
|
79
|
+
uri.path = "#{@base.path.chomp("/")}#{path}"
|
|
80
|
+
uri.query = nil
|
|
81
|
+
uri.to_s
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
private
|
|
85
|
+
# Requests the release is short of overall, spread across the routes,
|
|
86
|
+
# or sent to the home page when the plan names no route to send them to.
|
|
87
|
+
def spread_shortfall(targets, requests)
|
|
88
|
+
return unless requests
|
|
89
|
+
|
|
90
|
+
extra = requests["need"].to_i - requests["have"].to_i - targets.sum(&:count)
|
|
91
|
+
return unless extra.positive?
|
|
92
|
+
|
|
93
|
+
targets << Target.new("GET /", "/", 0) if targets.empty?
|
|
94
|
+
extra.times { |i| targets[i % targets.size].count += 1 }
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def cap(targets)
|
|
98
|
+
targets.max_by(&:count).count -= 1 while targets.sum(&:count) > @max_requests
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# The status code, or nil when there was no answer. Redirects aren't
|
|
102
|
+
# followed: the request already reached the app.
|
|
103
|
+
def get(url)
|
|
104
|
+
uri = URI(url)
|
|
105
|
+
request = Net::HTTP::Get.new(uri)
|
|
106
|
+
request["User-Agent"] = "DeployAngel-Exercise/#{DeployAngel::VERSION} (+https://www.deployangel.com/docs#exercise)"
|
|
107
|
+
request["Accept"] = "text/html,application/json;q=0.9,*/*;q=0.8"
|
|
108
|
+
Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", open_timeout: TIMEOUT, read_timeout: TIMEOUT) do |http|
|
|
109
|
+
http.request(request).code.to_i
|
|
110
|
+
end
|
|
111
|
+
rescue StandardError
|
|
112
|
+
nil
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
data/lib/deployangel/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: deployangel
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.1.
|
|
4
|
+
version: 0.1.20
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Jordan Owens
|
|
@@ -46,6 +46,7 @@ files:
|
|
|
46
46
|
- lib/deployangel/core/redaction.rb
|
|
47
47
|
- lib/deployangel/core/release.rb
|
|
48
48
|
- lib/deployangel/core/transport.rb
|
|
49
|
+
- lib/deployangel/exerciser.rb
|
|
49
50
|
- lib/deployangel/fork_hook.rb
|
|
50
51
|
- lib/deployangel/mcp.rb
|
|
51
52
|
- lib/deployangel/metadata.rb
|
|
@@ -82,7 +83,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
82
83
|
- !ruby/object:Gem::Version
|
|
83
84
|
version: '0'
|
|
84
85
|
requirements: []
|
|
85
|
-
rubygems_version: 4.0.
|
|
86
|
+
rubygems_version: 4.0.10
|
|
86
87
|
specification_version: 4
|
|
87
88
|
summary: 'DeployAngel agent for Rails: production verification for every deployment.'
|
|
88
89
|
test_files: []
|