Dagger
Search

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.0

Entrypoint

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
NameTypeDefault ValueDescription
repoString !-No description provided
refString !-No description provided
shaString !-No description provided
actorString !-No description provided
eventString !-No description provided
runIdString !-No description provided
runNumberString !-No description provided
runAttemptString !-No description provided
serverString !-No description provided
rowsString !-

JSON [{row, match, mode}] in card order. match is a job name (exact) or its prefix (prefix, the default, which aggregates a matrix as n/m). When two rows match one job the most specific wins.

selfJobString !-

the exact name: of the job running this watcher. Required: a watcher that counts itself never sees the run finish and holds its runner to the deadline, so a name that matches no job is an error.

eventTypeString !-

Slack metadata event_type, ^[a-z0-9_]+$, e.g. pipeline_app. Also the trend series readTrend compares against.

githubTokenSecret !-

needs actions: read and checks: read on repo.

slackChannelString !-No description provided
slackTokenSecret -

omit the flag when there is no token; an empty one is treated the same.

workflowFileString !""

file name for the median (pipeline.yml); empty skips it.

branchString !""

branch the median is taken on; empty means all branches.

msgString !""No description provided
titleString !""No description provided
runnerPricesString !""

JSON {label: usdPerMinute} merged over the defaults (ubuntu-latest 0.006, macos-* 0.062). * globs are allowed.

pollSecondsInteger !30No description provided
deadlineMinutesInteger !100No 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 integer
func (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
NameTypeDefault ValueDescription
repoString !-No description provided
refString !-No description provided
shaString !-No description provided
actorString !-No description provided
eventString !-No description provided
runIdString !-No description provided
runNumberString !-No description provided
runAttemptString !-No description provided
serverString !-No description provided
jobStatusString !-

${{ job.status }}: success | failure | cancelled.

eventTypeString !-No description provided
githubTokenSecret !-No description provided
slackChannelString !-No description provided
slackTokenSecret -No description provided
jobNameString !""

the job’s name:; empty works when the run has exactly one job.

rowsString !""

optional JSON [{row, match, mode}] matched against STEP names, to group or rename steps. Empty means one row per step.

msgString !""No description provided
titleString !""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 string
func (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
NameTypeDefault ValueDescription
slackChannelString !-

the channel ID (C…); history lookups reject names.

eventTypeString !-No description provided
runIdString !-No description provided
textString !-No description provided
slackTokenSecret -No description provided
runAttemptString !""

empty matches the newest card of any attempt.

blocksString !""

optional JSON Block Kit array; text is then the notification fallback.

waitSecondsInteger !60No 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 integer
func (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
NameTypeDefault ValueDescription
slackChannelString !-No description provided
eventTypeString !-No description provided
runIdString !-No description provided
slackTokenSecret -No description provided
runAttemptString !""No description provided
waitSecondsInteger !60No 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 integer
func (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
NameTypeDefault ValueDescription
jobsString !-

JSON GhJob[]github.runJobs output or a fixture.

rowsString !-

JSON [{row, match, mode}].

selfJobString !""

a job name to exclude, as watch does.

Example
dagger -m github.com/wildbitca/daggerverse/runcard@b68f832f1e95a18619e4a0705dddfa46100bc509 call \
 card-rows --jobs string --rows string --self-job string
func (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)
}