12 Commits

10 changed files with 480 additions and 33 deletions

View File

@@ -7,17 +7,26 @@ on:
jobs: jobs:
push: push:
if: ${{ github.server_url == 'https://github.com' }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
contents: write contents: write
id-token: write id-token: write
environment: release environment: release
steps: steps:
- uses: actions/checkout@v5 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
persist-credentials: false persist-credentials: false
- uses: ruby/setup-ruby@v1 - uses: ruby/setup-ruby@003a5c4d8d6321bd302e38f6f0ec593f77f06600 # v1.319.0
with: with:
ruby-version: ruby ruby-version: "4.0"
bundler-cache: true bundler-cache: true
- uses: rubygems/release-gem@v1 - name: Verify tag matches gem version
env:
RELEASE_TAG: ${{ github.ref_name }}
run: >-
ruby -Ilib -ropencode/version -e
'expected = ENV.fetch("RELEASE_TAG").delete_prefix("v");
abort "tag #{expected.inspect} does not match gem #{Opencode::VERSION.inspect}"
unless Opencode::VERSION == expected'
- uses: rubygems/release-gem@052cc82692552de3ef2b81fd670e41d13cba8092 # v1.4.0

View File

@@ -12,12 +12,12 @@ jobs:
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
ruby: ["3.2", "3.3", "3.4"] ruby: ["3.2", "3.3", "3.4", "4.0"]
steps: steps:
- uses: actions/checkout@v5 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Set up Ruby ${{ matrix.ruby }} - name: Set up Ruby ${{ matrix.ruby }}
uses: ruby/setup-ruby@v1 uses: ruby/setup-ruby@003a5c4d8d6321bd302e38f6f0ec593f77f06600 # v1.319.0
with: with:
ruby-version: ${{ matrix.ruby }} ruby-version: ${{ matrix.ruby }}
bundler-cache: true bundler-cache: true

View File

@@ -1,5 +1,33 @@
# Changelog # Changelog
## 0.0.1.alpha8 - 2026-07-20
### Fixed
- Parse SSE events with CRLF, CR-only, or LF framing; accept `data:` fields
with or without the optional leading space; join multiple `data:` fields;
ignore the stream's optional leading UTF-8 BOM and comment fields; and decode
correctly when a transport chunk splits any byte of the event framing.
### Changed
- Test 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::VERSION`.
## 0.0.1.alpha7 - 2026-07-18
### Fixed
- Add an at-most-once `on_subscribed` hook to the lower-level
`Opencode::Client#stream_events` API. Higher-level orchestrators can now
wait for `server.connected` before submitting `prompt_async` without
abandoning their own Reply observers, persistence, or recovery pipeline.
- Propagate subscription-hook failures directly and never invoke the hook
again on SSE reconnect. Ambiguous prompt transport failures therefore
cannot silently become duplicate turns.
## 0.0.1.alpha6 - 2026-07-18 ## 0.0.1.alpha6 - 2026-07-18
### Fixed ### Fixed
@@ -88,5 +116,7 @@ First public alpha. HTTP + SSE client for OpenCode REST API.
### Compatibility ### Compatibility
- Ruby ≥ 3.2 - Ruby ≥ 3.2
- OpenCode server ≥ 1.15 (tested against the message bus schema in `packages/opencode/src/session/message-v2.ts`) - OpenCode targeted the then-current 1.15 message-bus shape. This historical
target was not a blanket SemVer compatibility guarantee; use the README's
current compatibility evidence for deployment decisions.
- Runtime dependency: `activesupport (>= 6.1)` for `blank?`/`present?`/`presence`/`truncate`/`duplicable?`/`megabytes`. ActiveSupport is *not* Rails — it's a standalone helpers gem. - Runtime dependency: `activesupport (>= 6.1)` for `blank?`/`present?`/`presence`/`truncate`/`duplicable?`/`megabytes`. ActiveSupport is *not* Rails — it's a standalone helpers gem.

View File

@@ -67,7 +67,9 @@ child = client.create_session(
Model strings use OpenCode's `provider/model` form; a preformatted model hash Model strings use OpenCode's `provider/model` form; a preformatted model hash
with `providerID` and `id` keys is also accepted. These configured-session with `providerID` and `id` keys is also accepted. These configured-session
fields require OpenCode 1.16.1 or newer. fields first appeared in OpenCode 1.16.1. That is an API-introduction note,
not evidence that every later server is compatible; use the
[certified compatibility evidence](#compatibility) for deployment choices.
### Streaming (the headline) ### Streaming (the headline)
@@ -106,8 +108,9 @@ client.update_session(session_id, permissions: permission_rules)
OpenCode appends PATCHed permission rules and evaluates the last matching OpenCode appends PATCHed permission rules and evaluates the last matching
rule. Hosts should send a complete ordered policy and fingerprint it so the rule. Hosts should send a complete ordered policy and fingerprint it so the
same policy is not appended on every turn. This endpoint requires OpenCode same policy is not appended on every turn. This endpoint first appeared in
1.16.1 or newer; the rest of the client remains compatible with 1.15. OpenCode 1.16.1; that version floor describes endpoint availability, not
compatibility with every later server.
### Lower-level event firehose ### Lower-level event firehose
@@ -119,6 +122,23 @@ client.stream_events(session_id: session_id) do |event|
end end
``` ```
Orchestrators that submit their own async prompt must do so through
`on_subscribed`; the callback runs only after the first `server.connected`
frame and at most once across automatic reconnects:
```ruby
client.stream_events(
session_id: session_id,
on_subscribed: -> { client.send_message_async(session_id, prompt) }
) do |event|
reply.apply(event)
end
```
If the callback raises, `stream_events` propagates that error and does not
retry it. This is intentional: a timed-out prompt response is ambiguous, so
reposting could duplicate the model turn and its cost.
### Interactive prompts ### Interactive prompts
When the agent uses the `question` or `permission` tools, opencode emits `question.asked` / `permission.asked` events. Answer them via: When the agent uses the `question` or `permission` tools, opencode emits `question.asked` / `permission.asked` events. Answer them via:
@@ -182,9 +202,29 @@ Want every OpenCode endpoint auto-generated from the OpenAPI spec? Use [`opencod
## Compatibility ## Compatibility
- Ruby ≥ 3.2 - Ruby ≥ 3.2
- OpenCode server ≥ 1.15
- Runtime dependency: `activesupport (>= 6.1)`*not* Rails. ActiveSupport is a standalone helpers gem (`blank?`, `present?`, `presence`, `truncate`, etc.). - Runtime dependency: `activesupport (>= 6.1)`*not* Rails. ActiveSupport is a standalone helpers gem (`blank?`, `present?`, `presence`, `truncate`, etc.).
OpenCode server compatibility is evidence-based, not an open-ended SemVer
promise. OpenCode's HTTP, SSE, and runtime behavior can change independently
of a numeric version floor, so this gem does not infer compatibility from a
constraint such as `>= 1.15`.
Use the [OpenCode compatibility corpus](https://github.com/ajaynomics/opencode-compat)
as the source of current evidence. Its
[image matrix](https://github.com/ajaynomics/opencode-compat/blob/main/manifests/image-matrix.json)
records the exact gem commit and immutable OpenCode OCI digest exercised by
the `ruby-rest-sse` profile. Its
[runtime tuples](https://github.com/ajaynomics/opencode-compat/blob/main/manifests/runtime-tuples.json)
add the exact consumer commit and canary evidence needed for promotion and
rollback. A version label or image tag is provenance only, never the execution
coordinate.
A combination absent from passing evidence is **unverified**, not necessarily
incompatible. Run the corpus against the exact gem, image digest, and consumer
commit before adopting or promoting it. See the
[certification policy](https://github.com/ajaynomics/opencode-compat/blob/main/docs/certification.md)
for the full process.
## Development ## Development
```sh ```sh
@@ -196,10 +236,13 @@ The smoke suite covers Client end-to-end against WebMock-stubbed OpenCode
endpoints, including subscription-before-prompt ordering and endpoints, including subscription-before-prompt ordering and
reconnect-without-repost. reconnect-without-repost.
Releases use RubyGems trusted publishing. After the repository's The repository contains a tag-triggered `release.yml` workflow intended for
`release.yml` workflow is registered as a trusted publisher with the `release` RubyGems trusted publishing, but its RubyGems trusted-publisher registration is
environment, pushing a `v*` tag builds, attests, and publishes the gem without not configured as of `0.0.1.alpha7`; that release was published manually. A
a long-lived RubyGems API key. `v*` tag push therefore does not currently guarantee publication. Before a
future release, verify the RubyGems registry result explicitly. Once the
workflow is registered as a trusted publisher for the `release` environment,
it can build, attest, and publish without a long-lived RubyGems API key.
## License ## License

View File

@@ -255,6 +255,10 @@ module Opencode
end end
MAX_SSE_BUFFER = 1_048_576 # 1 MB — safety valve against pathological server responses MAX_SSE_BUFFER = 1_048_576 # 1 MB — safety valve against pathological server responses
# Keep CRLF atomic so it cannot backtrack into CR + LF and look like a
# blank line. SSE also permits bare CR and LF line endings.
SSE_EVENT_BOUNDARY = /(?>\r\n|[\r\n]){2}/
UTF8_BOM = "\xEF\xBB\xBF".b.freeze
SSE_RECONNECT_DELAY = 0.1 SSE_RECONNECT_DELAY = 0.1
TRANSIENT_SSE_ERRORS = [ TRANSIENT_SSE_ERRORS = [
EOFError, EOFError,
@@ -285,6 +289,13 @@ module Opencode
# without nuking real reasoning. Callers that know their agent is # without nuking real reasoning. Callers that know their agent is
# short-prompt + fast can pass a lower value. # short-prompt + fast can pass a lower value.
# #
# on_subscribed: optional callable invoked at most once, after the first
# `server.connected` frame proves the SSE response body is flowing. This
# is the safe place for higher-level orchestrators to submit prompt_async;
# reconnects wait for their own connected frame but never invoke it again.
# A raised callback error is propagated directly and is never treated as
# a reconnectable SSE transport failure.
#
# idle_stream_timeout: seconds to wait BETWEEN meaningful events once # idle_stream_timeout: seconds to wait BETWEEN meaningful events once
# the session has started producing them. Default nil = no check # the session has started producing them. Default nil = no check
# (preserves the overall `timeout` ceiling behavior). Opt-in heartbeat # (preserves the overall `timeout` ceiling behavior). Opt-in heartbeat
@@ -298,7 +309,7 @@ module Opencode
# translate into a user-visible error / retry affordance. # translate into a user-visible error / retry affordance.
def stream_events(session_id:, timeout: 600, first_event_timeout: 120, def stream_events(session_id:, timeout: 600, first_event_timeout: 120,
idle_stream_timeout: nil, idle_stream_timeout: nil,
reply: nil, on_activity_tick: nil, &block) reply: nil, on_activity_tick: nil, on_subscribed: nil, &block)
consume_event_stream( consume_event_stream(
session_id: session_id, session_id: session_id,
timeout: timeout, timeout: timeout,
@@ -306,6 +317,7 @@ module Opencode
idle_stream_timeout: idle_stream_timeout, idle_stream_timeout: idle_stream_timeout,
reply: reply, reply: reply,
on_activity_tick: on_activity_tick, on_activity_tick: on_activity_tick,
on_subscribed: on_subscribed,
&block &block
) )
end end
@@ -318,6 +330,7 @@ module Opencode
first_event_deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + first_event_timeout first_event_deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + first_event_timeout
received_session_event = false received_session_event = false
last_meaningful_event_at = Process.clock_gettime(Process::CLOCK_MONOTONIC) last_meaningful_event_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
subscription_callback_attempted = on_subscribed.nil?
loop do loop do
now = Process.clock_gettime(Process::CLOCK_MONOTONIC) now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
@@ -351,6 +364,7 @@ module Opencode
subscription_ready = on_subscribed.nil? subscription_ready = on_subscribed.nil?
begin begin
buffer = String.new buffer = String.new
first_sse_event = true
http.request(request) do |response| http.request(request) do |response|
unless response.is_a?(Net::HTTPSuccess) unless response.is_a?(Net::HTTPSuccess)
@@ -377,9 +391,14 @@ module Opencode
raise ServerError, "SSE buffer exceeded #{MAX_SSE_BUFFER} bytes" raise ServerError, "SSE buffer exceeded #{MAX_SSE_BUFFER} bytes"
end end
while (idx = buffer.index("\n\n")) while (boundary = buffer.match(SSE_EVENT_BOUNDARY))
raw_event = buffer.slice!(0, idx + 2) raw_event = buffer.slice!(0, boundary.end(0))
event = parse_sse_event(raw_event, session_id) event = parse_sse_event(
raw_event,
session_id,
allow_bom: first_sse_event
)
first_sse_event = false
next unless event next unless event
unless subscription_ready unless subscription_ready
@@ -390,14 +409,22 @@ module Opencode
# available readiness handshake before prompting. # available readiness handshake before prompting.
next unless event[:type] == "server.connected" next unless event[:type] == "server.connected"
begin turn_started = false
turn_started = on_subscribed.call unless subscription_callback_attempted
rescue StandardError => error # Mark the attempt before invoking the callback. A timeout
# Prompt submission happens inside the open SSE response. # can mean the prompt reached OpenCode even though its HTTP
# Do not mistake its transport failure for an SSE disconnect # response did not reach us; retrying would duplicate the
# and hide it behind a reconnect/first-event timeout. # turn. The caller receives the original error instead.
subscription_callback_error = error subscription_callback_attempted = true
raise begin
turn_started = on_subscribed.call
rescue StandardError => error
# Prompt submission happens inside the open SSE response.
# Do not mistake its transport failure for an SSE disconnect
# and hide it behind a reconnect/first-event timeout.
subscription_callback_error = error
raise
end
end end
if turn_started if turn_started
# Before this fix stream_events began only after the prompt # Before this fix stream_events began only after the prompt
@@ -622,11 +649,22 @@ module Opencode
end end
end end
def parse_sse_event(raw, session_id) def parse_sse_event(raw, session_id, allow_bom: false)
data_line = raw.lines.find { |l| l.start_with?("data: ") } if allow_bom && raw.b.start_with?(UTF8_BOM)
return nil unless data_line raw = raw.byteslice(UTF8_BOM.bytesize..)
end
json = JSON.parse(data_line.sub("data: ", "").strip, symbolize_names: true) data = raw.split(/\r\n|\r|\n/, -1).filter_map do |line|
next if line.start_with?(":")
field, value = line.split(":", 2)
next unless field == "data"
(value || "").delete_prefix(" ")
end
return nil if data.empty?
json = JSON.parse(data.join("\n"), symbolize_names: true)
event_session = json.dig(:properties, :sessionID) || event_session = json.dig(:properties, :sessionID) ||
json.dig(:properties, :info, :sessionID) || json.dig(:properties, :info, :sessionID) ||

View File

@@ -1,5 +1,5 @@
# frozen_string_literal: true # frozen_string_literal: true
module Opencode module Opencode
VERSION = "0.0.1.alpha6" VERSION = "0.0.1.alpha8"
end end

View File

@@ -126,6 +126,57 @@ class SmokeTest < Minitest::Test
assert_equal({}, response) assert_equal({}, response)
end end
def test_send_message_async_serializes_a_structured_child_prompt
schema = {
type: "object",
properties: {
requirement_suggestions: {
type: "array",
items: { type: "string" }
}
},
required: [ "requirement_suggestions" ],
additionalProperties: false
}
invocation_query =
"Complete the bounded structured request from the preloaded worker skill and immutable Rails context."
expected_body = {
messageID: "msg_worker_1",
parts: [ { type: "text", text: invocation_query } ],
agent: "destination-list-curator",
format: {
type: "json_schema",
schema: schema,
retryCount: 0
},
system: "Immutable Rails context"
}
serialized_body = nil
prompt = stub_request(:post, "#{BASE}/session/#{SESSION_ID}/prompt_async")
.with(body: expected_body.to_json)
.to_return do |request|
serialized_body = request.body
{ status: 204, body: "" }
end
@client.send_message_async(
SESSION_ID,
invocation_query,
agent: "destination-list-curator",
system: "Immutable Rails context",
message_id: "msg_worker_1",
format: {
type: "json_schema",
schema: schema,
retryCount: 0
}
)
assert_equal expected_body.to_json, serialized_body
assert_requested prompt, times: 1
end
def test_stream_returns_typed_Reply_Result_with_full_text def test_stream_returns_typed_Reply_Result_with_full_text
stub_request(:post, "#{BASE}/session/#{SESSION_ID}/prompt_async") stub_request(:post, "#{BASE}/session/#{SESSION_ID}/prompt_async")
.to_return(status: 204, body: "") .to_return(status: 204, body: "")
@@ -271,6 +322,143 @@ class SmokeTest < Minitest::Test
assert_requested prompt, times: 1 assert_requested prompt, times: 1
end end
def test_stream_events_invokes_on_subscribed_once_after_connected_across_reconnects
order = []
connections = [
[ CONNECTED_EVENT, { type: "server.heartbeat", properties: {} } ],
[
CONNECTED_EVENT,
{
type: "session.status",
properties: { sessionID: SESSION_ID, status: { type: "idle" } }
}
]
]
event_stream = stub_request(:get, %r{#{Regexp.escape(BASE)}/event(\?.*)?\z})
.to_return do
events = connections.shift or raise "unexpected third SSE connection"
order << :sse_accepted
{
status: 200,
body: events.map { |event| "data: #{event.to_json}\n\n" }.join,
headers: { "Content-Type" => "text/event-stream" }
}
end
subscribed_calls = 0
@client.stream_events(
session_id: SESSION_ID,
timeout: 1,
first_event_timeout: 1,
on_subscribed: -> {
subscribed_calls += 1
order << :prompt
true
}
) { |_event| }
assert_equal 1, subscribed_calls
assert_equal [ :sse_accepted, :prompt, :sse_accepted ], order
assert_requested event_stream, times: 2
end
def test_stream_events_surfaces_on_subscribed_timeout_without_reconnecting
event_stream = stub_request(:get, %r{#{Regexp.escape(BASE)}/event(\?.*)?\z})
.to_return(status: 200, body: "data: #{CONNECTED_EVENT.to_json}\n\n",
headers: { "Content-Type" => "text/event-stream" })
calls = 0
error = assert_raises(Net::ReadTimeout) do
@client.stream_events(
session_id: SESSION_ID,
timeout: 1,
first_event_timeout: 1,
on_subscribed: -> {
calls += 1
raise Net::ReadTimeout, "ambiguous prompt response"
}
) { |_event| }
end
assert_equal "Net::ReadTimeout with \"ambiguous prompt response\"", error.message
assert_equal 1, calls
assert_requested event_stream, times: 1
end
def test_stream_events_accepts_standard_sse_framing_variants
terminal_event = {
type: "session.status",
properties: { sessionID: SESSION_ID, status: { type: "idle" } }
}
connected_data = JSON.pretty_generate(CONNECTED_EVENT).lines(chomp: true)
.map { |line| "data: #{line}\r\n" }
.join
body = ": readiness comment\r\nevent: message\r\n#{connected_data}\r\n" \
"data:#{terminal_event.to_json}\r\r"
event_stream = stub_request(:get, %r{#{Regexp.escape(BASE)}/event(\?.*)?\z})
.to_return(status: 200, body: body,
headers: { "Content-Type" => "text/event-stream" })
subscribed_calls = 0
event_types = []
@client.stream_events(
session_id: SESSION_ID,
timeout: 1,
first_event_timeout: 1,
on_subscribed: -> {
subscribed_calls += 1
true
}
) { |event| event_types << event.fetch(:type) }
assert_equal 1, subscribed_calls
assert_equal [ "server.connected", "session.status" ], event_types
assert_requested event_stream, times: 1
end
def test_stream_events_handles_a_bom_and_boundaries_split_across_chunks
terminal_event = {
type: "session.status",
properties: { sessionID: SESSION_ID, status: { type: "idle" } }
}
body = "\uFEFF: initial comment\r\n\r\n" \
"data: #{CONNECTED_EVENT.to_json}\r\n\r\n" \
"data: #{terminal_event.to_json}\n\n"
chunks = body.b.bytes.map { |byte| byte.chr(Encoding::BINARY) }
response = Net::HTTPOK.new("1.1", "200", "OK")
response.define_singleton_method(:read_body) do |&callback|
chunks.each(&callback)
end
http = Object.new
http.define_singleton_method(:use_ssl=) { |_value| }
http.define_singleton_method(:open_timeout=) { |_value| }
http.define_singleton_method(:read_timeout=) { |_value| }
http.define_singleton_method(:request) do |_request, &callback|
callback.call(response)
end
http.define_singleton_method(:started?) { false }
subscribed_calls = 0
event_types = []
Net::HTTP.stub(:new, ->(_host, _port) { http }) do
@client.stream_events(
session_id: SESSION_ID,
timeout: 1,
first_event_timeout: 1,
on_subscribed: -> {
subscribed_calls += 1
true
}
) { |event| event_types << event.fetch(:type) }
end
assert_equal 1, subscribed_calls
assert_equal [ "server.connected", "session.status" ], event_types
end
def test_stream_events_preserves_question_and_permission_wait_state def test_stream_events_preserves_question_and_permission_wait_state
events = [ events = [
{ {

View File

@@ -0,0 +1,19 @@
# frozen_string_literal: true
require "test_helper"
class ReadmeContractTest < Minitest::Test
README = File.read(File.expand_path("../README.md", __dir__))
def test_server_compatibility_points_to_exact_certification_evidence
assert_includes README, "https://github.com/ajaynomics/opencode-compat"
assert_includes README, "manifests/image-matrix.json"
assert_includes README, "manifests/runtime-tuples.json"
refute_match(/OpenCode server\s*(?:>=|≥)\s*\d/, README)
end
def test_release_guidance_does_not_claim_trusted_publishing_is_configured
assert_includes README, "not configured as of `0.0.1.alpha7`"
assert_includes README, "does not currently guarantee publication"
end
end

View 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::VERSION == expected"
end
end

View 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