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, ];