Compare commits
9 Commits
v0.0.1.alp
...
a9add2a7c1
| Author | SHA1 | Date | |
|---|---|---|---|
| a9add2a7c1 | |||
| 61beb17f55 | |||
| 8b78a558e1 | |||
| 4d975d99b3 | |||
|
|
b27d39edf4 | ||
| 75e4435d9d | |||
| 6148fc67da | |||
|
|
deeccb119e | ||
| fe953020d5 |
17
.github/workflows/release.yml
vendored
17
.github/workflows/release.yml
vendored
@@ -7,17 +7,26 @@ on:
|
||||
|
||||
jobs:
|
||||
push:
|
||||
if: ${{ github.server_url == 'https://github.com' }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
id-token: write
|
||||
environment: release
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: ruby/setup-ruby@v1
|
||||
- uses: ruby/setup-ruby@003a5c4d8d6321bd302e38f6f0ec593f77f06600 # v1.319.0
|
||||
with:
|
||||
ruby-version: ruby
|
||||
ruby-version: "4.0"
|
||||
bundler-cache: true
|
||||
- uses: rubygems/release-gem@v1
|
||||
- name: Verify tag matches gem version
|
||||
env:
|
||||
RELEASE_TAG: ${{ github.ref_name }}
|
||||
run: >-
|
||||
ruby -Ilib -ropencode/rails_version -e
|
||||
'expected = ENV.fetch("RELEASE_TAG").delete_prefix("v");
|
||||
abort "tag #{expected.inspect} does not match gem #{Opencode::RAILS_VERSION.inspect}"
|
||||
unless Opencode::RAILS_VERSION == expected'
|
||||
- uses: rubygems/release-gem@052cc82692552de3ef2b81fd670e41d13cba8092 # v1.4.0
|
||||
|
||||
6
.github/workflows/test.yml
vendored
6
.github/workflows/test.yml
vendored
@@ -12,12 +12,12 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
ruby: ["3.2", "3.3", "3.4"]
|
||||
ruby: ["3.2", "3.3", "3.4", "4.0"]
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
- name: Set up Ruby ${{ matrix.ruby }}
|
||||
uses: ruby/setup-ruby@v1
|
||||
uses: ruby/setup-ruby@003a5c4d8d6321bd302e38f6f0ec593f77f06600 # v1.319.0
|
||||
with:
|
||||
ruby-version: ${{ matrix.ruby }}
|
||||
bundler-cache: true
|
||||
|
||||
16
CHANGELOG.md
16
CHANGELOG.md
@@ -1,5 +1,21 @@
|
||||
# Changelog
|
||||
|
||||
## 0.0.1.alpha8 - 2026-07-20
|
||||
|
||||
### Bumped
|
||||
|
||||
- Runtime dependency `opencode-ruby` pinned to `= 0.0.1.alpha8`, carrying the
|
||||
hardened SSE framing parser while retaining the alpha7 subscribe-before-
|
||||
prompt and at-most-once reconnect contract.
|
||||
|
||||
### Changed
|
||||
|
||||
- Test the supported runtime surface on Ruby 3.2, 3.3, 3.4, and 4.0.
|
||||
- Pin every third-party CI and release action to an exact reviewed commit and
|
||||
use Ruby 4.0 for release builds.
|
||||
- Fail the trusted-publishing job before release when the pushed tag does not
|
||||
match `Opencode::RAILS_VERSION`.
|
||||
|
||||
## 0.0.1.alpha7 - 2026-07-18
|
||||
|
||||
### Fixed
|
||||
|
||||
2
Gemfile
2
Gemfile
@@ -2,6 +2,6 @@ source "https://rubygems.org"
|
||||
|
||||
gem "opencode-ruby",
|
||||
git: "https://github.com/ajaynomics/opencode-ruby.git",
|
||||
ref: "78b6f9c9e9c7d58b699af1c3c17764acd33de798"
|
||||
ref: "9277646a4bb2cf25a8384ffc140b154f49ea5766"
|
||||
|
||||
gemspec
|
||||
|
||||
73
README.md
73
README.md
@@ -17,10 +17,25 @@ Production-grade [OpenCode](https://opencode.ai) integration for Rails apps. Lay
|
||||
|
||||
## Install
|
||||
|
||||
After both alpha8 gems are confirmed on RubyGems, pin the lockstep tuple:
|
||||
|
||||
```ruby
|
||||
# Gemfile
|
||||
gem "opencode-ruby" # wire client + Reply state machine
|
||||
gem "opencode-rails" # AR-coupled session/turn/artifact stack
|
||||
gem "opencode-ruby", "= 0.0.1.alpha8" # wire client + Reply state machine
|
||||
gem "opencode-rails", "= 0.0.1.alpha8"
|
||||
```
|
||||
|
||||
Until publication is verified, validate this candidate checkout against the
|
||||
exact `opencode-ruby` source it was tested with:
|
||||
|
||||
```ruby
|
||||
# Gemfile
|
||||
gem "opencode-ruby",
|
||||
git: "https://github.com/ajaynomics/opencode-ruby.git",
|
||||
ref: "9277646a4bb2cf25a8384ffc140b154f49ea5766"
|
||||
|
||||
gem "opencode-rails",
|
||||
path: "../opencode-rails"
|
||||
```
|
||||
|
||||
```bash
|
||||
@@ -29,13 +44,16 @@ bundle install
|
||||
|
||||
Runtime deps: `activerecord`, `activestorage`, `activesupport` (>= 7.1). Depends on `opencode-ruby` for the underlying HTTP/SSE primitives.
|
||||
|
||||
During the alpha series both gems are pinned in lockstep. Version 0.0.1.alpha7
|
||||
uses a subscribe-ready-before-prompt transport contract and reconnects an
|
||||
accepted turn without posting its prompt again.
|
||||
During the alpha series both gems are pinned in lockstep. Version 0.0.1.alpha8
|
||||
retains the subscribe-ready-before-prompt transport contract and reconnects an
|
||||
accepted turn without posting its prompt again, while hardening SSE framing.
|
||||
|
||||
Releases use RubyGems trusted publishing. Register `release.yml` as the gem's
|
||||
trusted publisher (using the `release` environment); after that, a `v*` tag
|
||||
publishes without a long-lived RubyGems API key.
|
||||
`opencode-rails` 0.0.1.alpha8 is a release candidate and is not yet confirmed
|
||||
published on RubyGems. The `release.yml` workflow is prepared for RubyGems
|
||||
trusted publishing, but the gem's trusted publisher must be registered and
|
||||
verified for that workflow and its `release` environment. Until the registry
|
||||
result is verified, pushing a `v*` tag does not guarantee publication. Trusted
|
||||
publishing does not require a long-lived RubyGems API key.
|
||||
|
||||
## Quickstart
|
||||
|
||||
@@ -49,13 +67,29 @@ Opencode::ErrorReporter.adapter = ->(error, **opts) {
|
||||
}
|
||||
```
|
||||
|
||||
```ruby
|
||||
# app/services/noop_reply_observer.rb
|
||||
#
|
||||
# Turn requires an observer factory even when the app does not need live
|
||||
# partial rendering. For a streaming UI, replace this with an observer that
|
||||
# persists/broadcasts selected ReplyObserver callbacks (and throttle writes).
|
||||
class NoopReplyObserver
|
||||
include Opencode::ReplyObserver
|
||||
|
||||
def watch(reply)
|
||||
reply.add_observer(self)
|
||||
self
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
```ruby
|
||||
# app/jobs/generate_response_job.rb
|
||||
class GenerateResponseJob < ApplicationJob
|
||||
def perform(assistant_message)
|
||||
conversation = assistant_message.conversation
|
||||
user_message = conversation.messages.where(role: :user).last
|
||||
client = Opencode::Client.new(base_url: ENV["OPENCODE_URL"])
|
||||
client = Opencode::Client.new(base_url: ENV.fetch("OPENCODE_URL"))
|
||||
|
||||
session = Opencode::Session.new(
|
||||
conversation,
|
||||
@@ -68,9 +102,15 @@ class GenerateResponseJob < ApplicationJob
|
||||
subject: conversation,
|
||||
query_text: user_message.content,
|
||||
client: client,
|
||||
session: session,
|
||||
session_for: session,
|
||||
observer_factory: ->(_message) { NoopReplyObserver.new },
|
||||
system_context: ->(record) { "You are assisting with #{record.title}." },
|
||||
agent_name: ->(_record) { ENV.fetch("OPENCODE_AGENT", "build") },
|
||||
tracer: ->(name, **payload) {
|
||||
ActiveSupport::Notifications.instrument("assistant.#{name}", payload)
|
||||
},
|
||||
on_turn_finished: ->(result) {
|
||||
# result.status #=> :completed | :error | :cancelled
|
||||
# result.status #=> :completed | :cancelled | :error | :failed
|
||||
# result.message #=> the AR row (reloaded)
|
||||
# result.duration_ms
|
||||
}
|
||||
@@ -87,7 +127,16 @@ class GenerateResponseJob < ApplicationJob
|
||||
end
|
||||
```
|
||||
|
||||
The host's record (here `conversation`) must respond to `#title`, `#opencode_session_id`, `#opencode_session_id=`, `#with_lock(&block)`, `#update!`, `#reload`, `#id`. The host's message record (here `assistant_message`) must respond to `#error!(content)`, `#update_columns(...)`, `#with_lock(&block)`, `#reload`, `#pending?`.
|
||||
The host record (here `conversation`) must respond to `#title`,
|
||||
`#opencode_session_id`, `#opencode_session_id=`, `#with_lock(&block)`,
|
||||
`#update!`, `#reload`, and `#id`. The assistant message must respond to `#id`,
|
||||
`#reload`, `#cancelled?`, `#finalize!(**attrs)`, `#update!(**attrs)`, and
|
||||
`#error!(content)`. A non-no-op observer may impose additional record methods
|
||||
for its own live snapshots.
|
||||
|
||||
`Opencode::Turn` is an internal, alpha-stage composition seam. Its keyword
|
||||
constructor is intentionally explicit and may change before 1.0, so keep this
|
||||
wiring in one host service/job and keep the gem source pinned exactly.
|
||||
|
||||
## What you get
|
||||
|
||||
|
||||
@@ -11,5 +11,5 @@
|
||||
# We can't reuse the same constant from a second gem, so we use a
|
||||
# distinct, non-namespaced constant.
|
||||
module Opencode
|
||||
RAILS_VERSION = "0.0.1.alpha7"
|
||||
RAILS_VERSION = "0.0.1.alpha8"
|
||||
end
|
||||
|
||||
@@ -37,7 +37,7 @@ Gem::Specification.new do |spec|
|
||||
# this gem builds on. During alpha both gems evolve in lockstep — we pin
|
||||
# exactly (= not ~>) so that consumers always pick the version this gem
|
||||
# was tested against.
|
||||
spec.add_runtime_dependency "opencode-ruby", "= 0.0.1.alpha7"
|
||||
spec.add_runtime_dependency "opencode-ruby", "= 0.0.1.alpha8"
|
||||
|
||||
# Rails sub-libraries used at runtime. Depending on these individually
|
||||
# (instead of the `rails` umbrella) avoids forcing host apps to load
|
||||
|
||||
53
test/readme_test.rb
Normal file
53
test/readme_test.rb
Normal file
@@ -0,0 +1,53 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "test_helper"
|
||||
require "ripper"
|
||||
|
||||
class ReadmeTest < Minitest::Test
|
||||
README_PATH = File.expand_path("../README.md", __dir__)
|
||||
GEMFILE_PATH = File.expand_path("../Gemfile", __dir__)
|
||||
|
||||
def setup
|
||||
@readme = File.read(README_PATH)
|
||||
@quickstart = @readme[/^## Quickstart\n(?<body>.*?)(?=^## )/m, :body]
|
||||
refute_nil @quickstart, "README must retain a Quickstart section"
|
||||
end
|
||||
|
||||
def test_quickstart_turn_call_documents_every_required_keyword
|
||||
turn_call = @quickstart[/Opencode::Turn\.new\((?<args>.*?)^\s*\)\.call/m, :args]
|
||||
refute_nil turn_call, "Quickstart must contain an Opencode::Turn.new(...).call example"
|
||||
|
||||
documented = turn_call.scan(/^\s*([a-z_]+):/).flatten.map(&:to_sym)
|
||||
parameters = Opencode::Turn.instance_method(:initialize).parameters
|
||||
required = parameters.filter_map { |kind, name| name if kind == :keyreq }
|
||||
accepted = parameters.filter_map { |kind, name| name if %i[keyreq key].include?(kind) }
|
||||
|
||||
assert_empty required - documented,
|
||||
"Quickstart is missing required Turn keywords: #{(required - documented).join(", ")}"
|
||||
assert_empty documented - accepted,
|
||||
"Quickstart uses unsupported Turn keywords: #{(documented - accepted).join(", ")}"
|
||||
refute_includes documented, :session
|
||||
end
|
||||
|
||||
def test_readme_ruby_fences_parse
|
||||
ruby_fences = @readme.scan(/```ruby\n(.*?)```/m).flatten
|
||||
refute_empty ruby_fences
|
||||
|
||||
ruby_fences.each_with_index do |source, index|
|
||||
assert Ripper.sexp(source), "README Ruby fence #{index + 1} has invalid syntax"
|
||||
end
|
||||
end
|
||||
|
||||
def test_install_contract_tracks_candidate_versions_and_client_source
|
||||
gemfile = File.read(GEMFILE_PATH)
|
||||
client_ref = gemfile[/gem "opencode-ruby".*?ref:\s*"([0-9a-f]{40})"/m, 1]
|
||||
|
||||
refute_nil client_ref, "Gemfile must pin opencode-ruby to an exact commit"
|
||||
assert_includes @readme, %(gem "opencode-ruby", "= #{Opencode::VERSION}")
|
||||
assert_includes @readme, %(gem "opencode-rails", "= #{Opencode::RAILS_VERSION}")
|
||||
assert_includes @readme, %(ref: "#{client_ref}")
|
||||
assert_includes @readme,
|
||||
"`opencode-rails` #{Opencode::RAILS_VERSION} is a release candidate"
|
||||
assert_includes @readme, "pushing a `v*` tag does not guarantee publication"
|
||||
end
|
||||
end
|
||||
53
test/release_workflow_test.rb
Normal file
53
test/release_workflow_test.rb
Normal file
@@ -0,0 +1,53 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "minitest/autorun"
|
||||
require "yaml"
|
||||
|
||||
class ReleaseWorkflowTest < Minitest::Test
|
||||
ROOT = File.expand_path("..", __dir__)
|
||||
WORKFLOW_PATH = File.join(ROOT, ".github", "workflows", "release.yml")
|
||||
SETUP_RUBY_ACTION = "ruby/setup-ruby@003a5c4d8d6321bd302e38f6f0ec593f77f06600"
|
||||
RELEASE_GEM_ACTION = "rubygems/release-gem@052cc82692552de3ef2b81fd670e41d13cba8092"
|
||||
|
||||
def workflow
|
||||
@workflow ||= YAML.safe_load(File.read(WORKFLOW_PATH), aliases: false)
|
||||
end
|
||||
|
||||
def push_job
|
||||
workflow.fetch("jobs").fetch("push")
|
||||
end
|
||||
|
||||
def test_release_job_is_inert_on_non_github_runners
|
||||
assert_equal "${{ github.server_url == 'https://github.com' }}", push_job.fetch("if")
|
||||
end
|
||||
|
||||
def test_release_job_keeps_the_trusted_publisher_boundary
|
||||
assert_equal "release", push_job.fetch("environment")
|
||||
assert_equal(
|
||||
{ "contents" => "write", "id-token" => "write" },
|
||||
push_job.fetch("permissions")
|
||||
)
|
||||
|
||||
steps = push_job.fetch("steps")
|
||||
setup_ruby = steps.find { |step| step["uses"] == SETUP_RUBY_ACTION }
|
||||
|
||||
assert_equal "4.0", setup_ruby.dig("with", "ruby-version")
|
||||
assert_equal 1, steps.count { |step| step["uses"] == RELEASE_GEM_ACTION }
|
||||
assert steps.filter_map { |step| step["uses"] }.all? { |uses| uses.match?(/@[0-9a-f]{40}\z/) }
|
||||
refute steps.any? { |step| step.fetch("run", "").match?(/\bgem\s+push\b/) }
|
||||
end
|
||||
|
||||
def test_release_tag_must_match_the_gem_version_before_publish
|
||||
steps = push_job.fetch("steps")
|
||||
preflight_index = steps.index { |step| step["name"] == "Verify tag matches gem version" }
|
||||
publish_index = steps.index { |step| step["uses"] == RELEASE_GEM_ACTION }
|
||||
|
||||
refute_nil preflight_index
|
||||
refute_nil publish_index
|
||||
assert_operator preflight_index, :<, publish_index
|
||||
|
||||
preflight = steps.fetch(preflight_index)
|
||||
assert_equal "${{ github.ref_name }}", preflight.dig("env", "RELEASE_TAG")
|
||||
assert_includes preflight.fetch("run"), "unless Opencode::RAILS_VERSION == expected"
|
||||
end
|
||||
end
|
||||
16
test/version_test.rb
Normal file
16
test/version_test.rb
Normal file
@@ -0,0 +1,16 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "test_helper"
|
||||
|
||||
class Opencode::VersionTest < Minitest::Test
|
||||
GEMSPEC_PATH = File.expand_path("../opencode-rails.gemspec", __dir__)
|
||||
|
||||
def test_alpha_versions_and_runtime_dependency_stay_in_lockstep
|
||||
specification = Gem::Specification.load(GEMSPEC_PATH)
|
||||
dependency = specification.runtime_dependencies.find { |item| item.name == "opencode-ruby" }
|
||||
|
||||
refute_nil dependency
|
||||
assert_equal Opencode::RAILS_VERSION, Opencode::VERSION
|
||||
assert_equal "= #{Opencode::RAILS_VERSION}", dependency.requirement.to_s
|
||||
end
|
||||
end
|
||||
67
test/workflow_contract_test.rb
Normal file
67
test/workflow_contract_test.rb
Normal file
@@ -0,0 +1,67 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "minitest/autorun"
|
||||
require "yaml"
|
||||
|
||||
class WorkflowContractTest < Minitest::Test
|
||||
ROOT = File.expand_path("..", __dir__)
|
||||
WORKFLOW_DIRECTORY = File.join(ROOT, ".github", "workflows")
|
||||
TEST_WORKFLOW_PATH = File.join(WORKFLOW_DIRECTORY, "test.yml")
|
||||
ACTION_PINS = {
|
||||
"actions/checkout" => "9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0",
|
||||
"ruby/setup-ruby" => "003a5c4d8d6321bd302e38f6f0ec593f77f06600",
|
||||
"rubygems/release-gem" => "052cc82692552de3ef2b81fd670e41d13cba8092"
|
||||
}.freeze
|
||||
|
||||
def test_matrix_covers_every_supported_ruby
|
||||
workflow = YAML.safe_load(File.read(TEST_WORKFLOW_PATH), aliases: false)
|
||||
versions = workflow.dig("jobs", "test", "strategy", "matrix", "ruby")
|
||||
|
||||
assert_equal %w[3.2 3.3 3.4 4.0], versions
|
||||
end
|
||||
|
||||
def test_every_third_party_action_uses_its_reviewed_commit
|
||||
action_uses = Dir[File.join(WORKFLOW_DIRECTORY, "*.{yml,yaml}")].sort.flat_map do |path|
|
||||
workflow = YAML.safe_load(File.read(path), aliases: false)
|
||||
|
||||
workflow_uses(workflow)
|
||||
end
|
||||
|
||||
assert_equal 5, action_uses.length
|
||||
action_uses.each do |action_use|
|
||||
action, separator, revision = action_use.rpartition("@")
|
||||
|
||||
assert_equal "@", separator
|
||||
assert_equal ACTION_PINS.fetch(action), revision
|
||||
assert_match(/\A[0-9a-f]{40}\z/, revision)
|
||||
end
|
||||
end
|
||||
|
||||
def test_action_discovery_only_reads_workflow_action_locations
|
||||
workflow = YAML.safe_load(<<~YAML, aliases: false)
|
||||
jobs:
|
||||
reusable:
|
||||
uses: "owner/workflow@revision"
|
||||
with:
|
||||
uses: ordinary-job-input
|
||||
test:
|
||||
steps:
|
||||
- uses: "owner/action@revision"
|
||||
with:
|
||||
uses: ordinary-step-input
|
||||
YAML
|
||||
|
||||
assert_equal %w[owner/workflow@revision owner/action@revision], workflow_uses(workflow)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def workflow_uses(node)
|
||||
node.fetch("jobs").values.flat_map do |job|
|
||||
action_uses = job.key?("uses") ? [job.fetch("uses")] : []
|
||||
step_uses = job.fetch("steps", []).filter_map { |step| step["uses"] }
|
||||
|
||||
action_uses.concat(step_uses)
|
||||
end
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user