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.
── THREE 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.
· `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. 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`.
── 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.1.0Entrypoint
Return Type
Runcard Example
dagger -m github.com/wildbitca/daggerverse/runcard@b68f832f1e95a18619e4a0705dddfa46100bc509 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 |
Example
dagger -m github.com/wildbitca/daggerverse/runcard@b68f832f1e95a18619e4a0705dddfa46100bc509 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 integerfunc (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) 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)
}@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) -> 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)
)@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): 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)
}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 |
Example
dagger -m github.com/wildbitca/daggerverse/runcard@b68f832f1e95a18619e4a0705dddfa46100bc509 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 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) string {
return dag.
Runcard().
Report(ctx, repo, ref, sha, actor, event, runId, runNumber, runAttempt, server, jobStatus, eventType, githubToken, slackChannel, jobName, rows, msg, title)
}@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) -> str:
return await (
dag.runcard()
.report(repo, ref, sha, actor, event, runid, runnumber, runattempt, server, jobstatus, eventtype, githubtoken, slackchannel, jobname, rows, msg, title)
)@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): Promise<string> {
return dag
.runcard()
.report(repo, ref, sha, actor, event, runId, runNumber, runAttempt, server, jobStatus, eventType, githubToken, slackChannel, jobName, rows, msg, title)
}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@b68f832f1e95a18619e4a0705dddfa46100bc509 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@b68f832f1e95a18619e4a0705dddfa46100bc509 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@b68f832f1e95a18619e4a0705dddfa46100bc509 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)
}