Add explicit rollback bootstrap and maintenance model
This commit is contained in:
@@ -122,7 +122,9 @@ durable certification documents and Git history are the archive. This keeps a
|
|||||||
fast-moving upstream from making every future run retest every old release.
|
fast-moving upstream from making every future run retest every old release.
|
||||||
|
|
||||||
See [docs/certification.md](docs/certification.md) for promotion, canary, and
|
See [docs/certification.md](docs/certification.md) for promotion, canary, and
|
||||||
rollback evidence requirements.
|
rollback evidence requirements, and [docs/maintenance.md](docs/maintenance.md)
|
||||||
|
for consumer ownership, release order, runner boundaries, and expected failure
|
||||||
|
modes.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
@@ -155,14 +155,31 @@ have real passing evidence can the manifest honestly contain certified
|
|||||||
`current` and `previous` tuples. Until then, `promotion_readiness` remains
|
`current` and `previous` tuples. Until then, `promotion_readiness` remains
|
||||||
blocked and the candidate PR must not be treated as a deploy authorization.
|
blocked and the candidate PR must not be treated as a deploy authorization.
|
||||||
|
|
||||||
The schema-v1 promotion command deliberately cannot perform the first degraded
|
Use the explicit degraded-bootstrap transition for the first passing rollback
|
||||||
bootstrap transition when `current` is a known-failing baseline. Do not bypass
|
tuple. It requires passing evidence bound to the complete candidate fingerprint
|
||||||
that guard by hand-editing the manifest or relabeling alpha2 evidence. A
|
and a literal acknowledgement; it preserves the failed baseline as uncertified
|
||||||
separate reviewed state-machine change must first add an explicit
|
`emergency_provenance`, leaves `previous` null, and records the temporary
|
||||||
`bootstrap-current-only` state that preserves the failed baseline as
|
`bootstrap-current-only` state:
|
||||||
uncertified emergency provenance and leaves `previous` null. Itemized rollback
|
|
||||||
certification is complete only after a later, materially distinct passing tuple
|
```sh
|
||||||
can move the first certified current into `previous`.
|
ruby scripts/promote_runtime_tuple.rb bootstrap-current \
|
||||||
|
--consumer travelwolf \
|
||||||
|
--consumer-commit FULL_40_CHARACTER_ROLLBACK_COMMIT \
|
||||||
|
--status pass \
|
||||||
|
--certified-at 2026-07-20T12:00:00Z \
|
||||||
|
--evidence evidence/travelwolf-rollback.json \
|
||||||
|
--acknowledge-degraded-rollback \
|
||||||
|
accept-degraded-rollback-with-failed-emergency-provenance \
|
||||||
|
--dry-run
|
||||||
|
```
|
||||||
|
|
||||||
|
Review the dry run, repeat without `--dry-run`, and then treat that passing
|
||||||
|
tuple as the base for a normal promotion. Normal promotion moves it into
|
||||||
|
`previous`, removes the degraded rollback marker, and keeps the original failed
|
||||||
|
baseline only as historical emergency provenance. The repository becomes
|
||||||
|
`certified` only when every consumer has exact passing `current` and `previous`
|
||||||
|
tuples. Never use bootstrap to replace an existing previous tuple or to certify
|
||||||
|
a baseline already known to fail.
|
||||||
|
|
||||||
## Rollback
|
## Rollback
|
||||||
|
|
||||||
|
|||||||
117
docs/maintenance.md
Normal file
117
docs/maintenance.md
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
# Maintaining OpenCode consumers
|
||||||
|
|
||||||
|
OpenCode compatibility is an executable tuple, not a gem version. The tuple is
|
||||||
|
the exact client commit, Rails adapter commit where used, OpenCode image digest,
|
||||||
|
consumer commit, compatibility profile, and passing evidence. A change to any
|
||||||
|
coordinate creates a new candidate.
|
||||||
|
|
||||||
|
## Dependency ownership
|
||||||
|
|
||||||
|
The adjacent projects intentionally do not all use the same gems:
|
||||||
|
|
||||||
|
| Consumer | OpenCode boundary | Owned dependency |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Ajent Rails | Ruby REST/SSE plus persisted Rails turns | `opencode-ruby` and `opencode-rails` |
|
||||||
|
| Travelwolf | Ruby REST/SSE plus persisted Rails turns and Sprite lifecycle | `opencode-ruby` and `opencode-rails` |
|
||||||
|
| Mushu | Ruby REST/SSE with application-owned conversation, claim, recovery, Telegram scope, and idempotency | `opencode-ruby` only |
|
||||||
|
| Greenroom | Direct voice-stream worker | No Ruby adapter gem |
|
||||||
|
| Leela | Custom strict-v2 server, security, and toolchain lane | No Ruby adapter gem |
|
||||||
|
| opencode-ajent | Native CLI and plugin hook/event lane | No Ruby adapter gem |
|
||||||
|
| inference | Provider configuration, hooks, routing, and migration lane | No Ruby adapter gem |
|
||||||
|
| Context Kit | MCP and OpenCode configuration producer | No Ruby adapter gem |
|
||||||
|
|
||||||
|
Do not add `opencode-rails` to an application that owns different persistence
|
||||||
|
semantics, and do not route plugin, provider, voice, or strict-v2 behavior
|
||||||
|
through the Ruby REST/SSE adapter merely to make versions look uniform. Share
|
||||||
|
fixtures, provenance, and promotion policy across those lanes instead.
|
||||||
|
|
||||||
|
Generated release snapshots, detached operational copies, editor locks, caches,
|
||||||
|
and linked task worktrees are evidence or tooling. They are not additional
|
||||||
|
consumers and must not be bulk-upgraded.
|
||||||
|
|
||||||
|
## Supported window
|
||||||
|
|
||||||
|
Support the exact current and previous certified runtime tuples. Do not claim
|
||||||
|
compatibility with arbitrary future OpenCode versions. Additive fields and
|
||||||
|
unknown events should remain tolerant, while terminal text, request count,
|
||||||
|
ownership, persistence, and cleanup invariants remain strict.
|
||||||
|
|
||||||
|
The Ruby/SSE profile covers the endpoints and events the client actually uses,
|
||||||
|
including session creation and deletion, asynchronous prompts, event
|
||||||
|
subscription, status, terminal idle/status events, part deltas and updates,
|
||||||
|
authoritative assistant messages, questions, and permissions. A passing shared
|
||||||
|
profile does not certify Rails persistence, voice streaming, plugin hooks,
|
||||||
|
provider migrations, or generated MCP configuration.
|
||||||
|
|
||||||
|
## Release and promotion order
|
||||||
|
|
||||||
|
1. The watcher records a new upstream release tag and resolved OCI digest in a
|
||||||
|
PR. It never merges or deploys.
|
||||||
|
2. Update fixtures for any observed protocol change before changing the client.
|
||||||
|
3. Build `opencode-ruby` and `opencode-rails` as one release train. Rails must
|
||||||
|
resolve the exact Ruby version and commit being tested.
|
||||||
|
4. Run the shared fixture corpus, Ruby 3.2 through 4.0 lockstep matrix, and every
|
||||||
|
exact public image still used by a current, previous, or candidate tuple.
|
||||||
|
5. Run only the application-owned profiles for each consumer, in its own PR and
|
||||||
|
isolated environment. Health checks alone do not certify a tuple.
|
||||||
|
6. Promote and deploy one consumer at a time. Record the exact production
|
||||||
|
commit, image/base digest, loaded client commits, and live result.
|
||||||
|
7. Commit reviewed evidence and move the old passing current tuple to previous.
|
||||||
|
8. Publish annotated gem tags only after the exact commit candidate is green and
|
||||||
|
the trusted publisher is configured. Publication never implies deployment.
|
||||||
|
|
||||||
|
Commit pins are valid for an unpublished candidate, but the durable published
|
||||||
|
state must record the annotated tag object and peeled commit. Tags or `latest`
|
||||||
|
may be kept as human-readable provenance only; execution coordinates use full
|
||||||
|
Git commits and `image@sha256:...` references.
|
||||||
|
|
||||||
|
## Custom images
|
||||||
|
|
||||||
|
Record each provenance layer separately:
|
||||||
|
|
||||||
|
- consumer commit;
|
||||||
|
- exact output registry digest or Docker image ID;
|
||||||
|
- exact base image digest;
|
||||||
|
- custom OpenCode source commit, when the base is a fork;
|
||||||
|
- build-source commit when it differs from the deployment commit.
|
||||||
|
|
||||||
|
The tuple fingerprint binds these values. On the next rebuild of an older
|
||||||
|
unlabelled private image, add OCI labels for the custom OpenCode source, reported
|
||||||
|
version, consumer build revision, and base digest, then make preflight compare
|
||||||
|
the labels. Do not relabel an already certified image: that changes its digest.
|
||||||
|
|
||||||
|
## Runner and forge contract
|
||||||
|
|
||||||
|
Runner upgrades are a separate compatibility surface from OpenCode upgrades.
|
||||||
|
Workflows must install their required Ruby/toolchain explicitly, declare Bash
|
||||||
|
for scripts that use Bash syntax, and avoid relying on ambient runner packages.
|
||||||
|
|
||||||
|
GitHub and Gitea do not implement every Actions feature identically. Keep the
|
||||||
|
same tests on both forges, but use forge-specific execution where necessary:
|
||||||
|
GitHub retains review artifacts and parallel dynamic image jobs; Gitea runs the
|
||||||
|
same manifest image set sequentially and makes no artifact-retention claim.
|
||||||
|
Neither path may mutate a runner or deploy a consumer.
|
||||||
|
|
||||||
|
A runner-only workflow repair needs exact-head CI, not an application canary.
|
||||||
|
A client, runtime image, event, persistence, or toolchain change needs the
|
||||||
|
profile and consumer canaries described above.
|
||||||
|
|
||||||
|
## Expected breakages
|
||||||
|
|
||||||
|
- Prompt submission before event subscription can miss a terminal event and
|
||||||
|
hang a turn.
|
||||||
|
- Changes to terminal or message-part events can duplicate or lose final text.
|
||||||
|
- Usage events can undercount multi-step requests if totals are overwritten.
|
||||||
|
- Reconnect logic can replay a prompt and create duplicate model requests.
|
||||||
|
- Asset builds can fail when runtime configuration is evaluated without the
|
||||||
|
image variables available only at deploy time.
|
||||||
|
- A workflow can pass locally but fail under `sh` when it uses Bash arrays or
|
||||||
|
`mapfile`.
|
||||||
|
- Plugin hook names, provider schema, config/MCP schema, or CLI flags can break
|
||||||
|
direct consumers even when the Ruby profile stays green.
|
||||||
|
- A custom fork can silently lose its required ordering or permission patch if
|
||||||
|
only an opaque output digest is retained.
|
||||||
|
|
||||||
|
When one of these changes, update the owning profile and consumer evidence. Do
|
||||||
|
not weaken a strict invariant to make a new upstream release pass.
|
||||||
|
|
||||||
@@ -15,6 +15,8 @@ module OpenCodeCompat
|
|||||||
IMMUTABLE_IMAGE = /\A[^@\s]+@sha256:[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/
|
UTC_TIMESTAMP = /\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z\z/
|
||||||
CERTIFICATION_STATUS = "pass"
|
CERTIFICATION_STATUS = "pass"
|
||||||
|
DEGRADED_BOOTSTRAP_ACKNOWLEDGEMENT =
|
||||||
|
"accept-degraded-rollback-with-failed-emergency-provenance"
|
||||||
NON_CERTIFIABLE_TUPLE_STATUSES = %w[observed-production-contract-failed].freeze
|
NON_CERTIFIABLE_TUPLE_STATUSES = %w[observed-production-contract-failed].freeze
|
||||||
TUPLE_METADATA_KEYS = %w[
|
TUPLE_METADATA_KEYS = %w[
|
||||||
certification
|
certification
|
||||||
@@ -82,6 +84,37 @@ module OpenCodeCompat
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def bootstrap_current(consumer:, consumer_commit:, certification:, acknowledgement:, dry_run: false)
|
||||||
|
validate_full_commit!(consumer_commit, "consumer commit")
|
||||||
|
unless acknowledgement == DEGRADED_BOOTSTRAP_ACKNOWLEDGEMENT
|
||||||
|
raise PromotionError,
|
||||||
|
"bootstrap requires explicit acknowledgement #{DEGRADED_BOOTSTRAP_ACKNOWLEDGEMENT.inspect}"
|
||||||
|
end
|
||||||
|
|
||||||
|
if dry_run
|
||||||
|
manifest = read_manifest
|
||||||
|
return bootstrap_current_manifest(
|
||||||
|
manifest,
|
||||||
|
consumer: consumer,
|
||||||
|
consumer_commit: consumer_commit,
|
||||||
|
certification: certification,
|
||||||
|
acknowledgement: acknowledgement
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
with_current_manifest_lock do |manifest|
|
||||||
|
bootstrapped = bootstrap_current_manifest(
|
||||||
|
manifest,
|
||||||
|
consumer: consumer,
|
||||||
|
consumer_commit: consumer_commit,
|
||||||
|
certification: certification,
|
||||||
|
acknowledgement: acknowledgement
|
||||||
|
)
|
||||||
|
atomic_write(bootstrapped)
|
||||||
|
bootstrapped
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def read_manifest
|
def read_manifest
|
||||||
@@ -124,7 +157,55 @@ module OpenCodeCompat
|
|||||||
consumer_entry["previous"] = certified_previous
|
consumer_entry["previous"] = certified_previous
|
||||||
consumer_entry["current"] = certified_candidate
|
consumer_entry["current"] = certified_candidate
|
||||||
consumer_entry["candidate"] = nil
|
consumer_entry["candidate"] = nil
|
||||||
manifest["migration_state"] = all_consumers_certified?(manifest) ? "certified" : "candidate"
|
consumer_entry.delete("rollback_state")
|
||||||
|
refresh_promotion_state!(manifest)
|
||||||
|
manifest
|
||||||
|
end
|
||||||
|
|
||||||
|
def bootstrap_current_manifest(manifest, consumer:, consumer_commit:, certification:, acknowledgement:)
|
||||||
|
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 retain as emergency provenance"
|
||||||
|
end
|
||||||
|
validate_tuple!(current, "#{consumer} current")
|
||||||
|
unless NON_CERTIFIABLE_TUPLE_STATUSES.include?(current["status"])
|
||||||
|
raise PromotionError, "degraded bootstrap is only for a current tuple known to fail the contract"
|
||||||
|
end
|
||||||
|
unless consumer_entry["previous"].nil?
|
||||||
|
raise PromotionError, "degraded bootstrap cannot replace an existing previous tuple"
|
||||||
|
end
|
||||||
|
if consumer_entry.key?("emergency_provenance") || consumer_entry.key?("rollback_state")
|
||||||
|
raise PromotionError, "degraded bootstrap has already been recorded for this consumer"
|
||||||
|
end
|
||||||
|
|
||||||
|
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 failed current tuple"
|
||||||
|
end
|
||||||
|
|
||||||
|
certified_candidate = certify_tuple!(
|
||||||
|
candidate,
|
||||||
|
consumer: consumer,
|
||||||
|
profile: profile,
|
||||||
|
supplied: certification,
|
||||||
|
expected_fingerprint: candidate_fingerprint,
|
||||||
|
label: "bootstrap candidate"
|
||||||
|
)
|
||||||
|
|
||||||
|
consumer_entry["emergency_provenance"] = deep_copy(current)
|
||||||
|
consumer_entry["current"] = certified_candidate
|
||||||
|
consumer_entry["candidate"] = nil
|
||||||
|
consumer_entry["previous"] = nil
|
||||||
|
consumer_entry["rollback_state"] = {
|
||||||
|
"status" => "degraded-no-certified-previous",
|
||||||
|
"acknowledgement" => acknowledgement,
|
||||||
|
"recorded_at" => certification.fetch("certified_at"),
|
||||||
|
"emergency_provenance_status" => current.fetch("status")
|
||||||
|
}
|
||||||
|
refresh_promotion_state!(manifest)
|
||||||
manifest
|
manifest
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -455,6 +536,61 @@ module OpenCodeCompat
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def all_consumers_bootstrapped?(manifest)
|
||||||
|
manifest.fetch("consumers").all? do |consumer, entry|
|
||||||
|
next false unless entry["candidate"].nil? && entry["previous"].nil?
|
||||||
|
next false unless entry.dig("rollback_state", "status") == "degraded-no-certified-previous"
|
||||||
|
next false unless NON_CERTIFIABLE_TUPLE_STATUSES.include?(entry.dig("emergency_provenance", "status"))
|
||||||
|
|
||||||
|
profile = fetch_profile!(entry, consumer)
|
||||||
|
tuple = entry["current"]
|
||||||
|
next false unless tuple.is_a?(Hash) && tuple["status"] == "certified"
|
||||||
|
|
||||||
|
validate_tuple!(tuple, "#{consumer} current")
|
||||||
|
validate_recorded_certification!(
|
||||||
|
tuple,
|
||||||
|
consumer: consumer,
|
||||||
|
profile: profile,
|
||||||
|
expected_fingerprint: tuple_fingerprint(tuple, consumer: consumer, profile: profile),
|
||||||
|
label: "#{consumer} current"
|
||||||
|
)
|
||||||
|
true
|
||||||
|
rescue PromotionError
|
||||||
|
false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def migration_state_for(manifest)
|
||||||
|
return "certified" if all_consumers_certified?(manifest)
|
||||||
|
return "bootstrap-current-only" if all_consumers_bootstrapped?(manifest)
|
||||||
|
|
||||||
|
"candidate"
|
||||||
|
end
|
||||||
|
|
||||||
|
def refresh_promotion_state!(manifest)
|
||||||
|
manifest["migration_state"] = migration_state_for(manifest)
|
||||||
|
manifest["promotion_readiness"] = case manifest.fetch("migration_state")
|
||||||
|
when "certified"
|
||||||
|
{
|
||||||
|
"status" => "certified",
|
||||||
|
"reason" => "Every consumer has exact current and previous passing tuples.",
|
||||||
|
"required_action" => "Promote only a newly certified, materially changed tuple."
|
||||||
|
}
|
||||||
|
when "bootstrap-current-only"
|
||||||
|
{
|
||||||
|
"status" => "bootstrap-current-only",
|
||||||
|
"reason" => "Every current tuple is certified, but no independently passing previous tuple exists yet.",
|
||||||
|
"required_action" => "Certify the next meaningful release so normal promotion retains the current tuple as previous."
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
"status" => "candidate",
|
||||||
|
"reason" => "At least one consumer transition remains incomplete.",
|
||||||
|
"required_action" => "Finish exact tuple certification without treating failed emergency provenance as rollback evidence."
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def with_current_manifest_lock
|
def with_current_manifest_lock
|
||||||
loop do
|
loop do
|
||||||
retry_with_new_inode = false
|
retry_with_new_inode = false
|
||||||
|
|||||||
@@ -20,6 +20,10 @@ parser = OptionParser.new do |opts|
|
|||||||
--status pass --certified-at TIMESTAMP --evidence evidence/FILE.json \\
|
--status pass --certified-at TIMESTAMP --evidence evidence/FILE.json \\
|
||||||
[--previous-status pass --previous-certified-at TIMESTAMP \\
|
[--previous-status pass --previous-certified-at TIMESTAMP \\
|
||||||
--previous-evidence evidence/FILE.json] [--dry-run]
|
--previous-evidence evidence/FILE.json] [--dry-run]
|
||||||
|
ruby scripts/promote_runtime_tuple.rb bootstrap-current --consumer NAME --consumer-commit SHA \\
|
||||||
|
--status pass --certified-at TIMESTAMP --evidence evidence/FILE.json \\
|
||||||
|
--acknowledge-degraded-rollback \\
|
||||||
|
accept-degraded-rollback-with-failed-emergency-provenance [--dry-run]
|
||||||
USAGE
|
USAGE
|
||||||
|
|
||||||
opts.on("--consumer NAME") { |value| options["consumer"] = value }
|
opts.on("--consumer NAME") { |value| options["consumer"] = value }
|
||||||
@@ -30,6 +34,9 @@ parser = OptionParser.new do |opts|
|
|||||||
opts.on("--previous-status STATUS") { |value| options["previous_status"] = 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-certified-at TIMESTAMP") { |value| options["previous_certified_at"] = value }
|
||||||
opts.on("--previous-evidence PATH") { |value| options["previous_evidence"] << value }
|
opts.on("--previous-evidence PATH") { |value| options["previous_evidence"] << value }
|
||||||
|
opts.on("--acknowledge-degraded-rollback VALUE") do |value|
|
||||||
|
options["degraded_rollback_acknowledgement"] = value
|
||||||
|
end
|
||||||
opts.on("--dry-run") { options["dry_run"] = true }
|
opts.on("--dry-run") { options["dry_run"] = true }
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -94,8 +101,42 @@ begin
|
|||||||
"migration_state" => promoted.fetch("migration_state")
|
"migration_state" => promoted.fetch("migration_state")
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
when "bootstrap-current"
|
||||||
|
required!(
|
||||||
|
options,
|
||||||
|
"consumer",
|
||||||
|
"consumer_commit",
|
||||||
|
"status",
|
||||||
|
"certified_at",
|
||||||
|
"evidence",
|
||||||
|
"degraded_rollback_acknowledgement"
|
||||||
|
)
|
||||||
|
bootstrapped = promoter.bootstrap_current(
|
||||||
|
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")
|
||||||
|
},
|
||||||
|
acknowledgement: options.fetch("degraded_rollback_acknowledgement"),
|
||||||
|
dry_run: options.fetch("dry_run", false)
|
||||||
|
)
|
||||||
|
if options.fetch("dry_run", false)
|
||||||
|
puts JSON.pretty_generate(bootstrapped)
|
||||||
|
else
|
||||||
|
consumer = bootstrapped.fetch("consumers").fetch(options.fetch("consumer"))
|
||||||
|
puts JSON.pretty_generate(
|
||||||
|
"consumer" => options.fetch("consumer"),
|
||||||
|
"current_consumer_commit" => consumer.dig("current", "consumer_commit"),
|
||||||
|
"current_tuple_sha256" => consumer.dig("current", "certification", "tuple_sha256"),
|
||||||
|
"previous" => consumer.fetch("previous"),
|
||||||
|
"rollback_state" => consumer.fetch("rollback_state"),
|
||||||
|
"migration_state" => bootstrapped.fetch("migration_state")
|
||||||
|
)
|
||||||
|
end
|
||||||
else
|
else
|
||||||
raise OptionParser::InvalidArgument, "command must be fingerprint or promote"
|
raise OptionParser::InvalidArgument, "command must be fingerprint, promote, or bootstrap-current"
|
||||||
end
|
end
|
||||||
rescue OpenCodeCompat::PromotionError, OptionParser::ParseError => e
|
rescue OpenCodeCompat::PromotionError, OptionParser::ParseError => e
|
||||||
warn "error: #{e.message}"
|
warn "error: #{e.message}"
|
||||||
|
|||||||
@@ -10,14 +10,19 @@ class RuntimeTuplePromoterTest < Minitest::Test
|
|||||||
CONSUMER = "example"
|
CONSUMER = "example"
|
||||||
CURRENT_COMMIT = "1" * 40
|
CURRENT_COMMIT = "1" * 40
|
||||||
CANDIDATE_COMMIT = "2" * 40
|
CANDIDATE_COMMIT = "2" * 40
|
||||||
|
NEXT_COMMIT = "7" * 40
|
||||||
RUBY_CURRENT = "3" * 40
|
RUBY_CURRENT = "3" * 40
|
||||||
RUBY_CANDIDATE = "4" * 40
|
RUBY_CANDIDATE = "4" * 40
|
||||||
|
RUBY_NEXT = "8" * 40
|
||||||
RAILS_CURRENT = "5" * 40
|
RAILS_CURRENT = "5" * 40
|
||||||
RAILS_CANDIDATE = "6" * 40
|
RAILS_CANDIDATE = "6" * 40
|
||||||
|
RAILS_NEXT = "9" * 40
|
||||||
CURRENT_IMAGE = "ghcr.io/anomalyco/opencode@sha256:#{'a' * 64}"
|
CURRENT_IMAGE = "ghcr.io/anomalyco/opencode@sha256:#{'a' * 64}"
|
||||||
CANDIDATE_IMAGE = "ghcr.io/anomalyco/opencode@sha256:#{'b' * 64}"
|
CANDIDATE_IMAGE = "ghcr.io/anomalyco/opencode@sha256:#{'b' * 64}"
|
||||||
|
NEXT_IMAGE = "ghcr.io/anomalyco/opencode@sha256:#{'c' * 64}"
|
||||||
CURRENT_TIME = "2026-07-17T12:00:00Z"
|
CURRENT_TIME = "2026-07-17T12:00:00Z"
|
||||||
CANDIDATE_TIME = "2026-07-18T12:00:00Z"
|
CANDIDATE_TIME = "2026-07-18T12:00:00Z"
|
||||||
|
NEXT_TIME = "2026-07-19T12:00:00Z"
|
||||||
|
|
||||||
def setup
|
def setup
|
||||||
@root = Dir.mktmpdir("opencode-compat-promotion")
|
@root = Dir.mktmpdir("opencode-compat-promotion")
|
||||||
@@ -175,6 +180,163 @@ class RuntimeTuplePromoterTest < Minitest::Test
|
|||||||
assert_equal before, File.binread(manifest_path)
|
assert_equal before, File.binread(manifest_path)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def test_explicit_degraded_bootstrap_preserves_failed_emergency_provenance
|
||||||
|
manifest = valid_manifest
|
||||||
|
manifest.dig("consumers", CONSUMER, "current")["status"] = "observed-production-contract-failed"
|
||||||
|
write_manifest(manifest)
|
||||||
|
candidate_fingerprint = @promoter.fingerprints(
|
||||||
|
consumer: CONSUMER,
|
||||||
|
consumer_commit: CANDIDATE_COMMIT
|
||||||
|
).fetch("candidate_tuple_sha256")
|
||||||
|
evidence = write_evidence(
|
||||||
|
"bootstrap-candidate.json",
|
||||||
|
commit: CANDIDATE_COMMIT,
|
||||||
|
timestamp: CANDIDATE_TIME,
|
||||||
|
fingerprint: candidate_fingerprint
|
||||||
|
)
|
||||||
|
before = File.binread(manifest_path)
|
||||||
|
|
||||||
|
preview = @promoter.bootstrap_current(
|
||||||
|
consumer: CONSUMER,
|
||||||
|
consumer_commit: CANDIDATE_COMMIT,
|
||||||
|
certification: certification(CANDIDATE_TIME, evidence),
|
||||||
|
acknowledgement: OpenCodeCompat::RuntimeTuplePromoter::DEGRADED_BOOTSTRAP_ACKNOWLEDGEMENT,
|
||||||
|
dry_run: true
|
||||||
|
)
|
||||||
|
|
||||||
|
assert_equal before, File.binread(manifest_path)
|
||||||
|
assert_equal "bootstrap-current-only", preview.fetch("migration_state")
|
||||||
|
|
||||||
|
bootstrapped = @promoter.bootstrap_current(
|
||||||
|
consumer: CONSUMER,
|
||||||
|
consumer_commit: CANDIDATE_COMMIT,
|
||||||
|
certification: certification(CANDIDATE_TIME, evidence),
|
||||||
|
acknowledgement: OpenCodeCompat::RuntimeTuplePromoter::DEGRADED_BOOTSTRAP_ACKNOWLEDGEMENT
|
||||||
|
)
|
||||||
|
consumer = bootstrapped.dig("consumers", CONSUMER)
|
||||||
|
|
||||||
|
assert_equal "certified", consumer.dig("current", "status")
|
||||||
|
assert_equal CANDIDATE_COMMIT, consumer.dig("current", "consumer_commit")
|
||||||
|
assert_nil consumer["candidate"]
|
||||||
|
assert_nil consumer["previous"]
|
||||||
|
assert_equal "observed-production-contract-failed", consumer.dig("emergency_provenance", "status")
|
||||||
|
assert_nil consumer.dig("emergency_provenance", "certification")
|
||||||
|
assert_equal "degraded-no-certified-previous", consumer.dig("rollback_state", "status")
|
||||||
|
assert_equal "bootstrap-current-only", bootstrapped.dig("promotion_readiness", "status")
|
||||||
|
assert_equal bootstrapped, JSON.parse(File.read(manifest_path))
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_bootstrap_then_normal_promotion_retains_the_first_passing_tuple_as_previous
|
||||||
|
manifest = valid_manifest
|
||||||
|
manifest.dig("consumers", CONSUMER, "current")["status"] = "observed-production-contract-failed"
|
||||||
|
write_manifest(manifest)
|
||||||
|
bootstrap_fingerprint = @promoter.fingerprints(
|
||||||
|
consumer: CONSUMER,
|
||||||
|
consumer_commit: CANDIDATE_COMMIT
|
||||||
|
).fetch("candidate_tuple_sha256")
|
||||||
|
bootstrap_evidence = write_evidence(
|
||||||
|
"bootstrap-rollback.json",
|
||||||
|
commit: CANDIDATE_COMMIT,
|
||||||
|
timestamp: CANDIDATE_TIME,
|
||||||
|
fingerprint: bootstrap_fingerprint
|
||||||
|
)
|
||||||
|
@promoter.bootstrap_current(
|
||||||
|
consumer: CONSUMER,
|
||||||
|
consumer_commit: CANDIDATE_COMMIT,
|
||||||
|
certification: certification(CANDIDATE_TIME, bootstrap_evidence),
|
||||||
|
acknowledgement: OpenCodeCompat::RuntimeTuplePromoter::DEGRADED_BOOTSTRAP_ACKNOWLEDGEMENT
|
||||||
|
)
|
||||||
|
|
||||||
|
manifest = JSON.parse(File.read(manifest_path))
|
||||||
|
manifest.dig("consumers", CONSUMER)["candidate"] = {
|
||||||
|
"status" => "compatibility-certified",
|
||||||
|
"certified_at" => NEXT_TIME,
|
||||||
|
"consumer_commit" => NEXT_COMMIT,
|
||||||
|
"opencode_ruby" => {"version" => "0.0.1.alpha8", "git_commit" => RUBY_NEXT},
|
||||||
|
"opencode_rails" => {"version" => "0.0.1.alpha8", "git_commit" => RAILS_NEXT},
|
||||||
|
"runtime" => {"image" => NEXT_IMAGE, "reported_version" => "1.19.0"}
|
||||||
|
}
|
||||||
|
write_manifest(manifest)
|
||||||
|
next_fingerprint = @promoter.fingerprints(
|
||||||
|
consumer: CONSUMER,
|
||||||
|
consumer_commit: NEXT_COMMIT
|
||||||
|
).fetch("candidate_tuple_sha256")
|
||||||
|
next_evidence = write_evidence(
|
||||||
|
"next-current.json",
|
||||||
|
commit: NEXT_COMMIT,
|
||||||
|
timestamp: NEXT_TIME,
|
||||||
|
fingerprint: next_fingerprint
|
||||||
|
)
|
||||||
|
|
||||||
|
promoted = @promoter.promote(
|
||||||
|
consumer: CONSUMER,
|
||||||
|
consumer_commit: NEXT_COMMIT,
|
||||||
|
certification: certification(NEXT_TIME, next_evidence)
|
||||||
|
)
|
||||||
|
consumer = promoted.dig("consumers", CONSUMER)
|
||||||
|
|
||||||
|
assert_equal NEXT_COMMIT, consumer.dig("current", "consumer_commit")
|
||||||
|
assert_equal CANDIDATE_COMMIT, consumer.dig("previous", "consumer_commit")
|
||||||
|
assert_equal "certified", consumer.dig("previous", "status")
|
||||||
|
assert_equal "observed-production-contract-failed", consumer.dig("emergency_provenance", "status")
|
||||||
|
refute consumer.key?("rollback_state")
|
||||||
|
assert_equal "certified", promoted.fetch("migration_state")
|
||||||
|
assert_equal "certified", promoted.dig("promotion_readiness", "status")
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_degraded_bootstrap_requires_exact_acknowledgement
|
||||||
|
error = assert_raises(OpenCodeCompat::PromotionError) do
|
||||||
|
@promoter.bootstrap_current(
|
||||||
|
consumer: CONSUMER,
|
||||||
|
consumer_commit: CANDIDATE_COMMIT,
|
||||||
|
certification: {},
|
||||||
|
acknowledgement: "yes",
|
||||||
|
dry_run: true
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
assert_match(/explicit acknowledgement/, error.message)
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_degraded_bootstrap_rejects_a_passing_baseline_or_existing_previous_tuple
|
||||||
|
candidate, = write_matching_evidence
|
||||||
|
error = assert_raises(OpenCodeCompat::PromotionError) do
|
||||||
|
@promoter.bootstrap_current(
|
||||||
|
consumer: CONSUMER,
|
||||||
|
consumer_commit: CANDIDATE_COMMIT,
|
||||||
|
certification: certification(CANDIDATE_TIME, candidate),
|
||||||
|
acknowledgement: OpenCodeCompat::RuntimeTuplePromoter::DEGRADED_BOOTSTRAP_ACKNOWLEDGEMENT,
|
||||||
|
dry_run: true
|
||||||
|
)
|
||||||
|
end
|
||||||
|
assert_match(/only for a current tuple known to fail/, error.message)
|
||||||
|
|
||||||
|
manifest = valid_manifest
|
||||||
|
manifest.dig("consumers", CONSUMER, "current")["status"] = "observed-production-contract-failed"
|
||||||
|
manifest.dig("consumers", CONSUMER)["previous"] = manifest.dig("consumers", CONSUMER, "current").dup
|
||||||
|
write_manifest(manifest)
|
||||||
|
candidate_fingerprint = @promoter.fingerprints(
|
||||||
|
consumer: CONSUMER,
|
||||||
|
consumer_commit: CANDIDATE_COMMIT
|
||||||
|
).fetch("candidate_tuple_sha256")
|
||||||
|
evidence = write_evidence(
|
||||||
|
"bootstrap-existing-previous.json",
|
||||||
|
commit: CANDIDATE_COMMIT,
|
||||||
|
timestamp: CANDIDATE_TIME,
|
||||||
|
fingerprint: candidate_fingerprint
|
||||||
|
)
|
||||||
|
error = assert_raises(OpenCodeCompat::PromotionError) do
|
||||||
|
@promoter.bootstrap_current(
|
||||||
|
consumer: CONSUMER,
|
||||||
|
consumer_commit: CANDIDATE_COMMIT,
|
||||||
|
certification: certification(CANDIDATE_TIME, evidence),
|
||||||
|
acknowledgement: OpenCodeCompat::RuntimeTuplePromoter::DEGRADED_BOOTSTRAP_ACKNOWLEDGEMENT,
|
||||||
|
dry_run: true
|
||||||
|
)
|
||||||
|
end
|
||||||
|
assert_match(/cannot replace an existing previous/, error.message)
|
||||||
|
end
|
||||||
|
|
||||||
def test_rejects_evidence_that_does_not_match_the_complete_tuple
|
def test_rejects_evidence_that_does_not_match_the_complete_tuple
|
||||||
candidate, previous = write_matching_evidence
|
candidate, previous = write_matching_evidence
|
||||||
manifest = JSON.parse(File.read(manifest_path))
|
manifest = JSON.parse(File.read(manifest_path))
|
||||||
|
|||||||
Reference in New Issue
Block a user