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

Entrypoint

Return Type
Runcard
Example
dagger -m github.com/wildbitca/daggerverse/runcard@d80eda3c17d29269f2dde66e4c50716b837000d2 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
resumeAfterString !""

the exact name: of the watcher job that stopped at an approval. Set it on a second watcher job that needs the gated job (with if: always()): it edits that run’s existing card instead of posting a new one, and does not re-post failures the first watcher already threaded (every job that failed before that watcher completed).

reportArtifactsString !""

artifact name prefix, e.g. test-report-. At close the watcher reads every *.json inside the run’s artifacts with that prefix, keeps the wildbit.test-report/v1 ones, merges them and renders the card’s test line and the thread’s test sections from the result. Empty (default) reads nothing. Jobs upload with overwrite: true so a re-run replaces its report instead of failing the upload.

testReportsString !""

JSON of reports in hand (one or an array), merged with the artifacts. For a resuming watcher that needs a job exposing one as output.

specsDirectory -

the specs/features directory (sparse checkout in the watcher job). When given, the merged report is mapped to features and any drift is posted to the thread — report-only.

coverageMapFile -

a coverage map outside specs (overrides specs/coverage.tsv).

testReportsFileFile -

the same as testReports, from a file — a real report passes the ~128 kB argv limit, so a workflow passes the path, never the text.

Example
dagger -m github.com/wildbitca/daggerverse/runcard@d80eda3c17d29269f2dde66e4c50716b837000d2 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 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, 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)
}

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
testReportsString !""

JSON of this job’s wildbit.test-report/v1 reports (one or an array). They are merged, drive the card’s test line, and add a breakdown to the thread. Empty keeps the card exactly as before.

testReportsFileFile -

the same, from a file (--test-reports-file=./test-report.json); a report of hundreds of suites is too long for a command-line argument.

specsDirectory -

specs/features, to map the merged report to features (report-only).

coverageMapFile -

a coverage map outside specs.

Example
dagger -m github.com/wildbitca/daggerverse/runcard@d80eda3c17d29269f2dde66e4c50716b837000d2 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 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, 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
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@d80eda3c17d29269f2dde66e4c50716b837000d2 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@d80eda3c17d29269f2dde66e4c50716b837000d2 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@d80eda3c17d29269f2dde66e4c50716b837000d2 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)
}

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
NameTypeDefault ValueDescription
githubTokenSecret !-No description provided
repoString !-No description provided
runIdString !-No description provided
cacheBustString !-

MUST vary per call; artifacts uploaded since are otherwise invisible.

reportArtifactsString !""

artifact name prefix (test-report-); empty reads none.

testReportsString !""

JSON reports in hand, one or an array.

specsDirectory -No description provided
coverageMapFile -No description provided
testReportsFileFile -No description provided
Example
dagger -m github.com/wildbitca/daggerverse/runcard@d80eda3c17d29269f2dde66e4c50716b837000d2 call \
 run-reports --github-token env:MYSECRET --repo string --run-id string --cache-bust string --report-artifacts string --test-reports string
func (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)
}