Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
56 changes: 44 additions & 12 deletions .github/workflows/canary-ramp.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,14 @@ jobs:
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}
EDGE_CONFIG_ID: ${{ secrets.VERCEL_EDGE_CONFIG_ID }}
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
# Vercel Deployment Protection blocks raw *.vercel.app deploy URLs with
# HTTP 401 "Authentication Required" by default. The SLO check curls the
# raw prod deploy URL (needed for per-deployment canary routing), so
# without this bypass every SLO check fails and the canary rolls back to
# 0% every 15 min. Generate via Vercel → Project Settings → Deployment
# Protection → Protection Bypass for Automation.
# Local patch — not yet upstream in shadow-canary-templates (PR #16 style).
VERCEL_AUTOMATION_BYPASS_SECRET: ${{ secrets.VERCEL_AUTOMATION_BYPASS_SECRET }}
# GitHub injects GITHUB_REPOSITORY ("owner/repo") into every step by
# default; we restate it here so the derivation still works if someone
# later scopes env at the step level or containerizes the job.
Expand Down Expand Up @@ -100,13 +108,26 @@ jobs:
URL='${{ steps.state.outputs.prod_url }}/api/slo'
case "$URL" in http*) ;; *) URL="https://$URL" ;; esac

# Send the Vercel Deployment Protection bypass header when the secret
# is present. Without it, raw *.vercel.app URLs return 401 HTML and
# every SLO check fails. If the secret is unset (e.g. protection is
# disabled), the curl just omits the header and proceeds normally.
# Local patch — not yet upstream in shadow-canary-templates.
BYPASS_ARGS=()
if [ -n "$VERCEL_AUTOMATION_BYPASS_SECRET" ]; then
BYPASS_ARGS=(-H "x-vercel-protection-bypass: $VERCEL_AUTOMATION_BYPASS_SECRET")
fi

OK=0
LAST_BODY=""
for i in 1 2; do
CODE=$(curl -s -o /tmp/slo_body.txt -w "%{http_code}" --max-time 10 "$URL" || echo "000")
CODE=$(curl -s -o /tmp/slo_body.txt -w "%{http_code}" --max-time 10 "${BYPASS_ARGS[@]}" "$URL" || echo "000")
BODY=$(cat /tmp/slo_body.txt 2>/dev/null || true)
# Trim to 80 chars, strip newlines/CR for YAML-safe single-line output
BODY_TRIMMED="$(echo "${BODY:0:80}" | tr '\n\r' ' ')"
# Trim to 500 chars, strip newlines/CR for YAML-safe single-line output.
# 500 gives enough room to show a full JSON payload or a truncated
# stack trace without blowing the Edge Config 8 KB per-item cap
# (10 entries × 500 B ≈ 5 KB, plus the rest of ShadowConfig).
BODY_TRIMMED="$(echo "${BODY:0:500}" | tr '\n\r' ' ')"
echo "SLO check $i → HTTP $CODE :: $BODY_TRIMMED"
if [ "$CODE" = "200" ]; then OK=$((OK + 1)); fi
echo "code$i=$CODE" >> "$GITHUB_OUTPUT"
Expand Down Expand Up @@ -203,15 +224,26 @@ jobs:
# Runs on both pass and fail paths (not on skip) so the admin UI has a
# trail of recent checks to diagnose "why isn't the canary moving?".
if: steps.gate.outputs.skip == '0'
env:
# Pass step outputs through env instead of ${{ … }} substitution in
# the shell script — GitHub substitutes ${{ }} as raw text, so a body
# excerpt like `{"ok":true,...` breaks the surrounding "…" shell
# string with its embedded quotes. env vars are properly escaped and
# available as $SLO_* below.
# Local patch — not yet upstream in shadow-canary-templates (PR #17 style).
SLO_OK: ${{ steps.slo.outputs.ok }}
SLO_CODE1: ${{ steps.slo.outputs.code1 }}
SLO_CODE2: ${{ steps.slo.outputs.code2 }}
SLO_BODY_EXCERPT: ${{ steps.slo.outputs.body_excerpt }}
SLO_PCT_BEFORE: ${{ steps.state.outputs.pct }}
SLO_PCT_AFTER: ${{ steps.compute.outputs.next }}
run: |
OK='${{ steps.slo.outputs.ok }}'
PCT_BEFORE='${{ steps.state.outputs.pct }}'
if [ "$OK" = "1" ]; then
PCT_AFTER='${{ steps.compute.outputs.next }}'
if [ "$SLO_OK" = "1" ]; then
PCT_AFTER="$SLO_PCT_AFTER"
else
PCT_AFTER="0"
fi
OK_BOOL=$( [ "$OK" = "1" ] && echo true || echo false )
OK_BOOL=$( [ "$SLO_OK" = "1" ] && echo true || echo false )

# Re-read the current Edge Config value because the bump/rollback
# step just mutated it — we want to merge sloChecks on top of the
Expand All @@ -224,10 +256,10 @@ jobs:
ENTRY=$(jq -n \
--arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
--argjson ok "$OK_BOOL" \
--argjson code1 "${{ steps.slo.outputs.code1 }}" \
--argjson code2 "${{ steps.slo.outputs.code2 }}" \
--arg body "${{ steps.slo.outputs.body_excerpt }}" \
--argjson pctBefore "$PCT_BEFORE" \
--argjson code1 "$SLO_CODE1" \
--argjson code2 "$SLO_CODE2" \
--arg body "$SLO_BODY_EXCERPT" \
--argjson pctBefore "$SLO_PCT_BEFORE" \
--argjson pctAfter "$PCT_AFTER" \
'{ts: $ts, ok: $ok, codes: [$code1, $code2], bodyExcerpt: $body, pctBefore: $pctBefore, pctAfter: $pctAfter}')

Expand Down
28 changes: 21 additions & 7 deletions .github/workflows/deploy-shadow.yml
Original file line number Diff line number Diff line change
Expand Up @@ -54,14 +54,28 @@ jobs:

MERGED=$(echo "$CURRENT" | jq \
--arg shadowUrl "${{ steps.deploy.outputs.url }}" \
'. + {
deploymentDomainProd: (.deploymentDomainProd // $shadowUrl),
deploymentDomainShadowPrevious: (
if (.deploymentDomainShadow // "") != "" and .deploymentDomainShadow != $shadowUrl
then .deploymentDomainShadow
else (.deploymentDomainShadowPrevious // null)
'. as $cur
| (
# Prepend the outgoing shadow URL to history iff it exists
# and differs from the incoming one. Dedupe while preserving
# order (keeps the FIRST occurrence, which is the most
# recent). Trim to 20 — matches the admin UI rollback picker.
# Commit metadata (sha/ref/ts) is fetched on-demand by the
# admin UI from the Vercel API, so history stays compact.
if ($cur.deploymentDomainShadow // "") != "" and $cur.deploymentDomainShadow != $shadowUrl then
([$cur.deploymentDomainShadow] + ($cur.shadowHistory // []))
| reduce .[] as $x ([]; if any(. == $x) then . else . + [$x] end)
| .[0:20]
else
($cur.shadowHistory // [])
end
),
) as $history
| . + {
deploymentDomainProd: (.deploymentDomainProd // $shadowUrl),
shadowHistory: $history,
# Deprecated: kept populated for back-compat with v0.4.x admin
# UIs reading the old field. Remove in v0.5.
deploymentDomainShadowPrevious: ($history[0] // null),
deploymentDomainShadow: $shadowUrl,
trafficShadowPercent: (.trafficShadowPercent // 1),
trafficProdCanaryPercent: (.trafficProdCanaryPercent // 100),
Expand Down
120 changes: 92 additions & 28 deletions app/admin/admin.css
Original file line number Diff line number Diff line change
Expand Up @@ -383,6 +383,11 @@
color: rgba(255, 255, 255, 0.75);
}

.adm-timing-countdown--overdue {
color: #fbbf24;
font-weight: 600;
}

/* ---------- Traffic bar ---------- */

.adm-bar-wrap {
Expand Down Expand Up @@ -524,6 +529,16 @@
font-weight: 500;
font-size: 0.88rem;
padding-top: 2px;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 1px;
}

.adm-legend-value-hint {
font-size: 0.68rem;
font-weight: 400;
opacity: 0.5;
}

/* ---------- SLO log ---------- */
Expand All @@ -538,15 +553,11 @@
}

.adm-slo-row {
display: grid;
grid-template-columns: 16px auto auto 1fr auto;
align-items: center;
gap: 10px;
font-size: 0.82rem;
padding: 6px 10px;
border-radius: 6px;
background: rgba(255, 255, 255, 0.02);
border-left: 2px solid transparent;
overflow: hidden;
}

.adm-slo-row--ok {
Expand All @@ -558,6 +569,32 @@
background: rgba(239, 68, 68, 0.05);
}

.adm-slo-summary {
all: unset;
box-sizing: border-box;
display: grid;
grid-template-columns: 16px auto auto 1fr auto;
align-items: center;
gap: 10px;
font-size: inherit;
padding: 6px 10px;
width: 100%;
cursor: default;
}

.adm-slo-row--clickable .adm-slo-summary {
cursor: pointer;
}

.adm-slo-row--clickable .adm-slo-summary:hover {
background: rgba(255, 255, 255, 0.03);
}

.adm-slo-summary:focus-visible {
outline: 1px solid rgba(255, 255, 255, 0.3);
outline-offset: -1px;
}

.adm-slo-icon {
font-weight: 700;
font-size: 0.95rem;
Expand Down Expand Up @@ -603,17 +640,49 @@
font-weight: 600;
}

.adm-slo-body {
.adm-slo-caret {
opacity: 0.45;
font-size: 0.75rem;
margin-left: 6px;
transition: opacity 0.12s;
}

.adm-slo-row--clickable .adm-slo-summary:hover .adm-slo-caret {
opacity: 0.85;
}

.adm-slo-body-wrap {
padding: 0 10px 0 36px;
}

.adm-slo-body-preview {
display: block;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 0.7rem;
opacity: 0.5;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 100%;
grid-column: 1 / -1;
padding-left: 26px;
padding-top: 2px;
padding-bottom: 6px;
}

.adm-slo-body-wrap--open {
padding-bottom: 10px;
}

.adm-slo-body-full {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 0.72rem;
opacity: 0.8;
margin: 6px 0 0;
padding: 8px 10px;
background: rgba(0, 0, 0, 0.25);
border-radius: 4px;
white-space: pre-wrap;
word-break: break-all;
max-height: 300px;
overflow-y: auto;
}

.adm-slo-ok-inline {
Expand Down Expand Up @@ -883,30 +952,25 @@
padding-right: 24px;
}

.adm-shadow-rollback {
display: flex;
.adm-step-input-wrap {
position: relative;
display: inline-flex;
align-items: center;
gap: 10px;
margin-top: 18px;
padding-top: 14px;
border-top: 1px solid rgba(255, 255, 255, 0.05);
flex-wrap: wrap;
}

.adm-shadow-rollback-label {
font-size: 0.82rem;
opacity: 0.75;
}

.adm-shadow-rollback-urls {
font-size: 0.74rem;
opacity: 0.55;
flex: 1;
min-width: 0;
.adm-step-input-wrap::after {
content: '%';
position: absolute;
right: 9px;
pointer-events: none;
color: rgba(255, 255, 255, 0.4);
font-size: 0.78rem;
}

.adm-shadow-rollback-urls code {
font-size: 0.74rem;
.adm-step-input {
width: 62px;
padding-right: 22px;
font-size: 0.85rem;
}

/* ---------- Bucket forcer ---------- */
Expand Down
Loading