From a640f4497226ebb96cf306e6560f627ee9216c64 Mon Sep 17 00:00:00 2001 From: Ajay Krishnan Date: Sat, 18 Jul 2026 14:14:41 -0700 Subject: [PATCH] Add atomic runtime tuple promotion gate --- .github/workflows/candidate.yml | 2 +- README.md | 1 + docs/certification.md | 57 ++ lib/opencode_compat/runtime_tuple_promoter.rb | 495 ++++++++++++++++++ scripts/promote_runtime_tuple.rb | 104 ++++ test/repository_test.rb | 32 +- test/runtime_tuple_promoter_test.rb | 309 +++++++++++ 7 files changed, 995 insertions(+), 5 deletions(-) create mode 100644 lib/opencode_compat/runtime_tuple_promoter.rb create mode 100755 scripts/promote_runtime_tuple.rb create mode 100644 test/runtime_tuple_promoter_test.rb diff --git a/.github/workflows/candidate.yml b/.github/workflows/candidate.yml index 7061548..af2d981 100644 --- a/.github/workflows/candidate.yml +++ b/.github/workflows/candidate.yml @@ -20,7 +20,7 @@ jobs: - uses: ruby/setup-ruby@v1 with: ruby-version: "3.4" - - run: ruby test/repository_test.rb + - run: ruby test/repository_test.rb && ruby test/runtime_tuple_promoter_test.rb prepare: runs-on: ubuntu-latest diff --git a/README.md b/README.md index d23423e..1f586c3 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,7 @@ Prerequisites are Ruby 3.2+, Python 3, `jq`, and Docker. ```sh ruby test/repository_test.rb +ruby test/runtime_tuple_promoter_test.rb OPENCODE_RUBY_PATH=/data/projects/opencode-ruby-alpha6 \ ruby ruby/opencode_ruby_fixture_contract.rb OPENCODE_RUBY_PATH=/data/projects/opencode-ruby-alpha6 \ diff --git a/docs/certification.md b/docs/certification.md index 99046ae..59a08fd 100644 --- a/docs/certification.md +++ b/docs/certification.md @@ -25,6 +25,63 @@ Promotion is a reviewed manifest change that moves the old `current` tuple to merged and deployed separately. Workflows in this repository have no deploy credentials or deploy steps. +Use the repository promotion command; do not hand-edit the three tuple slots. +It binds evidence to a canonical SHA-256 of the complete tuple, requires full +consumer and gem commits, rejects mutable runtime image coordinates, and writes +the manifest with a same-filesystem atomic rename. The command only changes +`manifests/runtime-tuples.json`; it cannot deploy a consumer. + +First inspect the fingerprints for both the candidate and rollback tuple: + +```sh +ruby scripts/promote_runtime_tuple.rb fingerprint \ + --consumer travelwolf \ + --consumer-commit FULL_40_CHARACTER_CONSUMER_COMMIT +``` + +Commit one or more JSON evidence documents under `evidence/`. Each document +must explicitly contain values matching the promotion: + +```json +{ + "schema_version": 1, + "consumer": "travelwolf", + "profile": "rails-persisted-turn", + "consumer_commit": "FULL_40_CHARACTER_CONSUMER_COMMIT", + "status": "pass", + "certified_at": "2026-07-18T12:00:00Z", + "tuple_sha256": "sha256:FULL_64_CHARACTER_FINGERPRINT" +} +``` + +Preview the exact manifest transition with `--dry-run`, then repeat without it +to write the manifest: + +```sh +ruby scripts/promote_runtime_tuple.rb promote \ + --consumer travelwolf \ + --consumer-commit FULL_40_CHARACTER_CONSUMER_COMMIT \ + --status pass \ + --certified-at 2026-07-18T12:00:00Z \ + --evidence evidence/travelwolf-candidate.json \ + --previous-status pass \ + --previous-certified-at 2026-07-17T12:00:00Z \ + --previous-evidence evidence/travelwolf-rollback.json \ + --dry-run +``` + +The `--previous-*` arguments are required while the old `current` tuple lacks a +valid certification record. Later promotions reuse and revalidate that record. +Both candidate and previous evidence must match the printed tuple fingerprint, +consumer, full consumer commit, timestamp, and `pass` status. A tag may be kept +only in a `tag_provenance` field; `image`, `registry_ref`, and base image fields +must use `image@sha256:...`, while a private local artifact may use an exact +`docker_image_id`. + +The command clears `candidate` after promotion. It sets the repository-wide +`migration_state` to `certified` only after every consumer has certified +`current` and `previous` tuples and no pending candidate. + ## Rollback Rollback restores the whole `previous` tuple. Do not roll back only the gem or diff --git a/lib/opencode_compat/runtime_tuple_promoter.rb b/lib/opencode_compat/runtime_tuple_promoter.rb new file mode 100644 index 0000000..b7813ca --- /dev/null +++ b/lib/opencode_compat/runtime_tuple_promoter.rb @@ -0,0 +1,495 @@ +# frozen_string_literal: true + +require "digest" +require "json" +require "pathname" +require "tempfile" +require "time" + +module OpenCodeCompat + class PromotionError < StandardError; end + + class RuntimeTuplePromoter + FULL_COMMIT = /\A[0-9a-f]{40}\z/ + DIGEST = /\Asha256:[0-9a-f]{64}\z/ + IMMUTABLE_IMAGE = /\A[^@\s]+@sha256:[0-9a-f]{64}\z/ + UTC_TIMESTAMP = /\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z\z/ + CERTIFICATION_STATUS = "pass" + TUPLE_METADATA_KEYS = %w[ + certification + certified_at + compatibility_certified_at + promoted_at + status + ].freeze + EXACT_IMAGE_REFERENCE_KEYS = %w[ + base_image + image + registry_ref + source_image + upstream_base + ].freeze + + def initialize(root:, manifest_path: File.join(root, "manifests/runtime-tuples.json")) + @root = File.realpath(root) + @manifest_path = File.expand_path(manifest_path) + end + + def fingerprints(consumer:, consumer_commit:) + validate_full_commit!(consumer_commit, "consumer commit") + manifest = read_manifest + consumer_entry = fetch_consumer!(manifest, consumer) + profile = fetch_profile!(consumer_entry, consumer) + candidate = prepare_candidate!(consumer_entry, consumer_commit) + current = consumer_entry.fetch("current") do + raise PromotionError, "#{consumer} has no current tuple to preserve as previous" + end + validate_tuple!(current, "#{consumer} current") + + { + "consumer" => consumer, + "profile" => profile, + "candidate_tuple_sha256" => tuple_fingerprint(candidate, consumer: consumer, profile: profile), + "current_tuple_sha256" => tuple_fingerprint(current, consumer: consumer, profile: profile) + } + end + + def promote(consumer:, consumer_commit:, certification:, previous_certification: nil, dry_run: false) + validate_full_commit!(consumer_commit, "consumer commit") + + if dry_run + manifest = read_manifest + return promote_manifest( + manifest, + consumer: consumer, + consumer_commit: consumer_commit, + certification: certification, + previous_certification: previous_certification + ) + end + + with_current_manifest_lock do |manifest| + promoted = promote_manifest( + manifest, + consumer: consumer, + consumer_commit: consumer_commit, + certification: certification, + previous_certification: previous_certification + ) + atomic_write(promoted) + promoted + end + end + + private + + def read_manifest + parse_json(File.read(@manifest_path), @manifest_path) + rescue Errno::ENOENT + raise PromotionError, "runtime tuple manifest does not exist: #{@manifest_path}" + end + + def promote_manifest(manifest, consumer:, consumer_commit:, certification:, previous_certification:) + consumer_entry = fetch_consumer!(manifest, consumer) + profile = fetch_profile!(consumer_entry, consumer) + candidate = prepare_candidate!(consumer_entry, consumer_commit) + current = consumer_entry.fetch("current") do + raise PromotionError, "#{consumer} has no current tuple to preserve as previous" + end + validate_tuple!(current, "#{consumer} current") + + candidate_fingerprint = tuple_fingerprint(candidate, consumer: consumer, profile: profile) + current_fingerprint = tuple_fingerprint(current, consumer: consumer, profile: profile) + if candidate_fingerprint == current_fingerprint + raise PromotionError, "#{consumer} candidate is identical to its current tuple" + end + + certified_candidate = certify_tuple!( + candidate, + consumer: consumer, + profile: profile, + supplied: certification, + expected_fingerprint: candidate_fingerprint, + label: "candidate" + ) + certified_previous = certify_previous!( + current, + consumer: consumer, + profile: profile, + supplied: previous_certification, + expected_fingerprint: current_fingerprint + ) + + consumer_entry["previous"] = certified_previous + consumer_entry["current"] = certified_candidate + consumer_entry["candidate"] = nil + manifest["migration_state"] = all_consumers_certified?(manifest) ? "certified" : "candidate" + manifest + end + + def fetch_consumer!(manifest, consumer) + unless manifest.is_a?(Hash) && manifest["schema_version"] == 1 && manifest["consumers"].is_a?(Hash) + raise PromotionError, "runtime tuple manifest must use schema_version 1 and contain consumers" + end + + manifest.fetch("consumers").fetch(consumer) do + raise PromotionError, "unknown consumer #{consumer.inspect}" + end + end + + def prepare_candidate!(consumer_entry, consumer_commit) + candidate = consumer_entry["candidate"] + unless candidate.is_a?(Hash) + raise PromotionError, "consumer has no candidate tuple to promote" + end + unless candidate["status"] == "compatibility-certified" + raise PromotionError, "candidate status must be compatibility-certified" + end + validate_timestamp!(candidate["certified_at"], "candidate compatibility certification timestamp") + + if candidate.key?("consumer_commit") && candidate["consumer_commit"] != consumer_commit + raise PromotionError, "explicit consumer commit does not match the candidate" + end + + prepared = deep_copy(candidate) + prepared["consumer_commit"] = consumer_commit + validate_tuple!(prepared, "candidate") + prepared + end + + def fetch_profile!(consumer_entry, consumer) + profile = consumer_entry["profile"] + unless profile.is_a?(String) && profile.match?(/\A[a-z0-9][a-z0-9-]*\z/) + raise PromotionError, "#{consumer} must declare a valid compatibility profile" + end + + profile_path = File.join(@root, "profiles", "#{profile}.json") + unless File.file?(profile_path) + raise PromotionError, "#{consumer} compatibility profile does not exist: profiles/#{profile}.json" + end + + profile + end + + def certify_previous!(tuple, consumer:, profile:, supplied:, expected_fingerprint:) + if tuple["status"] == "certified" + validate_recorded_certification!( + tuple, + consumer: consumer, + profile: profile, + expected_fingerprint: expected_fingerprint, + label: "current rollback" + ) + return deep_copy(tuple) + end + + unless supplied + raise PromotionError, + "current tuple is not certified; explicit previous certification evidence, timestamp, and status are required" + end + + certify_tuple!( + tuple, + consumer: consumer, + profile: profile, + supplied: supplied, + expected_fingerprint: expected_fingerprint, + label: "previous" + ) + end + + def certify_tuple!(tuple, consumer:, profile:, supplied:, expected_fingerprint:, label:) + certification = validate_supplied_certification!( + supplied, + consumer: consumer, + profile: profile, + tuple: tuple, + expected_fingerprint: expected_fingerprint, + label: label + ) + + certified = deep_copy(tuple) + if certified.key?("certified_at") + certified["compatibility_certified_at"] = certified.delete("certified_at") + end + certified["status"] = "certified" + certified["certification"] = certification + certified + end + + def validate_supplied_certification!(supplied, consumer:, profile:, tuple:, expected_fingerprint:, label:) + unless supplied.is_a?(Hash) + raise PromotionError, "#{label} certification evidence, timestamp, and status are required" + end + + status = supplied["status"] + certified_at = supplied["certified_at"] + evidence = Array(supplied["evidence"]) + unless status == CERTIFICATION_STATUS + raise PromotionError, "#{label} certification status must be #{CERTIFICATION_STATUS.inspect}" + end + validate_timestamp!(certified_at, "#{label} certification timestamp") + raise PromotionError, "#{label} certification requires at least one evidence file" if evidence.empty? + raise PromotionError, "#{label} certification evidence files must be unique" unless evidence.uniq == evidence + + consumer_commit = tuple.fetch("consumer_commit") + normalized_evidence = evidence.map do |reference| + validate_evidence!( + reference, + consumer: consumer, + profile: profile, + consumer_commit: consumer_commit, + status: status, + certified_at: certified_at, + tuple_fingerprint: expected_fingerprint, + label: label + ) + end + + { + "status" => status, + "certified_at" => certified_at, + "tuple_sha256" => expected_fingerprint, + "evidence" => normalized_evidence + } + end + + def validate_recorded_certification!(tuple, consumer:, profile:, expected_fingerprint:, label:) + certification = tuple["certification"] + unless certification.is_a?(Hash) && certification["tuple_sha256"] == expected_fingerprint + raise PromotionError, "#{label} tuple has invalid or stale certification metadata" + end + + validate_supplied_certification!( + certification, + consumer: consumer, + profile: profile, + tuple: tuple, + expected_fingerprint: expected_fingerprint, + label: label + ) + end + + def validate_evidence!( + reference, + consumer:, + profile:, + consumer_commit:, + status:, + certified_at:, + tuple_fingerprint:, + label: + ) + unless reference.is_a?(String) && !reference.empty? + raise PromotionError, "#{label} evidence references must be non-empty strings" + end + + relative = Pathname.new(reference).cleanpath + if relative.absolute? || relative.each_filename.first != "evidence" + raise PromotionError, "#{label} evidence must be a repository-relative path under evidence/" + end + + full_path = File.join(@root, relative.to_s) + evidence_root = File.realpath(File.join(@root, "evidence")) + real_path = File.realpath(full_path) + unless real_path.start_with?("#{evidence_root}#{File::SEPARATOR}") + raise PromotionError, "#{label} evidence resolves outside evidence/" + end + + document = parse_json(File.read(real_path), relative.to_s) + unless document.is_a?(Hash) && document["schema_version"] == 1 + raise PromotionError, "#{label} evidence #{relative} must use schema_version 1" + end + expected = { + "consumer" => consumer, + "profile" => profile, + "consumer_commit" => consumer_commit, + "status" => status, + "certified_at" => certified_at, + "tuple_sha256" => tuple_fingerprint + } + expected.each do |key, value| + next if document[key] == value + + raise PromotionError, + "#{label} evidence #{relative} has #{key}=#{document[key].inspect}; expected #{value.inspect}" + end + + relative.to_s + rescue Errno::ENOENT + raise PromotionError, "#{label} evidence does not exist: #{reference}" + end + + def validate_tuple!(tuple, label) + raise PromotionError, "#{label} tuple must be an object" unless tuple.is_a?(Hash) + + validate_full_commit!(tuple["consumer_commit"], "#{label} consumer commit") + validate_client!(tuple["opencode_ruby"], "#{label} opencode_ruby", required: true) + validate_client!(tuple["opencode_rails"], "#{label} opencode_rails", required: false) + validate_runtime!(tuple["runtime"], "#{label} runtime") + end + + def validate_client!(client, label, required:) + if client.nil? + raise PromotionError, "#{label} is required" if required + + return + end + unless client.is_a?(Hash) && client["version"].is_a?(String) && !client["version"].empty? + raise PromotionError, "#{label} must include a non-empty version" + end + + validate_full_commit!(client["git_commit"], "#{label} git commit") + end + + def validate_runtime!(runtime, label) + raise PromotionError, "#{label} must be an object" unless runtime.is_a?(Hash) + unless runtime["reported_version"].is_a?(String) && !runtime["reported_version"].empty? + raise PromotionError, "#{label} must include the reported OpenCode server version" + end + + exact_execution_coordinate = false + runtime.each do |key, value| + if key == "docker_image_id" || key.end_with?("_image_id") + unless value.is_a?(String) && value.match?(DIGEST) + raise PromotionError, "#{label} #{key} must be an exact sha256 image ID" + end + exact_execution_coordinate = true if key == "docker_image_id" + elsif image_reference_key?(key) + unless value.is_a?(String) && value.match?(IMMUTABLE_IMAGE) + raise PromotionError, "#{label} #{key} must be an immutable image@sha256 digest, not a tag" + end + exact_execution_coordinate = true if %w[image registry_ref].include?(key) + elsif key == "source_commit" + validate_full_commit!(value, "#{label} source commit") + end + end + + unless exact_execution_coordinate + raise PromotionError, "#{label} must include an immutable image, registry_ref, or docker_image_id" + end + end + + def image_reference_key?(key) + EXACT_IMAGE_REFERENCE_KEYS.include?(key) || + (key.end_with?("_image") && key != "tag_provenance") || + (key.end_with?("_ref") && key != "tag_provenance") + end + + def validate_full_commit!(value, label) + return if value.is_a?(String) && value.match?(FULL_COMMIT) + + raise PromotionError, "#{label} must be a full 40-character lowercase Git commit" + end + + def validate_timestamp!(value, label) + unless value.is_a?(String) && value.match?(UTC_TIMESTAMP) + raise PromotionError, "#{label} must be an explicit RFC 3339 UTC timestamp" + end + + Time.iso8601(value) + rescue ArgumentError + raise PromotionError, "#{label} is not a valid timestamp" + end + + def tuple_fingerprint(tuple, consumer:, profile:) + payload = deep_copy(tuple) + TUPLE_METADATA_KEYS.each { |key| payload.delete(key) } + envelope = { + "consumer" => consumer, + "profile" => profile, + "tuple" => payload + } + "sha256:#{Digest::SHA256.hexdigest(JSON.generate(canonicalize(envelope)))}" + end + + def canonicalize(value) + case value + when Hash + value.keys.sort.to_h { |key| [key, canonicalize(value.fetch(key))] } + when Array + value.map { |item| canonicalize(item) } + else + value + end + end + + def deep_copy(value) + JSON.parse(JSON.generate(value)) + end + + def parse_json(contents, label) + JSON.parse(contents) + rescue JSON::ParserError => e + raise PromotionError, "invalid JSON in #{label}: #{e.message}" + end + + def all_consumers_certified?(manifest) + manifest.fetch("consumers").all? do |consumer, entry| + next false unless entry["candidate"].nil? + + profile = fetch_profile!(entry, consumer) + + %w[current previous].all? do |slot| + tuple = entry[slot] + next false unless tuple.is_a?(Hash) && tuple["status"] == "certified" + + validate_tuple!(tuple, "#{consumer} #{slot}") + validate_recorded_certification!( + tuple, + consumer: consumer, + profile: profile, + expected_fingerprint: tuple_fingerprint(tuple, consumer: consumer, profile: profile), + label: "#{consumer} #{slot}" + ) + true + rescue PromotionError + false + end + end + end + + def with_current_manifest_lock + loop do + retry_with_new_inode = false + result = nil + + File.open(@manifest_path, File::RDONLY) do |locked_file| + locked_file.flock(File::LOCK_EX) + unless File.identical?(locked_file, @manifest_path) + retry_with_new_inode = true + next + end + + result = yield parse_json(locked_file.read, @manifest_path) + end + + return result unless retry_with_new_inode + end + rescue Errno::ENOENT + raise PromotionError, "runtime tuple manifest does not exist: #{@manifest_path}" + end + + def atomic_write(manifest) + directory = File.dirname(@manifest_path) + mode = File.stat(@manifest_path).mode & 0o777 + payload = JSON.pretty_generate(manifest) + "\n" + + Tempfile.create([".runtime-tuples-", ".tmp"], directory) do |temporary| + temporary.chmod(mode) + temporary.write(payload) + temporary.flush + temporary.fsync + temporary.close + File.rename(temporary.path, @manifest_path) + fsync_directory(directory) + end + end + + def fsync_directory(directory) + File.open(directory, File::RDONLY, &:fsync) + rescue Errno::EINVAL, Errno::ENOTSUP + # Some filesystems do not support directory fsync; rename is still atomic. + end + end +end diff --git a/scripts/promote_runtime_tuple.rb b/scripts/promote_runtime_tuple.rb new file mode 100755 index 0000000..a0974e3 --- /dev/null +++ b/scripts/promote_runtime_tuple.rb @@ -0,0 +1,104 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require "json" +require "optparse" +require_relative "../lib/opencode_compat/runtime_tuple_promoter" + +root = File.expand_path("..", __dir__) +command = ARGV.shift +options = { + "evidence" => [], + "previous_evidence" => [] +} + +parser = OptionParser.new do |opts| + opts.banner = <<~USAGE + Usage: + ruby scripts/promote_runtime_tuple.rb fingerprint --consumer NAME --consumer-commit SHA + ruby scripts/promote_runtime_tuple.rb promote --consumer NAME --consumer-commit SHA \\ + --status pass --certified-at TIMESTAMP --evidence evidence/FILE.json \\ + [--previous-status pass --previous-certified-at TIMESTAMP \\ + --previous-evidence evidence/FILE.json] [--dry-run] + USAGE + + opts.on("--consumer NAME") { |value| options["consumer"] = value } + opts.on("--consumer-commit SHA") { |value| options["consumer_commit"] = value } + opts.on("--status STATUS") { |value| options["status"] = value } + opts.on("--certified-at TIMESTAMP") { |value| options["certified_at"] = value } + opts.on("--evidence PATH") { |value| options["evidence"] << value } + opts.on("--previous-status STATUS") { |value| options["previous_status"] = value } + opts.on("--previous-certified-at TIMESTAMP") { |value| options["previous_certified_at"] = value } + opts.on("--previous-evidence PATH") { |value| options["previous_evidence"] << value } + opts.on("--dry-run") { options["dry_run"] = true } +end + +def required!(options, *keys) + missing = keys.reject { |key| options[key] && (!options[key].respond_to?(:empty?) || !options[key].empty?) } + raise OptionParser::MissingArgument, missing.join(", ") unless missing.empty? +end + +begin + parser.parse!(ARGV) + raise OptionParser::InvalidArgument, "unexpected arguments: #{ARGV.join(' ')}" unless ARGV.empty? + + promoter = OpenCodeCompat::RuntimeTuplePromoter.new(root: root) + case command + when "fingerprint" + required!(options, "consumer", "consumer_commit") + puts JSON.pretty_generate( + promoter.fingerprints( + consumer: options.fetch("consumer"), + consumer_commit: options.fetch("consumer_commit") + ) + ) + when "promote" + required!(options, "consumer", "consumer_commit", "status", "certified_at", "evidence") + previous_fields = %w[previous_status previous_certified_at previous_evidence] + supplied_previous_fields = previous_fields.select do |key| + value = options[key] + value && (!value.respond_to?(:empty?) || !value.empty?) + end + if supplied_previous_fields.any? && supplied_previous_fields.length != previous_fields.length + raise OptionParser::MissingArgument, (previous_fields - supplied_previous_fields).join(", ") + end + + previous_certification = if supplied_previous_fields.empty? + nil + else + { + "status" => options.fetch("previous_status"), + "certified_at" => options.fetch("previous_certified_at"), + "evidence" => options.fetch("previous_evidence") + } + end + promoted = promoter.promote( + consumer: options.fetch("consumer"), + consumer_commit: options.fetch("consumer_commit"), + certification: { + "status" => options.fetch("status"), + "certified_at" => options.fetch("certified_at"), + "evidence" => options.fetch("evidence") + }, + previous_certification: previous_certification, + dry_run: options.fetch("dry_run", false) + ) + if options.fetch("dry_run", false) + puts JSON.pretty_generate(promoted) + else + current = promoted.fetch("consumers").fetch(options.fetch("consumer")).fetch("current") + puts JSON.pretty_generate( + "consumer" => options.fetch("consumer"), + "current_consumer_commit" => current.fetch("consumer_commit"), + "current_tuple_sha256" => current.dig("certification", "tuple_sha256"), + "migration_state" => promoted.fetch("migration_state") + ) + end + else + raise OptionParser::InvalidArgument, "command must be fingerprint or promote" + end +rescue OpenCodeCompat::PromotionError, OptionParser::ParseError => e + warn "error: #{e.message}" + warn parser + exit 1 +end diff --git a/test/repository_test.rb b/test/repository_test.rb index 22dfcfe..4b9378d 100644 --- a/test/repository_test.rb +++ b/test/repository_test.rb @@ -11,7 +11,7 @@ class RepositoryTest < Minitest::Test end def test_every_json_document_parses - paths = Dir.glob(File.join(ROOT, "{fixtures,manifests,profiles}/**/*.json")) + paths = Dir.glob(File.join(ROOT, "{evidence,fixtures,manifests,profiles}/**/*.json")) refute_empty paths paths.each { |path| JSON.parse(File.read(path)) } end @@ -50,9 +50,21 @@ class RepositoryTest < Minitest::Test return unless tuples.fetch("migration_state") == "certified" tuples.fetch("consumers").each_value do |consumer| - refute_nil consumer["current"] - refute_nil consumer["previous"] - assert_equal "certified", consumer.fetch("current").fetch("status") + %w[current previous].each do |slot| + tuple = consumer.fetch(slot) + assert_equal "certified", tuple.fetch("status") + assert_equal "pass", tuple.fetch("certification").fetch("status") + assert_match(/\Asha256:[0-9a-f]{64}\z/, tuple.fetch("certification").fetch("tuple_sha256")) + refute_empty tuple.fetch("certification").fetch("evidence") + end + end + end + + def test_runtime_tuple_profiles_exist + tuples = json("manifests/runtime-tuples.json") + tuples.fetch("consumers").each_value do |consumer| + profile = consumer.fetch("profile") + assert_path_exists File.join(ROOT, "profiles", "#{profile}.json") end end @@ -60,4 +72,16 @@ class RepositoryTest < Minitest::Test workflow = File.read(File.join(ROOT, ".github/workflows/watch-upstream.yml")) refute_match(/\b(kamal|kubectl|helm|nomad|docker\s+service)\b/i, workflow) end + + def test_tuple_promotion_has_no_command_execution_or_deployment_client + paths = %w[ + lib/opencode_compat/runtime_tuple_promoter.rb + scripts/promote_runtime_tuple.rb + ] + implementation = paths.map { |path| File.read(File.join(ROOT, path)) }.join("\n") + + refute_match(/\b(system|exec|spawn|popen|Open3)\b/, implementation) + refute_match(/`[^`]+`/, implementation) + refute_match(/\b(kamal|kubectl|helm|nomad|docker\s+service)\b/i, implementation) + end end diff --git a/test/runtime_tuple_promoter_test.rb b/test/runtime_tuple_promoter_test.rb new file mode 100644 index 0000000..b3079f0 --- /dev/null +++ b/test/runtime_tuple_promoter_test.rb @@ -0,0 +1,309 @@ +# frozen_string_literal: true + +require "fileutils" +require "json" +require "minitest/autorun" +require "tmpdir" +require_relative "../lib/opencode_compat/runtime_tuple_promoter" + +class RuntimeTuplePromoterTest < Minitest::Test + CONSUMER = "example" + CURRENT_COMMIT = "1" * 40 + CANDIDATE_COMMIT = "2" * 40 + RUBY_CURRENT = "3" * 40 + RUBY_CANDIDATE = "4" * 40 + RAILS_CURRENT = "5" * 40 + RAILS_CANDIDATE = "6" * 40 + CURRENT_IMAGE = "ghcr.io/anomalyco/opencode@sha256:#{'a' * 64}" + CANDIDATE_IMAGE = "ghcr.io/anomalyco/opencode@sha256:#{'b' * 64}" + CURRENT_TIME = "2026-07-17T12:00:00Z" + CANDIDATE_TIME = "2026-07-18T12:00:00Z" + + def setup + @root = Dir.mktmpdir("opencode-compat-promotion") + FileUtils.mkdir_p(File.join(@root, "manifests")) + FileUtils.mkdir_p(File.join(@root, "evidence")) + FileUtils.mkdir_p(File.join(@root, "profiles")) + File.write(File.join(@root, "profiles", "rails-persisted-turn.json"), "{}\n") + write_manifest(valid_manifest) + @promoter = OpenCodeCompat::RuntimeTuplePromoter.new(root: @root) + end + + def teardown + FileUtils.remove_entry(@root) + end + + def test_promotion_atomically_moves_certified_tuples_and_clears_candidate + candidate, previous = write_matching_evidence + + promoted = @promoter.promote( + consumer: CONSUMER, + consumer_commit: CANDIDATE_COMMIT, + certification: certification(CANDIDATE_TIME, candidate), + previous_certification: certification(CURRENT_TIME, previous) + ) + + consumer = promoted.fetch("consumers").fetch(CONSUMER) + assert_nil consumer["candidate"] + assert_equal CANDIDATE_COMMIT, consumer.dig("current", "consumer_commit") + assert_equal RUBY_CANDIDATE, consumer.dig("current", "opencode_ruby", "git_commit") + assert_equal "certified", consumer.dig("current", "status") + assert_equal candidate.fetch("tuple_sha256"), consumer.dig("current", "certification", "tuple_sha256") + assert_equal CURRENT_COMMIT, consumer.dig("previous", "consumer_commit") + assert_equal RUBY_CURRENT, consumer.dig("previous", "opencode_ruby", "git_commit") + assert_equal "certified", consumer.dig("previous", "status") + assert_equal previous.fetch("tuple_sha256"), consumer.dig("previous", "certification", "tuple_sha256") + assert_equal "certified", promoted.fetch("migration_state") + assert_equal promoted, JSON.parse(File.read(manifest_path)) + end + + def test_dry_run_returns_promotion_without_changing_manifest + candidate, previous = write_matching_evidence + before = File.binread(manifest_path) + + promoted = @promoter.promote( + consumer: CONSUMER, + consumer_commit: CANDIDATE_COMMIT, + certification: certification(CANDIDATE_TIME, candidate), + previous_certification: certification(CURRENT_TIME, previous), + dry_run: true + ) + + assert_equal "certified", promoted.dig("consumers", CONSUMER, "current", "status") + assert_equal before, File.binread(manifest_path) + end + + def test_rejects_missing_candidate_without_touching_manifest + manifest = valid_manifest + manifest.fetch("consumers").fetch(CONSUMER)["candidate"] = nil + write_manifest(manifest) + before = File.binread(manifest_path) + + error = assert_raises(OpenCodeCompat::PromotionError) do + @promoter.promote( + consumer: CONSUMER, + consumer_commit: CANDIDATE_COMMIT, + certification: certification(CANDIDATE_TIME, {"path" => "evidence/missing.json"}) + ) + end + + assert_match(/no candidate/, error.message) + assert_equal before, File.binread(manifest_path) + end + + def test_rejects_mutable_candidate_image_even_with_an_exact_image_id + manifest = valid_manifest + runtime = manifest.dig("consumers", CONSUMER, "candidate", "runtime") + runtime["registry_ref"] = "ghcr.io/anomalyco/opencode:latest" + runtime["docker_image_id"] = "sha256:#{'c' * 64}" + write_manifest(manifest) + before = File.binread(manifest_path) + + error = assert_raises(OpenCodeCompat::PromotionError) do + @promoter.fingerprints(consumer: CONSUMER, consumer_commit: CANDIDATE_COMMIT) + end + + assert_match(/immutable image@sha256 digest/, error.message) + assert_equal before, File.binread(manifest_path) + end + + def test_rejects_short_consumer_and_client_commits + assert_raises(OpenCodeCompat::PromotionError) do + @promoter.fingerprints(consumer: CONSUMER, consumer_commit: "abc123") + end + + manifest = valid_manifest + manifest.dig("consumers", CONSUMER, "candidate", "opencode_ruby")["git_commit"] = "abc123" + write_manifest(manifest) + error = assert_raises(OpenCodeCompat::PromotionError) do + @promoter.fingerprints(consumer: CONSUMER, consumer_commit: CANDIDATE_COMMIT) + end + assert_match(/full 40-character/, error.message) + end + + def test_rejects_missing_server_version_or_unknown_profile + manifest = valid_manifest + manifest.dig("consumers", CONSUMER, "candidate", "runtime").delete("reported_version") + write_manifest(manifest) + error = assert_raises(OpenCodeCompat::PromotionError) do + @promoter.fingerprints(consumer: CONSUMER, consumer_commit: CANDIDATE_COMMIT) + end + assert_match(/reported OpenCode server version/, error.message) + + manifest = valid_manifest + manifest.dig("consumers", CONSUMER)["profile"] = "untracked-profile" + write_manifest(manifest) + error = assert_raises(OpenCodeCompat::PromotionError) do + @promoter.fingerprints(consumer: CONSUMER, consumer_commit: CANDIDATE_COMMIT) + end + assert_match(/profile does not exist/, error.message) + end + + def test_rejects_missing_previous_certification_for_an_uncertified_baseline + candidate, = write_matching_evidence + before = File.binread(manifest_path) + + error = assert_raises(OpenCodeCompat::PromotionError) do + @promoter.promote( + consumer: CONSUMER, + consumer_commit: CANDIDATE_COMMIT, + certification: certification(CANDIDATE_TIME, candidate) + ) + end + + assert_match(/current tuple is not certified/, error.message) + assert_equal before, File.binread(manifest_path) + end + + def test_rejects_evidence_that_does_not_match_the_complete_tuple + candidate, previous = write_matching_evidence + manifest = JSON.parse(File.read(manifest_path)) + manifest.dig("consumers", CONSUMER, "candidate", "runtime")["image"] = + "ghcr.io/anomalyco/opencode@sha256:#{'f' * 64}" + write_manifest(manifest) + before = File.binread(manifest_path) + + error = assert_raises(OpenCodeCompat::PromotionError) do + @promoter.promote( + consumer: CONSUMER, + consumer_commit: CANDIDATE_COMMIT, + certification: certification(CANDIDATE_TIME, candidate), + previous_certification: certification(CURRENT_TIME, previous) + ) + end + + assert_match(/tuple_sha256/, error.message) + assert_equal before, File.binread(manifest_path) + end + + def test_rejects_non_passing_or_implicit_certification_metadata + candidate, previous = write_matching_evidence + + assert_raises(OpenCodeCompat::PromotionError) do + @promoter.promote( + consumer: CONSUMER, + consumer_commit: CANDIDATE_COMMIT, + certification: certification(CANDIDATE_TIME, candidate).merge("status" => "pending"), + previous_certification: certification(CURRENT_TIME, previous), + dry_run: true + ) + end + + assert_raises(OpenCodeCompat::PromotionError) do + @promoter.promote( + consumer: CONSUMER, + consumer_commit: CANDIDATE_COMMIT, + certification: certification("today", candidate), + previous_certification: certification(CURRENT_TIME, previous), + dry_run: true + ) + end + end + + def test_rejects_duplicate_or_unversioned_evidence + candidate, previous = write_matching_evidence + + assert_raises(OpenCodeCompat::PromotionError) do + @promoter.promote( + consumer: CONSUMER, + consumer_commit: CANDIDATE_COMMIT, + certification: certification(CANDIDATE_TIME, candidate).merge( + "evidence" => [candidate.fetch("path"), candidate.fetch("path")] + ), + previous_certification: certification(CURRENT_TIME, previous), + dry_run: true + ) + end + + path = File.join(@root, candidate.fetch("path")) + document = JSON.parse(File.read(path)) + document.delete("schema_version") + File.write(path, JSON.pretty_generate(document) + "\n") + assert_raises(OpenCodeCompat::PromotionError) do + @promoter.promote( + consumer: CONSUMER, + consumer_commit: CANDIDATE_COMMIT, + certification: certification(CANDIDATE_TIME, candidate), + previous_certification: certification(CURRENT_TIME, previous), + dry_run: true + ) + end + end + + private + + def valid_manifest + { + "schema_version" => 1, + "migration_state" => "candidate", + "consumers" => { + CONSUMER => { + "profile" => "rails-persisted-turn", + "current" => { + "status" => "observed-production", + "consumer_commit" => CURRENT_COMMIT, + "opencode_ruby" => {"version" => "0.0.1.alpha2", "git_commit" => RUBY_CURRENT}, + "opencode_rails" => {"version" => "0.0.1.alpha2", "git_commit" => RAILS_CURRENT}, + "runtime" => {"image" => CURRENT_IMAGE, "reported_version" => "1.16.1"} + }, + "candidate" => { + "status" => "compatibility-certified", + "certified_at" => "2026-07-18T10:00:00Z", + "opencode_ruby" => {"version" => "0.0.1.alpha6", "git_commit" => RUBY_CANDIDATE}, + "opencode_rails" => {"version" => "0.0.1.alpha6", "git_commit" => RAILS_CANDIDATE}, + "runtime" => {"image" => CANDIDATE_IMAGE, "reported_version" => "1.18.3"} + }, + "previous" => nil + } + } + } + end + + def write_matching_evidence + fingerprints = @promoter.fingerprints(consumer: CONSUMER, consumer_commit: CANDIDATE_COMMIT) + candidate = write_evidence( + "candidate.json", + commit: CANDIDATE_COMMIT, + timestamp: CANDIDATE_TIME, + fingerprint: fingerprints.fetch("candidate_tuple_sha256") + ) + previous = write_evidence( + "previous.json", + commit: CURRENT_COMMIT, + timestamp: CURRENT_TIME, + fingerprint: fingerprints.fetch("current_tuple_sha256") + ) + [candidate, previous] + end + + def write_evidence(name, commit:, timestamp:, fingerprint:) + path = File.join("evidence", name) + document = { + "schema_version" => 1, + "consumer" => CONSUMER, + "profile" => "rails-persisted-turn", + "consumer_commit" => commit, + "status" => "pass", + "certified_at" => timestamp, + "tuple_sha256" => fingerprint + } + File.write(File.join(@root, path), JSON.pretty_generate(document) + "\n") + {"path" => path, "tuple_sha256" => fingerprint} + end + + def certification(timestamp, evidence) + { + "status" => "pass", + "certified_at" => timestamp, + "evidence" => [evidence.fetch("path")] + } + end + + def manifest_path + File.join(@root, "manifests", "runtime-tuples.json") + end + + def write_manifest(manifest) + File.write(manifest_path, JSON.pretty_generate(manifest) + "\n") + end +end