Prepare repaired alpha9 release

This commit is contained in:
2026-07-20 20:04:59 -07:00
parent b02bcc74ef
commit 427bd47648
13 changed files with 367 additions and 74 deletions

View File

@@ -6,7 +6,64 @@ on:
- "v*"
jobs:
verify:
runs-on: ubuntu-latest
permissions:
contents: read
strategy:
fail-fast: false
matrix:
ruby: ["3.2", "3.3", "3.4", "4.0"]
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Set up Ruby ${{ matrix.ruby }}
uses: ruby/setup-ruby@003a5c4d8d6321bd302e38f6f0ec593f77f06600 # v1.319.0
with:
ruby-version: ${{ matrix.ruby }}
bundler-cache: true
- 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'
- name: Run tests
run: bundle exec rake test
- name: Build gem
run: gem build opencode-rails.gemspec
- name: Verify gems load after install
run: |
client_dir="$(bundle show opencode-ruby)"
(cd "$client_dir" && gem build opencode-ruby.gemspec)
client_version="$(ruby -I"$client_dir/lib" -ropencode/version -e 'print Opencode::VERSION')"
client_gem="$client_dir/opencode-ruby-${client_version}.gem"
rails_gem="opencode-rails-$(ruby -Ilib -ropencode/rails_version -e 'print Opencode::RAILS_VERSION').gem"
bundle_gem_path="$(bundle exec ruby -e 'puts Gem.path.join(":")')"
export GEM_HOME="${RUNNER_TEMP}/opencode-rails-${{ matrix.ruby }}"
export GEM_PATH="${GEM_HOME}:${bundle_gem_path}"
mkdir -p "$GEM_HOME"
gem install --local "$client_gem" --no-document
gem install --local "$rails_gem" --no-document
ruby -ropencode-rails -e '
root = File.realpath(ENV.fetch("GEM_HOME"))
%w[opencode-ruby opencode-rails].each do |name|
path = File.realpath(Gem.loaded_specs.fetch(name).full_gem_path)
abort "#{name} loaded outside isolated GEM_HOME: #{path}" unless path.start_with?("#{root}/")
end
puts Opencode::RAILS_VERSION
'
push:
needs: verify
if: ${{ github.server_url == 'https://github.com' }}
runs-on: ubuntu-latest
permissions:
@@ -21,14 +78,4 @@ jobs:
with:
ruby-version: "4.0"
bundler-cache: true
- 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'
- name: Run tests
run: bundle exec rake test
- uses: rubygems/release-gem@052cc82692552de3ef2b81fd670e41d13cba8092 # v1.4.0

View File

@@ -1,10 +1,10 @@
# Changelog
## 0.0.1.alpha8 - 2026-07-20
## 0.0.1.alpha9 - 2026-07-20
### Bumped
- Runtime dependency `opencode-ruby` pinned to `= 0.0.1.alpha8`, carrying the
- Runtime dependency `opencode-ruby` pinned to `= 0.0.1.alpha9`, carrying the
hardened SSE framing parser while retaining the alpha7 subscribe-before-
prompt and at-most-once reconnect contract.
@@ -16,18 +16,33 @@
destination symlinks without following them, and validate bounded reads from
the opened sandbox file. Upload copying now requires a traversable
`/proc/self/fd` or `/dev/fd` descriptor filesystem and fails closed without it.
- Reject hard-linked sandbox artifacts and open files with nonblocking,
no-follow descriptor checks so FIFO, socket, symlink, and device swaps fail
closed without hanging artifact collection.
- Make the reference integration deny every OpenCode tool by default and use a
realpath-validated per-conversation directory outside Git worktrees. Hosts
must add their own OS or container boundary before allowing filesystem tools.
- Load every runtime standard library explicitly and align the shipped
permissions, observer, Turn, prompt, and instrumentation examples with the
actual APIs.
### Changed
- Test the supported runtime surface on Ruby 3.2, 3.3, 3.4, and 4.0.
- Gate release on tests plus exact local package installation across Ruby 3.2,
3.3, 3.4, and 4.0 in a read-only verification job.
- 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.alpha8 - 2026-07-20
### Unpublished
- Superseded after the unrepaired `opencode-ruby` alpha8 package was yanked.
No `opencode-rails` alpha8 package was published; use the alpha9 lockstep
tuple.
## 0.0.1.alpha7 - 2026-07-18
### Fixed

View File

@@ -2,6 +2,6 @@ source "https://rubygems.org"
gem "opencode-ruby",
git: "https://github.com/ajaynomics/opencode-ruby.git",
ref: "49a161632e6631d3605af5170de00c4688cfcedb"
ref: "65a44ca1502926d533e6b4b6692779fa39740218"
gemspec

View File

@@ -17,12 +17,12 @@ Production-grade [OpenCode](https://opencode.ai) integration for Rails apps. Lay
## Install
After both alpha8 gems are confirmed on RubyGems, pin the lockstep tuple:
After both alpha9 gems are confirmed on RubyGems, pin the lockstep tuple:
```ruby
# Gemfile
gem "opencode-ruby", "= 0.0.1.alpha8" # wire client + Reply state machine
gem "opencode-rails", "= 0.0.1.alpha8"
gem "opencode-ruby", "= 0.0.1.alpha9" # wire client + Reply state machine
gem "opencode-rails", "= 0.0.1.alpha9"
```
Until publication is verified, validate this candidate checkout against the
@@ -32,7 +32,7 @@ exact `opencode-ruby` source it was tested with:
# Gemfile
gem "opencode-ruby",
git: "https://github.com/ajaynomics/opencode-ruby.git",
ref: "49a161632e6631d3605af5170de00c4688cfcedb"
ref: "65a44ca1502926d533e6b4b6692779fa39740218"
gem "opencode-rails",
path: "../opencode-rails"
@@ -50,12 +50,15 @@ Runtime deps: `activerecord`, `activestorage`, `activesupport` (>= 7.1), and
provide one of those traversable descriptor filesystems; unsupported platforms
fail closed before writing.
During the alpha series both gems are pinned in lockstep. Version 0.0.1.alpha8
During the alpha series both gems are pinned in lockstep. Version 0.0.1.alpha9
retains the subscribe-ready-before-prompt transport contract and reconnects an
accepted turn without posting its prompt again, while hardening SSE framing.
`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
The unrepaired `opencode-ruby` 0.0.1.alpha8 package was yanked, and
`opencode-rails` alpha8 was never published.
`opencode-rails` 0.0.1.alpha9 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
@@ -97,7 +100,11 @@ class GenerateResponseJob < ApplicationJob
unless user_message.conversation_id == conversation.id
raise ArgumentError, "User and assistant messages must belong to the same conversation"
end
client = Opencode::Client.new(base_url: ENV.fetch("OPENCODE_URL"))
sandbox_directory = sandbox_directory_for(conversation)
client = Opencode::Client.new(
base_url: ENV.fetch("OPENCODE_URL"),
directory: sandbox_directory.to_s
)
session = Opencode::Session.new(
conversation,
@@ -127,21 +134,54 @@ class GenerateResponseJob < ApplicationJob
private
def permission_rules_for(conversation)
def permission_rules_for(_conversation)
[
{ permission: "bash", pattern: "*", action: "deny" },
{ permission: "external_directory", pattern: "*", action: "deny" },
{ permission: "edit", pattern: "*", action: "deny" },
{
permission: "edit",
pattern: "data/sandbox/#{conversation.id}/**",
action: "allow"
}
{ permission: "*", pattern: "*", action: "deny" }
]
end
def sandbox_directory_for(conversation)
identifier = conversation.id.to_s
unless identifier.match?(/\A[0-9A-Za-z_-]+\z/)
raise ArgumentError, "Conversation id is not safe for a directory name"
end
root = Pathname.new(ENV.fetch("OPENCODE_SANDBOX_ROOT")).expand_path
root.mkpath
root = root.realpath
if root.ascend.any? { |directory| directory.join(".git").exist? }
raise ArgumentError, "OPENCODE_SANDBOX_ROOT must be outside every Git worktree"
end
directory = root.join(identifier)
directory.mkpath
stat = directory.lstat
unless stat.directory? && !stat.symlink?
raise ArgumentError, "Conversation sandbox must be a real directory"
end
resolved = directory.realpath
unless resolved.to_s.start_with?("#{root}#{File::SEPARATOR}")
raise ArgumentError, "Conversation sandbox escapes its root"
end
resolved
end
end
```
`OPENCODE_SANDBOX_ROOT` must be outside every Git worktree and mounted at the
same absolute path in the OpenCode server. OpenCode treats an entire detected
worktree as internal, so a directory inside the application repository is not
an `external_directory` boundary. This quickstart intentionally grants no
filesystem tools: OpenCode permissions alone are not an OS sandbox. Add
product-specific allows only when the OpenCode process also has an independent
container or operating-system boundary around the conversation directory.
`Opencode::Session` applies permissions only when it creates a session. Before
deploying a directory or permission-policy change, recreate persisted sessions
with a client scoped to the new directory; an existing session ID does not
prove that the new policy was applied.
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`,

View File

@@ -76,11 +76,17 @@ class GenerateResponseJob < ApplicationJob
unless user_message.conversation_id == conversation.id
raise ArgumentError, "User and assistant messages must belong to the same conversation"
end
client = Opencode::Client.new(base_url: ENV.fetch("OPENCODE_URL"))
sandbox_directory = sandbox_directory_for(conversation)
client = Opencode::Client.new(
base_url: ENV.fetch("OPENCODE_URL"),
directory: sandbox_directory.to_s
)
# Session: AR-coupled, row-locked, idempotent. ensure! creates the
# OpenCode session if conversation.opencode_session_id is blank;
# returns the existing id otherwise.
# Session applies permissions only when it creates a session.
# Recreate persisted sessions before deploying a directory or policy change.
session = Opencode::Session.new(
conversation,
permissions_for: ->(record) { permission_rules_for(record) },
@@ -114,28 +120,50 @@ class GenerateResponseJob < ApplicationJob
private
def permission_rules_for(conversation)
def permission_rules_for(_conversation)
# Per-product permissions. The shape mirrors what
# opencode-ruby's Client#create_session expects in `permissions:`.
[
{ permission: "bash", pattern: "*", action: "deny" },
{ permission: "external_directory", pattern: "*", action: "deny" },
{ permission: "edit", pattern: "*", action: "deny" },
{
permission: "edit",
pattern: "data/sandbox/#{conversation.id}/**",
action: "allow"
}
{ permission: "*", pattern: "*", action: "deny" }
]
end
def sandbox_directory_for(conversation)
# The root must be mounted at the same absolute path in the OpenCode server.
# Keeping it outside Git worktrees makes external_directory the boundary.
identifier = conversation.id.to_s
unless identifier.match?(/\A[0-9A-Za-z_-]+\z/)
raise ArgumentError, "Conversation id is not safe for a directory name"
end
root = Pathname.new(ENV.fetch("OPENCODE_SANDBOX_ROOT")).expand_path
root.mkpath
root = root.realpath
if root.ascend.any? { |directory| directory.join(".git").exist? }
raise ArgumentError, "OPENCODE_SANDBOX_ROOT must be outside every Git worktree"
end
directory = root.join(identifier)
directory.mkpath
stat = directory.lstat
unless stat.directory? && !stat.symlink?
raise ArgumentError, "Conversation sandbox must be a real directory"
end
resolved = directory.realpath
unless resolved.to_s.start_with?("#{root}#{File::SEPARATOR}")
raise ArgumentError, "Conversation sandbox escapes its root"
end
resolved
end
def build_system_context(conversation)
# System prompt context the agent gets. Your app probably already
# has helpers for this; the gem doesn't impose a shape.
<<~CONTEXT
User: #{conversation.user.name}
Conversation: #{conversation.id}
Sandbox: /sandbox/#{conversation.id}
Sandbox: #{sandbox_directory_for(conversation)}
CONTEXT
end
end

View File

@@ -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.alpha8"
RAILS_VERSION = "0.0.1.alpha9"
end

View File

@@ -84,16 +84,19 @@ module Opencode
def with_safe_file
before = File.lstat(path)
raise UnsafeFileError, "Unsafe sandbox file: #{basename}" unless before.file? && !before.symlink?
unless before.file? && !before.symlink? && before.nlink == 1
raise UnsafeFileError, "Unsafe sandbox file: #{basename}"
end
resolved = Pathname.new(path).realpath.to_s
unless resolved.start_with?(sandbox_prefix)
raise UnsafeFileError, "Sandbox file escapes its root: #{basename}"
end
File.open(path, "rb") do |file|
flags = safe_open_flags
File.open(path, flags, encoding: Encoding::BINARY) do |file|
opened = file.stat
unless opened.file? && opened.dev == before.dev && opened.ino == before.ino
unless opened.file? && opened.nlink == 1 && opened.dev == before.dev && opened.ino == before.ino
raise UnsafeFileError, "Sandbox file changed while opening: #{basename}"
end
if opened.size > @max_bytes
@@ -102,8 +105,20 @@ module Opencode
yield file
end
rescue Errno::ENOENT, Errno::ELOOP => e
rescue SystemCallError => e
raise UnsafeFileError, "Unsafe sandbox file #{basename}: #{e.message}"
end
def safe_open_flags
required = %i[NONBLOCK NOFOLLOW]
missing = required.reject { |name| File.const_defined?(name) }
unless missing.empty?
raise UnsafeFileError, "Platform cannot safely open sandbox files: missing #{missing.join(", ")}"
end
flags = File::RDONLY | File::NONBLOCK | File::NOFOLLOW
flags |= File::BINARY if File.const_defined?(:BINARY)
flags
end
end
end

View File

@@ -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.alpha8"
spec.add_runtime_dependency "opencode-ruby", "= 0.0.1.alpha9"
spec.add_runtime_dependency "marcel", "~> 1.0"
# Rails sub-libraries used at runtime. Depending on these individually

View File

@@ -37,8 +37,35 @@ class ExampleTest < Minitest::Test
def test_example_uses_current_fail_closed_permission_rules
refute_match(/\{\s*type:/, SOURCE)
assert_includes SOURCE, '{ permission: "bash", pattern: "*", action: "deny" }'
assert_includes SOURCE, '{ permission: "external_directory", pattern: "*", action: "deny" }'
assert_includes SOURCE, '{ permission: "edit", pattern: "*", action: "deny" }'
assert_includes SOURCE, 'sandbox_directory = sandbox_directory_for(conversation)'
assert_includes SOURCE, 'directory: sandbox_directory.to_s'
assert_includes SOURCE, '{ permission: "*", pattern: "*", action: "deny" }'
refute_includes SOURCE, 'action: "allow"'
assert_includes SOURCE, 'ENV.fetch("OPENCODE_SANDBOX_ROOT")'
assert_includes SOURCE, "root = root.realpath"
assert_includes SOURCE, 'directory.join(".git").exist?'
assert_includes SOURCE, 'identifier.match?(/\A[0-9A-Za-z_-]+\z/)'
assert_includes SOURCE, "stat = directory.lstat"
assert_includes SOURCE, "Conversation sandbox escapes its root"
assert_includes SOURCE, "permissions only when it creates a session"
assert_match(/recreate persisted sessions/i, SOURCE)
refute_includes SOURCE, 'Sandbox: /sandbox/#{conversation.id}'
end
def test_example_permission_order_denies_every_tool
body = SOURCE[/def permission_rules_for\(_conversation\)\n(?<body>.*?)^ end/m, :body]
rules = eval(body, binding, PATH)
action_for = lambda do |permission, pattern|
rules.reverse.find do |rule|
(rule.fetch(:permission) == "*" || rule.fetch(:permission) == permission) &&
(rule.fetch(:pattern) == "*" || rule.fetch(:pattern) == pattern)
end.fetch(:action)
end
assert_equal "deny", action_for.call("read", "arbitrary/worktree/path")
assert_equal "deny", action_for.call("edit", "arbitrary/worktree/path")
assert_equal "deny", action_for.call("external_directory", "/outside/*")
assert_equal "deny", action_for.call("grep", "secret")
assert_equal "deny", action_for.call("lsp", "*")
end
end

View File

@@ -2,6 +2,7 @@
require "test_helper"
require "fileutils"
require "socket"
require "tmpdir"
require "marcel" # SandboxFile#content_type uses Marcel::MimeType.for
@@ -76,6 +77,34 @@ class Opencode::SandboxFileTest < Minitest::Test
assert_raises(Opencode::SandboxFile::UnsafeFileError) { file.content }
end
def test_safe_rejects_a_hard_link
File.unlink(@path)
outside = File.join(@outside_dir, "private.txt")
File.write(outside, "private")
File.link(outside, @path)
file = Opencode::SandboxFile.new(
path: @path, sandbox_prefix: @sandbox_prefix, max_bytes: 10_000
)
refute file.safe?
end
def test_content_rejects_a_hard_link_added_immediately_before_open
file = Opencode::SandboxFile.new(
path: @path, sandbox_prefix: @sandbox_prefix, max_bytes: 10_000
)
outside_link = File.join(@outside_dir, "linked-notes.md")
real_open = File.method(:open)
link_before_open = lambda do |path, mode, *args, **kwargs, &block|
File.link(path, outside_link)
real_open.call(path, mode, *args, **kwargs, &block)
end
File.stub(:open, link_before_open) do
assert_raises(Opencode::SandboxFile::UnsafeFileError) { file.content }
end
end
def test_content_rejects_an_inode_that_grows_past_the_cap_while_reading
file = Opencode::SandboxFile.new(
path: @path, sandbox_prefix: @sandbox_prefix, max_bytes: 20
@@ -98,6 +127,76 @@ class Opencode::SandboxFileTest < Minitest::Test
end
end
def test_content_rejects_a_fifo_swapped_in_immediately_before_open_without_blocking
file = Opencode::SandboxFile.new(
path: @path, sandbox_prefix: @sandbox_prefix, max_bytes: 10_000
)
real_open = File.method(:open)
swap_before_open = lambda do |path, mode, *args, **kwargs, &block|
File.unlink(path)
File.mkfifo(path)
assert_kind_of Integer, mode
assert_operator mode & File::NONBLOCK, :>, 0
assert_operator mode & File::NOFOLLOW, :>, 0
real_open.call(path, mode, *args, **kwargs, &block)
end
File.stub(:open, swap_before_open) do
assert_raises(Opencode::SandboxFile::UnsafeFileError) { file.content }
end
end
def test_content_rejects_a_symlink_swapped_in_immediately_before_open
file = Opencode::SandboxFile.new(
path: @path, sandbox_prefix: @sandbox_prefix, max_bytes: 10_000
)
outside = File.join(@outside_dir, "private.txt")
File.write(outside, "private")
real_open = File.method(:open)
swap_before_open = lambda do |path, mode, *args, **kwargs, &block|
File.unlink(path)
File.symlink(outside, path)
real_open.call(path, mode, *args, **kwargs, &block)
end
File.stub(:open, swap_before_open) do
assert_raises(Opencode::SandboxFile::UnsafeFileError) { file.content }
end
end
def test_safe_rejects_a_socket_swapped_in_immediately_before_open
file = Opencode::SandboxFile.new(
path: @path, sandbox_prefix: @sandbox_prefix, max_bytes: 10_000
)
real_open = File.method(:open)
socket = nil
swap_before_open = lambda do |path, mode, *args, **kwargs, &block|
File.unlink(path)
socket = UNIXServer.new(path)
real_open.call(path, mode, *args, **kwargs, &block)
end
File.stub(:open, swap_before_open) do
refute file.safe?
end
ensure
socket&.close
end
def test_safe_fails_closed_when_secure_open_flags_are_unavailable
file = Opencode::SandboxFile.new(
path: @path, sandbox_prefix: @sandbox_prefix, max_bytes: 10_000
)
real_const_defined = File.method(:const_defined?)
const_defined = lambda do |name, inherit = true|
name == :NOFOLLOW ? false : real_const_defined.call(name, inherit)
end
File.stub(:const_defined?, const_defined) do
refute file.safe?
end
end
def test_content_returns_binary_empty_string_for_a_zero_byte_file
File.write(@path, "")
file = Opencode::SandboxFile.new(

View File

@@ -53,11 +53,21 @@ class ReadmeTest < Minitest::Test
def test_quickstart_uses_current_fail_closed_permission_rules
refute_match(/\{\s*type:/, @quickstart)
assert_includes @quickstart, '{ permission: "bash", pattern: "*", action: "deny" }'
assert_includes @quickstart, '{ permission: "external_directory", pattern: "*", action: "deny" }'
assert_includes @quickstart, '{ permission: "edit", pattern: "*", action: "deny" }'
assert_includes @quickstart, 'permission: "edit",'
assert_includes @quickstart, 'action: "allow"'
assert_includes @quickstart, 'sandbox_directory = sandbox_directory_for(conversation)'
assert_includes @quickstart, 'directory: sandbox_directory.to_s'
assert_includes @quickstart, '{ permission: "*", pattern: "*", action: "deny" }'
refute_includes @quickstart, 'action: "allow"'
assert_includes @quickstart, 'ENV.fetch("OPENCODE_SANDBOX_ROOT")'
assert_includes @quickstart, "root = root.realpath"
assert_includes @quickstart, 'directory.join(".git").exist?'
assert_includes @quickstart, 'identifier.match?(/\A[0-9A-Za-z_-]+\z/)'
assert_includes @quickstart, "stat = directory.lstat"
assert_includes @quickstart, "Conversation sandbox escapes its root"
assert_includes @readme, "must be outside every Git worktree"
assert_includes @readme, "same absolute path in the OpenCode server"
assert_match(/intentionally grants no\s+filesystem tools/, @readme)
assert_includes @readme, "permissions only when it creates a session"
assert_includes @readme, "recreate persisted sessions"
end
def test_quickstart_uses_the_enqueued_user_message

View File

@@ -17,6 +17,10 @@ class ReleaseWorkflowTest < Minitest::Test
workflow.fetch("jobs").fetch("push")
end
def verify_job
workflow.fetch("jobs").fetch("verify")
end
def test_release_job_is_inert_on_non_github_runners
assert_equal "${{ github.server_url == 'https://github.com' }}", push_job.fetch("if")
end
@@ -32,33 +36,41 @@ class ReleaseWorkflowTest < Minitest::Test
setup_ruby = steps.find { |step| step["uses"] == SETUP_RUBY_ACTION }
assert_equal "4.0", setup_ruby.dig("with", "ruby-version")
assert_equal true, setup_ruby.dig("with", "bundler-cache")
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/) }
refute steps.any? { |step| step.key?("run") }
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 }
preflight = verify_job.fetch("steps").find { |step| step["name"] == "Verify tag matches gem version" }
refute_nil preflight_index
refute_nil publish_index
assert_operator preflight_index, :<, publish_index
preflight = steps.fetch(preflight_index)
refute_nil preflight
assert_equal "${{ github.ref_name }}", preflight.dig("env", "RELEASE_TAG")
assert_includes preflight.fetch("run"), "unless Opencode::RAILS_VERSION == expected"
end
def test_release_runs_the_test_suite_before_publish
steps = push_job.fetch("steps")
test_index = steps.index { |step| step["name"] == "Run tests" }
publish_index = steps.index { |step| step["uses"] == RELEASE_GEM_ACTION }
def test_verification_job_has_read_only_credentials
assert_equal({ "contents" => "read" }, verify_job.fetch("permissions"))
refute_nil test_index
refute_nil publish_index
assert_operator test_index, :<, publish_index
assert_equal "bundle exec rake test", steps.fetch(test_index).fetch("run")
checkout = verify_job.fetch("steps").find { |step| step.fetch("uses", "").start_with?("actions/checkout@") }
assert_equal false, checkout.dig("with", "persist-credentials")
end
def test_release_verifies_the_supported_matrix_and_installed_gems_before_publish
assert_equal %w[3.2 3.3 3.4 4.0], verify_job.dig("strategy", "matrix", "ruby")
assert_equal "verify", push_job.fetch("needs")
commands = verify_job.fetch("steps").filter_map { |step| step["run"] }.join("\n")
assert_includes commands, "bundle exec rake test"
assert_includes commands, "gem build opencode-rails.gemspec"
assert_includes commands, "bundle exec ruby -e"
assert_includes commands, 'GEM_HOME="${RUNNER_TEMP}/opencode-rails-${{ matrix.ruby }}"'
assert_includes commands, 'client_gem="$client_dir/opencode-ruby-${client_version}.gem"'
assert_includes commands, 'rails_gem="opencode-rails-$(ruby -Ilib -ropencode/rails_version'
assert_includes commands, 'gem install --local "$client_gem" --no-document'
assert_includes commands, 'gem install --local "$rails_gem" --no-document'
assert_includes commands, "Gem.loaded_specs.fetch(name).full_gem_path"
assert_includes commands, "ruby -ropencode-rails"
end
end

View File

@@ -27,7 +27,7 @@ class WorkflowContractTest < Minitest::Test
workflow_uses(workflow)
end
assert_equal 5, action_uses.length
assert_equal 7, action_uses.length
action_uses.each do |action_use|
action, separator, revision = action_use.rpartition("@")