Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
8e59011
fix(python): serialize AGUI agent metadata safely
May 27, 2026
9158241
Merge branch 'main' into wt-4995-29229-6154
ranst91 May 27, 2026
ca5e561
Merge branch 'main' into wt-4995-29229-6154
ranst91 May 27, 2026
8966d76
fix(shell-docs): redirect legacy/external URLs that 404 post-BIA cutover
samjulien May 28, 2026
f954a2e
Merge branch 'main' into wt-4995-29229-6154
ranst91 May 28, 2026
f0fa6d6
fix(python): serialize AGUI agent metadata safely (#5036)
ranst91 May 28, 2026
8eba35e
fix(shell-docs): redirect legacy/external URLs that 404 post-BIA cuto…
samjulien May 28, 2026
4e21ab1
fix(showcase): restore authored shell-docs sidebar
tylerslaton May 28, 2026
c70e5ed
Merge branch 'main' into codex/restore-authored-shell-docs-sidebar
tylerslaton May 28, 2026
9b22849
fix(showcase): restore authored shell-docs sidebar (#5080)
tylerslaton May 28, 2026
4bc7427
fix(shell-docs): fix Tab double-escaping that hid JSON Configuration …
onsclom May 28, 2026
56e85df
feat(showcase): add bin/railway Ruby tooling for Railway ops
jpr5 May 28, 2026
8abe6c0
style: auto-fix formatting
github-actions[bot] May 28, 2026
83ec206
fix(showcase/pocketbase): lock anonymous user signup and baseline writes
jpr5 May 28, 2026
4fbcb4c
fix(shell-docs): fix Tab double-escaping that hid multi-word tab cont…
onsclom May 28, 2026
15303cd
chore(showcase): make lint-prod CI step advisory during initial soak
jpr5 May 28, 2026
63f5f15
style: auto-fix formatting
github-actions[bot] May 28, 2026
d4edc96
feat(showcase): add lint-prod visibility surfaces (step summary + sti…
jpr5 May 28, 2026
d425a0d
style: auto-fix formatting
github-actions[bot] May 28, 2026
0d8277c
fix(showcase): make lint-prod workflow resilient to snapshot/GraphQL …
jpr5 May 28, 2026
79ef098
ci: re-trigger lint-prod to verify sticky-comment idempotency
jpr5 May 28, 2026
9def341
fix(showcase): align bin/railway GraphQL with Railway public schema
jpr5 May 28, 2026
5cac38c
ci(showcase): pin actions and disable credential persistence in lint-…
jpr5 May 28, 2026
2775ac1
fix(showcase/pocketbase): lock anonymous user signup and baseline wri…
jpr5 May 28, 2026
9756697
feat(showcase): bin/railway Ruby tooling for Railway ops (#5082)
jpr5 May 28, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
173 changes: 173 additions & 0 deletions .github/workflows/showcase_lint_prod.yml
Original file line number Diff line number Diff line change
@@ -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 (<!-- lint-prod-sticky-comment -->). 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 << "<details><summary>Error</summary>\n\n```\n#{err}\n```\n\n</details>\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 "<!-- lint-prod-sticky-comment -->"
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="<!-- lint-prod-sticky-comment -->"
# 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
7 changes: 5 additions & 2 deletions sdk-python/copilotkit/langgraph_agui_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
}
36 changes: 36 additions & 0 deletions sdk-python/tests/test_agui_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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",
}
]
159 changes: 159 additions & 0 deletions showcase/bin/README.md
Original file line number Diff line number Diff line change
@@ -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
`<!-- lint-prod-sticky-comment -->` 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.
Loading
Loading