diff --git a/.github/workflows/showcase_lint_prod.yml b/.github/workflows/showcase_lint_prod.yml
new file mode 100644
index 00000000000..71d3c50447f
--- /dev/null
+++ b/.github/workflows/showcase_lint_prod.yml
@@ -0,0 +1,173 @@
+name: "Showcase: lint-prod (digest pinning)"
+
+# Runs `bin/railway lint-prod` on every PR that touches showcase/.
+#
+# Currently ADVISORY: the `--exit-zero` flag makes the step exit 0 even when
+# findings exist, so this workflow will not block PRs while we soak. Findings
+# still print to the step log so we can monitor drift. Once we have confidence
+# the findings are clean, remove `--exit-zero` to flip this to enforcing.
+#
+# Long-term contract: production must always be reproducible from a snapshot
+# (every service pinned to `ghcr.io/...@sha256:...`).
+#
+# Visibility surfaces:
+# - $GITHUB_STEP_SUMMARY: structured markdown table rendered at the top of
+# the workflow run page (every run, push or pull_request).
+# - Sticky PR comment: a single comment per PR, found+updated via an HTML
+# marker (). Only posted on
+# pull_request events; push events on main only write the step summary.
+
+on:
+ pull_request:
+ paths:
+ - "showcase/**"
+ - ".github/workflows/showcase_lint_prod.yml"
+ workflow_dispatch:
+
+concurrency:
+ group: showcase-lint-prod-${{ github.ref }}
+ cancel-in-progress: true
+
+permissions:
+ contents: read
+ pull-requests: write
+
+jobs:
+ lint-prod:
+ name: Lint production pinning (advisory)
+ runs-on: ubuntu-latest
+ timeout-minutes: 5
+ steps:
+ - name: Checkout
+ uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
+ with:
+ persist-credentials: false
+
+ - name: Set up Ruby
+ uses: ruby/setup-ruby@afeafc3d1ab54a631816aba4c914a0081c12ff2f # v1.310.0
+ with:
+ ruby-version: "3.2"
+
+ - name: Lint production pinning (advisory)
+ id: lint
+ env:
+ RAILWAY_TOKEN: ${{ secrets.RAILWAY_TOKEN }}
+ run: |
+ set -uo pipefail
+ if [ -z "${RAILWAY_TOKEN:-}" ]; then
+ echo "::warning::RAILWAY_TOKEN secret not configured; skipping lint-prod."
+ echo "skipped=true" >> "$GITHUB_OUTPUT"
+ echo "findings=0" >> "$GITHUB_OUTPUT"
+ echo "errored=false" >> "$GITHUB_OUTPUT"
+ exit 0
+ fi
+ echo "skipped=false" >> "$GITHUB_OUTPUT"
+ # Advisory mode: --exit-zero so findings don't block the PR while we soak.
+ # Flip to enforcing by removing --exit-zero once findings are clean.
+ # Also emit machine-readable JSON for the visibility renderer.
+ #
+ # We capture stderr + exit code so a snapshot/GraphQL error doesn't
+ # abort the workflow before we can render a "audit unavailable" block.
+ # The intent of advisory mode is "never block a PR on this check".
+ rc=0
+ ruby showcase/bin/railway lint-prod --exit-zero --format json \
+ > lint-prod.json 2> lint-prod.err || rc=$?
+ # Mirror to the job log for humans (best-effort; ignore errors).
+ ruby showcase/bin/railway lint-prod --exit-zero || true
+ if [ "${rc}" -ne 0 ] || [ ! -s lint-prod.json ]; then
+ echo "::warning::lint-prod failed (rc=${rc}); rendering audit-unavailable block."
+ echo "--- lint-prod stderr ---"
+ cat lint-prod.err || true
+ echo "errored=true" >> "$GITHUB_OUTPUT"
+ echo "findings=0" >> "$GITHUB_OUTPUT"
+ # Synthesize an empty payload so the renderer has something to chew on.
+ ruby -rjson -rtime -e '
+ err = File.exist?("lint-prod.err") ? File.read("lint-prod.err").strip : ""
+ puts JSON.generate({"services" => [], "findings" => 0,
+ "timestamp" => Time.now.utc.iso8601,
+ "error" => err})
+ ' > lint-prod.json
+ else
+ echo "errored=false" >> "$GITHUB_OUTPUT"
+ findings=$(ruby -rjson -e 'puts JSON.parse(File.read("lint-prod.json"))["findings"]')
+ echo "findings=${findings}" >> "$GITHUB_OUTPUT"
+ fi
+
+ - name: Render audit summary
+ if: always() && steps.lint.outputs.skipped != 'true'
+ id: render
+ run: |
+ set -uo pipefail
+ # Render in America/Los_Angeles so the timestamp respects DST.
+ export TZ="America/Los_Angeles"
+ ruby <<'RUBY' > audit.md
+ require "json"
+ require "time"
+ data = JSON.parse(File.read("lint-prod.json"))
+ services = data["services"] || []
+ findings = (data["findings"] || 0).to_i
+ ts_utc = Time.parse(data["timestamp"] || Time.now.utc.iso8601)
+ ts_pt = ts_utc.getlocal
+ total = services.size
+ err = data["error"].to_s
+ out = +""
+ out << "## Production Digest-Pinning Audit\n\n"
+ if !err.empty?
+ # Audit failed to run (e.g. snapshot/GraphQL error). Render a fallback
+ # block instead of leaving the surface blank.
+ out << "Audit could not run this round.\n\n"
+ out << "Error
\n\n```\n#{err}\n```\n\n \n\n"
+ elsif findings.zero?
+ out << "All #{total} services digest-pinned.\n\n"
+ else
+ out << "#{findings} of #{total} service(s) not digest-pinned.\n\n"
+ out << "| Service | Source.image | Status |\n"
+ out << "|---|---|---|\n"
+ services.each do |s|
+ next if s["status"] == "pinned"
+ src = s["source"].to_s.empty? ? "_(unset)_" : "`#{s['source']}`"
+ out << "| #{s['name']} | #{src} | mutable-tag |\n"
+ end
+ out << "\n"
+ end
+ out << "_Run #{ts_pt.strftime('%Y-%m-%d %H:%M:%S %Z')} — #{findings} finding(s)._\n"
+ puts out
+ RUBY
+ # Write to step summary (top of run page).
+ cat audit.md >> "$GITHUB_STEP_SUMMARY"
+ # Save body (with sticky marker) for the comment step.
+ {
+ echo ""
+ cat audit.md
+ } > comment.md
+
+ - name: Post/update sticky PR comment
+ if: always() && github.event_name == 'pull_request' && steps.lint.outputs.skipped != 'true'
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ PR_NUMBER: ${{ github.event.pull_request.number }}
+ REPO: ${{ github.repository }}
+ run: |
+ set -uo pipefail
+ MARKER=""
+ # Find existing sticky comment (returns id only).
+ existing_id=$(gh api \
+ "/repos/${REPO}/issues/${PR_NUMBER}/comments?per_page=100" \
+ --jq ".[] | select(.body | contains(\"${MARKER}\")) | .id" \
+ | head -1)
+ # Build a JSON body file so we can PATCH/POST a multiline body safely.
+ ruby -rjson -e 'puts JSON.generate({body: File.read("comment.md")})' \
+ > comment.json
+ if [ -n "${existing_id:-}" ]; then
+ echo "Updating existing sticky comment id=${existing_id}"
+ gh api -X PATCH \
+ "/repos/${REPO}/issues/comments/${existing_id}" \
+ --input comment.json > /dev/null
+ else
+ echo "Creating new sticky comment"
+ gh pr comment "${PR_NUMBER}" --repo "${REPO}" --body-file comment.md
+ fi
+
+ - name: Run bin/railway tests
+ run: |
+ ruby showcase/bin/spec/all_tests.rb
diff --git a/sdk-python/copilotkit/langgraph_agui_agent.py b/sdk-python/copilotkit/langgraph_agui_agent.py
index 1cf8de45690..b0778f19b03 100644
--- a/sdk-python/copilotkit/langgraph_agui_agent.py
+++ b/sdk-python/copilotkit/langgraph_agui_agent.py
@@ -291,5 +291,8 @@ def langgraph_default_merge_state(
def dict_repr(self):
"""Return dictionary representation of the agent"""
- super_repr = super().dict_repr()
- return {**super_repr, "type": "langgraph_agui"}
+ return {
+ "name": self.name,
+ "description": self.description or "",
+ "type": "langgraph_agui",
+ }
diff --git a/sdk-python/tests/test_agui_agent.py b/sdk-python/tests/test_agui_agent.py
index af78599852b..9cd770dd8b2 100644
--- a/sdk-python/tests/test_agui_agent.py
+++ b/sdk-python/tests/test_agui_agent.py
@@ -22,6 +22,7 @@
StateSnapshotEvent,
)
from ag_ui_langgraph import LangGraphAgent as AGUIBase
+from copilotkit import CopilotKitRemoteEndpoint
from copilotkit.langgraph_agui_agent import (
LangGraphAGUIAgent,
CustomEventNames,
@@ -625,3 +626,38 @@ def test_agui_style_event_not_suppressed_by_dispatch(self):
assert EventType.CUSTOM in types
assert EventType.TEXT_MESSAGE_START not in types
assert dispatched[0] is original
+
+
+class TestAgentMetadata:
+ """Regression coverage for info() serializing LangGraphAGUIAgent metadata."""
+
+ def test_dict_repr_includes_type_without_parent_support(self, agent):
+ """dict_repr should not depend on AG-UI's base class implementing dict_repr."""
+ assert agent.dict_repr() == {
+ "name": "test",
+ "description": "",
+ "type": "langgraph_agui",
+ }
+
+ def test_remote_endpoint_info_serializes_langgraph_agui_agent(self):
+ """sdk.info should include LangGraphAGUIAgent metadata without raising."""
+ mock_graph = MagicMock()
+ mock_graph.get_state = MagicMock()
+ agent = LangGraphAGUIAgent(
+ name="demo",
+ graph=mock_graph,
+ description="demo agent",
+ )
+ sdk = CopilotKitRemoteEndpoint(agents=[agent], actions=[])
+
+ info = sdk.info(
+ context={"properties": {}, "frontend_url": None, "headers": {}}
+ )
+
+ assert info["agents"] == [
+ {
+ "name": "demo",
+ "description": "demo agent",
+ "type": "langgraph_agui",
+ }
+ ]
diff --git a/showcase/bin/README.md b/showcase/bin/README.md
new file mode 100644
index 00000000000..f94e7903444
--- /dev/null
+++ b/showcase/bin/README.md
@@ -0,0 +1,159 @@
+# `bin/railway`
+
+Single-file Ruby tooling for showcase Railway operations.
+
+## Why
+
+The Showcase platform lives on Railway across two environments (staging and
+production). Day-to-day operations — promoting staging to production, pinning
+services to immutable image digests, rolling a bad deploy back, auditing drift
+between envs — used to require ad-hoc shell + GraphQL recipes. `bin/railway`
+makes those operations first-class CLI subcommands with consistent flags,
+exit codes, and production protection.
+
+## Install
+
+None. Requires system Ruby 3.x (stdlib only — no Bundler, no Gemfile).
+
+```sh
+showcase/bin/railway --help
+```
+
+## Auth
+
+The tool reads a Railway API token from (in order):
+
+1. `RAILWAY_TOKEN` environment variable
+2. `~/.railway/config.json` (the `token` field, or `user.token`)
+
+It never invokes `railway login`, `railway logout`, or `op`. If neither source
+yields a token, it exits with code 2 and a clear error.
+
+For GHCR digest resolution (`resolve-digest`, `pin`), set `GHCR_TOKEN` if you
+need to read private packages; public packages work anonymously via the GHCR
+`/token` endpoint.
+
+## Subcommands
+
+| Subcommand | Purpose |
+| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
+| `snapshot` | Capture an env's services + config into a YAML snapshot. |
+| `restore` | Restore an env to a snapshot (force-redeploy each service). |
+| `rollback` | Roll a single service back one deploy (or to a specific deployment id with `--to`). |
+| `rollback-commit` | Restore an env to the snapshot committed at a given git SHA. |
+| `promote` | Promote staging digests to production with prechecks. |
+| `pin` | Pin a service to a specific image digest. |
+| `env-diff` | Diff two envs; exits 1 on drift. |
+| `resolve-digest` | Resolve an image tag (e.g. `:latest`) to its `sha256:` digest. |
+| `lint-prod` | CI gate (advisory): warn if any prod service is not digest-pinned. `--exit-zero` for advisory mode, `--format json` for machine-readable output. |
+
+Run any subcommand with `--help` for full flag list.
+
+## Production protection
+
+Every subcommand that mutates state requires both:
+
+- `--yes` flag, **and**
+- typed confirmation of the literal string `production` on stdin
+
+…before any production mutation runs. `--non-interactive` skips the prompt
+but still requires `--yes`. There is no way to mutate production without an
+explicit acknowledgement.
+
+## Exit codes
+
+| Code | Meaning |
+| ---- | ------------------------------------------------------------------------ |
+| 0 | Clean / success |
+| 1 | Drift detected, findings reported, or promote refused for policy reasons |
+| 2 | Error (auth, network, GraphQL schema, refused confirmation, etc.) |
+
+## Worked example: promote staging → production
+
+```sh
+# 1. Audit drift first (read-only).
+showcase/bin/railway env-diff staging production
+# DRIFT: 3 finding(s)
+# service showcase-shell: digest sha256:abc != sha256:def
+# ...
+
+# 2. Lint prod to confirm baseline is pinned.
+showcase/bin/railway lint-prod
+# OK: all production services digest-pinned.
+
+# 3. Capture a "before" snapshot in case we need to roll back.
+showcase/bin/railway snapshot --env production --output before-promote.yaml
+
+# 4. Run the promote with prechecks. Production confirmation prompt fires here.
+showcase/bin/railway promote --yes
+# Type 'production' to confirm promote: production
+# promoted showcase-shell -> ghcr.io/copilotkit/showcase-shell@sha256:def...
+# ...
+
+# If anything goes sideways:
+showcase/bin/railway restore --env production --snapshot before-promote.yaml --yes
+```
+
+## CI integration
+
+`.github/workflows/showcase_lint_prod.yml` runs `bin/railway lint-prod` on
+every PR that touches `showcase/**`.
+
+**Currently advisory** — the workflow passes `--exit-zero`, so findings print
+to the job log but do not fail the PR. This lets us soak the check against
+real production state before turning it into a hard gate. Once we've built
+confidence the findings are clean, remove `--exit-zero` from the workflow to
+flip the check to enforcing (exit 1 on drift).
+
+Long-term contract: every production service must be pinned to an immutable
+`ghcr.io/...@sha256:...` digest, and the lint job will fail any PR that
+drifts away from that.
+
+### Visibility surfaces
+
+Two human-facing surfaces render the audit result every run:
+
+1. **Workflow step summary** — the workflow writes a structured markdown
+ block to `$GITHUB_STEP_SUMMARY` so the audit shows at the top of every
+ run page (every event: `pull_request`, `push`, `workflow_dispatch`).
+2. **Sticky PR comment** — on `pull_request` events, the workflow posts (or
+ updates) a single comment per PR. The comment is keyed by the HTML marker
+ `` so re-runs update the same comment
+ instead of creating duplicates.
+
+Both surfaces show the same content: a one-line status, a table of the
+unpinned services (only — `pinned` services are not enumerated), and a
+Pacific-time run timestamp with the finding count.
+
+### Machine-readable output
+
+`lint-prod --format json` emits:
+
+```json
+{
+ "services": [
+ { "name": "...", "source": "...", "status": "pinned|mutable-tag" }
+ ],
+ "findings": 3,
+ "timestamp": "2026-05-27T18:00:00Z"
+}
+```
+
+The CI workflow uses this shape to render the step summary and PR comment.
+The `findings` count is also written to `$GITHUB_OUTPUT` so downstream jobs
+(e.g. a future Slack alert step) can compare against prior runs.
+
+## Tests
+
+```sh
+ruby showcase/bin/spec/all_tests.rb
+```
+
+Tests are minitest (stdlib). They cover:
+
+- argv parsing per subcommand
+- snapshot YAML round-trip
+- GHCR digest-resolution decision tree (mocked HTTP)
+- production-protection prompt behavior
+
+No Railway / GHCR network calls are made during tests.
diff --git a/showcase/bin/railway b/showcase/bin/railway
new file mode 100755
index 00000000000..1bf96a2b045
--- /dev/null
+++ b/showcase/bin/railway
@@ -0,0 +1,1254 @@
+#!/usr/bin/env ruby
+# frozen_string_literal: true
+
+# bin/railway — single-file Ruby tooling for showcase Railway operations.
+#
+# Subcommands:
+# snapshot Capture current service config to a YAML snapshot.
+# restore Restore a snapshot to an environment (force-redeploy).
+# rollback Roll a single service back to its previous deploy.
+# rollback-commit Roll all services back to digests captured at a git SHA.
+# promote Promote staging snapshot to production with prechecks.
+# pin Pin a service to a specific image digest.
+# env-diff Diff two environments and exit non-zero on drift.
+# resolve-digest Resolve an image reference to its content digest via GHCR.
+# lint-prod Verify production is fully digest-pinned (CI gate).
+#
+# Stdlib only — no Bundler, no Gemfile. Ruby 3.x.
+#
+# Auth: RAILWAY_TOKEN env var, or ~/.railway/config.json (token field).
+# Never invokes `railway login` / `railway logout` / `op` / any external CLI.
+#
+# Production protection: --yes + typed env confirmation (unless --non-interactive).
+#
+# Exit codes:
+# 0 clean / success
+# 1 drift / findings
+# 2 error (network, auth, schema, etc.)
+
+require "json"
+require "net/http"
+require "optparse"
+require "set"
+require "uri"
+require "yaml"
+require "io/console"
+require "fileutils"
+require "time"
+
+module Railway
+ VERSION = "0.1.0"
+
+ # ── Constants ──────────────────────────────────────────────────────────────
+
+ WORKSPACE = "CopilotKit"
+ PROJECT_ID = "6f8c6bff-a80d-4f8f-b78d-50b32bcf4479"
+ PRODUCTION_ENV_ID = "b14919f4-6417-429f-848d-c6ae2201e04f"
+ STAGING_ENV_ID = "8edfef02-ea09-4a20-8689-261f21cc2849"
+ GHCR_ORG = "copilotkit"
+
+ GRAPHQL_ENDPOINT = "https://backboard.railway.app/graphql/v2"
+
+ ENV_IDS = {
+ "production" => PRODUCTION_ENV_ID,
+ "prod" => PRODUCTION_ENV_ID,
+ "staging" => STAGING_ENV_ID,
+ "stage" => STAGING_ENV_ID,
+ }.freeze
+
+ # Env vars required to be present (parity-checked) before any promote.
+ CRITICAL_ENV_KEYS = %w[
+ RAILWAY_TOKEN
+ GHCR_TOKEN
+ SHARED_SECRET
+ OPS_TRIGGER_TOKEN
+ POCKETBASE_SUPERUSER_EMAIL
+ POCKETBASE_SUPERUSER_PASSWORD
+ GITHUB_APP_PRIVATE_KEY
+ OPENAI_API_KEY
+ ANTHROPIC_API_KEY
+ GOOGLE_API_KEY
+ ].freeze
+
+ EXPECTED_DOMAINS = {
+ PRODUCTION_ENV_ID => %w[
+ showcase.copilotkit.ai
+ dashboard.showcase.copilotkit.ai
+ dojo.showcase.copilotkit.ai
+ docs.copilotkit.ai
+ hooks.showcase.copilotkit.ai
+ ].freeze,
+ STAGING_ENV_ID => %w[
+ showcase.staging.copilotkit.ai
+ dashboard.showcase.staging.copilotkit.ai
+ dojo.showcase.staging.copilotkit.ai
+ docs.staging.copilotkit.ai
+ hooks.staging.copilotkit.ai
+ ].freeze,
+ }.freeze
+
+ # Heuristic markers for env-scoped URLs (ignored in env-diff and promote).
+ ENV_SCOPED_URL_MARKERS = [
+ ".staging.copilotkit.ai",
+ ".copilotkit.ai",
+ "staging-",
+ "prod-",
+ ].freeze
+
+ # ── Token / Auth ───────────────────────────────────────────────────────────
+
+ module Auth
+ module_function
+
+ def token
+ t = ENV["RAILWAY_TOKEN"]
+ return t.strip if t && !t.strip.empty?
+
+ cfg = File.expand_path("~/.railway/config.json")
+ if File.exist?(cfg)
+ begin
+ data = JSON.parse(File.read(cfg))
+ # Railway CLI stores the bearer in `user.accessToken` (43+ chars).
+ # `user.token` is a short legacy CLI session token that does
+ # NOT authenticate to the public GraphQL API. Prefer accessToken.
+ candidate =
+ data.dig("user", "accessToken") ||
+ data["accessToken"] ||
+ data.dig("user", "token") ||
+ data["token"] ||
+ data.dig("projects", PROJECT_ID, "token")
+ return candidate.strip if candidate.is_a?(String) && !candidate.strip.empty?
+ rescue JSON::ParserError
+ # fall through
+ end
+ end
+
+ nil
+ end
+
+ def require_token!
+ t = token
+ if t.nil? || t.empty?
+ Railway.die!("RAILWAY_TOKEN not set and ~/.railway/config.json not usable. " \
+ "Export RAILWAY_TOKEN before running.")
+ end
+ t
+ end
+ end
+
+ # ── GraphQL Client ─────────────────────────────────────────────────────────
+
+ class GraphQL
+ class Error < StandardError; end
+
+ def initialize(token: nil, endpoint: GRAPHQL_ENDPOINT, http: nil)
+ @token = token || Auth.require_token!
+ @endpoint = endpoint
+ @http = http # optional injection for tests
+ end
+
+ # Execute a query. Returns parsed data hash; raises on errors.
+ def query(query, variables = {})
+ body = { query: query, variables: variables }.to_json
+
+ if @http
+ resp = @http.call(endpoint: @endpoint, token: @token, body: body)
+ else
+ uri = URI(@endpoint)
+ req = Net::HTTP::Post.new(uri)
+ req["Content-Type"] = "application/json"
+ req["Authorization"] = "Bearer #{@token}"
+ req.body = body
+
+ resp = Net::HTTP.start(uri.host, uri.port, use_ssl: true,
+ open_timeout: 10, read_timeout: 30) do |h|
+ h.request(req)
+ end
+ end
+
+ status = resp.respond_to?(:code) ? resp.code.to_i : resp[:status].to_i
+ body_str = resp.respond_to?(:body) ? resp.body : resp[:body]
+
+ raise Error, "HTTP #{status}: #{body_str}" if status >= 400
+
+ parsed = JSON.parse(body_str)
+ if parsed["errors"] && !parsed["errors"].empty?
+ msgs = parsed["errors"].map { |e| e["message"] }.join("; ")
+ raise Error, "GraphQL: #{msgs}"
+ end
+ parsed["data"]
+ end
+ end
+
+ # ── GHCR Client ────────────────────────────────────────────────────────────
+ #
+ # We need to resolve a tag (e.g. `ghcr.io/copilotkit/showcase-shell:latest`)
+ # to its content-addressable digest (`sha256:...`). GHCR exposes the
+ # OCI Distribution Spec at https://ghcr.io/v2///manifests/.
+ # Requires a bearer token; for public images, a token issued via the
+ # /token endpoint works.
+ class GHCR
+ class Error < StandardError; end
+
+ ACCEPT_MANIFEST = [
+ "application/vnd.oci.image.index.v1+json",
+ "application/vnd.oci.image.manifest.v1+json",
+ "application/vnd.docker.distribution.manifest.list.v2+json",
+ "application/vnd.docker.distribution.manifest.v2+json",
+ ].join(", ").freeze
+
+ def initialize(token: ENV["GHCR_TOKEN"], http: nil)
+ @token = token
+ @http = http
+ end
+
+ # Resolve "ghcr.io/copilotkit/showcase-shell:latest" to "sha256:<...>".
+ # Returns nil if the tag does not exist.
+ def resolve_digest(image_ref)
+ parts = parse_image_ref(image_ref)
+ return parts[:digest] if parts[:digest] # already pinned
+
+ org = parts[:org]
+ name = parts[:name]
+ tag = parts[:tag] || "latest"
+
+ bearer = bearer_for(org, name)
+ url = "https://ghcr.io/v2/#{org}/#{name}/manifests/#{tag}"
+
+ if @http
+ resp = @http.call(method: :head, url: url, headers: headers(bearer))
+ else
+ resp = http_head(url, headers: headers(bearer))
+ end
+
+ status = resp[:status]
+ return nil if status == 404
+ raise Error, "GHCR manifest HEAD #{status} for #{image_ref}" if status >= 400
+
+ digest = resp[:headers]["docker-content-digest"] ||
+ resp[:headers]["Docker-Content-Digest"]
+ raise Error, "GHCR did not return Docker-Content-Digest for #{image_ref}" if digest.nil?
+
+ digest
+ end
+
+ # Parse image ref into { registry, org, name, tag, digest }.
+ def parse_image_ref(ref)
+ r = ref.to_s.strip
+ registry = nil
+ if r.start_with?("ghcr.io/")
+ registry = "ghcr.io"
+ r = r.sub(/^ghcr\.io\//, "")
+ end
+
+ digest = nil
+ if r.include?("@")
+ r, digest = r.split("@", 2)
+ end
+
+ tag = nil
+ if r.include?(":")
+ r, tag = r.rsplit_colon
+ end
+
+ org, name = r.split("/", 2)
+ { registry: registry, org: org, name: name, tag: tag, digest: digest }
+ end
+
+ private
+
+ def headers(bearer)
+ h = { "Accept" => ACCEPT_MANIFEST }
+ h["Authorization"] = "Bearer #{bearer}" if bearer
+ h
+ end
+
+ def bearer_for(org, name)
+ return @token if @token && !@token.empty?
+ # Public images: anonymous token from /token endpoint.
+ url = "https://ghcr.io/token?service=ghcr.io&scope=repository:#{org}/#{name}:pull"
+ resp = http_get(url, headers: {})
+ return nil if resp[:status] >= 400
+ JSON.parse(resp[:body])["token"]
+ rescue StandardError
+ nil
+ end
+
+ def http_get(url, headers: {})
+ uri = URI(url)
+ req = Net::HTTP::Get.new(uri)
+ headers.each { |k, v| req[k] = v }
+ resp = Net::HTTP.start(uri.host, uri.port, use_ssl: true,
+ open_timeout: 10, read_timeout: 30) do |h|
+ h.request(req)
+ end
+ { status: resp.code.to_i, headers: resp.to_hash.transform_values(&:first), body: resp.body }
+ end
+
+ def http_head(url, headers: {})
+ uri = URI(url)
+ req = Net::HTTP::Head.new(uri)
+ headers.each { |k, v| req[k] = v }
+ resp = Net::HTTP.start(uri.host, uri.port, use_ssl: true,
+ open_timeout: 10, read_timeout: 30) do |h|
+ h.request(req)
+ end
+ hdrs = {}
+ resp.each_header { |k, v| hdrs[k] = v; hdrs[k.downcase] = v }
+ { status: resp.code.to_i, headers: hdrs, body: resp.body }
+ end
+ end
+
+ # ── Helpers ────────────────────────────────────────────────────────────────
+
+ module_function
+
+ def die!(msg, code: 2)
+ warn "railway: #{msg}"
+ exit code
+ end
+
+ def env_id_for(name)
+ ENV_IDS[name.to_s.downcase] || die!("Unknown env: #{name.inspect}. Use staging or production.")
+ end
+
+ def env_label(env_id)
+ case env_id
+ when PRODUCTION_ENV_ID then "production"
+ when STAGING_ENV_ID then "staging"
+ else env_id
+ end
+ end
+
+ def production?(env_id)
+ env_id == PRODUCTION_ENV_ID
+ end
+
+ # Prompt for typed confirmation. Returns true if confirmed.
+ def confirm_destructive!(env_label:, action:, non_interactive: false, yes: false)
+ return true unless production?(env_id_for(env_label))
+
+ unless yes
+ die!("Refusing #{action} on production without --yes.", code: 2)
+ end
+
+ if non_interactive
+ warn "[non-interactive] proceeding with #{action} on production (--yes given)."
+ return true
+ end
+
+ $stderr.print "Type 'production' to confirm #{action}: "
+ line = $stdin.gets&.strip
+ unless line == "production"
+ die!("Confirmation phrase mismatch. Aborting.", code: 2)
+ end
+ true
+ end
+
+ # Find service entry in a snapshot by name.
+ def find_service(snapshot, name)
+ (snapshot["services"] || []).find { |s| s["name"] == name }
+ end
+
+ # Strip env-scoped url-ish values when comparing two envs.
+ def env_scoped?(value)
+ return false unless value.is_a?(String)
+ ENV_SCOPED_URL_MARKERS.any? { |m| value.include?(m) }
+ end
+
+ # ── Snapshot Schema ────────────────────────────────────────────────────────
+ #
+ # snapshot:
+ # version: 1
+ # captured_at:
+ # project_id:
+ # environment:
+ # id:
+ # name: production|staging
+ # services:
+ # - name: showcase-shell
+ # service_id:
+ # image: ghcr.io/copilotkit/showcase-shell@sha256:...
+ # image_tag: ghcr.io/copilotkit/showcase-shell:latest
+ # digest: sha256:...
+ # start_command:
+ # auto_updates_disabled:
+ # latest_deployment_id:
+ # env_keys: [KEY1, KEY2, ...] # keys only, never values
+ # custom_domains: [...]
+ #
+ # We capture KEYS only for env vars (never values) so snapshots are safe
+ # to commit/share.
+ class SnapshotIO
+ SCHEMA_VERSION = 1
+
+ def self.write(path, snapshot)
+ FileUtils.mkdir_p(File.dirname(path)) unless path == "-"
+ yaml = YAML.dump(snapshot)
+ if path == "-"
+ $stdout.write(yaml)
+ else
+ File.write(path, yaml)
+ end
+ end
+
+ def self.read(path)
+ raw = path == "-" ? $stdin.read : File.read(path)
+ data = YAML.safe_load(raw, permitted_classes: [Time, Symbol], aliases: false)
+ unless data.is_a?(Hash) && data["version"] == SCHEMA_VERSION
+ Railway.die!("Snapshot schema mismatch (expected version=#{SCHEMA_VERSION}).")
+ end
+ data
+ end
+ end
+
+ # ── Service Inventory ──────────────────────────────────────────────────────
+ #
+ # GraphQL fragments used by snapshot/env-diff/promote/lint-prod.
+ #
+ # Railway's public schema notes (verified via introspection 2026-05):
+ # * Project has NO `domains` field. Custom domains are reached either via
+ # top-level `domains(projectId, environmentId, serviceId)` returning
+ # `AllDomains { customDomains, serviceDomains }`, or via
+ # `serviceInstance.domains` (same shape).
+ # * Service has NO `serviceInstances` field. To get an instance's
+ # image/startCommand/etc. for a given env, use
+ # `serviceInstance(serviceId, environmentId)` directly.
+ # * `serviceInstanceDeployV2` takes (serviceId, environmentId, commitSha)
+ # ONLY — no `image` arg. To pin a service to a specific image, use
+ # `serviceInstanceUpdate(serviceId, environmentId, input: { source: { image } })`
+ # followed by `serviceInstanceRedeploy`.
+ # * `Environment.variables` returns an `EnvironmentVariablesConnection`
+ # whose edges include `node.serviceId` — we filter by service id to get
+ # per-service env-key sets.
+ #
+ # The query below enumerates all services in the project and, for each one,
+ # the per-environment serviceInstance and that env's variables (keys only).
+ # We use GraphQL field aliases to fetch all per-service data in a single
+ # round-trip rather than N+1.
+
+ SERVICES_LIST_QUERY = <<~GQL
+ query ProjectServices($projectId: String!) {
+ project(id: $projectId) {
+ id
+ name
+ services {
+ edges {
+ node { id name }
+ }
+ }
+ }
+ }
+ GQL
+
+ SERVICE_INSTANCE_QUERY = <<~GQL
+ query ServiceInstance($serviceId: String!, $envId: String!) {
+ serviceInstance(serviceId: $serviceId, environmentId: $envId) {
+ id
+ serviceId
+ environmentId
+ startCommand
+ source { image repo }
+ latestDeployment { id status }
+ domains {
+ customDomains { id domain }
+ serviceDomains { id domain }
+ }
+ }
+ }
+ GQL
+
+ ENVIRONMENT_VARIABLES_QUERY = <<~GQL
+ query EnvVariables($envId: String!) {
+ environment(id: $envId) {
+ id
+ name
+ variables(first: 1000) {
+ edges {
+ node { name serviceId isSealed }
+ }
+ }
+ }
+ }
+ GQL
+
+ # ── Subcommands ────────────────────────────────────────────────────────────
+
+ class BaseCommand
+ attr_reader :argv, :options
+
+ def initialize(argv)
+ @argv = argv.dup
+ @options = default_options
+ end
+
+ def default_options
+ {
+ env: nil,
+ yes: false,
+ non_interactive: false,
+ dry_run: false,
+ output: nil,
+ }
+ end
+
+ def self.call(argv)
+ new(argv).run
+ end
+
+ # Each subcommand must implement run and parser.
+ def run
+ raise NotImplementedError
+ end
+
+ def parser
+ raise NotImplementedError
+ end
+
+ # Returns a Railway::GraphQL client (mockable via @gql=).
+ def gql
+ @gql ||= GraphQL.new
+ end
+
+ # Returns a Railway::GHCR client.
+ def ghcr
+ @ghcr ||= GHCR.new
+ end
+ end
+
+ # snapshot — capture an environment's state into a YAML file.
+ class SnapshotCommand < BaseCommand
+ def parser
+ OptionParser.new do |o|
+ o.banner = <<~BANNER
+ Usage: bin/railway snapshot --env [--output FILE] [--dry-run]
+
+ Capture current image digests, start commands, env-var KEYS,
+ and custom domains for every service in the given env.
+
+ Exits 0 on success. Exits 2 on error.
+ BANNER
+ o.on("--env ENV", "Environment (staging|production)") { |v| options[:env] = v }
+ o.on("--output FILE", "Write to FILE (default stdout)") { |v| options[:output] = v }
+ o.on("--dry-run", "Capture but do not write to disk") { options[:dry_run] = true }
+ o.on("-h", "--help", "Show this help") { puts o; exit 0 }
+ end
+ end
+
+ def run
+ parser.parse!(argv)
+ Railway.die!("--env is required") unless options[:env]
+ env_id = Railway.env_id_for(options[:env])
+
+ snap = build_snapshot(env_id)
+
+ if options[:dry_run] && options[:output].nil?
+ # always dump to stdout for dry-run with no output
+ puts YAML.dump(snap)
+ return 0
+ end
+
+ path = options[:output] ||
+ "showcase/.railway-snapshots/#{Time.now.utc.strftime('%Y%m%dT%H%M%SZ')}-#{options[:env]}.yaml"
+
+ SnapshotIO.write(path, snap)
+ warn "wrote snapshot: #{path}" unless path == "-"
+ 0
+ end
+
+ def build_snapshot(env_id)
+ # 1. List all services in the project.
+ list = gql.query(SERVICES_LIST_QUERY, projectId: PROJECT_ID)
+ service_nodes = (list.dig("project", "services", "edges") || []).map { |e| e["node"] }
+
+ # 2. Fetch the env's variables once, group keys by serviceId.
+ env_data = gql.query(ENVIRONMENT_VARIABLES_QUERY, envId: env_id)
+ keys_by_service = Hash.new { |h, k| h[k] = [] }
+ (env_data.dig("environment", "variables", "edges") || []).each do |edge|
+ n = edge["node"]
+ next unless n && n["serviceId"]
+ keys_by_service[n["serviceId"]] << n["name"]
+ end
+
+ # 3. For each service, fetch its serviceInstance for this env.
+ # Some services may not exist in this env (returns nil); skip them.
+ services = []
+ service_nodes.each do |node|
+ instance_data = gql.query(SERVICE_INSTANCE_QUERY,
+ serviceId: node["id"], envId: env_id)
+ inst = instance_data["serviceInstance"]
+ next if inst.nil?
+
+ image_ref = inst.dig("source", "image")
+ digest = nil
+ tag_ref = image_ref
+ if image_ref&.include?("@sha256:")
+ tag_ref, digest = image_ref.split("@", 2)
+ end
+
+ custom_domains = (inst.dig("domains", "customDomains") || [])
+ .map { |d| d["domain"] }.compact.sort
+
+ services << {
+ "name" => node["name"],
+ "service_id" => node["id"],
+ "image" => image_ref,
+ "image_tag" => tag_ref,
+ "digest" => digest,
+ "start_command" => inst["startCommand"],
+ "auto_updates_disabled" => nil,
+ "latest_deployment_id" => inst.dig("latestDeployment", "id"),
+ "env_keys" => (keys_by_service[node["id"]] || []).sort.uniq,
+ "custom_domains" => custom_domains,
+ }
+ end
+
+ {
+ "version" => SnapshotIO::SCHEMA_VERSION,
+ "captured_at" => Time.now.utc.iso8601,
+ "project_id" => PROJECT_ID,
+ "environment" => { "id" => env_id, "name" => Railway.env_label(env_id) },
+ "services" => services.sort_by { |s| s["name"].to_s },
+ }
+ end
+ end
+
+ # restore — given a snapshot YAML, force-redeploy each service to its
+ # captured digest via serviceInstanceUpdate(source.image) + redeploy.
+ #
+ # Railway's `serviceInstanceDeployV2` mutation does NOT accept an `image`
+ # arg — its signature is (serviceId, environmentId, commitSha). To pin a
+ # service to a specific image digest we must:
+ # 1. update the service instance's source.image to the desired ref
+ # 2. trigger a redeploy
+ class RestoreCommand < BaseCommand
+ UPDATE_IMAGE_MUTATION = <<~GQL
+ mutation UpdateImage($serviceId: String!, $envId: String!, $image: String!) {
+ serviceInstanceUpdate(
+ serviceId: $serviceId
+ environmentId: $envId
+ input: { source: { image: $image } }
+ )
+ }
+ GQL
+
+ REDEPLOY_MUTATION = <<~GQL
+ mutation Redeploy($serviceId: String!, $envId: String!) {
+ serviceInstanceRedeploy(
+ serviceId: $serviceId
+ environmentId: $envId
+ )
+ }
+ GQL
+
+ # Helper used by RestoreCommand, PinCommand, PromoteCommand: pin then redeploy.
+ def self.pin_and_redeploy(gql, service_id:, env_id:, image:)
+ gql.query(UPDATE_IMAGE_MUTATION,
+ serviceId: service_id, envId: env_id, image: image)
+ gql.query(REDEPLOY_MUTATION,
+ serviceId: service_id, envId: env_id)
+ end
+
+ def parser
+ OptionParser.new do |o|
+ o.banner = <<~BANNER
+ Usage: bin/railway restore --env ENV --snapshot FILE [--yes] [--non-interactive] [--dry-run] [--service NAME]
+
+ Restore an environment to the digests captured in a snapshot.
+ Production requires --yes + typed 'production' confirmation
+ (or --non-interactive to skip the prompt).
+
+ Exits 0 on success. Exits 2 on auth/network/refused errors.
+ BANNER
+ o.on("--env ENV") { |v| options[:env] = v }
+ o.on("--snapshot FILE") { |v| options[:snapshot] = v }
+ o.on("--service NAME", "Restrict to a single service") { |v| options[:service] = v }
+ o.on("--yes") { options[:yes] = true }
+ o.on("--non-interactive") { options[:non_interactive] = true }
+ o.on("--dry-run") { options[:dry_run] = true }
+ o.on("-h", "--help") { puts o; exit 0 }
+ end
+ end
+
+ def run
+ parser.parse!(argv)
+ Railway.die!("--env required") unless options[:env]
+ Railway.die!("--snapshot required") unless options[:snapshot]
+
+ env_id = Railway.env_id_for(options[:env])
+ snap = SnapshotIO.read(options[:snapshot])
+
+ Railway.confirm_destructive!(
+ env_label: options[:env],
+ action: "restore",
+ non_interactive: options[:non_interactive],
+ yes: options[:yes],
+ )
+
+ services = snap["services"] || []
+ services = services.select { |s| s["name"] == options[:service] } if options[:service]
+ Railway.die!("No matching services in snapshot.") if services.empty?
+
+ services.each do |svc|
+ image = svc["image"] || svc["image_tag"]
+ Railway.die!("Service #{svc['name']} has no image in snapshot.") if image.nil?
+
+ if options[:dry_run]
+ puts "[dry-run] would redeploy #{svc['name']} -> #{image}"
+ next
+ end
+
+ RestoreCommand.pin_and_redeploy(gql,
+ service_id: svc["service_id"], env_id: env_id, image: image)
+ puts "redeployed #{svc['name']} -> #{image}"
+ end
+ 0
+ end
+ end
+
+ # rollback — roll a single service back one deploy (its previous deploy).
+ class RollbackCommand < BaseCommand
+ DEPLOYMENTS_QUERY = <<~GQL
+ query Deployments($serviceId: String!, $envId: String!) {
+ deployments(
+ first: 10
+ input: { serviceId: $serviceId, environmentId: $envId }
+ ) { edges { node { id status meta createdAt } } }
+ }
+ GQL
+
+ # deploymentRollback returns a scalar Boolean — no selection set.
+ ROLLBACK_MUTATION = <<~GQL
+ mutation Rollback($id: String!) { deploymentRollback(id: $id) }
+ GQL
+
+ def parser
+ OptionParser.new do |o|
+ o.banner = <<~BANNER
+ Usage: bin/railway rollback --env ENV --service NAME [--to DEPLOYMENT_ID] [--yes]
+
+ Rolls a single service back to its previous successful
+ deploy (or to a specific deployment id with --to).
+ Production requires --yes + typed confirmation.
+ BANNER
+ o.on("--env ENV") { |v| options[:env] = v }
+ o.on("--service NAME") { |v| options[:service] = v }
+ o.on("--to ID", "Specific deployment id to roll to") { |v| options[:to] = v }
+ o.on("--yes") { options[:yes] = true }
+ o.on("--non-interactive") { options[:non_interactive] = true }
+ o.on("--dry-run") { options[:dry_run] = true }
+ o.on("-h", "--help") { puts o; exit 0 }
+ end
+ end
+
+ def run
+ parser.parse!(argv)
+ Railway.die!("--env required") unless options[:env]
+ Railway.die!("--service required") unless options[:service]
+
+ env_id = Railway.env_id_for(options[:env])
+ Railway.confirm_destructive!(
+ env_label: options[:env],
+ action: "rollback",
+ non_interactive: options[:non_interactive],
+ yes: options[:yes],
+ )
+
+ service_id = resolve_service_id(env_id, options[:service])
+
+ target_id = options[:to] || find_previous_deployment(service_id, env_id)
+ Railway.die!("No previous deployment found for #{options[:service]}.") unless target_id
+
+ if options[:dry_run]
+ puts "[dry-run] would rollback #{options[:service]} -> deployment #{target_id}"
+ return 0
+ end
+
+ gql.query(ROLLBACK_MUTATION, id: target_id)
+ puts "rolled back #{options[:service]} -> #{target_id}"
+ 0
+ end
+
+ def resolve_service_id(_env_id, name)
+ data = gql.query(SERVICES_LIST_QUERY, projectId: PROJECT_ID)
+ (data.dig("project", "services", "edges") || []).each do |e|
+ node = e["node"]
+ return node["id"] if node["name"] == name
+ end
+ Railway.die!("Service #{name.inspect} not found.")
+ end
+
+ def find_previous_deployment(service_id, env_id)
+ data = gql.query(DEPLOYMENTS_QUERY, serviceId: service_id, envId: env_id)
+ deployments = (data.dig("deployments", "edges") || []).map { |e| e["node"] }
+ # Pick the second SUCCESS in reverse-chronological order.
+ successes = deployments.select { |d| d["status"] == "SUCCESS" }
+ return nil if successes.size < 2
+ successes[1]["id"]
+ end
+ end
+
+ # rollback-commit — roll all services back to the digests captured at a
+ # given git SHA's snapshot file.
+ class RollbackCommitCommand < BaseCommand
+ def parser
+ OptionParser.new do |o|
+ o.banner = <<~BANNER
+ Usage: bin/railway rollback-commit --env ENV --sha SHA [--yes]
+
+ Looks up the snapshot file checked into git at SHA, then
+ redeploys every service to the digests recorded there.
+ Effectively a "restore to point-in-time" using committed
+ snapshots.
+ BANNER
+ o.on("--env ENV") { |v| options[:env] = v }
+ o.on("--sha SHA") { |v| options[:sha] = v }
+ o.on("--yes") { options[:yes] = true }
+ o.on("--non-interactive") { options[:non_interactive] = true }
+ o.on("--dry-run") { options[:dry_run] = true }
+ o.on("-h", "--help") { puts o; exit 0 }
+ end
+ end
+
+ def run
+ parser.parse!(argv)
+ Railway.die!("--env required") unless options[:env]
+ Railway.die!("--sha required") unless options[:sha]
+
+ # Locate the most recent snapshot file for env at SHA via `git show`.
+ env = options[:env]
+ sha = options[:sha]
+
+ # We look in showcase/.railway-snapshots/*-.yaml at SHA, take last.
+ list_cmd = %(git ls-tree -r --name-only #{sha} -- 'showcase/.railway-snapshots/*-#{env}.yaml')
+ entries = `#{list_cmd}`.lines.map(&:strip).reject(&:empty?).sort
+ Railway.die!("No snapshot for env=#{env} at #{sha}.") if entries.empty?
+ path = entries.last
+
+ yaml = `git show #{sha}:#{path}`
+ Railway.die!("git show failed for #{sha}:#{path}") if yaml.nil? || yaml.empty?
+
+ snap = YAML.safe_load(yaml, permitted_classes: [Time, Symbol], aliases: false)
+
+ # Hand off to RestoreCommand's logic by writing to a tmpfile.
+ tmp = "/tmp/railway-rollback-commit-#{Process.pid}.yaml"
+ File.write(tmp, YAML.dump(snap))
+ restore_argv = ["--env", env, "--snapshot", tmp]
+ restore_argv << "--yes" if options[:yes]
+ restore_argv << "--non-interactive" if options[:non_interactive]
+ restore_argv << "--dry-run" if options[:dry_run]
+ RestoreCommand.new(restore_argv).run
+ ensure
+ FileUtils.rm_f(tmp) if defined?(tmp) && tmp
+ end
+ end
+
+ # promote — copy staging digests into production with prechecks.
+ class PromoteCommand < BaseCommand
+ def parser
+ OptionParser.new do |o|
+ o.banner = <<~BANNER
+ Usage: bin/railway promote [--include-startcommand] [--yes] [--non-interactive] [--dry-run]
+
+ Promote staging snapshot to production.
+
+ MOVES: image digests; startCommand (only if --include-startcommand);
+ autoUpdate=disabled flag.
+ VERIFY-REFUSE: service-set parity, critical env-key parity,
+ PB superuser auth, PB collection parity,
+ cross-env URL leak scan.
+ WARN: missing/extra custom domains, sealed-var heuristics.
+ IGNORE: env-scoped URLs, volumes.
+
+ Exit 0 on clean promotion, 1 on refuse/findings, 2 on error.
+ BANNER
+ o.on("--include-startcommand") { options[:include_startcommand] = true }
+ o.on("--yes") { options[:yes] = true }
+ o.on("--non-interactive") { options[:non_interactive] = true }
+ o.on("--dry-run") { options[:dry_run] = true }
+ o.on("-h", "--help") { puts o; exit 0 }
+ end
+ end
+
+ def run
+ parser.parse!(argv)
+
+ # Capture both env snapshots.
+ staging = SnapshotCommand.new(["--env", "staging", "--dry-run"]).build_snapshot(STAGING_ENV_ID)
+ prod = SnapshotCommand.new(["--env", "production", "--dry-run"]).build_snapshot(PRODUCTION_ENV_ID)
+
+ findings = []
+
+ # VERIFY: service-set parity.
+ s_names = (staging["services"] || []).map { |s| s["name"] }.sort
+ p_names = (prod["services"] || []).map { |s| s["name"] }.sort
+ missing_in_prod = s_names - p_names
+ missing_in_stg = p_names - s_names
+ findings << "REFUSE: services in staging not in prod: #{missing_in_prod.join(', ')}" unless missing_in_prod.empty?
+ findings << "REFUSE: services in prod not in staging: #{missing_in_stg.join(', ')}" unless missing_in_stg.empty?
+
+ # VERIFY: critical env-key parity per service.
+ (staging["services"] || []).each do |svc|
+ pmatch = Railway.find_service(prod, svc["name"])
+ next unless pmatch
+ missing_keys = (CRITICAL_ENV_KEYS & svc["env_keys"]) - pmatch["env_keys"]
+ unless missing_keys.empty?
+ findings << "REFUSE: #{svc['name']}: critical keys missing in prod: #{missing_keys.join(', ')}"
+ end
+ end
+
+ # WARN: domain audits.
+ expected_prod_domains = EXPECTED_DOMAINS[PRODUCTION_ENV_ID]
+ actual_prod_domains = (prod["services"] || []).flat_map { |s| s["custom_domains"] || [] }.uniq.sort
+ missing_domains = expected_prod_domains - actual_prod_domains
+ findings << "WARN: production missing expected custom domains: #{missing_domains.join(', ')}" unless missing_domains.empty?
+
+ # WARN: cross-env URL leak in env-key NAMES is impossible (we have names only);
+ # we cannot read values without an explicit per-key fetch. Log as IGNORE note.
+
+ refuses = findings.select { |f| f.start_with?("REFUSE") }
+ unless refuses.empty?
+ refuses.each { |f| puts f }
+ puts "Promote refused due to #{refuses.size} REFUSE finding(s)."
+ return 1
+ end
+
+ findings.each { |f| puts f }
+
+ Railway.confirm_destructive!(
+ env_label: "production",
+ action: "promote",
+ non_interactive: options[:non_interactive],
+ yes: options[:yes],
+ )
+
+ # Execute promotion: redeploy each prod service with staging's image.
+ (staging["services"] || []).each do |svc|
+ pmatch = Railway.find_service(prod, svc["name"])
+ next unless pmatch
+ image = svc["image"] || svc["image_tag"]
+ if options[:dry_run]
+ puts "[dry-run] promote #{svc['name']} -> #{image}"
+ next
+ end
+
+ RestoreCommand.pin_and_redeploy(gql,
+ service_id: pmatch["service_id"],
+ env_id: PRODUCTION_ENV_ID,
+ image: image)
+ puts "promoted #{svc['name']} -> #{image}"
+ end
+
+ 0
+ end
+ end
+
+ # pin — pin a service to a specific image digest.
+ class PinCommand < BaseCommand
+ def parser
+ OptionParser.new do |o|
+ o.banner = <<~BANNER
+ Usage: bin/railway pin --env ENV --service NAME --image REF [--yes] [--dry-run]
+
+ Pin a service to a specific image (tag or @sha256:... digest).
+ If a tag is given, resolves it via GHCR first.
+ BANNER
+ o.on("--env ENV") { |v| options[:env] = v }
+ o.on("--service NAME") { |v| options[:service] = v }
+ o.on("--image REF") { |v| options[:image] = v }
+ o.on("--yes") { options[:yes] = true }
+ o.on("--non-interactive") { options[:non_interactive] = true }
+ o.on("--dry-run") { options[:dry_run] = true }
+ o.on("-h", "--help") { puts o; exit 0 }
+ end
+ end
+
+ def run
+ parser.parse!(argv)
+ %i[env service image].each do |k|
+ Railway.die!("--#{k} required") unless options[k]
+ end
+
+ env_id = Railway.env_id_for(options[:env])
+ image = options[:image]
+
+ unless image.include?("@sha256:")
+ digest = ghcr.resolve_digest(image)
+ Railway.die!("Could not resolve digest for #{image}.") unless digest
+ base = image.split(":", 2).first
+ image = "#{base}@#{digest}"
+ end
+
+ Railway.confirm_destructive!(
+ env_label: options[:env],
+ action: "pin",
+ non_interactive: options[:non_interactive],
+ yes: options[:yes],
+ )
+
+ service_id = RollbackCommand.new([]).resolve_service_id(env_id, options[:service])
+
+ if options[:dry_run]
+ puts "[dry-run] would pin #{options[:service]} -> #{image}"
+ return 0
+ end
+
+ RestoreCommand.pin_and_redeploy(gql,
+ service_id: service_id, env_id: env_id, image: image)
+ puts "pinned #{options[:service]} -> #{image}"
+ 0
+ end
+ end
+
+ # env-diff — diff two environments and exit 1 on drift.
+ class EnvDiffCommand < BaseCommand
+ def parser
+ OptionParser.new do |o|
+ o.banner = <<~BANNER
+ Usage: bin/railway env-diff ENV_A ENV_B [--ignore-env-scoped]
+
+ Compare image digests, startCommand, env-var key sets, and
+ custom domains between two envs. Exits 0 if equal (modulo
+ ignored markers), 1 if drift, 2 on error.
+ BANNER
+ o.on("--ignore-env-scoped") { options[:ignore_env_scoped] = true }
+ o.on("-h", "--help") { puts o; exit 0 }
+ end
+ end
+
+ def run
+ parser.parse!(argv)
+ Railway.die!("two env args required") if argv.length < 2
+
+ a, b = argv[0], argv[1]
+ id_a = Railway.env_id_for(a)
+ id_b = Railway.env_id_for(b)
+
+ snap_a = SnapshotCommand.new(["--env", a, "--dry-run"]).build_snapshot(id_a)
+ snap_b = SnapshotCommand.new(["--env", b, "--dry-run"]).build_snapshot(id_b)
+
+ drift = []
+ names = ((snap_a["services"] + snap_b["services"]).map { |s| s["name"] }).uniq.sort
+ names.each do |name|
+ sa = Railway.find_service(snap_a, name)
+ sb = Railway.find_service(snap_b, name)
+ if sa.nil?
+ drift << "service #{name}: missing in #{a}"
+ next
+ end
+ if sb.nil?
+ drift << "service #{name}: missing in #{b}"
+ next
+ end
+
+ if sa["digest"] != sb["digest"]
+ drift << "service #{name}: digest #{sa['digest']} != #{sb['digest']}"
+ end
+ if sa["start_command"] != sb["start_command"]
+ drift << "service #{name}: startCommand differs"
+ end
+ missing_in_b = sa["env_keys"] - sb["env_keys"]
+ missing_in_a = sb["env_keys"] - sa["env_keys"]
+ drift << "service #{name}: env keys missing in #{b}: #{missing_in_b.join(', ')}" unless missing_in_b.empty?
+ drift << "service #{name}: env keys missing in #{a}: #{missing_in_a.join(', ')}" unless missing_in_a.empty?
+ end
+
+ drift.each { |line| puts line }
+ puts drift.empty? ? "OK: #{a} and #{b} agree." : "DRIFT: #{drift.size} finding(s)."
+ drift.empty? ? 0 : 1
+ end
+ end
+
+ # resolve-digest — resolve an image reference to its digest via GHCR.
+ class ResolveDigestCommand < BaseCommand
+ def parser
+ OptionParser.new do |o|
+ o.banner = <<~BANNER
+ Usage: bin/railway resolve-digest IMAGE_REF
+
+ Resolve a tag like 'ghcr.io/copilotkit/showcase-shell:latest' to its
+ Docker-Content-Digest. Prints sha256:... on stdout. Exits 2 if absent.
+ BANNER
+ o.on("-h", "--help") { puts o; exit 0 }
+ end
+ end
+
+ def run
+ parser.parse!(argv)
+ Railway.die!("image ref required") if argv.empty?
+
+ digest = ghcr.resolve_digest(argv[0])
+ Railway.die!("Could not resolve digest for #{argv[0]}.") unless digest
+ puts digest
+ 0
+ end
+ end
+
+ # lint-prod — verify every prod service is pinned to a digest.
+ class LintProdCommand < BaseCommand
+ def initialize(argv)
+ super
+ @exit_zero = false
+ @format = "text"
+ end
+
+ def parser
+ OptionParser.new do |o|
+ o.banner = <<~BANNER
+ Usage: bin/railway lint-prod [--exit-zero] [--format text|json]
+
+ Fails (exit 1) if any production service is NOT pinned to
+ an immutable image digest (must be ghcr.io/...@sha256:...).
+ Intended as a CI gate on every PR touching showcase/.
+
+ --exit-zero Always exit 0 even on findings (advisory mode).
+ Findings still print to stdout.
+ --format FORMAT Output format: 'text' (default) or 'json'.
+ JSON shape:
+ {services:[{name,source,status}],
+ findings:N, timestamp:"ISO8601"}
+ 'source' is the raw Source.image / image-ref
+ string. 'status' is 'pinned' or 'mutable-tag'.
+ BANNER
+ o.on("--exit-zero", "Advisory: exit 0 even when findings exist") { @exit_zero = true }
+ o.on("--format FORMAT", %w[text json], "Output format (text|json)") { |v| @format = v }
+ o.on("-h", "--help") { puts o; exit 0 }
+ end
+ end
+
+ def run
+ parser.parse!(argv)
+
+ prod = SnapshotCommand.new(["--env", "production", "--dry-run"])
+ .build_snapshot(PRODUCTION_ENV_ID)
+
+ services = (prod["services"] || []).map do |svc|
+ image = svc["image"].to_s
+ status =
+ if image.empty?
+ "mutable-tag"
+ elsif !image.include?("@sha256:")
+ "mutable-tag"
+ else
+ "pinned"
+ end
+ { "name" => svc["name"], "source" => image, "status" => status }
+ end
+ findings = services.reject { |s| s["status"] == "pinned" }
+
+ if @format == "json"
+ payload = {
+ "services" => services,
+ "findings" => findings.size,
+ "timestamp" => Time.now.utc.iso8601,
+ }
+ puts JSON.generate(payload)
+ return 0 if findings.empty?
+ return 0 if @exit_zero
+ return 1
+ end
+
+ if findings.empty?
+ puts "OK: all production services digest-pinned."
+ return 0
+ end
+
+ findings.each do |f|
+ src = f["source"]
+ if src.empty?
+ puts "#{f['name']}: no image set"
+ else
+ puts "#{f['name']}: not digest-pinned (image=#{src})"
+ end
+ end
+ puts "DRIFT: #{findings.size} production service(s) not digest-pinned."
+ if @exit_zero
+ puts "(advisory mode: --exit-zero set; exiting 0)"
+ return 0
+ end
+ 1
+ end
+ end
+
+ # ── Dispatcher ─────────────────────────────────────────────────────────────
+
+ SUBCOMMANDS = {
+ "snapshot" => SnapshotCommand,
+ "restore" => RestoreCommand,
+ "rollback" => RollbackCommand,
+ "rollback-commit" => RollbackCommitCommand,
+ "promote" => PromoteCommand,
+ "pin" => PinCommand,
+ "env-diff" => EnvDiffCommand,
+ "resolve-digest" => ResolveDigestCommand,
+ "lint-prod" => LintProdCommand,
+ }.freeze
+
+ def self.usage
+ <<~USAGE
+ bin/railway — showcase Railway operations (Ruby, stdlib-only)
+
+ Subcommands:
+ snapshot Capture an env's services + config into a YAML snapshot.
+ restore Restore an env to a snapshot (force-redeploy).
+ rollback Roll a single service back one deploy.
+ rollback-commit Restore an env to the snapshot committed at a given SHA.
+ promote Promote staging digests to production with prechecks.
+ pin Pin a service to a specific image digest.
+ env-diff Diff two envs; exit 1 if drift.
+ resolve-digest Resolve an image tag to its GHCR digest.
+ lint-prod CI gate: fail if any prod service is not digest-pinned.
+
+ Run any subcommand with --help for full flag list.
+
+ Auth: RAILWAY_TOKEN env var (or ~/.railway/config.json).
+ Exit codes: 0 clean, 1 drift/findings, 2 error.
+ USAGE
+ end
+
+ def self.run(argv)
+ if argv.empty? || %w[-h --help help].include?(argv.first)
+ puts usage
+ return 0
+ end
+
+ if argv.first == "--version"
+ puts "railway #{VERSION}"
+ return 0
+ end
+
+ cmd = argv.shift
+ klass = SUBCOMMANDS[cmd]
+ if klass.nil?
+ warn "Unknown subcommand: #{cmd}"
+ warn usage
+ return 2
+ end
+
+ klass.call(argv)
+ rescue GraphQL::Error => e
+ warn "graphql error: #{e.message}"
+ 2
+ rescue GHCR::Error => e
+ warn "ghcr error: #{e.message}"
+ 2
+ rescue StandardError => e
+ warn "error: #{e.class}: #{e.message}"
+ warn e.backtrace.first(5).join("\n") if ENV["RAILWAY_DEBUG"]
+ 2
+ end
+end
+
+# String#rsplit_colon — split on last ':' (so tags with port-style refs are handled).
+class String
+ def rsplit_colon
+ idx = rindex(":")
+ return [self, nil] unless idx
+ [self[0...idx], self[(idx + 1)..]]
+ end
+end
+
+# Only run if invoked as a script (not when required by tests).
+if $PROGRAM_NAME == __FILE__
+ exit Railway.run(ARGV)
+end
diff --git a/showcase/bin/spec/all_tests.rb b/showcase/bin/spec/all_tests.rb
new file mode 100644
index 00000000000..005251ac588
--- /dev/null
+++ b/showcase/bin/spec/all_tests.rb
@@ -0,0 +1,12 @@
+#!/usr/bin/env ruby
+# frozen_string_literal: true
+
+# Entry point for bin/railway minitest suite. Discovers and runs every test_*.rb
+# in this directory.
+
+$LOAD_PATH.unshift(File.expand_path("..", __dir__))
+$LOAD_PATH.unshift(__dir__)
+
+require "minitest/autorun"
+
+Dir.glob(File.join(__dir__, "test_*.rb")).sort.each { |f| require f }
diff --git a/showcase/bin/spec/spec_helper.rb b/showcase/bin/spec/spec_helper.rb
new file mode 100644
index 00000000000..3f63ee00bea
--- /dev/null
+++ b/showcase/bin/spec/spec_helper.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+# Helper that loads bin/railway as a library (so we can access Railway:: classes
+# without invoking the CLI).
+
+require "minitest/autorun"
+
+# Stub $PROGRAM_NAME so the bottom-of-file invocation guard is skipped.
+unless defined?(::Railway)
+ load File.expand_path("../railway", __dir__)
+end
diff --git a/showcase/bin/spec/test_cli_parsing.rb b/showcase/bin/spec/test_cli_parsing.rb
new file mode 100644
index 00000000000..a086b55f41f
--- /dev/null
+++ b/showcase/bin/spec/test_cli_parsing.rb
@@ -0,0 +1,112 @@
+# frozen_string_literal: true
+
+require_relative "spec_helper"
+require "stringio"
+
+class CLIParsingTest < Minitest::Test
+ def test_usage_lists_all_nine_subcommands
+ out = Railway.usage
+ %w[snapshot restore rollback rollback-commit promote pin env-diff
+ resolve-digest lint-prod].each do |sub|
+ assert_includes out, sub, "usage missing subcommand: #{sub}"
+ end
+ end
+
+ def test_help_returns_zero
+ rc = nil
+ silence_io { rc = Railway.run(["--help"]) }
+ assert_equal 0, rc
+ end
+
+ def test_version_returns_zero
+ rc = nil
+ silence_io { rc = Railway.run(["--version"]) }
+ assert_equal 0, rc
+ end
+
+ def test_unknown_subcommand_returns_2
+ rc = nil
+ silence_io { rc = Railway.run(["definitely-not-a-cmd"]) }
+ assert_equal 2, rc
+ end
+
+ def test_snapshot_command_parses_env_and_output
+ c = Railway::SnapshotCommand.new(["--env", "staging", "--output", "/tmp/x.yaml"])
+ c.parser.parse!(c.argv)
+ assert_equal "staging", c.options[:env]
+ assert_equal "/tmp/x.yaml", c.options[:output]
+ end
+
+ def test_restore_command_requires_env_and_snapshot
+ c = Railway::RestoreCommand.new([])
+ ex = nil
+ silence_io { ex = assert_raises(SystemExit) { c.run } }
+ assert_equal 2, ex.status
+ end
+
+ def test_rollback_command_parses_to_flag
+ c = Railway::RollbackCommand.new(["--env", "staging", "--service", "showcase-shell", "--to", "dep-123"])
+ c.parser.parse!(c.argv)
+ assert_equal "dep-123", c.options[:to]
+ end
+
+ def test_envdiff_requires_two_args
+ c = Railway::EnvDiffCommand.new(["staging"])
+ ex = nil
+ silence_io { ex = assert_raises(SystemExit) { c.run } }
+ assert_equal 2, ex.status
+ end
+
+ def test_promote_flags_parse
+ c = Railway::PromoteCommand.new(["--include-startcommand", "--yes", "--dry-run"])
+ c.parser.parse!(c.argv)
+ assert c.options[:include_startcommand]
+ assert c.options[:yes]
+ assert c.options[:dry_run]
+ end
+
+ def test_resolve_digest_requires_arg
+ c = Railway::ResolveDigestCommand.new([])
+ ex = nil
+ silence_io { ex = assert_raises(SystemExit) { c.run } }
+ assert_equal 2, ex.status
+ end
+
+ def test_lint_prod_parses_format_and_exit_zero
+ c = Railway::LintProdCommand.new(["--exit-zero", "--format", "json"])
+ c.parser.parse!(c.argv)
+ assert_equal true, c.instance_variable_get(:@exit_zero)
+ assert_equal "json", c.instance_variable_get(:@format)
+ end
+
+ def test_lint_prod_rejects_invalid_format
+ c = Railway::LintProdCommand.new(["--format", "yaml"])
+ assert_raises(OptionParser::InvalidArgument) { c.parser.parse!(c.argv) }
+ end
+
+ def test_lint_prod_defaults_format_to_text
+ c = Railway::LintProdCommand.new([])
+ c.parser.parse!(c.argv)
+ assert_equal "text", c.instance_variable_get(:@format)
+ assert_equal false, c.instance_variable_get(:@exit_zero)
+ end
+
+ def test_env_id_for_resolves_aliases
+ assert_equal Railway::PRODUCTION_ENV_ID, Railway.env_id_for("production")
+ assert_equal Railway::PRODUCTION_ENV_ID, Railway.env_id_for("prod")
+ assert_equal Railway::STAGING_ENV_ID, Railway.env_id_for("staging")
+ assert_equal Railway::STAGING_ENV_ID, Railway.env_id_for("stage")
+ end
+
+ private
+
+ def silence_io
+ orig_stdout, orig_stderr = $stdout, $stderr
+ $stdout = StringIO.new
+ $stderr = StringIO.new
+ yield
+ ensure
+ $stdout = orig_stdout
+ $stderr = orig_stderr
+ end
+end
diff --git a/showcase/bin/spec/test_ghcr_digest.rb b/showcase/bin/spec/test_ghcr_digest.rb
new file mode 100644
index 00000000000..b5848b3556b
--- /dev/null
+++ b/showcase/bin/spec/test_ghcr_digest.rb
@@ -0,0 +1,68 @@
+# frozen_string_literal: true
+
+require_relative "spec_helper"
+
+class GHCRDigestTest < Minitest::Test
+ # Fake HTTP layer for the GHCR client.
+ class FakeHTTP
+ def initialize(responses)
+ @responses = responses
+ end
+
+ def call(method:, url:, headers: {})
+ key = [method, url]
+ r = @responses[key] || @responses[url]
+ raise "no fake response for #{key.inspect}" unless r
+ r
+ end
+ end
+
+ def test_parse_image_ref_handles_all_shapes
+ g = Railway::GHCR.new
+ p1 = g.parse_image_ref("ghcr.io/copilotkit/showcase-shell:latest")
+ assert_equal "ghcr.io", p1[:registry]
+ assert_equal "copilotkit", p1[:org]
+ assert_equal "showcase-shell", p1[:name]
+ assert_equal "latest", p1[:tag]
+ assert_nil p1[:digest]
+
+ p2 = g.parse_image_ref("ghcr.io/copilotkit/showcase-shell@sha256:abc")
+ assert_equal "sha256:abc", p2[:digest]
+
+ p3 = g.parse_image_ref("ghcr.io/copilotkit/showcase-shell:latest@sha256:def")
+ assert_equal "latest", p3[:tag]
+ assert_equal "sha256:def", p3[:digest]
+ end
+
+ def test_resolve_digest_returns_digest_from_header
+ url = "https://ghcr.io/v2/copilotkit/showcase-shell/manifests/latest"
+ fake = FakeHTTP.new(
+ url => { status: 200, headers: { "docker-content-digest" => "sha256:beefcafe" }, body: "" },
+ )
+ g = Railway::GHCR.new(token: "x", http: fake)
+ assert_equal "sha256:beefcafe", g.resolve_digest("ghcr.io/copilotkit/showcase-shell:latest")
+ end
+
+ def test_resolve_digest_returns_existing_digest_immediately
+ # When the ref already has @sha256:..., we don't hit the network at all.
+ g = Railway::GHCR.new(token: "x", http: nil)
+ assert_equal "sha256:abc",
+ g.resolve_digest("ghcr.io/copilotkit/showcase-shell@sha256:abc")
+ end
+
+ def test_resolve_digest_returns_nil_on_404
+ url = "https://ghcr.io/v2/copilotkit/showcase-shell/manifests/nope"
+ fake = FakeHTTP.new(url => { status: 404, headers: {}, body: "" })
+ g = Railway::GHCR.new(token: "x", http: fake)
+ assert_nil g.resolve_digest("ghcr.io/copilotkit/showcase-shell:nope")
+ end
+
+ def test_resolve_digest_raises_on_5xx
+ url = "https://ghcr.io/v2/copilotkit/showcase-shell/manifests/latest"
+ fake = FakeHTTP.new(url => { status: 500, headers: {}, body: "boom" })
+ g = Railway::GHCR.new(token: "x", http: fake)
+ assert_raises(Railway::GHCR::Error) do
+ g.resolve_digest("ghcr.io/copilotkit/showcase-shell:latest")
+ end
+ end
+end
diff --git a/showcase/bin/spec/test_production_protection.rb b/showcase/bin/spec/test_production_protection.rb
new file mode 100644
index 00000000000..005fd22e65c
--- /dev/null
+++ b/showcase/bin/spec/test_production_protection.rb
@@ -0,0 +1,76 @@
+# frozen_string_literal: true
+
+require_relative "spec_helper"
+require "stringio"
+
+class ProductionProtectionTest < Minitest::Test
+ def test_staging_does_not_require_confirmation
+ # staging is never a production env, so this returns true without prompting.
+ assert_equal true, Railway.confirm_destructive!(
+ env_label: "staging", action: "restore", yes: false, non_interactive: true,
+ )
+ end
+
+ def test_production_without_yes_aborts
+ ex = nil
+ capture_stderr do
+ ex = assert_raises(SystemExit) do
+ Railway.confirm_destructive!(env_label: "production", action: "restore",
+ yes: false, non_interactive: true)
+ end
+ end
+ assert_equal 2, ex.status
+ end
+
+ def test_production_with_yes_and_non_interactive_proceeds
+ # --yes + --non-interactive proceeds without prompting.
+ result = capture_stderr do
+ assert_equal true, Railway.confirm_destructive!(
+ env_label: "production", action: "restore",
+ yes: true, non_interactive: true,
+ )
+ end
+ assert_includes result, "non-interactive"
+ end
+
+ def test_production_with_yes_prompts_and_accepts_typed_phrase
+ # Simulate the user typing 'production' on stdin.
+ original_stdin = $stdin
+ $stdin = StringIO.new("production\n")
+ capture_stderr do
+ assert_equal true, Railway.confirm_destructive!(
+ env_label: "production", action: "restore",
+ yes: true, non_interactive: false,
+ )
+ end
+ ensure
+ $stdin = original_stdin
+ end
+
+ def test_production_with_yes_rejects_wrong_phrase
+ original_stdin = $stdin
+ $stdin = StringIO.new("yes\n")
+ ex = nil
+ capture_stderr do
+ ex = assert_raises(SystemExit) do
+ Railway.confirm_destructive!(env_label: "production",
+ action: "restore",
+ yes: true, non_interactive: false)
+ end
+ end
+ assert_equal 2, ex.status
+ ensure
+ $stdin = original_stdin
+ end
+
+ private
+
+ def capture_stderr
+ original = $stderr
+ $stderr = StringIO.new
+ yield
+ $stderr.string
+ ensure
+ $stderr = original
+ end
+end
diff --git a/showcase/bin/spec/test_snapshot_graphql.rb b/showcase/bin/spec/test_snapshot_graphql.rb
new file mode 100644
index 00000000000..2a5c90c02fc
--- /dev/null
+++ b/showcase/bin/spec/test_snapshot_graphql.rb
@@ -0,0 +1,180 @@
+# frozen_string_literal: true
+
+require_relative "spec_helper"
+
+# Verifies the GraphQL queries used by SnapshotCommand match Railway's
+# public schema shape (as of 2026-05). The mocks here mirror real Railway
+# responses; any drift between this file and the live schema means the
+# tool will fail at runtime — which is exactly the bug this PR fixes.
+class SnapshotGraphqlTest < Minitest::Test
+ # Fake GraphQL client that returns canned responses keyed by query name.
+ class FakeGQL
+ def initialize(responses)
+ @responses = responses
+ @calls = []
+ end
+
+ attr_reader :calls
+
+ def query(query_str, variables = {})
+ @calls << [query_str, variables]
+ # Match on the operation name (the line after `query `) so we can
+ # serve different mocks for SERVICES_LIST_QUERY,
+ # SERVICE_INSTANCE_QUERY, ENVIRONMENT_VARIABLES_QUERY.
+ op = query_str[/query\s+(\w+)/, 1]
+ response = @responses[op] || @responses[:default]
+ raise "no fake response for op=#{op.inspect}" unless response
+ response.respond_to?(:call) ? response.call(variables) : response
+ end
+ end
+
+ def services_list_response
+ {
+ "project" => {
+ "id" => Railway::PROJECT_ID,
+ "name" => "showcase",
+ "services" => {
+ "edges" => [
+ { "node" => { "id" => "svc-aimock", "name" => "aimock" } },
+ { "node" => { "id" => "svc-shell", "name" => "shell" } },
+ ],
+ },
+ },
+ }
+ end
+
+ def env_vars_response_with_per_service_keys
+ {
+ "environment" => {
+ "id" => Railway::PRODUCTION_ENV_ID,
+ "name" => "production",
+ "variables" => {
+ "edges" => [
+ { "node" => { "name" => "PORT", "serviceId" => "svc-aimock", "isSealed" => false } },
+ { "node" => { "name" => "NODE_OPTIONS","serviceId" => "svc-aimock", "isSealed" => false } },
+ { "node" => { "name" => "API_KEY", "serviceId" => "svc-shell", "isSealed" => true } },
+ ],
+ },
+ },
+ }
+ end
+
+ def service_instance_response(image:, start_cmd: nil, domains: [])
+ {
+ "serviceInstance" => {
+ "id" => "inst-#{image[/sha256:[a-f0-9]+/] || 'tag'}",
+ "serviceId" => "svc-aimock",
+ "environmentId" => Railway::PRODUCTION_ENV_ID,
+ "startCommand" => start_cmd,
+ "source" => { "image" => image, "repo" => nil },
+ "latestDeployment" => { "id" => "dep-1", "status" => "SUCCESS" },
+ "domains" => {
+ "customDomains" => domains.map { |d| { "id" => "cd-#{d}", "domain" => d } },
+ "serviceDomains" => [],
+ },
+ },
+ }
+ end
+
+ def test_build_snapshot_uses_corrected_field_names_and_produces_pinned_entries
+ fake = FakeGQL.new(
+ "ProjectServices" => services_list_response,
+ "EnvVariables" => env_vars_response_with_per_service_keys,
+ "ServiceInstance" => lambda do |vars|
+ if vars[:serviceId] == "svc-aimock"
+ service_instance_response(
+ image: "ghcr.io/copilotkit/showcase-aimock@sha256:cafef00d",
+ start_cmd: "node /app/dist/cli.js",
+ domains: ["aimock.showcase.copilotkit.ai"],
+ )
+ else
+ service_instance_response(
+ image: "ghcr.io/copilotkit/showcase-shell@sha256:beef1234",
+ domains: [],
+ )
+ end
+ end,
+ )
+
+ cmd = Railway::SnapshotCommand.new(["--env", "production", "--dry-run"])
+ cmd.instance_variable_set(:@gql, fake)
+
+ snap = cmd.build_snapshot(Railway::PRODUCTION_ENV_ID)
+
+ assert_equal 1, snap["version"]
+ assert_equal 2, snap["services"].length
+
+ aimock = snap["services"].find { |s| s["name"] == "aimock" }
+ assert_equal "ghcr.io/copilotkit/showcase-aimock@sha256:cafef00d", aimock["image"]
+ assert_equal "sha256:cafef00d", aimock["digest"]
+ assert_equal "ghcr.io/copilotkit/showcase-aimock", aimock["image_tag"]
+ assert_equal "node /app/dist/cli.js", aimock["start_command"]
+ assert_equal ["aimock.showcase.copilotkit.ai"], aimock["custom_domains"]
+ assert_equal %w[NODE_OPTIONS PORT], aimock["env_keys"]
+ assert_equal "dep-1", aimock["latest_deployment_id"]
+
+ shell = snap["services"].find { |s| s["name"] == "shell" }
+ assert_equal ["API_KEY"], shell["env_keys"]
+ assert_equal [], shell["custom_domains"]
+ end
+
+ def test_lint_prod_marks_mutable_tag_when_image_is_unpinned
+ fake = FakeGQL.new(
+ "ProjectServices" => services_list_response,
+ "EnvVariables" => env_vars_response_with_per_service_keys,
+ "ServiceInstance" => lambda do |vars|
+ # Both return an unpinned :latest tag.
+ service_instance_response(
+ image: "ghcr.io/copilotkit/#{vars[:serviceId]}:latest",
+ )
+ end,
+ )
+ snap_cmd = Railway::SnapshotCommand.new(["--env", "production", "--dry-run"])
+ snap_cmd.instance_variable_set(:@gql, fake)
+ snap = snap_cmd.build_snapshot(Railway::PRODUCTION_ENV_ID)
+
+ # All services are mutable-tag because none have @sha256:.
+ snap["services"].each do |svc|
+ refute svc["image"].include?("@sha256:"), "expected mutable tag for #{svc['name']}"
+ assert_nil svc["digest"], "expected nil digest for #{svc['name']}"
+ end
+ end
+
+ def test_build_snapshot_queries_use_only_supported_fields
+ # Regression guard: the previous version of this tool referenced
+ # `Project.domains` and `Service.serviceInstances`, both of which do
+ # NOT exist in Railway's public GraphQL schema. Ensure those tokens
+ # never reappear in the query constants.
+ sources = [
+ Railway::SERVICES_LIST_QUERY,
+ Railway::SERVICE_INSTANCE_QUERY,
+ Railway::ENVIRONMENT_VARIABLES_QUERY,
+ ]
+ sources.each do |q|
+ refute_match(/project\s*\([^)]*\)\s*\{[^}]*\bdomains\b/m, q,
+ "Project has no `domains` field — use serviceInstance.domains or the top-level domains query.")
+ refute_match(/\bserviceInstances\b/m, q,
+ "Service has no `serviceInstances` field — use serviceInstance(serviceId, environmentId) directly.")
+ end
+ end
+
+ def test_redeploy_mutation_uses_serviceInstanceRedeploy_not_serviceInstanceDeployV2_with_image
+ # serviceInstanceDeployV2 has signature (commitSha, environmentId, serviceId).
+ # It does NOT accept an `image` argument. Pinning must go through
+ # serviceInstanceUpdate (source.image) + serviceInstanceRedeploy.
+ refute_match(/serviceInstanceDeployV2\s*\([^)]*\bimage\b/m,
+ Railway::RestoreCommand::UPDATE_IMAGE_MUTATION,
+ "Restore must not call serviceInstanceDeployV2 with image arg.")
+ assert_match(/serviceInstanceUpdate\s*\(/, Railway::RestoreCommand::UPDATE_IMAGE_MUTATION)
+ assert_match(/source:\s*\{\s*image:/, Railway::RestoreCommand::UPDATE_IMAGE_MUTATION)
+ assert_match(/serviceInstanceRedeploy\s*\(/, Railway::RestoreCommand::REDEPLOY_MUTATION)
+ end
+
+ def test_deploymentRollback_mutation_has_no_selection_set_because_it_returns_boolean
+ # deploymentRollback's return type is Boolean (scalar). GraphQL
+ # forbids a selection set on scalar fields.
+ refute_match(/deploymentRollback\s*\([^)]*\)\s*\{/m,
+ Railway::RollbackCommand::ROLLBACK_MUTATION,
+ "deploymentRollback returns Boolean; no selection set allowed.")
+ end
+end
diff --git a/showcase/bin/spec/test_snapshot_roundtrip.rb b/showcase/bin/spec/test_snapshot_roundtrip.rb
new file mode 100644
index 00000000000..566b09d19a6
--- /dev/null
+++ b/showcase/bin/spec/test_snapshot_roundtrip.rb
@@ -0,0 +1,61 @@
+# frozen_string_literal: true
+
+require_relative "spec_helper"
+require "tempfile"
+require "stringio"
+
+class SnapshotRoundtripTest < Minitest::Test
+ def sample_snapshot
+ {
+ "version" => 1,
+ "captured_at" => "2026-01-01T00:00:00Z",
+ "project_id" => Railway::PROJECT_ID,
+ "environment" => { "id" => Railway::STAGING_ENV_ID, "name" => "staging" },
+ "services" => [
+ {
+ "name" => "showcase-shell",
+ "service_id" => "svc-1",
+ "image" => "ghcr.io/copilotkit/showcase-shell@sha256:abc",
+ "image_tag" => "ghcr.io/copilotkit/showcase-shell",
+ "digest" => "sha256:abc",
+ "start_command" => "node server.js",
+ "auto_updates_disabled" => nil,
+ "latest_deployment_id" => "dep-1",
+ "env_keys" => %w[KEY1 KEY2],
+ "custom_domains" => ["showcase.staging.copilotkit.ai"],
+ },
+ ],
+ }
+ end
+
+ def test_write_and_read_roundtrip
+ snap = sample_snapshot
+ Tempfile.create(["snap", ".yaml"]) do |f|
+ Railway::SnapshotIO.write(f.path, snap)
+ loaded = Railway::SnapshotIO.read(f.path)
+ assert_equal snap["version"], loaded["version"]
+ assert_equal "showcase-shell", loaded["services"][0]["name"]
+ assert_equal "sha256:abc", loaded["services"][0]["digest"]
+ end
+ end
+
+ def test_read_rejects_wrong_schema_version
+ bad = sample_snapshot
+ bad["version"] = 999
+ Tempfile.create(["snap", ".yaml"]) do |f|
+ File.write(f.path, YAML.dump(bad))
+ orig = $stderr
+ $stderr = StringIO.new
+ ex = assert_raises(SystemExit) { Railway::SnapshotIO.read(f.path) }
+ $stderr = orig
+ assert_equal 2, ex.status
+ end
+ end
+
+ def test_find_service_by_name
+ snap = sample_snapshot
+ svc = Railway.find_service(snap, "showcase-shell")
+ assert_equal "svc-1", svc["service_id"]
+ assert_nil Railway.find_service(snap, "does-not-exist")
+ end
+end
diff --git a/showcase/pocketbase/pb_migrations/1779989100_lockdown_users_createRule.js b/showcase/pocketbase/pb_migrations/1779989100_lockdown_users_createRule.js
new file mode 100644
index 00000000000..b486e839571
--- /dev/null
+++ b/showcase/pocketbase/pb_migrations/1779989100_lockdown_users_createRule.js
@@ -0,0 +1,27 @@
+///
+// Lockdown: users.createRule was "" (empty string), allowing anonymous
+// signup. The dashboard never exposes a signup flow — operators reuse the
+// PocketBase superuser credentials via the PbAuthPrompt component, so
+// admin-only create is the correct posture.
+//
+// Verified anonymous-signup vulnerability on prod via curl on 2026-05-28:
+// POST /api/collections/users/records -> 200 (an anon-probe user was
+// created and removed). After this migration, the same request returns
+// 403.
+//
+// list/view/update/delete rules remain "id = @request.auth.id" so a
+// signed-in user can still see and edit their own record.
+migrate(
+ (db) => {
+ const dao = new Dao(db);
+ const c = dao.findCollectionByNameOrId("users");
+ c.createRule = null;
+ dao.saveCollection(c);
+ },
+ (db) => {
+ const dao = new Dao(db);
+ const c = dao.findCollectionByNameOrId("users");
+ c.createRule = "";
+ dao.saveCollection(c);
+ },
+);
diff --git a/showcase/pocketbase/pb_migrations/1779989200_lockdown_baseline_updateRule.js b/showcase/pocketbase/pb_migrations/1779989200_lockdown_baseline_updateRule.js
new file mode 100644
index 00000000000..98625825238
--- /dev/null
+++ b/showcase/pocketbase/pb_migrations/1779989200_lockdown_baseline_updateRule.js
@@ -0,0 +1,33 @@
+///
+// Lockdown: baseline.updateRule was "" (empty string) — any anonymous
+// HTTP client could PATCH baseline rows, including silently flipping
+// status cells from "works" to "impossible" and rewriting the
+// updated_by/updated_at audit fields.
+//
+// The dashboard's edit flow already gates writes behind PbAuthPrompt,
+// which calls pb.collection("_superusers").authWithPassword(...) before
+// any pb.collection("baseline").update(...) call. With this change,
+// operators still edit baseline cells exactly the same way — the only
+// difference is that the request now requires the admin JWT the prompt
+// already provides.
+//
+// Verified on prod via curl on 2026-05-28:
+// Before: PATCH /api/collections/baseline/records/ {} -> 200
+// After: PATCH /api/collections/baseline/records/ {} -> 403
+//
+// list/view/create/delete already nulled or "" by the create-baseline
+// migration; only updateRule is tightened here.
+migrate(
+ (db) => {
+ const dao = new Dao(db);
+ const c = dao.findCollectionByNameOrId("baseline");
+ c.updateRule = null;
+ dao.saveCollection(c);
+ },
+ (db) => {
+ const dao = new Dao(db);
+ const c = dao.findCollectionByNameOrId("baseline");
+ c.updateRule = "";
+ dao.saveCollection(c);
+ },
+);
diff --git a/showcase/shell-docs/src/app/[[...slug]]/page.tsx b/showcase/shell-docs/src/app/[[...slug]]/page.tsx
index b23e8b511b8..ed3c9d33b19 100644
--- a/showcase/shell-docs/src/app/[[...slug]]/page.tsx
+++ b/showcase/shell-docs/src/app/[[...slug]]/page.tsx
@@ -23,9 +23,13 @@ import { ShellDocsLayout } from "@/components/shell-docs-layout";
import { SidebarFrameworkSelector } from "@/components/sidebar-framework-selector";
import { UnscopedDocsPage } from "@/components/unscoped-docs-page";
import { FrameworkLogo } from "@/components/icons/framework-icons";
-import { buildFrameworkNav, loadDoc } from "@/lib/docs-render";
+import {
+ buildFrameworkNav,
+ buildFrameworkOnlyNav,
+ loadDoc,
+} from "@/lib/docs-render";
import { navTreeToPageTree } from "@/lib/page-tree-bridge";
-import { getDocsFolder, getIntegration } from "@/lib/registry";
+import { getDocsFolder, getDocsMode, getIntegration } from "@/lib/registry";
import { buildDocMetadata } from "@/lib/seo-metadata";
// Force dynamic rendering so unknown slugs reliably return HTTP 404
@@ -78,16 +82,14 @@ export async function generateMetadata({
function DocsOverview() {
// Sidebar matches the soft-default framework so home `/` and
- // post-click `/built-in-agent/...` views share the same merged IA used
- // by every framework-scoped route.
+ // post-click `/built-in-agent/...` views share the same authored IA.
const docsFolder = getDocsFolder(HOME_DEFAULT_FRAMEWORK);
const integrationName =
getIntegration(HOME_DEFAULT_FRAMEWORK)?.name ?? "Built-in Agent";
- const navTree = buildFrameworkNav(
- docsFolder,
- integrationName,
- HOME_DEFAULT_FRAMEWORK,
- );
+ const navTree =
+ getDocsMode(HOME_DEFAULT_FRAMEWORK) === "authored"
+ ? buildFrameworkOnlyNav(docsFolder)
+ : buildFrameworkNav(docsFolder, integrationName, HOME_DEFAULT_FRAMEWORK);
const pageTree = navTreeToPageTree(navTree, `/${HOME_DEFAULT_FRAMEWORK}`);
// Rewrite the Introduction entry's URL from `/built-in-agent` (or
diff --git a/showcase/shell-docs/src/app/[framework]/[[...slug]]/page.tsx b/showcase/shell-docs/src/app/[framework]/[[...slug]]/page.tsx
index 3b9b4cd8499..9c364b9df86 100644
--- a/showcase/shell-docs/src/app/[framework]/[[...slug]]/page.tsx
+++ b/showcase/shell-docs/src/app/[framework]/[[...slug]]/page.tsx
@@ -41,6 +41,7 @@ import { transformerMeta } from "@/lib/rehype-code-meta";
import {
CONTENT_DIR,
buildFrameworkNav,
+ buildFrameworkOnlyNav,
findFrameworksWithCell,
findFrameworksWithPage,
loadDoc,
@@ -270,7 +271,8 @@ export default async function FrameworkScopedDocsPage({
// Content resolution order depends on docs_mode:
//
// authored — per-framework MDX wins for every slug. Authored pages
- // can replace root pages without changing sidebar IA.
+ // can replace root pages while keeping the framework's
+ // authored sidebar IA.
// Only fall back to root if the framework simply has no
// file for the requested slug (preserves the "shared"
// fallback for slugs the framework intentionally leaves
@@ -302,13 +304,13 @@ export default async function FrameworkScopedDocsPage({
}
}
- // Sidebar IA is mode-independent: authored/generated controls which
- // MDX file renders, not the user's navigation structure.
- const navTree: NavNode[] = buildFrameworkNav(
- docsFolder,
- frameworkName,
- framework,
- );
+ // Authored integrations own their full docs tree and sidebar IA.
+ // Generated integrations use the root docs IA with a sparse
+ // framework-specific override section.
+ const navTree: NavNode[] =
+ docsMode === "authored"
+ ? buildFrameworkOnlyNav(docsFolder)
+ : buildFrameworkNav(docsFolder, frameworkName, framework);
if (!doc) {
// No root MDX and no override for this framework. If the topic
@@ -433,8 +435,9 @@ async function FrameworkRootPage({ framework }: { framework: string }) {
// let the Tier 1/2/3 cascade below decide whether to render or 404.
const integration = getIntegration(framework);
- // Same nav merge as the scoped-page route. Resolve the URL slug to
- // its docs folder — see comment in FrameworkScopedDocsPage above.
+ // Resolve the URL slug to its docs folder — see comment in
+ // FrameworkScopedDocsPage above. Authored frameworks get their own
+ // sidebar tree; generated frameworks get the merged root/override IA.
// `getDocsFolder` already falls back to the slug itself when there's
// no override, so it's safe for docs-only frameworks.
const docsFolder = getDocsFolder(framework);
@@ -445,11 +448,10 @@ async function FrameworkRootPage({ framework }: { framework: string }) {
frameworkOverviews[framework]?.frameworkName ??
framework;
const docsMode = getDocsMode(framework);
- const navTree: NavNode[] = buildFrameworkNav(
- docsFolder,
- integrationName,
- framework,
- );
+ const navTree: NavNode[] =
+ docsMode === "authored"
+ ? buildFrameworkOnlyNav(docsFolder)
+ : buildFrameworkNav(docsFolder, integrationName, framework);
// Tier 1: data-driven FrameworkOverview. ONLY for `generated` mode —
// `authored` frameworks skip straight to Tier 2 so their ported
diff --git a/showcase/shell-docs/src/components/docs-tabs.tsx b/showcase/shell-docs/src/components/docs-tabs.tsx
index e7139d3206e..69efd65000b 100644
--- a/showcase/shell-docs/src/components/docs-tabs.tsx
+++ b/showcase/shell-docs/src/components/docs-tabs.tsx
@@ -6,9 +6,12 @@
// `value.toLowerCase().replace(/\s/, "-")` to derive radix-tabs
// `value` keys. Our authored MDX uses the literal label as the
// , e.g. `` against
-// ``. Without escaping the
-// Tab's value to match, radix can't pair the trigger with the
-// content and the tab body never renders.
+// ``. Fumadocs's Tab
+// component applies this same escapeValue internally, so we pass
+// the raw label — pre-escaping would double-escape multi-word
+// values and break the trigger↔content match for labels like
+// "JSON Configuration File" (two spaces → only first replaced →
+// residual space in trigger but not in double-escaped content).
//
// - We accept `groupId` / `persist` / `default` props that the legacy
// custom-Tabs MDX content was written against, so existing pages
@@ -25,15 +28,20 @@ import * as React from "react";
import {
Tabs as FumadocsTabs,
Tab as FumadocsTab,
- type TabsProps as FumadocsTabsProps,
- type TabProps as FumadocsTabProps,
+} from "fumadocs-ui/components/tabs";
+import type {
+ TabsProps as FumadocsTabsProps,
+ TabProps as FumadocsTabProps,
} from "fumadocs-ui/components/tabs";
/**
* Mirror Fumadocs's internal `escapeValue` — keep this in sync with
- * `node_modules/fumadocs-ui/dist/components/tabs.js`. Used so a Tab's
- * authored `value="JavaScript"` resolves to the same key Fumadocs
- * derives from the Tabs's `items` array.
+ * `node_modules/fumadocs-ui/dist/components/tabs.js`. Used ONLY for
+ * the `Tabs` defaultValue so the initial-selection value matches the
+ * trigger values Fumadocs generates from `items`. Do NOT apply to
+ * individual `Tab` values — Fumadocs's Tab component calls this
+ * internally; pre-escaping here would double-escape and break the
+ * trigger↔content pairing for multi-word labels.
*/
function escapeValue(v: string): string {
return v.toLowerCase().replace(/\s/, "-");
@@ -74,13 +82,12 @@ interface ExtendedTabProps extends FumadocsTabProps {
}
export function Tab({ value, title, ...rest }: ExtendedTabProps) {
- // Authored MDX passes the literal label as `value`. Escape so it
- // matches Fumadocs's derivation from ``.
+ // Pass the raw label to FumadocsTab — Fumadocs's Tab component
+ // applies escapeValue internally to match the trigger's derived key.
+ // Pre-escaping here would double-escape and corrupt multi-word labels
+ // (e.g. "JSON Configuration File" → "json-configuration file" after
+ // one pass, then "json-configuration-file" after the second pass,
+ // which no longer matches the trigger's "json-configuration file").
const resolved = value ?? title;
- return (
-
- );
+ return ;
}
diff --git a/showcase/shell-docs/src/lib/docs-render.tsx b/showcase/shell-docs/src/lib/docs-render.tsx
index c4e583817c9..341f68f3c2c 100644
--- a/showcase/shell-docs/src/lib/docs-render.tsx
+++ b/showcase/shell-docs/src/lib/docs-render.tsx
@@ -559,10 +559,9 @@ export function buildFrameworkOverridesNav(folder: string): NavNode[] {
/**
* Build a sidebar that contains ONLY the per-framework MDX tree
- * (no merge with root nav, no root-equivalent filtering). Retained for
- * diagnostics and any callers that need to inspect a package-owned IA
- * directly; framework routes use `buildFrameworkNav` so authored and
- * generated modes share the same sidebar structure.
+ * (no merge with root nav, no root-equivalent filtering). Authored
+ * integrations use this because their `integrations//meta.json`
+ * is the source of truth for page order and section grouping.
*
* Slugs are rewritten to drop the `integrations//` prefix and
* the literal `index` → "" rewrite, so links resolve at
@@ -721,9 +720,9 @@ export function mergeFrameworkNav(
}
/**
- * Build the framework-scoped sidebar IA used by every framework route.
- * This is intentionally independent of docs_mode: generated/authored
- * controls MDX resolution, not navigation structure.
+ * Build the framework-scoped sidebar IA used by generated framework
+ * routes. Generated docs share the root docs IA and layer sparse
+ * framework-specific overrides into that tree.
*/
export function buildFrameworkNav(
docsFolder: string,
diff --git a/showcase/shell-docs/src/lib/registry.ts b/showcase/shell-docs/src/lib/registry.ts
index 045c0c78c2c..7a29a5d6114 100644
--- a/showcase/shell-docs/src/lib/registry.ts
+++ b/showcase/shell-docs/src/lib/registry.ts
@@ -38,9 +38,10 @@ export interface Integration {
* - `generated` (default): data-driven `FrameworkOverview` + agnostic
* root MDX merged with per-framework overrides. Kept for the three
* "ready" frameworks (langgraph-{python,typescript}, google-adk).
- * - `authored`: render only the per-framework MDX tree under
+ * - `authored`: render the per-framework MDX tree under
* `content/docs/integrations//` with its own sidebar
- * (built from that folder's meta.json). No root-MDX fallback.
+ * (built from that folder's meta.json). Root MDX may still be used
+ * as a fallback for intentionally shared pages.
* - `hidden`: exclude from the docs site entirely — no `/`
* route, no switcher entry. Single toggle for "framework has no
* v1 docs to port" (or otherwise should not appear in docs yet).
diff --git a/showcase/shell-docs/src/lib/seo-redirects.ts b/showcase/shell-docs/src/lib/seo-redirects.ts
index 66091f7a361..b07d41dd8db 100644
--- a/showcase/shell-docs/src/lib/seo-redirects.ts
+++ b/showcase/shell-docs/src/lib/seo-redirects.ts
@@ -525,6 +525,206 @@ const ROOT_RENAMES: RedirectEntry[] = [
},
];
+// ---------------------------------------------------------------------------
+// BIA-default root surface — Built-in Agent is now the soft-default
+// framework, so legacy and external links of the form `/`
+// (without a framework prefix) should resolve to the canonical
+// `/built-in-agent/` rather than 404. Each entry below targets
+// a topic whose only on-disk file is under
+// `content/docs/integrations/built-in-agent/` — there is no agnostic
+// root MDX, so without these redirects the bare URL has nothing to
+// render. SidebarLink already rewrites internal sidebar clicks to the
+// framework-scoped URL, so the affected traffic is external (blog
+// posts, marketing material, old bookmarks).
+// ---------------------------------------------------------------------------
+
+const BIA_DEFAULT_ROOT_REDIRECTS: RedirectEntry[] = [
+ {
+ id: "BIA-server-tools",
+ source: "/server-tools",
+ destination: "/built-in-agent/server-tools",
+ },
+ {
+ id: "BIA-mcp-servers",
+ source: "/mcp-servers",
+ destination: "/built-in-agent/mcp-servers",
+ },
+ {
+ id: "BIA-model-selection",
+ source: "/model-selection",
+ destination: "/built-in-agent/model-selection",
+ },
+ {
+ id: "BIA-advanced-configuration",
+ source: "/advanced-configuration",
+ destination: "/built-in-agent/advanced-configuration",
+ },
+ {
+ id: "BIA-agent-app-context",
+ source: "/agent-app-context",
+ destination: "/built-in-agent/agent-app-context",
+ },
+];
+
+// ---------------------------------------------------------------------------
+// Moved root pages — topics that used to be addressable at `/` in
+// the legacy docs surface but moved into a new section under shell-docs.
+// These don't fit under "renames" (the slug stays the same) — only the
+// parent folder changed. Each maps to a real on-disk MDX file.
+// ---------------------------------------------------------------------------
+
+const MOVED_ROOT_REDIRECTS: RedirectEntry[] = [
+ // /mcp-apps lived at `/generative-ui/mcp-apps` in legacy docs; the
+ // bare `/mcp-apps` URL was used in external references (blog posts,
+ // product copy) and now 404s.
+ {
+ id: "MV-mcp-apps",
+ source: "/mcp-apps",
+ destination: "/generative-ui/mcp-apps",
+ },
+ // /copilot-runtime and /custom-agent moved under /backend/ in
+ // shell-docs. R25 already covers /runtime-server-adapter; these
+ // cover the canonical legacy paths.
+ {
+ id: "MV-copilot-runtime",
+ source: "/copilot-runtime",
+ destination: "/backend/copilot-runtime",
+ },
+ {
+ id: "MV-custom-agent",
+ source: "/custom-agent",
+ destination: "/backend/custom-agent",
+ },
+ // Deep Agents promoted from a langgraph subpath to its own
+ // integration. L12/L13 (in LEGACY_CHAINS_EXACT) cover the
+ // /langgraph/deep-agents and /langgraph-python/deep-agents variants;
+ // this catches the bare /deep-agents URL.
+ {
+ id: "MV-deep-agents",
+ source: "/deep-agents",
+ destination: "/deepagents",
+ },
+ // /multi-agent-flows is a LangGraph-only topic. The bare URL was
+ // never authored agnostically; send legacy hits to the LangGraph
+ // (Python) variant, which is the dominant traffic source.
+ {
+ id: "MV-multi-agent-flows",
+ source: "/multi-agent-flows",
+ destination: "/langgraph-python/multi-agent-flows",
+ },
+ // /generative-ui/specs/* — the "specs" subgroup was retired in
+ // favour of flat /generative-ui/ pages. The /learn/ tree's
+ // legacy variant is already covered upstream; these catch the
+ // /generative-ui/specs/* surface directly.
+ {
+ id: "MV-gs-mcp-apps",
+ source: "/generative-ui/specs/mcp-apps",
+ destination: "/generative-ui/mcp-apps",
+ },
+ {
+ id: "MV-gs-a2ui",
+ source: "/generative-ui/specs/a2ui",
+ destination: "/generative-ui/a2ui",
+ },
+ {
+ id: "MV-gs-open-json-ui",
+ source: "/generative-ui/specs/open-json-ui",
+ destination: "/generative-ui/open-json-ui",
+ },
+ {
+ id: "MV-gs-root",
+ source: "/generative-ui/specs",
+ destination: "/concepts/generative-ui-overview",
+ },
+ // /custom-look-and-feel folder index — no index.mdx exists, so the
+ // bare folder URL 404s. /slots is the canonical first-page entry
+ // (matches what the sidebar opens by default).
+ {
+ id: "MV-clf-root",
+ source: "/custom-look-and-feel",
+ destination: "/custom-look-and-feel/slots",
+ },
+ // /custom-look-and-feel/customize-built-in-ui-components was a
+ // pre-cutover page that consolidated into /slots. The /unselected/
+ // variant is handled in next.config.ts; this catches the canonical
+ // (non-prefixed) legacy URL.
+ {
+ id: "MV-clf-customize",
+ source: "/custom-look-and-feel/customize-built-in-ui-components",
+ destination: "/custom-look-and-feel/slots",
+ },
+ // /what-is-copilotkit was a landing alias in legacy docs (referenced
+ // from CONTRIBUTING.md). Send to the home page.
+ {
+ id: "MV-what-is",
+ source: "/what-is-copilotkit",
+ destination: "/",
+ },
+ // /getting-started/quickstart-chatbot — legacy quickstart URL used
+ // in older marketing copy. /quickstart already redirects to the BIA
+ // quickstart (handled in next.config.ts).
+ {
+ id: "MV-gs-qs-chatbot",
+ source: "/getting-started/quickstart-chatbot",
+ destination: "/quickstart",
+ },
+ // /telemetry was a one-off legacy page. Send to the home page rather
+ // than 404 since the topic has no current canonical home.
+ {
+ id: "MV-telemetry",
+ source: "/telemetry",
+ destination: "/",
+ },
+ // /reference/hooks/useCoAgent — useCoAgent (v1) was renamed to
+ // useAgent (v2). External links still point at the old name.
+ {
+ id: "MV-ref-useCoAgent",
+ source: "/reference/hooks/useCoAgent",
+ destination: "/reference/hooks/useAgent",
+ },
+ // /migration-guides → /migrate (MG1-MG4 in MIGRATION_GUIDES cover
+ // most entries; migrate-attachments wasn't in that set).
+ {
+ id: "MV-mg-attachments",
+ source: "/migration-guides/migrate-attachments",
+ destination: "/migrate/v2",
+ },
+ // /migration/* — pre-rename of /migrate/* and /migration-guides/*.
+ // Covers the singular "migration" prefix used by a few older docs.
+ {
+ id: "MV-migration-render-message",
+ source: "/migration/render-message",
+ destination: "/migrate/v2",
+ },
+];
+
+// ---------------------------------------------------------------------------
+// Legacy `/integrations//*` URL surface — the upstream Fumadocs
+// site rewrote // → /integrations// internally,
+// and authored external links sometimes leaked the rewritten form. R15
+// + R17 (above) cover the built-in-agent variant; this section covers
+// the remaining frameworks. The /docs/integrations/* variants are
+// already handled by DOCS_INTEGRATIONS_RENAMES below.
+// ---------------------------------------------------------------------------
+
+const INTEGRATIONS_PREFIX_RENAMES: RedirectEntry[] = FRAMEWORKS.filter(
+ (fw) => fw !== "unselected",
+).flatMap((fw) => {
+ const fwDest = canonicalSlug(fw);
+ return [
+ {
+ id: `INT-wild×${fw}`,
+ source: `/integrations/${fw}/:path*`,
+ destination: `/${fwDest}/:path*`,
+ },
+ {
+ id: `INT-root×${fw}`,
+ source: `/integrations/${fw}`,
+ destination: `/${fwDest}`,
+ },
+ ];
+});
+
// ---------------------------------------------------------------------------
// Category 2: Legacy Redirect Chains (coagents -> langgraph-python, crewai-crews -> crewai-crews)
// Specific entries BEFORE the catch-all wildcards
@@ -824,9 +1024,12 @@ export const seoRedirects: RedirectEntry[] = [
...SPECIFIC_FRAMEWORK,
...CODING_AGENTS_RENAMES,
...ROOT_RENAMES,
+ ...BIA_DEFAULT_ROOT_REDIRECTS,
+ ...MOVED_ROOT_REDIRECTS,
...LEGACY_CHAINS_EXACT,
...DOCS_INTEGRATIONS_INDEX.filter((e) => !e.source.includes(":path*")),
...DOCS_INTEGRATIONS_RENAMES.filter((e) => !e.source.includes(":path*")),
+ ...INTEGRATIONS_PREFIX_RENAMES.filter((e) => !e.source.includes(":path*")),
...DOCS_PREFIX,
...MIGRATION_GUIDES,
...FOLDER_INDEX,
@@ -835,6 +1038,7 @@ export const seoRedirects: RedirectEntry[] = [
// 3. Wildcard catch-alls last — order matters: most-specific wildcard first
...DOCS_INTEGRATIONS_RENAMES.filter((e) => e.source.includes(":path*")),
...DOCS_INTEGRATIONS_INDEX.filter((e) => e.source.includes(":path*")),
+ ...INTEGRATIONS_PREFIX_RENAMES.filter((e) => e.source.includes(":path*")),
...SLUG_RENAME_REDIRECTS,
...WILDCARD_REDIRECTS,
];