Skip to content

Commit 37577a2

Browse files
authored
Merge branch 'main' into feat/copilotkit-middleware-auto-a2ui
2 parents 59cbce0 + 669f60f commit 37577a2

34 files changed

Lines changed: 3753 additions & 107 deletions

.github/workflows/showcase_promote.yml

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ name: "Showcase: Promote (staging → prod)"
1717
# target service(s). Feature-level probes,
1818
# not naked 200.
1919
# 4. notify → Slack #oss-alerts on any red. Never #engr.
20+
# success → #team-showcase.
2021

2122
on:
2223
workflow_dispatch:
@@ -314,6 +315,7 @@ jobs:
314315
actions: read
315316
env:
316317
SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK_OSS_ALERTS }}
318+
SLACK_WEBHOOK_TS: ${{ secrets.SLACK_WEBHOOK_TEAM_SHOWCASE }}
317319
steps:
318320
# Best-effort promote invariant (do NOT reintroduce a PRE gate below):
319321
# resolve-targets = HARD precondition (must succeed).
@@ -387,9 +389,33 @@ jobs:
387389
github.run_id
388390
)) }}
389391
}
392+
- name: Post to #team-showcase
393+
if: steps.state.outputs.state == 'success' && inputs.service != '__select_a_service__' && env.SLACK_WEBHOOK_TS != ''
394+
uses: slackapi/slack-github-action@b0fa283ad8fea605de13dc3f449259339835fc52 # v2.1.0
395+
with:
396+
webhook: ${{ secrets.SLACK_WEBHOOK_TEAM_SHOWCASE }}
397+
webhook-type: incoming-webhook
398+
payload: |
399+
{
400+
"text": ${{ toJSON(format(
401+
'{0} *Showcase Promoted to Prod*\nServices: `{1}`\n<{2}/{3}/actions/runs/{4}|View run>',
402+
steps.state.outputs.icon,
403+
steps.state.outputs.csv,
404+
github.server_url,
405+
github.repository,
406+
github.run_id
407+
)) }}
408+
}
390409
- name: Log (no Slack — webhook unset)
391410
if: steps.state.outputs.state == 'failure' && inputs.service != '__select_a_service__' && env.SLACK_WEBHOOK == ''
392411
env:
393412
CSV: ${{ steps.state.outputs.csv }}
394413
run: |
395414
echo "::warning::Showcase promote failed for '$CSV' but SLACK_WEBHOOK_OSS_ALERTS is not set; no Slack notification sent."
415+
- name: Log (no Slack — team-showcase webhook unset)
416+
if: steps.state.outputs.state == 'success' && inputs.service != '__select_a_service__' && env.SLACK_WEBHOOK_TS == ''
417+
env:
418+
CSV: ${{ steps.state.outputs.csv }}
419+
ICON: ${{ steps.state.outputs.icon }}
420+
run: |
421+
echo "::notice::$ICON Showcase promoted to prod for '$CSV' but SLACK_WEBHOOK_TEAM_SHOWCASE is not set; no #team-showcase notification sent."

showcase/bin/railway

Lines changed: 41 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -332,15 +332,49 @@ module Railway
332332
h
333333
end
334334

335+
# Mint the bearer used for manifest reads via GHCR's /token exchange.
336+
#
337+
# GHCR's OCI manifest endpoint rejects a RAW GitHub/Actions token sent as
338+
# `Authorization: Bearer <token>` with HTTP 403 — the token must first be
339+
# exchanged for a short-lived registry bearer. So we ALWAYS hit /token,
340+
# even when a token is present (the previous "return @token raw" path was
341+
# the bug that made every `docs` promote 403 in CI).
342+
#
343+
# When a token is present we authenticate the exchange with Basic auth:
344+
# base64("x-access-token:<token>") — the username is arbitrary, the token
345+
# is the password. For public packages the exchange also succeeds
346+
# anonymously, so a missing token still yields a usable bearer.
335347
def bearer_for(org, name)
336-
return @token if @token && !@token.empty?
337-
# Public images: anonymous token from /token endpoint.
338348
url = "https://ghcr.io/token?service=ghcr.io&scope=repository:#{org}/#{name}:pull"
339-
resp = http_get(url, headers: {})
340-
return nil if resp[:status] >= 400
341-
JSON.parse(resp[:body])["token"]
342-
rescue StandardError
343-
nil
349+
headers = {}
350+
if @token && !@token.empty?
351+
headers["Authorization"] = "Basic " + ["x-access-token:#{@token}"].pack("m0")
352+
end
353+
354+
resp =
355+
if @http
356+
@http.call(method: :get, url: url, headers: headers)
357+
else
358+
http_get(url, headers: headers)
359+
end
360+
361+
status = resp[:status]
362+
if status >= 400
363+
# A token WAS supplied but the exchange failed: surface it. The
364+
# caller must NOT silently downgrade to an anonymous manifest
365+
# read (that conflates "no token" with "supplied token failed").
366+
raise Error, "GHCR /token exchange failed (#{status}) for #{org}/#{name}" if @token && !@token.empty?
367+
368+
# No token supplied: a non-2xx is the legitimate signal that the
369+
# package is not anonymously pullable. The caller handles nil.
370+
return nil
371+
end
372+
373+
begin
374+
JSON.parse(resp[:body])["token"]
375+
rescue JSON::ParserError => e
376+
raise Error, "GHCR /token returned unparseable body for #{org}/#{name}: #{e.message}"
377+
end
344378
end
345379

346380
def http_get(url, headers: {})
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
# frozen_string_literal: true
2+
3+
require_relative "spec_helper"
4+
5+
# Proves bearer_for ALWAYS performs the GHCR /token exchange instead of
6+
# returning a raw GitHub token. GHCR's OCI manifest endpoint rejects a raw
7+
# GitHub Actions token with HTTP 403 — only a bearer minted via the /token
8+
# exchange is accepted. When a token is present the exchange MUST authenticate
9+
# with Basic auth (base64("x-access-token:<token>")); for public packages the
10+
# exchange also succeeds anonymously.
11+
class GHCRBearerTest < Minitest::Test
12+
# Recording fake HTTP layer: captures every (method, url, headers) call so
13+
# tests can assert what was actually sent on the wire.
14+
class RecordingHTTP
15+
attr_reader :calls
16+
17+
def initialize(responses)
18+
@responses = responses
19+
@calls = []
20+
end
21+
22+
def call(method:, url:, headers: {})
23+
@calls << { method: method, url: url, headers: headers }
24+
r = @responses[[method, url]] || @responses[url]
25+
raise "no fake response for #{[method, url].inspect}" unless r
26+
r
27+
end
28+
end
29+
30+
DIGEST = "sha256:cafef00dcafef00dcafef00dcafef00dcafef00dcafef00dcafef00dcafef00d"
31+
REF = "ghcr.io/copilotkit/showcase-shell@#{DIGEST}"
32+
MANIFEST = "https://ghcr.io/v2/copilotkit/showcase-shell/manifests/#{DIGEST}"
33+
TOKEN_URL = "https://ghcr.io/token?service=ghcr.io&scope=repository:copilotkit/showcase-shell:pull"
34+
RAW_TOKEN = "ghs_rawGitHubActionsToken"
35+
MINTED = "minted-bearer-from-exchange"
36+
37+
def fakes(extra = {})
38+
RecordingHTTP.new({
39+
TOKEN_URL => { status: 200, headers: {}, body: %({"token":"#{MINTED}"}) },
40+
MANIFEST => { status: 200, headers: {}, body: "" },
41+
}.merge(extra))
42+
end
43+
44+
def test_token_present_performs_basic_auth_exchange
45+
http = fakes
46+
g = Railway::GHCR.new(token: RAW_TOKEN, http: http)
47+
assert_equal :exists, g.manifest_exists(REF)
48+
49+
token_call = http.calls.find { |c| c[:method] == :get && c[:url] == TOKEN_URL }
50+
refute_nil token_call, "bearer_for must hit the GHCR /token exchange even when a token is present"
51+
52+
expected_basic = "Basic " + ["x-access-token:#{RAW_TOKEN}"].pack("m0")
53+
auth = token_call[:headers]["Authorization"]
54+
assert_equal expected_basic, auth,
55+
"token exchange must authenticate with Basic base64(x-access-token:<token>)"
56+
end
57+
58+
def test_manifest_read_uses_minted_bearer_not_raw_token
59+
http = fakes
60+
g = Railway::GHCR.new(token: RAW_TOKEN, http: http)
61+
g.manifest_exists(REF)
62+
63+
manifest_call = http.calls.find { |c| c[:method] == :head && c[:url] == MANIFEST }
64+
refute_nil manifest_call
65+
assert_equal "Bearer #{MINTED}", manifest_call[:headers]["Authorization"],
66+
"manifest read must use the minted bearer, never the raw GitHub token"
67+
refute_equal "Bearer #{RAW_TOKEN}", manifest_call[:headers]["Authorization"],
68+
"sending the raw GitHub token as a Bearer is exactly the bug that 403s"
69+
end
70+
71+
def test_anonymous_exchange_still_works_for_public_packages
72+
# No token: the exchange must still run, anonymously (no Authorization
73+
# header on the /token request), and the minted token used downstream.
74+
http = fakes
75+
g = Railway::GHCR.new(token: nil, http: http)
76+
assert_equal :exists, g.manifest_exists(REF)
77+
78+
token_call = http.calls.find { |c| c[:method] == :get && c[:url] == TOKEN_URL }
79+
refute_nil token_call, "anonymous path must still mint a bearer via /token"
80+
assert_nil token_call[:headers]["Authorization"],
81+
"anonymous exchange must not send an Authorization header"
82+
83+
manifest_call = http.calls.find { |c| c[:method] == :head && c[:url] == MANIFEST }
84+
assert_equal "Bearer #{MINTED}", manifest_call[:headers]["Authorization"]
85+
end
86+
87+
# When a token WAS supplied and the /token exchange returns a non-2xx,
88+
# that is a real failure (bad/insufficient token), NOT a license to fall
89+
# back to an anonymous manifest read. bearer_for must RAISE GHCR::Error so
90+
# manifest_exists's documented raise-on-failure contract holds.
91+
def test_token_present_exchange_401_raises
92+
http = fakes(TOKEN_URL => { status: 401, headers: {}, body: "unauthorized" })
93+
g = Railway::GHCR.new(token: RAW_TOKEN, http: http)
94+
95+
err = assert_raises(Railway::GHCR::Error) { g.manifest_exists(REF) }
96+
assert_match(/token exchange failed/i, err.message)
97+
end
98+
99+
# A 200 with a body that is not parseable JSON is a broken exchange, not a
100+
# usable bearer. bearer_for must RAISE rather than swallow JSON::ParserError.
101+
def test_token_present_malformed_body_raises
102+
http = fakes(TOKEN_URL => { status: 200, headers: {}, body: "<html>not json</html>" })
103+
g = Railway::GHCR.new(token: RAW_TOKEN, http: http)
104+
105+
err = assert_raises(Railway::GHCR::Error) { g.manifest_exists(REF) }
106+
assert_match(/unparseable/i, err.message)
107+
end
108+
109+
# Regression guard: when a token was supplied and the exchange failed, the
110+
# manifest HEAD must NEVER be sent — and certainly never anonymously. The
111+
# old swallow-to-nil behavior issued an anonymous HEAD, conflating "no
112+
# token" with "supplied token failed to exchange".
113+
def test_token_present_exchange_failure_never_does_anonymous_manifest_read
114+
http = fakes(TOKEN_URL => { status: 403, headers: {}, body: "forbidden" })
115+
g = Railway::GHCR.new(token: RAW_TOKEN, http: http)
116+
117+
assert_raises(Railway::GHCR::Error) { g.manifest_exists(REF) }
118+
119+
manifest_call = http.calls.find { |c| c[:method] == :head && c[:url] == MANIFEST }
120+
assert_nil manifest_call,
121+
"no manifest HEAD must be issued when a supplied token fails the /token exchange"
122+
end
123+
end

showcase/bin/spec/test_ghcr_digest.rb

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,11 +34,19 @@ def test_parse_image_ref_handles_all_shapes
3434
assert_equal "sha256:def", p3[:digest]
3535
end
3636

37+
# bearer_for ALWAYS performs the /token exchange now, so every fake that
38+
# reaches a manifest HEAD must also model a successful exchange.
39+
TOKEN_URL = "https://ghcr.io/token?service=ghcr.io&scope=repository:copilotkit/showcase-shell:pull"
40+
41+
def token_ok
42+
{ TOKEN_URL => { status: 200, headers: {}, body: %({"token":"minted"}) } }
43+
end
44+
3745
def test_resolve_digest_returns_digest_from_header
3846
url = "https://ghcr.io/v2/copilotkit/showcase-shell/manifests/latest"
39-
fake = FakeHTTP.new(
47+
fake = FakeHTTP.new(token_ok.merge(
4048
url => { status: 200, headers: { "docker-content-digest" => "sha256:beefcafe" }, body: "" },
41-
)
49+
))
4250
g = Railway::GHCR.new(token: "x", http: fake)
4351
assert_equal "sha256:beefcafe", g.resolve_digest("ghcr.io/copilotkit/showcase-shell:latest")
4452
end
@@ -52,14 +60,14 @@ def test_resolve_digest_returns_existing_digest_immediately
5260

5361
def test_resolve_digest_returns_nil_on_404
5462
url = "https://ghcr.io/v2/copilotkit/showcase-shell/manifests/nope"
55-
fake = FakeHTTP.new(url => { status: 404, headers: {}, body: "" })
63+
fake = FakeHTTP.new(token_ok.merge(url => { status: 404, headers: {}, body: "" }))
5664
g = Railway::GHCR.new(token: "x", http: fake)
5765
assert_nil g.resolve_digest("ghcr.io/copilotkit/showcase-shell:nope")
5866
end
5967

6068
def test_resolve_digest_raises_on_5xx
6169
url = "https://ghcr.io/v2/copilotkit/showcase-shell/manifests/latest"
62-
fake = FakeHTTP.new(url => { status: 500, headers: {}, body: "boom" })
70+
fake = FakeHTTP.new(token_ok.merge(url => { status: 500, headers: {}, body: "boom" }))
6371
g = Railway::GHCR.new(token: "x", http: fake)
6472
assert_raises(Railway::GHCR::Error) do
6573
g.resolve_digest("ghcr.io/copilotkit/showcase-shell:latest")

showcase/bin/spec/test_ghcr_manifest_exists.rb

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -16,31 +16,38 @@ def call(method:, url:, headers: {})
1616
end
1717
end
1818

19-
DIGEST = "sha256:cafef00dcafef00dcafef00dcafef00dcafef00dcafef00dcafef00dcafef00d"
20-
REF = "ghcr.io/copilotkit/showcase-shell@#{DIGEST}"
21-
URL = "https://ghcr.io/v2/copilotkit/showcase-shell/manifests/#{DIGEST}"
19+
DIGEST = "sha256:cafef00dcafef00dcafef00dcafef00dcafef00dcafef00dcafef00dcafef00d"
20+
REF = "ghcr.io/copilotkit/showcase-shell@#{DIGEST}"
21+
URL = "https://ghcr.io/v2/copilotkit/showcase-shell/manifests/#{DIGEST}"
22+
TOKEN_URL = "https://ghcr.io/token?service=ghcr.io&scope=repository:copilotkit/showcase-shell:pull"
23+
24+
# bearer_for ALWAYS performs the /token exchange now, so every fake that
25+
# reaches a manifest HEAD must also model a successful exchange.
26+
def token_ok
27+
{ TOKEN_URL => { status: 200, headers: {}, body: %({"token":"minted"}) } }
28+
end
2229

2330
def test_manifest_exists_returns_exists_on_200
24-
fake = FakeHTTP.new(URL => { status: 200, headers: {}, body: "" })
31+
fake = FakeHTTP.new(token_ok.merge(URL => { status: 200, headers: {}, body: "" }))
2532
g = Railway::GHCR.new(token: "x", http: fake)
2633
assert_equal :exists, g.manifest_exists(REF)
2734
end
2835

2936
def test_manifest_exists_returns_missing_on_404
30-
fake = FakeHTTP.new(URL => { status: 404, headers: {}, body: "" })
37+
fake = FakeHTTP.new(token_ok.merge(URL => { status: 404, headers: {}, body: "" }))
3138
g = Railway::GHCR.new(token: "x", http: fake)
3239
assert_equal :missing, g.manifest_exists(REF)
3340
end
3441

3542
def test_manifest_exists_returns_auth_failed_on_401_403
36-
fake401 = FakeHTTP.new(URL => { status: 401, headers: {}, body: "" })
37-
fake403 = FakeHTTP.new(URL => { status: 403, headers: {}, body: "" })
43+
fake401 = FakeHTTP.new(token_ok.merge(URL => { status: 401, headers: {}, body: "" }))
44+
fake403 = FakeHTTP.new(token_ok.merge(URL => { status: 403, headers: {}, body: "" }))
3845
assert_equal :auth_failed, Railway::GHCR.new(token: "x", http: fake401).manifest_exists(REF)
3946
assert_equal :auth_failed, Railway::GHCR.new(token: "x", http: fake403).manifest_exists(REF)
4047
end
4148

4249
def test_manifest_exists_raises_on_5xx
43-
fake = FakeHTTP.new(URL => { status: 500, headers: {}, body: "boom" })
50+
fake = FakeHTTP.new(token_ok.merge(URL => { status: 500, headers: {}, body: "boom" }))
4451
g = Railway::GHCR.new(token: "x", http: fake)
4552
assert_raises(Railway::GHCR::Error) { g.manifest_exists(REF) }
4653
end

showcase/bin/spec/test_snapshot_ivar_lint.rb

Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -54,33 +54,33 @@ class SnapshotIvarLintTest < Minitest::Test
5454
# prose (do not perform a read)
5555
ALLOWED_LINES = [
5656
# `run` — capture full-fleet view before optional narrowing.
57-
'1149:@full_staging_snapshot = @staging_snapshot',
58-
'1150:@full_prod_snapshot = @prod_snapshot',
57+
'1183:@full_staging_snapshot = @staging_snapshot',
58+
'1184:@full_prod_snapshot = @prod_snapshot',
5959

6060
# Doc comment above narrow_snapshots_to_single_service!.
61-
'1158:# Narrow @staging_snapshot and @prod_snapshot to only the named',
61+
'1192:# Narrow @staging_snapshot and @prod_snapshot to only the named',
6262

6363
# narrow_snapshots_to_single_service! — the WRITE site.
64-
'1166:staging_match = (@staging_snapshot["services"] || []).select { |s| s["name"] == name }',
65-
'1171:@staging_snapshot = @staging_snapshot.merge("services" => staging_match)',
66-
'1172:prod_match = (@prod_snapshot["services"] || []).select { |s| s["name"] == name }',
67-
'1173:@prod_snapshot = @prod_snapshot.merge("services" => prod_match)',
64+
'1200:staging_match = (@staging_snapshot["services"] || []).select { |s| s["name"] == name }',
65+
'1205:@staging_snapshot = @staging_snapshot.merge("services" => staging_match)',
66+
'1206:prod_match = (@prod_snapshot["services"] || []).select { |s| s["name"] == name }',
67+
'1207:@prod_snapshot = @prod_snapshot.merge("services" => prod_match)',
6868

6969
# Doc comment above capture_snapshots.
70-
'1177:# @staging_snapshot / @prod_snapshot directly.',
70+
'1211:# @staging_snapshot / @prod_snapshot directly.',
7171

7272
# capture_snapshots — single test-seam assignment site.
73-
'1179:@staging_snapshot ||= SnapshotCommand.new(["--env", "staging", "--dry-run"]).build_snapshot(STAGING_ENV_ID)',
74-
'1180:@prod_snapshot ||= SnapshotCommand.new(["--env", "production", "--dry-run"]).build_snapshot(PRODUCTION_ENV_ID)',
73+
'1213:@staging_snapshot ||= SnapshotCommand.new(["--env", "staging", "--dry-run"]).build_snapshot(STAGING_ENV_ID)',
74+
'1214:@prod_snapshot ||= SnapshotCommand.new(["--env", "production", "--dry-run"]).build_snapshot(PRODUCTION_ENV_ID)',
7575

7676
# Doc comment above the accessor block (explains test seam).
77-
'1204:# promote tests stub @staging_snapshot/@prod_snapshot directly',
77+
'1238:# promote tests stub @staging_snapshot/@prod_snapshot directly',
7878

7979
# The four accessor bodies — the ONLY sanctioned reads.
80-
'1216:@full_staging_snapshot || @staging_snapshot',
81-
'1220:@full_prod_snapshot || @prod_snapshot',
82-
'1224:@staging_snapshot',
83-
'1228:@prod_snapshot',
80+
'1250:@full_staging_snapshot || @staging_snapshot',
81+
'1254:@full_prod_snapshot || @prod_snapshot',
82+
'1258:@staging_snapshot',
83+
'1262:@prod_snapshot',
8484
].freeze
8585

8686
def setup

0 commit comments

Comments
 (0)