Dagger
Search

github

── WHY THIS MODULE EXISTS ──────────────────────────────────────────────────
All of this lived as bash + `gh` + `jq` heredocs inside `.github/workflows`.
That is the worst place for it: it cannot be run from a laptop, it cannot be
tested without a push, and `jq` filters spanning eight lines were reviewed by
nobody because a YAML diff hides them. Every function here is a straight port
of a block that is currently a `run:` step, and the reasoning in the comments
is load-bearing — each paragraph is a measured failure, not a preference.

── WHAT STAYS IN YAML, AND WHY ─────────────────────────────────────────────
Per `org-gitops/docs/daggerverse-ci-contract.md` §1, a step survives in YAML
only for approval gates, runner topology, the trigger surface (`on:`, `if:`,
`permissions`, `concurrency`) or the runner's own API. So the `if:` guards of
`rerun-on-runner-loss.yml` — `conclusion == 'failure'`, `run_attempt == 1`,
`event == 'push'`, `head_branch == 'main'` — stay there: they are the trigger
surface, and `run_attempt == 1` is what bounds the retry to ONE (the rerun
creates attempt 2, the workflow fires again, and the condition no longer
holds). This module owns the decision, never the trigger.

── EVERY READ IS CACHE-BUSTED, AND THIS IS NOT OPTIONAL ────────────────────
Dagger caches an exec by its command and environment. A network read whose
arguments did not change is therefore served from the previous run's cache:
the container never starts, the API is never called, and the gate reports the
verdict of a run that happened yesterday. A check that is cached is a check
that did not happen. Every function that touches the network takes a
`cacheBust` that MUST vary per run — use the GitHub run id — and it is a
required parameter on purpose, because a default would be a default that
silently disables the gate.

── COMPLEX ARGUMENTS TRAVEL AS JSON STRINGS ────────────────────────────────
Structural types cross a Dagger module boundary poorly and a shape mismatch
fails at call time with an unreadable error. Non-scalars cross as JSON
strings, documented on each function, parsed and validated on entry.

── PARSING HAPPENS IN TYPESCRIPT, NOT IN `jq` ──────────────────────────────
The ported bash piped `gh api` into `jq`. Here the container only runs
`curl` and the JSON is parsed in the SDK. That is not a style choice: it
removes an `apk add jq` from every call, and a shared apt/apk cache mount is
a measured source of concurrent-build failures (the lock lives inside the
cached directory, so parallel containers fight over it). It also lets a
malformed response name itself in the error instead of becoming an empty
`jq` string that reads as "nothing found".

Installation

dagger install github.com/wildbitca/daggerverse/github@d80eda3c17d29269f2dde66e4c50716b837000d2

Entrypoint

Return Type
Github
Example
dagger -m github.com/wildbitca/daggerverse/github@d80eda3c17d29269f2dde66e4c50716b837000d2 call \
func (m *MyModule) Example() *dagger.Github  {
	return dag.
			Github()
}
@function
def example() -> dagger.Github:
	return (
		dag.github()
	)
@func()
example(): Github {
	return dag
		.github()
}

Types

Github 🔗

verifyTagCi() 🔗

Assert that the CODE a tag points at has passed CI on the default branch.

Ported from pacha/app/.github/workflows/pipeline.yml, job release, step «verificar que el código del tag pasó CI en main». Without it any commit could be tagged and shipped to the stores; it is a query, never a rebuild.

THE INVARIANT IS “THE PUBLISHED CODE PASSED CI”, NOT “THIS COMMIT HAS A RUN”. With paths-ignore, a docs-only commit legitimately has no run of its own and a literal check would reject it while nothing is wrong. So: if the tag’s commit is green, done. If not, walk up to maxCommits ancestors for the nearest green one and demand that EVERYTHING changed since then matches ignoredRe. One line of code without CI fails — which is the case the gate exists to catch.

⚠️ “GREEN” IS THE CONCLUSION OF A CHECK-RUN, NEVER OF THE RUN. The earlier version asked gh run list --json conclusion, which is the verdict of the WHOLE run and therefore red as soon as ANY job is. On 2026-09-03 the ios-e2e lane started running on every push, and overnight NO commit in the repo could satisfy this gate — not even through the ancestor path, which read the same list. The code was perfectly validated and tags were impossible. Asking for one named check-run also aligns the release gate with the merge gate: ci is the single context the branch-ci-required ruleset requires, so a new and still unstable lane can go red without blocking releases — which is exactly the property that makes adding lanes possible.

⚠️ FAILING TO READ CHECK-RUNS IS NOT “NOT GREEN”. The ported bash ended its query with || echo 0, so a 404 from a missing checks: read permission counted as zero green runs and rejected the tag for the wrong reason — worse than having no gate, because it looks like a verdict. Here every non-200 throws and names the permission. See the note on the first checkGreen call below for why that also deletes the bash’s preflight probe, and checkGreen itself for how far that claim has been proven.

Return Type
String !
Arguments
NameTypeDefault ValueDescription
tokenSecret !-No description provided
repoString !-

owner/name.

shaString !-

Anything the commits API resolves: a commit sha, a branch, or a TAG NAME. Pass github.ref_name, not github.sha: for an ANNOTATED tag the ref points at a tag object, and the commits API answers 422 «No commit found for SHA» for it (measured 2026-09-05 against wildbitca/pacha v3.8.2, whose ref object is 8eda9c76, not the commit bb08c4af). This is the same reason the bash ran git rev-list -n1.

checkNameString !-

The check-run name that must be green, e.g. ci.

ignoredReString !-

POSIX/JS regex of paths allowed to change without CI, e.g. ^(specs/|docs/)|\.md$. Required, never defaulted: a default here would be a silent policy for repos that never chose it.

cacheBustString !-

MUST vary per run (the run id). See the module header.

maxCommitsInteger !50

How far to walk. 50 is generous: above that the tag does not hang off anything this CI has seen and rejecting is the correct answer.

Example
dagger -m github.com/wildbitca/daggerverse/github@d80eda3c17d29269f2dde66e4c50716b837000d2 call \
 verify-tag-ci --token env:MYSECRET --repo string --sha string --check-name string --ignored-re string --cache-bust string --max-commits integer
func (m *MyModule) Example(ctx context.Context, token *dagger.Secret, repo string, sha string, checkName string, ignoredRe string, cacheBust string, maxCommits int) string  {
	return dag.
			Github().
			Verifytagci(ctx, token, repo, sha, checkName, ignoredRe, cacheBust, maxCommits)
}
@function
async def example(token: dagger.Secret, repo: str, sha: str, checkname: str, ignoredre: str, cachebust: str, maxcommits: int) -> str:
	return await (
		dag.github()
		.verifytagci(token, repo, sha, checkname, ignoredre, cachebust, maxcommits)
	)
@func()
async example(token: Secret, repo: string, sha: string, checkName: string, ignoredRe: string, cacheBust: string, maxCommits: number): Promise<string> {
	return dag
		.github()
		.verifyTagCi(token, repo, sha, checkName, ignoredRe, cacheBust, maxCommits)
}

lostRunnerVerdict() 🔗

Decide whether a finished run has the signature of a runner that vanished.

Ported from pacha/app/.github/workflows/rerun-on-runner-loss.yml. The bithome node — the k3s hosting the ARC runners and the shared Dagger engine — restarts its containers with nothing scheduling it. Measured on 2026-09-04: 73 pods terminated at once at 02:28:59 with reason=Unknown and came back at 02:29:30, and the cumulative counters confirm it recurs (local-path-provisioner 56 restarts in 69 days, metrics-server 43, coredns 27, dagger-engine 15). When that happens inside a ~95-minute ci, GitHub marks the job failure, the running step stays in_progress with no conclusion, and the logs are NOT EVEN ARCHIVED (log not found): the run and its evidence are both lost, and the red is indistinguishable from a broken test to anyone glancing at it.

THE DISCRIMINATOR: lost runner → job failure and NOT ONE step with conclusion == failure (the step in flight was left in_progress / null) real failure → the step that broke carries conclusion == failure

Checked against the two real samples that existed: 33822966475 ci failure at 49 min, dagger call ci in_progress → rerun 33827086621 ci failure at 53 s, dagger call ci failure → do not (a compilation error of the module: a legitimate failure)

⚠️ THIS HEURISTIC SURVIVES THE PIPELINE CONSOLIDATION. Fusing steps into one dagger call does not weaken it: a real failure still leaves exactly one step in failure (the fused one), and a lost runner still leaves zero. What would break it is a job whose only step cannot fail — and there is none.

⚠️ EVERY NAMED JOB IS EVALUATED SEPARATELY, and that is not a style detail. The earlier version picked ONE job — the first with conclusion == failure — and looked only at it. With a single lane that worked. After the pipeline was unified there were two, and on 2026-09-04 run 33909173457 showed what that does: the API returned ci (iOS) first (red with 1 step in failure, a REAL failure of the Mac toolchain guard), the selector kept it, concluded “real failure, no rerun” — and NEVER LOOKED at job ci, which was precisely the one that had lost its runner (OOMKilled, 0 steps in failure). While one lane is red for a legitimate reason the other could lose its runner and never be rerun, and that condition is not rare: it is EVERY run while the Mac carries its own red. The question is not “which job”, it is “does ANY job have the signature”.

Returns JSON: {lost, job, stuckStep, reason, diagnostic}. The diagnostic covers ALL named jobs, not just the chosen one — if this ever decides wrong, the log has to show why without calling the API again. It is also written to stderr so it lands in the Dagger log even when the caller drops the return.

Return Type
String !
Arguments
NameTypeDefault ValueDescription
tokenSecret !-No description provided
repoString !-No description provided
runIdString !-

The workflow run id (github.event.workflow_run.id).

jobNamesString !-

JSON string[] of job names to consider, e.g. ["ci","ci (iOS)"]. REQUIRED and never defaulted to “all jobs”: an unfiltered match would let any job in the run trigger a rerun of the whole thing.

cacheBustString !-

MUST vary per run. See the module header.

Example
dagger -m github.com/wildbitca/daggerverse/github@d80eda3c17d29269f2dde66e4c50716b837000d2 call \
 lost-runner-verdict --token env:MYSECRET --repo string --run-id string --job-names string --cache-bust string
func (m *MyModule) Example(ctx context.Context, token *dagger.Secret, repo string, runId string, jobNames string, cacheBust string) string  {
	return dag.
			Github().
			Lostrunnerverdict(ctx, token, repo, runId, jobNames, cacheBust)
}
@function
async def example(token: dagger.Secret, repo: str, runid: str, jobnames: str, cachebust: str) -> str:
	return await (
		dag.github()
		.lostrunnerverdict(token, repo, runid, jobnames, cachebust)
	)
@func()
async example(token: Secret, repo: string, runId: string, jobNames: string, cacheBust: string): Promise<string> {
	return dag
		.github()
		.lostRunnerVerdict(token, repo, runId, jobNames, cacheBust)
}

rerunFailedJobs() 🔗

Rerun the failed jobs of a run — POST /actions/runs/{id}/rerun-failed-jobs, which is what gh run rerun --failed calls.

DELIBERATELY SEPARATE FROM THE VERDICT. Deciding and acting are different privileges: reading jobs needs actions: read, this needs actions: write, and a caller must be able to ask “is this a lost runner?” from a laptop without relaunching anything. Bounding it to ONE retry is the caller’s job and stays in YAML as run_attempt == 1 — this function will happily rerun anything it is pointed at, so a caller that drops that guard builds a loop.

Return Type
String !
Arguments
NameTypeDefault ValueDescription
tokenSecret !-No description provided
repoString !-No description provided
runIdString !-No description provided
cacheBustString !-

MUST vary per run. A POST exec is cached exactly like a GET one, so without it a second call for the same run returns the first call’s output and no rerun is ever requested.

Example
dagger -m github.com/wildbitca/daggerverse/github@d80eda3c17d29269f2dde66e4c50716b837000d2 call \
 rerun-failed-jobs --token env:MYSECRET --repo string --run-id string --cache-bust string
func (m *MyModule) Example(ctx context.Context, token *dagger.Secret, repo string, runId string, cacheBust string) string  {
	return dag.
			Github().
			Rerunfailedjobs(ctx, token, repo, runId, cacheBust)
}
@function
async def example(token: dagger.Secret, repo: str, runid: str, cachebust: str) -> str:
	return await (
		dag.github()
		.rerunfailedjobs(token, repo, runid, cachebust)
	)
@func()
async example(token: Secret, repo: string, runId: string, cacheBust: string): Promise<string> {
	return dag
		.github()
		.rerunFailedJobs(token, repo, runId, cacheBust)
}

releaseExists() 🔗

Whether a release already exists for tag.

Public on purpose rather than folded into createRelease: it is the read-only half of the idempotency check, so it can be exercised against a live repository without creating anything. A 404 is the only “no”; any other non-200 throws, because “the API did not answer” must never be read as “the release is not there” — that is how an idempotent create turns into a duplicate.

⚠️ 404 IS AMBIGUOUS AND CANNOT BE MADE OTHERWISE HERE. GitHub answers 404 both for “no release with that tag” and for “this token cannot see this repository” — it will not confirm a private repo’s existence to a token without access. Measured 2026-09-05: wildbitca/does-not-exist-abc returns false, not an error. That is safe in the only place it is used: createRelease then tries the POST, which fails with its own error naming the repository. It would NOT be safe as the premise of a “skip the release” decision, so do not use it as one.

Return Type
Boolean !
Arguments
NameTypeDefault ValueDescription
tokenSecret !-No description provided
repoString !-No description provided
tagString !-No description provided
cacheBustString !-

MUST vary per run. See the module header.

Example
dagger -m github.com/wildbitca/daggerverse/github@d80eda3c17d29269f2dde66e4c50716b837000d2 call \
 release-exists --token env:MYSECRET --repo string --tag string --cache-bust string
func (m *MyModule) Example(ctx context.Context, token *dagger.Secret, repo string, tag string, cacheBust string) bool  {
	return dag.
			Github().
			Releaseexists(ctx, token, repo, tag, cacheBust)
}
@function
async def example(token: dagger.Secret, repo: str, tag: str, cachebust: str) -> bool:
	return await (
		dag.github()
		.releaseexists(token, repo, tag, cachebust)
	)
@func()
async example(token: Secret, repo: string, tag: string, cacheBust: string): Promise<boolean> {
	return dag
		.github()
		.releaseExists(token, repo, tag, cacheBust)
}

createRelease() 🔗

Create the GitHub Release for a tag. Idempotent.

Ported from the release job of pipeline.yml («crear GitHub Release») and from the private githubRelease of app/.github/dagger. Dagger used to create it inside ci when ci ran on tags; once CI was taken off tags that step became unreachable and the workflow redid it in bash. Both copies land here.

IDEMPOTENT BY SKIPPING, NOT BY OVERWRITING. An existing release is left exactly as it is, which is what the live workflow does («el release ya existe, no se recrea»). It matters because the release job can be replayed — GitHub re-dispatches on approval, and the lost-runner mitigation reruns failed jobs — and a release whose notes a human has edited must not be silently rewritten by a replay. updateExisting restores the PATCH behaviour of the private githubRelease for callers that want the notes regenerated; it is off by default because only one of the two copies did it and it is the destructive one.

PRERELEASE COMES FROM THE TAG, by the same semver convention that picks the store channel: a tag with a hyphen (v4.0.0-rc.1) is internal QA and must not appear as a stable release.

Return Type
String !
Arguments
NameTypeDefault ValueDescription
tokenSecret !-No description provided
repoString !-No description provided
tagString !-No description provided
cacheBustString !-

MUST vary per run. A POST is cached like a GET.

notesString !""

Release body. Empty asks GitHub to generate the notes, which is what gh release create --generate-notes did.

changelogFile -

Optional CHANGELOG.md; when notes is empty its section for this tag is used. See changelogNotes.

updateExistingBoolean !falseNo description provided
Example
dagger -m github.com/wildbitca/daggerverse/github@d80eda3c17d29269f2dde66e4c50716b837000d2 call \
 create-release --token env:MYSECRET --repo string --tag string --cache-bust string --notes string --update-existing boolean
func (m *MyModule) Example(ctx context.Context, token *dagger.Secret, repo string, tag string, cacheBust string, notes string, updateExisting bool) string  {
	return dag.
			Github().
			Createrelease(ctx, token, repo, tag, cacheBust, notes, updateExisting)
}
@function
async def example(token: dagger.Secret, repo: str, tag: str, cachebust: str, notes: str, updateexisting: bool) -> str:
	return await (
		dag.github()
		.createrelease(token, repo, tag, cachebust, notes, updateexisting)
	)
@func()
async example(token: Secret, repo: string, tag: string, cacheBust: string, notes: string, updateExisting: boolean): Promise<string> {
	return dag
		.github()
		.createRelease(token, repo, tag, cacheBust, notes, updateExisting)
}

isPrerelease() 🔗

Whether a tag is a prerelease, by the semver convention this organisation releases with: a hyphen means internal QA. Pure.

Public and separate so the rule can be asserted without creating anything — createRelease cannot be exercised live without publishing a release, and an untested rule that decides what appears in the stores is not a rule. It is also the SAME predicate the workflow evaluates in four other places (contains(github.ref_name, '-') picks the job name, the environment:, the Codemagic channel and this flag), and per the contract §3.1 a routing decision belongs in one testable place rather than in four if: strings.

Return Type
Boolean !
Arguments
NameTypeDefault ValueDescription
tagString !-No description provided
Example
dagger -m github.com/wildbitca/daggerverse/github@d80eda3c17d29269f2dde66e4c50716b837000d2 call \
 is-prerelease --tag string
func (m *MyModule) Example(ctx context.Context, tag string) bool  {
	return dag.
			Github().
			Isprerelease(ctx, tag)
}
@function
async def example(tag: str) -> bool:
	return await (
		dag.github()
		.isprerelease(tag)
	)
@func()
async example(tag: string): Promise<boolean> {
	return dag
		.github()
		.isPrerelease(tag)
}

changelogNotes() 🔗

Extract the ## [VERSION] section of a CHANGELOG for a tag. Pure.

Ported verbatim from the private githubRelease. The leading v is stripped and the version is regex-escaped before it is used as a pattern: a version is full of dots, and an unescaped 4.0.0 would also match 4x0y0, quietly picking the wrong section.

Returns “” when there is no such section, which is the caller’s signal to let GitHub generate the notes — the same fallback the private helper had.

Return Type
String !
Arguments
NameTypeDefault ValueDescription
changelogFile !-No description provided
tagString !-No description provided
Example
dagger -m github.com/wildbitca/daggerverse/github@d80eda3c17d29269f2dde66e4c50716b837000d2 call \
 changelog-notes --changelog file:path --tag string
func (m *MyModule) Example(ctx context.Context, changelog *dagger.File, tag string) string  {
	return dag.
			Github().
			Changelognotes(ctx, changelog, tag)
}
@function
async def example(changelog: dagger.File, tag: str) -> str:
	return await (
		dag.github()
		.changelognotes(changelog, tag)
	)
@func()
async example(changelog: File, tag: string): Promise<string> {
	return dag
		.github()
		.changelogNotes(changelog, tag)
}

annotation() 🔗

Render a workflow command — ::error title=…::message. Pure.

WHY IT LIVES HERE AT ALL: the diagnostic prose in these pipelines is long, carefully worded, and it is the only thing a person reads when a run goes red. Kept in YAML it is unreviewable and untestable; kept here it is a string a test can assert on.

HOW A CALLER EMITS IT: the runner parses workflow commands out of the step log, whatever writes them — this is already relied upon by the existing modules, which emit console.error("::error:: …") from inside a Dagger session and get real annotations. So either let a function print it, or capture the return and echo it:

dagger call annotation –level=warning –message=… > /tmp/a && cat /tmp/a

The level is validated because GitHub does not complain about an unknown one: ::warn:: is printed as ordinary log text and the annotation simply never appears — the exact class of failure where the check silently did not happen.

Return Type
String !
Arguments
NameTypeDefault ValueDescription
levelString !-

warning | error | notice | debug

messageString !-No description provided
titleString !""

Optional annotation title.

fileString !""

Optional path to attach the annotation to.

lineInteger !0

Optional line; 0 means absent.

Example
dagger -m github.com/wildbitca/daggerverse/github@d80eda3c17d29269f2dde66e4c50716b837000d2 call \
 annotation --level string --message string --title string --file string --line integer
func (m *MyModule) Example(ctx context.Context, level string, message string, title string, file string, line int) string  {
	return dag.
			Github().
			Annotation(ctx, level, message, title, file, line)
}
@function
async def example(level: str, message: str, title: str, file: str, line: int) -> str:
	return await (
		dag.github()
		.annotation(level, message, title, file, line)
	)
@func()
async example(level: string, message: string, title: string, file: string, line: number): Promise<string> {
	return dag
		.github()
		.annotation(level, message, title, file, line)
}

summarySection() 🔗

Render a $GITHUB_STEP_SUMMARY section. Pure.

Return Type
String !
Arguments
NameTypeDefault ValueDescription
headingString !-No description provided
bulletsString !""

JSON string[]; each becomes a - item. Empty for none.

bodyString !""

Free markdown appended after the bullets.

Example
dagger -m github.com/wildbitca/daggerverse/github@d80eda3c17d29269f2dde66e4c50716b837000d2 call \
 summary-section --heading string --bullets string --body string
func (m *MyModule) Example(ctx context.Context, heading string, bullets string, body string) string  {
	return dag.
			Github().
			Summarysection(ctx, heading, bullets, body)
}
@function
async def example(heading: str, bullets: str, body: str) -> str:
	return await (
		dag.github()
		.summarysection(heading, bullets, body)
	)
@func()
async example(heading: string, bullets: string, body: string): Promise<string> {
	return dag
		.github()
		.summarySection(heading, bullets, body)
}

summaryFile() 🔗

The same markdown as a File, for callers that would rather redirect than quote: dagger call … export --path=s.md && cat s.md >> "$GITHUB_STEP_SUMMARY".

Shell-quoting a multi-line markdown blob through $(…) is where the ported heredocs lost their blank lines, and a summary without blank lines renders as one run-on paragraph.

Return Type
File !
Arguments
NameTypeDefault ValueDescription
markdownString !-No description provided
Example
dagger -m github.com/wildbitca/daggerverse/github@d80eda3c17d29269f2dde66e4c50716b837000d2 call \
 summary-file --markdown string
func (m *MyModule) Example(markdown string) *dagger.File  {
	return dag.
			Github().
			Summaryfile(markdown)
}
@function
def example(markdown: str) -> dagger.File:
	return (
		dag.github()
		.summaryfile(markdown)
	)
@func()
example(markdown: string): File {
	return dag
		.github()
		.summaryFile(markdown)
}

lostRunnerWarning() 🔗

The ::warning:: the rerun mitigation emits. Pure.

NOISY ON PURPOSE. This mitigation does not fix the node, and it was chosen knowing the cause is still there: diagnosing it needs journalctl -u k3s on the host, which is not reachable from the cluster. A silent retry would turn an infrastructure problem into a slightly slower CI and nobody would look at it again. Hence a warning naming the suspicion, and warning rather than error: the run being annotated did not fail because of this.

Return Type
String !
Arguments
NameTypeDefault ValueDescription
runIdString !-No description provided
stuckStepString !-No description provided
Example
dagger -m github.com/wildbitca/daggerverse/github@d80eda3c17d29269f2dde66e4c50716b837000d2 call \
 lost-runner-warning --run-id string --stuck-step string
func (m *MyModule) Example(ctx context.Context, runId string, stuckStep string) string  {
	return dag.
			Github().
			Lostrunnerwarning(ctx, runId, stuckStep)
}
@function
async def example(runid: str, stuckstep: str) -> str:
	return await (
		dag.github()
		.lostrunnerwarning(runid, stuckstep)
	)
@func()
async example(runId: string, stuckStep: string): Promise<string> {
	return dag
		.github()
		.lostRunnerWarning(runId, stuckStep)
}

lostRunnerSummary() 🔗

The $GITHUB_STEP_SUMMARY block of the rerun mitigation. Pure.

Return Type
String !
Arguments
NameTypeDefault ValueDescription
runIdString !-No description provided
runUrlString !-No description provided
stuckStepString !-No description provided
Example
dagger -m github.com/wildbitca/daggerverse/github@d80eda3c17d29269f2dde66e4c50716b837000d2 call \
 lost-runner-summary --run-id string --run-url string --stuck-step string
func (m *MyModule) Example(ctx context.Context, runId string, runUrl string, stuckStep string) string  {
	return dag.
			Github().
			Lostrunnersummary(ctx, runId, runUrl, stuckStep)
}
@function
async def example(runid: str, runurl: str, stuckstep: str) -> str:
	return await (
		dag.github()
		.lostrunnersummary(runid, runurl, stuckstep)
	)
@func()
async example(runId: string, runUrl: string, stuckStep: string): Promise<string> {
	return dag
		.github()
		.lostRunnerSummary(runId, runUrl, stuckStep)
}

runJobs() 🔗

Every job of one attempt of a workflow run, as JSON RunJob[].

Paginated, and it FAILS rather than truncating past maxPages. A watcher that silently sees only the first 100 jobs closes its card as “done” while the jobs it never saw are still running, and reports a green run that is not over. An error names the problem; a truncated list looks like an answer.

Read through a curl container like every other call here, and cache-busted the same way — a poll served from Dagger’s cache returns the jobs as they were on the first poll, forever. The function itself is cache: "never" as well: the bust is what keeps the exec honest, the policy is what keeps the CALL honest when a caller reuses a bust by mistake.

Shape of each element: {id, name, status, conclusion, html_url, created_at, started_at, completed_at, labels, runner_name, steps: [{number, name, status, conclusion, started_at, completed_at}]} — the API’s own field names, reduced.

Needs actions: read.

Return Type
String !
Arguments
NameTypeDefault ValueDescription
tokenSecret !-No description provided
repoString !-No description provided
runIdString !-No description provided
runAttemptString !-

the attempt (github.run_attempt). Empty reads the latest attempt, which on a re-run is NOT the attempt the caller is running in.

cacheBustString !-

MUST change on every call (poll index plus a timestamp).

maxPagesInteger !10

100 jobs per page. Past this the call fails loudly.

Example
dagger -m github.com/wildbitca/daggerverse/github@d80eda3c17d29269f2dde66e4c50716b837000d2 call \
 run-jobs --token env:MYSECRET --repo string --run-id string --run-attempt string --cache-bust string --max-pages integer
func (m *MyModule) Example(ctx context.Context, token *dagger.Secret, repo string, runId string, runAttempt string, cacheBust string, maxPages int) string  {
	return dag.
			Github().
			Runjobs(ctx, token, repo, runId, runAttempt, cacheBust, maxPages)
}
@function
async def example(token: dagger.Secret, repo: str, runid: str, runattempt: str, cachebust: str, maxpages: int) -> str:
	return await (
		dag.github()
		.runjobs(token, repo, runid, runattempt, cachebust, maxpages)
	)
@func()
async example(token: Secret, repo: string, runId: string, runAttempt: string, cacheBust: string, maxPages: number): Promise<string> {
	return dag
		.github()
		.runJobs(token, repo, runId, runAttempt, cacheBust, maxPages)
}

jobFailure() 🔗

What a failed job left behind, as JSON {job, failedStep, annotations, logTail, logNote}.

· failedStep the first step whose conclusion is failure (“” if none — which is also the lost-runner signature, see lostRunnerVerdict) · annotations [{level, title, message, path, line}], notices dropped, at most 10. Needs checks: read; without it this is [] and logNote says so. · logTail the last tailLines meaningful lines of the job log, with timestamps, ANSI colour and runner bookkeeping (##[group], Post …, orphan cleanup) removed. ##[error] lines stay: they are the runner’s own summary of what broke.

BEST-EFFORT PER PART. A job still running has no log yet (404), a startup_failure never has one, and logs expire. Each missing part is empty with a reason in logNote, and only a failure to read the JOB itself throws — a detail message with half its parts is worth posting, one with none is not.

The log is downloaded with -L (the API answers 302 to blob storage; curl does not forward the Authorization header to another host) and only its last 256 KB leave the container, so a 50 MB log does not travel through stdout.

Return Type
String !
Arguments
NameTypeDefault ValueDescription
tokenSecret !-No description provided
repoString !-No description provided
jobIdString !-No description provided
cacheBustString !-

MUST vary per call. See the module header.

tailLinesInteger !25No description provided
Example
dagger -m github.com/wildbitca/daggerverse/github@d80eda3c17d29269f2dde66e4c50716b837000d2 call \
 job-failure --token env:MYSECRET --repo string --job-id string --cache-bust string --tail-lines integer
func (m *MyModule) Example(ctx context.Context, token *dagger.Secret, repo string, jobId string, cacheBust string, tailLines int) string  {
	return dag.
			Github().
			Jobfailure(ctx, token, repo, jobId, cacheBust, tailLines)
}
@function
async def example(token: dagger.Secret, repo: str, jobid: str, cachebust: str, taillines: int) -> str:
	return await (
		dag.github()
		.jobfailure(token, repo, jobid, cachebust, taillines)
	)
@func()
async example(token: Secret, repo: string, jobId: string, cacheBust: string, tailLines: number): Promise<string> {
	return dag
		.github()
		.jobFailure(token, repo, jobId, cacheBust, tailLines)
}

workflowRunDurations() 🔗

Durations of recent SUCCESSFUL runs of a workflow, newest first, as JSON [{id, attempt, ms}] — what a card compares the current wall clock against.

ms is updated_at − run_started_at of the latest attempt. That is the run’s wall clock including queueing, which is what a person waiting for it experiences. A run whose timestamps do not parse is dropped, not zeroed: a zero would drag the median down and make every run look slow.

Return Type
String !
Arguments
NameTypeDefault ValueDescription
tokenSecret !-No description provided
repoString !-No description provided
workflowFileString !-

the workflow’s file name (pipeline.yml) or its numeric id.

branchString !-

only runs on this branch; empty for all branches.

nInteger !-

how many runs to read (1..100).

cacheBustString !-

MUST vary per call.

Example
dagger -m github.com/wildbitca/daggerverse/github@d80eda3c17d29269f2dde66e4c50716b837000d2 call \
 workflow-run-durations --token env:MYSECRET --repo string --workflow-file string --branch string --n integer --cache-bust string
func (m *MyModule) Example(ctx context.Context, token *dagger.Secret, repo string, workflowFile string, branch string, n int, cacheBust string) string  {
	return dag.
			Github().
			Workflowrundurations(ctx, token, repo, workflowFile, branch, n, cacheBust)
}
@function
async def example(token: dagger.Secret, repo: str, workflowfile: str, branch: str, n: int, cachebust: str) -> str:
	return await (
		dag.github()
		.workflowrundurations(token, repo, workflowfile, branch, n, cachebust)
	)
@func()
async example(token: Secret, repo: string, workflowFile: string, branch: string, n: number, cacheBust: string): Promise<string> {
	return dag
		.github()
		.workflowRunDurations(token, repo, workflowFile, branch, n, cacheBust)
}

runArtifactFiles() 🔗

The files inside a run’s artifacts whose name starts with namePrefix, as JSON {files: [{artifact, path, contents}], skipped: [{artifact, path, reason}]}.

This is how reports cross JOBS: each job uploads its report with actions/upload-artifact (no shared disk, any runner, and the artifact is listed by the API as soon as the upload step finishes), and a watcher in a different job reads them back here. runcard.watch uses it for test-report-*.

── WHICH ATTEMPT ─────────────────────────────────────────────────────────── The artifacts API has no attempt filter: a run’s list holds every attempt’s. For each NAME the newest artifact wins. With overwrite: true on the upload (which a re-run needs anyway — v4 refuses a duplicate name otherwise) that is exactly the current state of the run: jobs re-run in attempt 2 replaced their reports, jobs carried over from attempt 1 kept theirs.

Expired artifacts are skipped. Downloads follow the API’s 302 to blob storage with -L; curl does not forward the Authorization header to another host. Needs actions: read.

Return Type
String !
Arguments
NameTypeDefault ValueDescription
tokenSecret !-No description provided
repoString !-No description provided
runIdString !-No description provided
namePrefixString !-

only artifacts whose name starts with this, e.g. test-report-. Required: reading every artifact of a run (an 80 MB APK among them) is never what a caller means.

cacheBustString !-

MUST vary per call.

patternString !"**/*.json"

glob of the files to return inside each artifact.

maxBytesInteger !5000000

a file larger than this is listed in skipped, not returned.

maxPagesInteger !10No description provided
Example
dagger -m github.com/wildbitca/daggerverse/github@d80eda3c17d29269f2dde66e4c50716b837000d2 call \
 run-artifact-files --token env:MYSECRET --repo string --run-id string --name-prefix string --cache-bust string --pattern string --max-bytes integer --max-pages integer
func (m *MyModule) Example(ctx context.Context, token *dagger.Secret, repo string, runId string, namePrefix string, cacheBust string, pattern string, maxBytes int, maxPages int) string  {
	return dag.
			Github().
			Runartifactfiles(ctx, token, repo, runId, namePrefix, cacheBust, pattern, maxBytes, maxPages)
}
@function
async def example(token: dagger.Secret, repo: str, runid: str, nameprefix: str, cachebust: str, pattern: str, maxbytes: int, maxpages: int) -> str:
	return await (
		dag.github()
		.runartifactfiles(token, repo, runid, nameprefix, cachebust, pattern, maxbytes, maxpages)
	)
@func()
async example(token: Secret, repo: string, runId: string, namePrefix: string, cacheBust: string, pattern: string, maxBytes: number, maxPages: number): Promise<string> {
	return dag
		.github()
		.runArtifactFiles(token, repo, runId, namePrefix, cacheBust, pattern, maxBytes, maxPages)
}

logTail() 🔗

Clean a raw job log down to its last meaningful lines. Pure; public so the filter can be asserted on a fixture instead of on a real failure.

Return Type
String !
Arguments
NameTypeDefault ValueDescription
logString !-No description provided
linesInteger !25No description provided
Example
dagger -m github.com/wildbitca/daggerverse/github@d80eda3c17d29269f2dde66e4c50716b837000d2 call \
 log-tail --log string --lines integer
func (m *MyModule) Example(ctx context.Context, log string, lines int) string  {
	return dag.
			Github().
			Logtail(ctx, log, lines)
}
@function
async def example(log: str, lines: int) -> str:
	return await (
		dag.github()
		.logtail(log, lines)
	)
@func()
async example(log: string, lines: number): Promise<string> {
	return dag
		.github()
		.logTail(log, lines)
}