CI builder progress view enhancements

This commit is contained in:
Ben Allfree
2026-04-10 06:20:29 -07:00
parent 6edd84539a
commit 61095ede6a
8 changed files with 244 additions and 24 deletions
+18
View File
@@ -35,6 +35,7 @@ jobs:
CONVEX_URL: ${{ inputs.convex_url }}
REPO_BUILD_ID: ${{ inputs.repo_build_id }}
CONVEX_BUILD_TOKEN: ${{ secrets.CONVEX_BUILD_TOKEN }}
CI_PROGRESS_TOTAL: "5"
steps:
- uses: actions/checkout@v4
@@ -46,6 +47,13 @@ jobs:
-H "Authorization: Bearer $CONVEX_BUILD_TOKEN" \
-d "{\"repo_build_id\":\"$REPO_BUILD_ID\",\"state\":\"running\",\"github_run_id\":${{ github.run_id }}}"
- name: CI progress — build started
shell: bash
env:
STEP_INDEX: 1
LABEL: Build started on Actions
run: python3 "${{ github.workspace }}/scripts/report-convex-ci-progress.py"
- name: Set up Python
uses: actions/setup-python@v5
with:
@@ -56,6 +64,13 @@ jobs:
with:
bun-version: latest
- name: CI progress — tooling ready
shell: bash
env:
STEP_INDEX: 2
LABEL: Runner tooling ready
run: python3 "${{ github.workspace }}/scripts/report-convex-ci-progress.py"
- name: Download source archive
shell: bash
env:
@@ -63,6 +78,7 @@ jobs:
REPO: ${{ inputs.repo }}
REF: ${{ inputs.ref }}
run: |
STEP_INDEX=3 LABEL="Downloading source archive" python3 "${{ github.workspace }}/scripts/report-convex-ci-progress.py"
ENC_REF=$(python3 -c "import urllib.parse,os; print(urllib.parse.quote(os.environ['REF'], safe=''))")
curl -fsSL -o /tmp/src.zip "https://codeload.github.com/${OWNER}/${REPO}/zip/${ENC_REF}"
@@ -74,6 +90,7 @@ jobs:
R2_BUCKET_NAME: ${{ secrets.R2_BUCKET_NAME }}
shell: bash
run: |
STEP_INDEX=4 LABEL="Building firmware (PlatformIO)" python3 "${{ github.workspace }}/scripts/report-convex-ci-progress.py"
set -e
sudo apt-get update -qq && sudo apt-get install -y -qq unzip
unzip -q /tmp/src.zip -d /tmp/src
@@ -107,6 +124,7 @@ jobs:
tar -czf "/tmp/$ARTIFACT_NAME" -C "$STAGE" .
OBJECT_PATH="${R2_BUCKET_NAME}/${ARTIFACT_NAME}"
bunx wrangler r2 object put "$OBJECT_PATH" --file "/tmp/$ARTIFACT_NAME" --remote
STEP_INDEX=5 LABEL="Firmware bundle uploaded" python3 "${{ github.workspace }}/scripts/report-convex-ci-progress.py"
echo "r2_key=${ARTIFACT_NAME}" >> "$GITHUB_OUTPUT"
- name: Notify Convex — success
+18
View File
@@ -35,6 +35,7 @@ jobs:
CONVEX_URL: ${{ inputs.convex_url }}
REPO_BUILD_ID: ${{ inputs.repo_build_id }}
CONVEX_BUILD_TOKEN: ${{ secrets.CONVEX_BUILD_TOKEN }}
CI_PROGRESS_TOTAL: "5"
steps:
- uses: actions/checkout@v4
@@ -46,6 +47,13 @@ jobs:
-H "Authorization: Bearer $CONVEX_BUILD_TOKEN" \
-d "{\"repo_build_id\":\"$REPO_BUILD_ID\",\"state\":\"running\",\"github_run_id\":${{ github.run_id }}}"
- name: CI progress — build started
shell: bash
env:
STEP_INDEX: 1
LABEL: Build started on Actions
run: python3 "${{ github.workspace }}/scripts/report-convex-ci-progress.py"
- name: Set up Python
uses: actions/setup-python@v5
with:
@@ -56,6 +64,13 @@ jobs:
with:
bun-version: latest
- name: CI progress — tooling ready
shell: bash
env:
STEP_INDEX: 2
LABEL: Runner tooling ready
run: python3 "${{ github.workspace }}/scripts/report-convex-ci-progress.py"
- name: Download source archive
shell: bash
env:
@@ -63,6 +78,7 @@ jobs:
REPO: ${{ inputs.repo }}
REF: ${{ inputs.ref }}
run: |
STEP_INDEX=3 LABEL="Downloading source archive" python3 "${{ github.workspace }}/scripts/report-convex-ci-progress.py"
ENC_REF=$(python3 -c "import urllib.parse,os; print(urllib.parse.quote(os.environ['REF'], safe=''))")
curl -fsSL -o /tmp/src.zip "https://codeload.github.com/${OWNER}/${REPO}/zip/${ENC_REF}"
@@ -74,6 +90,7 @@ jobs:
R2_BUCKET_NAME: ${{ secrets.R2_BUCKET_NAME }}
shell: bash
run: |
STEP_INDEX=4 LABEL="Building firmware (PlatformIO)" python3 "${{ github.workspace }}/scripts/report-convex-ci-progress.py"
set -e
sudo apt-get update -qq && sudo apt-get install -y -qq unzip
unzip -q /tmp/src.zip -d /tmp/src
@@ -107,6 +124,7 @@ jobs:
tar -czf "/tmp/$ARTIFACT_NAME" -C "$STAGE" .
OBJECT_PATH="${R2_BUCKET_NAME}/${ARTIFACT_NAME}"
bunx wrangler r2 object put "$OBJECT_PATH" --file "/tmp/$ARTIFACT_NAME" --remote
STEP_INDEX=5 LABEL="Firmware bundle uploaded" python3 "${{ github.workspace }}/scripts/report-convex-ci-progress.py"
echo "r2_key=${ARTIFACT_NAME}" >> "$GITHUB_OUTPUT"
- name: Notify Convex — success
+34
View File
@@ -56,6 +56,40 @@ http.route({
}),
})
http.route({
path: "/ingest-repo-build-progress",
method: "POST",
handler: httpAction(async (ctx, request) => {
if (!verifyBearer(request)) {
return new Response("Unauthorized", { status: 401 })
}
const payload = (await request.json()) as {
repo_build_id?: string
step_index?: number
step_total?: number
label?: string
}
if (
!payload.repo_build_id ||
typeof payload.step_index !== "number" ||
typeof payload.step_total !== "number" ||
typeof payload.label !== "string"
) {
return new Response("Missing repo_build_id, step_index, step_total, or label", { status: 400 })
}
await ctx.runMutation(internal.repoBuilds.patchCiProgress, {
buildId: payload.repo_build_id as Id<"repoBuilds">,
stepIndex: payload.step_index,
stepTotal: payload.step_total,
label: payload.label,
})
return new Response(null, { status: 200 })
}),
})
/** Legacy: old workflows posting build_id + state */
http.route({
path: "/github-webhook",
+33
View File
@@ -91,11 +91,41 @@ export const patchFromWebhook = internalMutation({
if (args.errorSummary !== undefined) patch.errorSummary = args.errorSummary
if (args.status === "succeeded" || args.status === "failed") {
patch.completedAt = Date.now()
patch.ciProgressStep = undefined
patch.ciProgressTotal = undefined
patch.ciProgressLabel = undefined
}
await ctx.db.patch(args.buildId, patch)
},
})
export const patchCiProgress = internalMutation({
args: {
buildId: v.id("repoBuilds"),
stepIndex: v.number(),
stepTotal: v.number(),
label: v.string(),
},
handler: async (ctx, args) => {
const doc = await ctx.db.get(args.buildId)
if (!doc) {
return
}
if (doc.status === "succeeded" || doc.status === "failed") {
return
}
if (args.stepTotal < 1 || args.stepIndex < 1 || args.stepIndex > args.stepTotal) {
return
}
await ctx.db.patch(args.buildId, {
ciProgressStep: args.stepIndex,
ciProgressTotal: args.stepTotal,
ciProgressLabel: args.label,
updatedAt: Date.now(),
})
},
})
export const logBuildDispatchError = internalMutation({
args: { buildId: v.id("repoBuilds"), message: v.string() },
handler: async (ctx, args) => {
@@ -104,6 +134,9 @@ export const logBuildDispatchError = internalMutation({
errorSummary: args.message,
updatedAt: Date.now(),
completedAt: Date.now(),
ciProgressStep: undefined,
ciProgressTotal: undefined,
ciProgressLabel: undefined,
})
},
})
+4
View File
@@ -49,6 +49,10 @@ export const repoBuildsFields = {
githubRunId: v.optional(v.number()),
r2ObjectKey: v.optional(v.string()),
errorSummary: v.optional(v.string()),
/** CI workflow progress (1-based step / total + label), pushed via /ingest-repo-build-progress. */
ciProgressStep: v.optional(v.number()),
ciProgressTotal: v.optional(v.number()),
ciProgressLabel: v.optional(v.string()),
}
export const deviceReportFields = {
+52
View File
@@ -0,0 +1,52 @@
#!/usr/bin/env python3
"""POST CI step progress to Convex (used by custom_build*.yml). Env: REPO_BUILD_ID, CONVEX_URL, CONVEX_BUILD_TOKEN, STEP_INDEX, CI_PROGRESS_TOTAL, LABEL."""
from __future__ import annotations
import json
import os
import sys
import urllib.error
import urllib.request
def main() -> None:
try:
repo_build_id = os.environ["REPO_BUILD_ID"]
convex_url = os.environ["CONVEX_URL"].rstrip("/")
token = os.environ["CONVEX_BUILD_TOKEN"]
step_index = int(os.environ["STEP_INDEX"])
step_total = int(os.environ["CI_PROGRESS_TOTAL"])
label = os.environ["LABEL"]
except KeyError as e:
print(f"missing env: {e}", file=sys.stderr)
sys.exit(1)
if step_total < 1 or step_index < 1 or step_index > step_total:
print("invalid STEP_INDEX / CI_PROGRESS_TOTAL", file=sys.stderr)
sys.exit(1)
body = {
"repo_build_id": repo_build_id,
"step_index": step_index,
"step_total": step_total,
"label": label,
}
url = f"{convex_url}/ingest-repo-build-progress"
req = urllib.request.Request(
url,
data=json.dumps(body).encode(),
headers={
"Content-Type": "application/json",
"Authorization": f"Bearer {token}",
},
method="POST",
)
try:
urllib.request.urlopen(req)
except urllib.error.HTTPError as e:
print(e.read().decode() or str(e), file=sys.stderr)
raise SystemExit(1) from e
if __name__ == "__main__":
main()
+14
View File
@@ -96,3 +96,17 @@
padding: 1rem;
overflow-x: auto;
}
@keyframes ci-progress-shimmer-x {
0% {
left: -45%;
}
100% {
left: 105%;
}
}
.ci-progress-shimmer-x {
position: absolute;
animation: ci-progress-shimmer-x 1.45s ease-in-out infinite;
}
+71 -24
View File
@@ -425,30 +425,77 @@ export default function RepoPage() {
<div className="max-w-2xl space-y-4">
{showCiCard ? (
<div className="rounded-lg border border-slate-800 bg-slate-900/40 p-4 space-y-2 text-sm">
<div className="flex flex-wrap gap-2 items-center">
<span className="text-slate-500">CI</span>
<span className="text-white font-medium">{build.status}</span>
{build.githubRunId ? (
<a
className="text-cyan-400 hover:underline text-xs"
href={`https://github.com/${MESH_FORGE_ACTIONS_REPO}/actions/runs/${build.githubRunId}`}
target="_blank"
rel="noreferrer"
>
View run on GitHub
</a>
) : build.status === "failed" ? (
<a
className="text-cyan-400 hover:underline text-xs"
href={meshForgeWorkflowUrl}
target="_blank"
rel="noreferrer"
title="No run ID — usually the workflow never started (e.g. dispatch rejected). Open the Mesh Forge workflow to fix YAML or inspect recent runs."
>
Mesh Forge workflow on GitHub
</a>
) : null}
</div>
{build.githubRunId || build.status === "failed" ? (
<div className="flex flex-wrap gap-2 items-center">
{build.githubRunId ? (
<a
className="text-cyan-400 hover:underline text-xs"
href={`https://github.com/${MESH_FORGE_ACTIONS_REPO}/actions/runs/${build.githubRunId}`}
target="_blank"
rel="noreferrer"
>
View run on GitHub
</a>
) : (
<a
className="text-cyan-400 hover:underline text-xs"
href={meshForgeWorkflowUrl}
target="_blank"
rel="noreferrer"
title="No run ID — usually the workflow never started (e.g. dispatch rejected). Open the Mesh Forge workflow to fix YAML or inspect recent runs."
>
Mesh Forge workflow on GitHub
</a>
)}
</div>
) : null}
{!isFlashView && buildInProgress && !build.githubRunId ? (
<p className="text-xs text-slate-400">Waiting for workflow to start</p>
) : null}
{isFlashView && buildInProgress ? (
<div className="space-y-2 pt-1">
{(() => {
const step = build.ciProgressStep
const total = build.ciProgressTotal
const label = build.ciProgressLabel
const hasSteps =
typeof step === "number" &&
typeof total === "number" &&
total > 0 &&
step >= 1 &&
step <= total
const pct = hasSteps ? Math.min(100, Math.round((step / total) * 100)) : null
return (
<>
<div
className={`relative h-2.5 w-full overflow-hidden rounded-full bg-slate-800/90 ${
pct === null ? "animate-pulse" : ""
}`}
>
{pct !== null ? (
<div
className="h-full rounded-full bg-linear-to-r from-cyan-600 to-emerald-500 transition-[width] duration-500 ease-out shadow-[0_0_12px_rgba(34,211,238,0.35)]"
style={{ width: `${pct}%` }}
/>
) : (
<div className="ci-progress-shimmer-x top-0 h-full w-[42%] rounded-full bg-linear-to-r from-cyan-700/85 via-emerald-400/95 to-cyan-700/85 shadow-[0_0_10px_rgba(52,211,153,0.4)]" />
)}
</div>
<p className="text-xs text-slate-400">
{hasSteps ? (
<>
Step {step} of {total}
{label ? ` · ${label}` : ""}
</>
) : (
<>Waiting for CI progress</>
)}
</p>
</>
)
})()}
</div>
) : null}
{build.status === "failed" && build.errorSummary ? (
<div className="space-y-2 text-xs">
{(() => {