runcard
does not own.── WHY THIS MODULE EXISTS ──────────────────────────────────────────────────
`slack` is a client: it renders and posts a card, and its header says the
loop that keeps the card current belongs to each repo. That held while every
pipeline was ONE `dagger call` that could carry the loop inside it. It stopped
holding once pipelines became many jobs on many runners: no dagger call spans
them, so each repo would need the same ~200-line watcher. `pacha/app` wrote it
first (`pipelineCardLive`, 2026-09-16); this is that prototype, shared.
── FOUR SHAPES, ONE CARD LOOK ──────────────────────────────────────────────
· `watch` multi-job workflows. A job with no `needs` polls the run's jobs
through the GitHub API and rewrites the card as rows change.
· `refresh` the same card, event-driven: ONE pass, no polling. Jobs call it
when they finish (`needs` + `if: always()`), each pass renders the
run's current state and the last one (all jobs done, or `final`)
closes the card through the same path as `watch`. Use it when
holding a runner for the whole run (watch: the full duration) is
the cost; use `watch` when one self-contained job is simpler and
the runner time does not matter. Concurrent refreshes are
last-writer-wins on an idempotent render.
· `report` single-job workflows, as the job's last step. One post at the
end; the steps are the rows. No second runner is paid for.
· `thread` post detail under an existing card, found by its metadata. A
Dagger call that used to open its own card posts here instead.
── IT NEVER WAITS FOR A HUMAN ──────────────────────────────────────────────
When all that is left of a run is jobs parked on an environment approval,
`watch` renders them as "awaiting approval", closes the card as `waiting` and
EXITS. A second watcher job that `needs` the gated job and passes
`resumeAfter` picks the same card up once the gate opens and carries it to
its verdict, so a released run does not stay "awaiting approval" forever. Polling an approval held a runner for up to thirty minutes and was
retired on 2026-08-22 (ADR-0003 §2.3); approvals are the `deploy-gate` service's
job, and it threads its request under this card by the commit sha in the
card's metadata.
── THE CARD'S METADATA IS API ──────────────────────────────────────────────
`event_type` = the caller's `eventType`; `event_payload` carries `repo`, `sha`,
`run_id`, `run_attempt` and `status`. `thread` finds cards by the first three
of (event_type, run_id, run_attempt); `deploy-gate` finds them by `sha`;
`slack.readTrend` reads `repo` and the numbers. See `slack`'s `EventPayload`.
── TEST REPORTS CROSS JOBS AS ARTIFACTS ────────────────────────────────────
A `wildbit.test-report/v1` built in job A has to reach the watcher in job B,
on another runner, with no shared disk. Each job uploads its report with
`actions/upload-artifact` under a name starting `test-report-` (with
`overwrite: true`), and `watch` reads them back through `github.runArtifactFiles`
when it closes, merges them with `testing.merge`, maps features when it has the
specs, and forwards ONE report to `slack.render`/`slack.breakdown`.
Chosen over a `runcard.attachReport` that posts each report into the card's
thread: that would put multi-kilobyte JSON through Slack metadata (capped, and
a report of 600 suites does not fit), race the watcher's first post, depend on
a Slack token every job would then need (Dependabot runs have none), and leave
nothing durable. An artifact needs only the `actions: read` the watcher already
holds, costs one step per job, is retained with the run, and is the file a
later Grafana import reads. `report` takes the reports directly: a single job
already has them in hand.
── NOTIFICATION IS BEST-EFFORT ─────────────────────────────────────────────
No function here fails a build because Slack or the API was slow. They throw
only on MISCONFIGURATION (bad JSON, a `selfJob` that matches nothing, a token
that cannot read the run) — the failures that would otherwise hold a runner
until the deadline and render a card that lies.
Installation
dagger install github.com/wildbitca/daggerverse/runcard@v0.4.0Entrypoint
Return Type
Runcard Example
dagger -m github.com/wildbitca/daggerverse/runcard@6edf4b951119acbf92be245ff8954794254f8e6d call \
func (m *MyModule) Example() *dagger.Runcard {
return dag.
Runcard()
}@function
def example() -> dagger.Runcard:
return (
dag.runcard()
)@func()
example(): Runcard {
return dag
.runcard()
}Types
Runcard 🔗
watch() 🔗
Keep one card live for a multi-job run. Returns ok, fail, waiting, or
skipped when there is no Slack token/channel (Dependabot, fork PRs).
Run it in a job with NO needs, continue-on-error: true, and a
timeout-minutes above deadlineMinutes. It polls every pollSeconds,
posts each failed job once to the thread (failing step, annotations, log
tail), and closes with statistics. It exits — never waits — once only
approval-gated jobs remain.
Return Type
String !Arguments
| Name | Type | Default Value | Description |
|---|---|---|---|
| repo | String ! | - | No description provided |
| ref | String ! | - | No description provided |
| sha | String ! | - | No description provided |
| actor | String ! | - | No description provided |
| event | String ! | - | No description provided |
| runId | String ! | - | No description provided |
| runNumber | String ! | - | No description provided |
| runAttempt | String ! | - | No description provided |
| server | String ! | - | No description provided |
| rows | String ! | - | JSON |
| selfJob | String ! | - | the exact |
| eventType | String ! | - | Slack metadata |
| githubToken | Secret ! | - | needs |
| slackChannel | String ! | - | No description provided |
| slackToken | Secret | - | omit the flag when there is no token; an empty one is treated the same. |
| workflowFile | String ! | "" | file name for the median ( |
| branch | String ! | "" | branch the median is taken on; empty means all branches. |
| msg | String ! | "" | No description provided |
| title | String ! | "" | No description provided |
| runnerPrices | String ! | "" | JSON |
| pollSeconds | Integer ! | 30 | No description provided |
| deadlineMinutes | Integer ! | 100 | No description provided |
| resumeAfter | String ! | "" | the exact |
| reportArtifacts | String ! | "" | artifact name prefix, e.g. |
| testReports | String ! | "" | JSON of reports in hand (one or an array), merged with the
artifacts. For a resuming watcher that |
| specs | Directory | - | the |
| coverageMap | File | - | a coverage map outside |
| testReportsFile | File | - | the same as |
Example
dagger -m github.com/wildbitca/daggerverse/runcard@6edf4b951119acbf92be245ff8954794254f8e6d call \
watch --repo string --ref string --sha string --actor string --event string --run-id string --run-number string --run-attempt string --server string --rows string --self-job string --event-type string --github-token env:MYSECRET --slack-channel string --workflow-file string --branch string --msg string --title string --runner-prices string --poll-seconds integer --deadline-minutes integer --resume-after string --report-artifacts string --test-reports stringfunc (m *MyModule) Example(ctx context.Context, repo string, ref string, sha string, actor string, event string, runId string, runNumber string, runAttempt string, server string, rows string, selfJob string, eventType string, githubToken *dagger.Secret, slackChannel string, workflowFile string, branch string, msg string, title string, runnerPrices string, pollSeconds int, deadlineMinutes int, resumeAfter string, reportArtifacts string, testReports string) string {
return dag.
Runcard().
Watch(ctx, repo, ref, sha, actor, event, runId, runNumber, runAttempt, server, rows, selfJob, eventType, githubToken, slackChannel, workflowFile, branch, msg, title, runnerPrices, pollSeconds, deadlineMinutes, resumeAfter, reportArtifacts, testReports)
}@function
async def example(repo: str, ref: str, sha: str, actor: str, event: str, runid: str, runnumber: str, runattempt: str, server: str, rows: str, selfjob: str, eventtype: str, githubtoken: dagger.Secret, slackchannel: str, workflowfile: str, branch: str, msg: str, title: str, runnerprices: str, pollseconds: int, deadlineminutes: int, resumeafter: str, reportartifacts: str, testreports: str) -> str:
return await (
dag.runcard()
.watch(repo, ref, sha, actor, event, runid, runnumber, runattempt, server, rows, selfjob, eventtype, githubtoken, slackchannel, workflowfile, branch, msg, title, runnerprices, pollseconds, deadlineminutes, resumeafter, reportartifacts, testreports)
)@func()
async example(repo: string, ref: string, sha: string, actor: string, event: string, runId: string, runNumber: string, runAttempt: string, server: string, rows: string, selfJob: string, eventType: string, githubToken: Secret, slackChannel: string, workflowFile: string, branch: string, msg: string, title: string, runnerPrices: string, pollSeconds: number, deadlineMinutes: number, resumeAfter: string, reportArtifacts: string, testReports: string): Promise<string> {
return dag
.runcard()
.watch(repo, ref, sha, actor, event, runId, runNumber, runAttempt, server, rows, selfJob, eventType, githubToken, slackChannel, workflowFile, branch, msg, title, runnerPrices, pollSeconds, deadlineMinutes, resumeAfter, reportArtifacts, testReports)
}refresh() 🔗
ONE pass that brings the run’s card up to date, then returns. Event-driven
alternative to watch: call it from a job that runs when something changes
(a job finishing, via needs + if: always()), so no runner is held polling.
Returns posted, updated, closed:<ok|fail>, or skipped when there is
no Slack token/channel.
It looks the card up by (eventType, runId, runAttempt) and posts a new one only
when none exists, reads the run’s jobs once, renders the same rows as watch,
and either updates the card in place (status running) or closes it exactly as
watch does: final status, test reports merged from artifacts, feature map,
breakdown and statistics in the thread. It closes when every job except
selfJob has completed and each row has a job, or when final is true.
Jobs parked on an approval render “awaiting approval” and the card shows
waiting; that is not a close, so nothing is threaded and a later refresh
carries it on once the gate opens.
Concurrency: refreshers that run close together each render from the API’s current state, so updates are idempotent and the last writer wins; a stale write is corrected by the next refresh. Two refreshers can still both post a NEW card if neither finds one yet (the first refresh of a run); the lookup before posting makes that rare, not impossible. Have the first job call it early, or accept a rare duplicate.
Differences from watch: failed jobs are threaded once, at close (a one-shot
has no memory of what it already posted); a final refresh with jobs still
open marks them failed (“watcher deadline”).
Return Type
String !Arguments
| Name | Type | Default Value | Description |
|---|---|---|---|
| repo | String ! | - | No description provided |
| ref | String ! | - | No description provided |
| sha | String ! | - | No description provided |
| actor | String ! | - | No description provided |
| event | String ! | - | No description provided |
| runId | String ! | - | No description provided |
| runNumber | String ! | - | No description provided |
| runAttempt | String ! | - | No description provided |
| server | String ! | - | No description provided |
| rows | String ! | - | , eventType, workflowFile, branch, runnerPrices, reportArtifacts,
testReports, testReportsFile, specs, coverageMap as in |
| selfJob | String ! | - | the exact |
| eventType | String ! | - | No description provided |
| githubToken | Secret ! | - | No description provided |
| slackChannel | String ! | - | No description provided |
| slackToken | Secret | - | No description provided |
| workflowFile | String ! | "" | No description provided |
| branch | String ! | "" | No description provided |
| msg | String ! | "" | No description provided |
| title | String ! | "" | No description provided |
| runnerPrices | String ! | "" | No description provided |
| reportArtifacts | String ! | "" | No description provided |
| testReports | String ! | "" | No description provided |
| specs | Directory | - | No description provided |
| coverageMap | File | - | No description provided |
| testReportsFile | File | - | No description provided |
| final | Boolean ! | false | force the close even when a row has no job (an |
Example
dagger -m github.com/wildbitca/daggerverse/runcard@6edf4b951119acbf92be245ff8954794254f8e6d call \
refresh --repo string --ref string --sha string --actor string --event string --run-id string --run-number string --run-attempt string --server string --rows string --self-job string --event-type string --github-token env:MYSECRET --slack-channel string --workflow-file string --branch string --msg string --title string --runner-prices string --report-artifacts string --test-reports string --final booleanfunc (m *MyModule) Example(ctx context.Context, repo string, ref string, sha string, actor string, event string, runId string, runNumber string, runAttempt string, server string, rows string, selfJob string, eventType string, githubToken *dagger.Secret, slackChannel string, workflowFile string, branch string, msg string, title string, runnerPrices string, reportArtifacts string, testReports string, final bool) string {
return dag.
Runcard().
Refresh(ctx, repo, ref, sha, actor, event, runId, runNumber, runAttempt, server, rows, selfJob, eventType, githubToken, slackChannel, workflowFile, branch, msg, title, runnerPrices, reportArtifacts, testReports, final)
}@function
async def example(repo: str, ref: str, sha: str, actor: str, event: str, runid: str, runnumber: str, runattempt: str, server: str, rows: str, selfjob: str, eventtype: str, githubtoken: dagger.Secret, slackchannel: str, workflowfile: str, branch: str, msg: str, title: str, runnerprices: str, reportartifacts: str, testreports: str, final: bool) -> str:
return await (
dag.runcard()
.refresh(repo, ref, sha, actor, event, runid, runnumber, runattempt, server, rows, selfjob, eventtype, githubtoken, slackchannel, workflowfile, branch, msg, title, runnerprices, reportartifacts, testreports, final)
)@func()
async example(repo: string, ref: string, sha: string, actor: string, event: string, runId: string, runNumber: string, runAttempt: string, server: string, rows: string, selfJob: string, eventType: string, githubToken: Secret, slackChannel: string, workflowFile: string, branch: string, msg: string, title: string, runnerPrices: string, reportArtifacts: string, testReports: string, final: boolean): Promise<string> {
return dag
.runcard()
.refresh(repo, ref, sha, actor, event, runId, runNumber, runAttempt, server, rows, selfJob, eventType, githubToken, slackChannel, workflowFile, branch, msg, title, runnerPrices, reportArtifacts, testReports, final)
}refreshPlan() 🔗
The decision refresh makes, exposed pure so it can be tested on fixtures.
Returns JSON {items, outcome, status, close} (outcome is
running|approval|done|deadline).
Return Type
String !Arguments
| Name | Type | Default Value | Description |
|---|---|---|---|
| jobs | String ! | - | JSON |
| rows | String ! | - | JSON |
| selfJob | String ! | "" | the calling job’s name, excluded as in |
| final | Boolean ! | false | the |
Example
dagger -m github.com/wildbitca/daggerverse/runcard@6edf4b951119acbf92be245ff8954794254f8e6d call \
refresh-plan --jobs string --rows string --self-job string --final booleanfunc (m *MyModule) Example(ctx context.Context, jobs string, rows string, selfJob string, final bool) string {
return dag.
Runcard().
Refreshplan(ctx, jobs, rows, selfJob, final)
}@function
async def example(jobs: str, rows: str, selfjob: str, final: bool) -> str:
return await (
dag.runcard()
.refreshplan(jobs, rows, selfjob, final)
)@func()
async example(jobs: string, rows: string, selfJob: string, final: boolean): Promise<string> {
return dag
.runcard()
.refreshPlan(jobs, rows, selfJob, final)
}report() 🔗
One final card for a SINGLE-JOB workflow, from that job’s last step.
Returns ok, fail or skipped.
Call it with if: always() and --job-status=${{ job.status }}. The rows
are the job’s steps as the API reports them at that moment: completed steps
only, without the runner’s own Set up job / Post … / Complete job. The
log is not published until the job completes, so a failure’s thread carries
the failing step and annotations plus a link, not a log tail.
Return Type
String !Arguments
| Name | Type | Default Value | Description |
|---|---|---|---|
| repo | String ! | - | No description provided |
| ref | String ! | - | No description provided |
| sha | String ! | - | No description provided |
| actor | String ! | - | No description provided |
| event | String ! | - | No description provided |
| runId | String ! | - | No description provided |
| runNumber | String ! | - | No description provided |
| runAttempt | String ! | - | No description provided |
| server | String ! | - | No description provided |
| jobStatus | String ! | - |
|
| eventType | String ! | - | No description provided |
| githubToken | Secret ! | - | No description provided |
| slackChannel | String ! | - | No description provided |
| slackToken | Secret | - | No description provided |
| jobName | String ! | "" | the job’s |
| rows | String ! | "" | optional JSON |
| msg | String ! | "" | No description provided |
| title | String ! | "" | No description provided |
| testReports | String ! | "" | JSON of this job’s |
| testReportsFile | File | - | the same, from a file ( |
| specs | Directory | - |
|
| coverageMap | File | - | a coverage map outside |
Example
dagger -m github.com/wildbitca/daggerverse/runcard@6edf4b951119acbf92be245ff8954794254f8e6d call \
report --repo string --ref string --sha string --actor string --event string --run-id string --run-number string --run-attempt string --server string --job-status string --event-type string --github-token env:MYSECRET --slack-channel string --job-name string --rows string --msg string --title string --test-reports stringfunc (m *MyModule) Example(ctx context.Context, repo string, ref string, sha string, actor string, event string, runId string, runNumber string, runAttempt string, server string, jobStatus string, eventType string, githubToken *dagger.Secret, slackChannel string, jobName string, rows string, msg string, title string, testReports string) string {
return dag.
Runcard().
Report(ctx, repo, ref, sha, actor, event, runId, runNumber, runAttempt, server, jobStatus, eventType, githubToken, slackChannel, jobName, rows, msg, title, testReports)
}@function
async def example(repo: str, ref: str, sha: str, actor: str, event: str, runid: str, runnumber: str, runattempt: str, server: str, jobstatus: str, eventtype: str, githubtoken: dagger.Secret, slackchannel: str, jobname: str, rows: str, msg: str, title: str, testreports: str) -> str:
return await (
dag.runcard()
.report(repo, ref, sha, actor, event, runid, runnumber, runattempt, server, jobstatus, eventtype, githubtoken, slackchannel, jobname, rows, msg, title, testreports)
)@func()
async example(repo: string, ref: string, sha: string, actor: string, event: string, runId: string, runNumber: string, runAttempt: string, server: string, jobStatus: string, eventType: string, githubToken: Secret, slackChannel: string, jobName: string, rows: string, msg: string, title: string, testReports: string): Promise<string> {
return dag
.runcard()
.report(repo, ref, sha, actor, event, runId, runNumber, runAttempt, server, jobStatus, eventType, githubToken, slackChannel, jobName, rows, msg, title, testReports)
}thread() 🔗
Reply in the thread of a run’s card. Returns the reply’s ts, or “” when
no card was found in time (best-effort: never throws on Slack errors).
The card is found by metadata — eventType + runId (+ runAttempt) — so
a Dagger call in any job of the run can post its inner detail (a test
breakdown, a failing flow) under the watcher’s card instead of opening a
second one. It retries for waitSeconds, because the job calling it may
start before the watcher has posted.
Return Type
String !Arguments
| Name | Type | Default Value | Description |
|---|---|---|---|
| slackChannel | String ! | - | the channel ID ( |
| eventType | String ! | - | No description provided |
| runId | String ! | - | No description provided |
| text | String ! | - | No description provided |
| slackToken | Secret | - | No description provided |
| runAttempt | String ! | "" | empty matches the newest card of any attempt. |
| blocks | String ! | "" | optional JSON Block Kit array; |
| waitSeconds | Integer ! | 60 | No description provided |
Example
dagger -m github.com/wildbitca/daggerverse/runcard@6edf4b951119acbf92be245ff8954794254f8e6d call \
thread --slack-channel string --event-type string --run-id string --text string --run-attempt string --blocks string --wait-seconds integerfunc (m *MyModule) Example(ctx context.Context, slackChannel string, eventType string, runId string, text string, runAttempt string, blocks string, waitSeconds int) string {
return dag.
Runcard().
Thread(ctx, slackChannel, eventType, runId, text, runAttempt, blocks, waitSeconds)
}@function
async def example(slackchannel: str, eventtype: str, runid: str, text: str, runattempt: str, blocks: str, waitseconds: int) -> str:
return await (
dag.runcard()
.thread(slackchannel, eventtype, runid, text, runattempt, blocks, waitseconds)
)@func()
async example(slackChannel: string, eventType: string, runId: string, text: string, runAttempt: string, blocks: string, waitSeconds: number): Promise<string> {
return dag
.runcard()
.thread(slackChannel, eventType, runId, text, runAttempt, blocks, waitSeconds)
}cardTs() 🔗
The ts of a run’s card, or “”. For callers that want slack.breakdown or
slack.failureDetail under the card rather than a plain reply.
Return Type
String !Arguments
| Name | Type | Default Value | Description |
|---|---|---|---|
| slackChannel | String ! | - | No description provided |
| eventType | String ! | - | No description provided |
| runId | String ! | - | No description provided |
| slackToken | Secret | - | No description provided |
| runAttempt | String ! | "" | No description provided |
| waitSeconds | Integer ! | 60 | No description provided |
Example
dagger -m github.com/wildbitca/daggerverse/runcard@6edf4b951119acbf92be245ff8954794254f8e6d call \
card-ts --slack-channel string --event-type string --run-id string --run-attempt string --wait-seconds integerfunc (m *MyModule) Example(ctx context.Context, slackChannel string, eventType string, runId string, runAttempt string, waitSeconds int) string {
return dag.
Runcard().
Cardts(ctx, slackChannel, eventType, runId, runAttempt, waitSeconds)
}@function
async def example(slackchannel: str, eventtype: str, runid: str, runattempt: str, waitseconds: int) -> str:
return await (
dag.runcard()
.cardts(slackchannel, eventtype, runid, runattempt, waitseconds)
)@func()
async example(slackChannel: string, eventType: string, runId: string, runAttempt: string, waitSeconds: number): Promise<string> {
return dag
.runcard()
.cardTs(slackChannel, eventType, runId, runAttempt, waitSeconds)
}cardRows() 🔗
The row logic, exposed pure so it can be tested on fixtures. Returns JSON Item[].
Return Type
String !Arguments
| Name | Type | Default Value | Description |
|---|---|---|---|
| jobs | String ! | - | JSON |
| rows | String ! | - | JSON |
| selfJob | String ! | "" | a job name to exclude, as |
Example
dagger -m github.com/wildbitca/daggerverse/runcard@6edf4b951119acbf92be245ff8954794254f8e6d call \
card-rows --jobs string --rows string --self-job stringfunc (m *MyModule) Example(ctx context.Context, jobs string, rows string, selfJob string) string {
return dag.
Runcard().
Cardrows(ctx, jobs, rows, selfJob)
}@function
async def example(jobs: str, rows: str, selfjob: str) -> str:
return await (
dag.runcard()
.cardrows(jobs, rows, selfjob)
)@func()
async example(jobs: string, rows: string, selfJob: string): Promise<string> {
return dag
.runcard()
.cardRows(jobs, rows, selfJob)
}runReports() 🔗
The run’s test reports as ONE merged wildbit.test-report/v1, plus notes for
the thread (unreadable artifacts, feature-map drift). Returns JSON
{report, notes}; report is the report’s JSON string, “” when there is none.
What watch and report do at close, exposed so a job can reuse it — e.g. to
write testing.summaryMarkdown of the whole run to $GITHUB_STEP_SUMMARY —
and so the artifact handoff is probed in CI without Slack.
Best-effort: a report that cannot be read is a note, never an error — the
card’s verdict comes from the jobs, and the reports only describe it. Only a
malformed testReports argument throws, because that is the caller’s wiring.
Return Type
String !Arguments
| Name | Type | Default Value | Description |
|---|---|---|---|
| githubToken | Secret ! | - | No description provided |
| repo | String ! | - | No description provided |
| runId | String ! | - | No description provided |
| cacheBust | String ! | - | MUST vary per call; artifacts uploaded since are otherwise invisible. |
| reportArtifacts | String ! | "" | artifact name prefix ( |
| testReports | String ! | "" | JSON reports in hand, one or an array. |
| specs | Directory | - | No description provided |
| coverageMap | File | - | No description provided |
| testReportsFile | File | - | No description provided |
Example
dagger -m github.com/wildbitca/daggerverse/runcard@6edf4b951119acbf92be245ff8954794254f8e6d call \
run-reports --github-token env:MYSECRET --repo string --run-id string --cache-bust string --report-artifacts string --test-reports stringfunc (m *MyModule) Example(ctx context.Context, githubToken *dagger.Secret, repo string, runId string, cacheBust string, reportArtifacts string, testReports string) string {
return dag.
Runcard().
Runreports(ctx, githubToken, repo, runId, cacheBust, reportArtifacts, testReports)
}@function
async def example(githubtoken: dagger.Secret, repo: str, runid: str, cachebust: str, reportartifacts: str, testreports: str) -> str:
return await (
dag.runcard()
.runreports(githubtoken, repo, runid, cachebust, reportartifacts, testreports)
)@func()
async example(githubToken: Secret, repo: string, runId: string, cacheBust: string, reportArtifacts: string, testReports: string): Promise<string> {
return dag
.runcard()
.runReports(githubToken, repo, runId, cacheBust, reportArtifacts, testReports)
}