diff --git a/.github/workflows/candidate.yml b/.github/workflows/candidate.yml index 51bdc2f..a5ac7e9 100644 --- a/.github/workflows/candidate.yml +++ b/.github/workflows/candidate.yml @@ -23,6 +23,7 @@ jobs: ruby test/runtime_tuple_promoter_test.rb ruby test/exact_live_contract_test.rb ruby test/watcher_test.rb + ruby test/client_candidate_test.rb ruby test/lockstep_client_contract_test.rb ruby test/matrix_json_test.rb ruby test/image_contract_evidence_test.rb @@ -31,10 +32,13 @@ jobs: runs-on: ubuntu-latest outputs: matrix: ${{ steps.matrix.outputs.value }} + publication_state: ${{ steps.clients.outputs.publication_state }} + ruby_provenance_kind: ${{ steps.clients.outputs.ruby_provenance_kind }} ruby_ref: ${{ steps.clients.outputs.ruby_ref }} ruby_version: ${{ steps.clients.outputs.ruby_version }} ruby_tag: ${{ steps.clients.outputs.ruby_tag }} ruby_tag_object: ${{ steps.clients.outputs.ruby_tag_object }} + rails_provenance_kind: ${{ steps.clients.outputs.rails_provenance_kind }} rails_ref: ${{ steps.clients.outputs.rails_ref }} rails_version: ${{ steps.clients.outputs.rails_version }} rails_tag: ${{ steps.clients.outputs.rails_tag }} @@ -44,38 +48,7 @@ jobs: - id: matrix run: echo "value=$(ruby scripts/matrix_json.rb)" >> "$GITHUB_OUTPUT" - id: clients - run: | - ruby_ref="$(jq -r '.clients["opencode-ruby"].ref' manifests/client-candidate.json)" - ruby_version="$(jq -r '.clients["opencode-ruby"].version' manifests/client-candidate.json)" - ruby_tag="$(jq -r '.clients["opencode-ruby"].tag_provenance.tag' manifests/client-candidate.json)" - ruby_tag_object="$(jq -r '.clients["opencode-ruby"].tag_provenance.annotated_tag_object' manifests/client-candidate.json)" - rails_ref="$(jq -r '.clients["opencode-rails"].ref' manifests/client-candidate.json)" - rails_version="$(jq -r '.clients["opencode-rails"].version' manifests/client-candidate.json)" - rails_tag="$(jq -r '.clients["opencode-rails"].tag_provenance.tag' manifests/client-candidate.json)" - rails_tag_object="$(jq -r '.clients["opencode-rails"].tag_provenance.annotated_tag_object' manifests/client-candidate.json)" - if ! [[ "$ruby_ref" =~ ^[0-9a-f]{40}$ ]]; then - echo "opencode-ruby candidate ref must be a full 40-hex commit" >&2 - exit 2 - fi - if ! [[ "$rails_ref" =~ ^[0-9a-f]{40}$ ]]; then - echo "opencode-rails candidate ref must be a full 40-hex commit" >&2 - exit 2 - fi - if ! [[ "$ruby_tag_object" =~ ^[0-9a-f]{40}$ && "$rails_tag_object" =~ ^[0-9a-f]{40}$ ]]; then - echo "candidate annotated tag objects must be full 40-hex object IDs" >&2 - exit 2 - fi - test "$ruby_tag" = "v$ruby_version" - test "$rails_tag" = "v$rails_version" - test "$ruby_version" = "$rails_version" - echo "ruby_ref=$ruby_ref" >> "$GITHUB_OUTPUT" - echo "ruby_version=$ruby_version" >> "$GITHUB_OUTPUT" - echo "ruby_tag=$ruby_tag" >> "$GITHUB_OUTPUT" - echo "ruby_tag_object=$ruby_tag_object" >> "$GITHUB_OUTPUT" - echo "rails_ref=$rails_ref" >> "$GITHUB_OUTPUT" - echo "rails_version=$rails_version" >> "$GITHUB_OUTPUT" - echo "rails_tag=$rails_tag" >> "$GITHUB_OUTPUT" - echo "rails_tag_object=$rails_tag_object" >> "$GITHUB_OUTPUT" + run: ruby scripts/client_candidate_outputs.rb >> "$GITHUB_OUTPUT" fixture-contract: needs: prepare @@ -133,7 +106,7 @@ jobs: strategy: fail-fast: false matrix: - ruby: ["3.2", "3.3", "3.4"] + ruby: ["3.2", "3.3", "3.4", "4.0"] name: lockstep clients Ruby ${{ matrix.ruby }} steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 @@ -171,10 +144,13 @@ jobs: env: OPENCODE_RUBY_PATH: ${{ github.workspace }}/ruby-client OPENCODE_RAILS_PATH: ${{ github.workspace }}/opencode-rails + OPENCODE_CLIENT_PUBLICATION_STATE: ${{ needs.prepare.outputs.publication_state }} OPENCODE_RUBY_COMMIT: ${{ needs.prepare.outputs.ruby_ref }} + OPENCODE_RUBY_PROVENANCE_KIND: ${{ needs.prepare.outputs.ruby_provenance_kind }} OPENCODE_RUBY_TAG: ${{ needs.prepare.outputs.ruby_tag }} OPENCODE_RUBY_TAG_OBJECT: ${{ needs.prepare.outputs.ruby_tag_object }} OPENCODE_RAILS_COMMIT: ${{ needs.prepare.outputs.rails_ref }} + OPENCODE_RAILS_PROVENANCE_KIND: ${{ needs.prepare.outputs.rails_provenance_kind }} OPENCODE_RAILS_TAG: ${{ needs.prepare.outputs.rails_tag }} OPENCODE_RAILS_TAG_OBJECT: ${{ needs.prepare.outputs.rails_tag_object }} OPENCODE_RUBY_VERSION: ${{ needs.prepare.outputs.ruby_version }} diff --git a/README.md b/README.md index a5d8d26..7fc59b0 100644 --- a/README.md +++ b/README.md @@ -8,11 +8,14 @@ prompts, credentials, sessions, or user data belong here. ## Policy -- A gem release is a candidate until the fixture suite and every required image - profile pass. +- An unpublished gem build is a candidate until the fixture suite and every + required image profile pass. It is identified only by an exact commit. - `opencode-ruby` and `opencode-rails` are one candidate release train. The - manifest records both peeled commits, and CI rejects a Rails candidate whose + manifest records both exact commits, and CI rejects a Rails candidate whose exact runtime dependency resolves to a different Ruby version or commit. +- Once a train is published, commit-only provenance is no longer accepted. The + manifest and lockstep contract require each annotated tag object, local tag + ref, and peeled commit to bind to the tested checkout. - Image references are immutable OCI index digests. A tag may be recorded as human-readable provenance, but it is never an execution coordinate. - Custom consumer images are certified with an isolated canary on the host that @@ -50,23 +53,23 @@ ruby test/repository_test.rb ruby test/runtime_tuple_promoter_test.rb ruby test/exact_live_contract_test.rb ruby test/watcher_test.rb -OPENCODE_RUBY_PATH=/path/to/opencode-ruby-at-78b6f9c9e9c7d58b699af1c3c17764acd33de798 \ +ruby test/client_candidate_test.rb +OPENCODE_RUBY_PATH=/path/to/opencode-ruby-at-9277646a4bb2cf25a8384ffc140b154f49ea5766 \ ruby ruby/opencode_ruby_fixture_contract.rb BUNDLE_GEMFILE=/path/to/opencode-rails/Gemfile \ OPENCODE_RUBY_PATH=/path/to/opencode-ruby \ OPENCODE_RAILS_PATH=/path/to/opencode-rails \ -OPENCODE_RUBY_COMMIT=FULL_40_HEX_COMMIT \ -OPENCODE_RUBY_TAG=v0.0.1.alpha7 \ -OPENCODE_RUBY_TAG_OBJECT=FULL_40_HEX_ANNOTATED_TAG_OBJECT \ -OPENCODE_RAILS_COMMIT=FULL_40_HEX_COMMIT \ -OPENCODE_RAILS_TAG=v0.0.1.alpha7 \ -OPENCODE_RAILS_TAG_OBJECT=FULL_40_HEX_ANNOTATED_TAG_OBJECT \ -OPENCODE_RUBY_VERSION=0.0.1.alpha7 \ -OPENCODE_RAILS_VERSION=0.0.1.alpha7 \ +OPENCODE_CLIENT_PUBLICATION_STATE=unpublished \ +OPENCODE_RUBY_COMMIT=9277646a4bb2cf25a8384ffc140b154f49ea5766 \ +OPENCODE_RUBY_PROVENANCE_KIND=commit \ +OPENCODE_RAILS_COMMIT=a9add2a7c1dd3eb978aa8b4ebf9ef7e111d1057f \ +OPENCODE_RAILS_PROVENANCE_KIND=commit \ +OPENCODE_RUBY_VERSION=0.0.1.alpha8 \ +OPENCODE_RAILS_VERSION=0.0.1.alpha8 \ bundle exec ruby ruby/lockstep_client_contract.rb -BUNDLE_GEMFILE=/path/to/opencode-ruby-at-78b6f9c9e9c7d58b699af1c3c17764acd33de798/Gemfile \ -OPENCODE_RUBY_PATH=/path/to/opencode-ruby-at-78b6f9c9e9c7d58b699af1c3c17764acd33de798 \ -OPENCODE_RUBY_COMMIT=78b6f9c9e9c7d58b699af1c3c17764acd33de798 \ +BUNDLE_GEMFILE=/path/to/opencode-ruby-at-9277646a4bb2cf25a8384ffc140b154f49ea5766/Gemfile \ +OPENCODE_RUBY_PATH=/path/to/opencode-ruby-at-9277646a4bb2cf25a8384ffc140b154f49ea5766 \ +OPENCODE_RUBY_COMMIT=9277646a4bb2cf25a8384ffc140b154f49ea5766 \ OPENCODE_IMAGE='ghcr.io/anomalyco/opencode@sha256:e975a0647576016dfdf77d54b979ca30d32b4750472c10263e9894aad6628c2a' \ bundle exec scripts/run_image_contract.sh ``` @@ -85,6 +88,17 @@ themselves. A person must review a passing artifact and commit the durable certification document under `evidence/`; no workflow commits or promotes its own result. +`manifests/image-matrix.json` binds the active pending matrix to both candidate +commits. Its `previous_certification` entries preserve the last alpha7 result; +they do not certify alpha8. Ajent and Mushu custom-image rows remain pending +until their host canaries run against this exact pair. + +The checked-in alpha8 local exact-image result is explicitly marked +`not-certified` and `shared-client-contract-only`. It proves the Ruby/SSE probe +on the recorded image IDs but does not claim Rails, plugin, application, or +deployment canary coverage; exact-head CI still supplies reviewable workflow +provenance. + ## Adding an upstream release The scheduled watcher compares both the latest upstream GitHub release tag and diff --git a/docs/certification.md b/docs/certification.md index 4e14ee7..c730532 100644 --- a/docs/certification.md +++ b/docs/certification.md @@ -10,7 +10,7 @@ The gate requires: 1. repository validation and the shared fixture corpus; 2. exact `opencode-ruby` and `opencode-rails` candidate checkouts, all Rails - candidate tests on Ruby 3.2 through 3.4, an exact Rails-to-Ruby runtime + candidate tests on Ruby 3.2 through 4.0, an exact Rails-to-Ruby runtime dependency, and loaded Bundler provenance for the Ruby commit; 3. the public exact-image matrix against the deterministic model stub; 4. an isolated custom-image canary for Ajent Rails and Mushu; @@ -21,6 +21,29 @@ The gate requires: Health-only probes do not certify a tuple. +### Client source provenance + +Before publication, `manifests/client-candidate.json` must use +`publication_state: unpublished` and `kind: commit` for both clients. Each +provenance commit must equal its full 40-character checkout ref, and the Rails +Gemfile/runtime dependency must resolve the same exact Ruby commit and version. +Commit-only evidence is candidate evidence; it is not proof of a gem release. +The lockstep checkout fetches tags and fails unpublished mode if the matching +`vVERSION` tag appears, forcing the manifest through the published gate. + +After publication, change the state to `published` and replace both commit +provenance objects with `kind: annotated-tag`, the exact tag, annotated tag +object ID, and peeled commit. The repository validator rejects a published +commit-only candidate. The lockstep job additionally verifies that each local +tag ref resolves to the declared annotated tag object and that both the object +and ref peel to the tested checkout. This transition is a reviewed manifest +change; CI does not create or publish tags. + +The image matrix's active candidate coordinates must equal the client manifest. +Changing either commit resets the active matrix to `pending`. A +`previous_certification` preserves historical evidence but does not certify the +new candidate. + The shared live probe is intentionally strict: `full_text` must equal the expected text byte-for-byte and the deterministic model must receive exactly one request. A response that merely contains the expected text, including a diff --git a/evidence/2026-07-20-opencode-alpha8-pre-release-shared-client.json b/evidence/2026-07-20-opencode-alpha8-pre-release-shared-client.json new file mode 100644 index 0000000..6110f08 --- /dev/null +++ b/evidence/2026-07-20-opencode-alpha8-pre-release-shared-client.json @@ -0,0 +1,103 @@ +{ + "schema_version": 1, + "kind": "pre-release-client-image-review-input", + "status": "pass", + "certification_status": "not-certified", + "publication_state": "unpublished", + "coverage_scope": "shared-client-contract-only", + "clients": { + "opencode_ruby": { + "version": "0.0.1.alpha8", + "commit": "9277646a4bb2cf25a8384ffc140b154f49ea5766", + "executed": true + }, + "opencode_rails": { + "version": "0.0.1.alpha8", + "commit": "a9add2a7c1dd3eb978aa8b4ebf9ef7e111d1057f", + "executed_by_image_contract": false + } + }, + "checks": [ + { + "id": "upstream-1.16.1", + "checked_at": "2026-07-20T05:38:34Z", + "image": "ghcr.io/anomalyco/opencode@sha256:e975a0647576016dfdf77d54b979ca30d32b4750472c10263e9894aad6628c2a", + "docker_image_id": "sha256:e975a0647576016dfdf77d54b979ca30d32b4750472c10263e9894aad6628c2a", + "platform": "linux/amd64", + "reported_version": "1.16.1", + "status": "pass", + "expected_text": "compat-ok", + "full_text": "compat-ok", + "authoritative_assistant_message_count": 1, + "llm_request_count": 1, + "executed_profiles": ["ruby-rest-sse"], + "required_consumer_profiles": ["rails-persisted-turn"] + }, + { + "id": "upstream-1.17.18", + "checked_at": "2026-07-20T05:39:06Z", + "image": "ghcr.io/anomalyco/opencode@sha256:bf9d0e84b7cedef436a8f57db4d48767cd5d8fc6604f666335c1cc916b199a97", + "docker_image_id": "sha256:bf9d0e84b7cedef436a8f57db4d48767cd5d8fc6604f666335c1cc916b199a97", + "platform": "linux/amd64", + "reported_version": "1.17.18", + "status": "pass", + "expected_text": "compat-ok", + "full_text": "compat-ok", + "authoritative_assistant_message_count": 1, + "llm_request_count": 1, + "executed_profiles": ["ruby-rest-sse"], + "required_consumer_profiles": ["plugin-ledger"] + }, + { + "id": "upstream-1.18.3", + "checked_at": "2026-07-20T05:39:18Z", + "image": "ghcr.io/anomalyco/opencode@sha256:c2d5d6398df72aac85cb1bdc8f900c71a9b75a33fb7c0a76dc1484e4b126e41e", + "docker_image_id": "sha256:c2d5d6398df72aac85cb1bdc8f900c71a9b75a33fb7c0a76dc1484e4b126e41e", + "platform": "linux/amd64", + "reported_version": "1.18.3", + "status": "pass", + "expected_text": "compat-ok", + "full_text": "compat-ok", + "authoritative_assistant_message_count": 1, + "llm_request_count": 1, + "executed_profiles": ["ruby-rest-sse"], + "required_consumer_profiles": ["rails-persisted-turn", "plugin-ledger"] + }, + { + "id": "ajent-blackline-alpha8-shared-contract", + "checked_at": "2026-07-20T05:39:57Z", + "image": "sha256:239e3dc6cd2958251cda636fb20bd7cd7c46f596bd4c61cb81863ebc49d824e8", + "docker_image_id": "sha256:239e3dc6cd2958251cda636fb20bd7cd7c46f596bd4c61cb81863ebc49d824e8", + "platform": "linux/amd64", + "reported_version": "1.18.3", + "status": "pass", + "expected_text": "compat-ok", + "full_text": "compat-ok", + "authoritative_assistant_message_count": 1, + "llm_request_count": 1, + "executed_profiles": ["ruby-rest-sse"], + "required_consumer_profiles": ["rails-persisted-turn", "plugin-ledger"] + }, + { + "id": "mushu-permission-shortcuts-alpha8-shared-contract", + "checked_at": "2026-07-20T05:40:08Z", + "image": "sha256:ed6293ca87e6db1dc07870d3ed6da21960743e6e2e904533b3c566d334b58d6a", + "docker_image_id": "sha256:ed6293ca87e6db1dc07870d3ed6da21960743e6e2e904533b3c566d334b58d6a", + "platform": "linux/amd64", + "reported_version": "0.0.0-permission-shortcuts-202606300721", + "status": "pass", + "expected_text": "compat-ok", + "full_text": "compat-ok", + "authoritative_assistant_message_count": 1, + "llm_request_count": 1, + "executed_profiles": ["ruby-rest-sse"], + "required_consumer_profiles": [] + } + ], + "limitations": [ + "This local evidence has no GitHub workflow run provenance and is review input only.", + "The exact-image contract executed opencode-ruby but did not execute opencode-rails.", + "Rails persistence, plugin-ledger, consumer application, deployment, and user-visible canary profiles remain unverified.", + "No runtime tuple was promoted and no consumer was deployed." + ] +} diff --git a/lib/opencode_compat/client_candidate.rb b/lib/opencode_compat/client_candidate.rb new file mode 100644 index 0000000..f76944f --- /dev/null +++ b/lib/opencode_compat/client_candidate.rb @@ -0,0 +1,154 @@ +# frozen_string_literal: true + +require "json" +require "rubygems" + +module OpenCodeCompat + class ClientCandidate + class ValidationError < StandardError; end + + SCHEMA_VERSION = 3 + CLIENT_NAMES = %w[opencode-rails opencode-ruby].freeze + PUBLICATION_PROVENANCE = { + "published" => "annotated-tag", + "unpublished" => "commit" + }.freeze + SHA_PATTERN = /\A[0-9a-f]{40}\z/ + + def self.load(path) + new(JSON.parse(File.binread(path))) + rescue JSON::ParserError => error + raise ValidationError, "client candidate is not valid JSON: #{error.message}" + end + + attr_reader :document + + def initialize(document) + @document = document + end + + def verify! + assert_exact_keys!(document, + %w[clients publication_state release_train schema_version status], "client candidate") + assert_equal!(SCHEMA_VERSION, document.fetch("schema_version"), "schema_version") + assert_equal!("candidate", document.fetch("status"), "status") + + release_train = exact_version!(document.fetch("release_train"), "release_train") + publication_state = document.fetch("publication_state") + expected_kind = PUBLICATION_PROVENANCE.fetch(publication_state) do + raise ValidationError, + "publication_state must be one of #{PUBLICATION_PROVENANCE.keys.join(', ')}, got #{publication_state.inspect}" + end + + clients = document.fetch("clients") + assert_equal!(CLIENT_NAMES, clients.keys.sort, "client names") + clients.each do |name, client| + validate_client!(name, client, release_train, expected_kind) + end + validate_runtime_dependency!(clients, release_train) + + self + rescue KeyError => error + raise ValidationError, "client candidate is missing required field #{error.key.inspect}" + end + + def github_outputs + verify! + clients = document.fetch("clients") + + { + "publication_state" => document.fetch("publication_state"), + "ruby_provenance_kind" => provenance(clients, "opencode-ruby").fetch("kind"), + "ruby_ref" => clients.dig("opencode-ruby", "ref"), + "ruby_tag" => provenance(clients, "opencode-ruby")["tag"].to_s, + "ruby_tag_object" => provenance(clients, "opencode-ruby")["annotated_tag_object"].to_s, + "ruby_version" => clients.dig("opencode-ruby", "version"), + "rails_provenance_kind" => provenance(clients, "opencode-rails").fetch("kind"), + "rails_ref" => clients.dig("opencode-rails", "ref"), + "rails_tag" => provenance(clients, "opencode-rails")["tag"].to_s, + "rails_tag_object" => provenance(clients, "opencode-rails")["annotated_tag_object"].to_s, + "rails_version" => clients.dig("opencode-rails", "version") + } + end + + private + + def validate_client!(name, client, release_train, expected_kind) + expected_keys = %w[provenance ref repository version] + expected_keys << "runtime_dependencies" if name == "opencode-rails" + assert_exact_keys!(client, expected_keys.sort, "#{name} candidate") + assert_equal!("ajaynomics/#{name}", client.fetch("repository"), "#{name} repository") + assert_equal!(release_train, exact_version!(client.fetch("version"), "#{name} version"), "#{name} version") + + ref = exact_commit!(client.fetch("ref"), "#{name} ref") + source = client.fetch("provenance") + assert_equal!(expected_kind, source.fetch("kind"), "#{name} provenance kind") + + case expected_kind + when "commit" + assert_exact_keys!(source, %w[commit kind], "#{name} commit provenance") + assert_equal!(ref, exact_commit!(source.fetch("commit"), "#{name} provenance commit"), + "#{name} provenance commit") + when "annotated-tag" + assert_exact_keys!(source, %w[annotated_tag_object kind peeled_commit tag], + "#{name} annotated-tag provenance") + assert_equal!("v#{release_train}", exact_tag!(source.fetch("tag"), "#{name} tag"), "#{name} tag") + exact_commit!(source.fetch("annotated_tag_object"), "#{name} annotated tag object") + assert_equal!(ref, exact_commit!(source.fetch("peeled_commit"), "#{name} peeled commit"), + "#{name} peeled commit") + end + end + + def validate_runtime_dependency!(clients, release_train) + dependency = clients.fetch("opencode-rails") + .fetch("runtime_dependencies") + .fetch("opencode-ruby") + assert_exact_keys!(dependency, %w[ref requirement], "opencode-rails runtime dependency") + assert_equal!("= #{release_train}", dependency.fetch("requirement"), + "opencode-rails runtime dependency requirement") + assert_equal!(clients.dig("opencode-ruby", "ref"), + exact_commit!(dependency.fetch("ref"), "opencode-rails runtime dependency ref"), + "opencode-rails runtime dependency ref") + end + + def provenance(clients, name) + clients.fetch(name).fetch("provenance") + end + + def exact_commit!(value, label) + return value if value.is_a?(String) && value.match?(SHA_PATTERN) + + raise ValidationError, "#{label} must be a full lowercase 40-character Git commit, got #{value.inspect}" + end + + def exact_tag!(value, label) + if value.is_a?(String) && value.match?(/\A[A-Za-z0-9][A-Za-z0-9._-]*\z/) && + !value.include?("..") && !value.end_with?(".") + return value + end + + raise ValidationError, "#{label} must be a safe exact Git tag, got #{value.inspect}" + end + + def exact_version!(value, label) + if value.is_a?(String) && value.match?(/\A[0-9A-Za-z][0-9A-Za-z.-]*\z/) && Gem::Version.correct?(value) + return value + end + + raise ValidationError, "#{label} must be an exact RubyGems version, got #{value.inspect}" + end + + def assert_exact_keys!(value, expected, label) + actual = value.keys.sort + return if actual == expected + + raise ValidationError, "#{label} keys must equal #{expected.inspect}, got #{actual.inspect}" + end + + def assert_equal!(expected, actual, label) + return if expected == actual + + raise ValidationError, "#{label} must equal #{expected.inspect}, got #{actual.inspect}" + end + end +end diff --git a/manifests/client-candidate.json b/manifests/client-candidate.json index b764ef2..92b718c 100644 --- a/manifests/client-candidate.json +++ b/manifests/client-candidate.json @@ -1,30 +1,29 @@ { - "schema_version": 2, - "release_train": "0.0.1.alpha7", + "schema_version": 3, + "release_train": "0.0.1.alpha8", + "publication_state": "unpublished", "clients": { "opencode-ruby": { "repository": "ajaynomics/opencode-ruby", - "ref": "78b6f9c9e9c7d58b699af1c3c17764acd33de798", - "version": "0.0.1.alpha7", - "tag_provenance": { - "tag": "v0.0.1.alpha7", - "annotated_tag_object": "abfce3ec8cb1ee468107d0845521769502251645", - "peeled_commit": "78b6f9c9e9c7d58b699af1c3c17764acd33de798" + "ref": "9277646a4bb2cf25a8384ffc140b154f49ea5766", + "version": "0.0.1.alpha8", + "provenance": { + "kind": "commit", + "commit": "9277646a4bb2cf25a8384ffc140b154f49ea5766" } }, "opencode-rails": { "repository": "ajaynomics/opencode-rails", - "ref": "2a391ccad1d098e4bb51eb19b6ca52adaa79e5cb", - "version": "0.0.1.alpha7", - "tag_provenance": { - "tag": "v0.0.1.alpha7", - "annotated_tag_object": "61e128084b2b665c0ba20b332987e41794948d7c", - "peeled_commit": "2a391ccad1d098e4bb51eb19b6ca52adaa79e5cb" + "ref": "a9add2a7c1dd3eb978aa8b4ebf9ef7e111d1057f", + "version": "0.0.1.alpha8", + "provenance": { + "kind": "commit", + "commit": "a9add2a7c1dd3eb978aa8b4ebf9ef7e111d1057f" }, "runtime_dependencies": { "opencode-ruby": { - "requirement": "= 0.0.1.alpha7", - "ref": "78b6f9c9e9c7d58b699af1c3c17764acd33de798" + "requirement": "= 0.0.1.alpha8", + "ref": "9277646a4bb2cf25a8384ffc140b154f49ea5766" } } } diff --git a/manifests/image-matrix.json b/manifests/image-matrix.json index 04ade4d..df35f80 100644 --- a/manifests/image-matrix.json +++ b/manifests/image-matrix.json @@ -1,5 +1,12 @@ { - "schema_version": 1, + "schema_version": 2, + "client_candidate": { + "release_train": "0.0.1.alpha8", + "publication_state": "unpublished", + "opencode_ruby_commit": "9277646a4bb2cf25a8384ffc140b154f49ea5766", + "opencode_rails_commit": "a9add2a7c1dd3eb978aa8b4ebf9ef7e111d1057f", + "certification_status": "pending" + }, "public_ci": [ { "id": "upstream-1.16.1", @@ -9,12 +16,15 @@ "consumers": ["travelwolf"], "profiles": ["ruby-rest-sse"], "required_consumer_profiles": ["rails-persisted-turn"], - "certification_status": "certified", - "certified_client_commit": "78b6f9c9e9c7d58b699af1c3c17764acd33de798", - "certified_at": "2026-07-18T22:03:12Z", - "expected_text": "compat-ok", - "full_text": "compat-ok", - "llm_request_count": 1 + "certification_status": "pending", + "previous_certification": { + "status": "certified", + "client_commit": "78b6f9c9e9c7d58b699af1c3c17764acd33de798", + "certified_at": "2026-07-18T22:03:12Z", + "expected_text": "compat-ok", + "full_text": "compat-ok", + "llm_request_count": 1 + } }, { "id": "upstream-1.17.18", @@ -24,12 +34,15 @@ "consumers": ["opencode-ajent"], "profiles": ["ruby-rest-sse"], "required_consumer_profiles": ["plugin-ledger"], - "certification_status": "certified", - "certified_client_commit": "78b6f9c9e9c7d58b699af1c3c17764acd33de798", - "certified_at": "2026-07-18T22:03:13Z", - "expected_text": "compat-ok", - "full_text": "compat-ok", - "llm_request_count": 1 + "certification_status": "pending", + "previous_certification": { + "status": "certified", + "client_commit": "78b6f9c9e9c7d58b699af1c3c17764acd33de798", + "certified_at": "2026-07-18T22:03:13Z", + "expected_text": "compat-ok", + "full_text": "compat-ok", + "llm_request_count": 1 + } }, { "id": "upstream-1.18.3", @@ -39,12 +52,15 @@ "consumers": ["ajent-rails"], "profiles": ["ruby-rest-sse"], "required_consumer_profiles": ["rails-persisted-turn", "plugin-ledger"], - "certification_status": "certified", - "certified_client_commit": "78b6f9c9e9c7d58b699af1c3c17764acd33de798", - "certified_at": "2026-07-18T22:03:14Z", - "expected_text": "compat-ok", - "full_text": "compat-ok", - "llm_request_count": 1 + "certification_status": "pending", + "previous_certification": { + "status": "certified", + "client_commit": "78b6f9c9e9c7d58b699af1c3c17764acd33de798", + "certified_at": "2026-07-18T22:03:14Z", + "expected_text": "compat-ok", + "full_text": "compat-ok", + "llm_request_count": 1 + } } ], "host_canary": [ @@ -58,12 +74,15 @@ "reported_version": "1.18.3", "upstream_base": "ghcr.io/anomalyco/opencode@sha256:c2d5d6398df72aac85cb1bdc8f900c71a9b75a33fb7c0a76dc1484e4b126e41e", "profiles": ["rails-persisted-turn"], - "certification_status": "candidate-certified", - "certified_client_commit": "78b6f9c9e9c7d58b699af1c3c17764acd33de798", - "certified_rails_commit": "2a391ccad1d098e4bb51eb19b6ca52adaa79e5cb", - "certified_at": "2026-07-18T21:58:00Z", - "persisted_final_text": "compat-ok", - "model_request_count": 1 + "certification_status": "pending", + "previous_certification": { + "status": "candidate-certified", + "client_commit": "78b6f9c9e9c7d58b699af1c3c17764acd33de798", + "rails_commit": "2a391ccad1d098e4bb51eb19b6ca52adaa79e5cb", + "certified_at": "2026-07-18T21:58:00Z", + "persisted_final_text": "compat-ok", + "model_request_count": 1 + } }, { "id": "mushu-permission-shortcuts-alpha7-canary", @@ -75,11 +94,14 @@ "source_commit": "99d6328b18ff8a340a928449a2aa8bc184a063f9", "reported_version": "0.0.0-permission-shortcuts-202606300721", "profiles": ["ruby-rest-sse"], - "certification_status": "candidate-certified", - "certified_client_commit": "78b6f9c9e9c7d58b699af1c3c17764acd33de798", - "certified_at": "2026-07-18T21:53:00Z", - "persisted_final_text": "compat-ok", - "model_request_count": 1 + "certification_status": "pending", + "previous_certification": { + "status": "candidate-certified", + "client_commit": "78b6f9c9e9c7d58b699af1c3c17764acd33de798", + "certified_at": "2026-07-18T21:53:00Z", + "persisted_final_text": "compat-ok", + "model_request_count": 1 + } } ] } diff --git a/ruby/lockstep_client_contract.rb b/ruby/lockstep_client_contract.rb index 92f0176..4ce840b 100644 --- a/ruby/lockstep_client_contract.rb +++ b/ruby/lockstep_client_contract.rb @@ -8,18 +8,30 @@ require "rubygems" module OpenCodeCompat class LockstepClientContract class ContractError < StandardError; end + class GitCommandError < ContractError + attr_reader :exitstatus + + def initialize(message, exitstatus:) + @exitstatus = exitstatus + super(message) + end + end CONTRACT_NAME = "opencode-client-lockstep" + OPENCODE_RUBY_GIT_URI = "https://github.com/ajaynomics/opencode-ruby.git" + PUBLICATION_PROVENANCE = { + "published" => "annotated-tag", + "unpublished" => "commit" + }.freeze REQUIRED_ENV = %w[ + OPENCODE_CLIENT_PUBLICATION_STATE OPENCODE_RUBY_PATH OPENCODE_RUBY_COMMIT - OPENCODE_RUBY_TAG - OPENCODE_RUBY_TAG_OBJECT + OPENCODE_RUBY_PROVENANCE_KIND OPENCODE_RUBY_VERSION OPENCODE_RAILS_PATH OPENCODE_RAILS_COMMIT - OPENCODE_RAILS_TAG - OPENCODE_RAILS_TAG_OBJECT + OPENCODE_RAILS_PROVENANCE_KIND OPENCODE_RAILS_VERSION ].freeze @@ -87,22 +99,25 @@ module OpenCodeCompat rails_commit = exact_commit("OPENCODE_RAILS_COMMIT") expected_ruby_version = exact_version("OPENCODE_RUBY_VERSION") expected_rails_version = exact_version("OPENCODE_RAILS_VERSION") + publication_state = exact_publication_state assert_equal!(ruby_commit, checkout_head(ruby_path, "opencode-ruby"), "opencode-ruby checkout commit") assert_equal!(rails_commit, checkout_head(rails_path, "opencode-rails"), "opencode-rails checkout commit") - ruby_tag_provenance = annotated_tag_provenance( + ruby_source_provenance = source_provenance( path: ruby_path, label: "opencode-ruby", - tag_name: "OPENCODE_RUBY_TAG", - tag_object: "OPENCODE_RUBY_TAG_OBJECT", - expected_commit: ruby_commit + prefix: "OPENCODE_RUBY", + publication_state: publication_state, + expected_commit: ruby_commit, + expected_version: expected_ruby_version ) - rails_tag_provenance = annotated_tag_provenance( + rails_source_provenance = source_provenance( path: rails_path, label: "opencode-rails", - tag_name: "OPENCODE_RAILS_TAG", - tag_object: "OPENCODE_RAILS_TAG_OBJECT", - expected_commit: rails_commit + prefix: "OPENCODE_RAILS", + publication_state: publication_state, + expected_commit: rails_commit, + expected_version: expected_rails_version ) assert_equal!(expected_ruby_version, @ruby_version, "loaded Opencode::VERSION") assert_equal!(expected_rails_version, @rails_version, "loaded Opencode::RAILS_VERSION") @@ -117,36 +132,45 @@ module OpenCodeCompat dependency = exact_runtime_dependency!(rails_spec, expected_ruby_version) bundle_spec = bundled_ruby_spec! source = bundle_spec.source - unless source && source.respond_to?(:revision) && source.class.name.match?(/(?:\A|::)Git\z/) + unless source && source.respond_to?(:revision) && source.respond_to?(:ref) && source.respond_to?(:uri) && + source.class.name.match?(/(?:\A|::)Git\z/) raise ContractError, "Bundler opencode-ruby source must be a Git source" end bundler_revision = source.revision.to_s + bundler_ref = source.ref.to_s + bundler_uri = source.uri.to_s assert_exact_commit!(bundler_revision, "Bundler opencode-ruby revision") + assert_exact_commit!(bundler_ref, "Bundler opencode-ruby ref") assert_equal!(ruby_commit, bundler_revision, "Bundler opencode-ruby revision") + assert_equal!(ruby_commit, bundler_ref, "Bundler opencode-ruby ref") + assert_equal!(OPENCODE_RUBY_GIT_URI, bundler_uri, "Bundler opencode-ruby URI") { "contract" => CONTRACT_NAME, + "publication_state" => publication_state, "opencode_rails" => { "checkout_commit" => rails_commit, "gem_version" => rails_spec.version.to_s, "loaded_version" => @rails_version, - "tag_provenance" => rails_tag_provenance, + "source_provenance" => rails_source_provenance, "runtime_dependency" => { "name" => dependency.name, "requirement" => dependency.requirement.to_s } }, "opencode_ruby" => { + "bundler_git_ref" => bundler_ref, "bundler_git_revision" => bundler_revision, + "bundler_git_uri" => bundler_uri, "bundler_source" => source.class.name, "checkout_commit" => ruby_commit, "gem_version" => ruby_spec.version.to_s, "loaded_version" => @ruby_version, - "tag_provenance" => ruby_tag_provenance + "source_provenance" => ruby_source_provenance }, "ruby_runtime_version" => @runtime_ruby_version, - "schema_version" => 1, + "schema_version" => 2, "status" => "pass", "workflow" => { "head_sha" => @env["OPENCODE_COMPAT_HEAD_SHA"], @@ -194,13 +218,24 @@ module OpenCodeCompat value end + def exact_publication_state + value = @env.fetch("OPENCODE_CLIENT_PUBLICATION_STATE") + return value if PUBLICATION_PROVENANCE.key?(value) + + raise ContractError, + "OPENCODE_CLIENT_PUBLICATION_STATE must be one of #{PUBLICATION_PROVENANCE.keys.join(', ')}, got #{value.inspect}" + end + def exact_tag(name) - value = @env.fetch(name) + exact_tag_value(@env.fetch(name), name) + end + + def exact_tag_value(value, label) if value.match?(/\A[A-Za-z0-9][A-Za-z0-9._-]*\z/) && !value.include?("..") && !value.end_with?(".") return value end - raise ContractError, "#{name} must be a safe exact Git tag name, got #{value.inspect}" + raise ContractError, "#{label} must be a safe exact Git tag name, got #{value.inspect}" end def exact_tag_object(name) @@ -238,6 +273,55 @@ module OpenCodeCompat raise ContractError, "could not resolve #{label} checkout HEAD: #{error.message}" end + def source_provenance(path:, label:, prefix:, publication_state:, expected_commit:, expected_version:) + kind_name = "#{prefix}_PROVENANCE_KIND" + kind = @env.fetch(kind_name) + expected_kind = PUBLICATION_PROVENANCE.fetch(publication_state) + assert_equal!(expected_kind, kind, "#{label} provenance kind for #{publication_state} candidate") + + case kind + when "commit" + ensure_unpublished_tag_absent!(path, label, expected_version) + supplied_tag_fields = %W[#{prefix}_TAG #{prefix}_TAG_OBJECT].select do |name| + !@env[name].to_s.empty? + end + unless supplied_tag_fields.empty? + raise ContractError, + "#{label} unpublished commit provenance must not supply #{supplied_tag_fields.join(', ')}" + end + + {"commit" => expected_commit, "kind" => "commit"} + when "annotated-tag" + annotated_tag_provenance( + path: path, + label: label, + tag_name: "#{prefix}_TAG", + tag_object: "#{prefix}_TAG_OBJECT", + expected_commit: expected_commit + ).merge("kind" => "annotated-tag") + else + raise ContractError, "#{kind_name} has unsupported value #{kind.inspect}" + end + end + + def ensure_unpublished_tag_absent!(path, label, expected_version) + tag = exact_tag_value("v#{expected_version}", "#{label} expected release tag") + tag_ref = "refs/tags/#{tag}" + resolved = begin + @git_resolver.call(path, "rev-parse", "--verify", "--quiet", tag_ref).to_s.strip + rescue GitCommandError => error + raise ContractError, "could not check #{label} release tag: #{error.message}" unless error.exitstatus == 1 + + nil + rescue KeyError + nil + end + return if resolved.nil? || resolved.empty? + + raise ContractError, + "#{label} release tag #{tag} already exists; publication_state must be published with annotated-tag provenance" + end + def annotated_tag_provenance(path:, label:, tag_name:, tag_object:, expected_commit:) tag = exact_tag(tag_name) object = exact_tag_object(tag_object) @@ -277,7 +361,7 @@ module OpenCodeCompat detail = stderr.strip detail = "git exited #{status.exitstatus}" if detail.empty? - raise ContractError, detail + raise GitCommandError.new(detail, exitstatus: status.exitstatus) end def loaded_spec!(name) @@ -324,7 +408,7 @@ if $PROGRAM_NAME == __FILE__ failure = OpenCodeCompat::LockstepClientContract.canonical_json( "contract" => OpenCodeCompat::LockstepClientContract::CONTRACT_NAME, "error" => "#{error.class}: #{error.message}", - "schema_version" => 1, + "schema_version" => 2, "status" => "fail" ) if (evidence_path = ENV["OPENCODE_COMPAT_EVIDENCE_PATH"]) && !evidence_path.empty? diff --git a/scripts/client_candidate_outputs.rb b/scripts/client_candidate_outputs.rb new file mode 100644 index 0000000..36b1f06 --- /dev/null +++ b/scripts/client_candidate_outputs.rb @@ -0,0 +1,9 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require_relative "../lib/opencode_compat/client_candidate" + +root = File.expand_path("..", __dir__) +path = ARGV.fetch(0, File.join(root, "manifests/client-candidate.json")) +candidate = OpenCodeCompat::ClientCandidate.load(path) +candidate.github_outputs.each { |key, value| puts "#{key}=#{value}" } diff --git a/scripts/matrix_json.rb b/scripts/matrix_json.rb index c4f6ba6..0c32f43 100755 --- a/scripts/matrix_json.rb +++ b/scripts/matrix_json.rb @@ -1,9 +1,27 @@ # frozen_string_literal: true require "json" +require_relative "../lib/opencode_compat/client_candidate" root = File.expand_path("..", __dir__) manifest = JSON.parse(File.read(File.join(root, "manifests/image-matrix.json"))) +candidate = OpenCodeCompat::ClientCandidate.load(File.join(root, "manifests/client-candidate.json")) +candidate.verify! +clients = candidate.document.fetch("clients") +expected_candidate = { + "release_train" => candidate.document.fetch("release_train"), + "publication_state" => candidate.document.fetch("publication_state"), + "opencode_ruby_commit" => clients.dig("opencode-ruby", "ref"), + "opencode_rails_commit" => clients.dig("opencode-rails", "ref") +} +matrix_candidate = manifest.fetch("client_candidate") +unless expected_candidate.all? { |key, value| matrix_candidate[key] == value } + abort "image matrix client_candidate must equal the exact client candidate" +end +unless %w[pending certified].include?(matrix_candidate.fetch("certification_status")) + abort "image matrix client_candidate certification_status must be pending or certified" +end + matrix = manifest.fetch("public_ci").map do |target| id = target.fetch("id") profiles = target.fetch("profiles") diff --git a/test/client_candidate_test.rb b/test/client_candidate_test.rb new file mode 100644 index 0000000..50bc7b3 --- /dev/null +++ b/test/client_candidate_test.rb @@ -0,0 +1,114 @@ +# frozen_string_literal: true + +require "fileutils" +require "json" +require "minitest/autorun" +require "open3" +require "rbconfig" +require "tmpdir" +require_relative "../lib/opencode_compat/client_candidate" + +class ClientCandidateTest < Minitest::Test + ROOT = File.expand_path("..", __dir__) + RUBY_COMMIT = "9277646a4bb2cf25a8384ffc140b154f49ea5766" + RAILS_COMMIT = "a9add2a7c1dd3eb978aa8b4ebf9ef7e111d1057f" + + def setup + @document = JSON.parse(File.binread(File.join(ROOT, "manifests/client-candidate.json"))) + end + + def test_accepts_exact_unpublished_commit_candidate_and_emits_safe_outputs + outputs = OpenCodeCompat::ClientCandidate.new(@document).github_outputs + + assert_equal "unpublished", outputs.fetch("publication_state") + assert_equal "commit", outputs.fetch("ruby_provenance_kind") + assert_equal RUBY_COMMIT, outputs.fetch("ruby_ref") + assert_equal "", outputs.fetch("ruby_tag") + assert_equal "commit", outputs.fetch("rails_provenance_kind") + assert_equal RAILS_COMMIT, outputs.fetch("rails_ref") + assert_equal "", outputs.fetch("rails_tag_object") + end + + def test_unpublished_candidate_rejects_mixed_or_mismatched_provenance + @document.dig("clients", "opencode-ruby", "provenance")["tag"] = "v0.0.1.alpha8" + error = assert_raises(OpenCodeCompat::ClientCandidate::ValidationError) { candidate.verify! } + assert_match(/commit provenance keys/, error.message) + + @document.dig("clients", "opencode-ruby", "provenance").delete("tag") + @document.dig("clients", "opencode-ruby", "provenance")["commit"] = "f" * 40 + error = assert_raises(OpenCodeCompat::ClientCandidate::ValidationError) { candidate.verify! } + assert_match(/provenance commit/, error.message) + end + + def test_published_candidate_requires_annotated_tag_provenance_for_both_clients + publish_document! + + candidate.verify! + + @document.dig("clients", "opencode-ruby")["provenance"] = { + "kind" => "commit", + "commit" => RUBY_COMMIT + } + error = assert_raises(OpenCodeCompat::ClientCandidate::ValidationError) { candidate.verify! } + assert_match(/provenance kind must equal "annotated-tag"/, error.message) + end + + def test_published_candidate_rejects_wrong_tag_object_shape_or_peel + publish_document! + ruby_provenance = @document.dig("clients", "opencode-ruby", "provenance") + ruby_provenance["annotated_tag_object"] = "short" + error = assert_raises(OpenCodeCompat::ClientCandidate::ValidationError) { candidate.verify! } + assert_match(/annotated tag object must be a full lowercase 40-character/, error.message) + + ruby_provenance["annotated_tag_object"] = "3" * 40 + ruby_provenance["peeled_commit"] = "f" * 40 + error = assert_raises(OpenCodeCompat::ClientCandidate::ValidationError) { candidate.verify! } + assert_match(/peeled commit/, error.message) + end + + def test_rejects_non_lockstep_runtime_dependency + dependency = @document.dig("clients", "opencode-rails", "runtime_dependencies", "opencode-ruby") + dependency["requirement"] = "~> 0.0.1.alpha8" + error = assert_raises(OpenCodeCompat::ClientCandidate::ValidationError) { candidate.verify! } + assert_match(/runtime dependency requirement/, error.message) + + dependency["requirement"] = "= 0.0.1.alpha8" + dependency["ref"] = "f" * 40 + error = assert_raises(OpenCodeCompat::ClientCandidate::ValidationError) { candidate.verify! } + assert_match(/runtime dependency ref/, error.message) + end + + def test_output_script_validates_the_repository_manifest + output, error, status = Open3.capture3( + RbConfig.ruby, + File.join(ROOT, "scripts/client_candidate_outputs.rb") + ) + + assert status.success?, error + assert_includes output.lines, "ruby_ref=#{RUBY_COMMIT}\n" + assert_includes output.lines, "rails_ref=#{RAILS_COMMIT}\n" + assert_includes output.lines, "publication_state=unpublished\n" + end + + private + + def candidate + OpenCodeCompat::ClientCandidate.new(@document) + end + + def publish_document! + @document["publication_state"] = "published" + { + "opencode-ruby" => "3" * 40, + "opencode-rails" => "4" * 40 + }.each do |name, tag_object| + client = @document.fetch("clients").fetch(name) + client["provenance"] = { + "kind" => "annotated-tag", + "tag" => "v#{client.fetch('version')}", + "annotated_tag_object" => tag_object, + "peeled_commit" => client.fetch("ref") + } + end + end +end diff --git a/test/lockstep_client_contract_test.rb b/test/lockstep_client_contract_test.rb index a6a29a7..bad1e8b 100644 --- a/test/lockstep_client_contract_test.rb +++ b/test/lockstep_client_contract_test.rb @@ -8,10 +8,12 @@ require_relative "../ruby/lockstep_client_contract" module LockstepClientContractTestSources class Git - attr_reader :revision + attr_reader :ref, :revision, :uri - def initialize(revision) + def initialize(revision, ref: revision, uri: OpenCodeCompat::LockstepClientContract::OPENCODE_RUBY_GIT_URI) @revision = revision + @ref = ref + @uri = uri end end @@ -36,13 +38,16 @@ class LockstepClientContractTest < Minitest::Test Dir.mkdir(@ruby_path) Dir.mkdir(@rails_path) @env = { + "OPENCODE_CLIENT_PUBLICATION_STATE" => "published", "OPENCODE_RUBY_PATH" => @ruby_path, "OPENCODE_RUBY_COMMIT" => RUBY_COMMIT, + "OPENCODE_RUBY_PROVENANCE_KIND" => "annotated-tag", "OPENCODE_RUBY_TAG" => RUBY_TAG, "OPENCODE_RUBY_TAG_OBJECT" => RUBY_TAG_OBJECT, "OPENCODE_RUBY_VERSION" => VERSION, "OPENCODE_RAILS_PATH" => @rails_path, "OPENCODE_RAILS_COMMIT" => RAILS_COMMIT, + "OPENCODE_RAILS_PROVENANCE_KIND" => "annotated-tag", "OPENCODE_RAILS_TAG" => RAILS_TAG, "OPENCODE_RAILS_TAG_OBJECT" => RAILS_TAG_OBJECT, "OPENCODE_RAILS_VERSION" => VERSION @@ -79,20 +84,90 @@ class LockstepClientContractTest < Minitest::Test assert_equal "#{expected_json}\n", output.string assert_equal "#{expected_json}\n", File.binread(evidence_path) assert_equal "pass", document.fetch("status") + assert_equal 2, document.fetch("schema_version") + assert_equal "published", document.fetch("publication_state") assert_equal RUBY_COMMIT, document.dig("opencode_ruby", "checkout_commit") + assert_equal RUBY_COMMIT, document.dig("opencode_ruby", "bundler_git_ref") assert_equal RUBY_COMMIT, document.dig("opencode_ruby", "bundler_git_revision") - assert_equal RUBY_TAG_OBJECT, document.dig("opencode_ruby", "tag_provenance", "annotated_tag_object") - assert_equal RUBY_COMMIT, document.dig("opencode_ruby", "tag_provenance", "peeled_commit") - assert_equal RUBY_TAG, document.dig("opencode_ruby", "tag_provenance", "tag") + assert_equal OpenCodeCompat::LockstepClientContract::OPENCODE_RUBY_GIT_URI, + document.dig("opencode_ruby", "bundler_git_uri") + assert_equal "annotated-tag", document.dig("opencode_ruby", "source_provenance", "kind") + assert_equal RUBY_TAG_OBJECT, document.dig("opencode_ruby", "source_provenance", "annotated_tag_object") + assert_equal RUBY_COMMIT, document.dig("opencode_ruby", "source_provenance", "peeled_commit") + assert_equal RUBY_TAG, document.dig("opencode_ruby", "source_provenance", "tag") assert_equal RAILS_COMMIT, document.dig("opencode_rails", "checkout_commit") - assert_equal RAILS_TAG_OBJECT, document.dig("opencode_rails", "tag_provenance", "annotated_tag_object") - assert_equal RAILS_COMMIT, document.dig("opencode_rails", "tag_provenance", "peeled_commit") + assert_equal RAILS_TAG_OBJECT, document.dig("opencode_rails", "source_provenance", "annotated_tag_object") + assert_equal RAILS_COMMIT, document.dig("opencode_rails", "source_provenance", "peeled_commit") assert_equal "= #{VERSION}", document.dig("opencode_rails", "runtime_dependency", "requirement") assert_equal RUBY_VERSION, document.fetch("ruby_runtime_version") assert_nil document.dig("workflow", "run_id") assert_equal expected_json, JSON.generate(JSON.parse(expected_json).sort.to_h) end + def test_accepts_unpublished_commit_only_provenance_without_tag_lookup + use_commit_only_provenance! + + document = contract.verify + + assert_equal "unpublished", document.fetch("publication_state") + assert_equal({"commit" => RUBY_COMMIT, "kind" => "commit"}, + document.dig("opencode_ruby", "source_provenance")) + assert_equal({"commit" => RAILS_COMMIT, "kind" => "commit"}, + document.dig("opencode_rails", "source_provenance")) + end + + def test_publication_state_cannot_weaken_published_tag_binding + @env["OPENCODE_RUBY_PROVENANCE_KIND"] = "commit" + error = assert_raises(OpenCodeCompat::LockstepClientContract::ContractError) { contract.verify } + assert_match(/published candidate/, error.message) + + use_commit_only_provenance! + @env["OPENCODE_RAILS_PROVENANCE_KIND"] = "annotated-tag" + error = assert_raises(OpenCodeCompat::LockstepClientContract::ContractError) { contract.verify } + assert_match(/unpublished candidate/, error.message) + end + + def test_commit_only_provenance_rejects_residual_tag_coordinates + use_commit_only_provenance! + @env["OPENCODE_RUBY_TAG"] = RUBY_TAG + + error = assert_raises(OpenCodeCompat::LockstepClientContract::ContractError) { contract.verify } + + assert_match(/must not supply OPENCODE_RUBY_TAG/, error.message) + end + + def test_commit_only_provenance_stops_when_the_release_tag_appears + use_commit_only_provenance! + set_git_result( + @ruby_path, + ["rev-parse", "--verify", "--quiet", "refs/tags/#{RUBY_TAG}"], + RUBY_TAG_OBJECT + ) + + error = assert_raises(OpenCodeCompat::LockstepClientContract::ContractError) { contract.verify } + + assert_match(/release tag #{Regexp.escape(RUBY_TAG)} already exists/, error.message) + assert_match(/publication_state must be published/, error.message) + end + + def test_commit_only_provenance_does_not_treat_git_errors_as_an_absent_tag + use_commit_only_provenance! + @git_results[[@ruby_path, ["rev-parse", "--verify", "--quiet", "refs/tags/#{RUBY_TAG}"]]] = + OpenCodeCompat::LockstepClientContract::GitCommandError.new("corrupt ref", exitstatus: 128) + + resolver = lambda do |path, *arguments| + result = resolve_git(path, *arguments) + raise result if result.is_a?(Exception) + + result + end + error = assert_raises(OpenCodeCompat::LockstepClientContract::ContractError) do + contract(git_resolver: resolver).verify + end + + assert_match(/could not check opencode-ruby release tag: corrupt ref/, error.message) + end + def test_rejects_checkout_commit_mismatch_for_either_client { @ruby_path => "opencode-ruby checkout commit", @@ -223,6 +298,17 @@ class LockstepClientContractTest < Minitest::Test @bundle_ruby_spec.source = LockstepClientContractTestSources::Git.new("f" * 40) error = assert_raises(OpenCodeCompat::LockstepClientContract::ContractError) { contract.verify } assert_match(/Bundler opencode-ruby revision/, error.message) + + @bundle_ruby_spec.source = LockstepClientContractTestSources::Git.new(RUBY_COMMIT, ref: "main") + error = assert_raises(OpenCodeCompat::LockstepClientContract::ContractError) { contract.verify } + assert_match(/Bundler opencode-ruby ref/, error.message) + + @bundle_ruby_spec.source = LockstepClientContractTestSources::Git.new( + RUBY_COMMIT, + uri: "https://example.test/opencode-ruby.git" + ) + error = assert_raises(OpenCodeCompat::LockstepClientContract::ContractError) { contract.verify } + assert_match(/Bundler opencode-ruby URI/, error.message) end def test_rejects_loaded_rails_gem_from_another_checkout @@ -278,6 +364,7 @@ class LockstepClientContractTest < Minitest::Test assert_equal 1, status.exitstatus failure = JSON.parse(File.binread(evidence_path)) assert_equal "opencode-client-lockstep", failure.fetch("contract") + assert_equal 2, failure.fetch("schema_version") assert_equal "fail", failure.fetch("status") assert_match(/missing required environment/, failure.fetch("error")) assert_equal failure, JSON.parse(stderr) @@ -285,10 +372,10 @@ class LockstepClientContractTest < Minitest::Test private - def contract(ruby_version: VERSION, rails_version: VERSION) + def contract(ruby_version: VERSION, rails_version: VERSION, git_resolver: method(:resolve_git)) OpenCodeCompat::LockstepClientContract.new( env: @env, - git_resolver: method(:resolve_git), + git_resolver: git_resolver, loaded_specs: { "opencode-ruby" => @ruby_spec, "opencode-rails" => @rails_spec @@ -315,4 +402,14 @@ class LockstepClientContractTest < Minitest::Test def set_git_result(path, arguments, result) @git_results[[path, arguments]] = result end + + def use_commit_only_provenance! + @env["OPENCODE_CLIENT_PUBLICATION_STATE"] = "unpublished" + @env["OPENCODE_RUBY_PROVENANCE_KIND"] = "commit" + @env["OPENCODE_RAILS_PROVENANCE_KIND"] = "commit" + @env.delete("OPENCODE_RUBY_TAG") + @env.delete("OPENCODE_RUBY_TAG_OBJECT") + @env.delete("OPENCODE_RAILS_TAG") + @env.delete("OPENCODE_RAILS_TAG_OBJECT") + end end diff --git a/test/matrix_json_test.rb b/test/matrix_json_test.rb index 5967e3c..7fb22ad 100644 --- a/test/matrix_json_test.rb +++ b/test/matrix_json_test.rb @@ -12,9 +12,18 @@ class MatrixJsonTest < Minitest::Test def setup @tmp = Dir.mktmpdir("opencode-matrix-json") + FileUtils.mkdir_p(File.join(@tmp, "lib/opencode_compat")) FileUtils.mkdir_p(File.join(@tmp, "scripts")) FileUtils.mkdir_p(File.join(@tmp, "manifests")) FileUtils.cp(File.join(ROOT, "scripts/matrix_json.rb"), File.join(@tmp, "scripts")) + FileUtils.cp( + File.join(ROOT, "lib/opencode_compat/client_candidate.rb"), + File.join(@tmp, "lib/opencode_compat") + ) + FileUtils.cp( + File.join(ROOT, "manifests/client-candidate.json"), + File.join(@tmp, "manifests") + ) @manifest = JSON.parse(File.read(File.join(ROOT, "manifests/image-matrix.json"))) end @@ -50,6 +59,32 @@ class MatrixJsonTest < Minitest::Test assert_match(/certification_scope must be shared-client-contract-only/, error) end + def test_rejects_a_matrix_bound_to_another_client_candidate + @manifest.fetch("client_candidate")["opencode_ruby_commit"] = "f" * 40 + + _output, error, status = run_matrix(@manifest) + + refute status.success? + assert_match(/must equal the exact client candidate/, error) + end + + def test_allows_reviewed_candidate_certification_without_changing_coordinates + @manifest.fetch("client_candidate")["certification_status"] = "certified" + + _output, error, status = run_matrix(@manifest) + + assert status.success?, error + end + + def test_rejects_unknown_candidate_certification_status + @manifest.fetch("client_candidate")["certification_status"] = "deployed" + + _output, error, status = run_matrix(@manifest) + + refute status.success? + assert_match(/certification_status must be pending or certified/, error) + end + private def run_matrix(manifest) diff --git a/test/repository_test.rb b/test/repository_test.rb index 3af03c2..7c47537 100644 --- a/test/repository_test.rb +++ b/test/repository_test.rb @@ -2,6 +2,7 @@ require "json" require "minitest/autorun" +require_relative "../lib/opencode_compat/client_candidate" require_relative "../lib/opencode_compat/runtime_tuple_promoter" class RepositoryTest < Minitest::Test @@ -37,7 +38,9 @@ class RepositoryTest < Minitest::Test end def test_public_matrix_uses_only_immutable_oci_digests - targets = json("manifests/image-matrix.json").fetch("public_ci") + manifest = json("manifests/image-matrix.json") + assert_equal 2, manifest.fetch("schema_version") + targets = manifest.fetch("public_ci") refute_empty targets targets.each do |target| assert_match %r{\Aghcr\.io/anomalyco/opencode@sha256:[0-9a-f]{64}\z}, target.fetch("image") @@ -45,38 +48,87 @@ class RepositoryTest < Minitest::Test assert_equal ["ruby-rest-sse"], target.fetch("profiles") assert_equal "shared-client-contract-only", target.fetch("certification_scope", "shared-client-contract-only") - next unless target.fetch("certification_status") == "certified" + assert_equal "pending", target.fetch("certification_status") + previous = target["previous_certification"] + next unless previous - assert_match(/\A[0-9a-f]{40}\z/, target.fetch("certified_client_commit")) - assert_equal target.fetch("expected_text"), target.fetch("full_text") - assert_equal 1, target.fetch("llm_request_count") + assert_equal "certified", previous.fetch("status") + assert_match(/\A[0-9a-f]{40}\z/, previous.fetch("client_commit")) + assert_equal previous.fetch("expected_text"), previous.fetch("full_text") + assert_equal 1, previous.fetch("llm_request_count") end end - def test_candidate_client_train_is_lockstep_and_bound_to_annotated_tags + def test_candidate_client_train_is_lockstep_and_bound_to_unpublished_commits candidate = json("manifests/client-candidate.json") + OpenCodeCompat::ClientCandidate.new(candidate).verify! clients = candidate.fetch("clients") release_train = candidate.fetch("release_train") - assert_equal 2, candidate.fetch("schema_version") + assert_equal 3, candidate.fetch("schema_version") assert_equal "candidate", candidate.fetch("status") + assert_equal "unpublished", candidate.fetch("publication_state") + assert_equal "0.0.1.alpha8", release_train assert_equal %w[opencode-rails opencode-ruby], clients.keys.sort clients.each do |name, client| - provenance = client.fetch("tag_provenance") + provenance = client.fetch("provenance") assert_equal "ajaynomics/#{name}", client.fetch("repository") assert_equal release_train, client.fetch("version") assert_match(/\A[0-9a-f]{40}\z/, client.fetch("ref")) - assert_equal "v#{release_train}", provenance.fetch("tag") - assert_match(/\A[0-9a-f]{40}\z/, provenance.fetch("annotated_tag_object")) - assert_equal client.fetch("ref"), provenance.fetch("peeled_commit") + assert_equal "commit", provenance.fetch("kind") + assert_equal client.fetch("ref"), provenance.fetch("commit") + refute provenance.key?("tag") end + assert_equal "9277646a4bb2cf25a8384ffc140b154f49ea5766", clients.dig("opencode-ruby", "ref") + assert_equal "a9add2a7c1dd3eb978aa8b4ebf9ef7e111d1057f", clients.dig("opencode-rails", "ref") dependency = clients.fetch("opencode-rails").fetch("runtime_dependencies").fetch("opencode-ruby") assert_equal "= #{release_train}", dependency.fetch("requirement") assert_equal clients.fetch("opencode-ruby").fetch("ref"), dependency.fetch("ref") end + def test_image_matrix_is_pending_and_bound_to_the_exact_client_candidate + candidate = json("manifests/client-candidate.json") + clients = candidate.fetch("clients") + matrix_candidate = json("manifests/image-matrix.json").fetch("client_candidate") + + assert_equal "pending", matrix_candidate.fetch("certification_status") + assert_equal candidate.fetch("release_train"), matrix_candidate.fetch("release_train") + assert_equal candidate.fetch("publication_state"), matrix_candidate.fetch("publication_state") + assert_equal clients.dig("opencode-ruby", "ref"), matrix_candidate.fetch("opencode_ruby_commit") + assert_equal clients.dig("opencode-rails", "ref"), matrix_candidate.fetch("opencode_rails_commit") + + json("manifests/image-matrix.json").fetch("host_canary").each do |target| + assert_equal "pending", target.fetch("certification_status") + previous = target.fetch("previous_certification") + assert_includes %w[candidate-certified certified], previous.fetch("status") + assert_match(/\A[0-9a-f]{40}\z/, previous.fetch("client_commit")) + end + end + + def test_alpha8_local_image_evidence_is_review_input_not_app_certification + evidence = json("evidence/2026-07-20-opencode-alpha8-pre-release-shared-client.json") + candidate = json("manifests/client-candidate.json").fetch("clients") + + assert_equal "not-certified", evidence.fetch("certification_status") + assert_equal "shared-client-contract-only", evidence.fetch("coverage_scope") + assert_equal "unpublished", evidence.fetch("publication_state") + assert_equal candidate.dig("opencode-ruby", "ref"), evidence.dig("clients", "opencode_ruby", "commit") + assert_equal candidate.dig("opencode-rails", "ref"), evidence.dig("clients", "opencode_rails", "commit") + assert_equal true, evidence.dig("clients", "opencode_ruby", "executed") + assert_equal false, evidence.dig("clients", "opencode_rails", "executed_by_image_contract") + assert_equal 5, evidence.fetch("checks").length + evidence.fetch("checks").each do |check| + assert_equal "pass", check.fetch("status") + assert_equal ["ruby-rest-sse"], check.fetch("executed_profiles") + assert_equal check.fetch("expected_text"), check.fetch("full_text") + assert_equal 1, check.fetch("authoritative_assistant_message_count") + assert_equal 1, check.fetch("llm_request_count") + end + assert evidence.fetch("limitations").any? { |entry| entry.include?("remain unverified") } + end + def test_certified_migration_keeps_previous_tuple tuples = json("manifests/runtime-tuples.json") return unless tuples.fetch("migration_state") == "certified" @@ -204,7 +256,11 @@ class RepositoryTest < Minitest::Test assert_includes workflow, "repository: ajaynomics/opencode-ruby" assert_includes workflow, "repository: ajaynomics/opencode-rails" assert_includes workflow, "path: opencode-rails" - assert_includes workflow, "ruby: [\"3.2\", \"3.3\", \"3.4\"]" + assert_includes workflow, "ruby: [\"3.2\", \"3.3\", \"3.4\", \"4.0\"]" + assert_includes workflow, "scripts/client_candidate_outputs.rb" + assert_includes workflow, "OPENCODE_CLIENT_PUBLICATION_STATE" + assert_includes workflow, "OPENCODE_RUBY_PROVENANCE_KIND" + assert_includes workflow, "OPENCODE_RAILS_PROVENANCE_KIND" assert_includes workflow, "ruby/lockstep_client_contract.rb" assert_includes workflow, "OPENCODE_RUBY_TAG_OBJECT" assert_includes workflow, "OPENCODE_RAILS_TAG_OBJECT"