Add atomic runtime tuple promotion gate
This commit is contained in:
2
.github/workflows/candidate.yml
vendored
2
.github/workflows/candidate.yml
vendored
@@ -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
|
||||
|
||||
@@ -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 \
|
||||
|
||||
@@ -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
|
||||
|
||||
495
lib/opencode_compat/runtime_tuple_promoter.rb
Normal file
495
lib/opencode_compat/runtime_tuple_promoter.rb
Normal file
@@ -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
|
||||
104
scripts/promote_runtime_tuple.rb
Executable file
104
scripts/promote_runtime_tuple.rb
Executable file
@@ -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
|
||||
@@ -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
|
||||
|
||||
309
test/runtime_tuple_promoter_test.rb
Normal file
309
test/runtime_tuple_promoter_test.rb
Normal file
@@ -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
|
||||
Reference in New Issue
Block a user