Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 8 additions & 5 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,20 @@ name: CI
on: [push]

jobs:
lint:
test:
runs-on: ubuntu-latest
strategy:
matrix:
ruby-version: ['2.7', '3.0', '3.1', '3.2', '3.3', '3.4']
steps:
- uses: actions/checkout@v4
- uses: ruby/setup-ruby@v1
with:
ruby-version: 3.4.1
ruby-version: ${{ matrix.ruby-version }}
- name: Install dependencies
run: bundle install
- name: Run linter
run: bundle exec rubocop
- name: Run linter and tests
run: bundle exec rake

release-please:
runs-on: ubuntu-latest
Expand All @@ -26,7 +29,7 @@ jobs:

release:
runs-on: ubuntu-latest
needs: [lint, release-please]
needs: [test, release-please]
if: ${{ needs.release-please.outputs.release_created }}
steps:
- uses: actions/checkout@v4
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ examples
.idea
Deployfile
Gemfile.lock
spec/examples.txt
3 changes: 3 additions & 0 deletions .rspec
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
--require spec_helper
--format documentation
--color
17 changes: 15 additions & 2 deletions .rubocop.yml
Original file line number Diff line number Diff line change
@@ -1,14 +1,26 @@
AllCops:
TargetRubyVersion: 2.7
NewCops: enable
SuggestExtensions: false

Metrics/BlockLength:
Exclude:
- spec/**/*
- test/**/*

Metrics/ModuleLength:
Exclude:
- spec/**/*
- test/**/*

Metrics/MethodLength:
Max: 14
Exclude:
- test/**/*

Metrics/AbcSize:
Exclude:
- test/**/*

Style/StringConcatenation:
Enabled: false
Expand All @@ -20,8 +32,9 @@ Naming/FileName:
Exclude:
- lib/klogger-logger.rb

Metrics/MethodLength:
Max: 14
Naming/PredicateMethod:
Exclude:
- lib/deploy/resource.rb

Metrics/ClassLength:
Max: 120
Expand Down
98 changes: 98 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## Project Overview

DeployHQ Ruby API library and CLI client. Provides programmatic access to the DeployHQ deployment platform and a command-line tool for triggering deployments.

## Development Commands

### Setup
```bash
bundle install
```

### Linting and testing
```bash
# Run all checks (linting + tests)
bundle exec rake

# Run only linting
bundle exec rubocop

# Run only tests
bundle exec rspec

# Run a specific test file
bundle exec rspec spec/configuration_spec.rb

# Run tests with verbose output
bundle exec rspec --format documentation
```

### Building the gem
```bash
gem build deployhq.gemspec
```

### Testing CLI locally
```bash
ruby -Ilib bin/deployhq <command>
```

## Architecture

### Core Components

**Deploy Module** (`lib/deploy.rb`): Main entry point that provides configuration management via `Deploy.configure` and `Deploy.configuration`. Configuration can be loaded from files using `Deploy.configuration_file=`.

**Resource System**: Base class pattern where `Deploy::Resource` provides ActiveRecord-like interface for API objects:
- `find(:all)` and `find(id)` for retrieval
- `save`, `create`, `update` for persistence
- `destroy` for deletion
- Child resources (Project, Deployment, Server, ServerGroup, DeploymentStep, DeploymentStepLog) inherit this behavior

**Request Layer** (`lib/deploy/request.rb`): HTTP client using Net::HTTP with basic auth. Handles JSON serialization/deserialization and translates HTTP status codes to appropriate exceptions or boolean success states.

**CLI** (`lib/deploy/cli.rb`): OptionParser-based command-line interface with three main commands:
- `configure`: Interactive setup wizard for creating Deployfile
- `servers`: Lists servers and server groups
- `deploy`: Interactive deployment workflow with real-time progress via WebSocket

**Configuration** (`lib/deploy/configuration.rb`): Loads from JSON Deployfile containing:
- `account`: DeployHQ account URL (e.g., https://account.deployhq.com)
- `username`: User email or username
- `api_key`: API key from user profile
- `project`: Default project permalink
- `websocket_hostname`: Optional WebSocket endpoint (defaults to wss://websocket.deployhq.com)

### Resource Relationships

Projects contain Servers and ServerGroups. Deployments belong to Projects and have DeploymentSteps. DeploymentSteps have DeploymentStepLogs. All child resources use the `:project` param to construct proper API paths like `projects/:permalink/deployments/:id`.

### WebSocket Integration

`Deploy::CLI::WebSocketClient` connects to deployment progress streams. `Deploy::CLI::DeploymentProgressOutput` consumes WebSocket messages and renders deployment progress to terminal in real-time.

## Release Process

This project uses [Conventional Commits](https://www.conventionalcommits.org/) for automated releases via [release-please](https://github.com/googleapis/release-please).

**Commit Message Format**:
- `feat:` or `feature:` - New features
- `fix:` - Bug fixes
- `docs:` - Documentation changes
- `refactor:` - Code refactoring
- `perf:` - Performance improvements
- `chore:` - Maintenance tasks

On merge to master, release-please analyzes commits and creates a release PR. When merged, it:
1. Updates CHANGELOG.md
2. Bumps version in lib/deploy/version.rb
3. Creates GitHub release
4. Publishes gem to RubyGems

## Configuration File

Each developer needs a `Deployfile` in their working directory (not committed). Use `deployhq configure` to generate interactively, or create manually following `Deployfile.example`.
6 changes: 6 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,10 @@ source 'http://rubygems.org'

gemspec

gem 'rake', '~> 13.0'
gem 'rubocop'

group :test do
gem 'rspec', '~> 3.13'
gem 'webmock', '~> 3.23'
end
10 changes: 10 additions & 0 deletions Rakefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# frozen_string_literal: true

require 'rspec/core/rake_task'
require 'rubocop/rake_task'

RSpec::Core::RakeTask.new(:spec)

RuboCop::RakeTask.new

task default: [:rubocop, :spec]
6 changes: 3 additions & 3 deletions deployhq.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ Gem::Specification.new do |s|
s.add_dependency('json', '~> 2.6')
s.add_dependency('websocket-eventmachine-client', '~> 1.2')

s.authors = ['Adam Cooke']
s.email = ['adam@k.io']
s.homepage = 'https://github.com/krystal/deployhq-lib'
s.authors = ['DeployHQ Team']
s.email = ['support@deployhq.com']
s.homepage = 'https://github.com/deployhq/deployhq-lib'
end
8 changes: 3 additions & 5 deletions lib/deploy/cli.rb
Original file line number Diff line number Diff line change
Expand Up @@ -100,17 +100,17 @@ def invoke(args)

def server_list
@server_groups ||= @project.server_groups
if @server_groups.count.positive?
if @server_groups.any?
@server_groups.each do |group|
puts "Group: #{group.name}"
puts group.servers.map { |server| format_server(server) }.join("\n\n")
end
end

@ungrouped_servers ||= @project.servers
return unless @ungrouped_servers.count.positive?
return unless @ungrouped_servers.any?

puts "\n" if @server_groups.count.positive?
puts "\n" if @server_groups.any?
puts 'Ungrouped Servers'
puts @ungrouped_servers.map { |server| format_server(server) }.join("\n\n")
end
Expand Down Expand Up @@ -197,15 +197,13 @@ def format_server(server)
end.join("\n")
end

# rubocop:disable Lint/FormatParameterMismatch
def format_kv_pair(hash)
longest_key = hash.keys.map(&:length).max + 2
hash.each_with_index.map do |(k, v), _i|
str = format("%#{longest_key}s : %s", k, v)
str
end.join("\n")
end
# rubocop:enable Lint/FormatParameterMismatch

end

Expand Down
106 changes: 106 additions & 0 deletions spec/configuration_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
# frozen_string_literal: true

require 'spec_helper'
require 'tempfile'

RSpec.describe Deploy::Configuration do
describe '#websocket_hostname' do
it 'has a default value' do
config = described_class.new
expect(config.websocket_hostname).to eq('wss://websocket.deployhq.com')
end

it 'allows setting a custom value' do
config = described_class.new
config.websocket_hostname = 'wss://custom.example.com'
expect(config.websocket_hostname).to eq('wss://custom.example.com')
end
end

describe 'attribute setters and getters' do
it 'sets and retrieves all configuration attributes' do
config = described_class.new
config.account = 'https://test.deployhq.com'
config.username = 'testuser'
config.api_key = 'test-api-key'
config.project = 'test-project'

expect(config.account).to eq('https://test.deployhq.com')
expect(config.username).to eq('testuser')
expect(config.api_key).to eq('test-api-key')
expect(config.project).to eq('test-project')
end
end

describe '.from_file' do
context 'with all fields present' do
it 'loads configuration from JSON file' do
config_data = {
'account' => 'https://test.deployhq.com',
'username' => 'testuser',
'api_key' => 'test-key',
'project' => 'test-project',
'websocket_hostname' => 'wss://test.example.com'
}

Tempfile.create(['config', '.json']) do |f|
f.write(JSON.generate(config_data))
f.flush
f.rewind

config = described_class.from_file(f.path)

expect(config.account).to eq('https://test.deployhq.com')
expect(config.username).to eq('testuser')
expect(config.api_key).to eq('test-key')
expect(config.project).to eq('test-project')
expect(config.websocket_hostname).to eq('wss://test.example.com')
end
end
end

context 'without websocket_hostname' do
it 'uses the default websocket hostname' do
config_data = {
'account' => 'https://test.deployhq.com',
'username' => 'testuser',
'api_key' => 'test-key',
'project' => 'test-project'
}

Tempfile.create(['config', '.json']) do |f|
f.write(JSON.generate(config_data))
f.flush
f.rewind

config = described_class.from_file(f.path)

expect(config.account).to eq('https://test.deployhq.com')
expect(config.websocket_hostname).to eq('wss://websocket.deployhq.com')
end
end
end

context 'with missing file' do
it 'raises Errno::ENOENT' do
expect do
described_class.from_file('/nonexistent/file.json')
end.to raise_error(Errno::ENOENT)
end
end

context 'with invalid JSON' do
it 'raises JSON::ParserError' do
Tempfile.create(['config', '.json']) do |f|
f.write('invalid json {')
f.flush
f.rewind

expect do
described_class.from_file(f.path)
end.to raise_error(JSON::ParserError)
end
end
end
end
end
Loading